Project Setup

Publishing a CLI to PyPI with Trusted Publishing

Publish a Python CLI to PyPI from GitHub Actions with no API token: configure a trusted publisher, a protected environment, separate build and publish jobs, and TestPyPI.

Updated

The classic way to publish from CI is to create a PyPI API token, paste it into a repository secret, and pass it to twine upload. That token can upload any version of your package, never expires unless you remember to revoke it, and is readable by any workflow in the repository that asks for it — including one modified in a pull request if your settings allow. Trusted publishing removes the token entirely. PyPI trusts a specific repository, workflow and environment on GitHub; each release job proves its identity with a short-lived OpenID Connect token and receives an upload credential valid for a few minutes. This guide sets it up for a CLI project end to end: the PyPI configuration, a workflow that builds once and publishes from a protected environment, a TestPyPI rehearsal, and what to check when it fails. It is part of the CI/CD pipelines topic.

Prerequisites

How it works

Trusted publishing is built on OpenID Connect. GitHub Actions can mint a signed identity token (a JWT) for a running job, stating which repository, workflow file, git ref and environment it belongs to. PyPI verifies the signature against GitHub's public keys, checks the claims against the trusted publishers you configured, and, if they match, issues an API token scoped to your project that expires in about fifteen minutes.

How trusted publishing works The CI job requests an OIDC identity token from GitHub, exchanges it with PyPI for a short-lived upload token, and uploads the distributions. How trusted publishing works CI job GitHub OIDC PyPI request identity token signed JWT: repo, workflow, env exchange JWT upload token, valid ~15 min upload wheel + sdist No long-lived secret exists anywhere, so there is nothing to leak or rotate.

Nothing long-lived exists at any point. There is no secret to store, to leak into a log, to exfiltrate from a compromised dependency, or to rotate when a maintainer leaves.

API token versus trusted publishing A comparison of a stored PyPI API token and trusted publishing by lifetime, where it is stored, scope and rotation. API token versus trusted publishing Property API token secret Trusted publishing Lifetime until revoked minutes Stored in repository secrets nowhere Usable from anywhere it leaks to one repo + workflow + env Rotation manual not needed Trusted publishing is the default recommendation from PyPI for any project released from CI.

Step 1: configure the publisher on PyPI

On PyPI, open your project's Settings → Publishing page (for a project that does not exist yet, use Your account → Publishing → Add a pending publisher, which reserves the name and creates the project on first upload). Add a GitHub publisher with:

  • Owner and repository name — for example acme and mytool.
  • Workflow name — the file name only, such as release.yml.
  • Environment namepypi. Optional on PyPI's side, but strongly recommended; it is what lets you require approval.

Do the same on test.pypi.org with environment testpypi if you want a rehearsal target. TestPyPI is a separate service with separate accounts.

Step 2: create a protected environment

In the GitHub repository, go to Settings → Environments, create pypi, and configure:

  • Required reviewers: one or two maintainers who must approve each publish.
  • Deployment branches and tags: restrict to tags matching v*, so no branch workflow can ever deploy to it.

The environment is where the powerful permission lives. A workflow job that is not in this environment cannot obtain a token PyPI will accept, because the environment name is part of the claims PyPI checks.

Step 3: the workflow

Build and publish run as separate jobs. The build job runs your build backend and any build-time dependencies — code you did not write — with no special permissions. The publish job has id-token: write, but runs no build tooling at all: it only downloads the artefacts and uploads them.

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: astral-sh/setup-uv@v6
      - name: Build sdist and wheel
        run: uv build
      - name: Check the version matches the tag
        run: |
          tag="${GITHUB_REF_NAME#v}"
          ls dist/ | grep -q -- "-${tag}-py3-none-any.whl" || {
            echo "::error::built version does not match tag ${GITHUB_REF_NAME}"; ls dist; exit 1; }
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish-testpypi:
    needs: build
    runs-on: ubuntu-latest
    environment: testpypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          repository-url: https://test.pypi.org/legacy/

  publish-pypi:
    needs: publish-testpypi
    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

The pypa/gh-action-pypi-publish action handles the OIDC exchange automatically when id-token: write is granted, validates the distributions with twine check, uploads them, and — by default for trusted publishing — generates and uploads attestations: signed statements linking each file to this repository and workflow run, which PyPI displays and tools can verify. If you prefer to stay entirely within uv, uv publish also supports trusted publishing from GitHub Actions with no configuration beyond the permission.

The version check in the build job is a cheap guard for projects whose version is written in pyproject.toml rather than derived from the tag: publishing 1.4.0 from tag v1.5.0 is a mistake that is tedious to undo, because PyPI never allows a filename to be reused. The adjacent guides on deriving versions from git tags with hatch-vcs remove the possibility altogether.

