Skip to content

feat(changelog): fragments instead of per-PR CHANGELOG hunks — the conflicts stop existing - #679

Merged
c03rad0r merged 4 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/changelog-fragments
Oct 6, 2026
Merged

c03rad0r merged 4 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/changelog-fragments

Conversation

@felixfelix-bot

@felixfelix-bot felixfelix-bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

The problem, measured

Every PR that changes operator-visible behaviour appends one bullet to CHANGELOG.md at the same anchor, so two PRs in flight always conflict on the same lines. That is not bad luck, it is structural, and it has cost real time:

The fix

A change's changelog entry becomes its own file. changelog.d/<pr-number>-<slug>.<type>.md, where <type> is the section (added, changed, internal, deprecated, fixed, removed, security) and the body is the bullet, verbatim and wrapped as it should read.

Unique filenames cannot conflict, so a merge never touches CHANGELOG.md — not with a conflict, and not cleanly either. scripts/changelog-assemble.py:

fold [--target Unreleased] [--dry-run]   # one wave under the release heading, newest PR first, canonical subsection order; deletes the folded fragments
check                                    # names, types, bodies (CI + pre-commit)

fold writes nothing else in the file, refuses to run on a malformed fragment, and refuses a CHANGELOG.md that still carries conflict markers. Folding is a maintainer action in batches — before a release, or whenever the changelog should read current. CHANGELOG.md stays the human record; RELEASE-NOTES.md and the release step that renames [Unreleased] are unchanged.

Why this, and not .gitattributes merge=union

Union is one line and nobody changes habit, but it does not remove the shared anchor — it hides the conflict. When two branches resolve the same bullet differently (one reworded it, the other added a neighbour) union emits both versions and the merge is silent, which is precisely the failure this train produced twice by hand; on a prose file with hand-ordered waves, "both sides' lines, in some order" is not a merge anyone can review. Full comparison, including the rejected "generate from commit subjects" option: docs/architecture/changelog-fragments-decision.md.

Transition — the PRs already open

Nothing has to be rewritten for this to work. Fragments apply to new work; the open PRs keep their inline hunks and stay mergeable exactly as they are. Once that queue lands, the presence rule can be tightened with a CI gate. Today's enforcement is validation, not prohibition: check runs in hooks/pre-commit and in the contract-lint job (.github/workflows/test.yml and its ngit port, the lane that actually executes).

Proof

  • RED first: the contract test fails 21/21 with the tool absent.
  • GREEN: bash tests/contract/changelog-fragments_test.sh → 18 cases, 0 failed — where entries land, newest-first order, verbatim bodies, fragment deletion, the dry run, README.md ignored, unknown target refused without writing, a populated block keeping its content.
  • shellcheck clean on the test and the hook; both workflow files still parse (yaml.safe_load: 7 and 5 jobs).
  • Run against the real 3,500-line CHANGELOG.md (in a copy): the wave lands under ## [Unreleased] in canonical order, fragments are deleted, and the rest of the file is byte-identical.
  • The pre-commit hook fires on a staged fragment and passes it.

Composes with #581

#581 polices the same entry in two places; fragments add a second place an entry can live (a stale fragment beside a folded section), so that lint stays useful and gains scope rather than being superseded.

Status

The decision record is Status: Proposed — acceptance is a maintainer action, and the drafting account does not accept its own proposal. This PR's own entry is a fragment —
changelog.d/679-changelog-fragments.added.md, added in 6a300c9c — and is deliberately not folded here, so the first fold produces it:

$ python3 scripts/changelog-assemble.py check
changelog.d/: 1 fragment(s), no findings

$ python3 scripts/changelog-assemble.py fold --dry-run | head -20   # against main @ d3be9357
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,21 @@
 ## [Unreleased]
+
+### Added
+
+- **Changelog entries no longer conflict on merge: a change's entry is its own
+  file.** …
 
 ### Fixed   <- #678's entry, untouched

13 lines added, 0 removed — the whole fold is additive, which is what makes it unable to conflict with anything.

