Skip to content

feat(docs): changelog.d fragment system for conflict-free unreleased entries - #2994

Merged
bpamiri merged 2 commits into
developfrom
peter/changelog-fragments
Jun 10, 2026
Merged

bpamiri merged 2 commits into
developfrom
peter/changelog-fragments

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every open PR used to append its changelog entry at the same [Unreleased] anchor in CHANGELOG.md — so the moment one merged, every other open PR conflicted (exactly what happened to the 8-PR campaign batch today). This implements the fragments pattern (towncrier / changesets prior art):

  • changelog.d/ — one fragment file per PR: <slug>.<type>.md (type ∈ added,changed,deprecated,removed,fixed,security,performance) containing the complete markdown bullet line(s). Files never share lines → no conflicts. README.md documents the convention.
  • tools/changelog-promote.sh — --preview prints the assembled sections; <version> [date] merges fragments plus any legacy [Unreleased] content into a new ## [<version>] - <date> section (appending to existing ### Type headings in file order, creating missing ones in Keep-a-Changelog order), resets [Unreleased] to an empty section with a do-not-edit marker, and deletes the fragments. Fails loudly on malformed fragment names/content; edits files only — you review the diff and commit.
  • Touchpoints converted: PR template checklist, propose-fix step 9 (the bot now writes fragments), review-pr checklist (a direct [Unreleased] edit is now a review finding), address-review path allowlists, the TDD gate's non-implementation path list (changelog\.d/), a do-not-edit marker comment in CHANGELOG.md, and a CLAUDE.md convention note.
  • Dogfooded: this PR's own entry is a fragment.

The six currently-open campaign PRs were already converted to fragments (their CHANGELOG.md edits reverted, entries moved to changelog.d/), so they merge cleanly in any order.

Refs #2958 — this delivers the issue's drift-guard acceptance item; the [Unreleased] reconcile itself still happens at the next release cut, where changelog-promote.sh now does the assembly.

Type of Change

  • New feature (tooling/process)

Test Plan

  • Sandbox end-to-end: 3 sample fragments (added/fixed/security, incl. a multi-bullet one) promoted into a copy of the real CHANGELOG — entries landed under the correct existing headings, new Security content appended, marker comment installed in the fresh [Unreleased], fragments deleted, README preserved
  • --preview on the real repo prints this PR's own fragment
  • bot-tdd-gate.yml still parses (js-yaml); changelog-promote.sh passes bash -n
  • Malformed-name and empty-fragment paths fail loudly (exercised in sandbox)

🤖 Generated with Claude Code

…entries

Every open PR used to append its changelog entry at the same
[Unreleased] anchor in CHANGELOG.md, so each merge conflicted with
every other open PR. Entries are now per-PR fragment files under
changelog.d/ (<slug>.<type>.md, complete markdown bullets);
tools/changelog-promote.sh <version> assembles fragments plus any
legacy [Unreleased] content into the new version section at release
cut and clears the folder. PR template, propose-fix/review-pr/
address-review bot prompts, the TDD gate's non-impl path list, and
CLAUDE.md all updated to the fragment convention.

Refs #2958 (delivers the drift-guard acceptance item; the reconcile
itself still happens at the next release cut)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

This PR introduces the changelog.d fragment system to eliminate the [Unreleased]-anchor merge conflict that was hitting every concurrent campaign PR. The approach is sound — towncrier/changesets prior art, clean promotion script, dogfooded, all bot prompts and the TDD gate updated consistently. Overall: comment (two issues, neither blocks the core use-case).

Correctness

--preview silently omits legacy [Unreleased] content

File: tools/changelog-promote.sh, the --preview branch (lines 121–131):

if args[0] == "--preview":
    if not fragments:
        print("No fragments in changelog.d/.")
        sys.exit(0)
    for heading in CANONICAL:
        if heading in fragments:
            ...
    sys.exit(0)

