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:

A release rehearsal that catches mistakes A release sequence: build the artifacts, upload to TestPyPI, install from TestPyPI into a clean environment, then publish to the real index. A release rehearsal that catches mistakes Build wheel + sdist 1 TestPyPI upload the rehearsal 2 Install it from the test index 3 PyPI the real thing, once 4 a version number on PyPI can never be reused — the rehearsal is where mistakes are still free Step 3 is the one people skip, and the one that catches a missing dependency in the metadata.
$ 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.

Handling the upload credential Good and bad practice for PyPI API tokens, covering token scope, storage and rotation. Handling the upload credential Do this Scope the token to the single project it publishes Prefer trusted publishing so no token exists at all Store it as a CI secret, never in the repo Rotate immediately if it is ever printed in a log Not this An account-wide token pasted into a workflow file A token in ~/.pypirc on a shared machine Reusing the same token across several projects Echoing it during a debugging session A project-scoped token limits the blast radius; trusted publishing removes the radius entirely.

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.

Publishing without a stored token The trusted publishing exchange: the CI job requests an identity token, PyPI verifies it against the configured publisher, and issues a short-lived upload token. Publishing without a stored token GitHub job OIDC issuer PyPI Index request an identity token a signed claim about this workflow exchange it for upload rights publish the release Nothing long-lived is ever stored: the credential exists for the length of one job.

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.

Frequently asked questions

Can I re-upload a version if I made a mistake?

No. A version number on PyPI is permanent even after you delete the release — the index will refuse the same filename forever. That is why the rehearsal on TestPyPI matters, and why the fix for a broken release is to yank it and publish a patch version rather than trying to replace it.

What does yanking do, exactly?

It hides the release from ordinary resolution while leaving it installable for anyone who pins that exact version. So a user who already locked it keeps a working build, and everybody else silently moves to the next good version. It is the right response to a release that is broken but not dangerous.

Is trusted publishing better than an API token?

Yes, for anything published from CI. Trusted publishing exchanges a short-lived identity token from your CI provider for upload rights, so there is no long-lived secret in the repository to leak, rotate or accidentally print. A project-scoped API token is the fallback for publishing from a laptop.

How do I claim a name before the tool is ready?

Publish a real 0.0.1 with a working entry point and an honest description saying it is a placeholder. Uploading an empty package is against the spirit of the index and gets removed; publishing something small and functional is not.

Should the version in pyproject.toml be the source of truth?

Pick one source and derive the rest. Either the metadata is authoritative and your code reads it back with importlib.metadata.version, or a tool derives both from the git tag. What causes released-version confusion is a hard-coded version string that drifts from the packaged metadata.