git-cliff, a changelog from your git history and nothing else
I ran git-cliff 2.14.2 against a real repo and a few throwaway ones to see what it writes, what it quietly drops, and what it refuses to do. It writes a changelog. That’s the whole job, and it does it well. Tagging and publishing are yours.
Definition
What git-cliff is
git-cliff is a command line tool that reads your git history and writes a changelog, and it’s the one to reach for when you want the changelog without a release process attached. It’s written in Rust, and there’s an npm package that wraps the binary. It parses conventional commits by default, or your own regex parsers if your history looks different, groups the commits into sections, and renders them through a template you control in cliff.toml. It can work out the next version number from those commits. It won’t tag it, commit it, open a pull request or publish anything. That’s the difference from release-please or semantic-release, which do the whole release and write a changelog on the way. With git-cliff you get the file, and whatever happens next is a script you write.
Minimum working setup
Setup
npm install --save-dev git-cliff@2.14.2
npx git-cliff --init
npx git-cliff -o CHANGELOG.mdThe docs note that with the npm install you should call it as git-cliff with the hyphen. --init writes the default config to cliff.toml. Without one it warns "cliff.toml" is not found, using the default configuration and runs anyway.
I pointed it at mergetel/frogdb, a small public repo with four commits: an initial commit, a feat:, a fix: on a branch, and GitHub’s merge commit for that branch. This is everything it printed:
WARN git_cliff_core::changelog > process_commits: 2 commit(s) were skipped due to parse error(s) (run with `-vv` for details)
## [unreleased]
### 🚀 Features
- Build FrogDB field guide and admin with 35 frogs
### 🐛 Bug Fixes
- Use sharper portraits for large frog imagesNo tags yet, so everything lands under [unreleased]. The two skipped commits are “Initial commit” and the merge commit, and -vv says why for each: Commit did not match conventional format: Missing type in the commit summary, expected `type: description`. Skipping the merge commit turns out to be useful. release-please, run on the same repo, listed that fix twice, because it reads the conventional line GitHub puts in the merge commit’s body. git-cliff’s default config listed it once.
The real config
The default cliff.toml
The file --init writes has two halves. [git] decides which commits count and which section each one goes in:
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
...
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
...
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]Parsers are checked top to bottom and the first match wins. chore(deps) commits are skipped, and so are commits starting chore(release): prepare for, which matters later. The last line catches everything else as “Other”. One thing I got wrong at first: the numbers in the HTML comments don’t set the section order. I added a refactor, a docs and a perf commit and got Documentation, Performance, Refactor, the order of the parser list, even though Refactor is numbered 2.
[changelog] holds the template. It’s a Tera template, and this is the default body:
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for entry in commits | commit_groups(groups=commit_parsers_groups) %}
### {{ entry.group | striptags | trim | upper_first }}
{% for commit in entry.commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""striptags is what removes those HTML comments from the headings. To see every variable a template can use, run git-cliff --context. It prints the whole thing as JSON: each commit’s message, scope, group, breaking flag, author, and empty slots for GitHub PR data. git-cliff --list-templates shows the built-in alternatives, including keepachangelog, github and minimal. If you’re deciding what the output should look like, the changelog format guide covers the conventions.
--bump and --bumped-version
Picking the next version
--bumped-version prints the next version and nothing else. --bump renders the unreleased section under that version instead of [unreleased]. Neither creates a tag. I checked git tag after --bump -o CHANGELOG.md and the only new thing was the file. What each commit mix gave me:
| Last tag | Commits since | Printed |
|---|---|---|
| no tags | feat, fix | 0.1.0 |
| v1.2.0 | fix, docs | v1.2.1 |
| v1.2.0 | plus feat(api) | v1.3.0 |
| v1.2.0 | plus feat! | v2.0.0 |
| v0.3.0 | feat! | v1.0.0 |
| v0.3.0 | docs only | v0.3.1 |
Three things in there surprised me. With no tags it starts at 0.1.0 and ignores the 1.0.0 already sitting in frogdb’s package.json. A [bump] section with initial_tag = "1.0.0" fixed that. A breaking change on 0.3.0 goes to 1.0.0; setting breaking_always_bump_major = false made it 0.4.0 instead. And a docs-only change still gets a patch bump, where release-please would open no release at all. It keeps the v prefix from your tags, so the output drops straight into git tag.
Custom parsers
History that isn’t conventional
I made a repo with commits like “Add frog search” and “Fix login redirect” and ran the default config on it. Five commits, five skipped, an empty changelog, exit code 0. Turn conventional parsing off and write your own parsers instead:
[git]
conventional_commits = false
filter_commits = true
commit_parsers = [
{ message = "^Merge", skip = true },
{ message = "^Add", group = "Added" },
{ message = "^Fix", group = "Fixed" },
]WARN git_cliff_core::changelog > process_commits: 1 commit(s) were skipped due to grouping error(s) (run with `-vv` for details)
## [unreleased]
### Added
- Add frog search
- Add habitat filter
### Fixed
- Fix login redirectfilter_commits = true drops anything no parser matched, which is where “Bump dependencies” went. The docs also describe pulling PR titles, numbers and labels from GitHub and other hosts, and the built-in github template does that. On frogdb it printed by @mergetel in [#1] after each line, and listed the fix twice, since that template turns conventional parsing off and the merge commit stops being skipped. The git-cliff docs recommend squash merges for exactly this. Careful with --init github, too. It overwrote my existing cliff.toml without asking.
CI
Running it in GitHub Actions
The official action is orhun/git-cliff-action. This is the example from the git-cliff docs, with the checkout action moved to its current major:
- name: Check out repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Generate a changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}Keep fetch-depth: 0. Nothing in those two steps commits the file or creates a release, so that’s another step you add.
Troubleshooting
Errors people hit
- Commits missing, no error. The only sign is
2 commit(s) were skipped due to parse error(s). Run with-vvto see which. If you’d rather fail, setrequire_conventional = true. On frogdb that stopped withCommit 47e9a5c is not conventionalfor the initial commit and the merge commit, thenError: UnconventionalCommitsError(2). - Template mistakes. A typo in a variable gives
Template render error: Variable `commit.mesage` not found in context while rendering 'body'. A broken tag gives aTemplateParseErrorwith the line number andexpected `%}` or `-%}`.--contextshows the real field names. - No parser matched. If you delete the catch-all
.*parser and setfail_on_unmatched_commit = true, a commit type you didn’t list stops the run:Commit 68416ea was not matched by any commit parser, thenError: UnmatchedCommitsError(1). - An empty release in CI. On a shallow clone of a tagged repo it printed
## [2.1.0] - 2026-09-23and nothing under it, exit code 0. The earlier commits weren’t there to read. That’s whatfetch-depth: 0is for. - Windows, long paths. My test clone lived deep in a temp folder and git-cliff failed with
GitErrorandpath too longon the.gitfolder. A shorter path fixed it. Plaingitworked fine in the same folder, which made it confusing.
Where it fits
Pairing it with a release tool
Since git-cliff only writes the file, a release is a few lines around it. This is what I ran, in PowerShell:
$v = npx git-cliff --bumped-version
npx git-cliff --bump -o CHANGELOG.md
git add CHANGELOG.md
git commit -m "chore(release): prepare for $v"
git tag $vIt printed v2.1.1, wrote the file, committed and tagged. The commit message isn’t arbitrary. The default config skips commits that start chore(release): prepare for, so the release commit never shows up in the next changelog. Pushing the tag and publishing are still yours.
If you want all of that handled, release-please opens a release PR with the changelog in it, and merging it cuts the release. The git-cliff docs point at the project’s own release workflow, which uses the action to fill in the notes on its GitHub releases. There’s a comparison of changelog generators if you’re still choosing.
Honesty
What this doesn’t fix
git-cliff gives you a tidy CHANGELOG.md. The people who read it are the ones who already use the thing and went looking. Everyone else hears about a release when somebody tells them, and “Use sharper portraits for large frog images” is a fine changelog line and not much of an announcement.
That’s the gap I built Merge & Tell for. It reads each merged pull request and drafts a post for each network, and I read them before they go out. The updates page is it running on this product.
The changelog is done. Nobody has heard about it yet.
You were going to merge the PR anyway.