local-archive-export-upload
Use when a build must reach TestFlight without Xcode Cloud (quota, outage, CI not wired yet), or when a local `xcodebuild archive` / `-exportArchive -exportOptionsPlist` / `xcrun altool --upload-packa
SKILL.mdapple-dev-skills/skills/local-archive-export-upload/SKILL.md
GitHubLocal Archive, Export, Upload
The manual escape hatch for getting a build to TestFlight when Xcode Cloud's
Main CI workflow (→ xcode-cloud-single-track-ci) can't run — quota
exhausted, an outage, or CI not wired up yet. Same three Apple CLIs Xcode
Cloud uses under the hood — xcodebuild archive, -exportArchive, upload —
driven by hand instead of Apple's managed runner.
When to invoke
- Xcode Cloud can't run but a build must reach internal TestFlight today.
- Writing or debugging a local archive/export/upload script or one-off command.
- Diagnosing an
ExportOptions.plist/ signing / build-number failure. - Asked "how do I ship a build without Xcode Cloud" or "what does
-exportOptionsPlistneed".
Scope
Owns: the local three-step pipeline (archive → export → upload) and its signing/export-key semantics. Does NOT own:
- ASC operations once the build exists (TestFlight groups, what's-new,
submission) →
asc-api-automation. - What has to be true for the build to pass review →
app-store-review-rejections. .p8/ API-key storage →build-time-secret-injection+apple-public-repo-security.- Restoring Xcode Cloud once quota returns — this is a temporary substitute, not a parallel permanent CI track.
Pipeline
| Step | Command | Notes |
|---|---|---|
| 1. Archive | xcodebuild archive -scheme <Scheme> -destination 'generic/platform=iOS' -archivePath build/App.xcarchive |
-destination picks the platform; a generic/platform=... destination (not a specific simulator/device) is what produces an archivable, distributable build. |
| 2. Export | xcodebuild -exportArchive -archivePath build/App.xcarchive -exportPath build/export -exportOptionsPlist ExportOptions.plist |
Requires -archivePath + -exportOptionsPlist; -exportPath only needed when the plist's destination is export (see below). |
| 3. Upload | xcrun altool --upload-package build/export/App.ipa --api-key <keyID> --api-issuer <issuerID> --wait |
--api-key / --api-issuer are the spelling altool --help's own example uses; add --wait to block until Apple finishes processing instead of polling separately. Or fold into step 2 — see "One-step vs two-step" below. |
Signing: two non-interactive paths
xcodebuild -help documents two credential sources for -allowProvisioningUpdates:
an account added in Xcode's Accounts settings, or an App Store Connect authentication
key passed via the -authenticationKey* trio. Both paths below therefore pass the flag
(needed on a script/cron path, not just CI):
| Path | Flags | Use when |
|---|---|---|
| Automatic signing via your Xcode account | -allowProvisioningUpdates (+ -allowProvisioningDeviceRegistration if a new device needs registering) |
Run on your own dev Mac with an account added in Xcode's Accounts settings; xcodebuild creates/updates profiles and certs as needed. |
| API-key signing, no Apple ID session | -allowProvisioningUpdates -authenticationKeyPath <p8> -authenticationKeyID <id> -authenticationKeyIssuerID <issuer> |
Unattended/scripted runs — reuse the same ASC API key from asc-api-automation / build-time-secret-injection, no Xcode account needed. |
ExportOptions.plist: the common shape
The full key set is printed by xcodebuild -help under "Available keys for
-exportOptionsPlist" (18 keys on Xcode 26.5, covering thinning, manifests, on-demand
resources). For a plain "ship to TestFlight" export, six keys are load-bearing
(cross-checked against the key list in xcodebuild -help):
<key>destination</key> <string>export</string> <!-- or "upload" -->
<key>method</key> <string>app-store-connect</string>
<key>manageAppVersionAndBuildNumber</key> <false/>
<key>signingStyle</key> <string>automatic</string>
<key>teamID</key> <string>ABCDE12345</string>
<key>uploadSymbols</key> <true/>
method: app-store-connect— the current name;app-storestill works but Xcode 26.5'sxcodebuild -helpalready marks it "deprecated: use app-store-connect" (the exact Xcode version the rename landed in is unconfirmed).manageAppVersionAndBuildNumber: false— defaults toYES(Xcode bumps the build number for you on export); setfalsewhen your own tooling controlsCFBundleVersion— see build-number coordination below.teamID— omit to inherit the archive's signing team; set explicitly when a machine/CI identity could resolve ambiguously.
One-step vs two-step upload — pick two-step on purpose
destination |
Artifact | Gate before upload | Use when |
|---|---|---|---|
export |
.ipa/.pkg written to -exportPath |
Your own --dry-run/--i-am-sure-style flag on a separate upload command |
Default |
upload |
None — xcodebuild uploads directly to Apple, no local artifact, no separate altool call |
None — the network push happens the instant -exportArchive runs |
Deliberate one-shot only |
Prefer destination: export + a separate, deliberately-gated upload command
so archive/export stay safe to run freely and only upload needs a human's
explicit go-ahead.
Upload tool
xcrun altool --help (Xcode 26.5) lists --upload-package <file> first among
App-Upload commands and uses it in its own canonical example; the older
--upload-app -f <file> still works but isn't the tool's own example anymore.
altool searches fixed directories for AuthKey_<keyID>.p8 (./private_keys,
~/private_keys, ~/.private_keys, ~/.appstoreconnect/private_keys, or
$API_PRIVATE_KEYS_DIR) — stage a per-run symlink into one of these if your
key lives in a gitignored secrets/ dir, rather than moving the real file.
notarytool is not this path — it handles Developer-ID notarization
(outside-the-App-Store distribution), unrelated to TestFlight/App Store
uploads. The ASC API's Build Uploads path also works (see
references/official-docs.md), but this skill still defaults to altool.
For the other upload paths (Xcode Cloud, Xcode Organizer, Transporter), read
references/official-docs.md.
Build-number coordination with Xcode Cloud
Per xcode-cloud-single-track-ci, Xcode Cloud assigns its own sequential
CI_BUILD_NUMBER per build, independent of whatever's in the repo. A local
build needs a CFBundleVersion that will never collide with — or fall below —
that counter:
- Use a high-resolution timestamp (
YYYYMMDDHHmm) rather than a small hand-incremented integer — it can't collide with Xcode Cloud's small sequential counter, and TestFlight rejects a duplicateCFBundleVersionfor the sameCFBundleShortVersionString. - macOS additionally requires build numbers to strictly increase across
versions — if a local timestamp-based number ends up higher than Xcode
Cloud's next assigned number, fix it once on the ASC side (Xcode Cloud →
Settings → Build Number → Edit), the same fix
xcode-cloud-single-track-cidocuments for its "existing Mac app" exception. - Set
manageAppVersionAndBuildNumber: false(above) so Xcode's export-time bump doesn't fight your chosen number.
Rationale
Every flag and key here is verifiable straight from Apple's own CLI (man xcodebuild, xcodebuild -help, xcrun altool --help) — no fastlane, no
third-party packaging tool, consistent with this catalog's no-Homebrew /
Apple-native-first baseline (asc-api-automation makes the same call for ASC
REST automation). Provenance for each claim (doc-verified vs practice-observed)
is in references/evidence.md.
Deviation considerations
- Xcode Cloud is back / never was the bottleneck — retire the local path;
don't run it as a second permanent track (
xcode-cloud-single-track-ci's single-track rule). - Frequent local ships — the "temporary substitute" framing breaks down; invest in restoring/expanding Xcode Cloud capacity instead of hardening this manual path further.
Common Mistakes
- Pre-
touchingExportOptions.plistbefore writing it withPlistBuddy. - Using
destination: uploadas the default — loses the archive-only dry run. - Leaving
method: app-store(deprecated form) in an old plist. - Hand-incrementing a small
CFBundleVersionlocally — collides with Xcode Cloud's own counter. - Reaching for
xcrun notarytoolfor a TestFlight upload — it's for Developer-ID notarization, a different distribution path entirely. - Forgetting
ITSAppUsesNonExemptEncryptionon a new app — the build is marked Missing Compliance until the export-compliance questions are answered in ASC or viaPATCH /v1/builds/{id}(usesNonExemptEncryption).
Review Checklist
- Archive uses a
generic/platform=...destination, not a specific simulator/device. - Export uses
method: app-store-connect(not the deprecatedapp-store). - Signing passes
-allowProvisioningUpdateson every path, with a deliberate credential source: the Xcode Accounts login (your dev Mac) or the-authenticationKeyPathtrio (unattended). -
destination: export+ a separate gated upload step, unless a one-stepuploadis a deliberate choice. -
CFBundleVersionsource can't collide with Xcode Cloud'sCI_BUILD_NUMBER. -
ITSAppUsesNonExemptEncryptionset in Info.plist (or export-compliance is otherwise answered). -
.p8staged only via a per-run symlink into an altool-searched directory, never committed or left behind after the run.
Related skills
xcode-cloud-single-track-ci— the primary CI path this substitutes for; restore it once quota/outage clears.asc-api-automation— TestFlight/App Store operations once the build lands in ASC.app-store-review-rejections— its export-compliance row (an ASC upload step, not a Guideline number) and what has to be true for review to pass.apple-skills:guide-macos-spm-packaging(aggregated external) — Developer-ID signing /notarytoolnotarization for outside-the-App-Store distribution.build-time-secret-injection/apple-public-repo-security— where the.p8and its issuer/key IDs live and how leaks are prevented.storekit2-iap-defaults— this pipeline is how a build carrying that skill's IAP code reaches TestFlight.- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.