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.

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

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.