Project Setup

Dynamic Versioning for a Poetry CLI from Git Tags

Derive a Poetry CLI’s version from git tags with poetry-dynamic-versioning: requires-plugins, the PEP 517 backend, dev versions between tags, and CI checkout depth.

Updated

With a literal version = "1.5.2" in pyproject.toml, every release is two steps that must agree: edit the version, then tag the commit. Forget one, or do them in the wrong order, and mytool --version disagrees with the tag, or PyPI rejects a duplicate upload. Deriving the version from the git tag removes the duplication: tag v1.6.0, build, and the wheel is 1.6.0; build from a later commit and you get a unique development version such as 1.6.0.post1.dev0+a63fe40 that can never be confused with a release. Hatch users get this from hatch-vcs, as in deriving versions from git tags with hatch-vcs. For Poetry projects, the poetry-dynamic-versioning plugin does the same — and Poetry 2 makes installing it part of the project configuration. This guide sets it up, explains the two ways it hooks into builds, and covers the CI details that trip people up. It belongs to the Poetry workflows topic.

Prerequisites

How the version is derived

The plugin asks git for the nearest tag matching a version pattern and the distance from it, then formats a version in the style you choose:

Versions derived from git How poetry-dynamic-versioning derives versions from git history: the tagged commit gets the release version and later commits get development versions. Versions derived from git 1.4.0 release build v1.4.0 tag 1.4.0.post1.dev0 +a63fe40 +1 commit 1.4.0.post3.dev0 +9c1d2e7 +3 commits 1.5.0 release build v1.5.0 tag git history, oldest to newest Local labels (+hash) cannot be uploaded to PyPI — only tagged commits can be released.
  • On a commit exactly at tag v1.4.0: version 1.4.0.
  • Three commits after it: 1.4.0.post3.dev0+<hash> in the default PEP 440 style — sorts after 1.4.0, is clearly not a release, and carries the commit hash as a local label.
  • With uncommitted changes: the same, optionally with a dirty marker, depending on configuration.

Local version labels (the +<hash> part) are not accepted by PyPI, which is deliberate: only builds from a tagged commit can be published.

The recipe

Configure the project

[project]
name = "mytool"
dynamic = ["version"]
requires-python = ">=3.11"
dependencies = ["click (>=8.1)"]

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

[tool.poetry]
packages = [{ include = "mytool", from = "src" }]
version = "0.0.0"                      # placeholder; replaced at build time

[tool.poetry.requires-plugins]
poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }

