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"
One changelog file against fragmentsWith one changelog file, every pull request edits the same Unreleased section, so concurrent pull requests conflict. With fragments, each pull request adds its own uniquely named file, and the changelog is assembled from them at release time.Edit CHANGELOG.mdAdd changes/<pr>.<type>.mdfiles touched per PRthe same onea new oneconcurrent PRsconflictnever conflictwordinghumanhumanrelease stepnoneassemble + deletethe release step is the price, and it is a script

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
The life of a changelog fragmentA pull request adds a fragment named after itself and its change type. CI checks that one exists and is well named, unless the pull request is labelled no-changelog. At release, a script groups all fragments by type into the changelog and deletes them in the same commit.PR adds filechanges/839.added.mdCI checkpresent, named rightMergedno conflictsReleaseassemble by typeDelete fragmentssame commitfragments live only between merge and release

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
Rebases caused by changelog conflictsAn illustrative team merging about sixty pull requests a month. Before fragments, around a third of pull requests needed a rebase purely to resolve a changelog conflict. After switching, none did.PRs rebased only for a changelog conflict, per month (illustrative)before, month 121before, month 218after, month 10after, month 20the merge queue stops stalling on CHANGELOG.md as well

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.