This path reads only changelog.d/ fragments. It never calls parse_unreleased or merged_sections, so any entries already sitting under [Unreleased] in CHANGELOG.md (e.g. the existing ### Added block this PR leaves in place) are invisible.

The script header says "print the assembled sections" and changelog.d/README.md says "see what would be assembled," but the actual assembly (merged = merged_sections(existing, fragments)) merges both sources. Preview and promotion diverge.

Concretely: the next release cut will have a populated ### Added section from before this system existed. Running --preview beforehand will show only the fragment entries — not the legacy ones — so the release engineer gets an incomplete picture. If changelog.d/ happens to be empty at that moment, --preview prints "No fragments in changelog.d/." — which could be read as "nothing to release" even when the legacy block is substantial.

Suggested fix — extend --preview to merge both sources, matching what promotion actually does:

if args[0] == "--preview":
    if CHANGELOG.exists():
        text = CHANGELOG.read_text(encoding="utf-8")
        _, existing, _ = parse_unreleased(text)
    else:
        existing = []
    merged = merged_sections(existing, fragments)
    if not merged:
        print("Nothing to promote: no fragments and [Unreleased] is empty.")
        sys.exit(0)
    for heading, bullets in merged:
        if heading is not None:
            print(f"### {heading}\n")
            print("\n".join(bullets))
            print()
    sys.exit(0)

Docs

Type ordering in README vs CANONICAL list is transposed

changelog.d/README.md documents the valid types as:

one of added, changed, deprecated, removed, fixed, security, performance

CANONICAL in tools/changelog-promote.sh:

CANONICAL = ["Added", "Changed", "Deprecated", "Removed", "Performance", "Fixed", "Security"]

Performance is before Fixed in the script but after Security in the README listing. For a contributor using the README to understand what section their entry lands in relative to others, this creates a mismatch. The README should either list the types in CANONICAL order (so contributors see the actual promotion order) or note explicitly that the list is unordered and sequence is determined by the script.

Commits

Single commit feat(docs): changelog.d fragment system for conflict-free unreleased entries — type, scope, and header length (75 chars) all pass commitlint. ✓

@wheels-bot

wheels-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Reviewer B (round 1)

A's review is accurate and well-supported. Both findings check out against the actual diff; no sycophancy, no false positives, no significant missed issues. Aligning on converged-changes — the two findings are actionable and should be addressed before merge.

Sycophancy

None detected. A's verdict is COMMENTED (not APPROVE), and both findings are backed by specific code references.

False positives

None detected.

Finding 1 — --preview divergence. Verified against the diff. The --preview block in tools/changelog-promote.sh iterates only fragments (the result of read_fragments()) and never calls parse_unreleased or merged_sections. The existing ### Added block in CHANGELOG.md (present in the diff) is invisible to --preview. A's finding is accurate and the scenario described (release engineer sees an empty/incomplete preview when the legacy block is substantial) is real.

A's suggested fix is also sound — _, existing, _ = parse_unreleased(text) correctly unpacks the (head, sections, tail) triple, and passing existing to merged_sections mirrors what promotion actually does.

Finding 2 — CANONICAL vs README ordering. Verified. CANONICAL in the script: [..., Removed, Performance, Fixed, Security]. README listing: ..., removed, fixed, security, performance. Performance occupies position 5 in the script and position 7 (last) in the README — a real discrepancy A correctly identified.

Missed issues

None detected. The diff touches every expected touchpoint: propose-fix.md, review-pr.md, address-review.md, PR template, bot-tdd-gate.yml, CHANGELOG.md (do-not-edit marker), CLAUDE.md, changelog.d/README.md, the fragment file, and the promotion script. The TDD gate regex addition (changelog\.d/) is correctly escaped. No CFML involved, so no cross-engine concerns. Commit header is 75 chars and passes commitlint. DCO sign-off is present for the author.

Verdict alignment

A chose COMMENTED with two concrete, actionable findings — appropriate for non-blocking issues that should still be fixed before merge.

Convergence

Aligned. A found two real issues (preview divergence, README ordering), both verified. The joint recommendation is to address both before merge: extend --preview to merge both sources using A's suggested fix, and align the README type listing with CANONICAL order. Emitting converged-changes to trigger bot-address-review.yml.

- tools/changelog-promote.sh: extend --preview to merge legacy
  [Unreleased] sections with changelog.d/ fragments, matching what
  promotion actually does so a release engineer sees the full picture
  (previously preview read only fragments and was silently incomplete
  when [Unreleased] still held entries that predate this system).
- changelog.d/README.md: reorder the type listing to match
  CANONICAL in tools/changelog-promote.sh
  (added,changed,deprecated,removed,performance,fixed,security) so
  contributors see the actual promoted section order.

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
@wheels-bot

wheels-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Address Review (round 1)

Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):

  • --preview divergence — tools/changelog-promote.sh --preview branch now reads CHANGELOG.md, parses any existing [Unreleased] sections, and runs them through merged_sections() together with changelog.d/ fragments, matching what promotion actually does. Preview no longer silently omits legacy entries that predate the fragment system. Also swapped the "No fragments in changelog.d/." message for "Nothing to promote: no fragments and [Unreleased] is empty." so an empty [Unreleased] plus empty changelog.d/ produces the same unambiguous output as the promotion path.
  • Type ordering — changelog.d/README.md now lists the valid types in added, changed, deprecated, removed, performance, fixed, security order, matching CANONICAL in tools/changelog-promote.sh:30 so contributors can read the README and predict which order their entry will land in.

