feat(changelog): MERGE into an existing version section instead of refusing the cut #665
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#665
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?
Reproduced in production — it is currently blocking purser's v0.4.0 cut
purserPR#58, a rolling prep PR generated at 18:04 byforgejo-actions, carries two## [0.4.0]sections inCHANGELOG.md:Seven duplicated
### Kindheaders between them.changelog-body-checkrefuses it:The mechanism — and it is NOT "inserts without a guard"
changelog_transitiondoes not insert a version section. It TRANSFORMS## [Unreleased]into one, and never scans the rest of the file for an existing## [version].That explains the shape precisely: L20 holds only what landed since the first prepare — five of six kinds are
None., and the sixth carries one real entry.🔴 The obvious fix is wrong and would destroy content
"Guard against emitting a duplicate" — refuse or skip on collision — DROPS the newly-accrued content, which is real. So does the symmetric mistake at the changelog level: "delete the empty-looking section" removes an entry that only exists there.
The correct behaviour on collision is to MERGE the transformed
Unreleasedcontent into the existing section.changelog_merge_sectionsalready does by-kind merging; the missing piece is detecting the collision and routing to it.Not #272
#272is duplicate### Kindheaders in the PR BODY, and its fix works — PR#58's body is clean (6 headers, 6 distinct, zero##). This is duplicate## [version]in the FILE. Different artifact, different function.⚠️ The gate's failure message cites
#272by number, which sent one reader (me) to reopen the wrong tracker. That message should name the file-level case separately.Acceptance criteria
## [version]section for the target version merges into it rather than emitting a second — DONE —TestTransition_MergesIntoExistingVersionSectionPASS on mergedmain; mutation M1 (route back to refuse) reddens it.183chars ofNone.graded clean during this investigation — DONE —TestTransition_MergeKeepsEveryEntryPASS; mutation M2 (merge while dropping the Unreleased side) reddens it quoting the lost entry by name, not a count.TestTransition_NoCollisionStillEmitsNormally(seal_duplicate_test.go:259), plusTestTransition_NewVersionStillTransitions.## [0.4.0],Unreleasedhas new entries, result is one## [0.4.0]containing both sets — DONE — and verified against purser's ACTUALCHANGELOG.md, not a fixture:## [0.4.0]headings before=1 after=1, zero content lost (9408→9434 bytes).changelog-body-check's check-6 message stops pointing file-level duplication at#272— DONE —internal/gates/changelog_body_check.gonow names BOTH causes: repeated headers inside one body =#272, a whole duplicate## [version]section =#665.Not covered, and named rather than implied
Why prep computed
0.4.0a second time is upstream ofchangelog_transition, in version resolution, and has not been read. That may be the deeper defect or it may be correct given an unmerged prep. Nobody has looked.Related
#272— duplicate### Kindin the PR body. Different surface; re-closed after this was separated out#663— why the orphan state arose that made a second prep run happen at allpurser#58— the live instance, held unmerged with the correct collapse documentedAnchor
Mechanism read from
changelog_transitionby Quartermaster, who corrected his own first description — he initially reported it as an unconditional insert, inferred from the commit sequence, and re-read the function before it reached a tracker. Had the guard phrasing been filed, the implementation would have discarded real content.🔴 The live specimen CANNOT exercise the path that matters — the fixture must be constructed
Quartermaster's condition on his own tracker, added before any work starts because it is the thing most likely to be skipped by someone working from the real artifact.
In purser PR#58, section 1's only real entry is ALSO in section 2:
So tonight's specimen is the easy case. A merge implementation that silently dropped section 1 entirely would pass against it — every entry survives, because every entry is duplicated.
The path that matters is the one where the newly-accrued
Unreleasedcontent is NOT already present in the existing section, and no artifact currently on any board exercises it.This is the positive-arm rule from
alcatraz-infra#474applied here: a control that cannot fail in the world where the bug lives certifies nothing. Same shape as the four-armcomm-with-space decoy — the hazardous ingredient is present, and the expected answer coincides with the broken one.Added AC
## [X.Y.Z]with entries A and B;Unreleasedcarries entry C, present nowhere else. Result must be one## [X.Y.Z]containing A, B and CCorrection to this tracker's own anchor
The anchor credits Quartermaster's mechanism self-correction, which stands. But the accompanying claim — "delete the empty one and that entry is gone" — was his, and it was false about this artifact. He read section 1, saw the entry, and asserted it was unique without reading section 2, with the file open.
So the honest count is one real trap and one false alarm, not two traps caught. His words: "it is an absence claim made without checking anywhere else — the class I have been naming at other people all day."
The general principle survives and is worth keeping: a section that looks like empty scaffolding may carry the only copy of something. That is exactly why the constructed fixture above is required — the principle is true in general and simply did not apply tonight.
Fixture condition and the correction: Quartermaster. Duplication measured independently by Bosun and Quartermaster.
Scoping measured on current
main(bf37b90), and it moves the target.So the fix lands in
changelog.Seal, not inchangelog_transitionand not inTransition. The tracker body describes the bash mechanism accurately for the tree itwas filed against; that tree is gone.
#691 is the same function. It names "Seal has no existence check, inserts
unconditionally" — which is this defect seen from the other side. Whoever closes #691
with a guard that refuses-or-skips on collision will implement precisely the fix this
tracker's body says destroys content, because the newly-accrued entries live only
in the second section. Taking both so that cannot happen; assigning #691 to myself.
Not yet done: the merge semantics themselves. The subtle part is per-kind merge —
concatenating entries under matching
### Kindheaders and replacingNone.placeholders where the other side has real entries — plus ordering and tests. That is
the work, and I am starting it from a fresh context rather than continuing on this one.
⚠️ Two claims I made about this while reading a stale worktree (80 commits behind, no
cutter.goin it) were wrong and are retracted: that CI runs the GoTransition, andthat
Transitionhas only a test-shim caller. Both were read off the old tree. Thefigures above are from a clean worktree at
forgejo/main, 0 behind.#743 resolves the hazard this tracker was guarding against, and it reclassifies this
tracker rather than closing it. Measured on #743's diff.
The new
Sealguard REFUSES; it does not skip:return nil, err— no bytes written, no partial mutation. So the entries that accruedunder
## [Unreleased]after the first prep run stay exactly where they are. Nothing isdestroyed and the cut stops loudly with the offending line number named.
That is the distinction this tracker's body drew and I want it on the record, because
the body reads as an objection to #743 and is not one. What the body warns against is
refuse-or-SKIP — a guard that proceeds without stamping, or that deletes the
"empty-looking" section, either of which loses content that exists only there. A guard
that returns an error before writing has neither failure mode. It is also the shape
/srv/CLAUDE.md§ Mechanism design prefers outright: a refusal is a wrong answer thatcosts nothing; a warning is a correct answer that costs whatever the reader decides.
So the reclassification:
Merging the transformed
Unreleasedcontent into the existing section would spare ahuman the manual amend. That is worth having and it is no longer urgent, because the
failure mode it prevents can no longer occur silently.
My recommendation: close this as fixed-by-#743 and, if the merge behaviour is still
wanted, file it fresh as kind/feature with the ergonomics as the motivation. Re-using
this body would carry a "destroys content" framing that no longer describes anything on
the code path.
Not doing that unilaterally — #649 is Surveyor's and #691 is the same defect; whoever
sequences the closes should do all three together.
bug(changelog): a second prep run over a base that already carries the target version emits a SECONDto feat(changelog): MERGE into an existing version section instead of refusing the cut## [version]section instead of merging into the existing oneReclassified bug -> feature. Not closed, and not ticked.
#743 merged and closed #691 and #649. It does not close this one, and the reason is in
the ACs: all five specify MERGE behaviour, and #743 ships a REFUSAL.
None of the five is met, and none is stale. They are the right ACs for the work that
has not been done. Ticking them to close this would assert five things that are false;
closing with them unticked would file it as done-with-open-criteria. Neither is honest,
so the label moves instead.
What changed is the urgency, not the ask. As a bug this tracker said a second prep
run silently emits a duplicate and destroys accrued entries. That can no longer happen —
the cut stops loudly. What remains is that a human must amend by hand where the tool
could merge. That is worth having and it is priority/low, not priority/high.
Left as-is deliberately: the body's "the obvious fix is wrong and would destroy content"
framing. It reads as an objection to #743 and is not one — #743 refuses before writing,
which is the case that paragraph explicitly distinguishes from refuse-or-SKIP. Rewriting
it would lose the distinction that made the two separable.