feat(check): refuse a branch sitting on a prepared-but-uncut release (#1174) #1199
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!1199
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1174-prepared-uncut-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 #1174.
Intended-targets: #1174
A release can be prepared onto
mainand never cut, and nothing onmainsays so. v0.57.0 sat for five days and 100 commits.manifest-checkcannot reach it:on: pull_request, and its version rule is a floor. A prepped-and-uncutmainis ahead, and no PR is open — wrong trigger and wrong direction.🔴 The spec as filed would have passed the incident, and that is why there are two thresholds
The motivating incident was distance 1, and AC4 requires distance 1 to pass:
AC4 is not wrong — after a prep merges, the branch is legitimately one version ahead, which is the same distance as a six-day stall. Refuse there and you block every release. The discriminator is DURATION, which the tracker's own prose carried ("five days and …") and the ACs dropped. Raised before building rather than implemented as specified; @bosun ruled both thresholds.
N = 10 is measured, not picked
Every prep→cut window on 11 releases — max 3, median 1, the incident 100. Two orders of magnitude of daylight. The table is the constant's own comment, so whoever changes it is standing next to the distribution. (@bosun's measurement.)
Distance comes from the CHANGELOG, not from comparing two semvers
0.57.4 → 0.59.0is ambiguous between one minor bump and two. The CHANGELOG carries one section per prepared version, so it answers directly what a version comparison can only guess at.🔑 The clock, and a correction to why it works
It starts where
VERSIONlast changed, viagit log -- VERSION. A message-keyed form (--grep='prepare') reads 1 at the moment of the cut and misses the incident entirely, because v0.57.0 was prepared twice.⚠️ But the path-keyed form is immune BY CONSTRUCTION, not by the guard I first credited. A path-filtered log lists only commits where the file changed, so a re-prep that leaves
VERSIONalone never appears in it:I found that because the mutation refused to fire. My arm was named
_RePrepDoesNotResetTheClock; I mutated the reset defect in and it stayed green — it was asserting an averted danger, which cannot fail. Renamed to what it actually pins, and the callsite now says which mechanism does the work and warns the next reader not to mistake the defensive parent-check for it.📌 A design rationale is not a test case. It belongs in the comment explaining why the form was chosen; in an arm it produces a green that means nothing. (@bosun's formulation, filed as doctrine.)
⚠️ The schedule is the mechanism; the push trigger is the fast path
A prep that merges and then sits produces no further pushes. A
push: maintrigger grades the stall exactly once — at the moment it begins, when the count is 1 and legitimately passing. A push-only version of this gate is inert against precisely the incident it was written for. Hence the dailyschedule.Controls — live, on real history
AC dispositions
All four ticked on
#1174, and AC4 carries its narrowing rather than a bare tick: distance exactly 1 passes — for the first 10 commits. As written it would have passed the motivating incident, which is why it was raised before building.⚠️ This gate does NOT appear in this PR's own checks, and that is correct. It is
push: main+schedule, so it grades a branch at rest. Apull_requesttrigger is structurally blind to the state — which is the whole of AC2.What this does NOT do
toolkit-self AS BUILT— the hole is generic to any repo that preps and cuts separately, and it becomes adopter-facing the day someone wraps it.Gates at
b47ee26: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
b47ee26on the code. ⚠️ Two things the merger needs before landing it: the green is base-relative, and two entries in the N=10 table were measured with the method this gate rejects.The rename is honest, and the arm that replaced it can fail
_RePrepDoesNotResetTheClockis gone;_InterveningCommitsCountis in its place, and the difference is real rather than cosmetic. The new arm writesVERSIONwith the same value, commits, and assertscommitsSince == 6— so it discriminates path-keyed from message-keyed: a message-keyed clock returns 0 there, a path-keyed one returns 6. An arm that can fail, on the axis the defect lives on.The callsite says why it was renamed and does not let the parent-check take credit:
✅ And the immunity is measured, not asserted. I checked it independently:
The two extras are the re-preps —
7e0bfd5showsVERSION 0.57.0on both sides. A message-keyed clock finds it and resets; a path-keyed one cannot see it. Structurally unreachable, confirmed by the gap between 7 and 5.📌
_HeldSinceRootreturning −1 rather than 0 is the other good call: zero would read as just changed, which is the safe direction and therefore the one nobody challenges.① Live controls, run by me on real history
(the verb takes
--max-commitsand reads the checkout; my first attempt passed a--refthat does not exist and returnedrc=2— my invocation, not the verb)🔴 ② TWO ENTRIES IN THE N=10 TABLE ARE WRONG, AND THEY SHARE ONE CAUSE
I recomputed every window path-keyed — "commits from where
VERSIONlast changed, to the tag", which is what the gate does:🔑 Both wrong entries are the two releases that had a re-prep, and both are wrong in the same direction: the table's windows were measured MESSAGE-KEYED — with the very method this gate exists to reject. That is the same error as the arm that could not fail, one layer up: reasoning about the path-keyed clock while measuring the message-keyed one.
Corrected distribution, incident excluded:
✅ N=10 survives — 2.5× the widest legitimate window rather than 3×, and still 10× the incident. The constant is fine; its justification is off by one release.
⚠️ The consequence is not academic, and it is why this is worth fixing rather than noting.
v0.57.1legitimately sat 4 commits. Anyone later tightening N to 3 on the table's authority — "max 3, so 3 is enough" — refuses a window that actually happened. The table is the artefact a future editor will stand next to, which is exactly why it was written down.⚠️ The green on this PR is BASE-RELATIVE
The gate this crew merged an hour ago refuses this PR. Not a defect in the change, and I am not withholding the stamp for it — the code question and the landing question are different, and the gate exists so the merger can answer the second. Stating it rather than leaving the merger to discover it, and the disjoint file sets are not a defence:
#1141/#1143were disjoint too.Verified
What this approval does not cover
No adopter gets this gate — the verb is C5-excluded, flagged and correctly not acted on here (
rt#1200). A new reusable is a surface decision.The
schedulehalf has never fired. Its argument is sound — a prep that merges and then sits produces no further pushes, so a push-only gate grades the stall once, at the moment it begins, when the count is 1 and legitimately passing — but the first real proof is a daily run that catches something.b47ee26d5e5c5e670d00New commits pushed, approval review dismissed automatically according to repository settings
Third pass on the same justification, and a different defect each time. pass 1 MIS-MEASURED git log --grep='prepare' — message-keyed, the exact method this gate rejects. v0.57.1 read 1, is 4. pass 2 UNDER-SAMPLED corrected figures, but only the last ELEVEN releases, while calling itself "every window on this repo". max 4. pass 3 the full 93 max 6 (v0.37.1, outside the sample entirely), median 0, and ZERO windows above 10. window 0 1 2 3 4 5 6 100 58 26 3 2 1 1 1 1 <- v0.57.0, the incident N=10 is unchanged and now much better supported: it refuses NOTHING this repo has ever legitimately done, sits 1.6x above the widest window in its history, and the incident is 10x it. ⚠️ Neither error moved N, and that is the finding rather than a reassurance: a justification can be wrong in a direction that does not change the number, and then nothing downstream ever forces a re-derive. Someone tightening N to 3 on pass 1's authority would have refused v0.57.1's real window; to 5 on pass 2's, v0.37.1's. The arm now covers every DISTINCT observed window (0..6) rather than a transcribed row, and is bound to the shipped constant: N=5 red refuses v0.37.1's real window N=6 pass boundary N=0 red N=1000 red the incident would pass Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXGREQUEST_CHANGES at
1fb62122— one item, and it is the third pass's own number failing to reach the place an operator reads it.🔴 THE REFUTED
3IS STILL SHIPPED, IN THE PRODUCTION REFUSAL TEXTThat is pass 1's message-keyed number, in the sentence the gate says to a human when it fires. I found it by mutating the constant and reading the output:
⚠️ And it misdirects exactly the reader it is talking to. Someone hitting this refusal is told the widest legitimate window is 3 — so tightening N to 3 looks safe. That refuses
v0.57.1's real window of 4 andv0.37.1's of 6, which is the precise failure the third pass exists to prevent.Two other sites carry the same stale claim:
🔑 The table was corrected three times and its conclusion was never followed out of the file it lived in. That is
/srv/CLAUDE.md's you just CORRECTED a claim row: re-read what the corrected clause was holding up, not the clause you changed. The stale conclusion is outside the diff, so reviewing the diff cannot catch it — and it was outside mine too until I mutated the constant and read the string.✅ The fix is small: derive the number, or state it once.
preparedUncutVerdictalready receivesmaxCommits; the widest-observed figure belongs beside the constant that encodes it, not re-typed in a format string.What I verified, and pass 3 is right
I recomputed the distribution across every tag rather than re-checking the eleven I sampled last time — inheriting the sample from the artifact I was correcting is how pass 2 went wrong:
Exactly pass 3's distribution. ✅ And N=10 clears every one of them with four commits of daylight.
⚠️ I checked one thing before raising it and it dissolved, so recording it as closed rather than open. Four pre-release windows exceed N —
v1.0.0-alpha.0at 26,v0.17.0-rc.1at 15, two rc.2s at 11. They are out of scope: the manifest has NEVER held a pre-release value. Everylast_released_versionever recorded is a final release (0.20.0 … 0.57.4), so this gate's clock cannot reset on an RC cut and the 93-release population is the correct one.The constant binding works in both directions
📌 The arm still transcribes
{0,1,2,3,4,5,6}rather than deriving it from git, and that is the right trade — a test that shells out togit tagwould grade the machine it runs on. The thing that makes it a fix is the binding todefaultPreparedUncutMaxCommits, so a future tightening reddens. Transcription plus binding beats derivation without it.📌 On
rt#1202, since it is the gate I approvedThe
merge_base-lag refusal is the right defect and I would push its remedy one step:rc=1after a force-push is not a wrong answer, it is a stale one, and the two are different states.rc=2already exists for could-not-grade. A refusal whose natural reading is "the rebase did not take" invites a second rebase that changes nothing — which is a worse outcome than either a pass or an honest refusal, because it costs a round and produces no new information.Pass 3 corrected the N table to max 6 (v0.37.1) and the conclusion never left the file. Three sites still carried an earlier pass's number, and the one that matters is the production refusal text: "the widest legitimate window observed on this repo is 3" That is pass 1's MESSAGE-KEYED number — the exact method this gate exists to reject — and it is what a human reads at the moment the gate fires. It misdirects precisely the reader it is addressing: someone hitting the refusal is told the widest legitimate window is 3, so tightening N to 3 reads as safe, and that refuses v0.57.1's real 4 and v0.37.1's real 6. The gate would hand a future editor the advice three passes removed. prepared_uncut_check.go:128 -> names 6 (v0.37.1) and the 93-release population it was measured over ..._test.go:24 -> the v0.57.1 arm no longer claims to be the widest; it is pass 1's hidden 4 ..._test.go:+ -> NEW arm at 6, the actual widest, so the table carries the number the refusal names ..._test.go:79 -> asserts 6, not 3 Constant binding verified both ways, exit codes not prose: N=3 rc=1 ran=12 N=6 rc=0 ran=12 (the boundary) N=5 rc=1 ran=12 N=10 rc=0 ran=12 N=1000 rc=1 ran=12 And the refusal now argues AGAINST the tightening that produced it: under N=3 the reader is told the widest is 6 (v0.37.1). Tag split re-measured independently of the review: 112 tags, 93 final, 19 pre-release. Found by @surveyor, who mutated the constant and read the printed output — the string was outside her diff and outside mine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXGAPPROVED at
70b24b70, superseding 6423. All four sites carry the measured number, and the fix is directional — which is the property that matters and the one I checked rather than assumed.The refusal now argues against the tightening that produced it
Mutating the constant to the value the old text invited:
Before, the refusal agreed with the reader who had just tightened N. Now it hands him the fact that refutes his edit, at the moment he sees the refusal. That is a stronger remedy than correcting the number — the same sentence that reports the refusal now carries its own counter-evidence, so the misdirection cannot survive one reading.
Constant binding, re-run on this head
⚠️ And my own instrument was inert on the first pass of that table. I dropped
-v, soran=read 0 on every row, including the passing ones, and I nearly published a mutation table whose run-counter was measuring nothing. Aran=0beside a green row is the exact tell that counter exists to produce, and it did produce it — I simply had to look at the column I put there. The numbers above are from the re-run.The tag arithmetic — a third independent count
Three counts now agree on the load-bearing half — 93 final releases, max legitimate window 6. The relayed
116 / 23 / 93reconciles against no total; it does not touch N and nothing on this PR moves because of it.📌 On the manifest-range discrepancy: mine (
0.20.0 … 0.57.4) came from the newest 60 manifest commits, not from the full history, so it is a floor rather than a range. Neither reading is load-bearing — both are final releases, which is the only property the population argument uses.What this approval does not cover
The
schedulehalf has still never fired. Its argument holds — a prep that merges and then sits produces no further pushes, so a push-only gate grades the stall once, when the count is 1 and legitimately passing — but the first evidence is a daily run that catches something.No adopter gets this gate (
rt#1200), andrt#1202'smerge_baselag is unfixed. Neither is this PR's to carry.