In many teams, App Store submission still looks like this: the PM pings Slack, the iOS lead manually archives in Xcode, uploads TestFlight at midnight, and re-enters metadata the next morning. The feature merged weeks ago. What actually blocks shipping is the lack of an engineered release path. This article takes a CI/CD view of the journey from Xcode to approval, splits it into six observable stages, defines boundaries for signing, build, upload, submission, and release, and includes a runbook you can adapt directly. If you are still deciding where the build node lives, start with the layering model in New Playbooks for iOS Pipelines.
By the end you will have: a six-stage submission map, a manual vs automated comparison table, a minimal fastlane + GitHub Actions YAML, and a checklist for shipping a new TestFlight build within 30 minutes after rejection.
Why Treat App Store Submission as an Engineering Pipeline, Not a Checklist
New teams often treat submission as a checklist: screenshots, privacy policy, hit Submit. The problem is that a checklist does not record state and does not guarantee release N uses the same assumptions as release 1. From an engineering perspective, submission is a directed pipeline where each stage produces explicit artifacts and the next stage consumes only the previous output:
- Repeatable: the same git tag on a build island should produce the same IPA as local (given identical Xcode and dependency locks)
- Observable: every stage has logs, duration, and failure codes; Slack should show “upload succeeded / processing / ready for review,” not “I think it uploaded”
- Rollback-ready: the store keeps the previous build; git keeps tags and dSYMs so crash symbolication does not break
- Separation of duties: engineering owns binaries and version numbers; product/ops owns store copy and screenshots — CI separates these with different jobs or approval gates
Cross-platform teams without a Mac follow the same path: code on Windows or Linux, concentrate release stages on a macOS build node. For the split-workflow model, see Windows/Linux primary with remote Mac build island.
Six Stages: From Xcode Scheme to App Store Availability
The table below is the skeleton of this article. The runbook in a later section orders jobs in this sequence so you do not upload a build and then discover the version number was never bumped.
| Stage | Primary actions | Typical duration | Automation level |
|---|---|---|---|
| ① Preflight | Version numbers, dependency locks, unit tests, privacy manifest draft | 5–15 min | High (Linux or Mac) |
| ② Signing prep | Certificates, provisioning profiles, keychain unlock, ASC API Key | One-time + rotation | Medium (secret injection) |
| ③ Archive & Export | xcodebuild archive, export IPA | 10–40 min | High (dedicated Mac) |
| ④ TestFlight | Upload, processing, internal group assignment | 15–60 min | High |
| ⑤ Submission | Metadata, screenshots, export compliance, submit for review | 30 min human + wait | Medium (approval gate) |
| ⑥ Release | Approval, phased release / full rollout | 24–48 h review | Medium (status polling) |
The engineering watershed sits between stages ④ and ⑤: TestFlight should be as automated as possible (every main merge or nightly can produce a build); submission should add a human or PM approval gate so half-finished copy does not enter the review queue. Stage ③ must run on a version-pinned Mac — the primary use case for Cloud Mac as an iOS CI node, not for day-to-day Swift editing.
Signing and Certificates: CI's Most Expensive Source of Non-Determinism
Archive works locally in Xcode; CI fails with errSecInternalComponent or No profiles for ... — almost always identity and environment are not pinned, not a broken project.
Three credential types — do not mix them in one secret
| Credential | Purpose | CI storage recommendation |
|---|---|---|
| Distribution certificate (p12) | codesign installable builds | Encrypted secret; build-island keychain or per-job temp keychain |
| Provisioning profile | Bind bundle ID, capabilities, devices | Download by UUID to job-private directory; never global overwrite |
| ASC API Key (.p8) | Upload, build status, deliver submission | GitHub encrypted secret; no keychain import needed |
When multiple jobs sign in parallel, keychain contention and provisioning-profile directory races are long-tail failure sources. Per-runner isolated users and per-job temporary keychains are the usual fix — details in codesign concurrency and keychain isolation FAQ.
Minimum CI keychain script (Xcode 16 / macOS 14 assumed)
# 在 self-hosted Mac runner 上,每个 release job 开头
KEYCHAIN=$RUNNER_TEMP/build.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security list-keychains -s "$KEYCHAIN" login.keychain-db
security import dist.p12 -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
Build Artifacts: Archive → Export → TestFlight Upload
The difference between release and debug builds is not “one more optimization flag.” It is that signing configuration, export options, and version metadata must align with the app record already registered in App Store Connect.
xcodebuild archive (command-line equivalent of Xcode Product → Archive)
xcodebuild -scheme MyApp -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
archive \
DEVELOPMENT_TEAM=XXXXXXXXXX \
-allowProvisioningUpdates
Export IPA and upload
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ExportOptions.plist
# ExportOptions.plist 中 method 应为 app-store
# 上传可用 xcrun altool(旧)或 fastlane pilot / deliver
Engineering essentials:
- CFBundleVersion (build number) must increment on every upload, bound to git tag or CI run number — document this in the runbook
- Retain .xcarchive and dSYM in GitHub Artifacts or S3; rejections and production crashes both depend on symbol files
- DerivedData cache keys include Xcode major version to avoid dirty-cache compiles that pass build but fail signing after an Xcode upgrade
Build-island disk and cache strategy directly affects stage ③ duration stability — see Self-hosted runner cache and disk FAQ.
Submission and Review: What You Can Automate Beyond Human Review
Apple review is still human-driven, but most repetitive work before and after review can be pipelined:
Good candidates for automation
- Poll build processing status (
processing→valid) - Verify saved answers for export compliance, content rights, and advertising identifier still apply
- fastlane
deliveror App Store Connect API to submit metadata and screenshots (prerequisite: screenshot sources in git) - Post-approval Slack notification or phased-release percentage trigger
Keep a human gate for
- First launch in a region, or sensitive categories: payments, health, children
- Major-version marketing copy, preview video, and final store screenshots
- The “Submit for Review” action itself — use GitHub Environment
required_reviewersor manualworkflow_dispatch
On rejection (Rejected) or metadata rejection (Metadata Rejected), the runbook should say: fix in git, bump build, rerun stages ③→④ — do not only edit Connect in the browser without shipping a new binary (unless it is purely a metadata issue). Common causes — missing Privacy Manifest, demo account required for login, incomplete third-party SDK declarations — should be caught in stage ① preflight by scanning PrivacyInfo.xcprivacy and key Info.plist entries.
CI/CD Runbook: GitHub Actions + fastlane Minimum Closed Loop
Below is the minimum workflow for “merge main → TestFlight visible.” The submission job is off by default; trigger it with a release/* tag or manual dispatch.
Fastfile snippet (TestFlight upload)
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
lane :beta do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
build_app(
scheme: "MyApp",
export_method: "app-store",
output_directory: "./build"
)
upload_to_testflight(
skip_waiting_for_build_processing: false,
api_key_path: "asc_api_key.json"
)
end
lane :release do
upload_to_app_store(
submit_for_review: true,
automatic_release: false,
precheck_include_in_app_purchases: false
)
end
end
GitHub Actions: preflight + release (self-hosted Mac)
name: App Store Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
submit_review:
description: 'Submit to App Review'
type: boolean
default: false
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: swiftlint --strict
- run: swift test --parallel
- name: Check Privacy Manifest exists
run: test -f MyApp/PrivacyInfo.xcprivacy
beta:
needs: preflight
runs-on: [self-hosted, macos, ios-release]
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/ci/DerivedData
key: dd-xcode16-${{ hashFiles('**/Package.resolved') }}
- name: TestFlight upload
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
run: bundle exec fastlane beta
submit-review:
if: github.event.inputs.submit_review == true
needs: beta
runs-on: ubuntu-latest
environment: app-store-review
steps:
- uses: actions/checkout@v4
- run: bundle exec fastlane release
Keep GitHub Actions as the orchestration layer and pin execution on a self-hosted Mac build island — same philosophy as the hybrid pipeline playbook: Linux for fast checks, Mac only for archive and upload. For temporary release-week capacity, see that article’s burst-runner section.
Common Anti-Patterns: When Not to Fully Automate Submission
Engineering discipline does not mean handing every button to a bot. Forcing full-auto submit_for_review often backfires in these cases:
- Store copy still lives in a Google Doc — Connect and the doc diverge → Metadata Rejected
- Backend API is tightly coupled to app version — confirm server-side gray release is ready before submitting
- First integration of ATT, payments, or HealthKit — review Q&A and demo video often need product sign-off in person
- Signing on shared hosted macOS runners — environment drift is harder to debug than queue time; release paths belong on dedicated Macs
The safer default: fully automate through TestFlight, semi-automate through App Review. Internal testers validate on TestFlight; the PM approves in the Environment; then run the submit-review job.
FAQ
Do you need a local Mac to submit to the App Store?
Not necessarily. Day-to-day coding can happen on Windows or Linux, but archive, codesign, and TestFlight upload must run on macOS. Teams without a local Mac can complete the full submission path over SSH or VNC on a Cloud Mac or self-hosted build island.
Can TestFlight and App Store review live in the same CI pipeline?
Yes — share the build and signing stages, but split into two jobs: upload_to_testflight first for internal testing; the submission job should require a manual or approval gate and separately validate metadata, screenshots, and the privacy manifest.
How do you choose between fastlane and native xcodebuild?
xcodebuild handles compile and archive; fastlane wraps signing, upload, metadata, and deliver submission. Small teams can let fastlane own the full path; large repos often call xcodebuild explicitly in CI and keep fastlane on the release layer only.
ASC API Key or p12 certificate — which is better for CI?
Prefer an ASC API Key (.p8) for binary upload and review-status polling — no keychain import required. codesign still needs a Distribution certificate and provisioning profile. The common split: API Key for Connect, p12 for signing, stored as separate secrets.
How do you quickly ship a new build after rejection?
Keep the git tag and dSYM from the rejected build; after the fix, bump the build number and run the same release pipeline upload_to_testflight, verify on TestFlight, then submit_for_review again. Build numbers must monotonically increase and metadata version must stay consistent.
Do Export Compliance and the privacy manifest have to be filled in manually?
The first time, fill them in App Store Connect and save a template; CI can write approved answers via fastlane deliver or the App Store Connect API. If the app only uses HTTPS for encryption, most teams pick the standard exemption and record it in Info.plist.
How long does it take from archive to approval?
Build: 10–40 minutes depending on project size; upload and processing: 15–60 minutes; review: 24–48 hours is typical, longer around holidays. The engineering goal is to compress and predict everything except waiting on Apple.
Is a remote Cloud Mac a good fit as a release node?
Yes. A dedicated physical Mac with a pinned Xcode version, persistent keychain, and DerivedData is more reliable for signing and release deadlines than shared hosted runners; use VNC for first-time TCC setup, then headless SSH for pipeline runs.
Conclusion
App Store submission from a CI/CD perspective is a six-stage release pipeline, not a one-off Archive in Xcode. Split signing, build, TestFlight, submission, and release into governed stages so failures map to a specific step instead of “submission failed again.”
- Automate preflight and TestFlight; add an approval gate before submission
- Store signing credentials in three separate buckets; isolate keychain and profiles for concurrent builds
- Bind build number to git tag; retain dSYM and archive artifacts
- Run stages ③④ on a dedicated Mac build island; stage ① on Linux
- After rejection, bump build through the same pipeline — do not only edit the Connect web UI
Next step: add a TestFlight-only workflow to your repo, run it end-to-end, then add an environment: app-store-review submission job. Validate the build node on a daily Cloud Mac for a week before deciding on a permanent Mac mini.
Related reads
- GitHub Actions Falling Short? New Playbooks for iOS Pipelines (2026)
- Enterprise Mac CI: codesign concurrency and keychain isolation
- iOS CI slow and Actions queued? Cloud Mac primer
- Self-hosted runner cache and disk FAQ
- Windows/Linux primary with remote Mac build island
- Enterprise Mac CI resource pool planning