Mobile: native builds only on fingerprint change
Status summary#
Implemented; verifying. All unit lanes green (scripts 291, os 9 new, mobile 204 incl. new incompatible-state cases); typecheck/lint/knip/format clean; mobile web specs being re-run (first batch had fresh-worktree dev server OOM noise — specs pass individually). Remaining: PR body risk map + interstitial screenshot.
The ask (Misha, verbatim-ish)#
"No more native build on every push. too expensive. do a native build when there are necessary changes (do we have some kind of fingerprinting mechanism?); keep track of what the expected native build is, bake that into the JS bundle; have the JS bundle say 'This JS bundle expects a different native build' with a Download button taking us to the expo.dev link; for the 'native build installer that always works' link it can be an interstitial of some kind. Don't regress the recent QR just-works guarantees."
Where the cost actually is#
We already fingerprint: runtimeVersion uses expo's fingerprint policy
(apps/mobile/fingerprint.config.js), and builds are only triggered when no
build matches. But ensureBuildForPr requires a build whose channel
matches the PR's channel (baked via the preview-pr profile rewrite, #2542).
No PR channel ever has a build, so every mobile PR triggers a ~20-minute
EAS build even when its fingerprint is identical to main's — that build
exists purely so that "install the build" lands you on the PR's JS without a
second scan. That convenience is what we're paying a build-per-PR for.
Design#
1. One native build per runtime fingerprint#
- All CI builds use the plain
previewprofile (channelpreview). Delete thepreview-prprofile,easJsonWithChannel,withProfileChannel,buildProfileForChannel. ensureBuildForPr({channel, runtime})becomesensureBuildForRuntime({runtime}): any FINISHED build with the runtime wins, else any in-progress one, else triggerpreview(--no-wait).- Consequences:
- JS-only PRs (the common case): zero builds.
- Native-change PRs: one build, and because it's channel-
preview, the post-merge main publish finds it already FINISHED — no second build, and #2550's refresh job becomes a rare-path safety net instead of the normal native-merge path. - Sibling PRs with the same fingerprint share the build.
2. Install links become live interstitials (never stale)#
New OS routes (public, like m.preview-channel.$channel):
/m/install/<channel>— HTML interstitial: shows the channel's expected native build (from the status store below), a big link to its expo.dev install page ("build still running" state when not finished), and an Open in app deep link (iterate://preview-channel/<channel>) so the post-install channel switch is one tap on a page you already have open. Missing status → fallback: deep link + EAS builds-list link (what the current interstitial shows)./m/channel-status/<channel>— the same data as JSON (CORS*) for the app.
PR-body/commit-comment install QRs encode /m/install/<channel> — the
QR content is channel-stable, so a QR printed three pushes ago still
resolves to the right build today. OTA QRs keep encoding the raw
iterate://preview-channel/<channel> scheme URL (camera-scan path — no
browser hop; deliberate, don't regress).
Because both QR contents are now sha-independent, asset names drop the sha:
one OTA + one install PNG per PR (still cleaned up by the
mobile-pr-<n>- prefix), and two stable assets for main instead of two new
ones per merge.
3. The status store: CI-pushed, tokenless reads#
apps/mobile/README.md records a deliberate decision NOT to ship
EXPO_TOKEN to the deployment, and hints at "a CI-pushed snapshot". So the
worker never calls the EAS API; CI pushes a per-channel snapshot to prd OS
at publish time:
- Shape:
{channel, runtimeVersion, buildId, installUrl, buildFinished, commit, message, publishedAt}. - Writers (all already-existing hooks, now writing status too):
publish-mobile-pr-preview.ts— on every PR pushpublish-mobile-update.ts— on every merge (channelpreview)refresh-mobile-main-qr.ts— flipsbuildFinishedwhen the build landscleanup-mobile-pr-preview.ts— deletes the channel's status on close
- Write path: admin-authenticated OS endpoint —
Authorization: Bearer $APP_CONFIG_ADMIN_API_SECRETviaauthenticateAdminApiSecret(apps/os/src/auth/admin.ts); CI already runs underdoppler --project os --config prd, which carries that secret. Storage: R2FILES_BUCKETunder the reservedplatform/key prefix (project file keys startprj_…so no collision; R2 is read-after-write consistent, unlike KV, and needs no envs.ts id). Handler logic lives in a non-route module (the routes dir excludes tests) followingoperator-session.ts/.test.ts; zod-validate the body hard. Write failures fail the publish loudly — silent drift is the thing this task kills. - Known staleness: a PR build that finishes after publish keeps
buildFinished: falseuntil the next push (nothing polls PR builds — by design, same as today's PR sections). The interstitial links the concrete build page either way, and that page is live truth.
4. The app says "you need a different native build"#
Today checkForUpdateAsync can't distinguish "current" from "the channel
has newer JS this binary can't run" (the server filters by runtime — see
build-state-core.ts UpdateCheck docs). Close the gap with the status
endpoint:
build-stategains a channel-status query against prd (/m/channel-status/<channel>); pure comparison lives inbuild-state-core.ts: status runtime ≠ binary runtime → newUpdateStatuskind"incompatible"carrying the install URL.- Watched builds surface it as the update row / banner: "This channel's latest JS expects a different native build" + Download button (opens the interstitial).
- The QR confirm screen's switched-but-no-update note upgrades from "either nothing is published yet, or the PR has native changes" to the precise verdict + Download button.
5. Bake the expected runtime into the bundle#
write-build-info.mjs stamps runtimeFingerprint: the publisher computes
the fingerprint (npx expo-updates fingerprint:generate) before stamping
and asserts post-publish that eas update agreed (loud failure on drift).
Build info shows it; it's the bundle's own record of which native build it
expects. (Detection of mismatch still rides the status endpoint — a bundle
that can't run never gets to report anything.)
Explicitly not regressing (guarantees from the last few days)#
- OTA QR = raw scheme URL, camera-scannable without a browser hop (#2542).
- Scanning always pulls the channel's latest + backend/identity mismatch cards (qr-scan-freshness).
- Merged PRs get main's section; close-vs-merge race guard; refresh job (#2550) — all kept, just re-pointed at interstitial links.
- The new-install guard (override cleared on first boot of a new binary) keeps its ordering trap defused: the interstitial's "Open in app" tap comes after install, which is the correct order by construction.
- The two-QR PR section stays two QRs (scan-direct OTA + install); what changes is that the install QR is channel-stable and always resolves.
Checklist#
-
mobile-preview.ts:ensureBuildForRuntime(drop channel matching, alwayspreviewprofile), delete the eas.json rewrite machinery, deletepreview-prfrom eas.json (easJsonWithChannel / withProfileChannel / buildProfileForChannel gone) -
mobile-preview.ts: install links/QRs →/m/install/<channel>; sha-independent asset names; section copy updated (one OTA + one install PNG per PR, two stable ones for main; note says "After installing, tap Open in app…") - OS: status store (admin-auth write endpoint + storage) with tests
(apps/os/src/domains/mobile/channel-status.ts + .test.ts; FILES_BUCKET
platform/mobile-channel-status/<channel>.json) - OS:
/m/install/$channelinterstitial +/m/channel-status/$channelJSON (CORS), fallback rendering when status is missing (thin routes; HTML orders install before Open-in-app; PUT tolerates route-absent 404 during the bootstrap window, loudly) - CI writers: publish-pr / publish-main / refresh / cleanup push status (refresh guards on buildId so a newer merge's snapshot is never regressed; cleanup deletes the channel's snapshot)
-
write-build-info.mjs+ publishers: stampruntimeFingerprint, assert it matches the published runtime (computeRuntimeFingerprint via expo-updates fingerprint:generate; verified stamping build-info.json does NOT move the fingerprint) - App:
build-state-core"incompatible"status + tests; channel-status query; Download button on the banner/Build info and the QR confirm screen's no-update path (Download opens the interstitial, not the raw build page — the new-install guard clears overrides, so the channel hop must come after the install) - README (apps/mobile): per-PR channels section rewritten for the new build economics; note the status store
- Tests: scripts planners, OS routes, mobile core (no new web spec: the incompatible path needs native OTA facts the expo-web dev bundle can't have — canOta() is false there, so the status query never arms; core logic is node-tested instead)
Implementation log#
- Storage decision: FILES_BUCKET
platform/prefix over a new KV namespace — R2 is read-after-write consistent (scan the QR the moment CI publishes) and needs no per-env id in envs.ts. - No EXPO_TOKEN in the worker (per the documented decision in the README):
the store is CI-pushed. Known staleness: a PR build finishing after publish
stays
buildFinished: falseuntil the next push; the interstitial links the concrete build page, which is live truth. - Transition note: until this merges and prd deploys, PR publishes log "channel-status endpoint not deployed yet — snapshot skipped" and QR sections still render; interstitial URLs 404 until then (this PR's own preview section will demonstrate the fallback once prd has the routes).