Project Setup

Automating Changelogs with Conventional Commits

Generate CLI changelogs from Conventional Commits: enforce the format, derive semantic version bumps, and produce release notes with git-cliff.

Updated

A hand-written CHANGELOG.md rots the moment someone forgets to update it. If your commit messages already describe every change in a structured way, the changelog can be generated instead of remembered — and the same structured messages can tell you whether the next release is a patch, a minor, or a major. This guide shows the Conventional Commits format, how it maps to semantic versioning, how to enforce it, and how to turn your git history into release notes with git-cliff (with towncrier as the alternative).

TL;DR

  • Write commits as type(scope): summaryfeat:, fix:, docs:, refactor:, etc.
  • fix: → patch bump, feat: → minor bump, BREAKING CHANGE: (or feat!:) → major bump.
  • Enforce the format at commit time with commitlint run from a pre-commit hook so bad messages never land.
  • Generate CHANGELOG.md from history with git-cliff and a small cliff.toml; towncrier is the fragment-file alternative.
  • Keep one source of version truth (pyproject.toml) and let the release step read it — don't maintain the version in three places.

The Conventional Commits format

Conventional Commits is a lightweight convention layered on your commit subject line. The structure is:

<type>[optional scope][!]: <description>

[optional body]

[optional footer, e.g. BREAKING CHANGE: ...]

The type is a small vocabulary. The two that drive releases are feat (a new feature) and fix (a bug fix); the rest — docs, refactor, test, chore, ci, perf, build — describe non-release-affecting work but still organize the changelog. Real examples from a CLI project:

$ git commit -m "feat(config): support TOML config files"
$ git commit -m "fix(parser): reject negative --retries values"
$ git commit -m "docs: document the --json output flag"
$ git commit -m "refactor(core): split resolver into its own module"

A breaking change is signaled two equivalent ways: a ! after the type/scope, or a BREAKING CHANGE: footer. Either is enough for tooling to trigger a major bump.

$ git commit -m "feat(cli)!: rename --output to --out"

# or, with a footer explaining the migration
$ git commit -m "feat(cli): rename --output to --out

BREAKING CHANGE: the --output flag is now --out; update scripts accordingly."

The scope in parentheses is optional but valuable in a CLI: scoping commits to parser, config, or a specific subcommand lets you group changelog entries by area later.

Mapping commit types to semantic versioning

The reason to bother with the format is that it makes the version bump mechanical. Given the commits since your last tag, the highest-priority change wins:

Commit signalSemantic version bumpExample
BREAKING CHANGE: / type!:major (1.4.2 → 2.0.0)feat!: drop Python 3.10
feat:minor (1.4.2 → 1.5.0)feat: add --json flag
fix: / perf:patch (1.4.2 → 1.4.3)fix: handle empty input
docs: / chore: / refactor: / test:none by defaultchore: bump ruff

For a CLI this discipline matters more than for a library, because your users pin your tool and script around its flags. A renamed flag or a changed exit code is a breaking change even if the code diff looks tiny — mark it with !. Choosing which changes are breaking is closely tied to how you assign meaning to exit codes: changing an exit code is a contract change your changelog must announce.

Enforcing the format with commitlint and pre-commit

Automation only works if the messages are actually well-formed, so enforce the convention at commit time rather than hoping. commitlint validates a message against the Conventional Commits rules, and you can wire it into the commit-msg stage via the pre-commit framework:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/compilerla/conventional-pre-commit
    rev: v3.6.0
    hooks:
      - id: conventional-pre-commit
        stages: [commit-msg]

Install the hook into the commit-msg stage and the check runs on every commit:

$ pre-commit install --hook-type commit-msg

$ git commit -m "fixed the bug"
[bad commit message] does not follow Conventional Commits — rejected

$ git commit -m "fix(parser): handle empty --config file"
[ok]

Because this hangs off the same framework you already use for linting and formatting, it costs almost nothing to add. The broader setup — installing hooks, pinning revs, running in CI — is covered in pre-commit hooks for CLI projects. Enforcing the format is the piece that makes the generation step below trustworthy.

Generating CHANGELOG.md with git-cliff

