Skip to content

Changelog fragments: one file per PR under changelog.d/, folded in at the release cut — sibling merges stop conflicting - #462

Merged
MarkusNeusinger merged 3 commits into
mainfrom
claude/changelog-fragments
Aug 30, 2026
Merged

Changelog fragments: one file per PR under changelog.d/, folded in at the release cut — sibling merges stop conflicting#462
MarkusNeusinger merged 3 commits into
mainfrom
claude/changelog-fragments

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

What

  • changelog.d/<slug>.md — one fragment per PR, in the CHANGELOG's own format (### Category over bold-titled bullets, so the cut section reads as if written in place). changelog.d/README.md is the manual; this PR's own fragment is changelog.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, bumps pyproject.toml, uv.lock and CITATION.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.
  • CI job „Changelog (fragment)"pull_request only, skipped by the skip-changelog label, fetch-depth: 0 for the base diff, uv run --no-project python -m tools.changelog check --base origin/<base>. Errors surface as ::error:: annotations.
  • Rules moved: the CHANGELOG header (the cut is one command now), the CLAUDE.md guardrail and .github/copilot-instructions.md (both spots, in sync), the /open-pr changelog gate, the .gitattributes comment (union stays as the net under the cut PR itself), a werkzeuge.md section, 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 own uv.lock block 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 next release folds it together with the fragments (fragments on top). preview shows the merged result already. Owner-side: if branch protection lists required checks, add „Changelog (fragment)".

Verification

ruff check / ruff format --check clean over the repo; full suite 1835 passed; the CI call uv run --no-project python -m tools.changelog check --base origin/main passes on this branch; preview and release 0.28.0 --title … --dry-run run against the real file and write nothing (working tree verified clean afterwards).

🤖 Generated with Claude Code

https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX

… 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
Copilot AI lite review requested due to automatic review settings August 30, 2026 07:01
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TVu1uouGhsjKCEVhWBinX

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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/changelog with check, preview, and release commands (including version-file bump + fragment folding/deletion).
  • Add CI gate “Changelog (fragment)” to enforce “fragment present + no bullets added directly to [Unreleased]”, with skip-changelog and data/ 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>...HEAD to detect changed files. If that command fails (e.g., because <base> isn't present in the local clone), returning an empty diff makes check_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.md is modified, merge-base/show failures (e.g., a missing/unfetched --base) currently degrade to before=None, which can produce misleading [Unreleased] gained a bullet errors (or miss that it's a release cut). Treat these git calls as required and raise a clear ChangelogError instead 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.

Comment thread tools/changelog/__init__.py Outdated
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
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

Re tools/changelog/__init__.py _git(): agreed — an unfetched --base made the diff fail, which read as an empty diff and passed. Fixed in 5fbb527: the gate's calls (diff, merge-base, show) are required=True and raise ChangelogError with git's stderr; only the fragment-date lookup stays best-effort (an uncommitted fragment is legitimately unknown to git). Pinned by test_an_unknown_base_stops_the_gate_instead_of_passing_it.

Copilot AI review requested due to automatic review settings August 30, 2026 07:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It changes CI enforcement and the release-cut workflow, so a final human review is warranted despite the strong test coverage.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@MarkusNeusinger
MarkusNeusinger merged commit 523bc3a into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants