iOS and macOS code signing usually 'just works' on a developer's Mac because the keychain has every piece of signing material already in it. CI does not have that luxury. Every new runner starts from a clean macOS image with no certificates, no provisioning profiles, no .p12s, and an unlocked default keychain that gets confused the moment you import anything sensitive into it.
This guide is the long-form version of the CI section in iOS code signing: the complete guide. It covers the keychain primitives every macOS CI runner needs, the two dominant patterns for getting signing material onto a runner (fastlane match and a managed service like HexSign), and ready-to-paste setups for GitHub Actions, Bitrise, CircleCI, GitLab, and fastlane.
Why CI signing is different
Three things make CI signing harder than local signing:
- There is no persistent keychain. Every job starts from a fresh runner. Whatever the previous build did to the keychain is gone.
- There is no logged-in human. macOS sometimes wants a UI prompt to confirm 'can codesign access this private key?'. Headless runners cannot answer that prompt, so codesign fails with `errSecInternalComponent`.
- Provisioning profiles are not where Xcode expects them. They have to be dropped into `~/Library/MobileDevice/Provisioning Profiles/` (creating the directory first) before xcodebuild can find them.
Almost every CI signing failure traces back to one of those three. Fix the keychain plumbing once and the rest is mostly stable.
The keychain primitives every macOS runner needs
Whatever pattern you pick (match, HexSign, hand-rolled), the runner has to do six steps before xcodebuild:
- 1
Create a scoped keychain
`security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain-db`. A scoped keychain means a teardown does not affect anything else on the runner.
- 2
Make it the default and unlock it
`security default-keychain -s build.keychain-db` plus `security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain-db`. Lock timeouts (`set-keychain-settings`) prevent re-locking mid-build.
- 3
Import the .p12
`security import cert.p12 -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k build.keychain-db`. The `-A` is what allows any tool to access it; restrict with `-T /usr/bin/codesign` in security-tighter contexts.
- 4
Set the partition list
`security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" build.keychain-db`. This is the step that prevents the `errSecInternalComponent` failure when codesign tries to read the private key non-interactively.
- 5
Install the provisioning profile
`mkdir -p ~/Library/MobileDevice/"Provisioning Profiles"` then `cp Profile.mobileprovision ~/Library/MobileDevice/"Provisioning Profiles"/`. Xcode reads profiles by UUID, so the filename doesn't matter, but the location does.
- 6
Run xcodebuild and clean up
`xcodebuild archive` and then `-exportArchive`. After the build, `security delete-keychain build.keychain-db` to leave nothing sensitive on the runner.
Pattern A: fastlane match
fastlane match is the most widely-used pattern. It stores certificates and provisioning profiles (encrypted with a shared passphrase) in a private git repo. Every CI runner clones the repo, decrypts the bundle, imports it into a local keychain, and uses it to sign.
On CI, run match in read-only mode so the runner never creates a new certificate by accident:
# Fastfile
platform :ios do
desc 'Build and ship to TestFlight'
lane :beta do
setup_ci
match(
type: 'appstore',
readonly: true,
app_identifier: 'com.example.app',
api_key_path: ENV['ASC_API_KEY_PATH'],
)
build_app(scheme: 'MyApp')
upload_to_testflight
end
end`setup_ci` (from fastlane) creates a temporary keychain with all the partition-list plumbing already correct, so you do not have to hand-write the six steps above. The trade-off is that match has its own pain points at scale (one shared passphrase, the nuke flow, the three-certificate cap), which the fastlane match alternatives guide covers in depth.
Pattern B: HexSign CLI
HexSign replaces the encrypted git repo with a managed service: the same signing material lives in a vault behind audited downloads, certificates rotate without the cap problem, and the CLI handles the keychain plumbing on the runner. The CI half of the integration is one or two commands depending on the pipeline. See the CLI page for the full command surface.
# Authenticate with a machine credential, then pull profile + cert hexsign login --machine-credential "$HEXSIGN_MACHINE_TOKEN" hexsign sign --bundle-id com.example.app --type appstore # After that, xcodebuild signs against the right material out of the box xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -archivePath build/MyApp.xcarchive archive
Behind the scenes, `hexsign sign` creates a scoped keychain, imports the cert and private key, sets the partition list, drops the matching .mobileprovision into Xcode's profile directory, and prints the build settings xcodebuild needs. The full setup is documented in Sign builds in CI and CI with machine credentials.
GitHub Actions
A working iOS build-and-TestFlight job. Assumes you stored the .p12, its password, the .mobileprovision, and the App Store Connect API key as repository secrets.
name: Ship to TestFlight
on:
push:
branches: [main]
jobs:
build:
runs-on: macos-14
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { ruby-version: '3.3', bundler-cache: true }
# Six-step keychain plumbing, with the unsigned/scoped keychain pattern
- name: Set up signing keychain
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Decode signing material
echo "$P12_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
mkdir -p ~/Library/MobileDevice/'Provisioning Profiles'
echo "$PROFILE_BASE64" | base64 --decode > \
~/Library/MobileDevice/'Provisioning Profiles'/profile.mobileprovision
# Build the scoped keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security default-keychain -s $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $RUNNER_TEMP/cert.p12 -P "$P12_PASSWORD" \
-A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
- name: Build and upload
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER: ${{ secrets.ASC_API_ISSUER }}
ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY_BASE64 }}
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "$ASC_API_KEY_BASE64" | base64 --decode > \
~/.appstoreconnect/private_keys/AuthKey_${ASC_API_KEY_ID}.p8
bundle exec fastlane betaThe HexSign equivalent collapses the keychain step into one command. See the HexSign CLI GitHub Action for the marketplace action that handles install + auth + sign in one step.
Bitrise
Bitrise has a 'Certificate and Profile Installer' step that does the keychain plumbing if you point it at .p12s and .mobileprovisions uploaded to Bitrise's Code Signing Files section. The HexSign equivalent is the HexSign Bitrise Step which fetches material from HexSign on each build instead of relying on Bitrise's storage.
# bitrise.yml
workflows:
ship:
steps:
- git-clone@8: {}
- hexsign-sign@1:
inputs:
- machine_token: $HEXSIGN_MACHINE_TOKEN
- bundle_id: com.example.app
- profile_type: appstore
- xcode-archive@4:
inputs:
- export_method: app-store
- automatic_code_signing: 'no-codesign'
- deploy-to-itunesconnect-application-loader@0: {}CircleCI
CircleCI's macOS executors are stateless. fastlane match works the same way as on GitHub Actions. HexSign ships a CircleCI orb that wraps the install + auth + sign sequence into one orb command.
# .circleci/config.yml
version: 2.1
orbs:
hexsign: hexsign/cli@1
jobs:
build:
macos:
xcode: '16.0'
steps:
- checkout
- hexsign/sign:
machine_token: HEXSIGN_MACHINE_TOKEN
bundle_id: com.example.app
profile_type: appstore
- run: bundle exec fastlane betaGitLab CI/CD
GitLab needs a self-hosted macOS runner or GitLab's hosted SaaS macOS runners. HexSign ships a GitLab CI/CD component that you `include` to add the signing step.
# .gitlab-ci.yml
include:
- component: gitlab.com/hexsign/components/sign@1
inputs:
bundle_id: com.example.app
profile_type: appstore
stages: [build]
build:
stage: build
tags: [macos]
variables:
HEXSIGN_MACHINE_TOKEN: $HEXSIGN_MACHINE_TOKEN
script:
- hexsign-sign
- bundle exec fastlane betafastlane (any CI)
If your pipeline is fastlane-driven and you would rather not introduce a separate tool, HexSign ships a fastlane plugin that drops in next to your existing match action.
# Fastfile
platform :ios do
lane :beta do
setup_ci
hexsign(
bundle_id: 'com.example.app',
profile_type: 'appstore',
machine_token: ENV['HEXSIGN_MACHINE_TOKEN'],
)
build_app(scheme: 'MyApp')
upload_to_testflight
end
endSecrets management
Three things have to be available to every signing job, and how you store them is one of the bigger reliability decisions you make:
- .p12 (certificate + private key)
- Base64-encode it, store as a CI secret, and treat the password as a separate secret. Rotating either means updating the secret on every CI provider that uses it.
- .mobileprovision (or download dynamically)
- Some teams base64 every profile into CI secrets. Better is to fetch the latest from the source of truth (Apple, fastlane match repo, or HexSign) on each build so a profile regeneration doesn't require a secret update.
- App Store Connect API key (.p8 + key ID + issuer ID)
- Stored as a CI secret in three parts. The App Store Connect API key glossary entry explains the three components.
Common CI signing failures
- errSecInternalComponent
- Missing partition-list step. Add `security set-key-partition-list -S apple-tool:,apple: -k "$PWD" $KEYCHAIN_PATH` after import.
- User interaction is not allowed
- Keychain is locked, or the runner timed out and re-locked it. Unlock with `security unlock-keychain` and extend the timeout with `security set-keychain-settings -lut 21600`.
- No matching profiles found
- The .mobileprovision is not in `~/Library/MobileDevice/Provisioning Profiles/`, or it does not match the Bundle ID / certificate combination Xcode chose. Print the profile UUID with `security cms -D -i Profile.mobileprovision` and confirm.
- Code signing 'Apple Distribution: Acme Corp' not found
- The certificate is in the keychain but the keychain is not in the search list, or the cert has expired. `security find-identity -p codesigning -v` lists the certs codesign can see.
- fastlane match passphrase rejected on CI
- Match's passphrase is in `MATCH_PASSWORD`. Wrong value or stale rotation. Re-set the secret on the CI provider after running `match change_password`.
Where to go next
- The signing primitives without CI: iOS code signing: the complete guide.
- Migrating away from fastlane match: fastlane match alternatives & migration.
- macOS notarization, which also runs through CI: macOS notarization for Electron + native.