[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"

[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"

Four parts work together. dynamic = ["version"] tells standard tooling that the version is computed, not written. [tool.poetry] version = "0.0.0" is a placeholder Poetry needs for its own commands. [tool.poetry.requires-plugins] — new in Poetry 2 — makes Poetry install the plugin automatically into its own environment the first time someone runs a Poetry command in the project, so contributors need no poetry self add step. And the [build-system] table uses the plugin's PEP 517 backend wrapper, which matters for the next point.

Two ways builds pick up the version

When you run poetry build, the plugin runs inside Poetry and substitutes the version. But not every build goes through Poetry: pip install ., uv build, python -m build and every tool that builds from an sdist call the PEP 517 backend named in [build-system] directly, without Poetry's plugin system. Pointing build-backend at poetry_dynamic_versioning.backend makes those builds compute the version too. In a test project, both paths produced the same result:

$ git tag v1.4.0 && poetry build
dist/mytool-1.4.0-py3-none-any.whl

$ git commit -qam "more work" && uv build
dist/mytool-1.4.0.post1.dev0+a63fe40-py3-none-any.whl
Two routes to the version How poetry build and standard PEP 517 builds each obtain the derived version with poetry-dynamic-versioning. Two routes to the version Build started by Version computed by Needs poetry build the Poetry plugin requires-plugins uv build, pip, build the backend wrapper build-backend setting wheel from sdist metadata in the sdist nothing — no git needed Configure both routes, or non-Poetry builds silently get the 0.0.0 placeholder.

Poetry may still print the placeholder ("Building mytool (0.0.0)") because the substitution happens while it runs; the file names in dist/ are authoritative.

Exposing the version at runtime

Read the version from installed metadata rather than a constant, so it always matches what was built:

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

try:
    __version__ = version("mytool")
except PackageNotFoundError:          # running from a source tree without installing
    __version__ = "0.0.0+unknown"

The plugin can also rewrite version strings in files at build time, but metadata is simpler and cannot drift; exposing version info and build metadata covers adding the commit hash to --version output.

CI details

Two settings cause nearly every "wrong version in CI" report:

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # full history and tags; the default shallow clone has neither
      - run: pipx install poetry==2.5.1
      - run: poetry build
      - run: ls dist/

With the default shallow checkout, git cannot see the tag, and the plugin falls back to 0.0.0 or a distance-from-nothing version. fetch-depth: 0 fetches history and tags. Release workflows triggered by a tag push see the tag on HEAD and produce the clean release version; workflows on branches produce development versions, which is exactly right for test builds.

Also check how tags reach the server. git push --follow-tags pushes only annotated tags (git tag -a v1.6.0 -m "Release 1.6.0"); a lightweight tag created with plain git tag v1.6.0 stays on your machine, the release workflow never triggers, and nothing explains why. Either create annotated tags — they also record who tagged and when — or push the tag explicitly with git push origin v1.6.0.

UX considerations

  • Tag format is configuration. The default pattern accepts v1.6.0 and 1.6.0. If your repository has other tags (docs-2026-10), set a pattern so they are ignored.
  • Make development builds obvious. The post…dev…+hash form tells anyone reading mytool --version that they are not on a release; keep the default PEP 440 style rather than shortening it.
  • Fail when a release has no tag. In the release job, assert that the built version contains no + or dev before publishing — a missing tag should stop the release, not publish a development version to TestPyPI or an internal index.
  • Document the bootstrap. Contributors cloning the repository need tags too (git fetch --tags) for a meaningful local version; mention it in CONTRIBUTING.md.
Guarding a release Terminal session where the release check rejects a development build and accepts a build made at the release tag. Guarding a release bash $ GITHUB_REF_NAME=v1.4.0 python scripts/check_release_version.py built version 1.4.0.post1.dev0+a63fe40 does not match release tag '1.4.0' $ git checkout v1.4.0 && uv build --wheel && python scripts/check_release_version.py release version 1.4.0 matches tag A two-second check that stops a development build from being published.

Testing the behaviour

A release-guard test, run in the release job after poetry build, checks that the wheel name matches the tag:

# scripts/check_release_version.py
import os
import sys
from pathlib import Path

from packaging.version import Version

tag = os.environ.get("GITHUB_REF_NAME", "").removeprefix("v")
wheels = list(Path("dist").glob("*.whl"))
if len(wheels) != 1:
    sys.exit(f"expected one wheel in dist/, found {len(wheels)}")
built = Version(wheels[0].name.split("-")[1])
if built.local or built.is_devrelease or str(built) != str(Version(tag)):
    sys.exit(f"built version {built} does not match release tag {tag!r}")
print(f"release version {built} matches tag")

Run it as GITHUB_REF_NAME=v1.4.0 python scripts/check_release_version.py locally after building at the tag to see it pass, and from a later commit to see it fail with the development version in the message. It is a two-second step that prevents the most embarrassing release mistake.

Conclusion

Deriving the version from git tags makes the tag the single source of truth: declare dynamic = ["version"], keep a placeholder in [tool.poetry], let [tool.poetry.requires-plugins] install poetry-dynamic-versioning automatically, and point build-backend at its wrapper so non-Poetry builds compute the same version. Fetch full history in CI, read the version from metadata at runtime, and guard releases with a check that the wheel matches the tag.

Frequently asked questions

Do I still need poetry self add poetry-dynamic-versioning?

Not with Poetry 2 and [tool.poetry.requires-plugins]: Poetry installs declared plugins automatically when you run a command in the project. On Poetry 1.x, poetry self add "poetry-dynamic-versioning[plugin]" is still required on every machine.

Why is my version 0.0.0 after installing?

The build could not see the tag — a shallow clone, a source archive without the .git directory, or a build that bypassed both the plugin and the backend wrapper. Check git describe --tags in the same environment; if it fails, the plugin cannot succeed either.

Can I use this with uv instead of Poetry later?

Yes. Because the version logic lives in the build backend, uv build already works, as shown above. If you migrate fully, switching to Hatchling with hatch-vcs is the more common long-term setup; see migrating a CLI from Poetry to uv.

What about sdists built from a tag and installed elsewhere?

The plugin writes the computed version into the sdist's metadata, so building a wheel from that sdist later — without git — still produces the right version.

Can several packages in one repository have their own tags?

Yes. Give each package a tag prefix — mytool-v1.6.0, mytool-plugins-v0.3.0 — and set the plugin's pattern-prefix (or a full pattern regular expression with a base group) in each package's [tool.poetry-dynamic-versioning] table so it only considers its own tags. Test it with a build at each kind of tag before relying on it, because a pattern that accidentally matches another package's tags produces plausible-looking but wrong versions.

Does the plugin modify pyproject.toml?

During a build it substitutes the version and then restores the file. In practice the restored file can differ cosmetically — key order within [tool.poetry], for example — which leaves a working tree that looks dirty. Build in a clean CI checkout, and if you want a git diff --exit-code guard, run it before building rather than after.