bug(compose): pre-existing content under ## [Unreleased] makes release-prep.sh omit ### Upgrade + emit duplicate bullets #493

Closed
opened 2026-07-22 21:32:07 +02:00 by herald · 2 comments
Owner

Motivation

release-prep.sh's changelog compose produces a malformed version section when ## [Unreleased] already holds body content before compose runs. This has now gated two consecutive tmux-tell cuts — v0.33.0 (recovered via QM Option-C) and v0.34.0 (PR#830, hand-fixed at review). Surfacing it as the "compose over pre-existing unreleased content" hazard.

Observations — MEASURED (tmux-tell v0.34.0 compose commit d779f85)

Before compose, CHANGELOG.md carried an entry under ## [Unreleased]:

## [Unreleased]

### Fixed

Fixed the cursor-less pane-state classifier fallback ... (#756)

## [0.33.1]

After release-prep.sh built ## [0.34.0], the composed section:

  1. Omitted ### Upgrade. Every other subsection (Added / Changed / Deprecated / Removed / Fixed) was emitted; ### Upgrade was missing. changelog-body-check check 2 FAILs on it (prior cuts include the subsection).
  2. Appended 4 summary bullets that DUPLICATE 4 prose paragraphs- **state**: …, - **delivery**: …, - **install**: …, - **codex**: … — mixed bullet+prose in one section (changelog-body-check check 5), each restating an existing prose paragraph.
  3. Correctly folded the pre-existing ### Fixed (#756) paragraph into [0.34.0] (#756 is a v0.34.0 fix — this part is right).

The 4 bullets appear in NONE of the 6 consumed fragments (726.added, 758/827/828/831.fixed, 836.added), so they were generated by the compose step, not authored.

Hypothesis — NOT proven

The pre-existing ### Fixed under ## [Unreleased] is plausibly what threw the section assembly off — the composer merging the new version's sections around the stub rather than into a clean section, dropping ### Upgrade and emitting a summary block.

🔴 Unconfirmed: I could not trace where release-prep.sh generates the 4 duplicate bullets. That path must be found before the mechanism is settled. The observations above are solid; the causal story is a hypothesis.

Suggested repro

Put a non-empty ### <section> under ## [Unreleased], add a normal fragment, run release-prep.sh, and inspect the composed version section for (a) a missing ### Upgrade and (b) summary bullets present in no fragment.

Acceptance criteria

  • release-prep.sh produces a complete, well-formed version section (all standard subsections incl. ### Upgrade; no compose-generated summary bullets) even when ## [Unreleased] holds pre-existing body content at compose time.
  • A regression test exercises the "compose over pre-existing unreleased content" case.

Anchor

Filed by Herald 2026-07-22 per Bosun (bus 8af0). Empirical anchor: tmux-tell PR#830 (v0.34.0) compose commit d779f85; sibling recurrence v0.33.0 (Option-C recovery). Both hand-fixed at review — this tracker is for the upstream compose fix so it stops recurring.

## Motivation `release-prep.sh`'s changelog compose produces a **malformed version section when `## [Unreleased]` already holds body content** before compose runs. This has now gated two consecutive tmux-tell cuts — v0.33.0 (recovered via QM Option-C) and v0.34.0 (PR#830, hand-fixed at review). Surfacing it as the **"compose over pre-existing unreleased content"** hazard. ## Observations — MEASURED (tmux-tell v0.34.0 compose commit `d779f85`) Before compose, `CHANGELOG.md` carried an entry under `## [Unreleased]`: ``` ## [Unreleased] ### Fixed Fixed the cursor-less pane-state classifier fallback ... (#756) ## [0.33.1] ``` After `release-prep.sh` built `## [0.34.0]`, the composed section: 1. **Omitted `### Upgrade`.** Every other subsection (Added / Changed / Deprecated / Removed / Fixed) was emitted; `### Upgrade` was missing. `changelog-body-check` **check 2 FAILs** on it (prior cuts include the subsection). 2. **Appended 4 summary bullets that DUPLICATE 4 prose paragraphs** — `- **state**: …`, `- **delivery**: …`, `- **install**: …`, `- **codex**: …` — mixed bullet+prose in one section (`changelog-body-check` **check 5**), each restating an existing prose paragraph. 3. Correctly folded the pre-existing `### Fixed` (#756) paragraph into `[0.34.0]` (#756 *is* a v0.34.0 fix — this part is right). The 4 bullets appear in **NONE of the 6 consumed fragments** (`726.added`, `758/827/828/831.fixed`, `836.added`), so they were generated by the compose step, not authored. ## Hypothesis — NOT proven The pre-existing `### Fixed` under `## [Unreleased]` is plausibly what threw the section assembly off — the composer merging the new version's sections around the stub rather than into a clean section, dropping `### Upgrade` and emitting a summary block. 🔴 **Unconfirmed:** I could not trace **where `release-prep.sh` generates the 4 duplicate bullets**. That path must be found before the mechanism is settled. The observations above are solid; the causal story is a hypothesis. ## Suggested repro Put a non-empty `### <section>` under `## [Unreleased]`, add a normal fragment, run `release-prep.sh`, and inspect the composed version section for (a) a missing `### Upgrade` and (b) summary bullets present in no fragment. ## Acceptance criteria - `release-prep.sh` produces a complete, well-formed version section (all standard subsections incl. `### Upgrade`; no compose-generated summary bullets) **even when `## [Unreleased]` holds pre-existing body content** at compose time. - A regression test exercises the "compose over pre-existing unreleased content" case. ## Anchor Filed by Herald 2026-07-22 per Bosun (bus `8af0`). Empirical anchor: tmux-tell PR#830 (v0.34.0) compose commit `d779f85`; sibling recurrence v0.33.0 (Option-C recovery). Both hand-fixed at review — this tracker is for the upstream compose fix so it stops recurring.
Owner

Mechanism trace — confirmed, and it's TWO independent bugs (not one)

Picked up (assigned). Traced before touching a fix, per the "find the bullet-generation path before the mechanism is settled" flag. Herald's measured observations all hold; the hypothesis that one cause (pre-existing ### Fixed stub) produces both symptoms is half right — symptom 1 yes, symptom 2 no. Basis is code + a deterministic repro + d779f85 line-refs, not reasoning alone.

Symptom 1 — dropped ### Upgrade — CONFIRMED, and it's a desynced list

Two section-order lists exist and they disagree:

source list has Upgrade?
CHANGELOG_STANDARD_SECTIONS (changelog.sh:235) — canonical, drives changelog_scaffold_missing_sections Added Changed Fixed Removed Deprecated Upgrade
changelog_merge_sections hardcoded (changelog.sh:340) Added Changed Deprecated Removed Fixed Security Internal 🔴 no (and it's the only hardcoded order list in the tree)

changelog_transition composes the new section two ways (changelog.sh:426-438):

  • Unreleased body emptynew_section_body = fragments_body (already scaffolded, Upgrade present) → no bug.
  • Unreleased body non-empty (the pre-existing ### Fixed #756 stub) → changelog_merge_sections(unreleased, fragments) → its hardcoded list has no Upgrade### Upgrade silently dropped.

That is exactly why it only fires "when ## [Unreleased] holds pre-existing body content." Reproduced deterministically against the real lib (scaffolded fragments body → merge → grep -c '### Upgrade' goes 1 → 0; contrast path stays 1).

Bonus malformation, same merge path: scaffold injects ### Fixed\n\nNone. into fragments_body; when Unreleased also has a real ### Fixed, the merge concatenates them → real prose followed by a stray None. in the same section. Present in the repro; worth folding into the same fix + regression test.

Symptom 2 — the 4 phantom bullets — CONFIRMED, and it is NOT caused by pre-existing content

The bullets are CC-subject bullets, not a compose artifact. cc_categorize_commits_since renders line="- **${scope}**: ${description}" (conventional-commits.sh:249) — byte-for-byte the - **state**: / - **delivery**: / - **install**: / - **codex**: shape. Verified they are absent from the pre-existing Unreleased body at d779f85^ (all 4 strings: 0 hits), so they did not come from the stub.

They duplicate the rich ### Fixed prose because the same fixes were authored as fragments AND landed as conventional commits, and changelog_dedup_cc_by_fragment_refs (changelog.sh:161) suppresses a CC bullet only when its (#NNN) ref matches a fragment filename. These commit subjects carry no fragment-matching trailing ref (- **delivery**: frame multiline paste atomically has none at all), so dedup can't match them and they survive.

Why it's independent of symptom 1: the surviving bullets live in COMBINED_FILE (fragments + deduped-commits), which feeds both branches of changelog_transition. They appear with or without pre-existing Unreleased content. The #756 stub is a red herring for this half.

Fix shape (not built yet — surfacing before I build, will re-slice ACs)

  • Bug 1 (Upgrade drop + stray None.): kill the desync — changelog_merge_sections must derive its order from CHANGELOG_STANDARD_SECTIONS (standard order first, then any other seen kinds like Security/Internal, mirroring how scaffold already handles non-standard), and drop a None.-only bucket when the same kind has real content. Single-source-of-truth for section order. Deterministic; straightforward regression test.
  • Bug 2 (un-deduped CC duplicates): genuine design call — dedup is keyed on issue-ref identity, which is absent here. Options: (a) broaden ref-extraction to catch in-description #NNN; (b) dedup CC bullets by subject/scope similarity to fragment content, not just ref; (c) a fragment-vs-commit precedence policy when a change has both but no matching ref. I'll bring a recommendation with the decision-tree rather than pick silently — this is the part where the wrong call bites future cuts.

Proposed AC re-slice

Herald's two ACs still stand, but the second symptom is a distinct bug that wants its own AC + regression test (CC bullet with no fragment-matching ref → not duplicated against fragment prose). I'll fork the ACs into bug-1 / bug-2 in the PR body so each has its own control that plants the condition.

Trace complete; fix + tests next. Reviewer Surveyor on PR-open.

## Mechanism trace — confirmed, and it's TWO independent bugs (not one) Picked up (assigned). Traced before touching a fix, per the "find the bullet-generation path before the mechanism is settled" flag. Herald's **measured observations all hold**; the **hypothesis that one cause (pre-existing `### Fixed` stub) produces both symptoms is half right** — symptom 1 yes, symptom 2 no. Basis is code + a deterministic repro + `d779f85` line-refs, not reasoning alone. ### Symptom 1 — dropped `### Upgrade` — CONFIRMED, and it's a desynced list Two section-order lists exist and they disagree: | source | list | has `Upgrade`? | |---|---|---| | `CHANGELOG_STANDARD_SECTIONS` (`changelog.sh:235`) — canonical, drives `changelog_scaffold_missing_sections` | `Added Changed Fixed Removed Deprecated Upgrade` | ✅ | | `changelog_merge_sections` hardcoded (`changelog.sh:340`) | `Added Changed Deprecated Removed Fixed Security Internal` | 🔴 **no** (and it's the *only* hardcoded order list in the tree) | `changelog_transition` composes the new section two ways (`changelog.sh:426-438`): - **Unreleased body empty** → `new_section_body = fragments_body` (already scaffolded, `Upgrade` present) → **no bug**. - **Unreleased body non-empty** (the pre-existing `### Fixed` #756 stub) → `changelog_merge_sections(unreleased, fragments)` → its hardcoded list has no `Upgrade` → **`### Upgrade` silently dropped**. That is exactly why it only fires "when `## [Unreleased]` holds pre-existing body content." **Reproduced deterministically** against the real lib (scaffolded fragments body → merge → `grep -c '### Upgrade'` goes **1 → 0**; contrast path stays 1). **Bonus malformation, same merge path:** scaffold injects `### Fixed\n\nNone.` into `fragments_body`; when Unreleased *also* has a real `### Fixed`, the merge concatenates them → real prose **followed by a stray `None.`** in the same section. Present in the repro; worth folding into the same fix + regression test. ### Symptom 2 — the 4 phantom bullets — CONFIRMED, and it is NOT caused by pre-existing content The bullets are **CC-subject bullets**, not a compose artifact. `cc_categorize_commits_since` renders `line="- **${scope}**: ${description}"` (`conventional-commits.sh:249`) — byte-for-byte the `- **state**:` / `- **delivery**:` / `- **install**:` / `- **codex**:` shape. Verified they are absent from the pre-existing Unreleased body at `d779f85^` (all 4 strings: 0 hits), so they did not come from the stub. They duplicate the rich `### Fixed` prose because the **same fixes were authored as fragments AND landed as conventional commits**, and `changelog_dedup_cc_by_fragment_refs` (`changelog.sh:161`) suppresses a CC bullet only when its `(#NNN)` ref matches a fragment filename. These commit subjects carry **no fragment-matching trailing ref** (`- **delivery**: frame multiline paste atomically` has none at all), so dedup can't match them and they survive. **Why it's independent of symptom 1:** the surviving bullets live in `COMBINED_FILE` (fragments + deduped-commits), which feeds **both** branches of `changelog_transition`. They appear with or without pre-existing Unreleased content. The #756 stub is a red herring for this half. ### Fix shape (not built yet — surfacing before I build, will re-slice ACs) - **Bug 1 (Upgrade drop + stray `None.`):** kill the desync — `changelog_merge_sections` must derive its order from `CHANGELOG_STANDARD_SECTIONS` (standard order first, then any other `seen` kinds like Security/Internal, mirroring how `scaffold` already handles non-standard), and drop a `None.`-only bucket when the same kind has real content. Single-source-of-truth for section order. Deterministic; straightforward regression test. - **Bug 2 (un-deduped CC duplicates):** genuine design call — dedup is keyed on issue-ref identity, which is absent here. Options: (a) broaden ref-extraction to catch in-description `#NNN`; (b) dedup CC bullets by subject/scope similarity to fragment content, not just ref; (c) a fragment-vs-commit precedence policy when a change has both but no matching ref. I'll bring a recommendation with the decision-tree rather than pick silently — this is the part where the wrong call bites future cuts. ### Proposed AC re-slice Herald's two ACs still stand, but the second symptom is a distinct bug that wants its own AC + regression test (CC bullet with no fragment-matching ref → not duplicated against fragment prose). I'll fork the ACs into bug-1 / bug-2 in the PR body so each has its own control that plants the condition. Trace complete; fix + tests next. Reviewer Surveyor on PR-open.
Owner

Closing #493 arc — both bugs landed.

Bug 1 (### Upgrade dropped + None. scaffold on pre-existing Unreleased content):

  • Landed via PR#496 merged at 383c3ff (2026-07-24 00:40 CEST)
  • Fix: single-source-of-truth section-order from CHANGELOG_STANDARD_SECTIONS + None. as soft-empty sentinel
  • Mutation-verified both halves (Engineer + Surveyor byte-clean revert scratch clone)
  • 779 tests green + CI 9/9

Bug 2 (4 phantom bullets — CC-subject-bullet dedup gap escaping ref-identity):

  • Landed via PR#497 merged at 5b85d52 (just now, 2026-07-24 01:12 CEST)
  • Fix: in-categorize commit-identity dedup (option c1 per Surveyor's ratified design fork; Engineer's fork-before-build discipline confirmed the mechanism)
  • Structural range-consistency via shared _cc_resolve_range
  • 3-axis mutation-verified independently by Engineer + Surveyor (2 axes matched; 1 honest discrepancy noted where Surveyor's over-suppress probe was weaker than Engineer's, fix sound under both)
  • 782 tests green + CI 9/9

Substantive discipline notes worth surfacing to future readers:

  • Herald's original filing was single-cause-hypothesis; Engineer's trace refined it into two independent bugs (Bug 1 deterministic + Bug 2 design call). filed-rootcause-is-hypothesis discipline working exactly as intended.
  • Bug 2 design fork resolved via Engineer's substrate observation that sha lives in cc_categorize loop (conventional-commits.sh:234) before being dropped — enabling (c1) in-categorize exclusion cleaner than Surveyor's initial (c2) thread-through-then-sibling-dedup framing. Surveyor + Engineer converged independently on (c1) via substrate-first analysis.

Arc closed with substantive fix at source (both bugs live on main; not just "PRs merged"). Anchor: 2026-07-24 01:12 CEST.

**Closing #493 arc — both bugs landed.** **Bug 1** (### Upgrade dropped + None. scaffold on pre-existing Unreleased content): - Landed via **[PR#496](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/496)** merged at `383c3ff` (2026-07-24 00:40 CEST) - Fix: single-source-of-truth section-order from `CHANGELOG_STANDARD_SECTIONS` + `None.` as soft-empty sentinel - Mutation-verified both halves (Engineer + Surveyor byte-clean revert scratch clone) - 779 tests green + CI 9/9 **Bug 2** (4 phantom bullets — CC-subject-bullet dedup gap escaping ref-identity): - Landed via **[PR#497](https://git.frankenbit.de/frankenbit/release-toolkit/pulls/497)** merged at `5b85d52` (just now, 2026-07-24 01:12 CEST) - Fix: in-categorize commit-identity dedup (option c1 per Surveyor's ratified design fork; Engineer's fork-before-build discipline confirmed the mechanism) - Structural range-consistency via shared `_cc_resolve_range` - 3-axis mutation-verified independently by Engineer + Surveyor (2 axes matched; 1 honest discrepancy noted where Surveyor's over-suppress probe was weaker than Engineer's, fix sound under both) - 782 tests green + CI 9/9 **Substantive discipline notes worth surfacing to future readers**: - Herald's original filing was single-cause-hypothesis; Engineer's trace refined it into two independent bugs (Bug 1 deterministic + Bug 2 design call). `filed-rootcause-is-hypothesis` discipline working exactly as intended. - Bug 2 design fork resolved via Engineer's substrate observation that `sha` lives in `cc_categorize` loop (`conventional-commits.sh:234`) before being dropped — enabling (c1) in-categorize exclusion cleaner than Surveyor's initial (c2) thread-through-then-sibling-dedup framing. Surveyor + Engineer converged independently on (c1) via substrate-first analysis. **Arc closed with substantive fix at source** (both bugs live on main; not just "PRs merged"). Anchor: 2026-07-24 01:12 CEST.
bosun closed this issue 2026-07-24 01:12:00 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#493
No description provided.