Skip to content

Publish to PyPI with a trusted publisher instead of an API token - #39

Merged
huangziwei merged 1 commit into
masterfrom
release-trusted-publishing
Aug 18, 2026
Merged

Publish to PyPI with a trusted publisher instead of an API token#39
huangziwei merged 1 commit into
masterfrom
release-trusted-publishing

Conversation

@huangziwei

@huangziwei huangziwei commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Step 3 of the trusted-publishing switch — the half that lives in the repo. The
PyPI side is yours.

Do not merge before configuring PyPI

The workflow is inert until a release is published, and at that point it needs a
matching publisher to already exist. If a release goes out first, it builds and
then fails to upload. At
https://pypi.org/manage/project/rfest/settings/publishing/, add a GitHub
publisher with:

field value
Owner berenslab
Repository name RFEst
Workflow name python-publish.yml
Environment name pypi

"Workflow name" is the filename, not the name: inside the file. Nothing needs
doing on the GitHub side: referencing an environment in a workflow creates it.
Repository settings only come into it if you want protection rules on that
environment, such as requiring a reviewer before a publish can proceed.

The change

user: __token__ / password: ${{ secrets.PYPI_API_TOKEN }} are gone. The job
mints a short-lived OIDC token instead, which PyPI exchanges for upload rights
to this project alone. Nothing is stored, so there is nothing to leak or
rotate, and a compromised repo secret stops being a way to publish rfest.

Building and publishing are now two jobs, matching the GitHub starter
workflow. release-build checks out and builds, and uploads dist/ as an
artifact; pypi-publish downloads that artifact and uploads it. The point of
the split is that the job holding id-token: write — the one that can publish —
checks nothing out and runs none of this repository's code. It needs no
contents permission at all.

The action moves from v1.4.2 to v1.14.2. The SHA pinned in the tree is
v1.4.2, which predates trusted publishing by about four years, so adding
id-token: write to it would have achieved nothing. The new pin dc37677… is
the commit behind the v1.14.2 annotated tag; the tag object's own SHA does
not resolve in uses:. actions/setup-python also goes v3 → v5.

A dependabot config comes with it. Pinning to a SHA is the safer way to
depend on a third-party action, but it only works if something bumps it, and
nothing here did — that is exactly how the publish action ended up four years
behind the authentication method it needed. Monthly, github-actions ecosystem,
which also covers checkout, setup-python, setup-uv and the artifact
actions across both workflows.

The starter workflow uses @release/v1, a moving branch, and would sidestep the
staleness problem differently. Pinning is kept because that is what the file
already did and because this job can publish; dependabot is the part that was
missing.

A side effect worth having: from v1.11 the action publishes PEP 740 digital
attestations by default under trusted publishing, so releases become verifiably
built by this workflow.

After the first successful release

Revoke the old credential in both places — the PYPI_API_TOKEN secret in repo
settings, and the token itself at https://pypi.org/manage/account/token/.
Deleting the secret alone leaves a working token in existence.

Testing

There is no dry run for trusted publishing, and PyPI will not accept a
re-upload of an existing version, so the first real proof is the 2.2.0 release
in #40. If you would rather rehearse, add a second publisher on TestPyPI and
point a throwaway workflow at repository-url: https://test.pypi.org/legacy/.

The Tests workflow runs on this PR and should be green; it says nothing about
these files, which only trigger on release.

@huangziwei huangziwei mentioned this pull request Aug 18, 2026
The release job authenticated with `secrets.PYPI_API_TOKEN`, a long-lived
credential with upload rights to the project. It now mints a short-lived OIDC
token that PyPI exchanges for those rights, so there is nothing stored to leak
or rotate.

Building and publishing are split into two jobs, following the GitHub starter
workflow, so that the job holding `id-token: write` checks nothing out and runs
none of the repository's own code: it downloads an artifact and uploads it.

pypa/gh-action-pypi-publish goes from v1.4.2 to v1.14.2. The pinned version
predates trusted publishing by four years and would ignore the OIDC token.
actions/setup-python goes from v3 to v5.

Adds a dependabot config for github-actions. Pinning to a SHA is the safer way
to depend on a third-party action, but only if something bumps it, and nothing
did: that is how the publish action came to predate the authentication the
workflow now needs.

Inert until the matching publisher exists on PyPI. Until then a release would
build and then fail to upload.
@huangziwei
huangziwei force-pushed the release-trusted-publishing branch from fdbb6d2 to bca5504 Compare August 18, 2026 10:06
@huangziwei
huangziwei merged commit fa18170 into master Aug 18, 2026
5 checks passed
@huangziwei
huangziwei deleted the release-trusted-publishing branch August 18, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant