Key takeaways
- Archive ≠ Build: Archive always runs Release + Any iOS Device and produces a
.xcarchive. A passing local Debug build does not mean Archive will pass. - CI stalls cluster in four segments: Scheme/config, compile cache, signing & keychain, and exportArchive — locate the segment in logs before you fix anything.
xcodebuild archiveis equivalent to the menu action, but CI must explicitly pass-scheme,-configuration Release, and-destination 'generic/platform=iOS'.- "Stuck with no output" usually means waiting on keychain authorization, waiting on provisioning profile download, or memory swap — not a dead xcodebuild process.
- Self-hosted cloud Mac runners with persistent DerivedData and a dedicated signing keychain are often an order of magnitude more stable than cold Archive on ephemeral hosted runners.
1. Archive vs Build: what actually changes
Many teams summarize CI failures as "xcodebuild broke." In reality, Cmd+B Build and Product → Archive are two different paths:
| Dimension | Build (⌘B) | Archive |
|---|---|---|
| Typical Configuration | Debug (local dev) | Release (App Store / TestFlight) |
| Destination | Simulator or connected device | Any iOS Device (arm64) |
| Output | .app in DerivedData |
.xcarchive + exportable IPA |
| Signing | Development cert may compile some targets | Distribution cert + provisioning profile required |
| Optimization | Low — fast compile | Full optimization — significantly longer compile |
So "3 minutes locally" and "20 minutes in CI then failure" are not contradictory: you may be running Debug + simulator locally while CI runs Release + device architecture + full signing chain. Align what you compare before you optimize. For compile-time gaps specifically, see why xcodebuild is 2–3× slower in CI than local.
2. From menu to CLI: the Product → Archive equivalent
Clicking Product → Archive in the Xcode GUI roughly maps to:
- Select a Shared Scheme with Release configuration;
- Set Destination to Any iOS Device (not a simulator);
- Run
xcodebuild archiveand write output toARCHIVE_PATH; - (Optional) Distribute App in Organizer → equivalent to
xcodebuild -exportArchive.
A minimal Archive command for CI (adjust paths for your project):
xcodebuild archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM=XXXXXXXXXX \
| tee archive.log
Three parameters teams often miss:
-destination 'generic/platform=iOS': without it, xcodebuild may default to a simulator — Archive fails or behaves unpredictably.-archivePath: must be writable; permission issues on hosted runner temp dirs cause "compiled but failed to write archive."- Scheme must be Shared and committed to Git: an unshared local Scheme does not exist after CI checkout →
scheme not found.
Official references: Apple — Building your app, Distributing your app.
3. Four-stage pipeline at a glance
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. Resolve │ → │ 2. Archive │ → │ 3. Sign │ → │ 4. Export │
│ SPM/Pods │ │ Release full │ │ Certs/profiles│ │ IPA / Upload │
│ DerivedData │ │ → .xcarchive │ │ Keychain auth │ │ TestFlight │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
pod install xcodebuild codesign exportArchive
often 5–15 min archive 8–25 min silent hang zone plist errors common
When CI "gets stuck here," ask first: which stage does the last log line belong to? Stopping at CompileSwift is compile; at CodeSign is signing; at exportArchive is export config. Mixing stages leads to endless cache clears.
4. Stage 1: Resolve & Compile (why CI is slower)
Dependency resolution and compilation before Archive are usually "warm" locally and "cold" in CI:
- CocoaPods / SPM: CI runs
pod installfrom scratch every job — logs full ofInstalling …and 10 minutes gone before Archive starts. See Flutter iOS CI cache propagation. - Cold DerivedData: full Release compile with no cache means every Pod native module recompiles. Pinning
DERIVED_DATA_PATHon an SSD-backed self-hosted runner often cuts the second Archive in half. - Memory pressure: Archive peaks higher than Debug; a 16 GB machine running parallel jobs swaps — CPU stays low but nothing moves. See runner memory and swap governance.
Quick check: if logs show heavy CompileC / SwiftCompile for third-party pods you did not touch, fix cache first — not signing.
5. Stage 2: Archive (generating .xcarchive)
After a successful xcodebuild archive, you should see this structure at -archivePath:
MyApp.xcarchive/
Info.plist
Products/Applications/MyApp.app
dSYMs/...
Common failure modes:
- Scheme Archive action has no target checked: a different Scheme builds locally, but the CI Scheme archives nothing.
- Multi-target / extension misconfiguration: main app passes, Notification Service Extension signing fails, whole Archive fails.
- Build number / version conflict: CI does not bump
CFBundleVersion; upload fails later but gets blamed on "slow Archive."
Validate immediately: run ls -la "$ARCHIVE_PATH" and plutil -p "$ARCHIVE_PATH/Info.plist" to confirm ApplicationProperties exists — do not wait for export failure to discover an incomplete archive.
6. Stage 3: Code Sign (the #1 CI pitfall)
Local Archive may prompt for keychain access; you click "Always Allow" and the pipeline moves on. Unattended CI has no such step, so:
errSecInternalComponent,User interaction is not allowed: private key lives in the login keychain; CI user has no GUI authorization.- Expired profile / Bundle ID mismatch: log stops at
CodeSignwithProvisioning profile … doesn't match. - Enabling
-allowProvisioningUpdatesby mistake: requires Apple ID interactive login; headless environments wait forever. - Keychain not unlocked: job is missing
security unlock-keychain/set-key-partition-listat the start.
Recommended on cloud Mac / self-hosted runners: create a dedicated keychain file (not the login keychain), import the distribution certificate + private key, unlock and set it as default at job start. Full chain: cloud Mac iOS CI: codesign and keychain boundaries.
# Job 开头示意(口令走 CI Secret)
KEYCHAIN=$RUNNER_TEMP/ci-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import dist.p12 -k "$KEYCHAIN" -P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -s "$KEYCHAIN" login.keychain-db
If you use fastlane match or Xcode Cloud for credentials, the principle is unchanged: the CI process must access the private key without interaction — not merely "the cert is on the machine somewhere." See fastlane match.
7. Stage 4: Export IPA (exportArchive)
Archive only produces .xcarchive; TestFlight upload still needs Export. Distribute App in the menu maps to:
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ExportOptions.plist
The method key in ExportOptions.plist sets export type (app-store, ad-hoc, development, etc.). Common CI issues:
methodmismatches profile type: development profile withapp-storemethod.- Missing
teamID/signingCertificate: Manual signing errors surface at export, mistaken for slow Archive. - Upload bundled with export:
altool/ Transporter network latency counted as "Archive timeout."
Split archive, export, and upload into three timed steps. Upload via xcrun altool --upload-app or xcrun notarytool (macOS distribution) — decoupled from Archive.
8. Seven root causes: symptom / stage / action
| Symptom / log keyword | Stage | First action |
|---|---|---|
| Long silence, low CPU | Sign | Check keychain unlock; disable GUI-dependent provisioning auto-update |
scheme 'Foo' not found |
Pre-flight | Mark Scheme Shared and commit; verify with -list in CI |
No profiles for … were found |
Sign | Verify Bundle ID; confirm profiles in repo or match fetch succeeded |
Heavy CompileC on untouched Pods |
Compile | Persist DerivedData; avoid clean inside the job |
pod install > 8 minutes |
Resolve | Cache ios/Pods; use pod install --deployment |
| Job killed by platform (no stack trace) | Global | Raise timeout; check swap / disk full (disk governance) |
exportArchive failed + plist errors |
Export | Run export in isolation; verify ExportOptions.plist method |
On call: scan logs from the bottom for the first error:, then match the table — faster than a blanket flutter clean.
9. Troubleshooting runbook: segment first, fix second
- Reproduce Release Archive locally: Product → Scheme → Edit Scheme → Archive uses Release; Destination is Any iOS Device. If local fails, do not burn CI minutes.
- Add four-segment timing in CI:
pod install→archive→exportArchive→upload; write each duration to the step summary. - Keep full logs: avoid overly quiet log prettifiers; signing failures need raw
CodeSignlines. - Pin Xcode version: lock path with
xcode-selectorsudo xcode-select -s; align with.xcode-versionor workflowmacos-15. - 48-hour validation: Archive the same commit twice; the second run should be noticeably faster (cache hit). If not, check memory and disk.
For release sprints or temporary build capacity, consider short-term remote Mac for release sprints instead of fighting Archive timeouts on ephemeral hosted runners.
10. GitHub Actions template
jobs:
archive-ios:
runs-on: [self-hosted, macOS, ios] # 或 macos-14 托管机
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Install pods
run: pod install --deployment
working-directory: ios
- name: Archive
run: |
set -o pipefail
xcodebuild archive \
-workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
| tee archive.log
env:
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ios/ExportOptions.plist
For self-hosted runner label routing, launchd keepalive, and CI user isolation, see Mac mini self-hosted runner setup and Flutter + GitHub Actions architecture overview.
11. Conclusion
Product → Archive is not "build a bit longer" — it is the full chain of Release compile + archive + distribution signing + optional export. When CI stalls here, Xcode is rarely broken. More often, teams apply Debug habits to Release, treat signing as a local-GUI-only concern, or merge export and archive into one opaque step.
Actionable next steps: pass local Archive first → add four-segment CI timing → dedicated keychain + persistent cache → then add machines or upgrade specs. For Apple platform teams, a self-hosted cloud Mac runner often saves more total time than endlessly tuning hosted-runner cache — because Archive needs a stable hot path, not a one-shot fastest chip.
Run the full Archive pipeline on cloud Mac
Dedicated M4 bare metal for xcodebuild archive, a dedicated signing keychain, and persistent DerivedData. Daily rent to benchmark Archive time and stability; upgrade to monthly when you want a permanent runner.