Code Signing iOS Builds on GitHub Actions
Create a temporary keychain in the job, import the certificate from repository secrets, install the profile, then archive on a warp-macos runner label.
Last verified:
Code signing an iOS build on GitHub Actions means putting three things on a macOS runner that starts without any of them: a distribution certificate with its private key, a matching provisioning profile, and an unlocked keychain that codesign can read without an interactive prompt. The workflow does that in the job itself by creating a temporary keychain, importing a base64 certificate from repository secrets, writing the profile into the Xcode profile directory, and letting the ephemeral runner destroy all of it when the job ends.
Diagnosis
Signing failures on GitHub Actions cluster into four causes, and each one prints a recognizable string. Read the string first, because the fixes differ.
| Symptom in the log | Usual cause | Where the fix lives |
|---|---|---|
errSecInternalComponent or User interaction is not allowed | Keychain missing, locked, or imported without a partition list | Keychain setup step |
No profiles for 'com.example.App' were found | Profile not installed, or the bundle identifier and profile do not match | Profile install step |
Provisioning profile "X" doesn't include signing certificate "Y" | Certificate and profile come from different rotations | Secret rotation |
The certificate used to sign ... has either expired or has been revoked | Certificate past its validity window | Secret rotation |
unable to load certificate after base64 --decode | Secret is empty in this job | Secret scoping |
Missing or locked keychain. A macOS runner has no interactive user and no unlocked login keychain. codesign reaches for the private key, macOS asks for authorization, and no one is there to click through it, so the step hangs until the job timeout fires or exits with errSecInternalComponent. Two commands prevent it: security unlock-keychain after creating the keychain, and security set-key-partition-list so codesign and /usr/bin/security are pre-authorized to use the imported key.
Provisioning profile mismatch. Xcode matches a profile to a build by bundle identifier, team, capabilities, and the certificate embedded in the profile. A profile regenerated after a certificate rotation stops matching the older certificate still sitting in a secret, and the error names the profile rather than the secret. Check both halves together: security find-identity -v -p codesigning for what the keychain holds, and the profile's DeveloperCertificates array for what the profile expects.
Expired certificates. Apple distribution certificates carry a fixed validity window, and a pipeline that signed cleanly for a year fails on a Tuesday with no workflow change in the diff. The certificate outlives the memory of whoever uploaded it, which is why the checklist further down records an expiry column next to every secret.
Secrets scoped to the wrong environment. GitHub resolves an empty secret to an empty string rather than failing, so the first visible error arrives several steps later when base64 --decode writes a zero-byte file. Three scoping rules produce this: a secret defined on a GitHub Environment reaches only jobs that declare that environment:, an organization secret reaches only repositories on its access list, and a pull_request run triggered from a fork receives no secrets at all. A fork contributor opening a pull request against a workflow that signs on every push sees the same red job every time.
One more pattern shows up in review of real iOS repositories: signing runs in jobs that never needed it. A unit test job on a simulator compiles fine with CODE_SIGNING_ALLOWED=NO, and every certificate import in that job is machine time and a second copy of the private key on a second machine.
Fix
Keep signing inside one job, build every artifact from a secret at the start of the job, and let the runner lifecycle handle disposal.
Create the keychain in the job. Generate a random password with openssl rand, create the keychain under $RUNNER_TEMP, unlock it, and add it to the user search list. Nothing here depends on state from a previous run.
Import the certificate from a secret. Base64 encode the .p12 locally, store it and its password as secrets, decode into $RUNNER_TEMP in the job, run security import with -T /usr/bin/codesign, then delete the decoded file. The -T flag and the follow-up set-key-partition-list call are what stop the authorization prompt.
Install the profile explicitly. Decode the .mobileprovision secret into the Xcode profile directory. Xcode 16 and later read ~/Library/Developer/Xcode/UserData/Provisioning Profiles, and older toolchains read ~/Library/MobileDevice/Provisioning Profiles, so write to the directory that matches the Xcode you select in the job.
Sign manually. Pass CODE_SIGN_STYLE=Manual and PROVISIONING_PROFILE_SPECIFIER to xcodebuild and point OTHER_CODE_SIGN_FLAGS at your keychain. Automatic signing wants an App Store Connect session and produces a different failure on a headless machine.
Verify before you archive. A one-line security find-identity -v -p codesigning step fails in seconds when the certificate is wrong, ahead of a compile that bills for tens of minutes.
Move the runner label. WarpBuild runners register with GitHub as self-hosted runners carrying warp- labels, so changing the value of runs-on is the whole migration for a signing job. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS catalog carries multiple sizes and configurations per chip, so the archive job and the test job can sit on different sizes in one workflow file.
The ephemeral runner facts are what make the whole pattern safe to repeat on every release. Each runner runs in its own virtual machine, created on demand and destroyed after each build. Each runner has its own encrypted storage volume, also created on demand and destroyed after each build. WarpBuild does not access or store any build secrets: they stay in your source code repository and reach only the runner environment. The isolation model is described in the runner security documentation, and the SOC 2 Type 2 report covering Security, Availability, and Confidentiality is available through the WarpBuild trust center.
Configuration
The release workflow below runs on a 12 vCPU macOS label and does the full sequence: keychain, certificate, profile, verification, archive, export, upload, teardown.
name: ios-release
on:
workflow_dispatch:
push:
tags: ["v*"]
jobs:
archive:
runs-on: warp-macos-26-arm64-12x
environment: release
steps:
- uses: actions/checkout@v4
- name: Select Xcode 27.0
run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)"
- name: Create a temporary keychain and import the certificate
env:
SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }}
SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 3600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
echo "$SIGNING_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" \
-k "$KEYCHAIN" \
-P "$SIGNING_CERT_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
rm -f "$RUNNER_TEMP/cert.p12"
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
- name: Install the provisioning profile
env:
PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
run: |
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$PROFILES"
echo "$PROVISIONING_PROFILE" | base64 --decode \
> "$PROFILES/build.mobileprovision"
- name: Verify the identity is visible to codesign
run: security find-identity -v -p codesigning "$KEYCHAIN"
- name: Archive
run: |
xcodebuild archive \
-scheme App \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/App.xcarchive" \
-derivedDataPath "$RUNNER_TEMP/DerivedData" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM=${{ vars.DEVELOPMENT_TEAM }} \
PROVISIONING_PROFILE_SPECIFIER="${{ vars.PROFILE_NAME }}" \
OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN"
- name: Export the IPA
run: |
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/App.xcarchive" \
-exportOptionsPlist ExportOptions.plist \
-exportPath "$RUNNER_TEMP/export"
- uses: actions/upload-artifact@v4
with:
name: ipa
path: ${{ runner.temp }}/export/*.ipa
- name: Tear down the keychain
if: always()
run: security delete-keychain "$KEYCHAIN" || trueThe pull request job stays on the smaller label and never sees a certificate:
test:
runs-on: warp-macos-15-arm64-6x
steps:
- uses: actions/checkout@v4
- run: |
xcodebuild test \
-scheme AppTests \
-destination "platform=iOS Simulator,name=iPhone 16" \
CODE_SIGNING_ALLOWED=NOwarp-macos-15-arm64-6x also answers to the alias warp-macos-latest-arm64-6x, and warp-macos-26-arm64-12x carries Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. The full label list lives in the cloud runners documentation.
Signing artifact checklist
Every artifact the job touches, where it lives, and how long it survives:
| Artifact | Where it lives | How long it survives |
|---|---|---|
Distribution certificate and private key, base64 .p12 | Repository or environment secret in GitHub | Until you rotate it or the certificate expires |
| Certificate password | Repository or environment secret in GitHub | Until you rotate the certificate |
Decoded cert.p12 on the runner | $RUNNER_TEMP on the job's encrypted volume | Deleted by rm in the same step, and the volume is destroyed after the build |
Temporary keychain build.keychain-db | $RUNNER_TEMP on the job's encrypted volume | Deleted by the teardown step, and the volume is destroyed after the build |
| Keychain password | Generated in the job by openssl rand | Lives in the job's process environment only |
Provisioning profile, base64 .mobileprovision | Repository or environment secret in GitHub | Until the profile expires or is regenerated |
Installed build.mobileprovision | Xcode profile directory in $HOME | Destroyed with the VM after the build |
App Store Connect API key .p8 | Repository or environment secret in GitHub | Until revoked in App Store Connect |
DerivedData and the .xcarchive | $RUNNER_TEMP on the job's encrypted volume | Destroyed with the VM after the build |
Signed .ipa | Workflow artifact in GitHub | Repository artifact retention, 90 days by default |
Three configuration facts decide whether the workflow above runs green on the first attempt.
- Secrets are scoped where you declared them. The job declares
environment: release, so environment secrets resolve. Drop that line and the same secrets read as empty strings. - Fork pull requests receive no secrets. Keep the signing job on
pushto tags or onworkflow_dispatch, and leave fork pull requests to the unsigned test job. - Runner storage is ephemeral. Anything a later job needs goes to an artifact or a cache, because the next job starts on a different machine.
For dashboard access control on top of that, SSO is available for a flat $250 per month, whatever the user count. When a signing step behaves differently on the runner than on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest route to inspecting a keychain or a profile in place.
Cost or Time Model
The signing commands themselves are cheap. security create-keychain, security import, and set-key-partition-list finish in a couple of seconds. The cost of code signing on GitHub Actions is the archive job that wraps them, plus every re-run caused by an expired secret.
macOS labels and rates
| Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias |
|---|---|---|---|---|---|---|
warp-macos-26-arm64-6x | macOS 26 | 6 | 22GB | 120GB SSD | $0.08 | |
warp-macos-26-arm64-12x | macOS 26 | 12 | 44GB | 270GB SSD | $0.16 | |
warp-macos-15-arm64-6x | macOS 15 | 6 | 22GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x |
warp-macos-15-arm64-12x | macOS 15 | 12 | 44GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x |
warp-macos-14-arm64-6x | macOS 14 | 6 | 22GB | 120GB SSD | $0.08 |
Rates come from the WarpBuild pricing page and the cloud runners documentation, verified on 2026-08-13.
The like-for-like comparison: warp-macos-latest-arm64-6x (6 vCPU, 22GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price, with one more vCPU and 8GB more memory on the WarpBuild side. GitHub list price read from the GitHub Actions per-minute rates, checked on 2026-08-13.
Worked monthly model
Assumptions, all stated so you can substitute your own:
- One iOS repository with one signed nightly build per working day, 20 working days in the month, 18 minutes per archive and export job.
- Four App Store release builds in the month at 30 minutes each.
- Unsigned pull request test jobs are excluded, since they carry no signing work.
- Billing is per minute of job time.
Signed macOS minutes: 20 x 18 = 360, plus 4 x 30 = 120, for 480 minutes per month.
| Configuration | Minutes | Monthly cost | Arithmetic |
|---|---|---|---|
| GitHub-hosted macOS ARM64 larger runner | 480 | $48.96 | 480 x $0.102 |
warp-macos-15-arm64-6x | 480 | $38.40 | 480 x $0.08 |
warp-macos-26-arm64-12x at the same minute count | 480 | $76.80 | 480 x $0.16 |
The 6 vCPU row is $10.56 per month below the GitHub-hosted row, or $126.72 over twelve months at the same volume. The 12 vCPU row is only worth its rate when the wider machine removes minutes: against the 6 vCPU label it has to finish the job in half the minutes to break even, and against the GitHub-hosted rate it has to finish in 63.75 percent of them. Measure the archive job on both labels for a week before you commit.
Now price the failure mode. One archive job re-run per week from an expired certificate or a mis-scoped secret adds 4 x 18 = 72 minutes to the month, which is $5.76 at $0.08 per minute and $11.52 at $0.16. That is the whole argument for the security find-identity verification step: it converts an 18-minute failed compile into a 10-second failed check.
Release weeks are where concurrency shows up. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For unusually high macOS concurrency, write to [email protected] before the release week rather than during it.
For the runner catalog and the image details behind these labels, start with WarpBuild macOS runners for GitHub Actions. For the pipeline around the signing job, see iOS builds on GitHub Actions with macOS runners, and for the same job expressed as lanes, see fastlane lanes on GitHub Actions. For what the runner keeps after a job, see do GitHub Actions runners store my source code.
FAQ
Why does codesign fail with "User interaction is not allowed" on a runner?
The signing key lives in a keychain that is either locked or missing a partition list, so codesign asks for an authorization the headless job cannot answer. Create the keychain in the job, unlock it, and run security set-key-partition-list with -S apple-tool:,apple:,codesign: before xcodebuild archive.
How do I get a signing certificate onto a GitHub Actions runner safely?
Store the .p12 base64 encoded in a repository or environment secret and decode it into $RUNNER_TEMP inside the job. Each WarpBuild job runs on a freshly provisioned VM with its own encrypted storage volume that is destroyed after the build, and WarpBuild does not access or store build secrets, which stay in your repository and reach only the runner environment.
Do I need a cleanup step to delete the signing keychain?
The runner VM and its encrypted volume are destroyed when the job finishes, so nothing survives on the machine. An explicit security delete-keychain step with if: always() is still worth keeping, because it makes the same workflow safe to run on a long-lived machine and it fails loudly if the keychain path drifted.
Which macOS runner label should the signing job use?
Use warp-macos-26-arm64-12x at $0.16 per minute for an archive and export job that compiles Release configuration, and warp-macos-15-arm64-6x at $0.08 per minute for pull request test jobs that pass CODE_SIGNING_ALLOWED=NO and never touch a certificate.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.