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 section 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
The Shape of the Workflow
A spatial wheel workflow has four jobs, and the dependency edges between them are what make it safe to run on every tag without producing half-published releases.
Two edges in that graph do the real work. The needs edge from validate to every build cell is what prevents a partial release: if the aarch64 cell fails, validate never runs and publish never runs, so users never see a version that exists for three platforms out of four. The tag condition on publish is what keeps the privileged job from running on ordinary pushes.
Cache Keys That Actually Hit
The single largest difference between a fifteen-minute workflow and a ninety-second one is whether the native build cache hits, and cache misses in spatial builds are almost always caused by a key that includes something volatile.
The restore-keys line is what turns the cache from all-or-nothing into something that degrades gracefully. When a PROJ patch version moves, the exact key misses but the prefix matches, so the job restores the previous build tree and ccache reuses most of the object files — a rebuild measured in seconds rather than a full compile.
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.
Frequently Asked Questions
Should the workflow build wheels on pull requests as well as tags?
Build a reduced grid on pull requests and the full grid on tags and on a schedule. Two representative cells — one Linux, one non-Linux — catch nearly all build breakage within minutes, while the full grid on a tag guarantees the release set is complete. Keeping the reduced grid a strict subset of the full one, driven by the same script, means a green pull request is genuine evidence about the release build.
How do I stop a partially-successful matrix from publishing?
Make the publish job depend on a validation job that itself depends on every build cell, rather than on the build cells directly. With needs pointing at the whole matrix, a single failed cell prevents validation from running, which prevents publication. The failure mode this avoids — a version that exists for three platforms and silently falls back to a source build on the fourth — is much harder to diagnose from the outside than a failed pipeline.
Are native ARM runners worth it compared with QEMU?
For a build that compiles GDAL from source, almost always. Emulation multiplies the CPU-bound phases by roughly an order of magnitude, and those phases are nearly the whole build. A native ARM runner turns a thirty-minute cell into a three-minute one, which changes the pipeline from something people avoid triggering into something that runs on every tag without comment.
What should the workflow do about the sdist?
Build it in its own job, validate that it can actually be built from — by installing it in a container with the development headers present — and upload it last in the release. The validation step matters because an sdist missing a CMakeLists.txt or a vendored source tree looks fine until a user on an unsupported platform tries to install, at which point they get a compile failure that looks like a bug in their environment.
How should secrets and permissions be scoped across the jobs?
Give the build and validation jobs no permissions at all, and put id-token: write only on the publish job. That single line of hygiene means the thousands of lines of upstream C that get compiled during the build run with no ability to reach the credential, and it costs nothing because the publish job needs only to download artifacts and upload them.
Can the same workflow serve several packages in a monorepo?
Yes, through a reusable workflow that takes the package directory, the import name and the platform list as inputs. Spatial projects tend to grow a family of packages — core bindings, a data package, a format extension — and a reusable workflow keeps the validation gate identical across all of them, which is the property that matters most when one of them is the weakest link.
Why does the cache hit locally in a branch build but miss on a tag build?
Because cache scoping is usually branch-aware: entries written from a feature branch may not be visible to a tag pipeline, or the key includes something that changes between them. Write cache entries from the default branch, key them only on native inputs, and confirm by printing the resolved key in both pipelines — a one-line change that turns an invisible slowdown into an obvious mismatch.
Should artifact retention be extended for release runs?
Yes, and it is easy to forget. Default retention is often shorter than the time between building a release candidate and deciding to publish it, and a publish job that finds expired artifacts fails in a way that looks like a permissions problem. Set retention explicitly on the jobs that produce 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/.