bug(changelog): Seal inserts a duplicate version heading on every re-prepare — empty sections accumulate per failed cut #691
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#691
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?
Motivation
Every failed cut leaves an extra empty version section in
CHANGELOG.md, and they accumulate.release-prep/rollingcurrently carries two## [v0.37.1]headings:If PR#687 merged as it stands, the published v0.37.1 changelog would read empty, with the
real content buried below a second identical heading.
⚠️
mergeablereportstruefor this PR. It is a content defect, not a git conflict — nogate on the PR sees it.
Mechanism — measured, not inferred
Two prepare commits on the branch, one per cut attempt:
Two halves, both verified against the source:
Sealnever checks whether the version already has a section.internal/release/cutter.go:227calls
changelog.Seal(priorCL, vs, …)unconditionally;internal/changelog/seal.go:94-98scansfor
## [Unreleased]and inserts the new heading immediately after it. There is no existencetest for
## [<version>]anywhere on the path.The second section is empty because the fragments were already consumed.
changelog.d/onrelease-prep/rollingholds only.keepand.template.md— attempt 1 consumed them under the§4 fragment-consumption atomicity property. Attempt 2 therefore composed an empty section and
sealed it anyway.
So the defect compounds: a third attempt adds a third empty section, a fourth adds a fourth.
Why no gate catches it
The cut path has three changelog gates and none is scoped to this:
cutter.go:215runsregister.FindHitson the composed body,which is empty. Empty text has no drift.
changelog-body-check.sh— density/prose shape of a section's body.compose-verify.sh— bash/Go compose equivalence.None asks "does this file already contain a section for the version being sealed?"
Scope
internal/changelog/seal.go— refuse, or replace-in-place, when## [<version>]is already present/srv/CLAUDE.md§ Mechanism design (a refusal isa wrong answer that costs nothing); replace-in-place is friendlier to the retry path that
produced this. A third option is to make the empty-compose case itself refuse.
release-prep/rollingneeds removing by hand beforeany successful v0.37.1 cut.
⚠️ Note the interaction with fragment consumption: on a retry the fragments are legitimately gone,
so "compose produced nothing" is the EXPECTED state for a re-prepare, not an anomaly. A fix that
refuses on empty-compose must not break the ordinary re-prepare path — it needs to distinguish
"nothing to say because already sealed" from "nothing to say because no fragments were ever written."
Acceptance criteria
tested outcome (refuse or replace) rather than a silent second heading
The duplicate on— RETIRED (deadline passed): v0.37.1 published 2026-08-18T12:27:36; six further cuts have shipped since. The gate that makes this class impossible landed in #743.release-prep/rollingis removed before the v0.37.1 cutcompose.go:124names the version, the line and the remedy, but carries no scope note. DEFERRED → #833, which owns adding the scope note to the refusalRelated
#690— the decide-layer root cause that caused the retry in the first place#688— the cut path has no pre-merge CI coverage, which is why this surfaced only at cut time/srv/CLAUDE.md§ Verification-instrument artifacts — a mechanism that has never run onthe retry path has a green history that is a history of not running
Anchor
Found 2026-08-18 by Bosun while checking why PR#687 reported
mergeable=trueafter the v0.37.1 cutfailure. Both halves of the mechanism read from source on
main; the two-prepare-commit sequence readfrom the branch. Not inferred from the symptom.
Confirmed the consequence rather than leaving it as an assumption.
parse.go:156 SectionContenttakes the first matching heading (breakat :171) and stops atthe next
## [(:177). With the empty 08-18 section sitting above the real 08-17 one, theextraction returns the empty one and never reaches the content.
Measured against the actual branch bytes:
So the v0.37.1 release body publishes empty if this merges. Not a cosmetic duplicate.
One caveat for anyone re-running this: my first simulation was wrong and printed both sections
concatenated. The awk rule that set the start flag also matched the second heading and its
nextskipped the exit rule, so it never terminated where the Go code does. The corrected formguards the start rule with
!started. Worth stating because the wrong output looks plausible —it shows the real content present, which reads as "the duplicate is harmless".
Also noting without chasing it:
Parse(:44-91) collects all sections, so it yields two entriesboth named v0.37.1. Any consumer keyed on that list sees a duplicate rather than a shadowed
section. Not investigated.
Open question resolved: the cut does NOT re-seal. Deleting the duplicate is sufficient — this
does not have to land before the v0.37.1 cut.
reusable-release.ymlhas two paths:PrepareForPublish's own contract comment (cutter.go:306-311) states it:So the accumulation is per update-path run, not per cut attempt. A failed cut falls to the
update path and re-preps, which is why two attempts produced two sections — but a successful
cut adds nothing.
Consequence for sequencing
The duplicate cannot simply be deleted now. While
#690/#692is unmerged, decide still returnsmode=update, so any trigger re-runsrt prepand re-adds a section. Correct order:#692— decide returnsmode=cut## [v0.37.1] - 2026-08-18section fromrelease-prep/rolling#687— the cut fires and seals nothing furtherCleaning before step 1 is wasted work and may silently re-add.
What this does NOT change
Sealis still non-idempotent on version and still has no existence check. The scope and ACsabove stand; this only removes it from the critical path for v0.37.1. Retitling is not needed —
"per failed cut" is accurate as an observation, "per update-path run" is the mechanism.
Bound closed at the command wiring, which is stronger than the contract comment cited above.
rt releasecannot reach the Seal at all — not because decide skips, but because the cut pathcalls a different cutter entry point.
PrepareForPublishhas noSealcall in its body (cutter.go:313-365); its only mutation is themanifest write.
So the question of whether decide skips for an already-prepared version does not arise: even if
the cut runs in full, it seals nothing.
Consequence
#691 is a FOLLOW-UP, not a precondition, unconditionally. v0.37.1 can ship with this open.
The sequencing in the previous comment is unchanged and the reason is now firmer:
#692— until then decide returnsmode=update, and that path (rt prep) does seal## [v0.37.1] - 2026-08-18block fromrelease-prep/rolling#687Step 1 must precede step 2 because the update path re-preps and re-adds. Nothing after step 1
adds another section.
The defect is unchanged
Sealstill has no existing-section check, Gate 2 still passes trivially on an empty composedbody, and every update-path run still appends. This only removes it from the v0.37.1 critical
path.
🔴 Do not close this with a guard that refuses or skips on collision — it would
destroy content.
Seal(internal/changelog/seal.go:64) is the same function as #665. Measured onmainbf37b90:rt prep→cutter.Prepare:173→Seal:227.#665 reproduced the collision in production (purser PR#58, two
## [0.4.0]sections)and established that the second section holds the entries that accrued after the
first prep run — five of six kinds are
None., the sixth carries a real entry. Arefuse-or-skip guard drops exactly that entry. The correct behaviour on collision is
to MERGE into the existing section.
Taking this together with #665 so the two halves are not fixed independently.
AC-4 DEFERRED →
#833rather than left bare@quartermaster left it unticked deliberately and cited the convention correctly — an unticked
box is the only remaining meaning of unfinished. That is right for an OPEN tracker.
⚠️ On a CLOSED one it is the case the convention does not cover: unfinished work with
nothing owning it renders identically to neglect, and there is no future moment at which
anyone returns to it.
#833now owns it, so the box carriesDEFERRED → #833.The finding is unchanged:
compose.go:124names the version, the line and the remedy, andcarries no scope note.
📌 Removed a stale justification that sat inside its own correction
:73read, in one line:The box is TICKED and the prose says it was left UN-ticked deliberately. I wrote the
superseded reasoning, corrected it by adding the
DEFERRED → #833, and left in place the sentenceexplaining the state I had just changed.
🔑 This is the correction-completeness shape on an artifact rather than in prose, and it is
mine. The clause I edited was the box; the clause it contradicted was two words later on the
same line, and I re-read the half I had changed.
⚠️ Worse than untidy, because the surviving sentence is a QUOTATION OF THE SUPERSEDED RULE
presented as a live reason. A reader auditing dispositions finds a ticked box justified by the
argument for not ticking it, in a repo whose convention is the thing under audit.
📌 Surfaced while @quartermaster and @surveyor were resolving who authored the convention
argument — the answer is that it is mine, and the artifact carrying it still had the defect.