No skipped findings — both consensus items were addressed.

Tests not re-run: this PR's branch is not fix/bot-* and the change touches Python (inside the heredoc) plus a Markdown doc only — no CFML specs are affected.

The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on approve or the outer-loop cap (5 rounds) is reached.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer A

Round 1 addressing of the prior Reviewer A findings (commit `fbb82a7f`) — both consensus items have been correctly fixed. The delta is minimal and targeted: `--preview` now merges legacy `[Unreleased]` content with fragments using the same code path as promotion, and the README type listing is reordered to match `CANONICAL`. Overall: comment (one pre-existing docstring inconsistency surfaced below; zero runtime impact, but worth cleaning up before more call sites appear).

Correctness

parse_unreleased docstring advertises four return values; function returns three

tools/changelog-promote.sh, line 74:

def parse_unreleased(text):
    """Returns (head, body_sections, tail, had_unreleased).

    head  = everything through the '## [Unreleased]' line
    body_sections = ordered list of (heading-or-None, [lines]) inside Unreleased
    tail  = everything from the next '## ' heading onward
    """

The function body (line 103) returns head, sections, tail — three values. All three call sites correctly unpack three values (head, existing, tail = parse_unreleased(text) and _, existing, _ = parse_unreleased(text)), so there is no runtime failure. The fourth value had_unreleased is never returned and is not described in the docstring body — it looks like a vestige from an earlier iteration. Drop it from the signature in the docstring to avoid a future ValueError: not enough values to unpack for anyone who adds a call site based on the docs:

def parse_unreleased(text):
    """Returns (head, body_sections, tail).

    head          = everything through the '## [Unreleased]' line
    body_sections = ordered list of (heading-or-None, [lines]) inside Unreleased
    tail          = everything from the next '## ' heading onward
    """

Round-1 `--preview` fix verified correct. The new path calls parse_unreleased to read legacy [Unreleased] sections and passes them through merged_sections alongside the fragment dict — identical to what promotion does. The "No fragments" early-exit is replaced with "Nothing to promote: no fragments and [Unreleased] is empty." which correctly covers the dual-source case. Output format (heading + blank line + bullets + blank line) matches the previous fragment-only format for identical inputs.

Docs

README ordering fix verified correct. changelog.d/README.md type listing now reads added, changed, deprecated, removed, performance, fixed, security, matching CANONICAL in the script. Contributor-visible section order is now accurate.

Commits

Two commits on this PR:

  • feat(docs): changelog.d fragment system for conflict-free unreleased entries (75-char header) — passes commitlint ✓
  • fix(docs): address Reviewer A/B consensus findings (round 1) (62-char header) — passes commitlint; DCO sign-off present ✓

@wheels-bot

wheels-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Reviewer B (round 1)

A's review is substantively sound. The docstring finding is real and actionable, the verifications of the round-1 fixes are accurate, and the commit linting checks out. One minor framing inaccuracy noted below; no false positives, no significant missed issues. Converging on changes.

Sycophancy

None detected. A issued a comment verdict with a concrete finding rather than a blanket LGTM.

False positives

Minor: "pre-existing" mischaracterizes the docstring bug. A describes the parse_unreleased docstring inconsistency as a "pre-existing docstring inconsistency." The file tools/changelog-promote.sh is entirely new in this PR (new file mode 100755), so there is no pre-existing version. The bug was introduced in the first commit of this PR, not inherited. The finding itself is correct; only the framing is slightly off. This does not affect the substance.

Missed issues

None that materially affect correctness. One minor observation: the promotion path calls CHANGELOG.read_text() directly without checking for file existence (unlike --preview, which guards with if CHANGELOG.exists()). This would surface as an unhandled FileNotFoundError if run against a fresh repo without a CHANGELOG. In practice this repo always has one, so the impact is negligible — not worth holding the PR for.

Verdict alignment

A's comment verdict is consistent with the findings. A's own phrasing ("worth cleaning up before more call sites appear") implies the fix is wanted before merge. Under the convergence rules, a comment verdict with concrete actionable findings qualifies for converged-changes.

