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

Source: https://voxgig.com/howto/changelog-from-conventional-commits

- Audience: library-maintainer
- Level: beginner
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://www.conventionalcommits.org/en/v1.0.0/) 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](https://www.conventionalcommits.org/en/v1.0.0/#specification), and the
[Angular convention](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md)
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](https://www.conventionalcommits.org/en/v1.0.0/#specification) | 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](https://github.com/conventional-changelog/conventional-changelog) | 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](https://git-cliff.org/docs/) | 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](https://github.com/googleapis/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.

```js title="changelog.mjs"
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.

```js title="changelog.mjs"
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.

```bash
node demo.mjs
```

```text output
# 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.

```bash
node --test changelog.test.mjs
```

```text output
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

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

## Last verified

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

[^1]: The Angular
[guideline](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md)
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](https://www.conventionalcommits.org/en/v1.0.0/) 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.

[^2]: The footer is the one place the specification bends its own rule. The
[token of a footer](https://www.conventionalcommits.org/en/v1.0.0/#specification) 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.

[^3]: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 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.