gh-action-pypi-publish: shipping to PyPI without a token
The blessed :octocat: GitHub Action, for publishing your :package: distribution files to PyPI, the tokenless way: https://github.com/marketplace/actions/pypi-publish
At a glance
- What is it?
- The Python Packaging Authority's blessed GitHub Action uploads your built distributions to PyPI using trusted publishing over OIDC, so there is no long lived API token stored in your repository's secrets, and as of v1.14 it also generates Sigstore attestations for every artefact by default. The action is deliberately narrow: it publishes, it does not build, and it wants the build in a separate job.
- Who is it for?
- gh-action-pypi-publish fits a Python project publishing to PyPI from GitHub Actions that wants to remove its API token from the secrets store and wants provenance for what it uploads. It does not fit a team that needs a single workflow to build and publish, because the action is explicitly not a builder and the recommended shape is a separate job, and it does not fit a release pipeline that runs on Windows or macOS runners.
- Can I use it commercially?
- Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 3 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What trusted publishing removes from your secrets
This action allows you to upload your Python distribution packages in the dist/ directory to PyPI, and the project describes itself as the blessed GitHub Action for doing it the tokenless way.
The mechanism is PyPI's trusted publishing, which the README notes is sometimes referred to by its underlying technology, OpenID Connect, or OIDC for short. It allows authentication to PyPI without a manually configured API token or a username and password combination, and the only thing you have to have done beforehand is configure the publisher on PyPI.
That is a genuine improvement in the threat model rather than a convenience. A long lived token in a repository's secrets is a credential that can leak through a workflow, a pull request, a compromised dependency, or a mistake in a logging step, and it never expires. A trusted publishing exchange gives PyPI a short lived, scoped identity token derived from the workflow run, and PyPI checks that identity against the publisher you configured.
The action still supports API tokens and the less secure username and password path, and the README points at how to specify them, but the trusted publishing example is presented as the current best practice.
The licence is BSD-3-Clause, the last push was on 2026-10-15, and the recent releases are v1.14.2 from 2026-07-29, v1.14.1 from 2026-07-19 and v1.14.0 from 2026-04-07.
The workflow the PyPA guide asks you to copy
The example in the README is short, and it is the same shape as the guide on the Python Packaging website, which is the project's homepage link.
# .github/workflows/ci-cd.yml
jobs:
pypi-publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/<your-pypi-project-name>
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
# retrieve your distributions here
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1Three parts of that are load bearing. The permissions block grants id-token write, which the comment says is mandatory for trusted publishing, and the absence of a username or password is what selects the trusted publishing flow rather than the token one. The environment declaration, named pypi, is where you would put the environment protection rules, and the url is the link shown to a human who approves the release.
Two notes follow. Other indices that support trusted publishing work too, and TestPyPI is the obvious one, with the repository-url set to the TestPyPI legacy endpoint.
- name: Publish package distributions to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/And if you only want to publish on a tagged commit, the job gets a filter, so that a push to a branch does not publish.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')Building in one job and publishing in another is the security model
The README spends a lot of space on something that looks like a limitation, and it is in fact the point of the action.
First, the non-goals. This GitHub Action has nothing to do with building package distributions. Users are responsible for preparing dists for upload by putting them into the dist/ folder prior to running the action, and they are typically expected to do that in a separate job running before the one where they call the action, and with restricted privileges.
The advice to separate building from publishing is repeated as a pro tip, with the reason: it makes sure that any scripts maliciously injected into the build or test environment will not be able to elevate privileges while flying under the radar. The same tip adds that the id-token write permission should only be set in the job that does the publishing, not globally, for the same reason.
Then there is the platform-specific argument, which is about correctness rather than security, and the README gives three reasons to build the OS specific wheels in separate jobs. You can test exactly the same artefacts that are about to be uploaded, rather than a rebuild of them. You prevent parallel unsynchronised jobs from publishing only part of the dists asynchronously, which is what happens when some jobs fail and others succeed, leaving an incomplete release on PyPI. And you make the upload atomic, because when only part of the dists appear, installers will use that version for dependency resolution while the wheel for their runtime is not yet available, so some environments end up on sdists.
The implementation detail is that the built artefacts move between jobs with the upload-artifact and download-artifact actions, and the needs setting orders the build, test and publish stages. That is a slightly larger workflow than a single job, and it is the price of the correctness guarantee.
Attestations are now on by default, and they share the identity
The most recent behaviour change in the action is that generating and uploading digital attestations is on by default for all projects using trusted publishing.
The attestation objects are created using Sigstore for each distribution package, and they are signed with the identity provided by GitHub's OIDC token associated with the current workflow. The README makes the consequence explicit: both the trusted publishing authentication and the attestations are tied to the same identity.
That is the elegant part of the design. The upload and the proof of what was uploaded are signed by the same key, derived from the same workflow run, so there is no second trust decision for the consumer of the package to make, and no way for the two to disagree about who produced the artefact.
The limitations are stated too. Support for generating and uploading digital attestations is currently limited to trusted publishing flows using PyPI or TestPyPI, and generating and uploading them requires authentication with a trusted publisher. In other words, the token based path does not get attestations.
If you do not want them, the switch is one key, with attestations set to false under the with block.
with:
attestations: falseThe constraints: Linux jobs, no container, and an expected runner
There are three constraints in the README that are worth knowing before you write the workflow.
The action is docker-based, so it can only be used from within GNU/Linux based jobs in GitHub Actions workflows. The README says this is by design and is unlikely to change due to a number of considerations the project relies on. That is a hard limit, and it is the reason the example uses ubuntu-latest and the reason the expected environment is the GitHub-provided Ubuntu VM.
Running the action in a job that has a container set is not supported. The README is blunt about it: it might work for you, but you are on your own when it breaks. It links the failure to the earlier recommendation, and says that if you feel the need to use it, you are likely not following the recommendation of invoking the build automation in a separate job, which is considered a security issue, especially when using trusted publishing.
The supported runner set is described conservatively. The project runs a smoke test against ubuntu-latest in CI, and says any currently available numbered version should do, and that they will be considered supported for as long as GitHub itself supports them.
That last point is a sensible policy, because the action runs as a container that GitHub provides, and the moving part is GitHub's runner images rather than the action. The consequence for you is that a workflow which pins ubuntu-22.04 will break when GitHub retires it, and the fix is to track the current version rather than to pin an old one forever.
The sunset of master, and why you should pin
There is a section at the top of the README with a sunset notice, and it is the kind of thing that has already bitten people once.
The master branch version has been sunset. You are asked to change the GitHub Action version you use from master to release/v1 or use an exact tag, or opt in to using a full Git commit SHA and Dependabot.
The pro tip in the usage section says the same thing in more detail: instead of using branch pointers, like unstable/v1, pin versions of actions that you use to tagged versions or sha1 commit identifiers, which will make your workflows more secure and more reproducible, saving you from sudden and unpleasant surprises.
That advice generalises well past this action. A branch pointer means your release workflow changes behaviour whenever somebody pushes to that branch, and the person pushing may not be the person who reviewed the release, or the person who will debug it.
Note the branch naming in the repository itself, which shows the project's own convention. The default branch is unstable/v1, the stable line is release/v1, and the master branch that used to be the default is gone. The repository also keeps a per-release announcement discussion for each version, and the README asks you to leave feedback about specific action versions there.
Inside the action: a small image with four Python scripts
The repository is a GitHub Action, so its shape is defined by action.yml and a Dockerfile rather than by a Python package, and the contents are small and legible.
The Dockerfile is based on python:3.13-slim, with pip-tools constraints and a requirements/ directory holding the runtime prerequisites and the runtime requirements, and it sets the usual packaging environment variables. The interesting part is the set of files it copies into the image: twine-upload.sh as the entry point, print-hash.py, print-pkg-names.py, oidc-exchange.py and attestations.py.
Those names describe the whole pipeline. The OIDC exchange is the step that turns the workflow's identity token into a PyPI upload token. The attestations module creates and uploads the Sigstore attestations. The print-hash and print-pkg-names scripts produce the values the action reports, so you can see what was uploaded and with what hash.
The image is a Docker action rather than a JavaScript one, which is what forces the GNU/Linux requirement, and the entry point is the shell script rather than a Node process.
The rest of the repository is the tooling around it: a create-docker-action.py script that generates the action scaffolding, type stubs under _type_stubs/, a mypy configuration, a pip-tools configuration, a pre-commit configuration and a yamllint configuration, which is the shape of a project that takes its own quality gates seriously even though its product is a release helper.
Editorial conclusion
gh-action-pypi-publish fits a Python project publishing to PyPI from GitHub Actions that wants to remove its API token from the secrets store and wants provenance for what it uploads. It does not fit a team that needs a single workflow to build and publish, because the action is explicitly not a builder and the recommended shape is a separate job, and it does not fit a release pipeline that runs on Windows or macOS runners. Verify three things before you switch your pipeline over: that the publisher on the PyPI side is already configured for trusted publishing, since the action cannot do that for you, that the id-token write permission is scoped to the publishing job alone, because the README warns that a script injected into a build job could otherwise escalate, and that you pin the action to a tag or a commit SHA rather than a branch, since the master branch has already been sunset once and the project asks you not to repeat that.
Frequently asked questions
What does the pypa gh-action-pypi-publish action do?
It uploads your Python distribution packages from the dist/ directory to PyPI, and it supports PyPI's trusted publishing implementation, which allows authentication without a manually configured API token or username and password combination. Your publisher must already be configured on PyPI, and the job needs the id-token write permission and no explicit credentials.
How do I publish to TestPyPI with the action?
Set the repository-url input to the TestPyPI legacy endpoint, https://test.pypi.org/legacy/, and use the action in the same way. The README reminds you to update the environment name to testpypi or similar, since the environment is where you would put your approval rules.
Why should I build in a separate job from the publish step?
The README gives three reasons. You can test exactly the same artefacts that are about to be uploaded, you prevent parallel unsynchronised jobs from publishing only part of the dists and leaving an incomplete release, and you make the upload atomic so installers do not resolve a version whose wheel for their runtime is not yet on PyPI. It is also the security reason, since a script injected into a build job cannot elevate privileges if the publishing job is the only one holding id-token write.
Are digital attestations generated by default?
Yes, for all projects using trusted publishing, and they are created with Sigstore for each distribution package and signed with the identity from the workflow's OIDC token, so the upload and the attestation share one identity. The feature is limited to trusted publishing flows on PyPI or TestPyPI, and you can turn it off by setting attestations to false in the with block.
Which runner can the action run on?
Only GNU/Linux based jobs, because the action is docker-based, and that is by design. The expected environment is the GitHub-provided Ubuntu VM, and the project smoke tests against ubuntu-latest. Running the action in a job that sets a container is not supported, and running it on a Windows or macOS runner is out of scope entirely.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/pypa-gh-action-pypi-publish)Was this useful?