How-to › Release and secure packages

How to set up PyPI trusted publishing from GitHub Actions#

Register the workflow on PyPI and TestPyPI, publish through pypa/gh-action-pypi-publish with id-token write, and check the record still matches the file.

Audience
Library maintainer
Level
intermediate
Topic
Publish to package registries
Languages
Python
Verified

Your release workflow uploads to PyPI with an API token pasted into a repository secret two maintainers ago. The token covers the whole account, it has no expiry, and the person who made it has left. Rotating it means someone logging in with a second factor and pasting a fresh token into CI, which is why the old one is still there.

What you get

You will end up with a workflow that builds the package and publishes it to TestPyPI and PyPI from a tag, with no stored token. A check proves the workflow matches the publisher record. This is for you if you release Python packages from CI.

Short answer

Register a trusted publisher on PyPI with the repository owner, repository name, workflow filename, and environment, pending when the project does not exist yet, and again on TestPyPI. Build the source distribution and wheel with python -m build. Publish from a job that has permissions: id-token: write and the environment you registered, with pypa/gh-action-pypi-publish, pointing repository-url at TestPyPI first. PyPI matches the token’s workflow file and environment, so a rename or a dropped environment is refused.

You will need

Python 3.10 or later, a PyPI account and a TestPyPI account, each with two-factor authentication, and a GitHub repository for the package. Verified 2026-10-21 against Python 3.13.16, build 1.6.1, flit_core 4.1.0, and PyYAML 6.0.3. The commands build the package and check the workflow against the records as files, so nothing is uploaded and no token is minted.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A project-scoped API token in a CI secretA CI system without a trusted publisher on PyPI, or an upload from a machine you controlA long-lived secret that works for whoever holds it, and a rotation you must schedule yourselfGitHub Actions, GitLab CI, or another provider PyPI lists, where PyPI can trust the job instead
Trusted publishing with pypa/gh-action-pypi-publishGitHub Actions, with the publishing step isolated in one small workflowA record on each index that must match the workflow filename and environment exactly, and a job that cannot be a reusable workflowA CI provider PyPI does not list, or uploads from a developer machine
twine upload run by handA handful of releases a year, from the machine of one maintainerA person at a keyboard for every release, and a token or password in that person’s keyringReleases cut by CI, or more than one person cutting them

The token is the portable option and the one that lives longest; PyPI’s own help page recommends trusted publishing over it wherever a provider supports it. Manual twine upload keeps a person in the loop and makes every release wait for one. Trusted publishing removes the stored secret and binds the release to one repository, one workflow file, and, when you choose, one environment, which is where the maintenance moves.

Build the artifacts the publisher will upload

python -m build makes the source distribution and the wheel from pyproject.toml. The metadata PyPI reads comes from the [project] table, and the backend named in [build-system] does the packing; the sample uses flit_core because it has no dependencies of its own.

[build-system]
requires = ["flit_core>=3.11,<5"]
build-backend = "flit_core.buildapi"

[project]
name = "acme-sdk"
version = "0.1.0"
description = "Python client for the Acme API, generated from its OpenAPI description"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [{ name = "Acme Developer Platform", email = "sdk@example.com" }]
classifiers = ["Programming Language :: Python :: 3"]

[project.urls]
Source = "https://github.com/acme/acme-sdk-python"
Changelog = "https://github.com/acme/acme-sdk-python/blob/main/CHANGELOG.md"

[tool.flit.module]
name = "acme_sdk"
PYTHONPATH="$PWD/vendor" python3 -m build --no-isolation --outdir dist . 2>&1
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Getting build dependencies for wheel...
* Building wheel...
Successfully built acme_sdk-0.1.0.tar.gz and acme_sdk-0.1.0-py3-none-any.whl

The sample keeps build and the backend under vendor/ and passes --no-isolation, so the build touches no network. In the workflow, python -m pip install build followed by a plain python -m build does the same inside the isolated environment the build documentation describes. The wheel’s file list is pinned by the last test below, because a wheel that ships tests/ is the mistake a publisher never catches.

Register the publisher on both indices

PyPI’s adding a publisher page asks four things of a GitHub Actions publisher: the repository owner, the repository name, the workflow filename, and an environment. The environment is optional, and the page calls it strongly recommended. A project that does not exist yet gets a pending publisher from your account page instead, with the project name added. It creates the project on first use and reserves nothing before that.1

TestPyPI is a separate index with separate accounts, so register the same workflow there with an environment name of its own. The sample keeps both records in publishers.json, exactly as they were typed into the two forms.

