GitHub Actions matrix for GDAL wheel builds
This page answers one question: what does a complete, production GitHub Actions workflow look like for building, caching, and collecting GDAL/PROJ wheels across Linux, macOS, and Windows — including the native-dependency cache key, the aarch64 cell, and the fan-in job that assembles one publishable artifact set? It sits inside the CI Matrix Recipes for Spatial Wheels cluster of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the full YAML plus the three things people get wrong.
Context & Root Cause
GitHub Actions expresses a build matrix through strategy.matrix, spawning one job per combination. For spatial wheels the combinations you actually want are OS-and-architecture cells, because the abi3 build already collapses the interpreter axis. The reason a naive workflow underperforms is twofold: it rebuilds GDAL from source on every run (no cache) and it either cancels the whole matrix when one cell fails (fail-fast) or scatters wheels across artifacts that never get reassembled. The recipe below fixes all three — a native-dependency cache keyed on the GDAL/PROJ versions, fail-fast: false, and a dedicated collect job.
The workflow is deliberately thin: the actual build logic lives in pyproject.toml’s [tool.cibuildwheel] table, so the YAML only defines the grid, the cache, and the artifact flow. That separation is what keeps the same build reproducible locally, as the parent CI Matrix Recipes for Spatial Wheels guide argues.
Solution / Fix
This targets cibuildwheel 3.0+, actions/cache@v4, actions/upload-artifact@v4, and GDAL 3.8.x.
1. The build job
name: wheels
on: [push, workflow_dispatch]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # one arch failing must not discard the others
matrix:
include:
- { os: ubuntu-latest, arch: x86_64, cibw: x86_64 }
- { os: ubuntu-latest, arch: aarch64, cibw: aarch64 }
- { os: macos-14, arch: arm64, cibw: arm64 }
- { os: windows-latest, arch: amd64, cibw: AMD64 }
steps:
- uses: actions/checkout@v4
with: { submodules: recursive }
- name: Cache compiled GDAL/PROJ
uses: actions/cache@v4
with:
path: ~/.cache/native-deps
# Key on the NATIVE versions, never the Python version.
key: native-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('ci/native.lock') }}
- name: Set up QEMU
if: matrix.arch == 'aarch64'
uses: docker/setup-qemu-action@v3
- name: Build & repair wheels
uses: pypa/cibuildwheel@v3.0
env:
CIBW_ARCHS: ${{ matrix.cibw }}
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.arch }} # unique per cell
path: wheelhouse/*.whl
2. The collect job
collect:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { path: dist, pattern: wheels-*, merge-multiple: true }
- run: pipx run twine check dist/*.whl
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
merge-multiple: true flattens every per-cell artifact into one dist/, the input a publishing job (see trusted publishing spatial wheels to PyPI) consumes.
Verification
# 1. Lint the workflow locally before pushing
pipx run yamllint .github/workflows/wheels.yml # expected: no errors
# 2. Reproduce a matrix cell on your machine
CIBW_ARCHS=x86_64 pipx run cibuildwheel --platform linux
ls wheelhouse/ # expected: cp39-abi3 manylinux + musllinux wheels
# 3. After the run, the collect artifact spans every platform
unzip -l dist.zip | grep -oE '(manylinux|musllinux|macosx|win)' | sort -u
# expected: all four platform families present
Pitfalls & Alternatives
Keying the cache on the Python version. The wheel is abi3, so the interpreter never changes — but GDAL does. A cache key that includes python-3.12 and omits the GDAL version rebuilds the native stack whenever nothing relevant changed, and reuses a stale build when GDAL bumps. Key on native.lock.
Reusing one artifact name across cells. Two cells uploading wheels overwrite each other and the collect job silently ships a partial set. Name artifacts per cell and merge on download.
Leaving fail-fast at its default. A single musllinux failure cancels the still-running manylinux and macOS cells, wasting the whole run. Set fail-fast: false for release matrices. For the GitLab equivalent of every step here, see GitLab CI pipeline for spatial wheels.
Related
- CI Matrix Recipes for Spatial Wheels — the parent guide comparing this to GitLab and covering the caching rationale.
- GitLab CI pipeline for spatial wheels — the same matrix in
.gitlab-ci.yml. - Trusted publishing spatial wheels to PyPI — the release job that consumes the collected
dist/.