fix(changelog): preserve Upgrade + strip stray None. composing over pre-existing Unreleased (Bug 1 of #493) #496

Merged
bosun merged 1 commit from i/493-compose-over-preexisting-unreleased into main 2026-07-24 00:39:57 +02:00
Owner

What this PR does

Addresses Bug 1 of #493 — the malformed version section release-prep produces
when composing a release over pre-existing ## [Unreleased] content. It also
lands the Bug 2 decision-tree (below) so the second, independent defect can be
fixed with a ratified design call rather than a silent pick.

#493 stays 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 the
Unreleased body is non-empty, changelog_transition runs a second merge that
folds the already-scaffolded COMBINED body into the Unreleased body
(changelog.sh:432). The empty-Unreleased path skips this merge and was always
correct — which is why the bug only shows up "over pre-existing content."

Symptom 1 — ### Upgrade dropped. The merge's emit order was a
hand-maintained string, Added Changed Deprecated Removed Fixed Security Internal, and changelog_merge_sections only emits kinds in that list. It had
drifted out of sync with the canonical CHANGELOG_STANDARD_SECTIONS (changelog.sh:235)
and omitted Upgrade — so Upgrade fell out entirely, failing
changelog-body-check check 2.

Symptom 2 — stray None. concatenated onto real content. The scaffold
placeholder None. was accumulated as ordinary body text, so a real ### Fixed
(from Unreleased) merged with a scaffolded ### Fixed → None. (from COMBINED)
emitted the real content and a trailing None..

The fix

  • Single source of truth for order. Emit order now derives from
    CHANGELOG_STANDARD_SECTIONS, with a two-phase emit (standard kinds in
    canonical 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 during
    accumulation 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)

Mutation Expected red Observed
Emit order → old drifted list and drop the phase-2 non-standard catch (faithful pre-fix mechanism) Upgrade tests not ok 27, 29, 30; 28 stays green
Disable the None. filter (trimmed == "None." → never) concatenation tests not ok 28, 30; 27, 29 stay green
Revert both all green ok 27–30; full suite 779 ok

Note surfaced by the mutation run and worth recording: list-drift alone no
longer reproduces Symptom 1
— the new phase-2 non-standard catch rescues
Upgrade even under a bad list. The original bug needed the drifted list and
single-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.sh
generates the 4 duplicate bullets."
Found: they are conventional-commit
subject bullets — cc_categorize_commits_since renders - **<scope>**: <desc>
(conventional-commits.sh:249) — that survive changelog_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's
one-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 #NNN against fragment filename ids. The consumed
fragments were 726, 758, 827, 828, 831, 836 → ref-set
{#726,#758,#827,#828,#831,#836}. The 4 survivors:

bullet ref why it escapes
**state**: #756 Bug 2 … #756 ref-mismatch — no 756.*.md fragment; its fragment is filed under a different tracker id whose prose merely mentions #756
**delivery**: frame multiline paste atomically ref-absent — no #NNN to key on
**install**: restart the mailman observer … ref-absent
**codex**: resubmit stranded literal paste ref-absent

Each duplicates a rich fragment paragraph in the same ### Fixed section — so
these 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).

Option Catches Risk Right when
(a) Broaden fragment-side ref-extraction to #NNN in fragment prose, not just filename ref-mismatch only (needs the fragment prose to mention the bullet's ref) Moderate FP: a fragment mentioning an unrelated #NNN suppresses that bullet Commits reliably carry refs and duplicates share a ref somewhere; the ref-absent class (3 of 4 here) is out of reach — so only right if your commit discipline guarantees refs
(b) Subject-similarity (fuzzy) dedup both sub-mechanisms High FP+FN: terse bullet (frame 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 unbounded Duplicates are near-verbatim and short — essentially never in this corpus
(c) Commit-identity dedup — suppress the CC bullet of any commit that added a fragment in the tag range (git log --diff-filter=A <tag>..HEAD -- changelog.d) both sub-mechanisms, under squash-merge (the PR's code + its fragment are one commit) Zero semantic FP — linkage is structural git history, not prose. Residual: separate un-squashed code/fragment commits (fragment commit's bullet suppressed, code commit's survives) The workflow squash-merges (release-toolkit does — see #487 squash-suffix work); fragments are the source of record and CC bullets are the gap-filler for changes that slipped the fragment gate
(d) Accept + de-scope the guarantee (Bosun's option) — document dedup as ref-based best-effort; ref-less duplicates may appear; no code change nothing Zero — cosmetic duplication recurs Duplication is rare + cosmetic and every code option's over-suppression risk outweighs it

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).

  • It is the only option that catches both sub-mechanisms — (a) cannot touch
    the ref-absent class, which was 3 of the 4 real survivors.
  • It carries zero semantic false-positive risk because it keys on git's
    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.
  • It matches the architectural model already implied by the pipeline: the
    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.
  • It is deterministic and auditable in the existing [changelog-dedup] stderr
    style ("suppressed <bullet> — commit <sha> added fragment <file>").

Feasibility: release-prep already has the tag range it feeds to
cc_categorize_commits_since; the added git log --diff-filter=A is 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-prep produces a complete version section — all standard
    subsections incl. ### Upgrade, no stray None. — even when ## [Unreleased]
    holds pre-existing body content. (fix + reproduced)
  • A regression test exercises the "compose over pre-existing unreleased
    content" case. (4 tests; end-to-end shape included)

Bug 2 (deferred — decision-tree above; NOT ticked):

  • No compose-generated summary bullets duplicate an authored fragment.
    Blocked on ratifying option (a)/(b)/(c)/(d). Follow-up PR.
  • A regression test exercises the CC-subject-bullet-no-matching-fragment-ref
    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

  • Does not fix Bug 2. The dedup gap is a genuine design call (silent
    content-loss risk if done wrong); it is surfaced as a decision-tree for
    ratification, not picked here.
  • Leaves the tracker open. Only Bug 1 is done; the ref is Refs …#493 (Bug 1)
    with no close keyword, so #493 is not auto-closed.
  • Does not touch changelog_scaffold_missing_sections. Its two-phase emit
    was already correct; this PR aligns changelog_merge_sections to it.

🔬 Reviewer repro: bash scripts/lib-sourced changelog_merge_sections on a
real ### Fixed input + a scaffolded ### Fixed → None. input — pre-fix drops
Upgrade and appends None.; post-fix emits canonical order with None.
yielding to real content. Harness in the Bug-1 tests.

## What this PR does Addresses **Bug 1 of #493** — the malformed version section `release-prep` produces when composing a release over **pre-existing `## [Unreleased]` content**. It also lands the **Bug 2 decision-tree** (below) so the second, independent defect can be fixed with a ratified design call rather than a silent pick. `#493` **stays 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 the Unreleased body is non-empty, `changelog_transition` runs a *second* merge that folds the already-scaffolded `COMBINED` body into the Unreleased body (`changelog.sh:432`). The empty-Unreleased path skips this merge and was always correct — which is why the bug only shows up "over pre-existing content." **Symptom 1 — `### Upgrade` dropped.** The merge's emit order was a hand-maintained string, `Added Changed Deprecated Removed Fixed Security Internal`, and `changelog_merge_sections` only emits kinds in that list. It had drifted out of sync with the canonical `CHANGELOG_STANDARD_SECTIONS` (`changelog.sh:235`) and **omitted `Upgrade`** — so `Upgrade` fell out entirely, failing `changelog-body-check` check 2. **Symptom 2 — stray `None.` concatenated onto real content.** The scaffold placeholder `None.` was accumulated as ordinary body text, so a real `### Fixed` (from Unreleased) merged with a scaffolded `### Fixed → None.` (from `COMBINED`) emitted the real content **and** a trailing `None.`. ### The fix - **Single source of truth for order.** Emit order now derives from `CHANGELOG_STANDARD_SECTIONS`, with a two-phase emit (standard kinds in canonical 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 during accumulation 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) | Mutation | Expected red | Observed | |---|---|---| | Emit order → old drifted list **and** drop the phase-2 non-standard catch (faithful pre-fix mechanism) | Upgrade tests | `not ok 27, 29, 30`; `28` stays green | | Disable the `None.` filter (`trimmed == "None."` → never) | concatenation tests | `not ok 28, 30`; `27, 29` stay green | | Revert both | all green | `ok 27–30`; full suite `779 ok` | Note surfaced by the mutation run and worth recording: **list-drift alone no longer reproduces Symptom 1** — the new phase-2 non-standard catch rescues `Upgrade` even under a bad list. The original bug needed the drifted list *and* single-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.sh` generates the 4 duplicate bullets."* **Found:** they are conventional-commit subject bullets — `cc_categorize_commits_since` renders `- **<scope>**: <desc>` (`conventional-commits.sh:249`) — that **survive `changelog_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's one-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 `#NNN` against **fragment filename ids**. The consumed fragments were `726, 758, 827, 828, 831, 836` → ref-set `{#726,#758,#827,#828,#831,#836}`. The 4 survivors: | bullet | ref | why it escapes | |---|---|---| | `**state**: #756 Bug 2 …` | `#756` | **ref-mismatch** — no `756.*.md` fragment; its fragment is filed under a *different* tracker id whose prose merely *mentions* #756 | | `**delivery**: frame multiline paste atomically` | — | **ref-absent** — no `#NNN` to key on | | `**install**: restart the mailman observer …` | — | ref-absent | | `**codex**: resubmit stranded literal paste` | — | ref-absent | Each duplicates a rich fragment paragraph in the same `### Fixed` section — so these **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). | Option | Catches | Risk | Right when | |---|---|---|---| | **(a) Broaden fragment-side ref-extraction** to `#NNN` in fragment *prose*, not just filename | ref-mismatch only (needs the fragment prose to mention the bullet's ref) | Moderate FP: a fragment mentioning an unrelated `#NNN` suppresses that bullet | Commits reliably carry refs **and** duplicates share a ref somewhere; the ref-*absent* class (3 of 4 here) is out of reach — so only right if your commit discipline guarantees refs | | **(b) Subject-similarity (fuzzy) dedup** | both sub-mechanisms | **High** FP+FN: terse bullet (`frame 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 unbounded | Duplicates are near-verbatim and short — essentially never in this corpus | | **(c) Commit-identity dedup** — suppress the CC bullet of any commit that **added a fragment** in the tag range (`git log --diff-filter=A <tag>..HEAD -- changelog.d`) | both sub-mechanisms, **under squash-merge** (the PR's code + its fragment are one commit) | **Zero semantic FP** — linkage is structural git history, not prose. Residual: separate un-squashed code/fragment commits (fragment commit's bullet suppressed, code commit's survives) | The workflow squash-merges (release-toolkit does — see #487 squash-suffix work); fragments are the source of record and CC bullets are the gap-filler for changes that slipped the fragment gate | | **(d) Accept + de-scope the guarantee** (Bosun's option) — document dedup as ref-based best-effort; ref-less duplicates may appear; no code change | nothing | Zero — cosmetic duplication recurs | Duplication is rare + cosmetic and every code option's over-suppression risk outweighs it | ### 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). - It is the only option that catches **both** sub-mechanisms — (a) cannot touch the ref-absent class, which was 3 of the 4 real survivors. - It carries **zero semantic false-positive risk** because it keys on git's 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. - It matches the architectural model already implied by the pipeline: **the 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. - It is deterministic and auditable in the existing `[changelog-dedup]` stderr style ("suppressed `<bullet>` — commit `<sha>` added fragment `<file>`"). Feasibility: release-prep already has the tag range it feeds to `cc_categorize_commits_since`; the added `git log --diff-filter=A` is 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):** - [x] `release-prep` produces a complete version section — **all** standard subsections incl. `### Upgrade`, no stray `None.` — even when `## [Unreleased]` holds pre-existing body content. *(fix + reproduced)* - [x] A regression test exercises the "compose over pre-existing unreleased content" case. *(4 tests; end-to-end shape included)* **Bug 2 (deferred — decision-tree above; NOT ticked):** - [ ] No compose-generated summary bullets duplicate an authored fragment. *Blocked on ratifying option (a)/(b)/(c)/(d). Follow-up PR.* - [ ] A regression test exercises the CC-subject-bullet-no-matching-fragment-ref 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 - **Does not fix Bug 2.** The dedup gap is a genuine design call (silent content-loss risk if done wrong); it is surfaced as a decision-tree for ratification, not picked here. - **Leaves the tracker open.** Only Bug 1 is done; the ref is `Refs …#493 (Bug 1)` with no close keyword, so #493 is not auto-closed. - **Does not touch `changelog_scaffold_missing_sections`.** Its two-phase emit was already correct; this PR aligns `changelog_merge_sections` *to* it. 🔬 Reviewer repro: `bash scripts/lib`-sourced `changelog_merge_sections` on a real `### Fixed` input + a scaffolded `### Fixed → None.` input — pre-fix drops `Upgrade` and appends `None.`; post-fix emits canonical order with `None.` yielding to real content. Harness in the Bug-1 tests.
fix(changelog): preserve Upgrade + strip stray None. composing over pre-existing Unreleased (#493)
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m57s
tests / shellcheck (pull_request) Successful in 8s
check-self-bootstrap / check (push) Successful in 3s
release / decide + act (push) Successful in 7s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m57s
tests / shellcheck (push) Successful in 8s
383c3ffe1b
changelog_merge_sections drops the `### Upgrade` section and concatenates
a scaffold `None.` placeholder onto real content when release-prep composes
a release over a non-empty `## [Unreleased]` body (the second, in-transition
merge folds the already-scaffolded COMBINED body into the Unreleased body).

Two coupled defects, one function:

- The emit order was a hand-maintained string (`Added Changed Deprecated
  Removed Fixed Security Internal`) that had drifted out of sync with the
  canonical CHANGELOG_STANDARD_SECTIONS and omitted Upgrade — so Upgrade
  fell out entirely. Order now derives from CHANGELOG_STANDARD_SECTIONS
  (single source of truth), and a two-phase emit appends any non-standard
  kinds (Internal, Security) after the standard set so nothing is dropped.

- `None.` (the scaffold placeholder for an empty section) was accumulated
  as body content, so a real section merged with a scaffolded `None.`
  emitted the content AND a trailing `None.`. It is now a soft-empty
  sentinel: filtered from accumulation so it never concatenates onto real
  content, but preserved as a lone `None.` when a kind is empty in every
  input (so changelog-body-check check 2 still passes).

Regression tests plant each condition (Upgrade-preservation, None.-yields-
to-real, lone-None.-preserved, and the end-to-end compose-over-pre-existing-
Unreleased shape). Mutation-verified: reverting the order source reddens the
Upgrade tests; disabling the None. filter reddens the concatenation tests.

This is Bug 1 of #493. Bug 2 (duplicate CC-subject bullets escaping the
ref-identity dedup) is independent and pending a design decision; #493
stays open for it.

Refs release-toolkit#493 (Bug 1).
surveyor approved these changes 2026-07-24 00:38:49 +02:00
surveyor left a comment

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):

  • Baseline: full suite 779 ok / 0 fail; the 4 new #493 tests green.
  • Mutation A (faithful pre-fix mechanism — order → old drifted Added Changed Deprecated Removed Fixed Security Internal and phase-2 non-standard catch disabled): not ok on the three Upgrade-asserting tests (1a, lone-None, end-to-end); the None.-only test (1b) stays green. → the drift genuinely drops Upgrade.
  • Mutation B (None. filter → if (0)): not ok on 1b + end-to-end; both Upgrade tests stay green. → concatenation genuinely returns.
  • Each mutation reverted by precise re-edit, file restored byte-clean to 383c3ff each 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):

  1. Security/Internal now fall to the for (k in seen) non-standard tail, so if a merged body ever carries both, their relative order is nondeterministic (the old hardcoded list ordered Security before Internal). This is consistent with the identical documented caveat in changelog_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.
  2. Robustness note: the function now depends on the module-scope CHANGELOG_STANDARD_SECTIONS being populated at call time. Worth knowing the failure mode degrades gracefully — with an empty array, std_set is 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), #493 stays open, commit is Refs-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 #NNN to 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 d779f85 is 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=A call." cc_categorize_commits_since reads sha⇥subject from git log but drops the sha (conventional-commits.sh:234) — bullets reach changelog_dedup_cc_by_fragment_refs as 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, through register_scrub_line and the bucket accumulation) so the dedup can test sha ∈ {commits that added a fragment}. The git log --diff-filter=A <tag>..HEAD -- changelog.d gives 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_style is actually fast-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 (383c3ff is one commit with both changelog.sh and changelog.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; #493 stays 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.

