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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A generator you own | Your grouping rules differ from every preset, and the format is 60 lines | Code to maintain, and a format nobody else already recognizes | A preset already produces what you want |
| conventional-changelog-cli | A Node project that wants the preset ecosystem and Angular conventions | A Node toolchain in the release path, which a Go or Python repository may not want | The repository has no Node runtime of its own |
| git-cliff | Any language, because it is one binary driven by a template file | A template syntax to learn, and a config file that grows commit parsers | You want the output shaped by code rather than by a template |
| release-please | You also want the version bump, the tag, and the release pull request | The file becomes owned by a bot, so a hand edit is overwritten next release | You 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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The Angular guideline lists eight types:
build,ci,docs,feat,fix,perf,refactorandtest.chore, the type this page filters, is not among them, and neither isstyle. 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 -
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 forBREAKING CHANGE, which may keep its space, and a further clause makesBREAKING-CHANGEits 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 -
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