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 section 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
The Same Grid in GitLab’s Vocabulary
Almost every construct in a spatial wheel pipeline has a direct GitLab equivalent, and the translation is mechanical once the vocabulary lines up. Where the two systems genuinely differ is in how jobs find each other’s artifacts and how the runner executor is chosen.
The two highlighted rows are where GitLab is genuinely more convenient for this kind of build. Artifacts flow along needs edges without an explicit download step, which removes a class of mistake where a fan-in job forgets to fetch one cell’s output. And because the Docker executor selects the image per job, running the whole build inside a manylinux_2_28 container is a one-line declaration rather than a container action with its own argument surface.
Where the Pipeline Needs Care
Three details are specific enough to GitLab that they are worth stating explicitly, because each has produced a broken spatial release somewhere.
The cache-key default is the one that costs the most and is hardest to notice, because branch pipelines — the ones developers watch — behave perfectly while the tag pipeline that actually produces releases recompiles everything from scratch.
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.
Frequently Asked Questions
Which executor should the Linux build jobs use?
The Docker executor, with the manylinux image named directly in the job’s image keyword. That gives you the curated toolchain and the auditwheel policy files without any container plumbing of your own, and it makes the image a reviewable, pinnable line in the pipeline file. Shell executors are for the platforms Docker cannot host — macOS and Windows — where the runner itself has to carry the toolchain.
How do artifacts move between stages?
Automatically along needs edges when artifacts: true is set, which is the main ergonomic advantage over a workflow where each consumer downloads explicitly. The corollary is that artifact expiry becomes load-bearing: if a pipeline waits for a manual approval longer than the expiry window, the publish job finds an empty directory. Set expire_in explicitly on every job that produces wheels rather than relying on the instance default.
What is the GitLab equivalent of trusted publishing?
An id_tokens block that requests a JSON Web Token with the appropriate audience, which the publish job then presents to PyPI in place of an API token. The registered publisher names the GitLab instance, the project path and the ref, and each of those is compared literally — so a project moved between groups needs its publisher updated, which is the usual cause of a first failure after a reorganisation.
Can parallel:matrix express the same grid as a GitHub matrix include?
Yes, with one difference worth knowing: parallel:matrix produces the full product of the lists you give it, so excluding specific combinations means either restructuring the lists or adding a rules clause that skips the unwanted cells. Generating the matrix from the shared platform file avoids the question entirely, because the file lists the cells you want rather than the axes to multiply.
Why is the tag pipeline slower than the branch pipeline?
Almost always the cache key. The default key is derived from the ref, so a tag pipeline looks for entries that were written under a branch name and finds nothing, recompiling GDAL from scratch in every cell. Keying the cache on the native lock file instead makes the two pipelines share entries, and printing the resolved key in the job log makes the mismatch obvious the first time it happens.
How should a self-hosted runner fleet be organised for this?
By tag, matching the platform list: a docker tag for the Linux cells, a macos-arm64 tag for the macOS runner, a windows tag for the Windows one. Keep the repair tooling — delocate, delvewheel — installed on the runner images rather than installing it per job, and pin their versions alongside the native library pins so a runner rebuild does not silently change how wheels are repaired.
Should the pipeline publish from GitLab if the project also builds on GitHub?
Build in both if you want redundancy, but publish from exactly one. Two pipelines uploading the same version race, and the loser fails with an error that reads like a bug. Record which system is authoritative in the repository so nobody has to reconstruct it during a release.
Where should the platform list live in a GitLab-only project?
Still in its own file rather than inline in the pipeline. Even without a second CI system to keep in step, a machine-readable list lets the validation job iterate the same cells the build produced, and lets a script assert that the collected artifact set matches the declared platforms — the check that catches a silently missing wheel.
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.