Building a Complete Swift CI/CD Pipeline with GitLab


The Goal

I recently built out a comprehensive CI/CD pipeline for xcode-sync, a Swift command-line tool that synchronizes Xcode project source groups with filesystem directories. The pipeline handles everything from testing to code signing, notarization, and automatic Homebrew formula updates.

The complete flow looks like this:

  1. Run Swift tests
  2. Build the release binary
  3. Bump version using Conventional Commits
  4. Sign the binary with a Developer ID certificate
  5. Submit to Apple for notarization
  6. Package and upload to GitLab Package Registry
  7. Create a GitLab release
  8. Trigger downstream pipeline to update the Homebrew tap

Pipeline Stages

The pipeline is organized into five stages:

stages:
  - test
  - build
  - package
  - release
  - publish

Testing and Building

The test and build stages run on a macOS runner (tagged with macos). Swift's built-in test runner generates JUnit XML output for GitLab's test reporting:

test:swift:
  stage: test
  tags:
    - macos
  script:
    - swift test --verbose --xunit-output .build/test-results.xml --parallel
  artifacts:
    reports:
      junit: .build/test-results.xml

Version Bumping with Commitizen

The package stage uses Commitizen to automatically determine the next version based on Conventional Commits. This eliminates manual version management:

- cz bump --annotated-tag --changelog || exit_code=$?

If there are no conventional commits since the last tag, Commitizen exits with code 21 or 3, which we handle gracefully to skip the release process.

Code Signing and Notarization

For macOS binaries to run without Gatekeeper warnings, they need to be signed with a Developer ID certificate and notarized by Apple. The pipeline handles this automatically:

# Unlock the keychain containing the signing certificate
security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "$HOME/CIKeychain.keychain"

# Sign the binary with hardened runtime
codesign --force --options runtime --timestamp \
  --sign "${DEVELOPER_ID_NAME}" \
  "$BINARY_PATH"

# Submit to Apple for notarization
xcrun notarytool submit "$NOTARIZE_ZIP" \
  --key "${ASC_KEY_FILE}" \
  --key-id "${ASC_KEY_ID}" \
  --issuer "${ASC_ISSUER_ID}" \
  --team-id "${APPLE_TEAM_ID}" \
  --wait \
  --timeout 30m

The --wait flag blocks until Apple's notarization service completes, typically taking 1-5 minutes.

Package Registry for Permanent Artifacts

Job artifacts in GitLab expire (typically after 30 days). For release assets that need to persist, I upload the tarball to GitLab's Generic Package Registry:

PACKAGE_URL="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${CI_PROJECT_NAME}/${VERSION}/${TARBALL}"
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
  --upload-file "${TARBALL}" \
  "${PACKAGE_URL}"

The release then links to this permanent URL instead of ephemeral job artifacts.

Creating the Release

GitLab's native release keyword creates the release with proper asset links:

release:
  stage: release
  image: registry.gitlab.com/gitlab-org/cli:latest
  release:
    tag_name: "${NEW_TAG}"
    name: "${NEW_TAG}"
    description: "./RELEASE_NOTES.md"
    assets:
      links:
        - name: "macOS executable"
          url: "${PACKAGE_URL}"
          link_type: "package"

The release notes are extracted from the CHANGELOG using a simple awk script that pulls out the most recent version's changes.

Triggering the Homebrew Tap Update

The final stage triggers a downstream pipeline in a separate homebrew-tap repository:

homebrew:
  stage: publish
  trigger:
    project: experiment/homebrew-tap
    branch: main
    strategy: depend
  variables:
    FORMULA_NAME: "${EXECUTABLE_NAME}"
    FORMULA_VERSION: "${VERSION}"
    FORMULA_SHA256: "${TARBALL_SHA256}"
    FORMULA_ARTIFACT_URL: "${PACKAGE_URL}"

The downstream pipeline downloads the tarball, uploads it to a distribution server, generates the Homebrew formula, and commits it to the tap repository.

Preventing Infinite Loops

One gotcha with this setup: when the package stage pushes the version bump commit, it could trigger the pipeline again. To prevent this, we skip the package/release/publish stages for bump commits:

rules:
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TITLE =~ /^bump/
    when: never
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The Downstream Homebrew Pipeline

The homebrew-tap repository has a simple pipeline that:

  1. Downloads the artifact from the package registry
  2. Uploads it to a distribution server (for faster downloads)
  3. Generates the Ruby formula file
  4. Commits and pushes the updated formula
# Download from package registry
curl -fSL -o "${FORMULA_TARBALL}" --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
  "${FORMULA_ARTIFACT_URL}"

# Generate the formula
cat > Formula/${FORMULA_NAME}.rb << EOF
class ${CLASS_NAME} < Formula
  desc "${FORMULA_DESCRIPTION}"
  homepage "${FORMULA_HOMEPAGE:-${FORMULA_PROJECT_URL}}"
  url "${TARBALL_URL}"
  sha256 "${FORMULA_SHA256}"
  version "${FORMULA_VERSION}"

  def install
    bin.install "bin/${FORMULA_BINARY:-${FORMULA_NAME}}"
  end
end
EOF

Conclusion

This pipeline automates the entire release process. A developer pushes a commit with a conventional commit message like feat: add new feature, and within minutes:

  • Tests run
  • Version is bumped appropriately (minor for feat, patch for fix)
  • Binary is signed and notarized
  • Release is created with permanent download links
  • Homebrew formula is updated

Users can then simply run brew upgrade xcode-sync to get the latest version.

The key lessons learned:

  1. Use the Package Registry for release artifacts, not job artifacts
  2. Handle Commitizen's non-zero exit codes for "no changes" gracefully
  3. Use --wait with notarytool to block until notarization completes
  4. Prevent infinite loops by skipping bump commits in rules
  5. Pass variables to downstream pipelines via the variables key, not artifacts