TestFlight Uploads from GitHub Actions
Archive and export on a macOS runner, write an App Store Connect API key into the runner temp directory, then upload the IPA with xcrun altool on a tag.
Last verified:
Uploading a build to TestFlight from GitHub Actions is one job on a macOS runner running four stages in order: xcodebuild archive produces an .xcarchive, xcodebuild -exportArchive turns it into a signed .ipa, an App Store Connect API key gets written where Apple's tooling looks for it, and xcrun altool --upload-app hands the IPA to App Store Connect. The stage that fails is rarely the upload command; it is usually the export method, the build number, or the location of the .p8 key file.
Diagnosis
Read the exact string App Store Connect returns before changing anything, because five different problems all surface as a red upload step.
| Message in the log | Cause | Where the fix lives |
|---|---|---|
Unable to authenticate or HTTP 401 from altool | Key file missing, misnamed, or outside the search path | Key write step |
No suitable application records were found | Bundle identifier has no App Store Connect record, or -t names the wrong platform | App Store Connect record |
The bundle version must be higher than the previously uploaded version | Build number repeated across uploads | Build number step |
Invalid Provisioning Profile on an otherwise signed IPA | Archive exported with a development method rather than an App Store method | ExportOptions.plist |
Missing or invalid signature | Archive signed with a development certificate | Signing step |
Key file resolution. altool locates the private key by filename rather than by a path passed on the command line. It expects AuthKey_<key id>.p8 inside the directory named by API_PRIVATE_KEYS_DIR, falling back to ./private_keys, ~/private_keys, ~/.private_keys, and ~/.appstoreconnect/private_keys. A job that decodes the secret to $RUNNER_TEMP/key.p8 and passes --apiKey gets a 401 that reads like a revoked key.
Export method. The export method in ExportOptions.plist decides which profile the exporter selects and which entitlements land in the IPA. An archive exported for development signs cleanly on the runner and is rejected on upload, which wastes the entire archive step.
Build number collisions. App Store Connect rejects a build whose CFBundleVersion matches one already uploaded for that marketing version. A workflow that re-runs after a transient network error re-uploads the same build number and fails on the second attempt.
Secret exposure. Two habits leak the API key: decoding it inside $GITHUB_WORKSPACE where a wildcard artifact upload will collect it, and uploading the whole export directory instead of the IPA. GitHub masks the raw secret value in logs, and it does not mask a file you wrote and then published as an artifact.
Fix
Keep all four stages in one job. The IPA exists on one machine, and each WarpBuild job runs in its own virtual machine created on demand and destroyed after the build, so a second job starts with an empty disk. The runner isolation model is described in the runner security documentation.
Gate the job on a tag. Put the upload behind on: push with a tags filter plus workflow_dispatch. A pull request run has no reason to consume an App Store Connect slot, and fork pull requests receive no secrets at all, so an ungated upload job fails on every external contribution.
Write the key where altool looks. Set API_PRIVATE_KEYS_DIR to a directory under $RUNNER_TEMP, decode the base64 secret into AuthKey_$ASC_KEY_ID.p8 inside it, and tighten permissions before the upload runs.
Set the build number from the run number. agvtool new-version -all "$GITHUB_RUN_NUMBER" gives every run a monotonic CFBundleVersion without a commit back to the repository.
Validate before you upload. xcrun altool --validate-app runs the same asset checks as the upload without consuming a build slot. It turns a rejected upload into a failure that costs seconds rather than a full re-archive.
Restrict the artifact path. Give actions/upload-artifact a glob that matches *.ipa and nothing else, and drop the key directory in an if: always() teardown step.
Move the runner label. WarpBuild runners register with GitHub as self-hosted runners carrying warp- labels, so changing runs-on is the whole migration. The macOS catalog carries several sizes, so the release job and the pull request test job can sit on different labels in one workflow file.
Configuration
name: testflight
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
jobs:
upload:
runs-on: warp-macos-26-arm64-12x
environment: release
timeout-minutes: 45
env:
API_PRIVATE_KEYS_DIR: ${{ runner.temp }}/asc_keys
steps:
- uses: actions/checkout@v4
- name: Select the Xcode that ships the release SDK
run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)"
- name: Install signing assets
env:
SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }}
SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }}
PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }}
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 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
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$PROFILES"
echo "$PROVISIONING_PROFILE" | base64 --decode \
> "$PROFILES/build.mobileprovision"
- name: Stamp the build number
run: agvtool new-version -all "$GITHUB_RUN_NUMBER"
- 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 ci/ExportOptions.plist \
-exportPath "$RUNNER_TEMP/export"
- name: Write the App Store Connect API key
env:
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: |
mkdir -p "$API_PRIVATE_KEYS_DIR"
chmod 700 "$API_PRIVATE_KEYS_DIR"
echo "$ASC_KEY_P8" | base64 --decode \
> "$API_PRIVATE_KEYS_DIR/AuthKey_$ASC_KEY_ID.p8"
chmod 600 "$API_PRIVATE_KEYS_DIR/AuthKey_$ASC_KEY_ID.p8"
- name: Validate then upload
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
run: |
IPA="$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)"
xcrun altool --validate-app -f "$IPA" -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
- uses: actions/upload-artifact@v4
with:
name: ipa
path: ${{ runner.temp }}/export/*.ipa
retention-days: 14
- name: Tear down
if: always()
run: |
rm -rf "$API_PRIVATE_KEYS_DIR"
security delete-keychain "$KEYCHAIN" || trueExport options that decide whether the upload is accepted
ci/ExportOptions.plist is the file that separates an IPA App Store Connect accepts from one it rejects.
| Key | Value for a TestFlight build | What breaks without it |
|---|---|---|
method | An App Store export method for your Xcode version | Upload rejected as an invalid provisioning profile |
teamID | The ten character team identifier | Export picks the wrong team when the keychain holds two |
signingStyle | manual | Automatic signing asks for an App Store Connect session the job cannot supply |
provisioningProfiles | Bundle identifier mapped to the installed profile name | Export fails to find a profile for the target |
uploadSymbols | true | TestFlight crash reports arrive unsymbolicated |
stripSwiftSymbols | true | Larger IPA, and larger IPAs cost upload minutes |
The provisioningProfiles dictionary takes the profile's own name, which differs from its filename on disk. See provisioning profile for how the profile, the certificate, and the bundle identifier have to agree, and the code signing guide for the keychain half of the job.
warp-macos-26-arm64-12x ships 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. When the upload 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 the key directory in place.
Cost or Time Model
macOS labels and rates, from the WarpBuild pricing page and the cloud runners documentation, verified on 2026-08-13:
| 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 |
Where a release job spends its minutes
Substitute your own stage timings; these are placeholders for a medium iOS app so the arithmetic is visible.
| Stage | Assumed minutes | At $0.08 per minute | At $0.16 per minute |
|---|---|---|---|
| Checkout and package resolution | 2.0 | $0.16 | $0.32 |
| Keychain, certificate, profile | 0.5 | $0.04 | $0.08 |
xcodebuild archive | 14.0 | $1.12 | $2.24 |
xcodebuild -exportArchive | 2.0 | $0.16 | $0.32 |
altool --validate-app and --upload-app | 3.0 | $0.24 | $0.48 |
| Total | 21.5 | $1.72 | $3.44 |
Only the archive row responds to a wider label. Export is bounded by disk, and upload is bounded by IPA size and network throughput, so a 12 vCPU label has to cut archive minutes by more than half before it pays for itself against the 6 vCPU label.
Twenty TestFlight builds in a month is 430 minutes, or $34.40 at $0.08 per minute. 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. The same 430 minutes at the GitHub-hosted rate is $43.86, a difference of $9.46 per month at this volume.
Two cost mistakes are worth naming. The first is polling App Store Connect for processing status on the macOS runner: altool returns once the upload finishes, and waiting for the build to appear in TestFlight burns macOS minutes at $0.08 or $0.16 while the work happens on Apple's side. Move that poll to a separate job on warp-ubuntu-latest-x64-2x at $0.004 per minute, which reads the App Store Connect API over HTTP and needs no Xcode. The second is skipping --validate-app: a rejected upload after a 14 minute archive costs the whole archive again, while the validation step costs part of a minute.
CI observability keeps the per-stage minute counts in the table honest over time, so the sizing decision stays measured rather than assumed.
For the label catalog and image details, start with WarpBuild macOS runners for GitHub Actions. For the signing half of this job, see code signing iOS builds on GitHub Actions, and for the same upload expressed as a lane, see fastlane lanes on GitHub Actions.
FAQ
Why does xcrun altool fail with a 401 when the key works locally?
altool resolves the private key by filename rather than by a path passed on the command line. The file has to be named AuthKey_<key id>.p8 and sit in the directory named by API_PRIVATE_KEYS_DIR, or in ./private_keys, ~/private_keys, ~/.private_keys, or ~/.appstoreconnect/private_keys. A key decoded to any other path produces an authentication failure that looks like a bad key.
How do I keep the App Store Connect API key out of logs and artifacts?
Decode the .p8 into a directory under $RUNNER_TEMP rather than the workspace, chmod the directory to 700 and the key to 600, and give actions/upload-artifact a path glob that matches the IPA only. GitHub masks secret values in logs automatically, and the WarpBuild runner VM and its encrypted volume are destroyed after the build.
Which macOS label should a TestFlight release job use?
The archive step is the only part that scales with vCPU, so start on warp-macos-15-arm64-6x at $0.08 per minute and move to warp-macos-26-arm64-12x at $0.16 per minute only if archive minutes fall by more than half. Export and upload are dominated by disk and network, and a wider label does not shorten them.
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.