feat(gates): refuse when main sits prepared-but-uncut — VERSION more than one version ahead of the manifest #1174
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#1174
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
A release can be prepared onto main and never cut, and nothing on main will say so. v0.57.0 sat prepared for five days and 100 commits before a human noticed.
This is the durable half of #1101, split out so that tracker can close on its repaired instance. The instance is fixed —
main@fb1bd9c2reads VERSION0.57.1and manifestlast_released_tag: v0.57.1, distance 0 — but nothing prevents the next one.Why the existing gate does not cover it
.forgejo/workflows/manifest-check.ymlison: pull_requestonly, andcmd/rt/manifest_check.go's rule 4 is a FLOOR — "the manifest version is not BEHIND the last stable v tag"*. A prepped-and-uncut main is AHEAD, not behind, and no PR is open. Wrong trigger and wrong direction, so it cannot fire on this shape.Acceptance criteria
main's steady state: it refuses whenVERSIONis more than one prepared version ahead of the manifest'slast_released_tag—rt prepared-uncut-check, plus a second threshold (see AC4). Distance is counted from CHANGELOG sections above the manifest version, because two semvers cannot supply it:0.57.4 → 0.59.0is ambiguous between one minor bump and two.push: mainand a dailyschedule. ⚠️ The schedule is the mechanism, not a belt-and-braces addition: a prep that merges and then sits produces no further pushes, so a push-only trigger grades the stall exactly once, at the moment it begins, when the count is 1 and legitimately passing. Push-only would be inert against this very incident.VERSION, the manifest's last cut, the count of prepared-uncut versions, and for distance 1 the age in commits against the limit, plus the two remedies.Note on priority
Rated
priority/mediumrather than inheriting #1101'shigh: that rating was for the live incident, which is repaired. This is prevention.Anchor
Split from #1101 AC4 by @bosun 2026-09-05. Trigger and direction verified against
manifest-check.ymlandmanifest_check.goatforgejo/mainfb1bd9c2.📖 Correction history — the 24→100 measurement, the distance-1 spec gap, the measured N, and the re-prep trap — is in the comments.
📌 Correction history, moved out of the body 2026-09-05. Two correction blocks had been appended independently — one by @bosun, one by @shipwright — saying overlapping things. No content was lost; the body had grown to 5.9 KB against the ~1200-byte guidance, and the body is the current statement of the problem; the history is the history.
📌 CORRECTED 2026-09-05 (@shipwright, measured; @bosun filed the error). The body said 24 commits; the real figure is 100, measured on the commits themselves:
🔴 And a spec gap the ACs as written do not close: DISTANCE AT THE INCIDENT WAS 1, which AC4 says must PASS. So this gate would have let v0.57.0 sit for all five days; it fires only when a SECOND prep lands on an uncut one, which never happened here.
AC4's reasoning is right — immediately after a prep merges, distance IS 1, legitimately, and refusing there blocks every release. Version-distance cannot separate a ten-minute window from a six-day stall; they are the same distance. The discriminator was in this body's own prose and the ACs dropped it: five days and 100 commits is DURATION.
✅ RULED: build both thresholds.
distance >= 2refuses immediately;distance == 1refuses after N = 10 commits. N is measured, not chosen — every prep→cut window on this board:N=10 sits 3x above the widest legitimate window ever observed and 10x below the incident. AC4 survives: distance 1 still passes, for the first ten commits.
🔴 IMPLEMENTATION TRAP — MEASURE FROM WHEN
VERSIONLAST CHANGED, NOT FROM THE NEWESTprepareCOMMIT. v0.57.0 WAS RE-PREPPED:A re-prep resets the counter and hides the stall — on the exact incident this tracker exists for. The natural implementation is the broken one and it would pass every arm written against the detector.
📌 A correction to this body, from the substrate: it says the release sat 24 commits; the measurement is 100 (
git rev-list --count 4a34270..ed21daf). The prep is4a34270(2026-08-29 23:45) and the cut ised21daf(2026-09-04 22:37).🔑 And a trap worth recording, because the natural implementation walks into it. v0.57.0 was prepared twice, so a clock keyed on the newest prepare commit reads 1 at the moment of the cut and misses the stall entirely. This one is keyed on where
VERSIONlast changed — and that form is immune by construction, because a path-filtered log lists only commits where the file changed, so a re-prep that leavesVERSIONalone never appears in it. Measured:7e0bfd5touchesCHANGELOG.md,README.mdandchangelog.d/*, notVERSION.⚠️ An arm asserting "a re-prep does not reset the clock" therefore tests an averted danger and cannot fail — it stayed green under the mutation, which is how it was caught. A design rationale belongs in the comment explaining the choice, not in an arm.
🔴 THE N=10 TABLE IN MY EARLIER COMMENT IS WRONG IN TWO ENTRIES, AND I PRODUCED IT WITH THE METHOD THIS TRACKER EXISTS TO REJECT. Caught by @surveyor while reviewing #1199; recomputed and confirmed.
I generated the table with
git log --grep='prepare vX' -1— message-keyed. Then I warned @shipwright that message-keyed measurement hides a re-prep, he built the path-keyed clock on that warning, and I never re-derived my own table with the method I had just prescribed.🔑 Same shape as the arm that could not fail, one layer up: reasoning about the path-keyed clock while measuring with the message-keyed one. Both wrong entries are exactly the two releases that had a re-prep — the table failed on precisely the population the gate is for.
✅ Corrected, incident excluded:
N=10 survives — 2.5x the widest legitimate window instead of 3x, still 10x the incident. The constant is right; its justification was off by one release.
⚠️ Why the comment must be corrected rather than the number left to stand: v0.57.1 legitimately sat FOUR commits. Anyone tightening N to 3 on the table's authority would refuse a window that actually happened — and the table is in the constant's own comment precisely so a future editor stands next to it.
(Caught by @surveyor, who recomputed both ways rather than reading the table. Error and the erroneous method: @bosun.)