Skip to content

fix(release): refuse to ship a ref that lags the newest native release - #3110

Merged
innolope-dev merged 4 commits into
devfrom
fix/ota-release-provenance
Sep 10, 2026
Merged

innolope-dev merged 4 commits into
devfrom
fix/ota-release-provenance

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

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 the 1.6.0 binary running it.

ota-1.6.1 points at ff3489650, the tip of main on 2026-09-10, which does not contain v1.6.0 (cut from dev at 331002ff8 the day before). It sorts above 1.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:

tag commit what actually happened
ota-1.6.1 ff3489650 that commit still had the retired capgo-deploy.yml → it ran → shipped the old JS
ota-1.6.2 780ab610f no capgo-deploy.yml at that commit → no workflow ran; shipped nothing
ota-1.6.3 780ab610f same — shipped nothing

A push-triggered workflow runs the workflow file as it existed at the pushed ref. Retiring capgo-deploy.yml on dev did not retire it at older commits — the v* prefix has the same exposure.

The guard

Neither resolver can tell a lagging ref from a current one. ota lands the next bundle inside the newest native build's range; native returns latestBuild + 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.mjs did assert this ancestry, but only as a precondition of its fingerprint diff: in the deploy job, after a full pnpm 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 outright — a fingerprint says nothing about how old the JS is.

Both lanes now assert it before anything is built, and name the remedy:

::error::main (ff34896) does not contain the newest native release v1.6.0 —
publishing it would hand every device on that binary JS older than the one it
shipped with. Back-merge the release into this ref first.
ref guard
ff3489650 (the incident commit) BLOCKED — missing v1.6.0
dev / main / release/android-kyc PASS

The 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-floor has no answer there, while native correctly starts at <major>.1.0).

Placed after setup-node and before pnpm 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 dev and v1.6.0 is two lines — the /app and /app/ App Links paths from d91ea7cee — and since an intent filter cannot reach a device over the air, reverting them looked free. 478cd04 did that; f463e45 reverts it.

src/utils/__tests__/app-links.test.ts (added by the same commit) caught it, and it is right to:

  • /app is 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.
  • It pins iOS/AASA ↔ Android/manifest parity, because the two lists are hand-maintained copies that have drifted before, and a route claimed on one platform only reads downstream as "deep links are flaky on Android". Reverting one side manufactures exactly that drift; reverting both fails the test that requires iOS to claim /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.android in check-native-change-scope.cjs is { 'android/app/proguard-rules.pro' }, so an AndroidManifest.xml delta 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. dev and main are identical and both contain v1.6.0, so it is dispatchable now, and the release auto-publishes a matching 1.7.0 production 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: nextOta resolves 1.6.2 (the channel serves 1.6.1), deploys fine, then the tag job runs git tag -a ota-1.6.2 and fails because the tag exists. Re-run and it repeats on 1.6.3. Neither records a bundle, and both point at a commit nothing shipped from:

git push origin :refs/tags/ota-1.6.2 :refs/tags/ota-1.6.3

ota-1.6.1 should stay — it is the real record of what went out.

