HOMELAB-1679: fix(forgejo): harden the head pin — named 409 refusal, sequenced-head test, required pin (LR-20261111-017, review N1–N4) #127
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "lr-20261111-017-headfollow"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Spec 017, follow-up to #126 (B5). All four of homelab-88's non-blocking findings from the #126 review, each with a test that fails on the old code. This PR touches the merge write path — gate-changing: two pane seats plus homelab-88's file review land before it merges.
The four fixes
HeadMovedRefusal(head moved since lock checked <sha7> — the PR gained a commit after the landing lock judged <full 40-hex>; re-review needed on the new head). No retry; exactly one merge POST is ever sent. Test: red on old code, green asserts the named sentence, one POST, no second.toMatchObjecttest would stay green. Mutation-verified.mergeAsLandingrequireshead_commit_id(exactly 40 or 64 hex, refused otherwise with a named one-line error), and the rawmergePRis un-exported —git grepshowsmergeAsLandingas its sole caller, so the unguarded write path no longer exists.Proven
vitest run: 52 files, 1226 pass; the 1 failure istests/merge-identity.test.ts, which fails identically on the untouched base in this sandbox (ambient landing token) — pre-existing, not from this change.tsc --noEmitclean. Runs under--experimental-strip-types(a parameter-properties break in the new error class was caught and fixed).Lab-Run: LR-20261111-017
LD-VERDICT APPROVED head=0c38e2434c6f03ac68de9a3e273cd45359b832d2 session=LR-20261111-017-seatA-20261111T1510Z
Chair: seat A (fresh single-run). Head checked is the PR's current head (0c38e24…), base
491882a…; the pin in this verdict matches the live head, so no head-drift refusal.WHAT I CHECKED
The pin is the JUDGED head, not a re-read, and is not optional.
assertLandingLockreturnsheadSha: evidence.headSha(forgejo/index.ts:449-451), the same value it handsevaluateLandingLock— so the sha carried out is the one the lock judged, not a second read.forgejo_merge_pr(forgejo/index.ts:748), passeshead_commit_id: lock.headSha(forgejo/index.ts:848). Between that and the POST there is only credential staging and the identityGET /user; no pull re-read exists in the window, so nothing can substitute a fresher head.mergeAsLanding(forgejo/api.ts:1606-1638) requires the field in the type and re-checks it at runtime throughunknown(api.ts:1626-1636): must be a string matching the anchored/^[0-9a-f]{40}$|^[0-9a-f]{64}$/(api.ts:1549), otherwise a named one-lineHeadPinRequiredRefusaland zero writes.mergePRis now module-private (api.ts:1559); repo-widegit grep mergePRshowsmergeAsLanding(api.ts:1638) as its sole caller and no other merge endpoint write anywhere in the tree.mergeAsLandingitself has one production caller, the tool. So every merge path the extension exposes is pinned.The 409 handling is by STATUS, not prose, and nothing is retried.
request()now throwsForgejoApiErrorcarryingres.status(api.ts:255-263) with the historic message text unchanged; the merge tool translates onlyerr.status === 409into the namedHeadMovedRefusalquoting the judged sha short and full (index.ts:858-865).request()contains no retry loop, and the 409 test asserts exactly one merge POST and exactly one POST overall.Nothing else changed. Diff against origin/main is 6 files:
api.ts(error class + private raw write + required pin),index.ts(try/catch for 409 + an N4 comment), and tests (fake.tsgains an opt-inprHeadSequence/prHeadServedfixture, two new test files, one call-site in merge-identity.test.ts). No new tool, no schema/description change, no dependency change, no behavior drift on the success path (head_commit_idis passed through unchanged).tsc --noEmitexits 0. Full suite: 52 files, 1226 pass, 1 fail =tests/merge-identity.test.ts(ambient landing token), which fails identically on the untouched base — pre-existing, not a hold.The new tests genuinely fail on the OLD code. I kept the tests (including the new fixture) and reverted only
forgejo/api.ts+forgejo/index.tsto origin/main: 9 of 14 in the two head-pin files fail — the missing-pin refusal, the six malformed-pin cases (39/41 hex, non-hex, 7-char prefix, whitespace, empty), "does not export mergePR" (expected [AsyncFunction mergePR] to be undefined), and the 409 case (expected 'Error' to be 'HeadMovedRefusal'). The sequenced A/B case passes on old B5 by design — it is the regression guard, and itsprHeadServedassertions prove head B really was served to the post-lock read, so it would fail a pin fed by a re-read. Restored the tree to the reviewed head afterwards (git diff 0c38e24…empty).N4 is honest: the added note (index.ts:867-870) is comment-only, says the pin covers the head and NOT the comment record, and does not claim the hold/blocker window is closed.
HOLDS: none.
Non-blocking observations (no action required): the pin regex accepts uppercase hex, which Forgejo would simply answer 409 to — no wrong merge; and 409 is read as "head moved" unconditionally, which matches Forgejo's merge endpoint semantics (405 not-mergeable, 423 locked).
model: omniroute/deepseek-flash
session: LR-20261111-017-seatA-20261111T1510Z
LD-VERDICT APPROVED head=0c38e2434c6f03ac68de9a3e273cd45359b832d2 session=LR-20261111-017-seatB-20261111T1510Z
Reviewed at the exact pin: PR #127's current head is
0c38e2434c(base491882a), and I cloned the repo read-only, checked that sha out, and diffed it against origin/main (491882a). Six files, all in scope: forgejo/api.ts, forgejo/index.ts, forgejo/tests/fake.ts, forgejo/tests/landing-head-pin-017.test.ts, forgejo/tests/landing-head-pin-required-017.test.ts (new), forgejo/tests/merge-identity.test.ts.What I checked:
The pin is the JUDGED head. The head is read once (forgejo/index.ts:326) into
evidence.headSha, and that same value feedsevaluateLandingLockand is carried out ofassertLandingLock(forgejo/index.ts:426-450); the merge POST takeshead_commit_id: lock.headSha(forgejo/index.ts:848). No re-read anywhere between the lock's reads and the POST —readMergeOutcomeruns only after the write.Present in every merge path the tool exposes.
mergePRis now module-private (forgejo/api.ts:1559) with exactly one caller,mergeAsLanding(forgejo/api.ts:1638);git grepat this head confirms no other reference outside tests.mergeAsLandinghas a single call site (forgejo/index.ts:836), and the onlyPOST …/pulls/{n}/mergein the tree is forgejo/api.ts:1581.forgejo_merge_pris the only merge tool registered.N3 — required pin. Anchored 40/64-hex regex (forgejo/api.ts:1549), the options type requires
head_commit_id, and a runtime guard reads it throughunknownso anas anycaller still hits the namedHeadPinRequiredRefusalbefore any request is built (forgejo/api.ts:1624-1636). Prefixes, padded strings, non-hex and empty all refuse with zero writes.N2 — named 409. A
ForgejoApiErrorwith status 409 is rethrown asHeadMovedRefusal, one line, quoting the judged sha short and full (forgejo/index.ts:858-866). Nothing retries: the test asserts exactly one merge POST and exactly one POST overall. The status is carried on the error, not parsed from body prose, which is the right discriminator.No behavior drift or new surface.
request()only gains a typed subclass whose message is byte-identical to before, so nothing that reads the message changes; the one new export (ForgejoApiError) is what makes the 409 check possible, andMergeAcceptanceis a type-only import.tsc --noEmit -p tsconfig.jsonis clean. The N4 note is comment-only and honestly says the pin covers the head, not the comment record.Tests. Full suite at this head: 52 files, 1226 pass, 1 fail. The single failure is
merge-identity.test.ts > merge write credential > merges with the dedicated landing credential, never the operator token, which I reproduced identically on pristine origin/main (with the PR's tests aside) — pre-existing ambient-token condition in this sandbox, not a hold. Red-on-old-code verified by reverting only api.ts+index.ts to origin/main: the 409 naming test and all 9 assertions of landing-head-pin-required-017 fail there, with the new pin tests green. The sequenced-heads A/B test legitimately passes on the old code (the old code already pinned the judged head); I mutation-verified it is real by replacing the pin with a fresh re-read — the test then fails with the moved head (HEAD_B) in the POST body, so it will catch exactly the regression it claims to.One non-blocking observation: the PR body's "every fix ships a test that fails on the old code" is slightly overstated for N1 — that test is a mutation guard rather than a red-on-base reproduction, which the body itself then states correctly.
Holds: none.
model: omniroute/deepseek-flash
session: LR-20261111-017-seatB-20261111T1510Z