Step 4 — PyPI Trusted Publishing
Publish to PyPI without ever handling an API token
There are two ways to publish a Python package to PyPI:
- The old way. Create an API token on PyPI, save it in your GitHub Secrets, and hope you never leak it. If you rotate it, update the secret. If a contributor forks your repo and their fork accidentally triggers the release workflow, they can publish under your name.
- The new way — OIDC Trusted Publishing. GitHub Actions authenticates directly to PyPI using signed OpenID Connect tokens. No secret is stored anywhere. PyPI knows to trust one specific workflow file in one specific repo. If the workflow moves, publishing breaks (safely). If the repo is forked, the fork cannot publish.
Use the new way. It’s the only sensible option in 2026.
The one-time PyPI setup (about 10 minutes)
You need a pyproject.toml that builds cleanly (Step 3). Verify python -m build produces a wheel + sdist without errors.
1. Create a PyPI account
Sign up at https://pypi.org/account/register/. Enable two-factor authentication (mandatory for publishing since 2024).
2. Reserve your package name — early
Go to https://pypi.org/manage/account/publishing/ and click “Add a new pending publisher”. Fill in:
- PyPI Project Name:
yourthing(the name you’ll use inpip install) - Owner: your GitHub username or organization (e.g.,
buckai-observatory) - Repository name:
yourthing - Workflow name:
publish.yml(the filename we’re about to create) - Environment name:
pypi(a GitHub Environment name — created in step 3)
Click Add. Your name is now reserved on PyPI, tied specifically to that repo + workflow + environment combination. Nobody else can register it.
PyPI does not enforce “prior art” — someone can register your package name before you do. This has happened to serious research projects. Reserve the name the moment you know what you want to call it, even before your first release.
3. Create the GitHub Environment
In your GitHub repository, go to Settings → Environments → New environment. Name it pypi. Under protection rules, tick “Required reviewers” and add yourself. This means every publish requires you to click “Approve” — a good backstop against accidental releases.
4. Add the publish workflow
Create .github/workflows/publish.yml:
name: Publish to PyPI
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # setuptools_scm needs full history
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build tooling
run: pip install build
- name: Build wheel + sdist
run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
name: Publish to PyPI
needs: [build]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/yourthing
permissions:
id-token: write # <-- this is the OIDC magic
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1The critical line is id-token: write — that’s what tells GitHub Actions to mint an OIDC token during this job. pypa/gh-action-pypi-publish reads that token, sends it to PyPI, and PyPI verifies against the pending-publisher record you set up in step 2.
No secrets involved. Nothing to leak.
Test-publish first (TestPyPI)
Before you push to real PyPI, do a dry run on TestPyPI — a full clone of PyPI where uploads are throwaway and don’t affect the real index.
Add a second pending publisher on TestPyPI at https://test.pypi.org/manage/account/publishing/ pointing at the same repo but a different workflow (e.g., publish-test.yml). Copy the publish workflow to publish-test.yml, add repository-url: https://test.pypi.org/legacy/ to the publish step:
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/Publish there first. Verify:
pip install --index-url https://test.pypi.org/simple/ yourthing
python -c "import yourthing; print(yourthing.__version__)"If TestPyPI works, real PyPI will work. You can delete the publish-test.yml after your first successful release, or keep it as a rehearsal step for major versions.
The release flow, end to end
Once everything’s wired up, every future release is:
# 1. Update CHANGELOG.md with what's new
# 2. Commit + push to main
git add CHANGELOG.md
git commit -m "release: 0.2.0"
git push origin main
# 3. Tag the release
git tag v0.2.0
git push origin v0.2.0Then on GitHub, Draft a new release off the v0.2.0 tag with release notes. When you click Publish release:
- GitHub Actions triggers
publish.yml. - The
buildjob builds a wheel + sdist. - The
publishjob waits for your approval on thepypienvironment. - You click approve.
- PyPI receives the wheel + sdist over OIDC and publishes.
pip install yourthing==0.2.0starts working within a minute or two.
Simultaneously, Zenodo picks up the GitHub Release and archives the tag (Step 2). One tag, two artifacts (PyPI + Zenodo), one DOI update.
Common issues
“Trusted Publishing rejected the token.” — 99% of the time this is a mismatch between the pending publisher record and reality. Check that your repo owner, repo name, workflow filename, and environment name all match exactly what you entered at https://pypi.org/manage/account/publishing/.
“setuptools_scm produced a 0.0.dev...+dirty version.” — Your build workflow forgot fetch-depth: 0 on the checkout step. Full git history is required for setuptools_scm to see the tag.
“The wheel installs but import yourthing fails.” — Your MANIFEST.in or [tool.setuptools.packages.find] is misconfigured. Inspect the wheel contents: unzip -l dist/*.whl | head.
“I want to yank a bad release.” — Log in to PyPI, go to your project → Manage → Releases, and click “Yank release” on the bad version. Yanked versions become invisible to pip install yourthing but remain accessible for pinned versions (pip install yourthing==0.1.5 still works). Yanking is safer than deleting.
Checklist
Once your package is public on PyPI, you’ll want tests running on every push so a broken commit doesn’t ship — on to Step 5 — Tests + CI.