Skip to content

fix(mobile): make EAS release submission deterministic - #356

Merged
i-xtsu-sixyou-ken-mei merged 21 commits into
mainfrom
fix/ios-release-exact-build-id
Sep 3, 2026
Merged

fix(mobile): make EAS release submission deterministic#356
i-xtsu-sixyou-ken-mei merged 21 commits into
mainfrom
fix/ios-release-exact-build-id

Conversation

@i-xtsu-sixyou-ken-mei

@i-xtsu-sixyou-ken-mei i-xtsu-sixyou-ken-mei commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Intent

Prevent iOS releases from submitting stale build numbers or the wrong EAS binary, and make submission retries safe without consuming another store build number.

Context

The failed iOS release exposed two independent release-state problems:

  • App Store Connect already had 2.1.0 (19) while EAS remote versioning was still in the single digits. The 2026-08-09 run produced build 5; its 2026-09-02 rerun produced build 6.
  • The release workflow built one binary, then submit-latest-production.mjs queried EAS again for the latest finished production build. That identity lookup was unnecessary mutable global state and could race with builds started outside this workflow.

GitHub reruns also rerun all steps in a failed build+submit job, so a submission-only failure could consume another build number.

Scope

  • Capture the exact EAS build ID from the build command itself.
  • Require an explicit build ID for every submission; remove latest-build lookup.
  • Split Android/iOS build and submit into separate GitHub Actions jobs.
  • Require explicit build IDs for submit-only recovery mode.
  • Add an iOS preflight that refuses to build while EAS remote versioning is below the recorded App Store Connect floor (2.1.0 (19)).
  • Update app release scripts, tests, README, and Android/iOS release runbooks.
  • Harden exact-build handoff: reject CANCELED builds, simplify resolveBuild to real EAS contract (array of BuildFragment|null), add runEasJson helper, distinct error for uninitialized remote.

Out of scope

  • Calling Expo's undocumented GraphQL mutation to mutate remote version state from CI.
  • Storing a new App Store Connect private key in GitHub solely to discover and mutate EAS version state.
  • Automatically submitting an App Store review after TestFlight upload.

Product contract / invariants

  • This PR does not change a documented product/architecture invariant.
  • This PR intentionally changes an invariant with explicit product approval.

Affected invariant:
apps/app/AGENTS.md store-release guardrails: remote auto-increment remains authoritative and submissions select the intended production/store build by ID.

Why:
The implementation strengthens the existing invariant by carrying the exact build identity from build to submit instead of resolving global "latest" state.

Acceptance criteria

  • Normal build-and-submit carries the exact EAS build ID from the build job to the submit job.
  • submit-only requires an explicit platform build ID.
  • A failed submit job can be retried without rerunning its successful build job.
  • iOS build fails before EAS Build when the remote build number is below ASC build 19 for app version 2.1.0.
  • No normal release path calls eas build:list --limit 1 or eas submit --latest.
  • Release docs describe the deterministic flow and one-time EAS remote alignment requirement.
  • Tests exercise exact-ID handoff via fake EAS CLI (build/submit/preflight behavioral).

Implementation

build-production.mjs runs eas build --json --non-interactive, waits for completion, validates the returned array contains a FINISHED build, extracts the exact ID, and publishes it to $GITHUB_OUTPUT when running in Actions.

submit-production-build.mjs accepts only an explicit EAS build ID, validates it via eas build:view --json (platform/profile/distribution/status), and invokes eas submit --id <build-id>.

The workflow now has separate build-android, submit-android, build-ios, and submit-ios jobs. Build outputs are passed directly to submit jobs. Recovery mode accepts android_build_id / ios_build_id inputs instead of resolving latest state.

release-baselines.json records the known ASC floor of build 19 for iOS 2.1.0. assert-ios-remote-version.mjs compares that floor to eas build:version:get --json and fails fast when EAS is behind, with a distinct message when buildNumber is missing (not initialized).

eas.mjs gains runEasJson (captures stdout + JSON.parse with tail on failure) removing duplicated parse code. ios:release now chains ios:version:check && build-production so local and CI use identical preflight; the separate workflow Verify EAS iOS build number step is removed.

Docs note how to locate the exact EAS ID: last path segment of the See logs: URL printed when the build starts (visible even on runner timeout) and on the Expo dashboard.

Contract alignment

  • Implementation matches the invariant.
  • Regression / contract tests cover it.
  • Scoped AGENTS.md remains accurate.
  • README / runbook remains accurate.

Validation

Local (worktree fix/ios-release-exact-build-id @ f76d26f7):

pnpm --filter @zapengine/app exec vitest run tests/easTooling.test.ts
# 24 passed (behavioral: build [null,{id,FINISHED}] -> GITHUB_OUTPUT, CANCELED -> exit 1,
# missing ID -> exit 1, submit validates distribution, build:view never --non-interactive,
# forwarded "--" -> exit 1 with no EAS call, preflight floor 19, uninitialized -> distinct error)

