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.
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.
twine5.x for the size/metadata pre-flight;pypa/gh-action-pypi-publishon GitHub ortwine uploadunder 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
-
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: {}" \; -
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'" -
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 -
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 GitLabrules: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. sdistmatters for source builds. Ship ansdistalongside 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.
Related
- Trusted publishing spatial wheels to PyPI — configuring OIDC so no API token is stored.
- Handling wheel size limits on PyPI for GDAL — shrinking a vendored wheel and requesting a limit increase.
- Testing and Validating Spatial Wheels — the gate every wheel must pass before it reaches this stage.
Further Reading
- PyPI trusted publishing documentation (
docs.pypi.org/trusted-publishers/).