Skip to content

How to Publish to PyPI with Trusted Publishing

Trusted publishing lets GitHub Actions upload packages to PyPI without storing any secrets. Instead of creating an API token and pasting it into your CI settings, you tell PyPI which GitHub repository and workflow are allowed to publish. GitHub Actions then proves its identity to PyPI using an OIDC token that lives only for the duration of the workflow run. Why use trusted publishing for PyPI? explains what this protects against.

This guide covers GitHub Actions, the most common CI provider for Python projects. PyPI also supports trusted publishing from GitLab CI/CD, Google Cloud, and ActiveState.

Prerequisites

Configure PyPI to trust your repository

Log in to PyPI

Go to pypi.org and sign in.

Open the trusted publisher settings

For an existing project: On the Your projects page, click Manage on the project, then click Publishing in the project’s sidebar.

For a new project that has never been uploaded: Go to your account publishing settings and scroll to Create a new pending publisher. Enter the package name exactly as it will appear on PyPI. The first successful upload creates the project and converts the pending publisher into a normal one.

Fill in the GitHub details

Enter the following:

Field Value
Owner Your GitHub username or organization
Repository name The repository that will publish the package
Workflow name The filename of the workflow, e.g. publish.yml
Environment name Optional. If your workflow uses a GitHub Actions environment, enter its name here.

Save

Click Add. PyPI will now accept uploads from that specific workflow.

Create the publish workflow

Create .github/workflows/publish.yml in your repository. The workflow uses two jobs: a build job that produces the distributions, and a publish job that uploads them. Only the publish job holds the credential:

name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: astral-sh/setup-uv@v10.0.1
      - run: uv build
      - uses: actions/upload-artifact@v7.0.1
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    environment:
      name: pypi
      url: https://pypi.org/p/<YOUR_PACKAGE_NAME>
    steps:
      - uses: actions/download-artifact@v8.0.1
        with:
          name: dist
          path: dist/
      - uses: astral-sh/setup-uv@v10.0.1
      - uses: astral-sh/attest-action@v0.0.6
      - run: uv publish --trusted-publishing always

A few things to note:

  • Only the publish job grants id-token: write. uv build runs third-party build backends and their dependencies, any of which can execute code during the build. Keeping the OIDC permission out of the build job means a malicious build dependency cannot request an OIDC token and mint a PyPI credential. The build job hands its output to publish through upload-artifact, and publish runs uv publish with no build step. PyPA’s own pip release workflow splits the jobs the same way.
  • permissions: id-token: write lets the publish job request an OIDC token from GitHub’s token service. Without it, uv publish has no credential to present to PyPI.
  • on: release triggers the workflow when you create a GitHub release. You can also trigger on tag pushes (on: push: tags: ["v*"]).
  • astral-sh/attest-action writes a .publish.attestation file next to each distribution in dist/, signed with the same OIDC identity. These are digital attestations (PEP 740), the signed statements PyPI displays as Provenance. uv publish does not generate them itself, but it uploads any it finds in dist/. How to publish Python packages with digital attestations covers the alternatives.
  • uv publish detects the GitHub Actions environment, requests an OIDC token, exchanges it with PyPI for a short-lived upload credential, and publishes the files in dist/. --trusted-publishing always makes the step fail immediately if no OIDC token is available, instead of falling back to other credentials.
  • The environment block is optional but recommended. GitHub environments let you add required reviewers, restrict which branches can deploy, and see deployment history. If you specify an environment here, use the same environment name in your PyPI trusted publisher configuration.

To rehearse the workflow against a throwaway index first, follow How to publish to TestPyPI with uv.

Publish a release

Tag your release

$ git tag v0.1.0
$ git push origin v0.1.0

Create a GitHub release

Go to your repository on GitHub, click Releases โ†’ Draft a new release, select the tag you just pushed, and click Publish release.

Watch the workflow

Open the Actions tab. The publish workflow starts, and the uv publish step logs one line per file:

Publishing 2 files to https://upload.pypi.org/legacy/
Hashing example-0.1.0-py3-none-any.whl (1.4KiB)
Uploading example-0.1.0-py3-none-any.whl (1.4KiB)
Hashing example-0.1.0.tar.gz (635.0B)
Uploading example-0.1.0.tar.gz (635.0B)

Verify the upload

Open your project’s PyPI page and select the new release. Its file list shows a Provenance entry for each file, naming the repository and workflow that published it. If the entry is missing, the attest-action step did not run before uv publish, or the job lacks id-token: write.

Revoke old API tokens

If you were previously using an API token to publish, go to your PyPI account settings and delete the token. There is no reason to keep a long-lived secret around once trusted publishing is working.

Troubleshooting

Failed to obtain token for trusted publishing / No OIDC token discovered: uv could not get an OIDC token from GitHub. Check that permissions: id-token: write is set on the publish job and that the step runs on GitHub Actions, not a self-hosted runner without OIDC.

invalid-publisher from PyPI: the OIDC token is valid but matches no configured publisher. Verify that the owner, repository name, and workflow filename in your PyPI publisher configuration match the actual repository and the file in .github/workflows/, and that the environment name matches or is blank on both sides.

Pending publisher not matching: The package name in the pending publisher configuration must exactly match the name field in your pyproject.toml (PyPI normalizes names, so hyphens and underscores are equivalent).

Renamed or transferred repository: Renaming the GitHub repository, transferring it to a new owner, or renaming the workflow file breaks trusted publishing. The OIDC token then carries an owner, repository, or workflow value that no longer matches the trusted publisher on PyPI, and PyPI returns invalid-publisher. Update the publisher in your PyPI project’s Publishing settings so the owner, repository, and workflow filename match the current names, removing the stale entry and adding a new one if the values changed.

Reusable workflows: PyPI does not currently support reusable GitHub Actions workflows as the trusted workflow. The workflow file that calls uv publish must be defined directly in your repository.

Last updated on