{
  "pypi": {
    "index": "https://upload.pypi.org/legacy/",
    "project": "acme-sdk",
    "repository_owner": "acme",
    "repository_name": "acme-sdk-python",
    "workflow_filename": "release.yml",
    "environment": "pypi"
  },
  "testpypi": {
    "index": "https://test.pypi.org/legacy/",
    "project": "acme-sdk",
    "repository_owner": "acme",
    "repository_name": "acme-sdk-python",
    "workflow_filename": "release.yml",
    "environment": "testpypi"
  }
}

Give the job an identity instead of a token

The two publish jobs follow PyPI’s using a publisher page and the PyPA’s publishing guide: id-token: write at the job level, one environment per index, and the action called without a username or password.

  publish-testpypi:
    needs: build
    runs-on: ubuntu-latest
    environment: testpypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v6
        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@v6
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

The TestPyPI job points repository-url at https://test.pypi.org/legacy/, and the action asks GitHub for a token with the audience that index expects. PyPI’s internals page describes the rest. The token’s signature is verified and its claims are matched against every registered publisher. A PyPI API token good for fifteen minutes comes back, scoped to the projects that matched.2

The build job gets contents: read from the top of the file. As in the guide, it keeps no git credentials after its checkout, so the build backend it runs can neither push nor mint a token. Warehouse never checks the ref a job ran from. Give the pypi environment required reviewers, or a deployment rule that admits v* tags alone, so a branch carrying an edited copy of release.yml cannot run in it.

Check the workflow against the record

PyPI’s matching rules are public. Warehouse’s GitHub publisher model requires the repository, repository_owner, repository_owner_id, and job_workflow_ref claims to match the record, checks environment only when the record names one, and lists sub among the claims it never reads.3 check_publisher.py applies the same rules to a workflow file and the records, and adds the two triggers the troubleshooting page says PyPI refuses outright. A job’s own permissions block replaces the workflow’s, an empty one included, and job_permissions reads them that way.

ref = f"{record['repository_owner']}/{record['repository_name']}/.github/workflows/{filename}"
lines.append(f"    job_workflow_ref  {ref}@<ref>")
if filename != record["workflow_filename"]:
    job_failures.append(f"the record names {record['workflow_filename']}, this file is {filename}")

env = job.get("environment")
if isinstance(env, dict):
    env = env.get("name")
want = record.get("environment") or ""
lines.append(f"    environment       job: {env or 'none'}, record: {want or 'any'}")
if want and (env or "").lower() != want.lower():
    job_failures.append(f"the record requires environment {want}, the job runs in {env or 'none'}")

permissions = job_permissions(workflow, job)
lines.append(f"    id-token          {permissions.get('id-token', 'not granted')}")
if permissions.get("id-token") != "write":
    job_failures.append("id-token: write is missing, so GitHub mints no token")
PYTHONPATH=vendor python3 check_publisher.py release.yml
release.yml
  job publish-testpypi uploads to https://test.pypi.org/legacy/
    job_workflow_ref  acme/acme-sdk-python/.github/workflows/release.yml@<ref>
    environment       job: testpypi, record: testpypi
    id-token          write
    ok
  job publish-pypi uploads to https://upload.pypi.org/legacy/
    job_workflow_ref  acme/acme-sdk-python/.github/workflows/release.yml@<ref>
    environment       job: pypi, record: pypi
    id-token          write
    ok

demo.py runs the same check over the committed workflow, four edited copies, and a second set of records that name no environment.

PYTHONPATH=vendor python3 demo.py
release.yml against publishers.json: ok
fixtures/renamed/publish.yml against publishers.json: FAIL the record names release.yml, this file is publish.yml
fixtures/no-environment/release.yml against publishers.json: FAIL the record requires environment pypi, the job runs in none
fixtures/pull-request-target/release.yml against publishers.json: FAIL trigger pull_request_target is refused by PyPI whatever the record says
fixtures/no-id-token/release.yml against publishers.json: FAIL id-token: write is missing, so GitHub mints no token
release.yml against publishers-no-environment.json: ok

A renamed file fails both jobs, because the record names the file. A job that lost its environment fails only when the record has one. The last line shows records without an environment accepting jobs that have one, which is the rule in _check_environment. Adding an environment, or required reviewers, to a working job therefore changes nothing on PyPI’s side; removing an environment the record names does.

Check it worked

