Generating release notes from commit trailers Jump to heading

There are two familiar ways to produce release notes. Generate them from commit subjects, and you get a list written for developers: β€œrefactor: extract rounding helper” is not something a customer needs. Write them by hand at release time, and someone spends an afternoon reconstructing what changed from pull request titles. Commit trailers offer a middle path. The author of each change writes one user-facing sentence in a Changelog: trailer β€” at the moment they understand the change best β€” and release tooling collects those lines between two tags with a single git log command. Commits with nothing user-facing simply omit the trailer. This page sets up the convention, the collection script and the checks that keep it reliable, within commit message hooks and templates.

When to use this approach Jump to heading

  • Release notes are written by hand from pull request titles, and it takes hours.
  • Generated changelogs from commit subjects are too technical for your users.
  • You want breaking changes and security fixes called out consistently.
  • Trailers are already part of your workflow, for example from adding trailers automatically with a commit-msg hook.

Step 1 β€” Define the trailers Jump to heading

Trailers are Key: value lines at the end of a commit message. Pick a small set with clear meaning.

Add per-account export scheduling

Exports can now run on a schedule configured per account. The scheduler
reuses the existing job queue, so no new infrastructure is needed.

Changelog: Schedule CSV exports per account from the export settings page.
Changelog-Type: added
Fixes: PAY-812
Remove the legacy v1 export endpoint

Breaking: The /v1/exports endpoint has been removed; use /v2/exports instead.
Changelog-Type: removed
A small set of release-note trailersChangelog holds the user-facing sentence. Changelog-Type groups it under added, changed, fixed or removed. Breaking marks an incompatible change with migration advice. Security marks a vulnerability fix. Commits without any of these do not appear in the notes.Changelog:user-facing lineChangelog-Type:added / fixed …Breaking:what to changeSecurity:advisory linkno trailer means 'not user-facing' β€” refactors and tests stay out of the notes

Step 2 β€” Collect trailers between two tags Jump to heading

git log can print specific trailers directly with the %(trailers) placeholder, so collecting them needs no parsing of whole messages.

prev=v2.3.0; next=v2.4.0
git log --no-merges --format='%(trailers:key=Changelog-Type,valueonly,separator=%x2C)%x09%(trailers:key=Changelog,valueonly,separator=%x20)' \
  "$prev..$next" | awk -F'\t' '$2 != ""'

With squash merging, each pull request is one commit, so this gives one line per user-visible change. With preserved commits, put the trailer on one commit per pull request, or on the merge commit, and adjust --no-merges accordingly.

# Verification: count user-facing changes in the range
git log --no-merges --format='%(trailers:key=Changelog,valueonly)' v2.3.0..v2.4.0 | grep -c .

Step 3 β€” Assemble the notes by type Jump to heading

Group the lines by type and put breaking changes and security fixes first, where readers look.

#!/bin/sh
# scripts/release-notes.sh <from-tag> <to-tag>
set -eu
range="$1..$2"
section() {   # section <title> <git log args...>
  title=$1; shift
  out=$(git log --no-merges "$@" "$range" | sed '/^$/d' | sed 's/^/- /')
  [ -n "$out" ] && printf '### %s\n\n%s\n\n' "$title" "$out"
}
printf '## %s\n\n' "$2"
section "Breaking changes" --format='%(trailers:key=Breaking,valueonly)'
section "Security"         --format='%(trailers:key=Security,valueonly)'
for t in added changed fixed removed; do
  title=$(printf '%s' "$t" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')
  section "$title" --format='%(trailers:key=Changelog,valueonly)' \
    --grep="^Changelog-Type: $t" --extended-regexp
done
From commits to release notesAuthors add trailers when committing. At release time, a script reads the range between the previous and new tags, extracts Breaking, Security and Changelog trailers with git log formatting, groups changelog lines by type, and writes a notes section that is reviewed before publishing.CommitChangelog: …Tag rangev2.3.0..v2.4.0Extract%(trailers:key=…)Groupbreaking, security, typeReviewthen publisha person still reviews the notes β€” but starts from complete, well-written lines

Step 4 β€” Make sure trailers get written Jump to heading

The convention works only if authors write the trailer when it applies. Two checks help: a template that reminds them, and a CI check that asks β€” not demands β€” when a pull request touches user-facing code without one.

# CI: warn when user-facing paths changed but no Changelog trailer exists
base=$(git merge-base "origin/$BASE_REF" HEAD)
if git diff --name-only "$base" HEAD | grep -qE '^(src/api|src/ui)/'; then
  git log --format='%(trailers:key=Changelog,valueonly)' "$base..HEAD" | grep -q . ||
    echo "::warning::User-facing code changed β€” add a 'Changelog:' trailer, or label the PR no-changelog"
fi

A warning is the right strength: a hard failure pushes people to write empty trailers. The template approach is in generating a commit message template per branch.

Step 5 β€” Keep trailers through squash merges Jump to heading

Squash merging builds a new message on the forge, which may drop trailers from the original commits. Put the trailers in the pull request description, which most forges use as the squash body, or check after merging that they survived.

# After merge: did the trailers reach main?
git log -1 --format='%(trailers)' origin/main

The squash message itself is covered in writing a good squash commit message.

Three ways to produce release notesGenerating notes from commit subjects is automatic but developer-facing. Writing them by hand at release time reads well but takes hours and misses changes. Collecting Changelog trailers is automatic at release time and reads well, because authors wrote each line for users when they made the change.Effort at releaseReads well for usersfrom subjectsnonerarelyby handhoursyesfrom trailersminutes of reviewyesthe work moves to the author, who knows the change best

Step 6 β€” Publish the notes with the release Jump to heading

The assembled notes are most useful attached to the release itself, where users look. Generate them in the release job, let a person review them in a draft release, and publish once approved.

sh scripts/release-notes.sh "$PREV_TAG" "$NEW_TAG" > notes.md
gh release create "$NEW_TAG" --draft --notes-file notes.md --title "$NEW_TAG"
# after review in the forge UI:
gh release edit "$NEW_TAG" --draft=false

Keeping the release as a draft until someone has read the notes is a small safeguard: generated lines are only as good as the trailers, and a reviewer catches the odd sentence written for developers rather than users. Store the final notes in the repository too β€” in the changelog file or a docs/releases/ directory β€” so they survive a change of forge.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

How is this different from Conventional Commits changelogs? Jump to heading

Conventional Commits use the subject’s type and text, written for developers. Trailers carry a separate, user-facing sentence. You can combine them: the type from the subject, the text from the trailer.

Can trailers be added after the fact? Jump to heading

Not to commits already on main without rewriting history. Use git notes for a missed line, or add it to the release notes by hand during review.

Does git interpret-trailers help? Jump to heading

Yes, for adding or normalising trailers in hooks and scripts: git interpret-trailers --trailer "Changelog: …" appends correctly even when other trailers exist.