git-cliff reads your git history, groups commits by type, and renders a Markdown changelog from a template. Its config lives in cliff.toml. Here is a compact one tuned for a CLI:

# cliff.toml
[changelog]
header = "# Changelog\n\nAll notable changes to this project are documented here.\n"
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | upper_first }}{% endfor %}
{% endfor %}
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
  { message = "^feat", group = "Features" },
  { message = "^fix", group = "Bug Fixes" },
  { message = "^perf", group = "Performance" },
  { message = "^docs", group = "Documentation" },
  { message = "^refactor", group = "Refactor" },
  { message = "^chore", skip = true },
]
tag_pattern = "v[0-9]*"

commit_parsers decides which commits appear and under what heading; here chore: commits are skipped from user-facing notes. Generating (or refreshing) the changelog is one command, and you can preview just the unreleased section:

# Write/refresh the whole file
$ git-cliff --output CHANGELOG.md

# Preview only the notes since the last tag (great for release PRs)
$ git-cliff --unreleased --strip header

git-cliff can also compute the next version for you from the commit types with git-cliff --bumped-version, which closes the loop between "what changed" and "what number comes next."

towncrier: the fragment-file alternative

git-cliff derives everything from commit messages. towncrier takes the opposite approach: each change ships a small news fragment file in a newsfragments/ directory, and towncrier stitches them into the changelog at release time.

# One fragment per change; the number is the PR/issue id, the suffix is the type
$ cat newsfragments/142.feature.md
Add a --json flag to emit machine-readable output.

$ towncrier build --version 1.5.0

The trade-off is explicit. towncrier fragments are prose written for humans, so the changelog reads better and avoids leaking terse commit subjects — but contributors must remember to add a fragment (enforceable via CI). git-cliff needs zero extra files but is only as good as your commit hygiene. Fragment files also sidestep merge conflicts on a single CHANGELOG.md, which is why large projects often prefer towncrier. Choose git-cliff when you trust the commit convention; choose towncrier when you want editorial control over release notes.

Wiring it into a release with one source of version truth

The failure mode to avoid is a version number that lives in three places — pyproject.toml, __init__.py, and a git tag — that drift apart. Keep pyproject.toml as the single source and derive the rest. A minimal release flow:

# 1. Compute the next version from Conventional Commits
$ NEXT=$(git-cliff --bumped-version)

# 2. Update the one source of truth
$ uv version "${NEXT#v}"        # or: poetry version "${NEXT#v}"

# 3. Regenerate the changelog through the new tag
$ git-cliff --tag "$NEXT" --output CHANGELOG.md

# 4. Commit, tag, push
$ git commit -am "chore(release): $NEXT"
$ git tag "$NEXT"
$ git push --follow-tags

If your code needs to report its own version at runtime (mycli --version), read it from the installed metadata rather than duplicating the string:

from importlib.metadata import version

__version__ = version("mycli")  # reads the value packaged from pyproject.toml

This keeps pyproject.toml authoritative: the build backend stamps it into the wheel, importlib.metadata reads it back, and git-cliff tags it — one number, three consumers, zero drift. The surrounding policy — how you choose the number and communicate it — is the subject of the parent guide, managing CLI versioning and changelogs. Once tagged, the release is what you hand to publishing a Python CLI to PyPI.

Production notes

  • Run generation in CI, not by hand. A release job that runs git-cliff on tag push guarantees the changelog matches the history; a human running it locally will eventually forget.
  • Squash-merge PRs to a Conventional subject. If you squash-merge, the PR title becomes the commit — lint the PR title, since that is the message git-cliff will read.
  • chore(release): commits should be skipped. Filter your own release commits out of the changelog (as the cliff.toml above does) or they clutter every entry.
  • Breaking changes for a CLI are broader than for a library. Renamed flags, changed defaults, altered exit codes, and changed output formats are all breaking — mark them with ! even when the code change is small.
  • Pin the generator version. Pin git-cliff/towncrier in your CI image so a tool upgrade cannot silently reformat your entire changelog.
  • Don't hand-edit generated sections. If you use git-cliff, treat CHANGELOG.md as generated output; put editorial notes in a fragment tool like towncrier instead if you need prose control.