Changelog fragments: one file per PR under changelog.d/, folded in at the release cut — sibling merges stop conflicting - #462
Conversation
… the release cut Every PR used to add its bullets under [Unreleased] of the one shared CHANGELOG.md, and every sibling merge conflicted the others exactly there. The union merge driver (#461) heals the local rebase, but GitHub's mergeability check ignores merge drivers and a branch that moves changelog lines came out of the rebase duplicated. Removing the shared spot is the fix: a PR adds changelog.d/<slug>.md in the changelog's own format and touches nothing else. tools/changelog (stdlib only): `check [--base REF]` is the CI gate — a fragment per PR, or a release cut, or data-only, and no bullet written into [Unreleased] directly; `preview` prints the pending section; `release X.Y.Z --title …` folds the fragments newest-first under the new heading, bumps pyproject.toml, uv.lock and CITATION.cff and deletes the fragments. New CI job "Changelog (fragment)", skipped by the skip-changelog label. Rules moved in CHANGELOG header, CLAUDE.md, copilot-instructions, the open-pr gate, .gitattributes, werkzeuge.md, the glossary and sprachregelung §4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX
There was a problem hiding this comment.
🟡 Changes recommended
The PR gate currently treats failed git commands as “no changes” (silent pass) in tools/changelog/__init__.py, which can incorrectly bypass the fragment enforcement when --base isn’t available.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR replaces direct edits to CHANGELOG.md with a “one fragment per PR” workflow (changelog.d/<slug>.md), backed by a standard-library-only tools/changelog CLI, CI enforcement, and documentation updates to eliminate sibling-merge conflicts around [Unreleased].
Changes:
- Add
tools/changelogwithcheck,preview, andreleasecommands (including version-file bump + fragment folding/deletion). - Add CI gate “Changelog (fragment)” to enforce “fragment present + no bullets added directly to
[Unreleased]”, withskip-changeloganddata/exemptions. - Update repo docs/guardrails/glossary and add a dedicated unit test suite for the new tooling + PR gate behavior.
File summaries
| File | Description |
|---|---|
| tools/changelog/main.py | CLI entrypoint for python -m tools.changelog (check/preview/release). |
| tools/changelog/init.py | Core implementation: parsing, fragment loading/merging, release planning/apply, PR gate. |
| tests/test_changelog_tool.py | Unit tests pinning fragment format, merge order, release cut behavior, version bumping, and PR gate semantics. |
| docs/reference/werkzeuge.md | Adds German reference section describing the changelog cut tool and workflow. |
| docs/reference/sprachregelung.md | Extends the list of English artifacts to include changelog.d/*.md. |
| docs/reference/glossar.md | Adds glossary entry and index mention for “Changelog-Fragment”. |
| CLAUDE.md | Updates working guardrails to reflect fragment workflow and CI gate. |
| CHANGELOG.md | Updates header policy to describe fragments + tool-driven release cut; keeps union merge as fallback/net. |
| changelog.d/README.md | New contributor-facing manual for fragment format/rules and local commands. |
| changelog.d/changelog-fragments.md | This PR’s own changelog fragment. |
| .github/workflows/ci.yml | Adds “Changelog (fragment)” job running tools.changelog check --base origin/<base>. |
| .github/copilot-instructions.md | Updates Copilot guardrails to the fragment process and release command. |
| .gitattributes | Updates union-merge comment to reflect fragments as the primary mechanism. |
| .claude/skills/open-pr/SKILL.md | Updates the /open-pr skill’s changelog gate to require a fragment rather than CHANGELOG.md edits. |
Review details
Suppressed comments (2)
tools/changelog/init.py:293
- The PR gate relies on
git diff <base>...HEADto detect changed files. If that command fails (e.g., because<base>isn't present in the local clone), returning an empty diff makescheck_pr()incorrectly treat the branch as having no changes and pass. Use the stricter_git(..., allow_fail=False)here so a missing/invalid base ref fails the check explicitly.
def _changed_files(root: Path, base: str) -> dict[str, str]:
"""`path → status letter` for everything HEAD changed since it branched off `base`."""
out = _git(root, "diff", "--name-status", "--no-renames", f"{base}...HEAD")
changed: dict[str, str] = {}
tools/changelog/init.py:322
- When
CHANGELOG.mdis modified,merge-base/showfailures (e.g., a missing/unfetched--base) currently degrade tobefore=None, which can produce misleading[Unreleased] gained a bulleterrors (or miss that it's a release cut). Treat these git calls as required and raise a clearChangelogErrorinstead of guessing.
if changed.get(CHANGELOG_NAME) == "M":
merge_base = _git(root, "merge-base", base, "HEAD").strip()
before_text = _git(root, "show", f"{merge_base}:{CHANGELOG_NAME}") if merge_base else ""
before = split_changelog(before_text) if before_text else None
after = split_changelog((root / CHANGELOG_NAME).read_text(encoding="utf-8"))
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
An unfetched base ref made `git diff` fail, which read as an empty diff and let the PR pass — the one silent pass the gate must not have (Copilot finding on #462). The gate's calls are now required and raise with git's stderr; only the fragment-date lookup stays best-effort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX
|
Re |
What
changelog.d/<slug>.md— one fragment per PR, in the CHANGELOG's own format (### Categoryover bold-titled bullets, so the cut section reads as if written in place).changelog.d/README.mdis the manual; this PR's own fragment ischangelog.d/changelog-fragments.md.tools/changelog(standard library only, so CI needs no project deps):check [--base REF]— every fragment is well-formed; with--base, the diff against REF carries a fragment, or is a release cut (a version heading the base lacks), or is data-only (data/) — and[Unreleased]gained no bullet directly.preview— the pending section as the next cut would write it.release X.Y.Z --title "…" [--date] [--dry-run]— folds the fragments (newest first within a category, by the commit that added them; an uncommitted one on top) plus whatever[Unreleased]still holds under## [X.Y.Z] — date — title, bumpspyproject.toml,uv.lockandCITATION.cff(exactly one line each, or it stops), deletes the fragments. Working tree only — commit, tag and the condensed GitHub release stay the author's.pull_requestonly, skipped by theskip-changeloglabel,fetch-depth: 0for the base diff,uv run --no-project python -m tools.changelog check --base origin/<base>. Errors surface as::error::annotations..github/copilot-instructions.md(both spots, in sync), the/open-prchangelog gate, the.gitattributescomment (union stays as the net under the cut PR itself), awerkzeuge.mdsection, the glossary entry „Changelog-Fragment" (+ Schnellindex),sprachregelung.md§4's list of English artifacts.tests/test_changelog_tool.py— 21 tests: the fragment format (each malformed shape names its line), merge order, the cut against a synthetic changelog (older sections byte-identical, a second cut finds nothing), the version-line bumps (only the project's ownuv.lockblock moves), and the PR gate against a throwaway git repository — fragment present / missing / data-only / bullet sneaked into[Unreleased]/ the release cut with and without fragments.Why
#461's union driver heals the local rebase for pure additions — but GitHub's own mergeability check ignores merge drivers (#459 went DIRTY after #458 merged with union already on
main), and a branch that moves changelog lines came out of the union rebase with the block duplicated. Removing the shared spot is the fix: two PRs never meet at the same line again.Transition
The current
[Unreleased]section stays as it is; the nextreleasefolds it together with the fragments (fragments on top).previewshows the merged result already. Owner-side: if branch protection lists required checks, add „Changelog (fragment)".Verification
ruff check/ruff format --checkclean over the repo; full suite 1835 passed; the CI calluv run --no-project python -m tools.changelog check --base origin/mainpasses on this branch;previewandrelease 0.28.0 --title … --dry-runrun against the real file and write nothing (working tree verified clean afterwards).🤖 Generated with Claude Code
https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX