HOMELAB-1679: fix(forgejo): pin the merge POST to the checked head — head_commit_id (LR-20261111-017, review B5) #126
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "lr-20261111-017-headpin"
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 (l), homelab-88 review finding B5 — a live gap in today's landing tool, fixed first per El Director. This PR touches the merge write path, so it is gate-changing: two pane seats plus homelab-88's file review land before it merges.
The race
forgejo_merge_prjudged a head inassertLandingLockand then discarded it. Between that judgement and the merge POST — the lock's reads, up to 10 s of seat lookups, credential staging, the identity GET — a push can land a new head, and Forgejo would merge that unchecked head (no verdicts, no GO cover it).The fix
assertLandingLocknow returns the judgedheadSha, and the merge write sendshead_commit_id: <judged head>in themergeAsLandingbody. Forgejo answers 409 when the live head has moved; nothing merges that the lock did not judge.Proven
tests/landing-head-pin-017.test.ts: asserts the merge POST body carrieshead_commit_idequal to the exact head the lock verified (and keeps the pin when the caller also passes a method/branch-deletion). Both tests fail on the old code (demonstrated viagit stash: 2 failed on base, 2 passed with the fix) — El Director's red-on-old-code requirement.vitest run: 51 files, 1214 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.Lab-Run: LR-20261111-017
infra review (homelab-88), file review-88.md — the finding this PR fixes, quoted verbatim:
This PR is the quoted fix. Record note only, not a verdict — the pane seats own the verdicts.
LD-VERDICT APPROVED head=f903a3a80868ffb13fe17180ac440ccde21049f1 session=LR-20261111-017-seatA-20261111T1420Z
Reviewed the pinned head
f903a3a(PR head read at review time matched the pin exactly). Diff vs origin/main is 3 files, +108/−2: forgejo/api.ts, forgejo/index.ts, forgejo/tests/landing-head-pin-017.test.ts.(1) The pin is the JUDGED head, threaded, never a re-read.
pr.head.shais captured once in collectLandingEvidence (index.ts:322) and handed to evaluateLandingLock (index.ts:427) — the same value the CI leg and the exact-head verdict ledger are measured against.headSha: evidence.headSha.head_commit_id: lock.headSha, added to the one write at api.ts:1533 (POST repos/…/pulls/{n}/merge, body = caller data verbatim). Field spelling matches Forgejo's MergePullRequestOption, so the compare-and-swap is the forge's own 409.forgejo_merge_pris the only write tool (index.ts:744), it calls mergeAsLanding exactly once (index.ts:830), and mergeAsLanding → mergePR is the only path to the endpoint (api.ts:1559).forgejo_landing_censusissues no write. No fallback, no conditional, no disable parameter.(2) Nothing else changed. The api.ts hunks are additive type fields plus doc comments; the index.ts hunks are the widened return type, the extra returned field, and the body field with comments. No behaviour drift, no new tool, parameter or surface.
tsc --noEmitexits 0 on the pinned head.(3) The test genuinely fails on the old code. Verified directly: with tests/landing-head-pin-017.test.ts kept and api.ts+index.ts reverted to origin/main, both tests fail (2 failed) with exactly the missing
head_commit_idin the POST body; restored to the pinned head, both pass (2 passed). The assertions check the full 40-hex sha from the fake PR head, so a re-read or a truncated prefix would not satisfy them.Full suite on the pinned head: 51 files, 1214 pass, 1 fail — tests/merge-identity.test.ts:143, the documented pre-existing ambient-landing-token failure in this sandbox, not introduced here.
Holds: none.
Observation (non-blocking, not a hold):
head_commit_idis optional at the library type level (api.ts:1529 and api.ts:1561). The one production caller always supplies it, so no exposed path can omit the pin today; a future direct caller of mergeAsLanding could.No code edited; the red-on-old-code check was a temporary local revert in my own clone, restored and confirmed clean at
f903a3abefore posting.model: omniroute/deepseek-flash
session: LR-20261111-017-seatA-20261111T1420Z
LD-VERDICT APPROVED head=f903a3a80868ffb13fe17180ac440ccde21049f1 session=LR-20261111-017-seatB-20261111T1426Z
Judged the diff at the pinned head only (PR reports head
f903a3a808, base0c5ef847a3— matches the pin; branch lr-20261111-017-headpin fetched, clone read-only in my own folder).Scope: 3 files, +108/−2 — forgejo/api.ts (one optional field added to each of mergePR / mergeAsLanding's body type, comments only otherwise), forgejo/index.ts (assertLandingLock carries the judged head out; merge body sends it), one new test file. No tool parameters added, no new tool, no other behaviour touched.
The pin is the JUDGED head, not a re-read:
headShaand throws when it is empty (index.ts:317-323), so a lock cannot pass with a blank pin.headSha: evidence.headShainto evaluateLandingLock (CI, verdict ledger, dangerous-class, holds all evaluated against it).headSha: evidence.headShawith no read in between.head_commit_id: lock.headSha, and api.ts:1533 forwards the body verbatim (data ?? {}→ JSON.stringify at the request layer). No re-read of the PR head exists on the path between judgement and POST, andhead_commit_idis not an exposed tool parameter, so a caller cannot override or drop it.finallycleanup at index.ts:887), so a 409 from a moved head propagates as a tool error instead of merging an unjudged head.Red on old code (verified, not taken on trust): kept tests/landing-head-pin-017.test.ts, restored api.ts and index.ts to origin/main, ran the file → 2 failed (assertion shows the POST body missing head_commit_id); restored the change → 2 passed. The second test also guards that Do / DeleteBranchAfterMerge survive alongside the pin.
Suite: 51 files, 1214 passed, 1 failed — only tests/merge-identity.test.ts ("merges with the dedicated landing credential, never the operator token"), which fails identically on the untouched base in this sandbox (ambient landing token): pre-existing, not a hold.
tsc --noEmitclean.Holds: none.
Non-blocking observation (not a hold, no behavioural gap today):
head_commit_idis declared optional in both body types (api.ts:1529, api.ts:1566), so the pin is enforced at the single call site rather than by the write helper — a future second caller of mergeAsLanding could omit it silently. Worth a required-field tightening if a second merge path ever appears.model: omniroute/deepseek-flash
session: LR-20261111-017-seatB-20261111T1426Z