Trusted publishing spatial wheels to PyPI
This page answers one question: how do you publish your GDAL/PROJ wheels to PyPI from CI without storing a long-lived PYPI_API_TOKEN — using trusted publishing, where the CI provider mints a short-lived OpenID Connect token that PyPI verifies against a publisher you registered? It sits inside the Publishing and Distributing Spatial Wheels section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the PyPI publisher setup, the GitHub and GitLab release jobs, and the identity-mismatch failures.
Context & Root Cause
The old model stored a PyPI API token as a CI secret and passed it to twine. For a project that builds across many runners and forks, that token is a standing liability: it is long-lived, broadly scoped, and copied into every environment that can read the secret. Trusted publishing removes it. The CI provider (GitHub Actions, GitLab CI) can mint an OpenID Connect identity token that cryptographically asserts which workflow, in which repository, is running. You register a matching trusted publisher on PyPI once; at release time PyPI verifies the OIDC token’s claims against that registration and issues a short-lived, project-scoped upload token for exactly that run.
Nothing about the wheels themselves changes — this is purely how the upload authenticates — but it matters most for packages like geospatial ones that publish a large multi-platform matrix from CI, where a leaked token could poison many artifacts. The trade-off is that the publisher registration must match the workflow identity exactly; a mismatch is the entire failure surface. This is the authentication mechanism the parent Publishing and Distributing Spatial Wheels guide assumes.
Solution / Fix
This targets PyPI trusted publishing, pypa/gh-action-pypi-publish@release/v1 on GitHub, and twine under an OIDC token on GitLab.
1. Register the trusted publisher on PyPI
In the PyPI project’s Publishing settings, add a publisher matching your CI exactly:
Owner: myorg
Repository: mypkg
Workflow name: release.yml # the filename, not the display name
Environment: pypi # optional, but recommended as a gate
2. The GitHub release job
# .github/workflows/release.yml
on:
release: { types: [published] }
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi # must match the publisher registration
permissions:
id-token: write # REQUIRED — enables OIDC minting
steps:
- uses: actions/download-artifact@v4
with: { name: dist, path: dist }
- uses: pypa/gh-action-pypi-publish@release/v1
# No 'password:' — the action exchanges the OIDC token automatically
3. The GitLab release job
publish:
stage: release
image: python:3.12-slim
id_tokens:
PYPI_ID_TOKEN: { aud: pypi } # GitLab mints the OIDC token
rules:
- if: $CI_COMMIT_TAG
script:
- pip install twine
- twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
# twine reads PYPI_ID_TOKEN and exchanges it for a scoped upload token
Verification
# 1. Confirm the workflow requests the OIDC permission (GitHub)
grep -A2 'permissions:' .github/workflows/release.yml | grep 'id-token: write'
# expected: the line is present — without it, minting fails
# 2. Dry-run against TestPyPI's trusted publisher first
# (register a TestPyPI publisher too; run the same job against test.pypi.org)
twine upload --repository testpypi dist/* # under the TestPyPI OIDC token
# expected: upload succeeds with no stored token
# 3. After release, the file is present and no token was used
pip index versions mypkg 2>/dev/null | head -1
# expected: the just-published version listed
A present id-token: write, a token-free TestPyPI upload, and the published version confirm trusted publishing works end to end.
The Exchange, Step by Step
Trusted publishing is a three-party exchange, and knowing which party checks what makes every configuration error easy to place.
Nearly every “403 Forbidden” during setup is one of the claims in the middle box disagreeing with the publisher you registered. The workflow file path is the most common culprit, because it is compared literally: a publisher registered for release.yml will refuse a token minted by publish.yml, and moving a workflow into a subdirectory changes the claim. The environment name is the second, because a publisher bound to an environment refuses a job that does not declare it.
Making the Privileged Job Small
The second half of trusted publishing is organisational rather than cryptographic: the job holding the credential should be as small as it is possible to make it. A release job that also builds is a job in which every compiler, every downloaded source tarball and every build script runs with the ability to publish under your project name.
The separation costs one extra job definition and an artifact hand-off. What it buys is that the code running with publish rights is short enough for a reviewer to read in full, and that a compromised build dependency — a scenario the geospatial stack is unusually exposed to, given how much upstream C it compiles — cannot reach the credential at all.
Frequently Asked Questions
Does trusted publishing work for the first release of a project?
Yes, through a pending publisher: you register the publisher against a project name that does not exist yet, and the first successful upload creates it. That closes the gap where the very first release would otherwise need a manually created token, which is exactly the token people forget to revoke afterwards.
Can I keep an API token as a fallback?
You can, and it defeats most of the benefit — a stored token remains a stealable credential whether or not it is normally used. If you need a break-glass path, prefer a token created at the moment it is needed and revoked immediately afterwards, so nothing durable sits in the repository settings.
Does this work on GitLab, or only GitHub?
It works with any provider PyPI supports as an issuer, which includes GitLab and several others; the mechanism is identical and only the claim names and the way the job requests a token differ. The publisher you register names the issuer, so a project can have publishers for more than one CI system — useful during a migration, though publishing should still happen from one of them.
What happens if someone forks the repository and runs the workflow?
The assertion carries the repository that minted it, and a fork is a different repository, so PyPI refuses it. This is the property that makes trusted publishing meaningfully safer than a secret: a fork cannot obtain a usable credential even if it can run the workflow file.
What has to change if the repository moves or is renamed?
The registered publisher, because the claims are compared literally. A repository that moves between organisations, or a workflow file that is renamed or relocated, mints a token whose claims no longer match, and the upload is refused with a permission error that says nothing about the cause. Updating the publisher is a one-minute change once you know to look there.
Can several workflows publish the same project?
Yes — register a publisher per workflow. It is worth keeping the number small, since each one is an independent path to publishing under your name, and worth removing publishers for workflows that no longer exist rather than leaving them registered indefinitely.
Does this protect against a compromised build dependency?
Partly, and only if the release job is small. The credential is short-lived and scoped, but a build step running in the same job can still use it while it exists. Separating build from publish, so the privileged job only downloads artifacts and uploads them, is what turns that partial protection into a meaningful one.
How should the publisher be documented for the team?
Record the exact owner, repository, workflow filename and environment in the repository, next to the release instructions. Every one of those is compared literally at upload time, and the person debugging a refused release six months from now is unlikely to be the person who registered it.
Pitfalls & Alternatives
Workflow filename mismatch. The publisher registration keys on the workflow file name (release.yml), not its name: field. Renaming the file breaks publishing with a 403. Keep them in sync.
Missing id-token: write. Without the permission, the provider will not mint an OIDC token and the action falls back to looking for a password it does not have. Grant the permission on the publishing job only, not repository-wide.
Reusing one publisher across forks. The OIDC claims include the repository; a fork’s workflow has different claims and is correctly rejected. Do not try to loosen the match — that is the security property working. If a runner cannot use OIDC at all (some self-hosted setups), a scoped API token remains the fallback, stored as a masked secret and rotated regularly.
Related
- Publishing and Distributing Spatial Wheels — the parent guide covering staging, size limits, and the full release flow.
- Handling wheel size limits on PyPI for GDAL — the other constraint a spatial release must clear.
- GitHub Actions matrix for GDAL wheel builds — the build job that produces the
dist/this release consumes.