Repository navigation
fix(release): refuse to ship a ref that lags the newest native release - #3110
Conversation
Both resolvers produce a number that outranks the binary whether or not the commit contains it: `ota` lands the next bundle inside the newest native build's range, `native` returns latestBuild + 1. Neither asks about the code. A bundle numbered above the binary is accepted by every device, so an OTA published from a lagging ref is a silent downgrade of the JS that binary shipped with — and a store build in that state is worse, since only another store release undoes it. On 2026-09-10 production bundle 1.6.1 was published from ff34896, the tip of main, which did not contain the v1.6.0 release cut from dev the day before. Every install on the 1.6.0 binary took it and ran older JS. check-native-ota-surface already asserted this ancestry, but only as a precondition of its fingerprint diff — in the deploy job, after a full install and native build, reported as "v1.6.0 is not an ancestor of HEAD". A lagging ref whose native surface happened to match would have passed it outright. Both lanes now assert it before anything is built, and name the back-merge as the remedy. The native lane tolerates the first release of a new major, which has no earlier build to contain. Also recorded in docs/NATIVE-RELEASE.md: that bundle shipped through a tag push, and a push-triggered workflow runs the workflow file as it existed at the pushed ref — so it ran the long-retired capgo-deploy.yml still present at that commit, with no floor check and no surface check. Retiring a workflow does not retire it at older commits, and the same applies to `v*`.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code-analysis diffPainscore total: 8994.43 → 8994.43 (0) |
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Production is stuck on bundle 1.6.1 — JS older than the 1.6.0 binary it runs on — and every attempt to replace it fails the native-surface check. The whole drift between `dev` and v1.6.0 is two lines: the `/app` and `/app/` App Links paths d91ea7c added to AndroidManifest.xml. An intent filter cannot reach a device over the air. Those two lines were therefore inert on every shipped binary while blocking every bundle that could have fixed the fleet, so they buy nothing where they are and cost everything. Reverted byte-exact to v1.6.0 so the fingerprint matches and the OTA can ship; a comment marking the revert would itself change the bytes and fail the check, which is why the note lives in docs/NATIVE-RELEASE.md instead — at the top of "Cutting a release", where whoever cuts the next one will read it. iOS is untouched: apple-app-site-association still claims `/app` and native-routes.ts still maps `/app/*` → `/app`, so only Android `/app` deep links are missing, exactly as they already are on every install in the field.
…ve release" This reverts commit 478cd04.
There was a problem hiding this comment.
Chip review — changes requested
The dispatch-time ancestry checks are directionally correct, but the incident's legacy tag-push path remains available and the first native release of a new major bypasses the actual provenance floor.
Findings
-
BLOCKING · .github/workflows/release-ota.yml:74 · Legacy tag pushes still bypass the new provenance guard — also flagged by moonshotai/kimi-k3
This guard exists only in the current workflow-dispatch definition. If someone pushes a freshota-*tag atff3489650or another historical commit that still contains the retired tag-triggeredcapgo-deploy.yml, GitHub runs that historical workflow, which uploads to production without executing this step; the repository has no tag ruleset, and the Production environment has no protection or deployment-branch policy, so the incident path is still reachable. Block legacyota-*/v*tag creation or enforce a Production deployment policy/credential boundary that historical tag workflows cannot satisfy, then verify the incident ref is rejected. -
MAJOR · .github/workflows/release-native.yml:81 · A new major's first native release skips the real provenance floor — also flagged by moonshotai/kimi-k3
Whenpackage.jsonadvances to a major with no matching native tag,native-floorfails and this branch exits successfully. A lagging allowed ref can therefore build2.1.0without containing the latestv1.x.0release, producing a higher store version with older code—the exact failure this guard is meant to prevent. On the no-current-major path, resolve the newest valid native tag across prior majors and require it to be an ancestor; only skip when the repository has no native release tag at all, and add a cross-major stale-ref regression test. -
MINOR · .github/workflows/release-ota.yml:74 · [claude-opus] New release-provenance guard has no test, unlike every sibling guard in these files
Both workflows gate production release state (the Capgo production channel and store binaries) on a newGuard release provenancestep, and neither is covered by a test. This repo already tests this exact class of shell guard:scripts/__tests__/release-branch-guards.test.js:10regex-extracts theGuard release refstep out ofrelease-ota.yml/release-native.ymland runs it under bash against accepted and rejected values, andscripts/__tests__/android-release-workflow.test.js:15/staging-ota-workflow.test.js:18assert thenative-floorcommand strings verbatim. CONTRIBUTING.md:519 makes this a hard rule for anything that mutates shared state; a production OTA reaches every install.
Exact untested cases:
release-ota.yml:74— a ref that does not containv$FLOORmust exit 1 with::error::. This is the 2026-09-10 incident the PR exists to prevent, and nothing pins it. A future edit to the tag prefix (native-floorreturns a bare1.6.0; the guard prependsv) or to themerge-basedirection would pass CI and only surface at release time.release-native.yml:81— theif ! FLOOR="$(... 2>/dev/null)"; then echo ...; exit 0fallback. With stderr discarded, any non-zero exit fromrelease-version.mjs native-floor— not only the intended first-release-of-a-new-major case — skips the guard and lets the build proceed. That silent-skip branch is precisely what a test would pin, and it is also the behaviour prior finding P2 flags as wrong.
Fix: add scripts/__tests__/release-provenance-guard.test.js following the release-branch-guards.test.js pattern — extract the step body, run it in a temp git repo with a v<floor> tag on and off the HEAD ancestry, and assert exit 1 + ::error:: for the lagging ref, exit 0 for the containing ref, and (for the native lane) assert what the resolver-failure branch is allowed to swallow.
Checked clean
- Verified the detached worktree HEAD and merge base exactly match the supplied head and base SHAs.
- Reviewed the complete three-file diff and the surrounding OTA/native release, version resolver, tag, and native-surface paths.
- Confirmed the retired capgo-deploy workflow still exists at historical commits with an ota-* push trigger and production upload credentials.
- Checked live repository rulesets and the Production environment: rulesets target branches only, and the environment has no protection rules or deployment-branch policy.
- Exact-head unit, typecheck, eslint, format, native-export, ds-lint, and analysis checks passed; aggregate CI and preview deployment were still in progress when reviewed.
Security review by moonshotai/kimi-k3: 2 finding(s), marked with the model name. It reads the diff only and answers only security, privacy and money, so treat its findings as advice.
Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.
Exact head: f463e45226d4 · Context: repo · Took 5m
Production is serving bundle 1.6.1 — JS older than the 1.6.0 binary running it — and every replacement fails check-native-ota-surface. The whole native drift between dev and v1.6.0 is d91ea7c's `/app` claim: two lines in AndroidManifest.xml, three in the AASA. v1.6.0 predates that commit, so no shipped binary and no shipped AASA ever carried the claim, and an intent filter cannot be shipped over the air. The manifest half did move the native fingerprint, which is what the surface check compares against the binary an OTA targets — so those lines blocked every bundle that could have fixed the fleet while delivering nothing to anyone. Now that the client-side store-update gate has landed, the version this unblocks matters as much as the unblocking. A native release would number its bundle 1.7.0, which the gate reads as needing a newer binary on every 1.6.0 install — pinning the fleet behind a store update that only exists in TestFlight and Play internal, invisibly on iOS where the listing is not live. 1.6.2 shares the binary's build number, so the gate passes it and the OTA lane stays open. Both platforms are reverted together. Dropping only the Android half would manufacture exactly the drift app-links.test.ts pins, so the parity and shape cases stay enforced and only the two `/app`-presence cases are `it.skip`, with the reason and the restore condition inline. docs/NATIVE-RELEASE.md carries the same note at the top of "Cutting a release", where whoever cuts the next one will read it: restore the AASA entries, the manifest entries and the two cases together, in that PR, never on dev alone.
Chip review — changes requestedThe dispatch guards catch stale refs on the normal paths, but legacy tag pushes still bypass them, first-of-major native releases skip the real floor, and the temporary /app rollback can enter the next native build. The new guard also remains untested and its ancestry documentation is inaccurate. Findings
Inline anchors unavailable for 6 finding(s); the findings remain in this summary. Checked clean
Security review by Third opinion by Exact head: |
Stops the thing that shipped old JS to production from being able to happen again.
What happened
Production is serving bundle
1.6.1— JS older than the1.6.0binary running it.ota-1.6.1points atff3489650, the tip ofmainon 2026-09-10, which does not containv1.6.0(cut fromdevat331002ff8the day before). It sorts above1.6.0, so every install on the new binary accepted it.All three
ota-*tags were hand-pushed, and only one of them did anything:ota-1.6.1ff3489650capgo-deploy.yml→ it ran → shipped the old JSota-1.6.2780ab610fcapgo-deploy.ymlat that commit → no workflow ran; shipped nothingota-1.6.3780ab610fA
push-triggered workflow runs the workflow file as it existed at the pushed ref. Retiringcapgo-deploy.ymlondevdid not retire it at older commits — thev*prefix has the same exposure.The guard
Neither resolver can tell a lagging ref from a current one.
otalands the next bundle inside the newest native build's range;nativereturnslatestBuild + 1. Both produce a number that outranks the binary whether or not the commit contains it — the numbers come from the tags, and nothing asked about the code.check-native-ota-surface.mjsdid assert this ancestry, but only as a precondition of its fingerprint diff: in the deploy job, after a fullpnpm installand native build, reported asv1.6.0 is not an ancestor of HEAD. A lagging ref whose native surface happened to match would have passed outright — a fingerprint says nothing about how old the JS is.Both lanes now assert it before anything is built, and name the remedy:
ff3489650(the incident commit)dev/main/release/android-kycThe native lane gets it too, where the same mistake is worse: a higher versionName carrying older code, undoable only by another store release. It tolerates the first release of a new major, which has no earlier build to contain (
native-floorhas no answer there, whilenativecorrectly starts at<major>.1.0).Placed after
setup-nodeand beforepnpm install, so a bad ref fails in seconds instead of after a build.Why unblocking the OTA is not in this PR
I tried the cheap route and it was wrong. The whole native-surface drift between
devandv1.6.0is two lines — the/appand/app/App Links paths fromd91ea7cee— and since an intent filter cannot reach a device over the air, reverting them looked free.478cd04did that;f463e45reverts it.src/utils/__tests__/app-links.test.ts(added by the same commit) caught it, and it is right to:/appis the smart download link every QR encodes. An installed user who scans one has to land in the app — that is the point of claiming it (TASK-21788). Not cosmetic./app. Either way the fix is editing a deliberate guard.There is also no sanctioned shortcut around the surface check for this:
LEGACY_COMPATIBLE_INPUTS.androidincheck-native-change-scope.cjsis{ 'android/app/proguard-rules.pro' }, so anAndroidManifest.xmldelta can never qualify for a replacement attestation.So the only correct unblock is cutting native
v1.7.0— which is what the check has been asking for.devandmainare identical and both containv1.6.0, so it is dispatchable now, and the release auto-publishes a matching1.7.0production bundle from its own commit, restoring current JS.Before that dispatch
Delete the two phantom tags, or the next two runs go red after a successful ship:
nextOtaresolves1.6.2(the channel serves1.6.1), deploys fine, then the tag job runsgit tag -a ota-1.6.2and fails because the tag exists. Re-run and it repeats on1.6.3. Neither records a bundle, and both point at a commit nothing shipped from:ota-1.6.1should stay — it is the real record of what went out.Also worth confirming
channel currentBundle productionreally reads1.6.1. That is inferred from run history — the 18:05 tag-push deploy was the last successful production write, and both attempts after it failed before the upload step.Verification
bash -n.app-links,native-routes,native-fingerprint,check-native-ota-surface,release-version.tsc --noEmitclean;prettier --checkclean.