Keeping a changelog people actually read
The six Keep a Changelog categories, why the Unreleased section is the part that makes it work, and what to automate.
The part of a changelog that decides whether anyone trusts it is the empty section at the top.
[Unreleased] is where an entry goes the day a pull request merges, months before anyone knows
which version it will ship under.
Leave it out and the file gets written on release day, from git log, by whoever happens to be
cutting the release.
Written that way it summarises the commits instead of doing the one thing a changelog is for: letting a reader see what changed without reading the diff.
Everything below exists to keep that section cheap to fill in.
You are building one file, CHANGELOG.md, at the top level of the repository: a hand-written
list of the notable changes in each release, newest first, written for the people who use the
software rather than the people who wrote it.
A commit records one step in the source; an entry records one notable difference for someone downstream, and often spans several commits.
Keep a Changelog is a community convention with a version number of its own, not a standards-body standard.
Version 2.0.0 shipped on 7 June 2026, the first major revision of a document that had sat at 1.1.0 since 2019.
The format itself is untouched, so existing changelogs stay valid: same six change types, same
YYYY-MM-DD dates, same Unreleased and [YANKED] markers.
Create the file before you need it
cd /path/to/your/repo
cat > CHANGELOG.md <<'EOF'
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
EOF
git add CHANGELOG.md
git commit -m "docs: add CHANGELOG.md following Keep a Changelog 2.0.0"That is the whole of day one. An [Unreleased] heading with nothing under it is already a
working changelog, because from here adding an entry is part of the pull request that caused it.
Both preamble links are versioned, deliberately. https://keepachangelog.com/ is still a
landing page whose one link goes to /en/1.1.0/. The root was never repointed at 2.0.0, so
linking it stamps a 2019 spec version into a 2026 project.
The Semantic Versioning link is versioned for a different reason: https://semver.org/ is live
and correct, but https://semver.org/spec/v2.0.0.html is the URL Keep a Changelog's own
template uses, and it is what makes "adheres to Semantic Versioning" a checkable statement
rather than a gesture.
The six types, and one worked file
Addedfor new features.Changedfor changes to behaviour that already existed.Deprecatedfor something that still works today, announced now for removal in a named later release.Removedfor something that used to exist and now does not.Fixedfor bug fixes.Securityfor vulnerability patches.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.0] - 2026-08-15
### Added
- Multi-factor authentication using WebAuthn/FIDO2 security keys and passkeys.
- A webhook endpoint that fires when a transaction changes state.
### Changed
- The default database connection pool size is now 25, up from 10.
- **Breaking:** `--output` now requires a file path. Passing it with no value,
which previously wrote to stdout, is an error. Use `--output -` for stdout.
### Deprecated
- `/api/v1/auth`. Use `/api/v2/tokens`, which takes the same credentials and
returns a token object instead of a bare string. `/api/v1/auth` will be removed
in 2.0.0.
### Removed
- The `--legacy-tls` flag, deprecated in 1.0.0.
### Fixed
- A memory leak in the background worker queue: buffers were held after the queue
drained, so long-running workers grew without bound ([#412]).
### Security
- CVE-YYYY-NNNNN: upgraded PACKAGE to VERSION, which fixes a denial of service
triggered by deeply nested input.
[Unreleased]: https://github.com/OWNER/REPO/compare/v1.2.0...HEAD
[1.2.0]: https://github.com/OWNER/REPO/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/OWNER/REPO/releases/tag/v1.1.0
[#412]: https://github.com/OWNER/REPO/issues/412The block at the bottom is the link-definition footer, the part copied examples drop most often.
[1.2.0] in a heading is markdown link syntax; with no matching [1.2.0]: https://… line it
renders as literal brackets and goes nowhere. Those targets are mostly compare URLs, which show
the diff between two git tags.
The oldest release points at its own tag instead, and an issue reference points at the issue.
A tag is a permanent name attached to one commit, usually the version number, marking the code a release was built from.
That footer is also why [#412] beats a bare (#412). 2.0.0 asks you to link when it helps,
prefer plain prose to a trailing list of bare references, and use portable references that still
resolve later.
There is no Breaking heading. A breaking change stops code from working after an upgrade,
though the person upgrading changed nothing themselves. It goes under the type it actually is,
marked in bold at the front of the entry.
The Security entry leads with the CVE identifier. CVE stands for Common Vulnerabilities and
Exposures. It is a unique public identifier for one specific flaw, so everyone discussing that
flaw means the same one.
Each identifier points at a security advisory saying what is broken, which versions are affected and which version fixes it. Leading with the identifier lets a human and a scanner both match the entry to that advisory.
After the upgrade, a re-scan shows which of the advisories the scanner knows about no longer match (scanning images and filesystems).
CVE-YYYY-NNNNN, PACKAGE, VERSION, OWNER and REPO are all placeholders. Never paste a
real CVE into an example to make it look convincing: the string is indexed and tooling matches on
it.
Write entries from the reader's side. The first Added line names WebAuthn and FIDO2.
WebAuthn is the browser standard for signing in with a security key, fingerprint or passkey instead of a password; FIDO2 is the protocol that talks to the physical key.
Naming both lets a reader tell in one line whether they care. The Deprecated entry gives both
the replacement and the release that removes the old thing.
Newest first, and dates that sort
Chronologically ordered is the phrase everyone repeats, and it is backwards. Keep a Changelog's
guiding principle is that the latest version comes first: [Unreleased] at the top, then the
newest release, then down through history.
Append to the bottom instead and readers landing on your file see 2019.
## [1.2.0] - 2026-08-15
## [0.0.5] - 2014-12-13 [YANKED]A release is a version you have published and labelled so other people can install exactly that
one. [YANKED] marks one you published and then pulled. It stays in the file, marked, so
readers know not to use it.
Dates are ISO 8601, YYYY-MM-DD: largest unit first, sorts as plain text, never read as
day-month by one person and month-day by another.
On release day you insert a ## [X.Y.Z] - YYYY-MM-DD heading under [Unreleased], move the
accumulated entries into it, add its compare link to the footer, and leave [Unreleased] empty
again.
Which heading, and which number
Semantic Versioning, or SemVer, reads MAJOR.MINOR.PATCH: MAJOR when you break something, MINOR
when you add something backward-compatible, PATCH when you only fix bugs. You are not obliged to
use it.
Keep a Changelog 2.0.0 says calendar versioning, a plain number or a date all work, provided you
note which scheme you use. The failure mode is an undeclared scheme, not a non-SemVer one;
2026.09.1 is fine.
If you do use SemVer, one rule catches people out and most changelog advice states it wrong. "Bump MAJOR on every breaking change" is true above 1.0.0 and false below it.
Rules 6, 7 and 8 of SemVer 2.0.0 are each conditioned on the major version being above zero, and
rule 4 says that in 0.y.z anything may change at any time.
A breaking change on 0.4.2 ships as 0.5.0. Releasing 1.0.0 is rule 5's job: it defines
your public API: the functions, endpoints, flags and file formats other people's code depends
on. That is a promise.
Every entry lands in [Unreleased] either way; what this decides is the heading it sits under
and the next version number.
The diagram below routes a merged change to one of the six headings, and its second decision is the pre-1.0.0 fork.
Automate the draft, not the changelog
Conventional Commits is a convention for writing commit messages as type(scope): description,
so that a tool can tell a feature from a bug fix by reading the first word.
# MINOR bump: a new feature
git commit -m "feat(auth): add WebAuthn second factor"
# PATCH bump: a bug fix
git commit -m "fix(worker): release buffers when the queue drains"
# MAJOR bump: the "!" marks a breaking change
git commit -m "feat(cli)!: require a file path for --output"When one line is not enough, the breaking change moves to a footer:
feat(cli): change how --output is parsed
BREAKING CHANGE: --output now requires a file path. Use "--output -" for stdout.What the specification actually mandates
Conventional Commits 1.0.0 mandates exactly two types: feat for a new feature and fix for a
bug fix. Everything else is optional.
docs, chore, perf, refactor, style, test, build and ci come from
@commitlint/config-conventional, a rule set for commitlint, the linter that checks commit
messages; commitlint took them from the commit convention the Angular project uses.
Almost every post prints that list as the standard, which is how a reviewer ends up rejecting a teammate's valid commit.
The mapping the spec does define: fix is PATCH, feat is MINOR, and a ! after the type or
scope, or a BREAKING CHANGE: footer, is MAJOR regardless of type.
To enforce the format rather than suggest it, the same local hook machinery that stops secrets before they land can reject a message that does not parse.
Generating the draft with git-cliff
git-chglog is archived
The git-chglog repository is archived and read-only, its own description now points readers
at git-cliff, and its last release was v0.15.4 in February 2023. The generator most changelog
posts still recommend is telling you not to install it.
git-cliff is the successor the project itself names, and it reads Conventional Commits. The current release is v2.14.1.
# macOS or Linux, via Homebrew
brew install git-cliff
# or, with no install at all
npx git-cliff@latest --version
# write a default cliff.toml into the repo
git cliff --init
# generate entries for commits since the last tag, as version 1.3.0,
# and prepend them to the existing file
git cliff --unreleased --tag 1.3.0 --prepend CHANGELOG.mdgit cliff works as a git subcommand as soon as the binary is on PATH.
Or let release-please open the pull request
release-please works the other way round: instead of writing the file it opens a release PR, a pull request holding the proposed version bump and the changelog entries, kept current as more commits land. Merging it cuts the release.
on:
push:
branches:
- main
permissions:
contents: write
issues: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
release-type: simpleTwo repositories share that name. The GitHub Action is googleapis/release-please-action,
currently v5.0.0; the CLI and library are googleapis/release-please, currently v17.11.2, on
npm as release-please.
Pin @v5: the action's README quick-start still shows @v4. MY_RELEASE_PLEASE_TOKEN is a
personal access token you add as a repository secret; the default GITHUB_TOKEN is not
recommended. To try it locally first:
npm i release-please -g
release-please release-pr --token=$GITHUB_TOKEN --repo-url=OWNER/REPO --release-type=simple --dry-run--dry-run reports what it would do and changes nothing.
What none of it writes for you
Keep a Changelog 2.0.0 is blunt about what any of this buys you: "A generated changelog is raw material at best." It names semantic-release, release-please, Changesets and git-cliff as reasonable starting points, then says why none finishes the job: many commits do not matter to your readers, and the changes that do often span several commits.
Which comes back to the section at the top of the file. A generator can only summarise commits that already exist. It cannot know that the deprecation you shipped three weeks ago needs a migration note, or that two unrelated-looking fixes were one bug.
[Unreleased] can, because you wrote it down the week it happened.