All writing
Updated 7 min readNaren

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

  • Added for new features.
  • Changed for changes to behaviour that already existed.
  • Deprecated for something that still works today, announced now for removal in a named later release.
  • Removed for something that used to exist and now does not.
  • Fixed for bug fixes.
  • Security for 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/412

The 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.

A merged change routes to one of the six changelog headings; breaking changes bump MAJOR only once the project is past version 1.0.0. yes yes no no vulnerability a bug no adds alters warns removes Breaks existing usage? Still pre-1.0? Breaking, MINOR Breaking, MAJOR Fixes something? Security, PATCH Fixed, PATCH What does it do? Added, MINOR Changed Deprecated Removed

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.md

git 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.

.github/workflows/release-please.yml
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: simple

Two 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.