# 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.

Source: https://voxgig.com/howto/set-up-pypi-trusted-publishing-from-actions

- Audience: library-maintainer
- Level: intermediate
- Languages: python
- Verified: 2026-10-21
- Published: 2026-10-19

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A project-scoped API token in a CI secret](https://pypi.org/help/#apitoken) | A CI system without a trusted publisher on PyPI, or an upload from a machine you control | A long-lived secret that works for whoever holds it, and a rotation you must schedule yourself | GitHub Actions, GitLab CI, or another provider PyPI lists, where PyPI can trust the job instead |
| [Trusted publishing with pypa/gh-action-pypi-publish](https://docs.pypi.org/trusted-publishers/) | GitHub Actions, with the publishing step isolated in one small workflow | A record on each index that must match the workflow filename and environment exactly, and a job that cannot be a reusable workflow | A CI provider PyPI does not list, or uploads from a developer machine |
| [twine upload run by hand](https://twine.readthedocs.io/en/stable/) | A handful of releases a year, from the machine of one maintainer | A person at a keyboard for every release, and a token or password in that person's keyring | Releases 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.

```toml title="pyproject.toml"
[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"
```

```bash
PYTHONPATH="$PWD/vendor" python3 -m build --no-isolation --outdir dist . 2>&1
```

```text output
* 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](https://build.pypa.io/en/stable/) 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](https://docs.pypi.org/trusted-publishers/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](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
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.

```json title="publishers.json"
{
  "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](https://docs.pypi.org/trusted-publishers/using-a-publisher/) page and the
PyPA's
[publishing guide](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/):
`id-token: write` at the job level, one environment per index, and the action called without a
username or password.

```yaml title="release.yml"
  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](https://docs.pypi.org/trusted-publishers/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](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments)
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](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)
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](https://docs.pypi.org/trusted-publishers/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.

```py title="check_publisher.py"
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")
```

```bash
PYTHONPATH=vendor python3 check_publisher.py release.yml
```

```text output
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.

```bash
PYTHONPATH=vendor python3 demo.py
```

```text output
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

```bash
PYTHONPATH=vendor python3 -m unittest -v test_publisher 2>&1
```

```text output
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:

```bash
PYTHONPATH=vendor python3 check_publisher.py fixtures/no-environment/release.yml
```

```text output
    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](https://docs.pypi.org/trusted-publishers/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](https://github.com/pypa/gh-action-pypi-publish) says to run the
publishing step from the job that calls it instead.

## Related how-tos

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

- [Scaffold a Python API client project with Cookiecutter](/howto/scaffold-a-python-project-with-cookiecutter)

- [Audit the scopes of the tokens your integrations use](/howto/audit-the-scopes-of-integration-tokens)

- [Publish a Ruby gem to RubyGems.org with trusted publishing](/howto/publish-a-ruby-gem-to-rubygems)

## 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.

[^1]: A pending publisher reserves nothing. The
[creating a project](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
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.

[^2]: The exchange is two-phase on purpose. PyPI's
[internals](https://docs.pypi.org/trusted-publishers/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.

[^3]: The subject claim is the one most tutorials describe, and the one Warehouse does not read.
In [`github.py`](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/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.