Build and Release an iOS App
Status: draft. This v1 body only covers the headless TestFlight upload path. Before publishing,
expand it into the full build-and-release lifecycle.
What this is
A fastlane setup that builds, signs, and uploads an iOS (and macOS Catalyst) app to
TestFlight with one credential: an App Store Connect API key (.p8). No interactive
Apple ID login, no portal clicking for signing, no certs to restore on a fresh machine.
The key mints the distribution cert and provisioning profile on demand, so a brand-new CI
runner needs nothing but the .p8, the key ID, and the issuer ID.
When to use
- Headless CI/CD where you cannot do a browser-based Apple Sign-In.
- A fresh machine with no signing assets restored.
- You want store metadata + screenshots reproducible from versioned files, not hand-typed.
- Works for native Xcode projects, Expo (
expo prebuild), and Capacitor (npx cap sync ios).
Prerequisites
- An App Store Connect API key with App Manager / Admin role. Three values:
key_id, issuer_id, and the .p8 file (keep it gitignored, never commit it).
- Xcode +
fastlane installed. A Release scheme that archives.
- Automatic signing left on in the target (
CODE_SIGN_STYLE=Automatic).
- The app record already exists in App Store Connect (see "The one manual gate").
The procedure
All lanes call app_store_connect_api_key(...) first: it stuffs the token into the
lane context (Spaceship::ConnectAPI), which is how produce, upload_to_testflight,
and deliver authenticate without a login. They have no api_key of their own to pass it.
Register the app record (first run only).
produce with skip_devcenter: true. This creates only the App Store Connect
record via the token-auth API. We skip the Developer-Portal half because that uses
legacy Apple-ID web login the key can't satisfy, and we don't need it: the App ID in
the portal gets auto-created by xcodebuild -allowProvisioningUpdates during the build.
Build + upload to TestFlight (:beta).
build_app is the one action with no api_key param, so auth is threaded through
xcargs instead:
xcargs: "DEVELOPMENT_TEAM=YOUR_TEAM_ID CODE_SIGN_STYLE=Automatic " \
"-allowProvisioningUpdates " \
"-authenticationKeyID YOUR_KEY_ID " \
"-authenticationKeyIssuerID YOUR_ISSUER_ID " \
"-authenticationKeyPath #{absolute_path_to_p8}"
The -authenticationKeyPath must be absolute: relative paths silently fail to
authenticate. File.expand_path("../keys/AuthKey_XXXX.p8", __dir__) from the Fastfile.
Then upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true).
Push metadata (:metadata).
upload_to_app_store with skip_binary_upload: true, skip_screenshots: true, force: true. Fields live in fastlane/metadata/. force: true suppresses the
interactive HTML preview so it runs unattended.
Push screenshots (:screenshots).
upload_to_app_store with skip_binary_upload: true, skip_metadata: true, overwrite_screenshots: true, force: true, reading from fastlane/screenshots/.
macOS Catalyst build (:mac).
Same as :beta plus catalyst_platform: "macos" in build_app and
app_platform: "osx" in upload_to_testflight. With SUPPORTS_MACCATALYST=YES and
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER=NO in the target, the Mac build shares
the same bundle id and ASC record as iOS (Universal Purchase): no separate record.
macOS screenshots (:mac_screenshots).
upload_to_app_store(platform: "osx", screenshots_path: "./fastlane/screenshots-mac").
Catalyst screenshots are 2880×1800; deliver auto-maps that size to the
APP_DESKTOP display type. You don't name the display type yourself.
Mint the Mac installer cert key-only (:mac_cert), if uploading a Catalyst build.
The ASC API can create certificates (POST /v1/certificates), so
cert(platform: "macos", type: "mac_installer_distribution", api_key: api_key)
generates the CSR, creates + downloads the cert, and imports it to the keychain. No
portal click. Idempotent: reuses an existing valid cert.
Gotchas & failure modes
app_store_connect_api_key must run first, in every lane. If produce/deliver/
pilot prompts for an Apple ID, you forgot it or it ran after.
build_app has no api_key: authenticate via xcargs -authenticationKey*. Easy to
pass the key everywhere except the build and then wonder why signing prompts for login.
-authenticationKeyPath must be ABSOLUTE. Relative path = silent auth failure.
produce without skip_devcenter: true falls back to legacy web login (Apple ID +
password) for the portal half → forces an interactive login. Always skip devcenter.
- Expo / Capacitor regenerate the project (
expo prebuild --clean,
npx cap sync ios) and wipe signing build settings: that's exactly why
DEVELOPMENT_TEAM + CODE_SIGN_STYLE go through xcargs, not the project file. For
Capacitor: run the web build + npx cap sync ios before the lane.
- Reused build number is auto-rejected by TestFlight. Keep the build number ahead of
the latest processed build; bump it before every upload.
force: true is required on upload_to_app_store for unattended runs, or it opens
an HTML preview and blocks waiting for a human.
The one manual gate
Honest caveat: the very first creation of the App Store Connect app record can require
a one-time interactive web session in some Apple accounts (produce via the key works for
many, but Apple occasionally gates brand-new record creation behind a logged-in session).
After the record exists, everything above is fully key-only and headless forever. Treat it
like a one-time account bootstrap, the same shape as the first Catalyst installer cert.
Minimal example
lane :beta do
api_key = app_store_connect_api_key(
key_id: "YOUR_KEY_ID", issuer_id: "YOUR_ISSUER_ID",
key_filepath: "keys/AuthKey_XXXX.p8",
)
auth_key_path = File.expand_path("../keys/AuthKey_XXXX.p8", __dir__)
build_app(
workspace: "ios/App.xcworkspace", scheme: "App", configuration: "Release",
export_method: "app-store",
export_options: { signingStyle: "automatic", teamID: "YOUR_TEAM_ID" },
xcargs: "DEVELOPMENT_TEAM=YOUR_TEAM_ID CODE_SIGN_STYLE=Automatic " \
"-allowProvisioningUpdates " \
"-authenticationKeyID YOUR_KEY_ID " \
"-authenticationKeyIssuerID YOUR_ISSUER_ID " \
"-authenticationKeyPath #{auth_key_path}",
)
upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
end
Full commented template with every lane: reference/Fastfile.example.
1---2name: build-and-release-ios-app3description: Draft skill for building and releasing iOS or macOS Catalyst apps. Current coverage is the App Store Connect API key TestFlight upload path; expand before publishing into the full lifecycle from app/design import through build, test, metadata, screenshots, TestFlight, and App Review submission.4---56# Build and Release an iOS App78> Status: draft. This v1 body only covers the headless TestFlight upload path. Before publishing,9> expand it into the full build-and-release lifecycle.1011## What this is1213A fastlane setup that builds, signs, and uploads an iOS (and macOS Catalyst) app to14TestFlight with **one credential**: an App Store Connect API key (`.p8`). No interactive15Apple ID login, no portal clicking for signing, no certs to restore on a fresh machine.16The key mints the distribution cert and provisioning profile on demand, so a brand-new CI17runner needs nothing but the `.p8`, the key ID, and the issuer ID.1819## When to use2021- Headless CI/CD where you cannot do a browser-based Apple Sign-In.22- A fresh machine with no signing assets restored.23- You want store metadata + screenshots reproducible from versioned files, not hand-typed.24- Works for native Xcode projects, Expo (`expo prebuild`), and Capacitor (`npx cap sync ios`).2526## Prerequisites2728- An App Store Connect API key with **App Manager / Admin** role. Three values:29 `key_id`, `issuer_id`, and the `.p8` file (keep it gitignored, never commit it).30- Xcode + `fastlane` installed. A `Release` scheme that archives.31- Automatic signing left on in the target (`CODE_SIGN_STYLE=Automatic`).32- The app record already exists in App Store Connect (see "The one manual gate").3334## The procedure3536All lanes call `app_store_connect_api_key(...)` **first**: it stuffs the token into the37lane context (`Spaceship::ConnectAPI`), which is how `produce`, `upload_to_testflight`,38and `deliver` authenticate without a login. They have no `api_key` of their own to pass it.39401. **Register the app record (first run only).**41 `produce` with `skip_devcenter: true`. This creates *only* the App Store Connect42 record via the token-auth API. We skip the Developer-Portal half because that uses43 legacy Apple-ID web login the key can't satisfy, and we don't need it: the App ID in44 the portal gets auto-created by `xcodebuild -allowProvisioningUpdates` during the build.45462. **Build + upload to TestFlight (`:beta`).**47 `build_app` is the one action with **no `api_key` param**, so auth is threaded through48 `xcargs` instead:49 ```50 xcargs: "DEVELOPMENT_TEAM=YOUR_TEAM_ID CODE_SIGN_STYLE=Automatic " \51 "-allowProvisioningUpdates " \52 "-authenticationKeyID YOUR_KEY_ID " \53 "-authenticationKeyIssuerID YOUR_ISSUER_ID " \54 "-authenticationKeyPath #{absolute_path_to_p8}"55 ```56 The `-authenticationKeyPath` **must be absolute**: relative paths silently fail to57 authenticate. `File.expand_path("../keys/AuthKey_XXXX.p8", __dir__)` from the Fastfile.58 Then `upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)`.59603. **Push metadata (`:metadata`).**61 `upload_to_app_store` with `skip_binary_upload: true, skip_screenshots: true,62 force: true`. Fields live in `fastlane/metadata/`. `force: true` suppresses the63 interactive HTML preview so it runs unattended.64654. **Push screenshots (`:screenshots`).**66 `upload_to_app_store` with `skip_binary_upload: true, skip_metadata: true,67 overwrite_screenshots: true, force: true`, reading from `fastlane/screenshots/`.68695. **macOS Catalyst build (`:mac`).**70 Same as `:beta` plus `catalyst_platform: "macos"` in `build_app` and71 `app_platform: "osx"` in `upload_to_testflight`. With `SUPPORTS_MACCATALYST=YES` and72 `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER=NO` in the target, the Mac build shares73 the **same bundle id and ASC record** as iOS (Universal Purchase): no separate record.74756. **macOS screenshots (`:mac_screenshots`).**76 `upload_to_app_store(platform: "osx", screenshots_path: "./fastlane/screenshots-mac")`.77 Catalyst screenshots are **2880×1800**; deliver auto-maps that size to the78 `APP_DESKTOP` display type. You don't name the display type yourself.79807. **Mint the Mac installer cert key-only (`:mac_cert`), if uploading a Catalyst build.**81 The ASC API *can* create certificates (`POST /v1/certificates`), so82 `cert(platform: "macos", type: "mac_installer_distribution", api_key: api_key)`83 generates the CSR, creates + downloads the cert, and imports it to the keychain. No84 portal click. Idempotent: reuses an existing valid cert.8586## Gotchas & failure modes8788- **`app_store_connect_api_key` must run first**, in every lane. If `produce`/`deliver`/89 `pilot` prompts for an Apple ID, you forgot it or it ran after.90- **`build_app` has no `api_key`**: authenticate via `xcargs -authenticationKey*`. Easy to91 pass the key everywhere *except* the build and then wonder why signing prompts for login.92- **`-authenticationKeyPath` must be ABSOLUTE.** Relative path = silent auth failure.93- **`produce` without `skip_devcenter: true`** falls back to legacy web login (Apple ID +94 password) for the portal half → forces an interactive login. Always skip devcenter.95- **Expo / Capacitor regenerate the project** (`expo prebuild --clean`,96 `npx cap sync ios`) and wipe signing build settings: that's exactly why97 `DEVELOPMENT_TEAM` + `CODE_SIGN_STYLE` go through `xcargs`, not the project file. For98 Capacitor: run the web build + `npx cap sync ios` **before** the lane.99- **Reused build number** is auto-rejected by TestFlight. Keep the build number ahead of100 the latest processed build; bump it before every upload.101- **`force: true` is required** on `upload_to_app_store` for unattended runs, or it opens102 an HTML preview and blocks waiting for a human.103104## The one manual gate105106Honest caveat: the **very first creation of the App Store Connect app record** can require107a one-time interactive web session in some Apple accounts (`produce` via the key works for108many, but Apple occasionally gates brand-new record creation behind a logged-in session).109After the record exists, everything above is fully key-only and headless forever. Treat it110like a one-time account bootstrap, the same shape as the first Catalyst installer cert.111112## Minimal example113114```ruby115lane :beta do116 api_key = app_store_connect_api_key(117 key_id: "YOUR_KEY_ID", issuer_id: "YOUR_ISSUER_ID",118 key_filepath: "keys/AuthKey_XXXX.p8",119 )120 auth_key_path = File.expand_path("../keys/AuthKey_XXXX.p8", __dir__)121122 build_app(123 workspace: "ios/App.xcworkspace", scheme: "App", configuration: "Release",124 export_method: "app-store",125 export_options: { signingStyle: "automatic", teamID: "YOUR_TEAM_ID" },126 xcargs: "DEVELOPMENT_TEAM=YOUR_TEAM_ID CODE_SIGN_STYLE=Automatic " \127 "-allowProvisioningUpdates " \128 "-authenticationKeyID YOUR_KEY_ID " \129 "-authenticationKeyIssuerID YOUR_ISSUER_ID " \130 "-authenticationKeyPath #{auth_key_path}",131 )132 upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)133end134```135136Full commented template with every lane: `reference/Fastfile.example`.