Build your distributions in one job, save them as artifacts, and upload them from a second job that does nothing else. The publish job holds the identity PyPI trusts, so keeping build steps out of it limits what a compromised build tool or dependency can do.
What the packaging guide says
The Python Packaging User Guide's GitHub Actions walkthrough is explicit: keep the build job separate from the publishing jobs. It adds that building distributions in a publishing job is unsupported, and that publishing jobs should only download the already built artifacts and upload them.
The same guide uses trusted publishing, which exchanges short lived identity tokens instead of long lived API tokens. PyPI's docs say the minted token is valid for only 15 minutes, enough for CI to upload.
The idea in one picture
| Build job | Publish job | |
|---|---|---|
| Runs untrusted or complex code | Yes, build tools and build dependencies | No |
| Needs the OIDC permission | No | Yes, id-token write |
| Output | Artifacts stored by the workflow | Upload to PyPI |
| Can be reviewed as | A normal CI job | A tiny, auditable job |
Build jobs run your backend, your build requirements and often a plugin or two. That is a lot of third party code. If any of it is hostile, you do not want it executing in the same job that can request a publishing token.
A minimal shape
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- run: python3 -m pip install build --user
- run: python3 -m build
- uses: actions/upload-artifact@v5
with:
name: python-package-distributions
path: dist/
publish-to-pypi:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
environment:
name: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v6
with:
name: python-package-distributions
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
This mirrors the structure in the guide. Check the guide for current action versions before you copy, as they change.
Details that matter
- Environment approval. The guide says that for security reasons you must require manual approval on each run for the pypi environment. A human sees the tag and the artifacts before the token is minted.
- Tag only publishing. The condition on tag refs stops branch pushes from reaching PyPI.
- No stored credentials in checkout. The guide's example sets
persist-credentialsto false. - Separate TestPyPI job. The guide configures separate publishers and environments for TestPyPI and PyPI.
- Attestations. With the official PyPA publish action, PyPI's docs say attestations are generated and uploaded by default, with no extra configuration.
For setup steps on the PyPI side, see our trusted publisher guide.
Try it yourself
Click through it at your own pace, or open it full screen.
Step through the gate and notice that checks run before anything is allowed through. A release job benefits from the same ordering.
Safeguard supports policies and gates for pull requests and pipelines. You can also try the free scan.
Quick check
Can the publish job also run the build?
The packaging guide says building in a publishing job is unsupported. Publishing jobs should download built artifacts and upload them.
How long does a trusted publishing token last?
PyPI's docs say 15 minutes from creation, enough for the upload.
Why require approval on the environment?
It puts a human check between a pushed tag and the upload, so a surprise run cannot publish silently.
Sources
- Publishing with GitHub Actions, Python Packaging User Guide
- Publishing to PyPI with a Trusted Publisher, PyPI Docs
- Adding a Trusted Publisher to an existing PyPI project, PyPI Docs
- Producing attestations, PyPI Docs
Last reviewed: 19 October 2026.