Project Setup

Bumping Versions Consistently Across a Python CLI Project

Keep a CLI’s version in sync across pyproject.toml, uv.lock, the changelog and packaging files with one source of truth, bump-my-version and a consistency test.

Updated

The version number of a CLI shows up in more places than anyone remembers: pyproject.toml, the lock file, a __version__ constant someone added years ago, the changelog heading, a Homebrew formula, a Dockerfile label, the docs' installation snippet, perhaps a man page. Releases go wrong when these disagree — mytool --version says 1.5.2 while PyPI has 1.6.0, the formula still downloads last month's tarball, the changelog's newest heading is two releases old. This guide reduces the number of places a version is written by hand to one, automates the rest with bump-my-version, and adds a test that fails the build when anything drifts. It belongs to the versioning and changelogs topic.

Prerequisites

  • A CLI project with a pyproject.toml; uv for the commands shown (other tools work the same way).
  • bump-my-version, run with uvx bump-my-version so it needs no project dependency.
  • Git, since the bump commits and tags.

Fewer copies first

The best way to keep copies in sync is to not have them. Before automating anything, remove every copy that can be derived:

Derive first, bump the rest Where a command line tool version should come from: one written source, derived copies, and the few files a bump tool must rewrite. Derive first, bump the rest pyproject.toml [project] version source the one place a person writes the number (or the git tag) Derived automatically no copies importlib.metadata in code, uv.lock via uv lock, docs at build time Rewritten by the bump tool bump-my-version changelog heading, Homebrew formula, CITATION.cff Checked by tests CI everything above must agree on every commit Every copy you can derive is one that can never drift.
  • Inside the package, read the installed metadata rather than storing a constant: importlib.metadata.version("mytool") always matches what is installed. Exposing version info and build metadata covers the details, including the fallback for running from a source checkout.
  • Let the lock file follow pyproject.toml. uv lock updates the project's own entry; nobody should edit uv.lock by hand.
  • Generate documentation snippets from the version at build time instead of hard-coding "pip install mytool==1.5.2".
  • Or derive everything from git tags. With hatch-vcs or setuptools-scm, the tag is the only place a version is written, and the build fills in the rest — see deriving versions from git tags with hatch-vcs.

What remains are files that must contain a literal version and that you do not want to template: the changelog, a Homebrew formula or Scoop manifest kept in the repository, perhaps a CITATION.cff. Those are what a bump tool is for.

Finding every copy

Before deciding what to automate, find out where the current version actually appears. A quick search for the literal string usually turns up surprises:

git grep -n --fixed-strings "1.5.2" -- ':!uv.lock' ':!CHANGELOG.md'

Typical results in a CLI repository, and what to do with each:

WhereTypical contentAction
pyproject.tomlversion = "1.5.2"the single source
src/mytool/__init__.py__version__ = "1.5.2"replace with importlib.metadata
README.mduv tool install mytool==1.5.2drop the pin, or generate
DockerfileLABEL version="1.5.2"pass as a build argument from CI
packaging/homebrew/mytool.rbversion "1.5.2" plus a URL and checksumbump tool, or update from CI after publishing
docs/conf.pyrelease = "1.5.2"read from metadata at build time
tests/expected --version outputcompute from metadata in the test

Excluding the lock file and changelog keeps the output focused — those are expected to contain versions. Anything else that matches is either a copy to remove, a file for the bump tool, or a coincidence (a dependency that happens to share the number) that confirms why precise search patterns matter. Re-run the search occasionally; new copies creep in with new files.

The recipe

Simple case: uv version

If pyproject.toml is the only file with a literal version, uv does the whole job:

uv version --bump minor         # pyproject.toml and uv.lock, together

Several files: bump-my-version

bump-my-version (the maintained successor to bumpversion and bump2version) reads its configuration from pyproject.toml, rewrites every listed file, and optionally commits and tags:

[tool.bumpversion]
current_version = "1.5.2"
commit = true
tag = true
tag_name = "v{new_version}"
message = "Release {new_version}"
pre_commit_hooks = ["uv lock", "git add uv.lock"]

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

[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
search = "## [Unreleased]"
replace = "## [Unreleased]\n\n## [{new_version}] - {now:%Y-%m-%d}"

[[tool.bumpversion.files]]
filename = "packaging/homebrew/mytool.rb"
search = 'version "{current_version}"'
replace = 'version "{new_version}"'

Each [[tool.bumpversion.files]] entry names a file and the exact text to find and replace. Searching for version = "1.5.2" rather than the bare 1.5.2 matters: the bare number might also appear as a dependency pin, a date or an unrelated example, and a bump tool that replaces all of them silently corrupts files. The changelog entry turns the Unreleased heading into a dated release heading and opens a fresh Unreleased section above it, which is the Keep a Changelog convention. The pre_commit_hooks run after the files are rewritten and before the commit, so uv.lock is updated and included in the same commit.

uvx bump-my-version show-bump          # preview the possible next versions
uvx bump-my-version bump minor --dry-run -v
uvx bump-my-version bump minor         # rewrite, re-lock, commit, tag
git push --follow-tags                 # CI publishes from the tag
One command, every file Terminal session previewing the next versions with bump-my-version, bumping the minor version, and showing the resulting commit and tag. One command, every file bash $ uvx bump-my-version show-bump 1.5.2 ── bump ─┬─ major ─ 2.0.0 ├─ minor ─ 1.6.0 $ uvx bump-my-version bump minor && git show --stat HEAD | tail -4 CHANGELOG.md | 2 ++ packaging/homebrew/mytool.rb | 2 +- pyproject.toml | 4 ++-- uv.lock | 2 +- $ git tag --points-at HEAD v1.6.0 The lock file is updated by the pre-commit hook and lands in the same commit.

The result is one commit that changes every version-bearing file at once, and a tag pointing at it. Because CI publishes from the tag, the published artefacts and the repository can never disagree about which commit is 1.6.0.

Pre-releases

bump-my-version's default version pattern is major.minor.patch. To bump into and out of release candidates, extend the parse and serialise patterns, or use uv version --bump rc for pyproject.toml and let bump-my-version handle only the files that should change for final releases. Shipping pre-releases and release candidates describes the cycle; most projects only update the Homebrew formula and changelog heading for final releases anyway.

UX considerations

The users of this process are maintainers who release a few times a month at most and forget the steps in between:

  • One command. bump-my-version bump minor or a just release minor wrapper. Anything that needs a checklist will eventually be done wrong.
  • Refuse a dirty tree. bump-my-version does this by default when committing; keep it that way, so a release never includes unrelated edits.
  • Preview before writing. --dry-run -v prints every replacement it would make — the fastest way to debug a search pattern that does not match.
  • Fail when a pattern is missing. By default bump-my-version errors if a configured search string is not found in a file; do not turn that off, because it is how you learn that someone reformatted the Homebrew formula.
  • Keep current_version honest. It is the tool's only memory. If someone bumps by hand, the next automated bump fails to find its patterns, which is the right outcome — fix the config rather than forcing it.
Bump configuration that holds up Practices for configuring a version bump tool and the shortcuts that corrupt files. Bump configuration that holds up Do ✓ Search for version = "{current_version}" ✓ Fail when a pattern is not found ✓ Re-lock in a pre-commit hook ✓ Tag in the same step as the commit Avoid ✗ Replacing the bare number everywhere ✗ Bumping by hand "just this once" ✗ A literal __version__ constant ✗ Editing uv.lock by hand Precise patterns are what keep a dependency pin with the same number untouched.

Testing the behaviour

A consistency test turns "the versions agree" into something CI checks on every commit, independent of how the bump was done:

# tests/test_version_consistency.py
import re
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def project_version() -> str:
    return tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"]


def test_bumpversion_config_matches_project():
    data = tomllib.loads((ROOT / "pyproject.toml").read_text())
    assert data["tool"]["bumpversion"]["current_version"] == project_version()


def test_lock_file_matches_project():
    lock = tomllib.loads((ROOT / "uv.lock").read_text())
    entry = next(p for p in lock["package"] if p["name"] == "mytool")
    assert entry["version"] == project_version()


def test_changelog_has_heading_for_current_version():
    text = (ROOT / "CHANGELOG.md").read_text()
    assert re.search(rf"^## \[{re.escape(project_version())}\]", text, re.M), \
        "CHANGELOG.md has no heading for the current version"


def test_homebrew_formula_matches_project():
    formula = (ROOT / "packaging/homebrew/mytool.rb").read_text()
    assert f'version "{project_version()}"' in formula

During development between releases, the changelog test fails if the newest heading is still [Unreleased] and the version was bumped without a release — which is precisely the inconsistency you want to know about. Add one more check to the built-wheel smoke test: that mytool --version prints the same number.

Conclusion

Version drift is a symptom of too many hand-maintained copies. Remove the ones you can derive — read installed metadata in code, let uv lock follow pyproject.toml, generate docs snippets — then let uv version or bump-my-version rewrite the rest in one commit with a tag, using precise search patterns and a pre-commit hook for the lock file. A small consistency test makes sure that whichever way a bump happens, CI notices when the numbers disagree.

Frequently asked questions

Is bump-my-version better than uv version?

They solve different sizes of problem. uv version updates pyproject.toml and the lock file and understands PEP 440 pre-release segments. bump-my-version updates any number of files with search-and-replace rules and can commit and tag. Many projects use uv version until a second file needs a literal version.

Should __version__ still exist?

Only if something depends on it. If you keep it, set it from metadata — __version__ = importlib.metadata.version("mytool") — rather than as a literal, so it can never drift.

What about version numbers in documentation?

Prefer phrasing that does not need them ("install the latest release with uv tool install mytool"). Where a version is necessary, have the docs build read it from the package metadata or the latest git tag.

Can CI do the bump instead of a maintainer?

Yes — a manually triggered workflow can run bump-my-version bump minor and push the commit and tag, which then trigger the publish workflow. Use a dedicated token with permission to push to the protected branch, and keep the publish job's protections as they are.