Project Setup

Managing CLI Versioning & Changelogs

Manage Python CLI versioning with semantic versioning, bump-my-version, and automated CHANGELOG generation using Conventional Commits and git-cliff.

Updated

Every CLI a human installs eventually answers one question first: "what version is this?" Get versioning wrong and you ship a tool whose --version lies, a CHANGELOG nobody trusts, and a release process held together by manual find-and-replace. This article shows the modern, single-sourced approach: define the version once in pyproject.toml, read it at runtime with importlib.metadata, bump it with bump-my-version, and generate the changelog automatically from Conventional Commits using git-cliff.

TL;DR

  • Single-source the version: put it only in pyproject.toml; read it at runtime with from importlib.metadata import version.
  • Follow SemVer (MAJOR.MINOR.PATCH) — for CLIs the "public API" is your flags, subcommands, output format, and exit codes.
  • Bump consistently with bump-my-version bump patch|minor|major, which rewrites the version and tags the commit.
  • Automate the changelog: write Conventional Commits, then run git cliff -o CHANGELOG.md to generate it from history.
One version, many consumers One version, many consumers single source of truth pyproject.toml [project] version package metadata importlib.metadata .version() mytool --version build automation Conventional Commits CHANGELOG.md git-cliff

Semantic versioning for a CLI

Semantic Versioning reads MAJOR.MINOR.PATCH. The discipline is in knowing what counts as a breaking change for a command-line tool — it is not just importable Python symbols. Your public contract is everything a user or a script can depend on:

What counts as breaking for a CLI A table mapping command line changes to semantic version bumps: renaming a flag, changing an exit code, adding a command and changing output format. What counts as breaking for a CLI The change Bump Because Rename or remove a flag major scripts calling it break Change an exit code major CI conditions change meaning Change stdout format major pipes and parsers break Add a command or flag minor nothing existing changes Fix an error message patch no interface change For a CLI the public API is the flags, the exit codes and the shape of stdout — not the Python functions.
  • MAJOR — you removed or renamed a flag/subcommand, changed a default, altered machine-readable output (JSON schema, column order), or changed exit codes. Anything that breaks an existing invocation.
  • MINOR — you added a new subcommand, a new optional flag, or new output fields, in a backward-compatible way.
  • PATCH — bug fixes that don't change the documented behavior.

The implication: treat --help text loosely, but treat flag names, exit codes, and the shape of any --json/--output payload as a versioned API. Scripts in someone's CI pipeline parse that output; renaming a JSON key is a major bump even though the code "still works."

Single-sourcing the version

The cardinal rule is one source of truth. Declare the version in pyproject.toml and nowhere else — no hardcoded __version__ = "1.2.0" string that drifts out of sync with the package metadata. At runtime, read it back from the installed distribution's metadata with importlib.metadata.

Here's the package metadata in pyproject.toml (PEP 621):

[project]
name = "mytool"
version = "0.3.0"
description = "An example CLI"
requires-python = ">=3.10"
dependencies = ["typer>=0.12"]

[project.scripts]
mytool = "mytool.cli:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Now read that single value at runtime. The modern pattern (Python 3.8+, no third-party dependency) uses importlib.metadata.version with a PackageNotFoundError fallback so the tool still runs from a source checkout that was never pip installed:

# mytool/_version.py
from importlib.metadata import version, PackageNotFoundError

__all__ = ["__version__"]

try:
    # Resolves the version recorded in installed metadata at runtime.
    __version__ = version("mytool")
except PackageNotFoundError:
    # Running from a source checkout that was never installed.
    __version__ = "0.0.0+unknown"

This is validated below. With the package installed, version("mytool") returns exactly what's in pyproject.toml; from a bare checkout it falls back gracefully instead of crashing.

Note the distribution name ("mytool", matching [project].name) may differ from the import package name. Pass the distribution name to version().

Exposing --version

Wire __version__ into your entry point. In Typer, use an eager callback so --version short-circuits before any command runs:

# mytool/cli.py
import typer
from mytool._version import __version__

app = typer.Typer()


def _version_callback(value: bool) -> None:
    if value:
        typer.echo(f"mytool {__version__}")
        raise typer.Exit()


@app.callback()
def main(
    version: bool = typer.Option(
        None, "--version", callback=_version_callback, is_eager=True,
        help="Show the version and exit.",
    ),
) -> None:
    """mytool — an example CLI."""

The Click equivalent is the built-in decorator, which reads metadata for you:

import click
from mytool._version import __version__

@click.group()
@click.version_option(version=__version__, prog_name="mytool")
def cli() -> None:
    ...

For more on structuring the callable behind [project.scripts], see best practices for Python CLI entry points.

Bumping the version with bump-my-version

Editing pyproject.toml by hand on every release invites typos and forgotten git tags. bump-my-version (the maintained successor to bump2version) automates the rewrite, the commit, and the tag in one command. Configure it inside pyproject.toml:

