GitLab CI pipeline for spatial wheels
This page answers one question: how do you express the same GDAL/PROJ wheel matrix in .gitlab-ci.yml — with parallel:matrix for the OS/arch grid, a Docker executor for the manylinux build, cache:key:files for the native dependencies, and a fan-in stage — when GitLab has no cibuildwheel action and macOS/Windows need shell runners? It sits inside the CI Matrix Recipes for Spatial Wheels cluster of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the complete pipeline plus the GitLab-specific gotchas.
Context & Root Cause
GitLab CI models a pipeline as ordered stages of jobs, and a matrix is written with parallel:matrix, which expands one job template across variable combinations. The structural difference from GitHub Actions is that GitLab has no first-party cibuildwheel step, so each job runs cibuildwheel (or a raw build) as a script inside a chosen executor — a Docker executor for Linux, and shell runners for macOS and Windows because those cannot run inside a Linux container. Caching uses cache:key:files, which hashes named files to build the key, giving the same “invalidate when GDAL changes” behaviour as GitHub’s hashFiles.
The failure modes are GitLab-flavoured: forgetting needs: makes the collect stage wait for the entire build stage instead of streaming, Docker-in-Docker is needed for the manylinux image unless you run the build directly in a manylinux-based job image, and artifacts expire by default and vanish before the release job runs. The recipe below addresses each. It is the GitLab counterpart to GitHub Actions matrix for GDAL wheel builds.
Solution / Fix
This targets GitLab 16+, a Docker executor for Linux, registered macOS/Windows shell runners, and GDAL 3.8.x.
1. The Linux build job (runs inside a manylinux image)
stages: [build, collect]
.build-linux:
stage: build
image: quay.io/pypa/manylinux_2_28_x86_64 # build directly in the manylinux image
parallel:
matrix:
- ARCH: [x86_64, aarch64]
cache:
key:
files: [ci/native.lock] # invalidate when GDAL/PROJ change
paths: [.cache/native-deps]
script:
- pipx run cibuildwheel --platform linux --output-dir wheelhouse
artifacts:
paths: [wheelhouse/*.whl]
expire_in: 1 week # long enough for the release job
build:linux:
extends: .build-linux
tags: [docker]
2. The macOS and Windows jobs (shell runners)
build:macos:
stage: build
tags: [saas-macos-medium-m1] # a registered macOS runner
script:
- pipx run cibuildwheel --platform macos --output-dir wheelhouse
artifacts: { paths: [wheelhouse/*.whl], expire_in: 1 week }
build:windows:
stage: build
tags: [shared-windows]
script:
- pipx run cibuildwheel --platform windows --output-dir wheelhouse
artifacts: { paths: [wheelhouse/*.whl], expire_in: 1 week }
3. The collect stage
collect:
stage: collect
image: python:3.12-slim
needs: [build:linux, build:macos, build:windows] # stream, don't wait for the stage
script:
- mkdir -p dist && cp wheelhouse/*.whl dist/ 2>/dev/null || true
- pip install twine && twine check dist/*.whl
artifacts: { paths: [dist/], expire_in: 1 month }
Verification
# 1. Validate the pipeline definition before pushing (GitLab CLI)
glab ci lint # expected: "Configuration is valid"
# 2. Reproduce the Linux cell locally in the same image
docker run --rm -v "$PWD:/w" -w /w quay.io/pypa/manylinux_2_28_x86_64 \
bash -c "pipx run cibuildwheel --platform linux"
# expected: cp39-abi3 manylinux + musllinux wheels in wheelhouse/
# 3. The collect artifact spans every platform family
ls dist/ | grep -oE '(manylinux|musllinux|macosx|win)' | sort -u
# expected: all four present
Pitfalls & Alternatives
Artifacts expiring before release. GitLab expires job artifacts (default 30 days, often less on self-hosted). A tag pipeline that builds today and releases after review can find the wheels gone. Set expire_in explicitly on every build job.
Omitting needs:. Without needs:, the collect stage waits for the slowest build job’s whole stage and cannot start early; worse, it may run even if a build job you did not list failed. List explicit needs: so the DAG is correct.
Running manylinux via Docker-in-Docker unnecessarily. Building inside a manylinux job image: avoids DinD entirely — only reach for docker:dind if you must build a custom image in-pipeline. For that custom-image path, see cibuildwheel vs manual Docker matrix for GDAL wheels.
Related
- CI Matrix Recipes for Spatial Wheels — the parent guide with the cross-system decision table.
- GitHub Actions matrix for GDAL wheel builds — the same grid in GitHub Actions.
- Async build execution and cache strategies — the native-dependency caching that
cache:key:filesimplements here.