Convergence

Aligned. The single finding — remove had_unreleased from the parse_unreleased docstring signature (tools/changelog-promote.sh, the """Returns (head, body_sections, tail, had_unreleased).""" line) — is real, simple, and should be applied before merge. All other aspects of the PR are correct.

@bpamiri
bpamiri merged commit 5633f86 into develop Jun 10, 2026
7 checks passed
@bpamiri
bpamiri deleted the peter/changelog-fragments branch June 10, 2026 18:27
@wheels-bot

wheels-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Wheels Bot — Address Review held (PR already merged)

Round-2 convergence pointed at head SHA 6ad8fe3cca878a20aa09430c94be91d66ea6a216, but the PR was squash-merged into develop (commit 5633f86e, merged by @bpamiri at 2026-06-10T18:27:52Z) before this address-review run could push a follow-up commit.

Reviewer B's round-2 finding — the stale (head, body_sections, tail, had_unreleased) docstring in tools/changelog-promote.sh::parse_unreleased (the function actually returns the 3-tuple (head, sections, tail)) — landed in `develop` along with the rest of the squash merge. If the cleanup is still wanted, it needs a follow-up PR against `develop`; pushing to `peter/changelog-fragments` now wouldn't gate the already-merged change.

No commits were pushed to this branch.

bpamiri added a commit that referenced this pull request Jun 19, 2026
#3219)

* fix(build): changelog-promote emits release.yml-compatible section format

The changelog.d fragment system (#2994) was never exercised by a real cut.
Its promote step wrote "## [version] - date" (two-hash, no tag link), but
every released section — and release.yml's notes extraction
(awk '/^# \[VERSION\]/,/^---$/') — uses a single-hash "# [version](tag) => date".
With the two-hash header the one-hash awk extracts zero lines, so the GitHub
Release would publish with EMPTY notes.

Also: boundary detection searched for the next "## " heading, but version
sections here are single-hash, so the first "## " match landed on a
subheading inside the 3.0.0 section — pulling every release since into the
promoted body. And a stray "----"/"---" rule in [Unreleased] could leak in.

- Match the next version heading at "#" OR "##" + " [" (correct tail boundary).
- Drop horizontal-rule lines from the promoted body.
- Emit "# [version](tag-url) => date" + explicit "---" separators around it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* chore(release): assemble changelog.d fragments into [4.0.4]

Promote the 83 pending changelog.d fragments (plus prior [Unreleased] content)
into the [4.0.4] release section via tools/changelog-promote.sh, and clear the
fragment folder. Done on develop first (per the release playbook) so develop's
[Unreleased] doesn't go stale and force a back-port after the cut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* fix(build): consolidate changelog sections by heading in canonical order

The first promote pass exposed a second defect: when [Unreleased] carries the
same heading twice (develop had two '### Performance' blocks), the merge
appended the fragment bullets to EACH same-named section — double-counting every
performance fragment (e.g. the $getStatusCodes entry appeared twice). It also
emitted sections in [Unreleased] file order with the duplicate intact and
fragment-only sections tacked on last.

merged_sections now combines all bullets for a heading into ONE section and
emits sections in canonical order (Added, Changed, Deprecated, Removed,
Performance, Fixed, Security), existing bullets before fragment bullets.

Regenerated the [4.0.4] section: 116 unique entries (was 124 with 8 dupes),
canonical order, no duplicate headings. Verified zero unique entries lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* docs(build): sync changelog-promote usage header + summary to new format

Address the Reviewer-A nit: the top-of-file usage comment still advertised the
old "## [version] - date" shape and the summary print() used " - " — both now
reflect the single-hash, tag-linked, "=>"-dated section the script emits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

* test(build): cover new changelog-promote format + section consolidation

Address Reviewer-A (CHANGES_REQUESTED): the format change broke Test 3, which
still asserted the old "## [ver] - date" header — re-anchor it to the
single-hash, tag-linked, "=>"-dated section the script now emits. Add Test 5
covering the merged_sections consolidation: a [Unreleased] body with two
"### Performance" blocks plus a matching *.performance.md fragment must collapse
to one heading in canonical order with each bullet appearing exactly once (the
double-count regression the 4.0.4 cut surfaced). Full suite green (11/11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>

---------

Signed-off-by: Peter Amiri <petera@pai.com>
Co-authored-by: Peter Amiri <petera@pai.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant