Skip to content

Commit subjects and release notes ​

Release notes are not written by hand. tools/changelog reads the commit subjects between two release tags and renders them twice: as the body of the GitHub Release, and as a version section at the top of CHANGELOG.md. A commit subject is therefore text an operator reads, and the rules below decide whether it reaches them.

Which subjects reach the notes ​

A subject has to be a Conventional Commit subject: a lowercase type, an optional scope in one pair of parentheses, an optional !, a colon, a space and a description. The type picks the section:

TypeSection
featAdded
refactor, perfChanged
fixFixed
docsDocumentation

Sections appear in that order, and the entries of a section in commit order, oldest first. Every other type (chore, test, ci, build, plan, style and the rest) is dropped, and so is every subject that does not parse as a Conventional Commit: free-form subjects such as Fix failing CI checks, subjects that name several types such as test,web: …, merge commits, and Dependabot's double scope chore(deps)(deps): Bump …. A range in which nothing is kept renders as No user-facing changes.

How an entry renders ​

The description is kept verbatim. A scope becomes a prefix, and the breaking marker becomes a suffix:

SubjectEntry
feat(api): add widget listing- api: add widget listing
fix: handle empty page token- handle empty page token
refactor(store)!: rename widget table- store: rename widget table (breaking)

Only the subject counts. A BREAKING CHANGE footer in the commit body is not read, so mark a breaking change with ! in the subject.

Write every subject for a reader outside the project:

  • A subject is rendered text. It must not carry internal traceability identifiers or (#N) issue and pull request references, because a reader of the release notes can open neither.
  • A change a user notices, including the deprecation of a /v1 operation (see API versioning and compatibility), needs a type the notes keep, usually feat or fix.

The first release ​

The range of a version starts at the newest release tag below it. A release tag is v followed by a SemVer core, such as v0.2.0, so a pre-release tag like v1.0.0-rc.1 never starts a range. The first release has no earlier tag: its notes list no commits and point at the "Before the first release" section of the changelog instead. That section keeps the hand-curated history of everything shipped before the first tag and receives no new entries.

Render the notes ​

make release-notes prints the Release body:

shell
make release-notes                      # previous release tag..HEAD
make release-notes TO=v0.2.0            # previous release tag of v0.2.0..v0.2.0
make release-notes FROM=v0.1.0 TO=v0.2.0

make changelog inserts the section for a version above the newest section of CHANGELOG.md:

shell
make changelog RELEASE_VERSION=v0.2.0                              # dated today, in UTC
make changelog RELEASE_VERSION=v0.2.0 RELEASE_DATE=2026-01-02 TO=v0.2.0

It refuses a changelog that already has a section for the version, and one that still carries an ## [Unreleased] heading. CHANGELOG_REPO points both targets at another repository and CHANGELOG_FILE at another changelog; the defaults are this checkout and its CHANGELOG.md. Do not edit a generated section by hand.

make release renders the Release body with the same generator, and the releases runbook walks through cutting a release, from the changelog pull request to the tag.