Supersedes the detect-the-fold approach in #2001. That issue proposed catching the release-fold after it happens; this one removes the fold's cause entirely.
The root cause (recap)
Every open PR edits the same shared lines — ## [current] in CHANGELOG.md. When a release is cut it renames those lines ([current] → [0.664.0]), so merging the new main back into any open PR silently folds that PR's entry INTO the released section (no conflict marker). It has bitten four PRs in a row (0.661→0.664 cut against open branches). The real problem is many writers, one shared file, one shared heading.
The design: one fragment file per change
Adopt the changelog.d / newsfragments pattern (towncrier, Cargo, GitLab):
- A PR does not edit
CHANGELOG.md. It drops a file in ./new_changelogs/, one per change, containing its bullet(s).
- Because each PR writes its own file, PRs never touch shared lines → no conflicts, and a release cannot fold an entry that isn't in
CHANGELOG.md yet.
- A daily
[skip actions] job collects settled fragments into CHANGELOG.md under ## [current], git rms them, and commits.
This deletes the whole failure class. The CI "CHANGELOG entry present" gate flips from "requires a ## [current] edit" to "requires a new_changelogs/ file (or [skip changelog])", and the fold-check gate (and #2001) can be removed.
The timestamp: filename = UTC time of the last push to the PR
Rather than derive age from git history or file mtime (mtime is rewritten by any checkout/clone, so it is useless here), encode the time in the filename — the UTC timestamp of the last push to the PR that introduced the fragment, e.g.:
new_changelogs/20260910T175500Z-1988-optional-library-linking.md
The daily collector folds a fragment only when its filename timestamp is older than a cooling-off window (≈4h). The window's purpose is to let a just-landed change settle (a revert or follow-up fix) before it becomes a permanent changelog line.
Semantics to ratify (the "last push" wrinkle)
Using last push (not first-authored) time means a branch that is force-pushed resets its fragment's clock — the change is treated as "not settled until pushes stop." That is arguably the intended behaviour (an actively-churning PR should not fold yet), but it should be a deliberate choice:
- Chosen (proposed): last-push time → cooling-off restarts on every push. An abandoned/settled PR folds ~4h after its final push.
- Alternative: first-landed-on-main time → folds ~4h after merge regardless of later branch activity.
Whichever is chosen, the timestamp must be written by the author/PR tooling into the filename, not inferred at collection time, so the collector needs no git archaeology.
Release interaction (also to ratify)
When a release is cut while fragments are still inside the cooling-off window:
- Proposed: the release job collects all pending fragments regardless of age (cutting a release is a deliberate "ship what's here"); the ≈4h bar applies only to the daily job. This guarantees no merged change is ever absent from its release.
- Alternative: the release respects the ≈4h bar too — safer against recording an unsettled note, but a change merged minutes before a release would miss it and need a catch-up next cut.
Fragment format (to ratify)
Section (### Added / ### Fixed / ### Changed / ### Performance) needs to be declarable per fragment. Options, in rough order of preference:
- Filename prefix:
20260910T175500Z-fixed-1988-*.md — visible in ls, trivial to group, no in-file boilerplate.
- In-file
section: header line.
- Raw bullet, defaulted to
### Fixed and re-sorted at collection.
Open questions for @nic
- last-push vs first-landed timestamp (the churn-reset tradeoff above).
- Does a release sweep all pending fragments, or respect the cooling-off bar?
- Cooling-off window: 4h as proposed, or another value.
- Who stamps the filename — a commit hook, a PR-template helper, or a tiny
make add-changelog that writes new_changelogs/$(date -u +%Y%m%dT%H%M%SZ)-<n>-<slug>.md? (The daily/release jobs carry the [skip actions] token in their own commits only — never in a PR title — per the release-pipeline rule.)
Non-goals
- Not auto-writing bullet prose — the author still writes the fragment.
- Not changing what a changelog entry says, only where it lives until collection.
Acceptance
- A PR with a
new_changelogs/ fragment and no CHANGELOG.md edit passes the (reworked) changelog gate.
- Two PRs adding fragments simultaneously never conflict on
CHANGELOG.md.
- A release cut against open PRs cannot fold any fragment (there is nothing in
CHANGELOG.md to fold).
- The daily job folds only fragments whose filename timestamp is older than the window,
git rms them, and commits with [skip actions].
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Supersedes the detect-the-fold approach in #2001. That issue proposed catching the release-fold after it happens; this one removes the fold's cause entirely.
The root cause (recap)
Every open PR edits the same shared lines —
## [current]inCHANGELOG.md. When a release is cut it renames those lines ([current]→[0.664.0]), so merging the newmainback into any open PR silently folds that PR's entry INTO the released section (no conflict marker). It has bitten four PRs in a row (0.661→0.664 cut against open branches). The real problem is many writers, one shared file, one shared heading.The design: one fragment file per change
Adopt the changelog.d / newsfragments pattern (towncrier, Cargo, GitLab):
CHANGELOG.md. It drops a file in./new_changelogs/, one per change, containing its bullet(s).CHANGELOG.mdyet.[skip actions]job collects settled fragments intoCHANGELOG.mdunder## [current],git rms them, and commits.This deletes the whole failure class. The CI "CHANGELOG entry present" gate flips from "requires a
## [current]edit" to "requires anew_changelogs/file (or[skip changelog])", and the fold-check gate (and #2001) can be removed.The timestamp: filename = UTC time of the last push to the PR
Rather than derive age from git history or file mtime (mtime is rewritten by any checkout/clone, so it is useless here), encode the time in the filename — the UTC timestamp of the last push to the PR that introduced the fragment, e.g.:
The daily collector folds a fragment only when its filename timestamp is older than a cooling-off window (≈4h). The window's purpose is to let a just-landed change settle (a revert or follow-up fix) before it becomes a permanent changelog line.
Semantics to ratify (the "last push" wrinkle)
Using last push (not first-authored) time means a branch that is force-pushed resets its fragment's clock — the change is treated as "not settled until pushes stop." That is arguably the intended behaviour (an actively-churning PR should not fold yet), but it should be a deliberate choice:
Whichever is chosen, the timestamp must be written by the author/PR tooling into the filename, not inferred at collection time, so the collector needs no git archaeology.
Release interaction (also to ratify)
When a release is cut while fragments are still inside the cooling-off window:
Fragment format (to ratify)
Section (
### Added/### Fixed/### Changed/### Performance) needs to be declarable per fragment. Options, in rough order of preference:20260910T175500Z-fixed-1988-*.md— visible inls, trivial to group, no in-file boilerplate.section:header line.### Fixedand re-sorted at collection.Open questions for @nic
make add-changelogthat writesnew_changelogs/$(date -u +%Y%m%dT%H%M%SZ)-<n>-<slug>.md? (The daily/release jobs carry the[skip actions]token in their own commits only — never in a PR title — per the release-pipeline rule.)Non-goals
Acceptance
new_changelogs/fragment and noCHANGELOG.mdedit passes the (reworked) changelog gate.CHANGELOG.md.CHANGELOG.mdto fold).git rms them, and commits with[skip actions].Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com