Takes one commit of an Expo app to a processed TestFlight build and proves, on the exported .ipa itself, that testers and users get exactly what was meant: the right identity, version and build number, no test-only code, no StoreKit test files, no debug entitlement, no placeholder value, every usage string in every language. Two lanes share one gate: EAS Build + EAS Submit (the default; eas-build-and-submit owns eas.json and credentials) and the local lane, npm run release:ios -- --variant preview|production, which needs no Expo account and signs with the team's App Store Connect API key through Apple's own command-line tools. Everything that needs a person is asked for, never worked around. Verified on 2026-10-14 with Expo SDK 57, Xcode 26.6 (altool 26.40) and the App Store Connect API v1; the Android twin is android-release-play.
Rules that must hold
- Nothing leaves the machine without the developer's word in this session. Uploading (
release:ios,eas submit), pushing tags and submitting for review are outward-facing and cannot be undone: ask first. "Ship" creates the release tag; only "submit" submits. Why: a wrong build in front of testers or reviewers cannot be taken back. - Never open, print, copy, move, commit or log the
.p8key, a JWT or anAuthorizationheader. Tools receive onlyASC_KEY_ID,ASC_ISSUER_IDandAPPLE_TEAM_ID; onlyscripts/asc/asc-credentials.tsreads the key, into memory; xcodebuild gets its path and altool finds it itself. On EAS the credentials live in EAS (eas credentials) and the agent never handles key files. Why: a leaked team key can publish apps in the team's name. - Sign only with the team API key: automatic signing,
-allowProvisioningUpdatesplus the three-authenticationKey*flags on archive and export, cloud-managed distribution certificates. No.p12, no hand-made profiles, no switching signing method to get past an error. Why: one signing method that needs no Keychain exports is the only one an agent can run safely. - One counter holds the build number, and it never goes back. Local lane: the single line
const IOS_BUILD_NUMBER = <n>;inapp.config.ts, bumped and committed (chore(release): build <n>) before the archive; EAS: remote versioning (appVersionSource: "remote"), never both. A failed upload still spends its number; the version must rise above the last approved one. Why: App Store Connect refuses a reused build number (ITMS-90189) and a lower version (ITMS-90062). - Build only through the release command, from a clean tree on the release branch, after the full verify gate, from a clean prebuild (for production builds also green E2E flows and reviewed screenshots of the same commit when the repo has them). Why: an upload must be one known commit whose gates were green; a stale
ios/builds another identity. - Preview builds are internal-only; production builds use the store export.
export-options-preview.plistsetstestFlightInternalTestingOnly: true, and a preview build carries<appId>.preview, so it needs its own app record. Why: an internal build can never reach external testers or the App Store by mistake. - The store-artifact gate passes on the exported
.ipabefore--validate-apporeas submit, on every lane.check-store-artifact.mjs(andstore-gate.tsinsiderelease:ios) read the signed export. Why: it is the only proof that the debug menu, the StoreKit harness, test ads and dev-client keys are absent from what users receive. - An upload is done only when processing reaches
VALID.altool --upload-package --wait(andeas submit) return while Apple is stillPROCESSING; the local lane polls the REST API every 60 s for up to an hour. Why: anINVALIDbuild never reaches TestFlight, and announcing it early sends testers looking for nothing. - Stop and ask, never retry, on
errSecInternalComponent, the Xcode licence, any agreement error, a missing app record, HTTP 401/403,INVALID/FAILEDprocessing, or a[ship-placeholder]line. Send one message: the step, the exact error line, the one action. Why: each needs a person or a credential, and retries can lock the account or burn build numbers. - Tag every processed upload, annotated:
v<version>+ios.<n>; tagv<version>on the developer's "ship". When multi-package, prefix the app folder (mobile/v1.4.0+ios.14). Push tags only with the developer's word. Why: each TestFlight build traces back to its commit, and the tag check (git-commits-and-reporting) can order builds by tag date. - Select the pinned Xcode through
DEVELOPER_DIR, neverxcode-selectorsudo; the gate checksDTXcode. Why: a Mac often holds several Xcodes, and a global switch needs a password and changes every build on the machine. - Nothing unfinished ships, in any variant: the contract's
unfinishedMarkersfiles stop the preflight and both checkers, itsforbiddenBundleStringsstop the gate, and the test-only sentinels are absent from production bundles (present in preview ones); a test-only gate without a known sentinel stopsrelease:iosbefore the bump. Why: testers and users would open a stand-in screen or a debug menu. - No placeholder value ships. The bundle id comes from
contracts/ios-release.json(appId;com.example.*always fails); a production build carries noexample.comlink and, with live ads, no sample or template AdMob id; no build carries a template AdMob app id or an unfilled__TOKEN__; each fails the gate (as a[ship-placeholder]stop, orad-app-idfor a testing build's app id), and no option turns it into a pass. Why: a placeholder is often format-valid, so only a value check stops it, and the real values come from the developer. - Every build carries every usage description, in Info.plist and in every language the app is localized into (the contract's
usageStrings, the tracking text whenever the app can ask for tracking, and everyNS*UsageDescriptionit carries). Why: without the Info.plist key iOS terminates an app that requests the protected resource and App Store Connect rejects the upload (ITMS-90683); without a language's text, users of that language see the prompt in the development language. - Human judgement steps never block the pipeline. A translation review, a play-test or a look at a new screen go into the report under "Human steps (not blocking)"; only "ship", "submit" and missing real values gate what happens next. Why: the release stays a machine-checkable process, and the person still sees everything they should judge.
- A keyless rehearsal is never release evidence.
check-store-artifact.mjs --rehearsalexists only for an archive built withCODE_SIGNING_ALLOWED=NO: it printsREHEARSAL: not a release gate, reports only the signing rule asSKIPand keeps every other rule strict;release:iosnever passes it. Why: a rehearsal proves the build, never what users receive.
Workflow
-
Before the first release in a repo: read references/signing-and-keys.md and references/human-steps.md. Ask the developer for every missing once-only step (developer program and agreements, the team API key with the Admin role, the Xcode licence) and the per-app steps (the app record for each uploaded variant with its internal tester group) in one message.
-
Pick the lane. EAS (default): read references/eas-lane-and-android.md;
eas-build-and-submitsets up profiles and credentials, and this skill adds the contract and the gate. Local lane: install the templates (line 1 of each names its repo path; fill__APP_ID__):Template Goes to templates/scripts/release/*(CLI, steps, pure modules with tests, both export options files)scripts/release/templates/scripts/asc/*(JWT, credentials, REST client, app lookup, What to Test, with tests)scripts/asc/templates/contracts/ios-release.json+.schema.jsoncontracts/(both lanes)templates/scripts/ios/toolchain.ts,templates/scripts/clock/system-clock.ts,templates/src/config/app-variant.ts(+ tests, canonical)their paths, when missing Add the build-number lines to
app.config.ts(examples/app-config-version.ts) and"release:ios": "node scripts/release/release-ios.ts"topackage.json, then runnpm run typecheck,npx eslint scripts --max-warnings 0andnpx jest scripts --ci --maxWorkers=2. -
Check the setup:
node ${CLAUDE_SKILL_DIR}/scripts/check-release-setup.mjs .. Fix everyFAILand rerun untilRESULT: PASS; references/contract-and-checks.md explains every rule and contract key. An EAS-only repo printsNOT APPLICABLE(no local lane). -
Prepare the commit:
npm run verifygreen, the version raised above the last release tag for a production build, E2E and screenshots of this commit when the repo has them. Without the team key yet, rehearse instead (references/release-pipeline.md, "Rehearsal without the key"): an unsigned archive and the gate with--rehearsal; it uploads nothing. -
Release, with the developer's go-ahead. Local:
npm run release:ios -- --variant production(orpreview; add--notes "..."for what testers should check,--known-issue "..."). EAS, in the ordereas-build-and-submit's release preflight prints:eas build --profile production --platform ios,check-eas-buildon its record, download the.ipatobuild/ios-release/export/app.ipa, run this gate on it, theneas submit --profile production --platform ios --path build/ios-release/export/app.ipa --non-interactive --what-to-test '<text>'. The pipeline reference says what each of the 12 steps does and how to run one by hand. -
Prove the build:
node ${CLAUDE_SKILL_DIR}/scripts/check-store-artifact.mjs . --ipa <ipa> --variant <v> --version <x.y.z> --build <n>must printRESULT: PASSwithout--rehearsal, and the run must have printedprocessed (VALID)(EAS: the build shows in the TestFlight tab). -
On any failure, read the message the run printed (it already matched references/failure-playbook.md). A stop goes to the developer as printed, without the
(log: ...)andResume:lines, which are for you. Then rerun as theResume:line says: the same command,--resume build(same number, nothing uploaded) or--resume processing(the upload is done). REST errors: references/app-store-connect-api.md. -
Report and wait: one message shaped like examples/release-messages.md: app, version, build, variant, gate result, What to Test, the one question ("ship" or "don't ship") and "Human steps (not blocking)". On "ship":
git tag -a v<version> -m "..."; submission only on "submit". Thengit-commits-and-reportingchecks the tags and the report.
Definition of done
- The developer agreed to this upload in this session; nothing was pushed or submitted without their word.
- The release finished: build number bumped and committed (local) or counted remotely (EAS), gate passed, validated, uploaded, processing
VALID, What to Test set, annotated upload tag created. - No key material, token or signing file appears in the repo, logs or report; the key was never opened.
- The developer has one report with the build, the evidence, the one question and the human steps that do not block.
- No
REHEARSALresult is cited as release evidence; a hand-over before the real values exist is a slice report with the rehearsal under "Not tested or not verified". -
node ${CLAUDE_SKILL_DIR}/scripts/check-store-artifact.mjs . --ipa <ipa> --variant <v> --version <x.y.z> --build <n>printsRESULT: PASS(a ship gate: no SKIP line; the only SKIP,[get-task-allow] REHEARSAL: ..., belongs to a--rehearsalrun). -
node ${CLAUDE_SKILL_DIR}/scripts/check-release-setup.mjs .printsRESULT: PASS(orNOT APPLICABLEon an EAS-only repo)
Anti-patterns
- Retrying after
errSecInternalComponent, a 401 or an agreement error. Send the stop message and wait. cat ~/.appstoreconnect/private_keys/AuthKey_*.p8"to check the key". Check path and mode withstat; the file is never opened.- Switching to manual signing or importing a
.p12when automatic signing fails. Follow the playbook row (missing flags,appleTeamIdbefore prebuild, key role) or stop. - Reusing build 14 after a failed upload, or lowering
IOS_BUILD_NUMBER. A number is spent once an upload was attempted; rerun without--resumeso it bumps again. - A plain rerun after the upload "because processing timed out". That uploads a second build; use
--resume processing. - Announcing "uploaded" after altool or
eas submitreturns. Wait forVALID;INVALIDneeds Apple's email and a new build. - Skipping the gate "because the simulator production build was clean", or because EAS built it. The gate checks the signed export users receive.
- Uploading a preview build with the production export options, or giving preview and production one bundle id. Preview builds are internal-only by construction.
- Local lane next to EAS remote versioning. Two counters collide; pick one (
check-release-setupruleversion-source). - Submitting for review because the developer said "ship". "Ship" tags the release; "submit" submits.
- Typing a real-looking value over a placeholder to pass the gate. The bundle id, ad ids and links come from the developer: ask, and report it as a human step.
- Citing a
--rehearsalrun as the store gate, or passing--rehearsalin the pipeline. A rehearsal proves the build, never what users receive.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/signing-and-keys.md | What signs what, the team key and its role, the three variables, key and token rules, keychain, entitlements, leak response, verified vs not | Workflow step 1, and on any signing error |
| references/release-pipeline.md | Steps 0-12 with every command, ExportOptions keys, the gate by hand, processing wait, What to Test, resuming, the keyless rehearsal, versions and tags, after "ship" | Workflow steps 4-5, and to run a step by hand |
| references/eas-lane-and-android.md | The EAS lane to TestFlight (profiles, local EAS builds, getting the .ipa, submit, remote versions, tags) and the Android counterpart of every step |
Workflow steps 2 and 5 |
| references/contract-and-checks.md | Every contract key, the rn-skills.config.json keys, every checker rule with its reason, when multi-package |
Workflow step 3, and when a check fails |
| references/app-store-connect-api.md | REST endpoints and when to use them, token rules, the scripts/asc/ modules, error codes |
When a REST call fails or a new one is needed |
| references/human-steps.md | Human steps once, per app and per release, how to ask, the release checklist | Workflow steps 1 and 4 |
| references/failure-playbook.md | Symptom, cause and action for every known release failure; stops; open questions | Workflow step 7 |
| examples/release-messages.md | A finished-release report, a stop message, an app record request, and bad versions | Workflow step 8 |
| examples/app-config-version.ts | The version, build number and team id lines of app.config.ts (shape, merge into the app's own) |
Workflow step 2 |
templates/scripts/release/ |
release-ios.ts (CLI, --resume build|processing), release-preflight.ts, release-build.ts, release-upload.ts, release-steps.ts, artifact-facts.ts, and the tested pure modules release-options.ts, release-config.ts, release-failures.ts, build-number.ts, what-to-test.ts, processing.ts, processing-wait.ts, store-gate.ts, test-only-sentinels.ts; bump-build-number.ts; export-options-{preview,production}.plist |
Workflow step 2 (local lane) |
templates/scripts/asc/ |
asc-jwt.ts (+ asc-jwt.test.ts), asc-credentials.ts, asc-client.ts (canonical, synced from the library and owned by this skill; do not edit here; in-app-purchases uses the same three), find-app.ts, print-app-record.ts, beta-notes.ts, with tests |
Workflow step 2 (local lane) |
templates/contracts/ios-release.json |
The release contract (appId, branch, scripts, usage strings, encryption answer, forbidden strings, unfinished markers, production ads mode, What to Test lines) | Workflow step 2 (both lanes) |
templates/contracts/ios-release.schema.json |
Its schema, with what each key does and its default | When editing the contract |
templates/scripts/ios/toolchain.ts |
The Xcode pin and selectXcode() (synced from the library; do not edit here) |
Workflow step 2, when missing |
templates/scripts/ios/toolchain.test.ts |
Its tests (synced; do not edit here) | With the toolchain |
templates/scripts/clock/system-clock.ts |
nowEpochSeconds(), the only wall-clock reader (synced; do not edit here) |
Workflow step 2, when missing |
templates/scripts/clock/system-clock.test.ts |
Its tests (synced; do not edit here) | With the clock |
templates/src/config/app-variant.ts |
The variant resolver, variantEnv, variantIdentity (synced; do not edit here) |
Workflow step 2, when missing |
templates/src/config/app-variant.test.ts |
Its tests (synced; do not edit here) | With the resolver |
scripts/check-release-setup.mjs |
Checks the local lane's setup: files, ExportOptions, contract, build number, team id, placeholders, scripts, signing, key and token hygiene, .gitignore, deny rules, Xcode switch, unfinished markers |
Workflow step 3 and the definition of done |
scripts/check-store-artifact.mjs |
The store-artifact gate on an .ipa or Payload/<App>.app from either lane; --rehearsal for the keyless dry run only |
Workflow step 6 and the definition of done |
scripts/lib/release-contract.mjs |
Reads the contract, the Xcode pin, dependencies and test-only sentinels for both checkers | Never by hand |
scripts/lib/artifact-rules.mjs |
The rules of check-store-artifact |
Never by hand |
scripts/lib/plist.mjs |
XML and binary plist reader (synced from the library; do not edit here) | Never by hand |
scripts/lib/usage-strings.mjs |
Usage descriptions per language (synced; do not edit here) | Never by hand |
scripts/lib/ship-placeholders.mjs |
The placeholder values a ship gate refuses (synced; do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared checker library (synced; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass their good fixtures and catch every planted bug (key files and .ipa files are made at run time) |
After changing a checker or a template |
assets/shared.json |
The shared files this skill syncs in | When adding a shared file |
assets/template-placeholders.json |
Values for the template placeholders in template checks | When a template gains a placeholder |
tests/bases/ |
The app repo and the unpacked .app the fixtures assemble from |
When adding a fixture |
tests/fixtures/ |
Overlay cases per checker: good, one bad case per rule, pass and error cases | When adding a rule |
Related skills
eas-build-and-submit-eas.jsonprofiles, EAS credentials and cloud builds (the default lane).android-release-play- the same release on Google Play.ios-simulator-build- Release simulator builds, the Xcode pin and the variant proof before a release.app-variants-and-environment- the variant resolver, bundle ids per variant and the Metro cache key.git-commits-and-reporting- release commits, tags, the report and stop messages.privacy-and-store-declarations- the privacy manifest, the audits after prebuild and the App Privacy answers.quality-gates- the verify gate the preflight runs.e2e-maestro- flows and screenshots of the release commit.debug-menu-and-test-hooks- the test-only gate and the sentinel the gate looks for.admob-ads-and-consent- real ad ids per variant, SKAdNetwork,app-ads.txt.in-app-purchases- products, the StoreKit test harness the gate refuses in production.troubleshooting-playbook- failures outside the release pipeline.