Also worth confirming channel currentBundle production really reads 1.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

  • Guard simulated against all four real refs (table above); YAML parses, guard bodies pass bash -n.
  • 469 tests pass across app-links, native-routes, native-fingerprint, check-native-ota-surface, release-version.
  • tsc --noEmit clean; prettier --check clean.

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*`.
@vercel

vercel Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 10, 2026 8:52pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 1fc75d45-8fbf-4262-ba29-7eddec2a4f45

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 8994.43 → 8994.43 (0)
Findings: 0 net (+0 new, -0 resolved)

@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • ✅ unit: 7067 ran, 0 failed, 0 skipped, 2.3m

📊 Coverage (unit)

metric %
statements 77.1%
branches 63.5%
functions 71.1%
lines 78.1%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Scan that recovers on the retry lands on the payment screen, not an error
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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.
@innolope-dev innolope-dev changed the title fix(release): refuse to ship a ref that lags the newest native release fix(release,android): unblock the stuck OTA and refuse a ref that lags the newest native release Sep 10, 2026
@innolope-dev innolope-dev changed the title fix(release,android): unblock the stuck OTA and refuse a ref that lags the newest native release fix(release): refuse to ship a ref that lags the newest native release Sep 10, 2026

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 fresh ota-* tag at ff3489650 or another historical commit that still contains the retired tag-triggered capgo-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 legacy ota-*/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
    When package.json advances to a major with no matching native tag, native-floor fails and this branch exits successfully. A lagging allowed ref can therefore build 2.1.0 without containing the latest v1.x.0 release, 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 new Guard release provenance step, and neither is covered by a test. This repo already tests this exact class of shell guard: scripts/__tests__/release-branch-guards.test.js:10 regex-extracts the Guard release ref step out of release-ota.yml/release-native.yml and runs it under bash against accepted and rejected values, and scripts/__tests__/android-release-workflow.test.js:15 / staging-ota-workflow.test.js:18 assert the native-floor command 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:

  1. release-ota.yml:74 — a ref that does not contain v$FLOOR must 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-floor returns a bare 1.6.0; the guard prepends v) or to the merge-base direction would pass CI and only surface at release time.
  2. release-native.yml:81 — the if ! FLOOR="$(... 2>/dev/null)"; then echo ...; exit 0 fallback. With stderr discarded, any non-zero exit from release-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

Comment thread .github/workflows/release-ota.yml
Comment thread .github/workflows/release-native.yml
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.
@innolope-dev
innolope-dev merged commit f22a759 into dev Sep 10, 2026
19 of 21 checks passed
@chip-peanut-bot

Copy link
Copy Markdown
Contributor

This pull request was already closed when the review finished, so these findings are follow-up work rather than a gate.

Chip review — changes requested

The 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

  • BLOCKING · .github/workflows/release-ota.yml:74 · Legacy tag pushes still bypass the provenance guard — also flagged by moonshotai/kimi-k3
    Pushing an ota-* tag at a pre-guard commit still runs capgo-deploy.yml as stored at that commit, with the production environment and no ancestry check; this new workflow_dispatch guard never runs. That is the exact route that shipped the stale bundle, so the PR does not yet prevent a recurrence. Enforce a Production environment deployment policy that rejects tag refs (and remove current tag triggers) or otherwise make the legacy workflows unable to use production credentials before calling this fixed.

  • MAJOR · .github/workflows/release-native.yml:81 · First release of a new major skips the actual provenance floor — also flagged by moonshotai/kimi-k3
    If package.json advances to major 2 while the newest native tag is v1.6.0, native-floor fails because it searches only major 2; this branch treats that failure as success and then resolves 2.1.0. A stale branch can therefore ship a higher store version without containing the latest shipped native code. Only skip ancestry when no native release exists at all; otherwise resolve the newest valid native tag across majors and require it to be an ancestor.

  • MINOR · .github/workflows/release-ota.yml:74 · The production provenance guard still has no regression test
    The new release-critical shell block is the only changed guard without a test. Exact-head CI can stay green if the inline command, its fail-closed behavior, or its placement drifts. Extract the ancestry check into a script and cover the incident ref, an up-to-date ref, a missing floor, and the first-new-major case; then assert both workflows invoke it before resolution/building.

  • MAJOR · android/app/src/main/AndroidManifest.xml:40 · The temporary /app rollback can ship in the next native binary
    This head removes the Android /app exact/prefix entries, removes the matching AASA entries, and skips the two tests that required them. Dispatching release-native.yml from this otherwise allowed head can therefore produce the next binary without the smart-link route used by every download QR, leaving installed users in the browser. A documentation reminder is not a release guard: make the native workflow fail while /app is absent (and restore the skipped assertions when cutting the release), or restore the entries before this branch is native-dispatchable.

  • MINOR · docs/NATIVE-RELEASE.md:428 · The ancestry documentation still contradicts the existing check
    check-native-ota-surface.mjs calls isAncestor(baseRef, headRef) and throws before computing any fingerprint diff, so a stale ref cannot pass merely because its native surface matches. The new guard is earlier and more actionable, but not semantically new. Correct this paragraph and the matching workflow comment so the release runbook describes the actual defense in depth.

  • MINOR · .github/workflows/release-ota.yml:74 · [claude-opus] New release-provenance guard is untested
    The guard added to both release workflows decides whether a production store build or an OTA bundle may be published — the shared state this repo guards most carefully — and it has no test, while every sibling guard in the same two files is covered: scripts/tests/release-branch-guards.test.js extracts the branch guard's bash out of release-ota.yml / release-native.yml / android-release.yml and runs it against accept and reject cases. Untested cases, exactly: (1) a ref that does not contain v$FLOOR must exit 1 and emit ::error::; (2) a ref that does contain it must exit 0; (3) the native lane's extra branch, where node scripts/release-version.mjs native-floor fails and the step exits 0 — that fallback exists only in release-native.yml and is the difference the two lanes were never asserted to have. A test in the shape of release-branch-guards.test.js (extract the run: block, drive it with a temp repo or a stubbed git merge-base/resolver, assert status and stderr per lane) would pin all three and would have made the native-lane divergence visible.

Inline anchors unavailable for 6 finding(s); the findings remain in this summary.

Checked clean

  • Pinned worktree HEAD and merge base match the supplied head and base SHAs; PR author and dev base match the trusted inputs.
  • Reviewed all six changed files plus release-version, native-surface checking, App Links routing, legacy tag-triggered workflows at the incident commit, and relevant history.
  • Exact-head substantive CI is green: aggregate CI, unit, native export, typecheck, lint, formatting, design-system checks, provenance, and analysis passed; preview deployment was still running when checked.
  • The normal OTA path now rejects a ref that does not contain its same-major native floor, and the normal native path rejects stale refs when a same-major floor exists.
  • The iOS and Android association lists remain mutually consistent after the rollback, but the intended /app presence assertions are explicitly skipped.

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: 0736c816c0b5 · Context: repo · Took 7m

This branch was successfully deployed

1 active deployment
Preview — 0736c816 Deployed Sep 10, 2026 by vercel[bot]
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