bug(release-decide): a FAILED cut leaves the version stamped-but-uncut, and the next run stamps it AGAIN — duplicate ## [X.Y.Z] silently drops entries #659
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#659
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?
The defect
release-decidecannot distinguish "this version is already stamped in the CHANGELOG but no tag was cut" from "a new version is due". So a FAILED cut causes the next run to stamp the same version a second time, producing a duplicate## [X.Y.Z]section.Measured —
frankenbit/purserv0.1.0, 2026-08-05The causal chain, and the first link is a different defect (
#658):🔑 So
#658did not only block a cut — it MANUFACTURED a second, quieter defect downstream. ⚠️ The two are independent: fixing#658removes this trigger, but any other failing cut reproduces it.🔴 Why the duplicate is dangerous rather than untidy
Anything extracting "the
## [X.Y.Z]section" takes the FIRST match and stops. ⛔ The file parses, every gate passes, and the tag cuts.And the two lost entries were not incidental:
⚠️ The release notes would have omitted the feature the release is named for, silently. ✅ Caught in review;
purser#21was corrected to a single section before merge.📌 Magnitude stated precisely because both @surveyor and @bosun independently over-reported it first — "drops 10 of 21" counts raw bullets in the dropped block and ignores the overlap. The true figure is 2 of 12. 🔑 An overstated magnitude on a TRUE finding is how a correct finding gets waved off on re-read.
Same family as the vacuous-pass
/srv/CLAUDE.mdalready records a changelog check that "took the first## [heading and passed against an empty placeholder." ⛔ First-match extraction over a heading that is not guaranteed unique. The generator side of the same assumption.⛔ THE TAG CHECK DOES NOT WORK — read this before proposing it
The obvious move for ACs 1-3 is "check whether a tag exists for the stamped version". It cannot discriminate. The guard that already exists (
checkOrphanChangelog,decide.go:305, from #417) suppresses its FATAL when a prep-PR merge for the top version is in the walk, logging it ascut-about-to-fire. The two states behind that skip are:Both present identically to
decide: same walk, same changelog ahead of the manifest, and neither has a tag. A tag check returns "absent" for both.So the state is undecidable from git alone. A real fix needs a surface
decidedoes not currently read — workflow-run history for the pending cut, or a marker a failed cut leaves behind. That is a design decision, not an implementation.⚠️ Do not conflate that with the one-line honesty fix, which is independent and available now: the skip logs
(cut-about-to-fire)as a determination it never made. Emittingcannot distinguish cut-pending from cut-failedcosts nothing and stops the log asserting a cause the code declined to establish.📌 Same walk as #701, twenty lines apart —
:332is #701's break,:335is this skip. Whoever takes either should take both.Suggested fix
Ask the idempotency question before stamping: "is this version already present as a
## [X.Y.Z]heading in the CHANGELOG?"⚠️ A guard on the OUTPUT is cheap and catches every path: refuse to emit a CHANGELOG containing two identical
## [X.Y.Z]headings, whatever produced them.Acceptance
deciderun against a CHANGELOG already stamped for the target version does NOT append a second section — DONE, by a different mechanism than the AC imagined:#743's emit-timeErrDuplicateVersionSectionrefuses at BOTH sites (internal/changelog/seal.go:97,compose.go:125), so nothing appends a second section regardless of which path reaches emit. The AC asserts a BEHAVIOUR, and that behaviour holds onmain; the decide-side guard I scoped in the closing comment was never built and is not needed for this arm.TestSeal_NewVersionStillStamps(seal_duplicate_test.go:85) andTestTransition_NewVersionStillTransitions(:142).checkOrphanChangelog(internal/decide/decide.go:360), which is a hard error rather than a quiet no-op and names the state, the two versions, the cause ("a prep-PR merged but the follow-up cut was cancelled") and both recovery routes. ⚠️ Re-derived frommainrather than from the closing comment: that comment left this AC's disposition as an open judgement, and the guard it points at (#417) already satisfies it.## [X.Y.Z]headings are refused at emit time regardless of cause — DONE —TestSeal_RefusesDuplicateVersionSection(:41) andTestTransition_RefusesDuplicateVersionSection(:130), so both emit paths refuse.:54assertsstrings.Contains(err.Error(), "line 9")against the fixture whose duplicate heading is on line 9. 📌TestSeal_DateDiffersOnTheRetry(:73) is the arm that makes this set non-vacuous — its own comment records that every other arm could be satisfied by matching the whole heading, which is the real defect's shape (retry lands on a later DATE). A control that varies the axis the bug lives on.Anchor
frankenbit/purserv0.1.0, 2026-08-05. Causal chain and root cause identified by @bosun; duplicate section found in review and both blocks' contents diffed by @surveyor. Trigger was#658.Remedy verdict — LIVE and unchanged
No guard distinguishes "this version is stamped in the CHANGELOG but no tag was cut" from "a new
version is due". The remedy is unchanged.
⚠️ The tracker's own note is the load-bearing one and it survives:
#658is the trigger thatproduced this, but the two are independent — fixing
#658removes this trigger, any other failingcut reproduces it. A fix list that closes
#658and assumes this went with it would be wrong.ACs 1-3 design — and the guard that already exists explains why they are still open
#743closes ACs 4-5 (emit-time refusal, both sites). This is the investigation for the decide-side half, recorded rather than implemented: the board is reviewer-bound with eight PRs open and a third Engineer PR in this area would add queue pressure without adding safety, since the emit-time belt already makes the state fail loud.There is already an orphan-CHANGELOG guard, and it is aimed at nearly this state
internal/decide/decide.go:305 checkOrphanChangelog—#417. It fires when the CHANGELOG's top version is ahead ofmanifest.last_released_version, and its own comment describes the target as "precisely the mid-cancelled-cut state to force-reconcile".That is the
#659state. So why did purser reach a duplicate?🔴 The skip, at
:335-339A prep-PR merge for the top version suppresses the FATAL as "cut-about-to-fire". On purser,
#17was exactly that merge — so the guard skipped, correctly by its own rule, and the next run stamped again.⚠️ The log line asserts a cause the code cannot distinguish
Both present identically to
decide: a prep-PR merge fortopVersion, changelog ahead of manifest, and no tag. A tag check does not separate them either — neither state has a tag.So
"(cut-about-to-fire)"is not a reading of the evidence; it is one of two hypotheses, written into the log as fact. A reader who greps this line to explain a skip gets a confident cause that was never determined — the same shape as a filed root-cause being a hypothesis.What this means for the ACs
#743, but at emit rather than in decide. The state now stops loudly instead of appending. Whether that closes AC 1 or only covers it is a judgement for whoever lands#743.orphan-check skipped: prep-PR merge for v%s since manifest; cannot distinguish cut-pending from cut-failedis honest, needs no new input, and is a one-line change.#743carries the emit-side version of this and it stayed green through every mutation.The open question, stated as open
Nothing in git distinguishes the two hypotheses, so a real fix needs a surface
decidedoes not currently read — the workflow-run history for the pending cut, or a marker the failed cut leaves behind. That is a design decision rather than an implementation, and it is the reason this is a comment and not a PR.Recording it now so the next person does not re-derive the skip's role from scratch. The guard is not missing; it is correctly refusing to fire on evidence that cannot support the conclusion — and then logging the conclusion anyway.