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 logUsual causeWhere the fix lives
errSecInternalComponent or User interaction is not allowedKeychain missing, locked, or imported without a partition listKeychain setup step
No profiles for 'com.example.App' were foundProfile not installed, or the bundle identifier and profile do not matchProfile install step
Provisioning profile "X" doesn't include signing certificate "Y"Certificate and profile come from different rotationsSecret rotation
The certificate used to sign ... has either expired or has been revokedCertificate past its validity windowSecret rotation
unable to load certificate after base64 --decodeSecret is empty in this jobSecret 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" || true

The 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=NO

warp-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:

ArtifactWhere it livesHow long it survives
Distribution certificate and private key, base64 .p12Repository or environment secret in GitHubUntil you rotate it or the certificate expires
Certificate passwordRepository or environment secret in GitHubUntil you rotate the certificate
Decoded cert.p12 on the runner$RUNNER_TEMP on the job's encrypted volumeDeleted 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 volumeDeleted by the teardown step, and the volume is destroyed after the build
Keychain passwordGenerated in the job by openssl randLives in the job's process environment only
Provisioning profile, base64 .mobileprovisionRepository or environment secret in GitHubUntil the profile expires or is regenerated
Installed build.mobileprovisionXcode profile directory in $HOMEDestroyed with the VM after the build
App Store Connect API key .p8Repository or environment secret in GitHubUntil revoked in App Store Connect
DerivedData and the .xcarchive$RUNNER_TEMP on the job's encrypted volumeDestroyed with the VM after the build
Signed .ipaWorkflow artifact in GitHubRepository artifact retention, 90 days by default

Three configuration facts decide whether the workflow above runs green on the first attempt.

  1. 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.
  2. Fork pull requests receive no secrets. Keep the signing job on push to tags or on workflow_dispatch, and leave fork pull requests to the unsigned test job.
  3. 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

LabelmacOSvCPUMemoryStorageRate per minuteAlias
warp-macos-26-arm64-6xmacOS 26622GB120GB SSD$0.08
warp-macos-26-arm64-12xmacOS 261244GB270GB SSD$0.16
warp-macos-15-arm64-6xmacOS 15622GB120GB SSD$0.08warp-macos-latest-arm64-6x
warp-macos-15-arm64-12xmacOS 151244GB270GB SSD$0.16warp-macos-latest-arm64-12x
warp-macos-14-arm64-6xmacOS 14622GB120GB 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.

ConfigurationMinutesMonthly costArithmetic
GitHub-hosted macOS ARM64 larger runner480$48.96480 x $0.102
warp-macos-15-arm64-6x480$38.40480 x $0.08
warp-macos-26-arm64-12x at the same minute count480$76.80480 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.