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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A project-scoped API token in a CI secret | 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 | 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 | 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.
[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.
Related how-tos
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
-
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
-
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 -
The subject claim is the one most tutorials describe, and the one Warehouse does not read. In
github.py,subsits in__unchecked_claims__besideactor,run_id, andworkflow, while the matching is done onjob_workflow_ref, which carries the filename, and onenvironment, 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