Repository navigation
feat(docs): changelog.d fragment system for conflict-free unreleased entries - #2994
Conversation
…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>
There was a problem hiding this comment.
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 — 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 SycophancyNone detected. A's verdict is False positivesNone detected. Finding 1 — A's suggested fix is also sound — Finding 2 — CANONICAL vs README ordering. Verified. CANONICAL in the script: Missed issuesNone detected. The diff touches every expected touchpoint: Verdict alignmentA chose ConvergenceAligned. A found two real issues (preview divergence, README ordering), both verified. The joint recommendation is to address both before merge: extend |
- 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 — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):
No skipped findings — both consensus items were addressed. Tests not re-run: this PR's branch is not The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
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 — 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. SycophancyNone detected. A issued a False positivesMinor: "pre-existing" mischaracterizes the docstring bug. A describes the Missed issuesNone that materially affect correctness. One minor observation: the promotion path calls Verdict alignmentA's ConvergenceAligned. The single finding — remove |
Wheels Bot — Address Review held (PR already merged)Round-2 convergence pointed at head SHA Reviewer B's round-2 finding — the stale No commits were pushed to this branch. |
#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>
Summary
Every open PR used to append its changelog entry at the same
[Unreleased]anchor inCHANGELOG.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.mddocuments the convention.tools/changelog-promote.sh—--previewprints the assembled sections;<version> [date]merges fragments plus any legacy[Unreleased]content into a new## [<version>] - <date>section (appending to existing### Typeheadings 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.propose-fixstep 9 (the bot now writes fragments),review-prchecklist (a direct[Unreleased]edit is now a review finding),address-reviewpath allowlists, the TDD gate's non-implementation path list (changelog\.d/), a do-not-edit marker comment inCHANGELOG.md, and a CLAUDE.md convention note.The six currently-open campaign PRs were already converted to fragments (their
CHANGELOG.mdedits reverted, entries moved tochangelog.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, wherechangelog-promote.shnow does the assembly.Type of Change
Test Plan
Securitycontent appended, marker comment installed in the fresh[Unreleased], fragments deleted, README preserved--previewon the real repo prints this PR's own fragmentbot-tdd-gate.ymlstill parses (js-yaml);changelog-promote.shpassesbash -n🤖 Generated with Claude Code