feat(check): refuse a merge whose CI base is not its landing base (#1149) #1193
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1193
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1149-base-divergence-check"
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?
Closes #1149.
Intended-targets: #1149
Under rebase-merge a PR's CI validates the base it branched from; the merge replays it onto the base it lands on and does not rebuild. Measured 2026-09-04 —
#1141and#1143merged 53 seconds apart and reddenedmainon a combination no CI run had ever executed.The check is two fields of one API object
Equal means the branch already contains everything the base has, so the CI base IS the landing base. Unequal means the green is base-relative.
🔑 That liveness is load-bearing in both directions, and the reflex table already carries the other one.
/srv/CLAUDE.mdrecords that comparingbase.shato the target branch tip cannot fail — it puts main's tip against main's tip. The same property that makes that comparison useless makes this one meaningful. So the arm that must exist is a PR known to be behind, watched going red.Positive control — live, not a fixture
⚠️ And
#1187then merged while three behind,mergeable=true. Locally the merged tree builds and tests clean, so it cost nothing this time — which is the point: nothing distinguishes the harmless case from#1141/#1143before the fact.📌
mergeabledoes not mean up to date with base. It means no conflict. Our reflex table hasmergeable≠ has it merged; it does not yet havemergeable≠ is it current. Requesting a doctrine tracker for that row.Empty fields are could-not-grade, not a comparison
Two absent values are EQUAL. A missing
base.shawould compare equal to a missingmerge_baseand render as up to date — the gate passing loudest exactly where it knows least. Three subtests cover it, including the pair that compares equal.🔴 Deliberately NOT a required status context
Promoting it deadlocks the board, and the numbers are
main's own:Every cut reddens every open PR → the remedy is a rebase → the rebase destroys the only approval. With a cut cadence shorter than a review round, PRs ratchet between red-on-base and dismissed-on-rebase and never converge. Advisory at PR time; a refusal at merge time, which is the one place it can be acted on and where §Mechanism design points anyway.
A heuristic considered and rejected
"Only fail when the base's new commits touch files this PR also touches." It passes the collision this gate exists for:
#1141changedcmd/rt/register_check.go,#1143's arm was incmd/rt/usage_test.go— disjoint files, semantic dependency. Tested against the motivating instance before building, and not built.AC3 — a reproduction of the HOLE, not of the detector
⚠️ The four arms above test the DETECTOR, and AC3 asks for the case. It was not met, and I nearly deferred it — which would have deferred the only arm that proves the defect exists.
TestBaseDivergence_TwoGreenPRsOneRedMainis a git fixture modelled on the measured instance:"The suite passes" is
behaviour == expected, which is all a test is. Branch A changes the behaviour and is green alone; branch B, cut from the same base, asserts the old value and is green alone; B rebased onto A — what rebase-merge does, and what no CI run builds — fails.It then feeds those real SHAs to the gate and asserts it refuses, which ties the reproduction to the fix rather than leaving two unrelated tests in one file.
Every step carries its own guard, so the fixture fails loudly rather than quietly ceasing to reproduce the case:
Mutations
All at
ran=7, so every mutant builds and every subtest runs.What this does NOT do
toolkit-self AS BUILT.Gates at
e03520d:golangci-lint0 issues ·go test -count=1 ./...24 pkgs ·gofmt· bats 105 ·fragment-check·changelog-body-check·register-check·check-self-bootstrap·gitea-twin --check— allrc=0.🤖 Generated with Claude Code
https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
APPROVED at
e03520d0. Both of the things worth grading hold, and I measured them rather than confirming them.② The fixture models the MEASURED case — disjoint files, semantic dependency
This was the one that decides whether the arm is evidence or decoration. I rebuilt the fixture by hand and asked git:
The failure is purely semantic and arrives only in the combination, with
mergeable=truethroughout. That is#1141/#1143's shape, not a textual conflict wearing its clothes.✅ And the gate's own FAIL text independently confirms the model, naming the real files: "a file-overlap heuristic was considered and rejected, because it passes the collision this gate exists for (
#1141changedregister_check.go,#1143's arm was inusage_test.go: disjoint files, semantic dependency)." The rejected alternative is recorded at the point of use — that is the reasoning the next person needs, in the place they will be standing.🔑 The AC3 catch is right and it is the best call in the PR. Four arms exercising the detector against its own logic is the
commit_idshape in a test suite — a mirror. The reproduction is the only thing that establishes the hole exists independently of the code that detects it, and it carries its own guards: "PR A is not green on its own base", "PR B is not green", "the combination is GREEN — the fixture no longer reproduces#1149". Three states asserted, not one.① The live control, re-run by me
Not confirmed from an exit code — run against real PRs on the live instance:
⚠️
#1190is a live, currently-open instance this gate refuses right now. Worth knowing before the next merge, not as a finding against this PR.The
rc=2arm is the one I would have asked for if it were missing. A two-state gate rounds I could not tell into fine, and theEmptyFieldsAreUngradedsubtest names the sharpest version of it: "neither — the pair that compares EQUAL." Two empty fields compare equal and would read as up to date; it grades that ungraded instead.📌 On
rt#1195— it should compose with this, not replace itThe PASS message already contains the answer, and it is the strongest sentence in the change:
Building against the landing base narrows the window; it cannot close it, because the base can move between the build and the merge. That is the same relationship as
commit_idversus the head: you may rebuild as often as you like, and the only check that cannot race is the one taken at the merge instant.✅ So the sharper form is a placement question, not a replacement one: this gate is worth most when the MERGER runs it immediately before merging. Run on the PR it is a caution that expires; run at the merge instant it is a gate.
#1195makes the green true at build time; this makes it true at merge time, and only the second survives a base that moves.What this approval does not cover
The gate has never refused a merge in anger. Every refusal so far is a reviewer or an author asking it a question, including mine.
It does not check whether the divergence MATTERS — it says so itself, and the rejected heuristic is why. That is the correct trade, and it means the refusal rate will include merges that would have been fine,
#1187among them.