How-to › Release and secure packages

How to generate a changelog from conventional commits#

Turn a commit range into CHANGELOG.md sections grouped by release, with dependency bumps and regeneration noise filtered out rather than published.

Audience
Library maintainer
Level
beginner
Topic
Version, changelog and automate releases
Verified

The release goes out and the notes say the usual thing about fixes and improvements. A customer asks which release fixed the token refresh bug, and answering means reading 90 commits. Half of them are dependency bumps and regenerated output, so the useful ones are buried in a list nobody will read twice.

What you get

You will end up with a script that turns a commit range into release sections a reader can scan, with breaking changes at the top and machine noise removed. This is for you if you publish a library and your release notes are written by hand or not at all.

Short answer

Group the commits in the range by their conventional type, put breaking changes first, and drop the ones a reader gains nothing from. A feat is a feature, a fix is a fix, and a ! after the scope moves an entry out of Features and into Breaking changes. Filter chore(deps) and regeneration commits, or the release notes read as a dependency report.

You will need

A repository whose commits follow the Conventional Commits format, and Node 22 or later. The sample reads a fixture standing in for git log --format=%B, so the parsing is testable without a repository attached. Read the whole message rather than the subject alone: a break declared in a footer lives in the body, and %s never shows it to you. The types and the ! marker come from the specification, and the Angular convention is where most of the presets got their type list.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A generator you ownYour grouping rules differ from every preset, and the format is 60 linesCode to maintain, and a format nobody else already recognizesA preset already produces what you want
conventional-changelog-cliA Node project that wants the preset ecosystem and Angular conventionsA Node toolchain in the release path, which a Go or Python repository may not wantThe repository has no Node runtime of its own
git-cliffAny language, because it is one binary driven by a template fileA template syntax to learn, and a config file that grows commit parsersYou want the output shaped by code rather than by a template
release-pleaseYou also want the version bump, the tag, and the release pull requestThe file becomes owned by a bot, so a hand edit is overwritten next releaseYou publish releases by hand and only want the notes

The split is between a generator you run and a bot that owns the file. Running a generator keeps the changelog under your control, and it lets the file drift from the version if somebody forgets to run it. A release bot couples the notes to the bump so the two cannot disagree, at the price of a file you no longer edit. Both read the same commits, so the decision costs nothing to reverse.

Group by type, and put breaking first

Three sections carry almost all the value, and their order matters more than their contents.

const SECTIONS = [
  ['breaking', 'Breaking changes'],
  ['feat', 'Features'],
  ['fix', 'Bug fixes'],
]

A breaking change is marked by ! before the colon, or by a BREAKING CHANGE footer. Both have to be read.2 The footer arrives on its own line in the body, so it is attached to the commit preceding it, and a parser fed only subject lines never sees it at all. Reading only the type would file the change under Features, and that is where an upgrade goes wrong. The reader skims a Features list, sees nothing alarming, and learns at runtime that a flag was renamed.

docs, refactor, test, and style are left out of the rendered file. They belong in the history, and a reader deciding whether to upgrade gains nothing from them.

Drop the noise before it reaches the file

Two patterns account for most of the volume in a generated repository.

const NOISE = [/^chore\(deps\)/, /^chore\(release\)/]

Dependency bumps and regeneration commits are real history, and they are not news.3 Filtering them in the generator is better than leaving them out of the commit messages, because the history stays accurate and only the published view is curated. Keep the filter short and visible. A filter that grows a rule at a time, with nobody reading it, ends up hiding things a reader needed.

Record which generator produced the file, and which version of it, in a line at the top. A changelog with no provenance cannot be regenerated by the next maintainer with any confidence that they will get the same output.

Scopes earn their place here. A reader scanning for the thing they use reads the bold scope and skips the rest, so a repository with real scopes produces a file that can be read in ten seconds. A repository where every commit is scoped to the package name produces a list of sentences.

Check it worked

Generate the two most recent releases and read what comes out.

node demo.mjs
# Changelog

## v1.4.0

### Breaking changes

- **cli:** rename --out to --output

### Features

- **meters:** add a cursor to the list endpoint

### Bug fixes

- **auth:** stop refreshing a token twice under load
- **meters:** keep the serial number when a meter moves site

## v1.3.0

### Breaking changes

- **auth:** accept a device code grant

### Bug fixes

- **cli:** exit non-zero when the spec fails to parse

Twelve commits went in and seven entries came out. The renamed flag is at the top of its release rather than buried among the features, and the two dependency bumps and the regeneration commit are gone. The device code grant is the other one to check: it is a feat, and it sits under Breaking changes because its body carries the footer. Compare that against release notes you wrote by hand for the same range: anything the generator missed is a commit message that needs fixing, not a rule that needs adding.

node --test changelog.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 111.215652

When it goes wrong

Half the commits are missing from the output. Their subjects do not match the header grammar, usually because of a capital letter in the type or a missing space after the colon. Add a commit message check to the pull request rather than a looser parser.

A breaking change appears under Features. The ! sits after the closing parenthesis and before the colon, and a message with it in the wrong place parses as an ordinary feature. Test that one case.

The file rewrites itself entirely on every run. The generator is rewriting history rather than adding to the front of the file. Generate only the range since the last tag, and leave the older entries alone.

When not to do this

Do not generate a changelog from commits nobody writes carefully. The output is exactly as good as the subjects, and a repository full of “fix stuff” produces a file that is worse than none, because it looks maintained. Fix the messages first, with a check on the pull request that refuses a subject the parser cannot read.

Do not publish every type. A reader deciding whether to upgrade wants what changed for them, and a list including every refactor buries it. Keep the rendered set small, and keep the full history one command away for anyone who wants it.

Do not hand edit a file a release bot owns. The next release overwrites it, and the explanation you added for a tricky migration disappears without a trace. Put that in the release notes, or take the file back and generate it yourself.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The Angular guideline lists eight types: build, ci, docs, feat, fix, perf, refactor and test. chore, the type this page filters, is not among them, and neither is style. The specification takes both from @commitlint/config-conventional, which it describes as based on the Angular convention. The type this page spends a filter on therefore entered the convention by way of a lint configuration. The same Angular page asks for the scope to be the name of the npm package affected, which in a repository of one package produces the list of sentences. ↩︎ Back to text

  2. The footer is the one place the specification bends its own rule. The token of a footer must use a hyphen in place of whitespace, as in Acked-by, so that a footer can be told apart from a paragraph of body. An exception is then made for BREAKING CHANGE, which may keep its space, and a further clause makes BREAKING-CHANGE its synonym. The one token allowed to break the rule therefore also has a spelling that obeys it. The specification correlates either form with a major version in Semantic Versioning, which is the arithmetic a reader is doing when they scan the top section first. ↩︎ Back to text

  3. Keep a Changelog puts first among its guiding principles that changelogs are for humans, not machines. It names commit log diffs as a bad idea because they are full of noise: merge commits, commits with obscure titles, documentation changes. A generator that reads commits and publishes them is the thing that page warns against. The filter, the dropped types and the reordered sections are the difference between the two, and they are the whole of the difference. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.