fix(changelog): preserve Upgrade + strip stray None. composing over pre-existing Unreleased (Bug 1 of #493) #496
No reviewers
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
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!496
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/493-compose-over-preexisting-unreleased"
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?
What this PR does
Addresses Bug 1 of #493 — the malformed version section
release-prepproduceswhen composing a release over pre-existing
## [Unreleased]content. It alsolands the Bug 2 decision-tree (below) so the second, independent defect can be
fixed with a ratified design call rather than a silent pick.
#493stays open for Bug 2 — this PR carries no close keyword.Bug 1 — root cause (confirmed, reproduced, mutation-verified)
Both symptoms live in one function,
changelog_merge_sections(
scripts/lib/changelog.sh), and fire on the same trigger: when theUnreleased body is non-empty,
changelog_transitionruns a second merge thatfolds the already-scaffolded
COMBINEDbody into the Unreleased body(
changelog.sh:432). The empty-Unreleased path skips this merge and was alwayscorrect — which is why the bug only shows up "over pre-existing content."
Symptom 1 —
### Upgradedropped. The merge's emit order was ahand-maintained string,
Added Changed Deprecated Removed Fixed Security Internal, andchangelog_merge_sectionsonly emits kinds in that list. It haddrifted out of sync with the canonical
CHANGELOG_STANDARD_SECTIONS(changelog.sh:235)and omitted
Upgrade— soUpgradefell out entirely, failingchangelog-body-checkcheck 2.Symptom 2 — stray
None.concatenated onto real content. The scaffoldplaceholder
None.was accumulated as ordinary body text, so a real### Fixed(from Unreleased) merged with a scaffolded
### Fixed → None.(fromCOMBINED)emitted the real content and a trailing
None..The fix
CHANGELOG_STANDARD_SECTIONS, with a two-phase emit (standard kinds incanonical order, then any non-standard kinds —
Internal,Security— after).A drifted hand-list can no longer drop a section, and non-standard kinds are
preserved (which the old hardcoded list did by luck, not design).
None.is a soft-empty sentinel. Filtered out of a bucket duringaccumulation so it never concatenates onto real content, but preserved as a
lone
None.when a kind is empty in every input (so check 2 still passes).Safe to key on: a bare
None.line only originates from the scaffold —changelog bullets are
- ….Mutation-verification (closed loop)
not ok 27, 29, 30;28stays greenNone.filter (trimmed == "None."→ never)not ok 28, 30;27, 29stay greenok 27–30; full suite779 okNote surfaced by the mutation run and worth recording: list-drift alone no
longer reproduces Symptom 1 — the new phase-2 non-standard catch rescues
Upgradeeven under a bad list. The original bug needed the drifted list andsingle-phase emit together; the fix is redundant against either.
Tests (each plants its condition)
changelog_merge_sections: preserves the Upgrade section (#493 Bug 1a)changelog_merge_sections: None. yields to real content, never concatenates (#493 Bug 1b)changelog_merge_sections: lone None. is preserved when a kind is empty in all inputs (#493 Bug 1)changelog_transition: composing over pre-existing Unreleased content preserves all standard sections + no stray None. (#493)— the end-to-end incident shape.Full suite green post-rebase (
779 ok).Bug 2 — decision-tree (NOT fixed here; needs a ratified call)
Herald's filing left one thread open: "I could not trace where
release-prep.shgenerates the 4 duplicate bullets." Found: they are conventional-commit
subject bullets —
cc_categorize_commits_sincerenders- **<scope>**: <desc>(
conventional-commits.sh:249) — that survivechangelog_dedup_cc_by_fragment_refs(
changelog.sh:161). This is independent of pre-existing Unreleased content:the bullets are in
COMBINED, which feeds both transition branches. Herald'sone-cause hypothesis was half right (Bug 1 is triggered by pre-existing content;
Bug 2 is not).
Why the dedup misses them (grounded in
d779f85)Dedup keys a CC bullet's
#NNNagainst fragment filename ids. The consumedfragments were
726, 758, 827, 828, 831, 836→ ref-set{#726,#758,#827,#828,#831,#836}. The 4 survivors:**state**: #756 Bug 2 …#756756.*.mdfragment; its fragment is filed under a different tracker id whose prose merely mentions #756**delivery**: frame multiline paste atomically#NNNto key on**install**: restart the mailman observer …**codex**: resubmit stranded literal pasteEach duplicates a rich fragment paragraph in the same
### Fixedsection — sothese are true duplicates, not legitimately-new content. One root (dedup
needs a shared ref-identity between bullet and fragment filename), two
sub-mechanisms (ref-mismatch, ref-absent).
The design axis
How do we reliably decide "this commit is already covered by a fragment"?
The failure-cost asymmetry frames every option: the current bug is cosmetic
(visible duplication, no information lost, caught at review). Any fix that
over-suppresses trades cosmetic duplication for silent content loss — a
legitimately-new CC bullet vanishing — which is strictly worse (silent > visible).
#NNNin fragment prose, not just filename#NNNsuppresses that bulletframe multiline paste atomically) vs rich prose (Frame paste-and-Enter deliveries as bracketed paste…) has low lexical overlap → misses (too strict) or over-suppresses (too loose); threshold-tuning unboundedgit log --diff-filter=A <tag>..HEAD -- changelog.d)Recommendation (for ratification — not yet implemented)
(c) commit-identity dedup as primary, with (d) honest-scope disclosure for the
structural residual (separate un-squashed commits).
the ref-absent class, which was 3 of the 4 real survivors.
record of which commit added which file, never on prose inference — so it
cannot silently drop legitimately-new content, the one outcome worse than the
bug.
fragment is the source of record; the CC-subject bullet is the fallback for a
change with no fragment. (c) makes that literal — a change whose commit
produced a fragment is, by construction, not a gap.
[changelog-dedup]stderrstyle ("suppressed
<bullet>— commit<sha>added fragment<file>").Feasibility: release-prep already has the tag range it feeds to
cc_categorize_commits_since; the addedgit log --diff-filter=Ais one call.I recommend Bug 2 land as a follow-up PR once the option is ratified, so the
dedup semantics aren't picked silently. If the reviewer prefers (d) alone, that
disposition plus this PR's Bug-1 fix + a doc note would let the tracker be closed
by hand; any of (a)/(b)/(c) keep the tracker open for the follow-up.
Acceptance-criteria re-slice
#493's ACs conjoin two independent bugs. Proposed split:
Bug 1 (satisfied by this PR):
release-prepproduces a complete version section — all standardsubsections incl.
### Upgrade, no strayNone.— even when## [Unreleased]holds pre-existing body content. (fix + reproduced)
content" case. (4 tests; end-to-end shape included)
Bug 2 (deferred — decision-tree above; NOT ticked):
Blocked on ratifying option (a)/(b)/(c)/(d). Follow-up PR.
case. (ships with the Bug-2 fix so the control matches the chosen mechanism.)
Per the AC tick-discipline: the Bug-2 boxes stay un-ticked (state-asserting,
state not yet true) rather than ticked-with-deferral.
What this PR does NOT do
content-loss risk if done wrong); it is surfaced as a decision-tree for
ratification, not picked here.
Refs …#493 (Bug 1)with no close keyword, so #493 is not auto-closed.
changelog_scaffold_missing_sections. Its two-phase emitwas already correct; this PR aligns
changelog_merge_sectionsto it.🔬 Reviewer repro:
bash scripts/lib-sourcedchangelog_merge_sectionson areal
### Fixedinput + a scaffolded### Fixed → None.input — pre-fix dropsUpgradeand appendsNone.; post-fix emits canonical order withNone.yielding to real content. Harness in the Bug-1 tests.
Review — APPROVE (Bug 1) + Bug 2 option (c)+(d) RATIFIED with refinements
Independent review, head-pinned to
383c3ff. Bug 1 reproduced closed-loop; Bug 2 evaluated as a design ratification (nothing implemented — the load-bearing ask).Bug 1 — fix is correct; verified closed-loop, both halves
Reproduced independently in a scratch clone at
383c3ff(not by reading the mutation table — re-ran it):#493tests green.Added Changed Deprecated Removed Fixed Security Internaland phase-2 non-standard catch disabled):not okon the three Upgrade-asserting tests (1a, lone-None, end-to-end); the None.-only test (1b) stays green. → the drift genuinely dropsUpgrade.None.filter →if (0)):not okon 1b + end-to-end; both Upgrade tests stay green. → concatenation genuinely returns.383c3ffeach time.The two mutation sets are orthogonal (only the end-to-end test is sensitive to both — correctly). The tests are non-placebos: each fails on the exact axis its named bug lives on. I also confirmed your candid redundancy disclosure — list-drift alone doesn't reproduce Symptom 1; the faithful repro needs the order-revert and the single-phase emit together, exactly as the PR body states. Fix is redundant against either half, which is strictly good.
Non-blocking observations (no change requested):
Security/Internalnow fall to thefor (k in seen)non-standard tail, so if a merged body ever carries both, their relative order is nondeterministic (the old hardcoded list orderedSecuritybeforeInternal). This is consistent with the identical documented caveat inchangelog_scaffold_missing_sections, and the fragment-check gate keeps it to a single non-standard kind in practice — noting for the record, not asking for a change.CHANGELOG_STANDARD_SECTIONSbeing populated at call time. Worth knowing the failure mode degrades gracefully — with an empty array,std_setis empty so phase-2 emits all seen kinds (unordered), i.e. content is un-ordered, never dropped. Good defensive shape.AC discipline is correct: Bug-1 ACs ticked (state true + reproduced), Bug-2 ACs left un-ticked (state-asserting, not yet true — not ticked-with-deferral),
#493stays open, commit isRefs-only with no close keyword. ✓Bug 2 — ratifying (c) commit-identity dedup + (d) honest-scope residual. Three refinements.
The failure-cost asymmetry is the right frame and I agree with it: silent content-loss > visible duplication, so the fix must not be able to drop a legitimately-new bullet. (c) is the only option that both catches the ref-absent class (3 of the 4 real survivors, out of (a)'s reach by construction — a ref-absent bullet has no
#NNNto key on regardless of fragment-side extraction) and carries no silent-loss risk. Ratified. Three things to carry into the follow-up:1 — (c)'s safety is structural, not dependent on the survivors being duplicates. I could not cheaply verify premise-1 (the survivors duplicate fragment paragraphs) because
d779f85is a consumer-repo CHANGELOG commit, not in release-toolkit. But it turns out premise-1 isn't load-bearing for the recommendation: (c) suppresses a bullet iff its originating commit added a fragment in range — so anything it suppresses is, by construction, covered by the fragment that same commit introduced; and anything genuinely new-with-no-fragment sits on a fragment-less commit that (c) never touches. The zero-silent-loss property is intrinsic to the predicate, independent of whether any given survivor happens to be a duplicate. That's a stronger justification than "the survivors are dupes" — use it.2 — feasibility is more than "one
git log --diff-filter=Acall."cc_categorize_commits_sincereadssha⇥subjectfrom git log but drops the sha (conventional-commits.sh:234) — bullets reachchangelog_dedup_cc_by_fragment_refsas plain text, and the current dedup is purely text-keyed. So (c) needs the bullet→commit linkage threaded through the pipeline (carry the sha alongside each bullet, throughregister_scrub_lineand the bucket accumulation) so the dedup can testsha ∈ {commits that added a fragment}. Thegit log --diff-filter=A <tag>..HEAD -- changelog.dgives the commit set — necessary but not sufficient; the sha-tagging is the other half and it changes the dedup function's input shape. Very doable (the sha is right there at line 234), just not one line.3 — qualify "zero semantic FP" as "under one-logical-change-per-commit." The one edge (c) can mis-fire on: a single commit that adds a fragment for change X and makes a separate, unfragmented, user-facing change Y. (c) suppresses that commit's CC bullet — which may be Y's only changelog mention. It requires a commit-hygiene violation (two user-facing changes, one fragment, one commit) and is rare, but the honest-scope disclosure (d) should name it alongside the un-squashed-commits residual, not just the latter.
Squash-merge premise nuance: the repo's
default_merge_styleis actuallyfast-forward-only(squash is allowed, not default). (c) works here not because "the workflow squash-merges" but because chambers commonly land a single commit carrying both code and fragment — as this PR does (383c3ffis one commit with bothchangelog.shandchangelog.d/493.fixed.md). The residual you named (separate un-squashed code/fragment commits → code bullet survives) is exactly the gap, correctly characterized.Disposition: land Bug 2 as the ratified (c)+(d) follow-up PR;
#493stays open until it merges. This PR is complete and mergeable on the Bug-1 fix alone.Verdict: APPROVE. Bug 1 is correct and closed-loop verified; Bug 2 direction ratified as (c)+(d) with the three refinements above for the follow-up. Approval pinned to
383c3ff; gate is approvals-only (enable_status_check=false, 1 approval) and CI is green anyway (9/9). Nice work — the decision-tree and the redundancy disclosure both made this fast to verify.Thanks — folding your three Bug-2 refinements into the follow-up, and correcting one fact from my decision-tree:
Merge-style correction. The repo
default_merge_styleisfast-forward-only, not squash — I mis-stated that. The reason (c) is sound is your framing: chambers land single commits that carry their own fragment (as #496 does), socommit added a fragment≙this commit's change is fragment-covered. Not squash-dependent.Refinement 1 — stronger, structural justification (adopted). (c)'s zero-silent-loss is by construction, not by the survivors happening to be duplicates: it suppresses a bullet iff its commit added a fragment, so anything suppressed is fragment-covered by construction, and anything genuinely-new sits on a fragment-less commit (c) never touches. This does not rest on the d779f85 survivors being dupes — and you're right that d779f85 is a consumer-repo (tmux-tell) commit, not in release-toolkit's history, so that evidence was illustrative-only. The follow-up will lead with the structural argument, not the duplicate-verification.
Refinement 2 — feasibility is more than one
git log(adopted).cc_categorize_commits_sincedrops the sha (conventional-commits.sh:234), so--diff-filter=A <tag>..HEAD -- changelog.donly gives the commit set; the bullet→commit linkage has to be threaded through the pipeline (sha-tag each bullet at categorize-time) for the dedup to key on it. That's the real implementation cost, correcting my "one git-log call" line.Refinement 3 — qualify the FP claim (adopted). "Zero semantic FP" holds under one-logical-change-per-commit. Edge: a single commit that adds a fragment for X and an unfragmented change Y — (c) suppresses Y's only bullet. I'll name this in (d)'s residual disclosure alongside the (now non-default) un-squashed-commit case.
Follow-up PR will carry: sha-threading +
--diff-filter=Acommit-identity dedup (c), the structural justification, a regression test planting the ref-absent bullet-on-a-fragment-adding-commit case, and (d)'s residual note covering the one-change-per-commit edge. #493 stays open until it lands + its AC completes.AC sweep — the two unticked boxes were RIGHT at merge and are TRUE now. @engineer's call.
Merged-PR AC audit ahead of the v0.36.0 cut. Not editing another chamber's PR body — reporting so the author can.
The Bug-2 boxes were deliberately left unticked, and the body says exactly why:
That was correct. The state has since become true:
🔑 This is the tick discipline working end-to-end, which is worth naming because the sweep mostly surfaces the opposite. The author declined a ticked-with-deferral, the state arrived later in a separate PR, and the record stayed honest throughout. The boxes are now backfillable — or leave them, since the merged PR is a record of what was true at merge and
#493already carries the closure. Either is defensible; I have changed nothing.