Founders

Release notes people read

Release notes are read by someone deciding whether to update, whether something will break, or whether the thing they asked for has arrived. Most fail that reader because they are copied from the commit log, which was written for the person making the change. This article takes a raw log, turns it into notes, and gives the structure, wording rules and a small checker that keep the next ones honest.

The product in the examples is a fictional shared list app. Its versions, dates and links are invented.

Start with the reader's three questions

A reader opening release notes wants to know what changed that affects them, whether they have to do anything, and whether it is safe to update. Everything in an entry should serve one of those. Anything that serves only the developer belongs in the commit history.

Apple's documentation for the App Store product page describes the same job from the store's side. Its "What's New" text appears on the product page and on the Updates tab, it suggests listing new features, content or functionality in order of importance, and it advises telling users when a change came from their feedback so they know you listened. None of that depends on the platform: lead with what matters most, and say when you are responding to a request.

From commit log to notes

Take this fictional log:

Commit messageDecisionWhy
fix: apostrophe bug (#41)Keep, rewrite under FixedUsers lost items; say so in their words
feat: share linksKeep under AddedA new capability
feat(ui): ticked items to bottomKeep under ChangedVisible behaviour changed
refactor: split store moduleDropNo user can see it
chore: bump dependenciesDrop, unless it fixes a vulnerabilityA security fix goes under Security
docs: typoDropNot part of the product
merge branch 'share'DropProcess noise
wipDropNot a change

Keep a Changelog's position is that using commit log diffs as changelogs is a bad idea, and its reason is that the purpose of a commit is to document a step in the evolution of the code, while the purpose of a changelog entry is to document the noteworthy difference, often across multiple commits, so users can follow it. Three of the eight rows survive, and all three need rewriting into words a user would use.

The structure that already exists

Keep a Changelog 1.1.0 sets out principles that cover most of what a reader needs:

  • Write for humans, not machines.
  • One entry for every version, newest first.
  • Group the same types of change together.
  • Make versions and sections linkable.
  • Show each version's release date.
  • Say whether you follow Semantic Versioning.

Its six types of change are Added, Changed, Deprecated, Removed, Fixed and Security. It also recommends an Unreleased section at the top, which collects upcoming changes as you work and gives you something to move into a new version at release time. A broken release is not hidden: yanked versions get a loud "[YANKED]" tag in the heading, because they should appear in the changelog.

Here is the fictional log as a changelog:

# Changelog

All notable changes to Pantry List (a fictional product) are recorded here.
The format follows Keep a Changelog, and versions follow Semantic Versioning.

## [Unreleased]

### Added

- Dark theme, switched on from Settings.

## [1.2.0] - 2026-10-16

### Added

- Share a list with a link. Anyone with the link can edit it.

### Changed

- Ticked items now move to the bottom of the list.

### Deprecated

- Addresses of the form /?list=ID stop working in 2.0.0. Use the share link instead.

## [1.1.1] - 2026-10-09

### Fixed

- Items containing an apostrophe no longer disappear after a reload.

## [1.1.0] - 2026-10-02

### Added

- Press N to add a new item.

[Unreleased]: https://example.com/pantry/compare/v1.2.0...HEAD
[1.2.0]: https://example.com/pantry/compare/v1.1.1...v1.2.0
[1.1.1]: https://example.com/pantry/compare/v1.1.0...v1.1.1
[1.1.0]: https://example.com/pantry/releases/tag/v1.1.0

The Deprecated entry shows the rule the guide cares about most. When people upgrade, it should be clear when something will break, and a notice one version ahead lets a user remove what is deprecated before the version where it becomes a removal.

What a version number promises

Semantic Versioning 2.0.0 makes a version number carry a promise about a declared public API. In MAJOR.MINOR.PATCH form, you raise the major version for incompatible changes, the minor version for backward-compatible functionality and the patch version for backward-compatible bug fixes. Three details matter to a writer of notes:

  • Deprecating something in the public API requires a minor increase, which is why the example's 1.2.0 carries the deprecation.
  • Version 0.y.z is for initial development, where anything may change at any time.
  • Once a version is released, its contents must not be modified. A correction ships as a new version.

The comparison rules also catch people out. Versions compare numerically, field by field, so 1.9.0 comes before 1.10.0, and a pre-release has lower precedence than its release. Sorting version strings as text gets this wrong, which is easy to confirm. Sorting with the semver package for Node gives the order below, while a plain text sort puts 1.10.0 and 1.11.0 before 1.9.0.

1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
  < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0

That order matches the example in the specification. For a site or an app with no public API, the honest options are a dated list with no version numbers, or a version number you say is only a label. What does not work is pretending a number carries guarantees that nobody has defined.

Writing the entries

  • Lead with what the user can now do. "Share a list with a link" beats "Implemented share endpoint".
  • Name the place in the product. "From Settings" tells the reader where to look.
  • One change per bullet. A bullet that joins three changes hides the one that breaks something.
  • Say when action is needed. If the reader must do anything, put it first, in the same sentence as the change.
  • Keep fixes concrete. "Items containing an apostrophe no longer disappear after a reload" lets the affected person recognise their problem. "Fixed a bug" does not.
  • Be complete about what matters. Keep a Changelog warns that a changelog mentioning only some changes can mislead readers into treating it as the single source of truth, which it ought to be. You may omit trivia, but not a behaviour change.
  • Describe, do not promote. "Ticked items now move to the bottom of the list" states a behaviour the reader can verify. "A smarter, smoother list" states a mood that nobody can check, and it ages badly the moment the next release changes the behaviour again.
  • Match the product's vocabulary. If the screen says "Share", the note says "Share", not "link generation". A reader searching the notes for the word on their screen should find it.
  • Use one date format. The guide recommends the year-month-day form because it follows the order of largest to smallest unit and does not overlap ambiguously with regional formats. RFC 3339 defines the same hyphenated full date.

Before and after

Vague entries are the commonest failure, and the fix is rarely more words. It is a more specific fact.

DraftWhat is wrongRewrite
Various bug fixesHides every fix, including the one the reader needsList each fix a user could have met, one bullet each
Improved performanceNo place, no size"Long lists open faster." Add a figure only if you measured it
Refactored the sync layerOnly the author can see itDrop it, or describe the effect: "Edits made offline now sync when the connection returns"
Removed the old address formatReads like an announcement after the damageMove it to Deprecated one version earlier, with the removal version

Write the bullet when you make the change, in the Unreleased section, because that is when you know why you made it. Keep a Changelog recommends the Unreleased section for the same reason: it spreads the effort across the work and means release day is a rename and a date.

Order each version's sections by what a reader must act on first. The guide's list is a sensible default, but if a release contains a removal or a breaking change, place that section at the top of the version and say so in its first line.

When a release goes wrong

Two rules cover most bad releases. The specification says a released version's contents must not be modified, so the correction is a new version, usually a patch. And Keep a Changelog says a version that had to be pulled for a serious bug or a security issue should still appear in the changelog, marked [YANKED] in its heading, so nobody wonders where it went.

The changelog itself can be edited too. The guide's answer to whether you should ever rewrite one is yes: there are good reasons to improve it, and if you find you forgot to mention a breaking change, fixing the notes for that version is obviously important. Do that openly. Add the missing entry, and if people may have acted on the old text, say what changed in the next release so the correction is findable.

Check it with a script

Structure rots when nobody checks it. The script below reads a changelog and reports headings that are not versions, invalid dates, section names outside the six types, empty sections, versions out of order or dated backwards, and versions missing a link reference at the bottom. It is standalone Node and handles only simple version numbers without pre-release labels.

import { readFileSync } from 'node:fs';

const lines = readFileSync(process.argv[2], 'utf8').split('\n');
const TYPES = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
const problems = [];
const validDate = (s) => !Number.isNaN(Date.parse(s)) && new Date(s).toISOString().slice(0, 10) === s;
const releases = [];
let section = null;
let current = null;

lines.forEach((line, i) => {
  const at = 'line ' + (i + 1) + ': ';
  if (line.startsWith('## [')) {
    const m = line.match(/^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?/);
    if (!m) { problems.push(at + 'unreadable release heading'); return; }
    current = { name: m[1], date: m[2], line: i + 1 };
    releases.push(current);
    section = null;
    if (m[1] === 'Unreleased') {
      if (releases.length > 1) problems.push(at + 'Unreleased must come first');
      return;
    }
    const core = m[1].match(/^(\d+)\.(\d+)\.(\d+)/);
    if (!core) problems.push(at + m[1] + ' is not a MAJOR.MINOR.PATCH version');
    else current.core = core.slice(1, 4).map(Number);
    if (!m[2] || !validDate(m[2])) problems.push(at + 'missing or invalid ISO date');
  } else if (line.startsWith('### ')) {
    section = { name: line.slice(4).trim(), bullets: 0 };
    if (!TYPES.includes(section.name)) problems.push(at + '"' + section.name + '" is not one of ' + TYPES.join(', '));
    (current.sections ||= []).push(section);
  } else if (line.startsWith('- ') && section) {
    section.bullets++;
  }
});

for (const r of releases) for (const s of r.sections || []) {
  if (!s.bullets) problems.push('line ' + r.line + ': ' + r.name + ' has an empty ' + s.name + ' section');
}
const cmp = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
const real = releases.filter((r) => r.core);
for (let i = 1; i < real.length; i++) {
  if (cmp(real[i - 1].core, real[i].core) <= 0) problems.push(real[i].name + ' is listed after ' + real[i - 1].name + ': newest must come first');
  if (real[i].date && real[i - 1].date && real[i].date > real[i - 1].date) problems.push(real[i].name + ' is dated later than the release listed above it');
}
const defined = new Set(lines.filter((l) => l.startsWith('[') && l.includes(']: ')).map((l) => l.slice(1, l.indexOf(']'))));
for (const r of releases) if (!defined.has(r.name)) problems.push(r.name + ' has no link reference at the bottom');

console.log(releases.length + ' release headings checked');
console.log(problems.length ? problems.join('\n') : 'ok');

On the example changelog above it printed:

4 release headings checked
ok

On a deliberately broken copy it printed:

4 release headings checked
line 11: missing or invalid ISO date
line 13: "Misc" is not one of Added, Changed, Deprecated, Removed, Fixed, Security
line 17: 1.1.0 has an empty Added section
1.2.0 is listed after 1.1.1: newest must come first
1.2.0 is dated later than the release listed above it
1.2.0 has no link reference at the bottom
1.1.0 has no link reference at the bottom

One check, the date-order comparison, fires on the invalid date too, which shows how one mistake can raise two reports. Fix the first and rerun.

Where to publish them

Pick one source of truth and point everything else at it. Keep a Changelog suggests a file named CHANGELOG.md and argues that GitHub's release pages are less portable and less discoverable than a file in the repository. For a product with no repository visible to users, the same text can sit on a changelog page on the site, and a short version can feed the store listing or an email. A short version is a selection from the full record, written the same way, and never a different account of what changed.

If the weekly routine in shipping every week as a team of one is yours, the Unreleased section is where Thursday's draft notes live, so Friday's release is a rename and a date.

Checklist

  • Write each entry for the reader's three questions: what changed, what to do, is it safe.
  • Rewrite commits into user language and drop everything a user cannot see.
  • Group by Added, Changed, Deprecated, Removed, Fixed and Security, newest version first, with ISO dates.
  • Say whether you follow Semantic Versioning; if there is no public API, use dates.
  • Run a checker on the file before every release.

Questions and answers

Should release notes list every commit?

No. Keep a Changelog says a commit log makes a poor changelog because it is full of noise such as merge commits, obscure titles and documentation changes. A changelog entry documents the noteworthy difference, often across several commits, in terms users can follow.

Do I need version numbers on a website?

Only if they mean something to someone. Semantic Versioning requires a declared public API, and a typical website does not have one. A dated list of changes works, and Keep a Changelog simply asks you to say whether you follow Semantic Versioning.

What date format should I use?

Year, month, day with hyphens, such as 2026-10-16. Keep a Changelog recommends it because it orders units from largest to smallest and does not clash with regional day-month orders. RFC 3339 defines the same full-date form as an Internet profile of ISO 8601.

Where do deprecations go?

In their own Deprecated section, with the version where removal will happen if you know it. Keep a Changelog says that if you do nothing else you should list deprecations, removals and breaking changes, because upgrading should make it painfully clear when something will break.

Sources

  1. Keep a Changelog 1.1.0 keepachangelog.com
  2. Semantic Versioning 2.0.0 semver.org
  3. Creating your product page (Apple App Store) developer.apple.com
  4. RFC 3339: Date and Time on the Internet: Timestamps rfc-editor.org