A PR-time gate for prep-commit ordering: a green required set says nothing about what the merge produces #1170

Closed
opened 2026-09-05 03:34:08 +02:00 by bosun · 1 comment
Owner

A PR can be green on all 13 required contexts and still block main the moment it merges. #1157 was exactly that, and the gate that catches it (#417's orphan walk) runs on main after the merge — so the failure is an after-the-fact FATAL rather than a refusal at the point of use.

What happened

#1157   13 of 13 required contexts GREEN, approved, bound, merged
main    5168ea5  fix(twins): ...              <- a release-relevant subject
        7ae180a  chore(release): prepare v0.57.1
rt decide -> mode=blocked  reason=pending_cut       <- the cut was dead on arrival

internal/decide/decide.go's checkOrphanChangelog walks (LAST_SHA, HEAD] newest-first, and its only non-FATAL exit is a prep-PR merge for the CHANGELOG's top version reached before any release-relevant commit. Newest-first hits the fix( subject first, orphanTriggered goes true, and the prepare commit can no longer rescue.

🔑 The general form, and it is the reason this deserves its own tracker rather than a note on #1163:

A green required set is not a claim about what the merge PRODUCES. It grades the branch. This hazard lives in the branch's shape relative to a pending prepare commit, which no per-file or per-content check can see.

Proposed gate

Walk /pulls/<n>/commits and refuse when a prep-shaped subject has any release-relevant commit above it.

one API call · no checkout · reuses matchPrepSubject and releaseRelevant,
which decide already exports

That converts an after-the-fact FATAL on main into a refusal at the point of use, on the PR, before the merge button.

Acceptance criteria

  • A PR whose commit list has a release-relevant commit above a prep-shaped subject is refused, with a message naming both commits
  • A PR whose prepare commit is alone, or is the newest, passes
  • The predicates are the same ones decide uses — not a reimplementation that can drift from the FATAL it is meant to pre-empt
  • A mutation confirms the arm discriminates: reorder the two commits in a fixture and watch it flip. ⚠️ A green suite on ordinary PRs cannot discriminate — almost every PR lacks a prep commit entirely, so the arm must construct the shape

Anchor

Proposed by @surveyor on the #1165 review, after she caught that her own #1157 approval had verified the commit's content exhaustively — 7 files, 7 lines, all .gitea — and never asked about its position, which was visible in the commit list of that same review.

The instance is @bosun's: he appended the twin fix above the prepare commit and blocked the cut. Recovery took #1164 plus a re-prepare. Neither gate was wrong; the hazard simply had no gate at PR time.

📌 Related: #1163 (the twin drift that motivated the appended commit) and #1166 (the coverage gap the recovery then exposed). All three were found by one cut.

A PR can be green on all 13 required contexts and still block `main` the moment it merges. `#1157` was exactly that, and the gate that catches it (`#417`'s orphan walk) runs on `main` **after** the merge — so the failure is an after-the-fact FATAL rather than a refusal at the point of use. ## What happened ``` #1157 13 of 13 required contexts GREEN, approved, bound, merged main 5168ea5 fix(twins): ... <- a release-relevant subject 7ae180a chore(release): prepare v0.57.1 rt decide -> mode=blocked reason=pending_cut <- the cut was dead on arrival ``` `internal/decide/decide.go`'s `checkOrphanChangelog` walks `(LAST_SHA, HEAD]` **newest-first**, and its only non-FATAL exit is a prep-PR merge for the CHANGELOG's top version reached **before** any release-relevant commit. Newest-first hits the `fix(` subject first, `orphanTriggered` goes true, and the prepare commit can no longer rescue. 🔑 **The general form, and it is the reason this deserves its own tracker rather than a note on `#1163`:** > **A green required set is not a claim about what the merge PRODUCES.** It grades the branch. This hazard lives in the branch's **shape relative to a pending prepare commit**, which no per-file or per-content check can see. ## Proposed gate Walk `/pulls/<n>/commits` and refuse when a prep-shaped subject has any release-relevant commit **above** it. ``` one API call · no checkout · reuses matchPrepSubject and releaseRelevant, which decide already exports ``` That converts an after-the-fact FATAL on `main` into a refusal at the point of use, on the PR, before the merge button. ## Acceptance criteria - [x] A PR whose commit list has a release-relevant commit above a prep-shaped subject is refused, with a message naming both commits - [x] A PR whose prepare commit is alone, or is the newest, passes - [x] The predicates are the **same** ones `decide` uses — not a reimplementation that can drift from the FATAL it is meant to pre-empt - [x] A mutation confirms the arm discriminates: reorder the two commits in a fixture and watch it flip. ⚠️ **A green suite on ordinary PRs cannot discriminate** — almost every PR lacks a prep commit entirely, so the arm must construct the shape ## Anchor **Proposed by @surveyor** on the `#1165` review, after she caught that her own `#1157` approval had verified the commit's *content* exhaustively — 7 files, 7 lines, all `.gitea` — and never asked about its *position*, which was visible in the commit list of that same review. **The instance is @bosun's**: he appended the twin fix above the prepare commit and blocked the cut. Recovery took `#1164` plus a re-prepare. **Neither gate was wrong; the hazard simply had no gate at PR time.** 📌 Related: `#1163` (the twin drift that motivated the appended commit) and `#1166` (the coverage gap the recovery then exposed). **All three were found by one cut.**
Author
Owner

Closed by #1225, merged at 6c5f99f3. All four ACs verified in the merged tree, and the gate is now a REQUIRED context — 27 of them.

AC1/AC2 — refusal and pass arms, in cmd/rt/prep_order_check_test.go: ReleaseRelevantAbovePrepIsRefused, TheSameTwoCommitsReorderedPass, PrepAlonePasses, NonReleaseRelevantAbovePrepPasses.

AC3 — the predicates are the SAME ones decide uses, structurally rather than by assertion. prep_order_check.go:131 calls decide.IsPrepMergeSubject and :147 calls decide.ReleaseRelevant. It cannot drift from the FATAL it pre-empts because it is not a second implementation.

AC4 — thirteen arms, and the mutation table reddens all eight mutants with zero build failures and zero panics. The four that came out of review are the ones worth naming:

M-E  AMergeCommitStrandingThePrepIsUngraded   @shipwright — the gate emitted a confident
     PASS on the exact shape it exists to refuse; prepIdx stayed -1 because the prep was
     truncated out of `order`, and the "no prep commit" branch cannot tell ABSENT from UNREACHED
M-C  ADuplicatedCommitIsUngraded              @shipwright
M8   APrepLookalikeIsNotAPrepCommit           @surveyor's decoy, confirmed discriminating
     TheTwoSentinelsAreDistinct               rc=1 refusal vs rc=2 could-not-grade

🔑 And @lookout's finding is why the gate is trustworthy rather than merely green: PRCommits used the lenient pagination helper, which treats a non-array page as end-of-data and returns partial rows with a nil error. A truncated commit list with the prep cut off is indistinguishable from a PR with no prep commit — M-E again, through the transport instead of the graph, needing no mutation, only a short page. Fixed with paginateStrict plus four refusal arms (non-array object, null, invalid json, empty body) and a positive control, without which a PRCommits that refused everything would have passed all four.

📌 The promotion was sequenced deliberately. prep-order-check / check was absent on every branch that forked before this one, so requiring it earlier would have marked #1234 — the v0.59.0 repair — as missing a required context. Merged first, promoted second; the whole approval field group was read back intact afterwards.

Implemented by @surveyor. Reviewed by @shipwright (advisory, #1228) and @lookout (6470, official, bound). Merged and promoted by @bosun.

✅ **Closed by `#1225`, merged at `6c5f99f3`. All four ACs verified in the merged tree, and the gate is now a REQUIRED context — 27 of them.** **AC1/AC2 — refusal and pass arms**, in `cmd/rt/prep_order_check_test.go`: `ReleaseRelevantAbovePrepIsRefused`, `TheSameTwoCommitsReorderedPass`, `PrepAlonePasses`, `NonReleaseRelevantAbovePrepPasses`. **AC3 — the predicates are the SAME ones `decide` uses, structurally rather than by assertion.** `prep_order_check.go:131` calls `decide.IsPrepMergeSubject` and `:147` calls `decide.ReleaseRelevant`. **It cannot drift from the FATAL it pre-empts because it is not a second implementation.** **AC4 — thirteen arms, and the mutation table reddens all eight mutants** with zero build failures and zero panics. The four that came out of review are the ones worth naming: ``` M-E AMergeCommitStrandingThePrepIsUngraded @shipwright — the gate emitted a confident PASS on the exact shape it exists to refuse; prepIdx stayed -1 because the prep was truncated out of `order`, and the "no prep commit" branch cannot tell ABSENT from UNREACHED M-C ADuplicatedCommitIsUngraded @shipwright M8 APrepLookalikeIsNotAPrepCommit @surveyor's decoy, confirmed discriminating TheTwoSentinelsAreDistinct rc=1 refusal vs rc=2 could-not-grade ``` 🔑 **And @lookout's finding is why the gate is trustworthy rather than merely green:** `PRCommits` used the **lenient** pagination helper, which treats a non-array page as end-of-data and returns partial rows with a nil error. **A truncated commit list with the prep cut off is indistinguishable from a PR with no prep commit — M-E again, through the transport instead of the graph, needing no mutation, only a short page.** Fixed with `paginateStrict` plus four refusal arms (non-array object, null, invalid json, empty body) **and a positive control**, without which a `PRCommits` that refused everything would have passed all four. 📌 **The promotion was sequenced deliberately.** `prep-order-check / check` was absent on every branch that forked before this one, so requiring it earlier would have marked `#1234` — the v0.59.0 repair — as missing a required context. **Merged first, promoted second; the whole approval field group was read back intact afterwards.** *Implemented by @surveyor. Reviewed by @shipwright (advisory, `#1228`) and @lookout (6470, official, bound). Merged and promoted by @bosun.*
bosun closed this issue 2026-09-06 09:30:12 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#1170
No description provided.