pnpm turbo run type-check lint test --filter=@zapengine/app
# 8 tasks successful (type-check, lint, test: 81 files / 683 tests passed)

pnpm --filter @zapengine/app format:check
# All matched files use Prettier code style!

pnpm turbo run deadcode dup:check --filter=@zapengine/app
# 0 clones

pnpm turbo run build --filter=@zapengine/app
# Exported: dist/android + dist/ios + ios:bundle-check stayed within baseline

EAS CLI contract checked against the pinned eas-cli@20.5.1 sources rather than prose: eas build --json
prints an array of build objects and routes every other message to stderr, build:view exposes only
--json (so --non-interactive must not be injected), build:version:get --json returns buildNumber,
and pnpm dlx sends its install progress to stderr — so capturing stdout for JSON is safe on a cold cache.

Not run locally: test:ios:release-smoke (41 min; CI ran it green on 13bdc426 and no native/dependency
configuration changed since). Real EAS calls cannot be exercised locally.

Defect found in review and fixed (f76d26f7)

pnpm <script> -- <arg> forwards the literal -- to the script instead of consuming it. Verified against
pnpm 10.30.3 for both pnpm --filter <pkg> <script> -- <arg> and the explicit run form:

pnpm --filter demo-pkg submit-like -- ID1   ->   argv ["ios", "--", "ID1"]
pnpm --filter demo-pkg submit-like ID1      ->   argv ["ios", "ID1"]

So pnpm --filter @zapengine/app android:submit -- "$BUILD_ID" reached the wrapper with -- as the build
ID and the real ID dropped, which broke every build-and-submit run and every submit-only recovery.
Reproduced against the wrapper with a stubbed EAS CLI: it printed Submitting production ios build --.
and issued build:view -- --json plus submit ... --id -- --non-interactive, exiting 0.

Fixed by passing the ID as a bare argument in the workflow and in every documented command, rejecting an
argument that cannot be a build ID, and adding regression tests for the wrapper guard and the workflow
invocation.

Known unrelated failures

None outstanding. Full CI was green on 13bdc426. The earlier red run was quick-gates (prettier drift in
three PR files, fixed) and code-quality (@zapengine/control-center#dup:check, on a run merged onto the
stale base f5522543; this PR touches no control-center file).

Implementation note — preflight enforcement

apps/app/package.json ios:release now runs node scripts/assert-ios-remote-version.mjs && node scripts/build-production.mjs ios. The separate Verify EAS iOS build number is not behind App Store Connect step in .github/workflows/release-mobile.yml (build-ios job) is removed, so a local release and CI enforce the same floor. ios:version:check remains as a standalone diagnostic. README and apps/app/docs/ios-release.md reflect this.

Reviewer notes

Do not solve the remaining one-time version alignment by calling Expo's undocumented createAppVersion GraphQL mutation from CI. EAS CLI's supported build:version:set command is interactive; after the remote value is set to the ASC high-water mark once, ordinary EAS-only releases remain monotonic.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
zap-engine-control-center Skipped Skipped Sep 2, 2026 8:16am UTC

@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:12 Inactive
@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:13 Inactive
@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:13 Inactive
@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:15 Inactive
@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:15 Inactive
@vercel
vercel Bot temporarily deployed to Preview – zap-engine-control-center September 2, 2026 08:16 Inactive
- eas.mjs: add runEasJson helper with raw-output tail on JSON parse failure
- build-production.mjs: use runEasJson, require array output and status FINISHED (reject CANCELED), error on missing ID
- submit-production-build.mjs: use runEasJson, restore WHY comments for numeric ascAppId and doc pointers
- assert-ios-remote-version.mjs: use runEasJson, distinct message when buildNumber missing (not initialized -> ios:version:init)
- tests: replace source-string checks with behavioral harness (stub pnpm logs calls, env-keyed JSON per subcommand, asserts exit/stdout/stderr/GITHUB_OUTPUT)
- docs: explain how to locate EAS build ID via See logs URL and Expo dashboard
- ios:release now chains ios:version:check preflight so local and CI are identical; remove redundant workflow step, keep ios:version:check standalone
- format: apply prettier to satisfy quick-gates

Co-Authored-By: internal-model
`pnpm <script> -- <arg>` forwards the literal `--` to the script instead of
consuming it, so `android:submit -- "$BUILD_ID"` reached the wrapper as
argv `[platform, '--', <id>]`. The wrapper read `--` as the build ID and
dropped the ID the build job had just produced, which broke every
build-and-submit run and every `submit-only` recovery.

Pass the ID as a bare argument in the workflow and in the documented
commands, reject an argument that cannot be a build ID, and cover both the
wrapper guard and the workflow invocation with regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@i-xtsu-sixyou-ken-mei
i-xtsu-sixyou-ken-mei merged commit 30ee3bc into main Sep 3, 2026
11 checks passed
@i-xtsu-sixyou-ken-mei
i-xtsu-sixyou-ken-mei deleted the fix/ios-release-exact-build-id branch September 3, 2026 01:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant