CI Matrix Recipes for Spatial Wheels

A geospatial wheel matrix has more axes than a pure-Python one — interpreter ABI, operating system, CPU architecture, and libc all multiply — and the same conceptual grid must be expressed twice if you support both GitHub Actions and GitLab CI. This guide sits under the Modern Python Build Tooling & Wheel Configuration reference and gives platform teams side-by-side, copy-pasteable matrix definitions for building GDAL/PROJ/GEOS wheels, plus the caching keys and fan-in step that turn a grid of jobs into one publishable set of artifacts. It targets cibuildwheel 3.0+, GitHub Actions and GitLab CI, GDAL 3.8.x, and the abi3 build that collapses the interpreter axis established in C-API vs CPython ABI compatibility.

The spatial wheel build matrix fanning out and folding back in One build trigger fans out into a matrix of jobs across operating system and architecture: Linux x86_64, Linux aarch64, macOS arm64, and Windows amd64. Each job builds and repairs an abi3 wheel. The jobs fold back into a single collect step that gathers all wheels into one artifact set ready for publishing. push / tag one trigger linux · x86_64 linux · aarch64 macos · arm64 windows · amd64 collect wheels one artifact set

Prerequisites & Environment

  • A wheel that builds under cibuildwheel locally, configured per configuring cibuildwheel in pyproject.toml for GDAL — the CI file should be a thin driver around it, not a second source of build logic.
  • Runner access for each target: GitHub-hosted ubuntu-latest, macos-14, windows-latest; GitLab needs a Docker executor for Linux and, for macOS/Windows, shell runners.
  • A cache backend: GitHub actions/cache, GitLab cache: keyed on the native-dependency lock.
# The one command both CI systems ultimately run
pipx run cibuildwheel --output-dir wheelhouse

Core Configuration

The invariant across both systems is: define the OS/arch grid, run the identical cibuildwheel step, key the cache on the native versions, and upload per-job artifacts that a final job collects. Only the YAML dialect differs.

Concern GitHub Actions GitLab CI
Grid definition strategy.matrix.include parallel:matrix
Per-job artifact actions/upload-artifact artifacts:paths
Cache actions/cache keyed on lock hash cache:key:files
Fan-in a needs: job with download-artifact a needs: job in a later stage
aarch64 QEMU or native ARM runner Docker --platform or ARM runner

The two full recipes are GitHub Actions matrix for GDAL wheel builds and GitLab CI pipeline for spatial wheels.

Step-by-Step Implementation

  1. Collapse the interpreter axis first. Build one abi3 wheel per platform (build = "cp39-*"), not one per Python version — this alone cuts the matrix by 4–5×.

  2. Enumerate only the OS/arch cells you ship, and mark fail-fast: false (GitHub) or allow_failure selectively so one arch failing does not cancel the rest.

  3. Key the cache on native versions, not Python. The expensive artifact is compiled GDAL, so the key is gdal3.8-proj9.3-${hashFiles('native.lock')} — the strategy in async build execution and cache strategies.

  4. Fan in. A final job gathers every per-job wheel into one directory and runs a single twine check before publishing.

Verification

# 1. Enumerate what the matrix will build — no surprises
pipx run cibuildwheel --print-build-identifiers
# expected: one cp39-abi3 identifier per OS/arch cell you declared
# 2. After the fan-in job, the collected set covers every platform
ls dist/ | sed -E 's/.*-(manylinux|musllinux|macosx|win).*/\1/' | sort -u
# expected: manylinux, musllinux, macosx, win — every promised platform present
# 3. Metadata is publishable
python -m twine check dist/*
# expected: PASSED for every wheel

Optimization & Edge Cases

  • Emulated aarch64 dominates wall-clock. A QEMU aarch64 cell can be 10× the native cells; either use a native ARM runner or cross-compile per building aarch64 GDAL wheels without QEMU.
  • Prune before you optimize. Drop cells nobody installs (32-bit, PyPy) rather than speeding them up. Read your download stats first.
  • Cache write only on the default branch so feature branches cannot poison the shared native-build cache.

Troubleshooting

One arch fails and cancels the whole matrix. fail-fast defaults to true on GitHub; set it false so a musllinux break does not discard finished manylinux wheels.

No space left on device on the runner. GDAL’s native build plus Docker layers fills the default runner disk. Prune Docker between steps or use a larger runner; this bites the emulated Linux cells first.

Artifacts collide on upload. Two jobs uploading wheelhouse/*.whl under the same artifact name overwrite each other. Name artifacts per cell (wheels-${os}-${arch}) and merge in the fan-in job.

Further Reading

  • cibuildwheel CI examples (cibuildwheel.readthedocs.io/en/stable/setup/).