Poetry handles a CLI project's whole lifecycle: dependencies, the lock file, the virtual environment — and the release. poetry version bumps the number, poetry build produces the wheel and sdist through poetry-core, and poetry publish uploads them. Poetry 2 also changed what a release looks like: project metadata now lives in the standard [project] table, so the wheel's metadata is the same whichever tool reads it, and Poetry-specific settings shrink to a small [tool.poetry] section. This guide walks a Poetry-managed CLI from version bump to PyPI, including the checks worth running before uploading, a TestPyPI rehearsal, and a CI workflow that publishes without a long-lived token. It belongs to the Poetry workflows topic; the tool-neutral process is in publishing a Python CLI to PyPI.
Prerequisites
- Poetry 2.x (
pipx install poetryoruv tool install poetry). - A CLI project with an entry point, as in Poetry entry points and scripts for CLIs.
- A PyPI account; for CI, a trusted publisher configured for the project.
Metadata in Poetry 2
[project]
name = "mytool"
version = "1.5.2"
description = "Deploy services from the terminal."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = ["click (>=8.1)", "rich (>=13)"]
[project.scripts]
mytool = "mytool.cli:main"
[tool.poetry]
packages = [{ include = "mytool", from = "src" }]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
Everything a user or a packaging tool sees is in [project]; [tool.poetry] keeps only what is Poetry-specific, such as the package location. Older projects with [tool.poetry.dependencies] and [tool.poetry.scripts] still build, but moving to [project] makes metadata portable — a later migration to uv or another backend then touches only the build table.
The recipe
1. Bump the version
poetry version minor # 1.5.2 -> 1.6.0, rewrites pyproject.toml
poetry version --short # 1.6.0
poetry version prerelease --dry-run # preview 1.6.1a0 without writing
poetry version accepts major, minor, patch, premajor, preminor, prepatch and prerelease, or an explicit version. If the version should come from git tags instead, use the plugin described in dynamic versioning with Poetry plugins and skip this step.
2. Check, then build
poetry check --lock # pyproject.toml valid and poetry.lock up to date
rm -rf dist
poetry build # dist/mytool-1.6.0.tar.gz and dist/mytool-1.6.0-py3-none-any.whl
poetry check --lock fails if pyproject.toml changed since the lock was written — "pyproject.toml changed significantly since poetry.lock was last generated" — which catches the classic mistake of editing a constraint by hand and releasing without re-locking. Removing dist/ first matters: poetry publish uploads everything in it, and a stale wheel from an earlier build is an easy accident.
3. Smoke-test the wheel
Install the built wheel into an isolated environment and run the command, exactly as a user would:
pipx run --spec dist/mytool-1.6.0-py3-none-any.whl mytool --version
# or: uvx --from dist/mytool-1.6.0-py3-none-any.whl mytool --version
A missing dependency, a broken entry point or a data file left out of the package fails here rather than for every user. The same check belongs in CI, as in smoke-testing the built wheel in CI.
4. Rehearse on TestPyPI
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry config pypi-token.testpypi "$TESTPYPI_TOKEN"
poetry publish -r testpypi
pipx run --index-url https://test.pypi.org/simple/ --pip-args="--extra-index-url https://pypi.org/simple/" \
--spec mytool==1.6.0 mytool --version
TestPyPI is a separate index with separate accounts and tokens. A rehearsal is worthwhile the first time and after any change to metadata or the README, which PyPI renders as the project page.
5. Publish
Manually, with a project-scoped API token provided through the environment rather than stored in Poetry's config:
POETRY_PYPI_TOKEN_PYPI="$PYPI_TOKEN" poetry publish
Poetry reads POETRY_PYPI_TOKEN_<REPOSITORY> variables, so the token never touches a configuration file. Use --skip-existing to resume an interrupted upload.
Publishing from CI with trusted publishing
Poetry's publish command uses tokens; trusted publishing — PyPI accepting short-lived OIDC credentials from a specific workflow — is easiest through the PyPA publishing action. Let Poetry build, and the action upload:
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx install poetry==2.5.1
- run: poetry check --lock
- run: poetry build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
Building and publishing in separate jobs means nothing that runs during the build can reach the publishing permission; the protected pypi environment means a tag alone cannot release without approval. The publishing action also records attestations for each file. Pinning Poetry's version keeps builds reproducible across runner image updates.
Controlling what goes into the package
Poetry includes the packages listed under [tool.poetry] packages and, by default, respects .gitignore when deciding what else belongs in the sdist. Two settings cover the cases where that is not enough:
[tool.poetry]
packages = [{ include = "mytool", from = "src" }]
include = [
{ path = "src/mytool/templates/**/*", format = ["sdist", "wheel"] },
{ path = "tests", format = "sdist" },
]
exclude = ["src/mytool/**/*_fixture.json"]
include with a format says where a file should appear: templates must reach the wheel so the installed CLI can read them (as in bundling data files with importlib.resources), while tests usually belong only in the sdist, for downstream packagers who run them. exclude removes files that would otherwise be picked up — test fixtures living inside the package, for example. After changing either, list the wheel's contents (unzip -l dist/*.whl) before publishing; packaging mistakes are invisible in an editable development install.
When a release goes wrong
PyPI never allows a file to be replaced, even after deletion, so a broken release is fixed by publishing a new version. For a packaging-only mistake — a missing data file, a wrong classifier — bump to a post-release (poetry version 1.6.0.post1) or the next patch version. Then yank the broken release from the project's PyPI management page: yanked releases stay available to anyone who pinned that exact version, but resolvers skip them for everyone else. Note the yank and the reason in the changelog, so users who hit the broken version can find the explanation.
UX considerations
The users of a release process are future maintainers, possibly yourself in six months:
- Script the order. Check, bump, build, smoke-test, tag — in a
Makefiletarget or a short script — so nobody publishes before checking the lock. - Tag after bumping, publish from the tag. The tag and the published version then always agree, and CI is the only place with publishing rights.
- Keep the README PyPI-friendly. Relative links and images break on PyPI's project page; use absolute URLs for anything users click.
- Write release notes before tagging, ideally generated as in automating changelogs with conventional commits.
Testing the behaviour
Guard the packaging in your normal test suite by building with Poetry and inspecting the wheel:
# tests/test_build.py
import subprocess
import zipfile
from pathlib import Path
def test_wheel_contains_package_and_entry_point(tmp_path):
subprocess.run(["poetry", "build", "--format", "wheel", "--output", str(tmp_path)],
check=True, capture_output=True)
wheel = next(Path(tmp_path).glob("*.whl"))
with zipfile.ZipFile(wheel) as zf:
names = zf.namelist()
entry_points = zf.read(next(n for n in names if n.endswith("entry_points.txt"))).decode()
assert any(n.startswith("mytool/") for n in names)
assert "mytool=mytool.cli:main" in entry_points.replace(" ", "")
Normalising spaces matters: poetry-core writes mytool=mytool.cli:main while other backends write mytool = mytool.cli:main, and both are valid. Run the test in CI along with poetry check --lock, and a broken packages setting or a renamed entry-point function fails a pull request instead of a release.
Conclusion
Releasing with Poetry 2 is a short, reliable sequence: keep metadata in [project], bump with poetry version, verify with poetry check --lock, build into a clean dist/, smoke-test the wheel in isolation, rehearse on TestPyPI when metadata changes, and publish — locally with a token from the environment, or from CI by pairing poetry build with the PyPA action and trusted publishing. Script the order and let tags drive releases, and the process stays boring in the best way.
Frequently asked questions
Can poetry publish use trusted publishing directly?
Not natively at the time of writing; it authenticates with tokens or username and password. Building with Poetry and uploading with the PyPA action, as above, gives you trusted publishing without changing your build.
Why does poetry build print the old version?
If you use a dynamic-versioning plugin, Poetry may print the placeholder version while the plugin substitutes the real one during the build. Check the file names in dist/ — they are authoritative.
Should I commit dist/?
No. Build artefacts belong in releases and package indexes, not in the repository. Add dist/ to .gitignore.
How do I publish to a private index?
Configure it with poetry config repositories.internal https://pkgs.example.com/legacy/, provide credentials through POETRY_HTTP_BASIC_INTERNAL_USERNAME and POETRY_HTTP_BASIC_INTERNAL_PASSWORD, and run poetry publish -r internal.
Do users need Poetry to install my CLI?
No. A published wheel is a standard package: users install it with pipx install mytool, uv tool install mytool or pip install mytool, and none of them know or care that Poetry built it. Poetry is a development tool; only contributors need it, and only poetry-core (pulled in automatically as the build backend) is involved when someone builds from the sdist.