- Sun 05 January 2025
- programming
- Gaige B. Paulsen
- #server admin, #programming, #swift, #gitlab, #cicd, #macos, #homebrew
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:
- Run Swift tests
- Build the release binary
- Bump version using Conventional Commits
- Sign the binary with a Developer ID certificate
- Submit to Apple for notarization
- Package and upload to GitLab Package Registry
- Create a GitLab release
- 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:
- Downloads the artifact from the package registry
- Uploads it to a distribution server (for faster downloads)
- Generates the Ruby formula file
- 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:
- Use the Package Registry for release artifacts, not job artifacts
- Handle Commitizen's non-zero exit codes for "no changes" gracefully
- Use
--waitwith notarytool to block until notarization completes - Prevent infinite loops by skipping bump commits in rules
- Pass variables to downstream pipelines via the
variableskey, not artifacts