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.

The two-job GitHub Actions shape: matrixed build then a single collect A build job runs as a strategy matrix across four OS and architecture cells, each restoring a native dependency cache keyed on the GDAL and PROJ versions, running cibuildwheel, and uploading a per-cell artifact. A second collect job needs the build job, downloads all artifacts, runs twine check, and produces the final dist set. job: build (strategy.matrix) restore cache gdal3.8-proj9.3 cibuildwheel 4 OS/arch cells upload-artifact per cell job: collect (needs build) download all artifacts twine check → dist/

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.

The four jobs of a GitHub Actions workflow for spatial wheels A setup job emits the platform matrix as an output. A build job fans out across that matrix, each cell caching its native build and uploading a per-cell artifact. A validate job depends on all build cells, downloads every artifact and runs the import gates in clean containers. A publish job depends on validate, holds the only id-token write permission, and runs only on a tag. setup emits the matrix build · linux x86_64 cache key: native versions build · linux aarch64 native ARM runner build · macos arm64 delocate repair build · windows amd64 delvewheel repair validate needs: all build cells publish tags only only the last job carries id-token: write — everything to its left runs with no ability to publish and `needs` on the whole matrix means a single failed cell stops the release rather than shipping a partial set

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.

Anatomy of a native build cache key, component by component A cache key is composed of a fixed prefix, the operating system and architecture, the pinned GDAL, PROJ and GEOS versions, and a hash of the native version lock and build script. A restore-keys prefix drops the trailing hash so a near-miss still restores the closest previous build. Two components are marked as mistakes: including the git commit, which guarantees a miss on every run, and including the Python version, which is irrelevant to an abi3 build. native- ${os}-${arch}- gdal3.8.4-proj9.3.1-geos3.12.1- ${hashFiles(lock, build.sh)} fixed the cell what was compiled how it was compiled restore-keys: native-${os}-${arch}-gdal3.8.4-proj9.3.1- a near miss still restores the closest build never include: ${github.sha} ${matrix.python} guarantees a miss on every single run irrelevant to an abi3 build — one wheel serves every interpreter write to the cache only from the default branch, so a feature branch cannot publish objects a release will later restore

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.