
Replace Apple ID auth with an App Store Connect API key, keep the .p8 out of Git and out of env > file dumps, and run the pipeline on a Mac that matches your toolchain. For device builds and Apple-silicon targets a Figment M-series Mac from $95/month is the right fit (exact chip, RAM and disk confirmed in the quote). A Figment $29 virtual Intel macOS instance can drive the signing and upload steps when you are targeting a compatible Intel Xcode (Xcode 26 or earlier); it is a real macOS environment.
Apple ID authentication in older Fastlane and Xcode flows relies on a session cookie that typically lapses within about 30 days. When it expires, the next TestFlight upload, cert fetch, or App Store Connect query fails with a 2FA prompt that no headless runner can answer, and the release pipeline stalls until someone re-authenticates on a trusted device.
The App Store Connect API uses a private key to sign short-lived JWTs instead. There is no interactive session, no 2FA, and the key is valid until you revoke it. Fastlane's App Store Connect API documentation describes the same action-based flow for match, gym, pilot, deliver and precheck.
.p8 file immediately — App Store Connect shows it once and does not expose a re-download.Store the three values as CI secrets in your provider's secret manager. Do not dump the entire environment to a file; env > file leaks every unrelated secret at the same time. If you need to carry secrets to a new CI, transfer the names and let each system re-inject the values.
Base64-encode the .p8 once so string-only secret stores (GitHub Actions, GitLab CI, Bitrise, Jenkins Credentials) can hold it verbatim:
# Run locally, paste the output into the CI secret value
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy
Then add three secrets: ASC_KEY_CONTENT (the base64 text), ASC_KEY_ID, and ASC_ISSUER_ID.
The script below is the artifact for this guide. Source it at the start of the job. It tightens the umask before touching disk, writes the key to a private temp path, and removes it on exit — success or failure — with a trap.
#!/usr/bin/env bash
# setup-asc-key.sh — source me: `source ./setup-asc-key.sh`
# Requires CI secrets: ASC_KEY_CONTENT (base64 .p8), ASC_KEY_ID, ASC_ISSUER_ID
set -euo pipefail
: "${ASC_KEY_CONTENT:?Set ASC_KEY_CONTENT to the base64-encoded .p8 content}"
: "${ASC_KEY_ID:?Set ASC_KEY_ID to the 10-character key ID}"
: "${ASC_ISSUER_ID:?Set ASC_ISSUER_ID to the team issuer UUID}"
# Restrictive perms BEFORE any file is created
_old_umask=$(umask)
umask 077
_ASC_KEY_DIR=$(mktemp -d "${TMPDIR:-/tmp}/asc-key.XXXXXX")
_ASC_KEY_FILE="$_ASC_KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
cleanup() { rm -rf "$_ASC_KEY_DIR"; }
trap cleanup EXIT INT TERM
printf '%s' "$ASC_KEY_CONTENT" | base64 --decode > "$_ASC_KEY_FILE"
umask "$_old_umask"
export ASC_KEY_PATH="$_ASC_KEY_FILE"
# Read by Fastlane's app_store_connect_api_key action when invoked with no args
export APP_STORE_CONNECT_API_KEY_KEY_ID="$ASC_KEY_ID"
export APP_STORE_CONNECT_API_KEY_ISSUER_ID="$ASC_ISSUER_ID"
export APP_STORE_CONNECT_API_KEY_KEY_FILEPATH="$_ASC_KEY_FILE"
echo "ASC key staged at $_ASC_KEY_FILE (removed when this shell exits)"
Note that a separate CI step runs in a separate shell: the trap only cleans up inside the shell that sourced the script. If your CI splits steps across shells, add an explicit teardown step and treat machine-failure cleanup as the runner's responsibility, not the script's.
The Fastfile side is a single call that every other action can reuse:
# Fastfile
lane :build_and_upload do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
duration: 1200, # seconds; Fastlane caps this at 1200
in_house: false
)
match(type: "appstore", api_key: api_key, readonly: true)
gym(scheme: ENV.fetch("APP_SCHEME"), configuration: "Release")
pilot(api_key: api_key, skip_waiting_for_build_processing: true)
end
Pass the project's real scheme through APP_SCHEME — do not hard-code a placeholder your CI has no way to build.
For projects without Fastlane, three flags cover archive and -exportArchive:
xcodebuild \
-scheme "$APP_SCHEME" \
-configuration Release \
-archivePath "build/${APP_SCHEME}.xcarchive" \
-authenticationKeyPath "$ASC_KEY_PATH" \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
archive
Use the same three flags for the -exportArchive step that uploads to App Store Connect. Keep set -e on the job so a failed signing step stops the pipeline; a build that exits non-zero is not a benchmark.
A 20-minute archive followed by an auth failure wastes a runner slot. Run a tiny verification lane first so bad keys surface in seconds:
lane :verify_asc_key do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_filepath: ENV.fetch("ASC_KEY_PATH"),
duration: 600
)
Spaceship::ConnectAPI.token = api_key
apps = Spaceship::ConnectAPI::App.all(limit: 1)
UI.user_error!("No apps visible to this key") if apps.empty?
UI.success("ASC key valid — #{apps.length} app visible")
end
Wire fastlane verify_asc_key as a short-timeout job ahead of the archive job in your pipeline graph. Only the full archive depends on it, so a bad rotation fails in under a minute instead of after twenty.
Keys cannot be edited in place, but two keys can be valid at once:
.p8.verify_asc_key and one full release-branch build against the new key.Name keys with a clear purpose and date (CI-prod-2026-11) so the revoke decision is obvious. Document who holds the downloaded .p8 for break-glass access — remember that App Store Connect does not re-issue it.
Match the host to the toolchain, not the other way round:
| Scenario | Figment plan | Monthly price | Notes |
|---|---|---|---|
| App uses Xcode 26 or earlier, Intel-compatible targets, signing + upload only | Virtual Intel macOS (2 vCPU, 8 GiB RAM, 80 GiB disk) | $29 | Real macOS on Linux/KVM; browser-console access; no GPU/audio. Intel Xcode runs here. |
| iOS/macOS device builds, Apple-silicon simulators, Xcode 27 | M-series Mac | from $95 | Entry price; exact chip, RAM and disk confirmed in the quote. |
| Signing + CI runner managed for you | Managed Build Mac | $249 | One M-series Mac, CI setup, backups, monitoring, 30 minutes monthly support. |
| Two parallel runners with team onboarding | Managed Mac Team | $999 | Two M-series Macs, two CI runners, team setup, backups, monitoring, one hour monthly support. |
A worked example: your verify_asc_key + archive + TestFlight upload pipeline that currently runs on a laptop-under-a-desk. Renting an M-series Mac at $95/month replaces the laptop at $95 × 12 = $1,140 per year (tax and fees as stated in the actual offer). CloudMac publishes an annual rate of $999/year for an M4 / 16 GB / 256 GB Mac mini (equivalent to $83.25/month effective), and $99.99/month on its pricing page — cheaper on annual than the Figment entry tier, on a specific published spec. Figment's $95 is an entry price for an M-series machine with the exact spec confirmed on quote, and the Managed Build and Managed Mac Team plans bundle the CI setup, backups, monitoring and support time that a bare Mac rental does not. If what you need is the Mac only, price both; if what you need is the signing pipeline running next week without you building it, the managed tiers are the comparison to run.
Physical i3 Mac ($49) and physical M-series requests collect in a queue until supply is confirmed — ask for a current slot rather than assuming stock. The $29 virtual Intel macOS tier is the one to pick when the job is "sign and upload with an older Intel-compatible Xcode and nothing else."
Ready to run this pipeline on a Figment Mac? Start here →
Related guides: