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.

The OIDC trusted-publishing handshake between CI and PyPI The CI job requests a short-lived OpenID Connect identity token from its provider, presents it to PyPI, and PyPI verifies the token's claims against the registered trusted publisher's owner, repository, and workflow. On a match PyPI issues a scoped upload token for that one release; no long-lived secret is ever stored. CI release job id-token: write OIDC token owner · repo · workflow PyPI verifies scoped upload ✓ no PYPI_API_TOKEN stored anywhere

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.

The OpenID Connect exchange between the CI provider, PyPI and the publish step Four steps. The workflow requests a token from its CI provider, which issues a short-lived assertion carrying claims about the repository, workflow file, environment and git reference. The publish step presents that assertion to PyPI. PyPI validates the signature against the provider's public keys and compares each claim to the trusted publisher registered for the project. On a match it issues a scoped, short-lived upload token for that project only. release job id-token: write CI provider signs a short-lived assertion PyPI verifies + issues 1 · request 2 · present assertion claims PyPI compares against the registered publisher repository owner and name · workflow file path environment name · git ref · issuer every one must match, or the upload is refused before any bytes move 3 · on a match, a scoped upload token valid for this project and this run only — 4 · publish nothing long-lived exists at any point, so there is no secret to rotate, leak or scope incorrectly

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.

A combined build-and-publish job compared with a separated one On the left, one job compiles GDAL from source, runs build scripts, assembles wheels and publishes, so all of that code runs with upload rights. On the right, a build job with no special permissions produces artifacts, a validation job checks them, and a separate publish job with upload rights only downloads artifacts and uploads them, running about six lines of reviewable code. combined — everything is privileged compile GDAL from upstream source run vendored build scripts assemble and repair wheels run the test suite publish id-token: write covers all of it separated — one small privileged job build matrix — no permissions uploads artifacts validate — no permissions clean-container import publish — id-token: write download artifacts upload — about six lines

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.