One command, four artifacts A version bump updates the version in the project metadata, writes a commit, creates a tag and leaves the changelog ready to generate. One command, four artifacts bump-my-version minor / patch / major Files rewritten pyproject and __init__ Commit + tag v1.5.0 CI publishes triggered by the tag run once atomically push --tags Because the tag is created by the same command, the tag and the metadata can never disagree.
[tool.bumpversion]
current_version = "0.3.0"
allow_dirty = false
commit = true
message = "chore(release): bump version {current_version} -> {new_version}"
tag = true
tag_name = "v{new_version}"
tag_message = "Release v{new_version}"

[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'

Because the version lives only in pyproject.toml, that single [[tool.bumpversion.files]] block is all you need — no second file to keep in sync. Driving it:

# Install once (kept out of your runtime deps; a dev/tool dependency):
uv tool install bump-my-version

# Preview without writing anything:
bump-my-version bump --dry-run --verbose patch

# 0.3.0 -> 0.3.1, commit, and tag v0.3.1:
bump-my-version bump patch

# 0.3.1 -> 0.4.0:
bump-my-version bump minor

# 0.4.0 -> 1.0.0:
bump-my-version bump major

git push --follow-tags

The --dry-run --verbose preview is your safety net: it prints the exact diff and the tag it would create before you commit to anything.

Conventional Commits

Automated changelogs need machine-readable history. Conventional Commits is a lightweight convention for commit subjects: <type>(<scope>): <description>, with a ! or BREAKING CHANGE: footer for incompatible changes.

feat(parser): add --json output to the list command
fix(auth): respect HTTPS_PROXY when refreshing tokens
docs(readme): document the new --json flag
refactor(core): extract retry logic into a helper
feat(api)!: rename --target to --destination   # breaking -> MAJOR

The payoff is twofold. First, the types map directly onto SemVer: fix → PATCH, feat → MINOR, anything with !/BREAKING CHANGE → MAJOR. Second, a tool can group these into changelog sections without you writing release notes by hand. Enforce the format in CI or with a pre-commit hook so a single non-conforming commit doesn't poison the generated log.

Automated changelogs with git-cliff

git-cliff parses your Conventional Commits and renders a CHANGELOG.md. It's a single Rust binary configured by a 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 = "^docs", group = "Documentation" },
  { message = "^perf", group = "Performance" },
  { message = "^refactor", group = "Refactor" },
  { body = ".*security", group = "Security" },
  { message = "^chore\\(release\\)", skip = true },
  { message = "^chore", skip = true },
]
tag_pattern = "v[0-9]*"

Note the commit_parsers: chore(release) commits (the ones bump-my-version creates) are skipped so your changelog isn't cluttered with "bump version" noise. Generate and regenerate it:

uv tool install git-cliff

# Render the full history into CHANGELOG.md:
git cliff -o CHANGELOG.md

# Only the unreleased commits, e.g. to paste into a draft release:
git cliff --unreleased

# Render notes for a specific tag range (handy in CI on tag push):
git cliff v0.3.0..v0.4.0

The clean release flow combines all three tools: write Conventional Commits as you work, run git cliff -o CHANGELOG.md and commit it, then bump-my-version bump minor to tag the release. In CI, you can run git-cliff on tag push to auto-attach release notes.

Why this works (and the trade-offs)

Single-sourcing removes the most common bug class entirely: a --version that disagrees with the published package. The version exists in exactly one place, the build backend stamps it into the wheel metadata, and importlib.metadata reads it back. There is nothing to keep in sync.

The trade-off is the PackageNotFoundError fallback. When you run straight from a git clone without installing, no distribution metadata exists, so version() raises. The fallback string ("0.0.0+unknown") keeps the CLI usable, but it means "version from a dev checkout is meaningless" — which is correct. If you want a real version in editable installs, pip install -e . / uv pip install -e . writes metadata, and importlib.metadata resolves it normally.

Conventional Commits asks for discipline from contributors. The win is that the discipline is enforceable and useful — it drives both the changelog and the SemVer decision, so it isn't bureaucracy for its own sake.

Production notes

  • Tag and version must agree. bump-my-version's tag_name = "v{new_version}" guarantees the git tag matches pyproject.toml. CI that builds on tag push should assert this before publishing.
  • Don't read the version with regex. Parsing pyproject.toml at runtime to extract the version is fragile and adds a startup file read. importlib.metadata is the supported path and works from the installed wheel.
  • Performance. importlib.metadata.version() does a small metadata lookup on each call. For a CLI it's negligible, but call it once at import time (as in _version.py) rather than per-command.
  • uv and Poetry both honor the same metadata. Whether you manage deps with uv or Poetry, the version lives in pyproject.toml and importlib.metadata reads it identically — the runtime pattern doesn't change.
  • Pre-1.0. Under SemVer, 0.x lets you break things in MINOR bumps. State this in your README so users know 0.x is unstable by design.

What a version number promises for a CLI

Semantic versioning is usually explained in terms of a library's public functions, which is the wrong mental model for a command-line tool. Nobody imports your code; they call flags and read exit codes. The public interface is the command line itself.

That makes the mapping concrete:

Major — anything that breaks an existing invocation. Renaming or removing a flag, changing what an exit code means, altering the shape of stdout, changing a default in a way that changes behaviour, or removing a command. The test is simple: could a script that worked yesterday behave differently or fail today?

Minor — new capability that leaves everything existing alone. A new command, a new optional flag, an additional field in JSON output, a new supported config key.

Patch — behaviour unchanged, defects fixed. A corrected error message, a performance improvement, a dependency bump that nobody can observe.

Two cases catch people out. Adding a key to JSON output is a minor change if consumers parse by key and a major one if you have documented the shape as fixed — so document that new keys may appear, and the ambiguity disappears. Tightening validation is a breaking change even though it fixes a bug: input that used to be accepted now fails, and somewhere a pipeline depended on it.

Deprecation is the pressure valve. Keep the old flag working as a hidden alias, warn on stderr naming the replacement, and state the removal version. One or two minor releases of overlap turns a breaking change into a scheduled one.

Single-sourcing the version

The failure mode to design out is three places disagreeing: the metadata, the tag and what --version prints. Pick one source and derive everything else.

Metadata as the source is the simplest arrangement:

[project]
name = "mytool"
version = "1.4.0"
from importlib.metadata import PackageNotFoundError, version

def get_version() -> str:
    try:
        return version("mytool")
    except PackageNotFoundError:      # running from a source checkout
        return "0.0.0+dev"

Reading the installed metadata means --version reports what was actually packaged, not what a constant in your source says. The PackageNotFoundError branch matters during development, when the package may not be installed at all.

The git tag as the source removes even the file edit:

[build-system]
requires = ["hatchling", "hatch-vcs"]

[tool.hatch.version]
source = "vcs"

Now git tag v1.4.0 is the release, and the build derives the version from it. This is tidy for projects that release from CI on tags, and slightly awkward for anyone building from a shallow clone without tags — a trade worth knowing before you adopt it.

Expose it with an eager flag, so it prints even when the rest of the command line is incomplete:

def version_callback(value: bool) -> None:
    if value:
        typer.echo(f"mytool {get_version()}")
        raise typer.Exit()

@app.callback()
def main(
    show_version: Annotated[bool, typer.Option("--version", callback=version_callback, is_eager=True)] = False,
) -> None:
    ...

For bug reports, a slightly richer output pays for itself: the tool version, the Python version, and the interpreter path. Those three lines resolve a surprising share of "it does not work on my machine".

Releasing on a tag

With a single source, a release is one command plus a push:

bump-my-version bump minor       # rewrites the version, commits, and tags in one step
git push --follow-tags

Because the same command edits the file and creates the tag, the two cannot drift. Configure which files it touches once:

[tool.bumpversion]
current_version = "1.4.0"
allow_dirty = false
commit = true
tag = true
tag_name = "v{new_version}"
message = "chore: release v{new_version}"

CI takes it from there — build on a tag, publish with trusted publishing, and create the release notes from the changelog you generated. The one manual decision left in the whole process is which part of the version number moves, which is exactly the decision a human should be making.

Frequently asked questions

Should I start at 0.1.0 or 1.0.0?

Start at 0.1.0 while the interface is still moving, and go to 1.0.0 when you are willing to treat flag names and exit codes as stable. Under 1.0 the convention is that the minor number carries breaking changes, which gives you room to iterate — but do not stay at 0.x for years while people depend on the tool, because it stops communicating anything.

How do I version a tool that is released continuously?

Date-based versions (2026.8.1) are a reasonable fit when there is no meaningful compatibility promise to make, and they tell users how current their install is at a glance. The cost is that the number no longer signals whether an upgrade is safe, so if scripts depend on your interface, semantic versioning is still the better trade.

Do I need a changelog if the commits are descriptive?

Yes — git history answers "what changed" and a changelog answers "what changed that affects me". The two are different documents with different audiences. Deriving the changelog from conventional commits makes it nearly free, which removes the usual excuse for not having one.

What goes in the changelog for a CLI specifically?

Flags added, renamed or removed; exit codes added or changed; output format changes; new requirements such as a raised minimum Python version. Internal refactors do not belong there, however satisfying they were. The reader is deciding whether upgrading will break their pipeline.

Should the version be printed in error messages?

Not in every message, but do include it in the output of an unexpected failure and in whatever your tool prints for bug reports. A traceback pasted into an issue without a version costs a round trip to establish something you could have printed for free.

How do I handle a bad release that is already published?

Yank it rather than deleting it. Yanking hides the release from ordinary resolution while leaving it installable for anyone who pinned that exact version, so existing locked builds keep working and everyone else moves to the next good version. Then publish a patch — the version number itself can never be reused.

How do I tell users an upgrade is available?

Check at most once a day, in the background of an unrelated command, and never block on it. Cache the result with a timestamp, print a single line on stderr when a newer version exists, and provide a way to switch it off — a --no-update-check flag or an environment variable. A version check that adds latency to every invocation, or that fails when the network is unavailable, does more harm than the notice is worth — and for a tool installed with pipx or uv, the upgrade command is worth naming in the message itself.