Changelog fragments instead of one changelog Jump to heading
A hand-maintained CHANGELOG.md with an “Unreleased” section is the most reliably conflicting file in many repositories. Every pull request adds a bullet to the same few lines at the top. Any two open pull requests conflict there, merge queues stall on it, and authors rebase just to resolve a changelog line. The content is useful; the format is the problem. Changelog fragments fix it: each pull request adds a tiny file in a changes/ directory describing its change, and a release step assembles them into the changelog and deletes them. No two pull requests touch the same file, so the conflicts disappear. This page sets that up, within conflict prevention by design.
When to use this approach Jump to heading
- Your changelog is written by hand in pull requests, and it shows up constantly in conflicts.
- Changelog entries need human wording — generated-from-commits changelogs are not good enough for your users.
- Several pull requests are usually open at once against the same branch.
- If commit messages already carry everything users need, automating changelog generation with semantic-release may be simpler still.
Step 1 — Define the fragment format Jump to heading
One file per change, named so that names never collide, with the change type encoded in the name or the content. A common convention is <issue-or-pr>.<type>.md.
changes/
812.fixed.md Credit notes no longer apply the customer discount twice.
839.added.md CSV exports can be scheduled per account.
845.changed.md The export API now returns 202 for scheduled jobs. # Creating a fragment for the current pull request
pr=839
printf '%s\n' "CSV exports can be scheduled per account." > "changes/$pr.added.md"
git add "changes/$pr.added.md" Step 2 — Require a fragment in CI Jump to heading
The scheme only works if every relevant pull request includes a fragment. Check it in CI, with an explicit escape hatch for changes users do not need to hear about.
#!/bin/sh
# ci/check-changelog-fragment.sh — every PR adds a fragment, or is labelled to skip
set -eu
if gh pr view "$PR" --json labels --jq '.labels[].name' | grep -qx 'no-changelog'; then
echo "skipped by label"; exit 0
fi
added=$(git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- 'changes/*.md')
[ -n "$added" ] || { echo "Add a changelog fragment: changes/<pr>.<added|changed|fixed|removed>.md"; exit 1; }
for f in $added; do
echo "$f" | grep -Eq '^changes/[0-9]+\.(added|changed|deprecated|removed|fixed|security)\.md$' \
|| { echo "Bad fragment name: $f"; exit 1; }
done Step 3 — Assemble the changelog at release time Jump to heading
At release, a script groups fragments by type, inserts them under a new version heading at the top of CHANGELOG.md, and removes the fragments. Do it in one commit so the changelog and the deletion land together.
#!/bin/sh
# scripts/assemble-changelog.sh <version>
set -eu
v=$1; date=$(date +%F); out=$(mktemp)
{
printf '## [%s] - %s\n\n' "$v" "$date"
for type in added changed deprecated removed fixed security; do
set -- changes/*."$type".md
[ -e "$1" ] || continue
printf '### %s\n\n' "$(echo "$type" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')"
for f in "$@"; do
pr=$(basename "$f" | cut -d. -f1)
printf -- '- %s (#%s)\n' "$(tr '\n' ' ' < "$f" | sed 's/ *$//')" "$pr"
done
echo
done
} > "$out"
# insert after the title line of the existing changelog
{ head -2 CHANGELOG.md; cat "$out"; tail -n +3 CHANGELOG.md; } > CHANGELOG.new && mv CHANGELOG.new CHANGELOG.md
git rm -q changes/*.md
git add CHANGELOG.md
git commit -m "Changelog for $v" Tools such as towncrier (Python), changesets (JavaScript) and scriv implement this pattern with more features; the script above is enough for many projects.
# Verification: no fragments remain and the new section is present
ls changes/*.md 2>/dev/null | wc -l # 0
sed -n '1,15p' CHANGELOG.md Step 4 — Handle edits and reverts Jump to heading
Because each fragment is its own file, editing a change’s description is editing that file in a follow-up pull request. Reverting a pull request before release should delete its fragment too, or the changelog will describe something that is not shipping.
git revert -m 1 <merge-sha> # the revert also removes changes/839.added.md if it was added in that merge
git show --stat HEAD | grep changes/ # confirm the fragment went with it Step 5 — Keep the “Unreleased” view without the conflicts Jump to heading
People still want to see what is coming in the next release. Generate it on demand from the fragments, without writing it anywhere that conflicts.
for f in changes/*.md; do printf '%-10s %s\n' "$(basename "$f" | cut -d. -f2)" "$(head -1 "$f")"; done | sort Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
What if the fragment’s number is not known until the pull request is opened? Jump to heading
Create the fragment after opening the pull request, or name it by issue number or branch name instead. The only requirement is that two pull requests never pick the same name.
Can fragments be longer than one line? Jump to heading
Yes. The assembly script joins lines; for multi-paragraph entries, keep Markdown formatting in the fragment and indent it under the bullet.
Should the release commit go through review? Jump to heading
Yes — it is the moment to edit wording for users. Many teams open the assembled changelog as a release pull request and polish it there.
Related Jump to heading
- Conflict Prevention by Design — the parent topic.
- Splitting Shared Config into Fragments — the same pattern for configuration.
- Generating Release Notes from Commit Trailers — an alternative that needs no extra files.
- Versioning a Monorepo with Changesets — fragments combined with version bumps.