PYTHONPATH=vendor python3 -m unittest -v test_publisher 2>&1
test_a_forbidden_trigger_fails_before_any_record_is_read (test_publisher.PublisherTest.test_a_forbidden_trigger_fails_before_any_record_is_read) ... ok
test_a_forbidden_trigger_named_alone_fails_too (test_publisher.PublisherTest.test_a_forbidden_trigger_named_alone_fails_too) ... ok
test_a_job_outside_the_registered_environment_fails (test_publisher.PublisherTest.test_a_job_outside_the_registered_environment_fails) ... ok
test_a_job_with_write_all_gets_a_token (test_publisher.PublisherTest.test_a_job_with_write_all_gets_a_token) ... ok
test_a_job_without_id_token_write_gets_no_token (test_publisher.PublisherTest.test_a_job_without_id_token_write_gets_no_token) ... ok
test_a_record_without_an_environment_accepts_a_job_that_has_one (test_publisher.PublisherTest.test_a_record_without_an_environment_accepts_a_job_that_has_one) ... ok
test_a_renamed_workflow_file_fails_for_both_jobs (test_publisher.PublisherTest.test_a_renamed_workflow_file_fails_for_both_jobs) ... ok
test_an_empty_permissions_block_on_a_job_overrides_the_workflow (test_publisher.PublisherTest.test_an_empty_permissions_block_on_a_job_overrides_the_workflow) ... ok
test_the_build_job_cannot_push_and_only_the_publish_jobs_get_a_token (test_publisher.PublisherTest.test_the_build_job_cannot_push_and_only_the_publish_jobs_get_a_token) ... ok
test_the_committed_workflow_matches_both_records (test_publisher.PublisherTest.test_the_committed_workflow_matches_both_records) ... ok
test_the_wheel_carries_the_package_and_the_license_only (test_publisher.PublisherTest.test_the_wheel_carries_the_package_and_the_license_only) ... ok

Each ok line covers one rule, and the last test builds the wheel and reads its file list. The sixth is the one to keep when you cut the rest, because it pins the direction of the environment rule.

When it goes wrong

The exchange fails with invalid-publisher. The token is valid but matches no record. The troubleshooting page says to check for typos and for the environment the publisher was configured with, and adds that a renamed source repository leaves the record pointing at the old name. The checker names the mismatch:

PYTHONPATH=vendor python3 check_publisher.py fixtures/no-environment/release.yml
    job_workflow_ref  acme/acme-sdk-python/.github/workflows/release.yml@<ref>
    environment       job: none, record: pypi
    id-token          write
    FAIL the record requires environment pypi, the job runs in none
    the exchange would end in invalid-publisher

Edit the record on the project’s publishing page, or add a second publisher for the second workflow, because a project can have several.

The first upload is refused with Non-user identities cannot create new projects. The pending publisher worked and the name in pyproject.toml differs from the project name on the record. The troubleshooting page says to decide which name is right, then either fix the metadata or delete the wrongly created project and register the pending publisher again.

The job never gets a token. id-token: write is missing, or the trigger is pull_request_target, which PyPI refuses whatever the record says. A pull request from a fork cannot mint a token in your repository either, per the security model page. That is the reason the record points at a workflow rather than at a person.

When not to do this

Do not register a publisher for a workflow that does anything besides publish. PyPI’s security model asks for the upload to be isolated in the smallest, least-privileged workflow, and points out that a file renamed to release.yml is release.yml as far as the token is concerned.

Do not grant id-token: write to the whole workflow to save a line. Every job in the file can then mint an identity token, and the action’s README says to set the permission only in the job that publishes.

Do not skip TestPyPI because the first real upload worked. The TestPyPI record is where a renamed workflow or a dropped environment shows up before a release is waiting on it.

Do not leave the record behind when a maintainer leaves. Publishers are registered to the project, not to the person, so the security model asks for a review of them whenever someone is removed.

Do not make the publisher a reusable workflow. The troubleshooting page says PyPI cannot match one, and the action’s README says to run the publishing step from the job that calls it instead.

Last verified

Verified 2026-10-21 against Python 3.13.16, build 1.6.1, flit_core 4.1.0, and PyYAML 6.0.3. Every output block is what the command preceding it printed. The build and the checks ran here. The publisher forms, the token exchange, and pypa/gh-action-pypi-publish were read from PyPI’s documentation, Warehouse’s source, and the action’s README rather than run, so no request reached PyPI, TestPyPI, or GitHub.

Footnotes

  1. A pending publisher reserves nothing. The creating a project page says the project is created only when the publisher is first used, and that another user registering the name first invalidates the pending record. The queue for a name is therefore the first upload, not the first form. ↩︎ Back to text

  2. The exchange is two-phase on purpose. PyPI’s internals page explains that its API tokens all begin with pypi- so that GitHub’s secret scanning can find them in public repositories and PyPI can revoke them. An OIDC token is a JWT with no prefix anyone could scan for, so PyPI swaps it for a token of its own shape, good for fifteen minutes. ↩︎ Back to text

  3. The subject claim is the one most tutorials describe, and the one Warehouse does not read. In github.py, sub sits in __unchecked_claims__ beside actor, run_id, and workflow, while the matching is done on job_workflow_ref, which carries the filename, and on environment, which the publisher may leave blank. A guide that says an added environment changes the subject is right about GitHub, but PyPI never looks at the subject. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.