Project Setup

Publishing a Python CLI to PyPI

Publish a Python CLI to PyPI and TestPyPI with twine and API tokens, pick a unique package name, and automate releases so pipx and pip installs work.

Updated

Publishing to PyPI is what turns pipx install your-cli from a wish into a command that works for anyone. The mechanics are straightforward — upload your built artifacts with twine — but the parts that bite are the ones around it: choosing a name that is actually free, testing the upload safely first, handling credentials without leaking them, and automating releases so you are not typing tokens by hand. This guide walks the whole release, ending with the modern recommendation: trusted publishing from CI with no long-lived token at all.

TL;DR

  • Check the name is free before you commit to it — PyPI names are global, normalized, and first-come.
  • Dry-run on TestPyPI to rehearse the upload without burning a real version number.
  • Authenticate with an API token, scoped to the project, stored in ~/.pypirc or the TWINE_* environment variables — never your password.
  • twine upload dist/* publishes; then verify with pipx install <name> from a clean machine.
  • Prefer trusted publishing (GitHub Actions + OIDC) so releases carry no secret to leak or rotate.

Pick a name and check availability

The name in [project] becomes your identity on PyPI, and it is permanent and global. Names are normalized before comparison — case, underscores, hyphens, and dots all fold together, so Greet_CLI, greet-cli, and greet.cli are the same name. Before you get attached to one, check that it is free:

$ curl -o /dev/null -s -w "%{http_code}\n" https://pypi.org/pypi/greet-cli/json
404      # 404 = available; 200 = already taken

If it is taken, pick another — you cannot reuse or force a name, and squatting disputes are slow. Once chosen, set it in pyproject.toml and never change it casually; a rename means a brand-new project that your existing users won't get updates from.

[project]
name = "greet-cli"
version = "0.1.0"

The rest of the metadata (readme, license, [project.urls]) is your listing page; the packaging overview covers filling it in well.

Rehearse on TestPyPI

TestPyPI is a full, separate copy of the index for exactly this: practicing an upload without consequences. Register a separate account there, then upload your built artifacts (see building wheels and sdists) to it first:

$ python -m build
$ twine check dist/*
$ twine upload --repository testpypi dist/*

Then install from TestPyPI to confirm the whole chain works. Because your dependencies live on real PyPI, point --extra-index-url back at it so they still resolve:

$ pipx install --index-url https://test.pypi.org/simple/ \
      --pip-args "--extra-index-url https://pypi.org/simple/" greet-cli
$ greet World
Hello, World!

If that installs and runs, the real upload will too. TestPyPI purges old projects periodically and is not for permanent hosting — it is a rehearsal stage only.

API tokens and where to put them

PyPI does not accept passwords for uploads; you authenticate with an API token. Create one at Account settings → API tokens. Make your first token account-scoped (you need it to create a brand-new project), then, after the first upload, create a project-scoped token and delete the broad one — least privilege, so a leak can't touch your other projects.

A token is used as the password with the username __token__. Two good places to keep it:

The ~/.pypirc file, for a workstation:

[distutils]
index-servers =
    pypi
    testpypi

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...your-token...

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgEIcHl...your-testpypi-token...

Or environment variables, which are better for CI because nothing is written to disk:

export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc...

Never commit either. ~/.pypirc should be chmod 600 and outside any repo; the env-var form belongs in your CI provider's secret store, not in the workflow file.

Upload with twine

With artifacts built, checked, and credentials in place, the publish itself is one command:

$ twine check dist/*
$ twine upload dist/*
Uploading greet_cli-0.1.0-py3-none-any.whl
Uploading greet_cli-0.1.0.tar.gz
View at:
https://pypi.org/project/greet-cli/0.1.0/

Two things to internalize. First, twine upload dist/* uploads everything in dist/, so clear stale files (rm -rf dist/ before building) or name the exact files to avoid shipping a leftover from a previous version. Second, a version number is single-use: PyPI permanently rejects re-uploading a version, even after you delete it. If 0.1.0 had a bug, you release 0.1.1 — you cannot overwrite 0.1.0. Keep versions marching forward, ideally driven from a tag as in managing CLI versioning and changelogs.

Trusted publishing from GitHub Actions

The modern, recommended way to publish is trusted publishing: PyPI trusts a specific GitHub Actions workflow directly via OpenID Connect, so the runner mints a short-lived token at publish time. There is no long-lived secret to store, leak, or rotate — the single biggest source of supply-chain incidents for packages.

Set it up once in the PyPI project's Publishing settings: register your GitHub repository, workflow filename, and (optionally) an environment name. Then this workflow publishes on every tagged release:

name: Publish to PyPI

on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write        # required for OIDC trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1

Notice there is no token anywhere. The id-token: write permission lets the runner prove its identity to PyPI, which hands back a scoped, minutes-long credential. Tag a release (git tag v0.1.0 && git push --tags) and the package publishes itself. This pairs naturally with tag-driven versioning and with automating changelogs with conventional commits to produce fully hands-off releases.

Verify the published release

Publishing is not done until a user's install path works. From a clean machine or container — not your dev box, where things may already be cached — install exactly the way your users will:

$ pipx install greet-cli
  installed package greet-cli 0.1.0
    - greet
$ greet World
Hello, World!

Also open https://pypi.org/project/greet-cli/ and confirm the README renders, the links in the sidebar work, and the version is what you expect. Installing via pipx here doubles as proof that your [project.scripts] entry point survived the whole trip and the command exists on PATH.

Yanking a bad release

Sometimes a release ships broken and you cannot delete it (and shouldn't — deletion breaks anyone who pinned it). The right move is to yank it. A yanked version stays downloadable for pins that already reference it exactly, but new installs and resolvers skip it, so pipx install greet-cli quietly picks the previous good version instead.

Yank from the release's page in the PyPI web UI (Manage → the version → Options → Yank), then publish a fixed version. Yanking is for "this release is broken, steer people away," not for "I changed my mind" — it is reversible, but the cleaner story is always to roll forward to a new version.

Production notes

  • Automate, then never publish by hand. Manual twine upload is fine for a first release, but tag-triggered trusted publishing removes the token and the human error of uploading the wrong dist/.
  • Generate a PEP 740 attestation. gh-action-pypi-publish emits signed provenance automatically under trusted publishing, giving users a verifiable link from the artifact back to the exact commit and workflow that built it.
  • Register the name early. If you are worried about a name being taken, publish a 0.0.1 placeholder to claim it; you own the project from the first upload.
  • Keep requires-python honest. It filters which interpreters even attempt the install; an overly generous floor turns a clean rejection into a confusing runtime crash for users.
  • TestPyPI accounts are separate. Its account database, tokens, and trusted-publisher config are independent from PyPI — set each up on both if you rehearse there.