Mobile: one module owns "which build am I on, and is it current?"
Status#
Done, pending merge. Deferred bits are recorded below (app-config extra
stamping; the on-hold merge-time QR swap). Implemented on mobile-build-state
(#2542). Done: the module and
its 11 unit tests, the app-global session, the rewritten Build info and QR
screens, the update banner, per-PR builds with the channel baked in, and the
docs. Not done: stamping the available update's commit message into app config
extra (see "corrections" — the cheap half of it landed, the config change did
not), and a device pass.
Two plan claims turned out to be wrong. See Corrections. A later re-audit on a stronger model found five more defects and one missed requirement. See Re-audit.
Two halves:
- Consolidate. Every query and mutation about the running bundle, the native binary, the OTA channel and update freshness moves into one module. Build info and the QR confirm screen become dumb views over it.
- Fix the four things that are actually broken. The native-build QR that
drops you on the wrong channel; no "am I stale?" check on open; the
not signed inlie; and Build info not showing what you asked it to show.
How it works today#
The moving parts#
| Thing | Where it lives | Who reads it |
|---|---|---|
| Bundle stamp — branch, commit, message, builder, expected backend, test email | src/build-info.json, written by apps/mobile/scripts/write-build-info.mjs at publish time, compiled into the JS bundle |
src/lib/build-info.ts → Build info, sign-in screen, QR screen |
| Native binary facts — version, build number, install time | expo-application |
Build info |
| Runtime fingerprint — which JS a binary will accept | Updates.runtimeVersion, from fingerprint.config.js |
Build info, CI's QR heuristic |
| Default channel — baked into the binary at build time | Updates.channel, from the eas.json build profile (preview, development) |
Build info only |
| Channel override — "point this binary at a PR's JS" | native UserDefaults (write-only via Updates.setUpdateRequestHeadersOverride), mirrored into AsyncStorage because expo-updates has no getter |
src/lib/preview-channel.ts |
| Auto-continue marker — one-shot "the Switch tap already consented" | AsyncStorage, survives the reload | src/lib/preview-channel.ts |
| Last-seen binary identity — detects a fresh install | AsyncStorage | src/lib/native-install-guard.ts |
| Sign-in | keychain, keyed per server base URL | src/lib/auth.ts + storage.ts |
The screens#
src/app/build-info.tsx— three cards (Bundle / Updates / App), a Check for update button, a Reset to default channel button. Owns four of its own queries and mutations.src/app/preview-channel/[channel].tsx— the QR deep-link target. 435 lines. Owns six queries/mutations: current override, freshness pull, phone state (server + signed-in email, sometimes two token refreshes), auto-continue, channel switch, apply-plan. Also owns the whole "this bundle expects a different backend" comparison UI.src/app/_layout.tsx— runs the new-install guard at the root and pops anAlertwhen it clears an override.src/app/index.tsx— reads the bundle stamp again to preselect a server and a login hint.
Four files, four separate readings of the same facts, no shared vocabulary.
The CI side#
scripts/ci/publish-mobile-pr-preview.ts (per PR) and
publish-mobile-update.ts (per merge to main) both:
- stamp
build-info.json, eas update --channel <branch-derived channel>,- find or trigger a native build whose runtime fingerprint matches
(
ensureBuildForRuntime), - render two QR codes into the PR body (or a commit comment) via
renderPreviewSection.
The OTA QR encodes iterate://preview-channel/<channel> — the camera
opens custom schemes directly. The tappable caption goes through an https
interstitial (apps/os/src/routes/m.preview-channel.$channel.ts) because
GitHub strips custom-scheme hrefs.
The install QR encodes an expo.dev build page URL. That URL carries no
channel.
Which one is expanded is decided by one heuristic: is the runtime fingerprint
of the update we just published equal to the fingerprint of the newest
finished preview build in the whole EAS project
(latestInstalledRuntime())?
Why the native-build QR doesn't work#
Four independent reasons, all live:
- The install QR carries no channel. A preview-profile binary boots on
channel
preview, which is main. Install it and you are running main's JS, not the PR's. The PR body says so in fine print and tells you to go scan the other QR. - When you need the install QR, the OTA QR is collapsed. They are
mutually exclusive
<details open>blocks. Exactly when the runtime differs — the one case where you must use both, install then switch — the switch link is folded away. - The new-install guard force-clears the override.
resetChannelOverrideForNewInstall()fires on the first boot of any new binary. It is right in general (installing a build should mean running that build) but it also means install-then-switch has a strict ordering: switch after the install, never before. Nothing tells you that. - The heuristic guesses about a phone it cannot see.
latestInstalledRuntime()reads the newest finished EAS build, not what is on the phone. An older binary on the phone, or a build still queued, and the wrong QR is expanded.
Net: the install path is "scan, install, come back, expand a collapsed section, scan again, confirm" — with a silent wrong answer if you do it in the other order.
Why nothing tells you you're stale#
expo-updates is left on its default checkAutomatically: ON_LOAD. So on
every launch it does check and download in the background — but it applies
on the next launch, and says nothing. The only visible freshness checks
are two explicit ones: the Build info button, and the QR screen's per-mount
pull. On a PR channel you can sit on JS from three pushes ago and the app will
look completely normal.
Why it says "not signed in"#
One string, in the QR screen's mismatch card:
value={`${mismatch.current || "not signed in"} → ${mismatch.recommended}`}mismatch.current is the email signed in on the recommended server — not
the app's session. Phone signed into prd, bundle stamped for preview_7, and
the card announces "not signed in", which reads as a claim about you.
Underneath: there is no app-global session at all. hasSignIn(baseUrl) and
getSignedInEmail(baseUrl) are re-derived from the keychain at each callsite,
per server URL, and two screens separately catch SignInRequiredError and
router.replace("/").
Needs a repro before I trust the diagnosis: if you also see it outside the QR screen, the cause is different (most likely the server base URL moved to a deployment you have no keychain entry for, so
getAccessTokenthrowsSignInRequiredError("Not signed in.")and bounces you to the sign-in screen). Say which screen you saw it on and I'll pin it.
What Build info doesn't show#
You asked for current OTA branch, the build's default OTA branch, and commit
messages. Today: Updates.channel and the override are both shown but
unlabelled as default-vs-current; the running bundle's commit message is
shown; the available update's commit message is not shown at all, because
nothing looks at it.
Corrections#
Both found while implementing, both change the design rather than the goal.
1. eas.json IS a fingerprint source#
The plan said the channel was safe in eas.json because fingerprint never
reads it. Wrong — expo-updates fingerprint:generate lists it explicitly:
{"type":"file","filePath":"eas.json","reasons":["easBuild"],"hash":"331eee28…"}
Measured: changing one profile's channel moves the fingerprint from
fdfdff89 to 793605ca. Rewriting eas.json per PR would therefore have
given each PR's build its own runtime version — and that build would refuse
the very updates the PR publishes. The exact failure the plan claimed to be
avoiding.
(preview and production share a runtime today only because they live in
the same file: the bytes don't differ per profile.)
Fix: fingerprint.config.js gains ignorePaths: ["eas.json"]. Measured
again with the ignore in place: the channel change leaves the hash
byte-identical (fca56340 both times).
Two consequences, both accepted:
- The baseline hash moves once (
fdfdff89→fca56340), so every installed binary needs one rebuild. CI already triggers one when no build matches. eas.json's other fields (distribution,developmentClient,simulator) stop bumping the runtime. They shape which binary you get, not which JS a binary can run, and each already lives on its own channel.
2. There is no honest "incompatible" update status#
The plan wanted the app to distinguish "you're current" from "the channel has
newer JS your binary can't run". It can't. checkForUpdateAsync answers
noUpdateAvailableOnServer for both, because the update server filters by
runtime version before it replies.
Same reason the app can't say how many commits behind you are — only that something newer exists. So the copy is "You're on the latest update this build can run", and the banner names the commit instead of counting.
The module#
apps/mobile/src/lib/build-state.ts. One owner for the whole question.
Absorbs build-info.ts, preview-channel.ts, native-install-guard.ts, and
the queries currently inlined in the four screens. expected-backend.ts stays
as it is — it is pure, well tested, and about backends, not builds — but its
callers go through this module.
Shape#
// Pure. Every branch unit-tested in the node lane, no Expo imports.
export function describeBuildState(facts: BuildFacts): BuildState;
// Effectful. One query key, one mutation set. What the views use.
export function useBuildState(): BuildState;
export function useBuildActions(): BuildActions;
// Mounted once in _layout.tsx. Owns the on-open check and the stale banner.
export function UpdateWatcher(): JSX.Element | null;type BuildState = {
/** The JS actually executing. */
running: {
branch: string;
commit: string;
message: string;
builtBy: string;
publishedAt: Date | null;
source: "metro" | "embedded" | "ota";
};
/** The binary underneath it. */
binary: {
version: string;
buildNumber: string;
runtimeVersion: string;
installedAt: Date | null;
/** The channel baked in at build time — "the default OTA branch for my
* build", and with per-PR builds also the branch the binary was made for. */
defaultChannel: string;
};
/** What updates are actually fetched from — the override, else
* binary.defaultChannel. "Overridden" is just current !== defaultChannel. */
channel: string;
update:
| { status: "current" }
| { status: "behind"; latest: { commit: string; message: string; publishedAt: Date } }
| { status: "pending" } // downloaded, applies on reload
| { status: "incompatible" } // channel has newer JS this binary can't run
| { status: "unsupported"; why: "metro" | "dev" }
| { status: "checking" }
| { status: "error"; message: string };
/** Not on the binary's own channel, or not a main binary: watch on every open. */
watched: boolean;
/** From expected-backend.ts — what this bundle wants the app pointed at. */
expectation: Recommendation;
};
type BuildActions = {
switchChannel(channel: string | null): Promise<"reloading" | "no-update">;
checkNow(): Promise<void>;
applyPending(): Promise<never>; // reloadAsync
resetToDefaultChannel(): Promise<void>;
};describeBuildState takes plain facts in — the stamp, the Updates.*
constants, the stored override, the result of a check — and returns the view
model. That is where every rule lives (is this watched? is this incompatible
or merely behind? is source embedded or OTA?), and it is testable without a
device.
Getting the available update's commit message#
checkForUpdateAsync() returns the manifest of the update it found, and
manifest.extra.expoClient is the app config as evaluated at publish time. So
if the stamp is also written into app config extra.buildInfo — not only
into src/build-info.json — we can show the branch, commit and message of an
update we have not downloaded yet. That is what turns "an update is available"
into "you're 1 behind: fix the drawer glyph".
Cost: app.json becomes app.config.js so it can read the stamp, and
fingerprint.config.js gains ExpoConfigExtraSection to its sourceSkips —
without it, every commit changes the fingerprint and strands every installed
binary. Verify with npx expo-updates fingerprint:generate before and after
a stamp change; do not merge on assumption.
If that turns out to be fragile, tier 1 without it still works: "a newer
update was published 12 minutes ago" from manifest.createdAt alone.
What the views become#
-
Build info — one
useBuildState()call, rows and four buttons. No queries of its own. Gains the rows you asked for:CHANNEL Current specs-create-agent-sweep Default for build specs-create-agent-sweep (this build is the PR's) RUNNING JS Branch specs/create-agent-sweep Commit 3f0e48f Playwright sweep: adopt createAgent Published 14:22, 8 minutes ago Source OTA update UPDATE Status 1 behind — "fix the drawer glyph", 2 minutes ago [ Update now ] -
QR confirm screen — keeps its two decisions (confirm before switching, auto-pull once on target) but reads state and calls actions instead of owning six queries. Target: under 200 lines.
-
_layout.tsx— renders<UpdateWatcher />instead of an inline guard query and anAlert. -
index.tsx— readsexpectationoff the module.
The four fixes#
1. Per-PR native builds, so the install QR lands on the PR's channel#
The install QR keeps pointing straight at the EAS install page and the OTA QR
keeps being a bare iterate:// link. What changes is the binary: a PR's
build gets that PR's channel baked in, so installing it is being on the PR's
JS. No interstitial, no second scan, no ordering to get wrong.
Where the channel can live. Not in app config. @expo/fingerprint
normalizes the Expo config and strips nothing under updates except
updates.url, so putting updates.requestHeaders["expo-channel-name"] in
app.config.js would move the runtime fingerprint on every PR — which would
break the OTA path for every PR with no native changes. The channel has to
stay where it is today: eas.json, which fingerprint never reads. (That
is exactly why the preview and development profiles share a runtime
version today.)
eas build has no --channel flag (checked against eas-cli 21.0.1), so CI
sets it the same way it already sets the bundle stamp: write the working tree,
build, don't commit.
- Add a
preview-prprofile toeas.json, a copy ofpreview. - Before triggering,
publish-mobile-pr-preview.tsrewrites that profile'schannelto the PR's channel.eas-cliuploads the working tree, so the value travels with the job — same mechanism assrc/build-info.json. eas build --profile preview-pr --no-wait.
One build per PR branch, not per push. ensureBuildForRuntime becomes
ensureBuildForPr({ channel, runtime }): reuse a build whose runtime and
channel both match, otherwise trigger one. So the first push to a mobile PR
costs a build (~15–20 min, EAS build minutes — worth watching); every later
push reuses it and rides OTA. A mid-PR fingerprint change triggers a fresh
one, as today.
Both QRs become individually correct.
| QR | Gets you | Correct when |
|---|---|---|
OTA (iterate://preview-channel/<channel>) |
the PR's JS on the binary you already have | your binary's runtime matches |
| Install (EAS build page) | a binary whose own channel is the PR's | always |
So the expansion heuristic stops mattering: pick wrong and you lose a scan,
not an afternoon. latestInstalledRuntime() stays only as the label that
decides which to expand.
The new-install guard stops being a footgun.
resetChannelOverrideForNewInstall() clears any override on the first boot of
a new binary. Today that means installing a PR build drops you onto main.
Once the binary's own channel is the PR's, clearing the override lands you
exactly where you wanted — the guard becomes purely protective. No change
needed to it, which is the point.
Also fix while in here: ensureBuildForPr should prefer a finished
build and render "build in progress — install link appears when it's done"
rather than linking a queued build as though it were installable.
2. Check on open#
UpdateWatcher, mounted at the root:
- runs when
watchedis true (channel !== binary.defaultChannel || binary.defaultChannel !== "preview"— an override, or a non-main binary, which with per-PR builds is now most of them); - runs on mount and on
AppStatereturning toactive, so the check happens on every open rather than every cold start; - shows a non-blocking banner —
1 behind: "fix the drawer glyph" · Update now— rather than reloading under you mid-typing; - when expo-updates has already downloaded it in the background,
"Update now" is just
reloadAsync().
3. App-global sign-in#
New src/lib/session.ts — small, one query key:
useSession(): { serverBaseUrl: string; signedIn: boolean; email: string | null };Invalidated by sign-in, sign-out and server switch; nothing else re-derives
it. The QR mismatch card names the server it is talking about
(preview 7 — needs a sign-in rather than the bare not signed in), and the
two duplicated SignInRequiredError → router.replace("/") catches collapse
into one place.
4. Build info shows what you asked for#
Covered by the module's BuildState — current channel, the build's default
channel, running commit + message, latest commit + message. Listed here so it
is checkable.
Work#
-
src/lib/build-state.ts: puredescribeBuildState+BuildStatetype, with unit tests for everyupdate.statusbranch — splitbuild-state-core.ts(pure, 11 tests) +build-state.ts(Expo/query binding), following the repo's existing*-core.tsidiom so the node lane can cover the rules - Fold in
build-info.ts,preview-channel.ts,native-install-guard.ts; delete them -
useBuildState/useBuildActions - Rewrite
build-info.tsxas a dumb view; add the channel/update rows - Rewrite
preview-channel/[channel].tsxagainst the module; confirm + auto-pull behaviour intact, specs green -
src/lib/session.ts+ adopt it inindex.tsx,projects.tsx,chat.tsx, the QR screen - Reword the mismatch card so it never claims you are signed out of the app —
none on preview 3 → pr…plus the real session underneath - Stale banner + on-open check —
components/update-banner.tsx; the foreground trigger is react-query'sfocusManager, whichquery.tsalready wires to AppState, so no listener of our own - Stamp into app config
extra.buildInfo;app.json→app.config.js; addExpoConfigExtraSectiontosourceSkips; verify the fingerprint does not move — not done; the reader below is in and falls back cleanly, so this is the only thing standing between "something newer exists" and "…and here's its commit message" - Read the available update's stamp off
manifest.extra.expoClient.extra—stampFromManifest, shape-checked; returns{}until the config change above lands -
eas.json: add apreview-prprofile; CI rewrites itschannelper PR before building -
ensureBuildForPr({channel, runtime}): match on channel too, prefer finished builds, report in-progress ones honestly - Confirm the runtime fingerprint does not move when only
eas.json's channel changes — it did move; see Corrections.ignorePaths: ["eas.json"]fixes it, measured either side -
renderPreviewSection: say what each QR now guarantees; the install one is no longer a half-measure - Web specs: the reworded mismatch card.
stale banner, "1 behind" row— web bundles reportupdate: unsupported, so neither renders there; the rules behind them are covered in the node lane instead - Update
apps/mobile/README.md— the "Per-PR channels" section still said installing gets you a main binary
Decisions I made without asking#
- Banner, not auto-reload, when a newer update is found. Reloading under you mid-note is worse than being one push behind. Easy to flip.
expected-backend.tssurvives. It's pure, tested, and about backends, not builds. Folding it in would make the module a junk drawer.- Session is its own module, not part of
build-state. Different lifetime, different invalidation, and every screen needs it while only three need build state. - Per-PR native builds over an https interstitial, per your call. If the build cost or latency turns out to be annoying, the interstitial (deep-link-then-install on one page) is still there as a fallback.
Answered (annotation round 2)#
- Where the "not signed in" was seen: don't remember — assume the QR confirm screen, i.e. covered by fix 3. If it shows up again after this lands, it's a second bug and gets its own hunt.
- Stale banner scope: overridden channels and non-
previewbinaries only. A phone tracking main stays silent, as now. - Build-per-PR cost: accepted. A cleverer compromise (only build when the fingerprint actually differs) can come later if the wait bites.
Implementation log#
build-state-core.tsis pure andbuild-state.tsis its Expo binding — the repo's existing*-core.tssplit (approver-core.ts,recent-photos-core.ts,media-sync-core.ts). One conceptual owner, and the rules stay node-testable.expected-backend.tsreads the stamp from the core, so it stays Expo-free too.- The banner component lives in
src/components/, not exported from the module, matching where every other component lives. BuildState.channelisstring | null, notstring: a Metro bundle genuinely has no channel and the pure function shouldn't invent one.isOverridden(state)is a helper overchannel !== binary.defaultChannelrather than stored state, per the annotation.- The dead-sign-in redirect moved to the query cache's
onErrorinquery.ts— one place, and it now covers every screen rather than the two that happened to catch it.
Still open#
- No device pass yet. The web lane can't exercise OTA at all
(
Updates.isEnabledis false), so the banner, the on-open check and the install-then-run flow are unproven on hardware. - The first PR to run this triggers a fresh EAS build, which is also the first real test of the channel-baking.
Record#
Planning and implementation up to commit d778360b0 ran on a weaker model.
Misha then asked for the request/plan/feedback/takeaways to be captured and
for the whole thing to be re-thought from the top on the assumption the
earlier work was flawed. This section is that capture; the re-audit's
findings follow it.
The initial request (2026-08-28, paraphrased tightly)#
- The mobile-PR QR codes "aren't really working well". The native-build QR should ALWAYS work: correct build AND corresponding OTA branch. It should keep landing on the page with the Install button. After installing, the app should show a screen (Build Info or other) with: current OTA branch, the build's default OTA branch, and commit messages.
- On every app open, if on a non-main native build OR any custom OTA branch, check whether it's on the latest commit of the relevant branch.
- Stop saying "not signed in" outside a project — signed-in state is app-global.
- All these queries/mutations are "jumbled up and spread all over the place"; organize them into a single module/class, with Build Info and the update UIs as simple dumb views.
- Process: plannotator the plan, including an explanation of how it currently works.
The plan, and the annotation feedback#
Plan v1 proposed: the module (build-state), a session module, an on-open
check with a banner, and — for the QR — a smarter https interstitial carrying
channel + build id. Misha's annotations:
isOverrideshould be derived (defaultChannel !== current), not stored.- No interstitial.
iterate://straight to the app for OTA; the native build straight to the install page. Do the "alternative": per-PR native builds with the PR's channel baked in. - Per-PR builds accepted ("if it's annoying we'll do the interstitial later"); round 2: assume the "not signed in" sighting is the QR screen; stale banner only on overridden channels + non-preview binaries; build-per-PR cost is fine.
Takeaways#
- Misha's annotations consistently removed indirection — derived over stored, direct links over interstitials, the structural fix (build for the channel) over the compensating fix (smarter landing page). Propose the grain-following variant first.
- Measure claims, don't argue them. The plan's one measured claim
(fingerprint vs
eas.json) was FALSE despite sounding authoritative; the measurement was the only thing that caught it. See Corrections. - Each sentence of the request is a checklist item. "After I install it it should show me a screen" is a behaviour (present the screen after install), not an existence claim (the screen exists). The weaker model read it as the latter; the re-audit caught it.
- Green CI is not an audit. All checks were green while the check-for-update button was dead on the most common phone state.
Re-audit#
A from-scratch review of the shipped branch (request re-read literally, code re-read with no trust). Five defects and one missed requirement, all fixed on the branch:
- Dead "Check for update" button on main phones (the common case).
checkNowusedrefetchQuerieson a query that is disabled for unwatched builds — react-query skips disabled queries, silent no-op — and the!armed → idlemapping would have discarded the result anyway. Fixed: imperativefetchQueryinto the same cache (disabled observers still receive cache updates), and the mapping no longer gates onarmed. - No way back to main on a per-PR binary. The per-PR-builds design changed what "Reset to default channel" means — the default IS the PR channel, which cleanup deletes on merge, stranding the phone. The PR-body footer still promised the old behaviour. Fixed: Build info grows a "Switch to main (preview)" button on non-main binaries; footer copy updated (+ test).
- Stale update verdict across channel switches.
switchChannelnever touched the check cache, so Build info could report the old channel's freshness. Fixed:removeQuerieson switch (remove, not invalidate — on an unwatched build nothing would refetch, and stale-but-displayed is the bug). - Override-read race on the QR screen. Until the AsyncStorage read
lands,
channelfalls back to the binary's default, so the screen could flash "You're on this channel" and fire the freshness pull — and its reload — against the wrong channel. Fixed:readyflag on the hook; the QR screen gatesalreadyOnTargeton it. - Answerless taps.
updateNow→ "up-to-date" changed nothing visibly; a "no-update" channel switch likewise. Fixed: the up-to-date verdict is recorded as a check result, and the switch outcome is exposed and shown. - Missed requirement: show the screen after install. The install guard already detects "first boot of a new binary"; it now also reports that, and the root layout opens Build info on it — the install is followed by the screen naming the channel and commit you landed on. Inert on first ever run (a new user's first open must not detour through Build info).
Also folded in: projects.tsx sign-out now goes through useSignOut (the
session cache has one owner; knip doesn't cover apps/mobile, so its dead
exports don't get flagged), and readSession is no longer exported.
Field test (Misha's phone, 2026-08-28 evening)#
Misha scanned the real install QR for this PR and hit a compound failure the re-audit missed. His trace, decoded:
- First boot: the old jargon Alert about a cleared
mobile-voice-clientoverride — word salad, two mystery buttons. (The alert predates this branch's later commits; the binary embeds JS from build-trigger time.) - "A newer update is available" right after installing — correct, not a bug: the binary compiled at the trigger commit while the branch kept moving; the banner is the catch-up. Surprising without explanation though.
- Tapped Switch to main (preview) → "nothing happened": main publishes
for a different runtime (it doesn't have this PR's fingerprint change), so
there was nothing to pull — but the override to
previewpersisted. - Kill + relaunch → expo-updates found nothing runnable for
previewand fell back to the EMBEDDED bundle — older JS than he'd been running. - Build info then showed Default for this build = preview: at the native
layer
Updates.channelisrequestHeaders["expo-channel-name"]with the persisted override merged in at launch (verified in expo-updates' AppController.swift / UpdatesConfigOverride.swift; the plist's original headers never cross the bridge). So the app computed "not overridden, main binary" → unwatched, no reset button, no way back.
Fixes (same branch)#
- Baked channel learned, not assumed. The stored override is captured at
import time (before the guard or any tap can rewrite it); on a boot that
launched override-free,
Updates.channelis trustworthy and gets cached per binary id. Until one clean boot happens the default is honestly unknown (null) — and unknown counts as watched, since the just-installed boot is exactly when freshness matters. - Build info replaces the Alert. New-binary first boot opens Build info, with the cleared override (if any) as a plain-words note. The screen's labeled rows and buttons ARE the explanation; no modal.
- Explicit switches revert on no-update. Build info's switch-to-main undoes the override when the target serves nothing this binary can run, with copy saying why ("native code differs; resolves when the PR merges"). The QR flow stays sticky on purpose — there, CI is about to publish for your runtime and the copy says the override sticks.
- With defaultChannel honest, the stuck state itself self-heals: overridden computes true against an unknown default, so "Reset to this build's own channel" renders and recovers in one tap.
Recovery for the currently-stuck phone#
Scan the PR's OTA QR once — it re-points the override at mobile-build-state
and pulls latest (which includes all of the above). Reinstalling would NOT
help: same binary id, the guard stays quiet and the preview override
persists.
Follow-up (requested, on hold until Misha says go)#
- On PR merge: collapse the PR's QR section, and when main's build for the new fingerprint finishes, a CI job finds the merged PR and puts MAIN's QR code(s) into its body — so getting onto latest main never means hunting commit comments. (Asked 2026-08-29; explicitly "don't do that yet".)
Field test round 2 (2026-08-29)#
Misha reinstalled the PR build and hit the OLD alert again + a mislabeled banner. Why: the binary is reused per branch by design, so its EMBEDDED JS is frozen at build-trigger time (4cdeb36) — first-boot UX comes from that old bundle no matter what's published since. One Update-now later the alert is gone for good on this binary (it fires once per binary change).
Real gaps in head code this exposed, fixed here:
- Build info's switch/update buttons swallowed rejections (
void mutateAsync) and rendered no error — a failed tap was pixel-identical to "nothing happened". Errors now render. - The no-update outcome was one line of small grey text — functionally invisible. Outcomes are now cards, with per-case copy: switching to main on a native-changed PR binary says plainly that main has no JS this build can run, nothing changed, and it resolves on merge.
- The install note copy tightened again ("bro" pass).