Project Setup

Building and Publishing a Python CLI with Poetry

Release a Poetry-managed CLI: Poetry 2 project metadata, poetry version bumps, poetry build checks, TestPyPI rehearsal, token publishing and trusted publishing in CI.

Updated

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 poetry or uv 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.

A Poetry release, step by step Releasing a command line tool with Poetry: bump the version, check the lock, build, smoke-test the wheel and publish. A Poetry release, step by step poetry version minor check --lock lock is current poetry build clean dist/ smoke test isolated install publish token or trusted 1.6.0 ok wheel works Removing dist/ before building keeps stale files out of the upload.

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 with a token from the environment Terminal session bumping the version with Poetry, checking the lock, building and publishing with a token supplied through an environment variable. Publishing with a token from the environment bash $ poetry version minor Bumping version from 1.5.2 to 1.6.0 $ poetry check --lock && rm -rf dist && poetry build - Built mytool-1.6.0-py3-none-any.whl $ POETRY_PYPI_TOKEN_PYPI="$PYPI_TOKEN" poetry publish Publishing mytool (1.6.0) to PyPI The token lives in the environment, never in Poetry’s config file.

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 Makefile target 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.
Where publishing credentials live Options for supplying PyPI credentials when publishing a Poetry project and the risk of each. Where publishing credentials live Method Credential Risk poetry config pypi-token token stored on disk leaks with the laptop POETRY_PYPI_TOKEN_PYPI token in the environment scoped to one shell Trusted publishing in CI short-lived OIDC credential nothing to steal Let Poetry build and the PyPA action upload to get trusted publishing.

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.