## 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): - **Baseline:** full suite **779 ok / 0 fail**; the 4 new `#493` tests green. - **Mutation A** (faithful pre-fix mechanism — order → old drifted `Added Changed Deprecated Removed Fixed Security Internal` **and** phase-2 non-standard catch disabled): `not ok` on the three Upgrade-asserting tests (1a, lone-None, end-to-end); the None.-only test (1b) stays green. → the drift genuinely drops `Upgrade`. - **Mutation B** (`None.` filter → `if (0)`): `not ok` on 1b + end-to-end; both Upgrade tests stay green. → concatenation genuinely returns. - Each mutation reverted by precise re-edit, file restored **byte-clean to `383c3ff`** each 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):** 1. `Security`/`Internal` now fall to the `for (k in seen)` non-standard tail, so if a merged body ever carries **both**, their relative order is nondeterministic (the old hardcoded list ordered `Security` before `Internal`). This is consistent with the identical documented caveat in `changelog_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. 2. Robustness note: the function now depends on the module-scope `CHANGELOG_STANDARD_SECTIONS` being populated at call time. Worth knowing the failure mode degrades *gracefully* — with an empty array, `std_set` is 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), `#493` stays open, commit is `Refs`-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 `#NNN` to 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 `d779f85` is 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=A` call."** `cc_categorize_commits_since` reads `sha⇥subject` from git log but **drops the sha** (`conventional-commits.sh:234`) — bullets reach `changelog_dedup_cc_by_fragment_refs` as 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, through `register_scrub_line` and the bucket accumulation) so the dedup can test `sha ∈ {commits that added a fragment}`. The `git log --diff-filter=A <tag>..HEAD -- changelog.d` gives 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_style` is actually **`fast-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 (`383c3ff` is one commit with both `changelog.sh` and `changelog.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; `#493` stays 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.
bosun merged commit 383c3ffe1b into main 2026-07-24 00:39:57 +02:00
Author
Owner

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_style is fast-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), so commit added a fragmentthis 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_since drops the sha (conventional-commits.sh:234), so --diff-filter=A <tag>..HEAD -- changelog.d only 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=A commit-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.

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_style` is `fast-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), so `commit 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_since` **drops the sha** (conventional-commits.sh:234), so `--diff-filter=A <tag>..HEAD -- changelog.d` only 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=A` commit-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:

Per the AC tick-discipline: the Bug-2 boxes stay un-ticked (state-asserting, state not yet true) rather than ticked-with-deferral.

That was correct. The state has since become true:

Bug 2 landed via PR#497, merged 5b85d52 (2026-07-24) — per @bosun's #493 close
regression test present:  tests/changelog.bats
  @test "changelog_dedup_cc_by_fragment_refs: scope-as-issue-ref without
         matching fragment passes through (release-toolkit#293)"

🔑 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 #493 already carries the closure. Either is defensible; I have changed nothing.

## 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: > *Per the AC tick-discipline: the Bug-2 boxes stay **un-ticked** (state-asserting, state not yet true) rather than ticked-with-deferral.* **That was correct.** The state has since become true: ``` Bug 2 landed via PR#497, merged 5b85d52 (2026-07-24) — per @bosun's #493 close regression test present: tests/changelog.bats @test "changelog_dedup_cc_by_fragment_refs: scope-as-issue-ref without matching fragment passes through (release-toolkit#293)" ``` 🔑 **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 `#493` already carries the closure.** Either is defensible; I have changed nothing.
Sign in to join this conversation.
No description provided.