Felix added 4 commits October 6, 2026 21:27
Two PRs that change operator-visible behaviour both append one bullet to the
same anchor of CHANGELOG.md, so the second merge conflicts every time — about a
dozen hand-resolved hunks on the 0.6.0 train, and two of those resolutions
(OpenTollGate#524, OpenTollGate#531) resurrected an entry a branch had already moved past a
keep-both-sides resolution. 15 open PRs carry a CHANGELOG.md hunk at once as
this lands, so the merge order was also a changelog-editing order.

A fragment is one file per change — changelog.d/<pr>-<slug>.<type>.md, the body
being the bullet verbatim — so unique filenames cannot conflict and a merge
never touches the changelog at all. `scripts/changelog-assemble.py fold` writes
the accumulated entries into the release section in one deterministic step (one
wave under the release heading, newest PR first, subsections in canonical
order) and deletes the folded fragments; `check` validates names and bodies;
`--dry-run` prints the wave and writes nothing.

The contract test went red first — 21 failing cases with the tool absent — and
green after: 18 cases covering where entries land, the order they land in, what
is deleted, the dry run, and each verdict of check. shellcheck clean; the fold
was also run against the real 3,500-line CHANGELOG.md in a copy to see the wave
it produces (folded into `## [Unreleased]`, everything else untouched).

Decision and the rejected alternatives: docs/architecture/changelog-fragments-decision.md
`check` runs where the other contract checks run: the pre-commit hook refuses a
malformed fragment (wrong filename, unknown type, a body that is not the bullet)
before it is committed, and the `contract-lint` job runs the check plus the
tool's contract test on every PR. Wired into `.github/workflows/test.yml` and
its ngit port, which is the lane that actually executes.
The decision record states the problem (one shared anchor, a dozen hand-resolved
hunks a train, two resurrections), the decision (fragments + one fold), and the
alternatives it rejects — a `merge=union` gitattributes driver (hides the shared
anchor and silently keeps both sides of a bullet that was rewritten) and
generating the changelog from commit subjects (loses the prose this changelog
is worth reading for). CONTRIBUTING sends authors to changelog.d/ instead of
CHANGELOG.md, and the release process folds the accumulated fragments in its own
commit before the release commit is cut.
The rule this PR adds applies to it: the changelog entry lives in
changelog.d/679-changelog-fragments.added.md and is not folded here, so the
first fold the maintainer runs produces it.
@felixfelix-bot

Copy link
Copy Markdown
Contributor Author

Review record — self-authored PR, so the approval has to be yours (GitHub will not let the author approve their own).

Re-run at head 6a300c9c:

$ python3 scripts/changelog-assemble.py check
changelog.d/: 1 fragment(s), no findings                       (exit 0)
$ bash tests/contract/changelog-fragments_test.sh
18 cases, 0 failed
$ git merge <this branch> refs/pr/581   # the other changelog PR
clean, 5 files; hooks/pre-commit runs both checks; both CI steps present;
check-changelog-duplicates.py exits 0 beside the fragments check

Scope: 10 files, +750/−3 — the assembler, its contract test, changelog.d/README.md, this PR's own fragment, the two workflow files, and the decision record. CHANGELOG.md itself is untouched by the branch (folding happens at release time, fold --dry-run verified: +13/−0 on the real 3.5k-line file).

The rationale — why one fragment per change instead of an appended hunk, and why the .gitattributes merge=union alternative was rejected — is in docs/architecture/changelog-fragments-decision.md (Status: Proposed, yours to accept or drop).

@felixfelix-bot

Copy link
Copy Markdown
Contributor Author

Tag legend: pre-release-safe = land before the pre-release you are testing · ready = no blocker, timing is yours · post-release = hold until after · needs-author-pass = back to the author.

TAG: pre-release-safe

Changelog fragments replace the per-PR CHANGELOG.md hunk — the class that put five PRs into conflict today. No product code; CI + hook only.

Verified on this head (6a300c9c):

python3 scripts/changelog-assemble.py check   -> exit 0  "changelog.d/: 1 fragment(s), no findings"
bash tests/contract/changelog-fragments_test.sh -> 18 cases, 0 failed
git merge <this branch> refs/pr/581            -> clean; hooks/pre-commit runs BOTH checks;
                                                  both CI steps present; both gates exit 0
fold --dry-run on the real 3.5k-line CHANGELOG -> +13/-0, purely additive

Nothing to change — I wrote it, so this is a verification record, not a review. Needs your approval (GitHub blocks author self-approval). Rationale and the rejected merge=union alternative: docs/architecture/changelog-fragments-decision.md.

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