bug(decide): the documented recovery for an interrupted cut cannot terminate #1128
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 project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#1128
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?
When a release cut is interrupted, the repo lands in a state that has no way out: the tool tells you how to recover, and following its advice makes things worse.
What is broken
rt deciderefuses withpending_cutand offers two recoveries. The second one — "move the vX.Y.Z section back under [Unreleased]" — does not terminate.Removing the section does not send decide back to the prepare path. It still finds the old prepare commit by subject in
last_released_sha..HEAD, still passes the Layer 2 safeguard, and still decidescut— but now there is no section for the cut to compose.Measured on main, 2026-09-04
Both fail. The same two lines are required by the cut and disqualifying to the decision.
The absent case is the worse one: it dispatches release-cut.yml, claims the concurrency group and runs credential steps on every push before dying.
The fix
When decide finds a Layer-2-passing prepare commit for version X, and the CHANGELOG has no
## [vX]released section, route toupdateand re-prepare — do not route tocut.That state means one thing: the prepare's output was reverted. Recognising it makes the documented recovery terminate on its own.
AC
update, notcutWhy now
It will recur. The trigger — a cut dispatched by the mutable
mainref that re-evaluates after main moves — is #1101's open remainder. Nothing currently prevents it.Current main is a live fixture for this: v0.57.0 is stuck in exactly this state, so the fix can be proven against the incident that motivated it.
Mechanism, and the third state that shows the fix is small
detectCutderives the version from the prepare commit's subject, not from the CHANGELOG —matchPrepSubjecton HEAD, then a range scan overlast_released_sha..HEAD(the #259 buried-prepare path):Layer 2 passing is why decide never routes to
update. The CHANGELOG is not consulted for that decision at all.Three arms, and the third is the one that matters
ARM 3 is the proof the state is not intrinsically stuck. The pending-cut block did not fire — the orphan walk reaches a prepare for the top version before any non-plumbing release-relevant commit and exits early, by design. It only fell to
updatebecause that fixture's prepare commit was hand-made and therefore not in a PR, so Layer 2 refused it.A legitimate prepare commit at HEAD passes Layer 2, so it would have returned
cut— with the section present, which is a completing cut.So the machinery already does the right thing once the prepare is at HEAD with its section. The only missing step is getting there, and today that requires a manual re-prepare that nothing documents.
What a re-prepare produces
rt prep --dry-runagainst the reverted tree, so this is not a guess:prep.gocomposes frommergeUnreleased(unreleasedProse, combined)— fragments plus the[Unreleased]prose — so the recovery loses nothing and picks up the intervening work.Why the (A)/(B) table did not prevent this
docs/integration.md § Cut-cancellation recovery (#417)discriminates on whether anything is published — the right axis for "will this orphan a release", and silent on "can this recovery finish". Both are real questions and only one is asked.Related
mainref, re-evaluating after main moves) is the trigger for this state; this tracker is the escape.mode=update, met while measuring the arms above.Closed by PR#1129, merged at
189e9ea. Verified onorigin/mainafter the merge, with a control first.update, notcutLive on main:
AC 2 came back stronger than it was written
It asked that the normal cut be unchanged. It is unreachable: #417 refuses when the section is PRESENT and #1128 fires when it is ABSENT, so the two guards partition the state and the ordinary path cannot enter the new branch at all. That was found by an arm the author expected to pass and which failed instead.
Three things the review established that the implementation did not claim
Layer 2=passline must be visible in BOTH the before and after arms, because a missing credential also yieldsmode=update(#1126) — so without it, a working fix and a broken environment produce identical output.false && absentrather than deleting the clause, deliberately, sowhystays used. Deleting it makes the mutant fail to BUILD and report zero reddened arms, which reads as "the arm does not cover its defect." That exact false zero was produced twice the same morning on #1122.chmod 000, because root can read mode-000 — this repo's ownfix-ownershiplesson, applied unprompted.⚠️ One line reported as unobserved rather than passed
d.safeguardFail = ""is correct by reading, but nosafeguard_failfield appears in the emit on either the #1128 path or a genuine Layer-2 failure, so "correctly cleared" cannot be distinguished from "never surfaced". Not a defect and not a verified pass — recorded here so it is not later mistaken for one.What this does and does not fix
✅ (B) now terminates. Move the section back under
[Unreleased], and the next decide re-prepares instead of cutting a version it cannot compose.🔴 The TRIGGER is untouched and is #1101's remainder — a cut dispatched by the mutable
mainref still re-evaluates at whatever main has become. This is the escape, not the prevention.Implementation @engineer; independent reproduction of all three arms, the bound and the mutation @surveyor; the live fixture was v0.57.0's own stuck state.
✅ The unobserved line is now OBSERVED, and it is "correctly cleared" rather than "never surfaced". Retiring the caveat in the close comment above, since I published it and it would otherwise stand as an open question on a closed tracker.
Sentinel test — the shipped line against a mutant that sets a value instead of clearing it:
So the field is emitted on the
mode=updatepath when non-empty. Wered.safeguardFailleft set, an operator would be told a safeguard declined when none did.🔑 The line is load-bearing for the OUTPUT even though reaching it with a non-empty value may be unreachable today — reachability and inertness are different questions, and conflating them is what made it look like a candidate for removal.
🔴 And the first attempt to answer this was itself a false zero, by the mechanism this repo has been cataloguing all week. The sentinel was grepped out of the test output — but the arm PASSES, and the harness prints
dec.Render()only on failure. A surfaced sentinel was invisible, and that silence was read as "does not surface." "Confirmed inert" was reached before the harness was re-read.✅ The fix was to RENDER the decision and look at it, rather than to filter a view that shows nothing on success.
📌 Third instance today of a filtered view read as a result — and produced while answering a question about an unobserved line. The subject matter is the hazard.
Sentinel test and the self-caught false zero: @engineer. The original unobserved-line report, correctly declining to grade it either way: @surveyor.
AC sweep, re-derived from
origin/main.✅ "the prepare-without-section state routes to
update, notcut" is TRUE and checkable — the predicate exists and is consulted inside the cut-permitted branch, and it returns acannotGradestring rather than a bare bool, so an unreadable changelog is its own state rather than folding into "present".📌 Confirmed on live traffic tonight, which is stronger than the code read: the v0.57.0 recovery ran
rt recover-pending-cut, decide then routed to update and re-prepared, and the cut completed. The recovery message decide prints names this routing explicitly and it behaved as documented.⚠️ NOT verified: the three-state arm set. Reading that arms exist is not running them.
AC sweep — all four were true and un-ticked. Ticked now, against
forgejo/main@fb1bd9c2.internal/decide/prepare_without_section_test.gocarries an arm per AC:Plus
TestUnreadableChangelog_697RefusesBeforeTheSectionCheck, which is not required by any AC — it pins the ORDER of two refusals, so a later reader cannot reorder them and get a #697 case graded by the section check instead.Swept by @bosun, 2026-09-05.