An Expo app builds every variant on EAS from one checked eas.json, and a store build reaches
TestFlight and Google Play internal testing only after the gates passed, from the reviewed commit, with
a build number no store has seen and a fingerprint that matches the reviewed native inputs. Credentials
stay with EAS and the developer; the agent builds, proves and submits with the developer's word.
Rules that must hold
- Nothing leaves the machine without the developer's word in this session. A store
eas build, everyeas submit,eas build:version:setand anyeas credentialschange ask first. Why: each uploads, spends a build number or changes shared signing state, and none can be undone. - eas.json is valid for the CLI it names.
cli.versionis a floor>= 24.10.0(or newer), every key and value passes EAS CLI's own schema (imageonly insideios/android,nodea full version), andextendschains resolve. Why: EAS CLI refuses an invalid file only after a login, often in CI; the checker andeas config --eas-json-onlycatch it offline in seconds. - Build numbers are remote and only go up.
cli.appVersionSourceisremote, every store profile hasautoIncrement: true, and the app config holds a literalversion: 'x.y.z'(raised per store release) and noios.buildNumberorandroid.versionCode. Why: EAS keeps one counter per app id, so two machines can never upload the same number (ITMS-90189, a usedversionCode), and a number in the file would only mislead. - A build is exactly a commit.
cli.requireCommitistrue, and store builds start from a clean tree on the release branch. Why: without it EAS CLI copies uncommitted and untracked files into the build, so itsgitCommitHashlies and the review covered other code. - A profile per variant (and per simulator build), and only
productionis a store build.development,development-simulator,preview,preview-simulatorandproduction(templates/eas.json); the store profile builds an Android app bundle, signs, and never setsdevelopmentClient,withoutCredentialsorios.simulator; internal profiles never build an AAB. Why: EAS defaults a profile withoutdistributiontostore, Play takes only app bundles, and testers cannot install an AAB from a link. - Every build profile pins the project's Node.
"node": "26.4.0", equal to.nvmrc(or aneas-build-pre-installscript that upgrades npm). Why: the SDK 57 images ship npm 10.9.8, which theenginesfield and the install policy refuse (EBADENGINE), and which ignoresmin-release-age. submit.productionpins the production ids and reaches testers only.ios.ascAppId(digits) andios.bundleIdentifier,android.applicationId, never a.dev/.previewid; Androidtrackisinternal(oralpha/beta) withreleaseStatuscompletedordraft. Why: a standaloneeas submitevaluates the app config without the profile's env and would look up the development id; promotion and rollouts are store-console decisions (android-release-play).- Credentials stay with EAS and the developer. Managed credentials (
credentialsSource: remote),--freeze-credentialson everyeas build(EAS Workflows build jobs have no such flag), noappleIdlogin and no literal key path ineas.json(EAS-stored keys or$VARIABLESfrom CI secrets); the agent never opens a.p8,.p12, keystore or service-account file. Why: a leaked key publishes apps in the developer's name, and a build that regenerates a certificate breaks every app that shares it. - Gates first, then one named command. An outward
eas buildruns afternpm run verifyorscripts/eas/release-preflight.ts, and aneas submitaftercheck-eas-buildon its record, in the same script or job, as a command, never only in a comment (an EAS Workflowstype: buildjobneeds:the job that ran verify, atype: submitjob the job that ran the record check); everyeas build/eas submitnames--profile(a job:params.profile); EAS CLI is the globaleas, nevernpx eas-cli; noeas submit --latest;--auto-submit(-s) only from the store profile and only for iOS; CI commands pass--platform, andeas submit--non-interactive. Why:eas builddefaults toproduction,--latestsubmits whatever finished last, verify proves the code but not what EAS built, an auto-submitted Android bundle reaches Play before its gate read it, andeas submitstays interactive even in CI. - Only a build whose record proves it is submitted, and only the file its gate read.
check-eas-buildoneas build:view <id> --json: finished, store distribution, the production profile, the reviewed commit, the production id, the app config's version, a build number above every upload tag, and the fingerprint the preflight wrote. Then the record's artifact is downloaded tobuild/ios-release/export/app.ipa/build/release-android/app-release.aab, the stores' own gates read it (ios-release-testflight, android-release-play), andeas submit --path <that file>uploads exactly those bytes. Why: the record is the only evidence of what EAS built, the artifact gate the only evidence of what is inside it; a wrong variant or commit looks like success until testers open it. - The fingerprint decides between a build and an update, and nothing hides native inputs from it.
Compare fingerprints of the same variant and platform (
eas fingerprint:compare --build-id <id>);.fingerprintignorenever hidespackage.json, the lockfile, the app config,plugins/,patches/ornode_modules/, andsourceSkipsneverExpoConfigAll. Why: a hidden native change ships as an update to binaries that lack the code, and the build-record check loses its proof. - Stops are never retried. Credentials errors, agreements, HTTP 401/403, missing app records, a
used build number, a draft Play app, an
INVALIDprocessing state: one message with the step, the exact error and the one action. Why: retries lock accounts, burn numbers and hide the real cause. - Every submitted build is tagged from its record.
git tag -a v1.4.0+ios.14 -m "App Store build 14"(and+android.<n>, "Play build N"), with the record'sappBuildVersion, pushed only with the developer's word. Why: each TestFlight and Play build traces back to its commit, and the next record check compares against these numbers (git-commits-and-reporting owns the scheme).
Workflow
- Choose the lane and list the human steps. EAS is the default; a team that cannot use EAS takes the
local lane (ios-release-testflight, android-release-play) and this skill's checker prints NOT
APPLICABLE. Read references/credentials-and-signing.md and
ask the developer, one step per message, for what is missing: accounts,
eas init(writesextra.eas.projectId), app records, signing and submit keys,EXPO_TOKENfor CI. - Install EAS CLI globally with the policy flags:
npm install -g --min-release-age=7 --ignore-scripts eas-cli@24.10.0, nevernpx eas-cliin the app (references/build-and-submit-runbook.md, "Install the CLI"). - Write eas.json from
templates/eas.json: fill__ASC_APP_ID__(the Apple ID number of the app record) and__APP_ID__(the production id); keep an existing file's extra profiles and merge the variant wiring app-variants-and-environment owns. Read references/eas-json.md for every key and the ownership table; leaveimageout (expo-sdk-upgrade'sautodefault). Validate offline:eas config --eas-json-only --profile production --platform ios --json --non-interactive. - Fix the version fields in
app.config.ts: a literalversion, noios.buildNumberorandroid.versionCode. For an app with store history, ask the developer to runeas build:version:setper platform with the last store numbers (references/versions-and-fingerprints.md). - Copy the release templates:
templates/scripts/eas/(eas-profile.ts,eas-commands.ts,release-plan.ts,release-preflight.tsand the three tests) and, when missing,templates/src/config/app-variant.tswith its test (the canonical resolver). Runnpx jest scripts/eas --ci --maxWorkers=2,npm run typecheckandnpx eslint scripts/eas --max-warnings 0. - Check the setup:
node ${CLAUDE_SKILL_DIR}/scripts/check-eas-config.mjs .; fix everyFAILline and rerun untilRESULT: PASS. Then app-variants-and-environment'scheck-variantsand expo-sdk-upgrade'scheck-sdk-alignmentfor the parts they own. - Internal builds (dev client, preview, simulator builds for E2E) need no preflight: the runbook's table has each command.
- A store release, step by step from the runbook:
check-eas-config.mjs . --release; thennode scripts/eas/release-preflight.ts --profile production --platform all --what-to-test "<text>", which prints the sequence; then ask (rule 1, shape in examples/release-report.md); then its build line,eas build ... --json > dist/eas/builds.json(no--auto-submit); thennode ${CLAUDE_SKILL_DIR}/scripts/check-eas-build.mjs . --build dist/eas/builds.json --platform ios(and android); then its download and store-artifact gate lines (ios-release-testflight'scheck-store-artifact, android-release-play'scheck-store-aab); then its submit lines,eas submit --profile production --platform <p> --path <gated file> --non-interactive(What to Test on iOS); then tag and report. The preflight's--auto-submit(iOS only: the build submits itself, record checked afterwards) only when the developer explicitly agreed to skip the record check. - Before an update instead of a build, compare fingerprints (references/versions-and-fingerprints.md, "New build or update?") and hand the update to ota-updates.
- On any failure find the row in references/failure-playbook.md: fix and rerun, or send the stop message and wait.
Definition of done
-
eas.jsonhas the five profiles, remote versions,requireCommit, the CLI floor,nodein every profile andsubmit.productionwith the production ids;eas config --eas-json-onlyaccepts it. -
app.config.tshas a literalversionand no build numbers; remote counters are initialised for an app with store history (or it is a new app). -
scripts/eas/is in place and its tests pass with the app's typecheck and lint. - For a store release: the developer agreed in this session; the preflight passed; each submitted
build's
check-eas-buildprintedRESULT: PASS; each build is tagged; the report holds every RESULT line and nothing was retried after a stop. - No key file was opened, copied or uploaded by the agent;
eas.jsonholds no secret or key path. - An app on the local lane prints
NOT APPLICABLE: no eas.json and nothing uses EAS ...; an app whose EAS profiles are all internal whilerelease:ios/release:androidships the store builds prints theinternal-only EAS: ...note; there are no SKIP lines (both checkers are strict; a sharedbaseprofile others extend is judged through the profiles built from it). -
node ${CLAUDE_SKILL_DIR}/scripts/check-eas-config.mjs .printsRESULT: PASS(with--releasebefore a store build)
Anti-patterns
eas buildwith no--profile. It silently buildsproduction, spends a store number and, with--auto-submit, uploads. Name the profile every time.eas submit --latest. It takes whatever build finished last: a preview build, another branch. Submit the gated file with--pathaftercheck-eas-buildand the store-artifact gate.eas build --platform all --auto-submit. The Android bundle reaches Play before any gate read it; build, check, gate, theneas submit --path."appVersionSource": "local"plus a script that bumpsbuildNumberand commits. Two machines or a retried CI job upload the same number. Use remote versions."image": "latest"(whateas init's template writes). A new Xcode reaches the build without a commit; leaveimageout.- No
nodein the profiles "because it worked locally". The worker's npm 10 refusesenginesor, worse, ignores the release-age policy. - Fixing a credentials error by switching to
credentialsSource: "local"or an Apple ID login. A stop was worked around and a key file was handled; send the stop message. - Reading the
.p8or the keystore "to check it is there". Ask the developer or readeas credentialsoutput; never open the file. eas build:version:setto a lower number to "reuse" a failed build's number. The store refuses it; spent numbers stay spent.- Announcing "on TestFlight" when
eas buildreturns. Check the record, submit, then wait for Apple's processing. .fingerprintignorewithpackage.jsonor**/node_modules/**to make builds "reusable". Native changes stop changing the fingerprint and updates reach binaries without the code.--auto-submitfrompreviewto put testers on TestFlight. Preview is an internal build with the preview id; TestFlight takes store builds of the production id.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/eas-json.md | Every profile and key with its reason, the extends merge, Node on EAS, images, the submit profile, the full schema table, offline validation, ownership | Workflow step 3; an eas-schema, cli-version, store-profile, internal-profile, build-node or submit-profile failure |
| references/credentials-and-signing.md | What signs what, the human setup steps, frozen credentials, submit keys, local credentials, CI, never-list, leak response | Workflow step 1; a submit-credentials failure; any credentials error |
| references/versions-and-fingerprints.md | Version policy, remote counters per app id, initialising them, tags, what the fingerprint hashes (measured), how EAS records it, build or update, test recipes | Workflow steps 4 and 9; a version-source, config-versions or fingerprint-config failure |
| references/build-and-submit-runbook.md | Installing the CLI, internal builds, the store release step by step, What to Test, groups and tracks, after submission, --local, workflows, what needs the developer |
Workflow steps 2, 7 and 8 |
| references/failure-playbook.md | Symptom, cause, fix or stop for every known local, worker, version, submission and processing failure | Workflow step 10 |
| examples/release-report.md | The ask, the evidence message and a stop message for a Tasks release, and bad versions | Workflow step 8 |
templates/eas.json |
The five build profiles and submit.production (__ASC_APP_ID__, __APP_ID__) |
Workflow step 3 |
templates/scripts/eas/eas-profile.ts |
Resolves a build profile like EAS CLI; store-profile problems; pinned submit ids | Workflow step 5 |
templates/scripts/eas/eas-commands.ts |
eas build / eas submit --path argument lists, the gated artifact paths, the release sequence, the What to Test limit, upload tags, a shell-safe line |
Workflow step 5 |
templates/scripts/eas/release-plan.ts |
The preflight's pure decisions: tree, branch, profile, version above the last release, ids | Workflow step 5 |
templates/scripts/eas/release-preflight.ts |
The gates-first CLI: tree and profile, npm run verify, the app config, fingerprints to dist/eas/, the printed release sequence (build, record checks, download and store-artifact gates, --path submits; iOS --auto-submit only on request); unattended under CI=true, non-zero on any failed gate |
Workflow steps 5 and 8 |
templates/scripts/eas/eas-profile.test.ts |
Tests of the profile resolution and the store-profile rules | With the module |
templates/scripts/eas/eas-commands.test.ts |
Tests of the command lines, the release sequence (no --auto-submit by default, never for Android), the What to Test limit and upload tags |
With the module |
templates/scripts/eas/release-plan.test.ts |
Tests of the preflight's decisions | With the module |
templates/src/config/app-variant.ts |
The canonical variant resolver the profile module reads (synced from the library; do not edit here) | Workflow step 5, when missing |
templates/src/config/app-variant.test.ts |
Its tests (synced; do not edit here) | With the resolver |
scripts/check-eas-config.mjs |
Checks eas.json, the app config's version fields, commands and their gates, the preflight and the fingerprint inputs (--release: ship mode) |
Workflow steps 6 and 8 |
scripts/check-eas-build.mjs |
The ship gate on a build record before submission | Workflow step 8 |
scripts/lib/eas-json.mjs |
eas.json reader, EAS's schema and extends resolution, offline | Only when changing a checker |
scripts/lib/repo-facts.mjs |
App config facts, Node pins, eas commands in scripts and workflows | Only when changing a checker |
scripts/lib/ship-placeholders.mjs |
The placeholder values a ship gate refuses (synced from the library; do not edit here) | Only when changing a checker |
scripts/check-lib.mjs |
Shared checker library (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Runs both checkers against the fixtures (the tags suite builds a throwaway git repo per case from its TAGS.txt) |
After changing a checker or a template |
tests/bases/ |
The Tasks base app, build records and fingerprints, and decoys under .agents/skills/, skills/ and a vendored SKILL.md folder |
When adding a fixture |
tests/fixtures/ |
Overlay cases per checker: good, one bad case per rule, pass and error cases; eas-build-tags/ reads upload tags of an app at apps/mobile |
When adding a rule |
assets/eas-facts.json |
The verified CLI floor, image Node and npm, the project Node, the What to Test limit | When re-verifying for a new CLI or SDK |
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 |
Related skills
app-variants-and-environment- the variant env andenvironmentof each profile, secrets in EAS environments.expo-sdk-upgrade- EAS images (autoby default, neverlatest) and the SDK move.dependency-management- the install on EAS (npm ci, the lockfile,NPM_CONFIG_*), the global EAS CLI install.ota-updates- channels per profile, runtime versions from fingerprints,eas update.ios-release-testflight- signing details, the store-artifact gate on the.ipa, and the local lane.android-release-play- Play App Signing, the AAB gate, tracks beyond internal, staged rollout.ci-pipeline-and-gates- the CI jobs that run these commands withEXPO_TOKEN.git-commits-and-reporting- upload and release tags, the evidence message, the stop list.privacy-and-store-declarations- privacy manifest and store answers before a submission.ios-simulator-build,android-emulator-build,e2e-maestro- local builds and E2E on named devices.- Expo's official
eas-app-storesandeas-workflowsskills - API references for EAS Submit and EAS Workflows.