Repository navigation
feat(changelog): fragments instead of per-PR CHANGELOG hunks — the conflicts stop existing - #679
Conversation
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.
|
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 Scope: 10 files, +750/−3 — the assembler, its contract test, The rationale — why one fragment per change instead of an appended hunk, and why the |
|
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 Verified on this head ( 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 |
The problem, measured
Every PR that changes operator-visible behaviour appends one bullet to
CHANGELOG.mdat 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:CHANGELOG.mdconflicts resolved by hand on the 0.6.0 train;CHANGELOG.mdhunk at once right now, so merge order is also changelog-editing order;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:foldwrites nothing else in the file, refuses to run on a malformed fragment, and refuses aCHANGELOG.mdthat still carries conflict markers. Folding is a maintainer action in batches — before a release, or whenever the changelog should read current.CHANGELOG.mdstays the human record;RELEASE-NOTES.mdand the release step that renames[Unreleased]are unchanged.Why this, and not
.gitattributes merge=unionUnion 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:
checkruns inhooks/pre-commitand in thecontract-lintjob (.github/workflows/test.ymland its ngit port, the lane that actually executes).Proof
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.mdignored, unknown target refused without writing, a populated block keeping its content.shellcheckclean on the test and the hook; both workflow files still parse (yaml.safe_load: 7 and 5 jobs).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.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 in6a300c9c— and is deliberately not folded here, so the first fold produces it:13 lines added, 0 removed — the whole fold is additive, which is what makes it unable to conflict with anything.