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 cluster 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.

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.