A safe publish job The properties of a safe release job: a protected environment, minimal permissions, building once in a separate job, and attestations. A safe publish job Configure A "pypi" environment with required reviewers permissions: id-token: write, only there Download the artefact built earlier Attestations on (the default) Avoid Publishing from pull request workflows id-token permission on every job Rebuilding in the publish job A long-lived token as a fallback Separating build and publish keeps the powerful permission away from your build dependencies.

What attestations give your users

Attestations are easy to overlook because nothing breaks without them, but they change what a user can know about the tool they are installing. Each uploaded file gets a signed statement, following the PEP 740 format, recording the repository, the workflow file and the commit that produced it. PyPI shows this on the file's page as its provenance, and verification tools can check that the wheel a user downloaded was built by your pipeline from your repository — not uploaded by someone who obtained a maintainer's password.

For a CLI, which users typically install with pipx or uv tool install and then run with their own credentials and filesystem access, that provenance is a meaningful security property. It costs nothing to keep: attestations are generated automatically by the publish action when trusted publishing is used. The one thing that disables them is falling back to a stored API token "just for this release", which is another reason to delete old tokens once the new pipeline works.

UX considerations

For a CLI's maintainers and users, a few practices make releases smoother:

  • Rehearse on TestPyPI. The first trusted-publishing setup nearly always has a typo in a workflow or environment name. Publishing to TestPyPI first surfaces it without burning a real version number. Once the process is routine, some teams keep TestPyPI as a permanent first stage; others remove it.
  • Install from TestPyPI to check the artefact. uv tool install --index-url https://test.pypi.org/simple/ --index-strategy unsafe-best-match --extra-index-url https://pypi.org/simple/ mytool==1.5.0 resolves your package from TestPyPI and its dependencies from PyPI, which is a realistic install check.
  • Approve deliberately. Required reviewers on the pypi environment give a last chance to notice "wait, that tag was on the wrong commit". Keep the reviewer list short so releases are not blocked on availability.
  • Keep the release notes near the artefact. Creating a GitHub release with the changelog section and the wheel attached, after publishing, gives users one place to read what changed.

Testing the behaviour

You cannot unit-test OIDC, but you can verify every other part before the first real release:

  • Validate distributions locally with uvx twine check dist/*, which catches README rendering problems that PyPI would reject.
  • Lint the workflow with actionlint, which catches misspelled keys, bad expressions and shell errors.
  • Dry-run the build job on a branch by temporarily adding workflow_dispatch: to the triggers; the publish jobs will not run because the environments only accept tags.
  • Push a pre-release tag such as v1.5.0rc1 for the first end-to-end run. Pre-releases are not installed by default, so a mistake reaches almost nobody.

When the publish step fails, the error message from PyPI is usually specific. The common causes, in order:

  • invalid-publisher — the repository, workflow file name or environment does not exactly match the PyPI configuration. Check capitalisation and that the workflow name is the file name, not the name: field.
  • Missing id-token: write — the permission must be on the job (or workflow) that runs the publish action.
  • File already exists — that version was uploaded before. Versions are immutable on PyPI; bump the version.
  • A reusable workflow — trusted publishing from reusable workflows needs the publisher configured for the calling workflow; keep the publish job in the top-level workflow to avoid the confusion.

Conclusion

Trusted publishing turns the most sensitive credential in a Python project into something that does not exist. Configure a publisher on PyPI, put the publish job in a protected environment restricted to version tags, build in one job and publish in another with only id-token: write, and rehearse on TestPyPI. Releases become a tag push and an approval click, and there is no token left to lose.

Frequently asked questions

Can I use trusted publishing from GitLab or other CI systems?

PyPI supports trusted publishers for GitHub Actions, GitLab CI/CD, Google Cloud Build and ActiveState, with more added over time. The configuration differs per provider, but the model — short-lived identity tokens exchanged for upload tokens — is the same.

Do I still need an API token for anything?

Not for publishing from CI. Keep your account's two-factor authentication on, and delete any old project-scoped tokens once trusted publishing works, so the only way to upload is the audited pipeline.

What about publishing standalone binaries alongside the wheel?

Binaries built with PyInstaller or Nuitka are not uploaded to PyPI; attach them to the GitHub release in a later job. Building cross-platform release binaries in CI shows the matrix for building them.

Should the publish job run tests again?

No — it should publish exactly the artefacts that were already tested. Run tests and the wheel smoke test in jobs that the publish job needs, and never rebuild in the publish job.