Publishing and Distributing Spatial Wheels

Getting a validated GDAL wheel onto PyPI is its own discipline: the release must be authenticated without long-lived API tokens, the multi-platform artifact set must upload atomically, and a vendored PROJ can push a wheel past PyPI’s per-file size limit. This guide sits under the Modern Python Build Tooling & Wheel Configuration reference and covers the publishing half of the pipeline — trusted publishing with OpenID Connect, staged releases through TestPyPI, and the size constraints that hit geospatial packages hardest. It targets twine 5.x, PyPI trusted publishing (OIDC), GitHub Actions / GitLab CI release jobs, and the validated wheels produced by Testing and Validating Spatial Wheels.

The release flow from collected wheels through TestPyPI to PyPI via OIDC A collected multi-platform wheel set is validated, then uploaded to TestPyPI for a staging install check, then published to PyPI. The release job authenticates with a short-lived OpenID Connect token from the CI provider rather than a stored API token, and a size guard rejects any wheel exceeding the per-file limit before upload. collected dist/ all platforms TestPyPI staging install PyPI public release OIDC token no stored secret size guard reject oversize

Prerequisites & Environment

  • A validated dist/ from the build matrix — every wheel already past the gates in Testing and Validating Spatial Wheels.
  • A PyPI (and TestPyPI) project with a configured trusted publisher bound to your CI workflow — no API token stored anywhere.
  • twine 5.x for the size/metadata pre-flight; pypa/gh-action-pypi-publish on GitHub or twine upload under an OIDC token on GitLab.
# Pre-flight the whole set before any upload
python -m twine check dist/*

Core Configuration

Three concerns define a safe geospatial release:

Concern Mechanism Why it matters for spatial wheels
Authentication Trusted publishing (OIDC) No long-lived token to leak across many CI runners
Staging TestPyPI dry run A bad multi-GB upload is expensive to discover on real PyPI
Size Per-file limit guard Vendored GDAL/PROJ routinely approach the 100 MB cap

Trusted publishing replaces a stored PYPI_API_TOKEN with a short-lived OIDC token the CI provider mints per run, detailed in trusted publishing spatial wheels to PyPI. The size constraint is acute here because a self-contained GDAL wheel bundles PROJ, GEOS, libtiff, and their data — the mitigation lives in handling wheel size limits on PyPI for GDAL.

Step-by-Step Implementation

  1. Pre-flight metadata and size:

    python -m twine check dist/*.whl
    # Fail early if any wheel exceeds the per-file limit (100 MB default)
    find dist -name '*.whl' -size +100M -exec echo "OVERSIZE: {}" \;
    
  2. Stage to TestPyPI and install from it in a clean container:

    twine upload --repository testpypi dist/*
    docker run --rm python:3.12-slim bash -c \
      "pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ mypkg && python -c 'from osgeo import gdal'"
    
  3. Publish to PyPI from a tag-triggered release job authenticated via OIDC (no token):

    # GitHub: the release job
    permissions: { id-token: write }        # enables OIDC
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }
      - uses: pypa/gh-action-pypi-publish@release/v1   # uses the trusted publisher
    
  4. Verify the public install once propagation completes (see Verification).

Verification

# 1. No wheel exceeds the size cap before upload
find dist -name '*.whl' -size +100M | grep . && echo "BLOCK: oversize wheel" || echo "size ok"
# expected: size ok
# 2. TestPyPI install of the exact release works end to end
docker run --rm python:3.12-slim bash -c \
  "pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ mypkg==1.0 && python -c 'import pyproj; pyproj.Transformer.from_crs(4326,3857)'"
# expected: clean import and transform
# 3. After PyPI release, a plain install resolves every platform
pip download --only-binary=:all: --no-deps mypkg==1.0 -d /tmp/dl && ls /tmp/dl
# expected: wheels for each platform you published

Optimization & Edge Cases

  • Publish on tags only. Gate the release job on if: startsWith(github.ref, 'refs/tags/') (or a GitLab rules: tag condition) so every commit does not attempt an upload.
  • Uploads are not atomic across files. If the job dies mid-set, some wheels land and some do not; re-running skips existing files with --skip-existing, so design the job to be safely retryable.
  • sdist matters for source builds. Ship an sdist alongside the wheels so users on unsupported platforms can still build from source.

Troubleshooting

HTTPError: 403 Forbidden on upload. The trusted publisher is not configured or the workflow identity does not match. Confirm the publisher’s owner/repo/workflow fields exactly match the running job, per the child guide.

400 File already exists. PyPI never allows overwriting a released file. Bump the version; use --skip-existing only for retrying a partially-failed same-version upload.

400 File too large. A vendored wheel exceeded the limit. Request a limit increase or shrink the wheel per handling wheel size limits on PyPI for GDAL.

Further Reading

  • PyPI trusted publishing documentation (docs.pypi.org/trusted-publishers/).