GitLab CI Spatial Gates
GitLab CI expresses a spatial quality gate as an ordered pipeline of stages, and that ordering is the whole point: a .gitlab-ci.yml lets you run cheap, fail-fast validation before expensive spatial computation ever starts, so a missing CRS or a malformed schema stops the pipeline in seconds rather than after a multi-minute topology audit. This pattern sits beneath CI/CD spatial quality gates and shows how to structure the stages, pin the GDAL/PROJ runtime through an image, gate merge requests with rules, and report geometry failures as JUnit so they render inline in the merge request. Where the GitHub Actions spatial testing model leans on a matrix and required status checks, GitLab leans on stages and pipeline rules — the underlying discipline is identical, but the primitives differ enough to warrant their own treatment.
The stages map onto the same cost tiers every spatial gate uses: validate is the fast, exact pre-merge tier; test is the moderate geometric tier; gate is where a merge-request rule turns the aggregate result into an enforced decision; report publishes the evidence. The sections below build each stage, and the full worked configuration lives in configuring GitLab CI spatial validation stages.
Stage and Rule Reference
| Stage | Purpose | Key GitLab primitive | Failure behaviour |
|---|---|---|---|
| validate | Schema, CRS declaration, geometry validity | needs: [], cheap image |
Blocks all later stages |
| test | Geometry, topology, parity assertions | rules:changes for spatial paths |
Blocks gate |
| gate | Merge-request decision | rules: if $CI_MERGE_REQUEST_IID |
Fails the pipeline |
| report | JUnit + artifacts | artifacts:reports:junit |
Non-blocking, always runs |
Pinning the Runtime with an Image
Every GitLab job runs in a container, which makes runtime pinning natural: set image: to an exact GDAL/PROJ tag rather than a floating one. This is the reproducibility boundary — the same image that runs the validate stage runs the test stage, so a topology verdict is consistent across the pipeline and reproducible by an engineer who pulls the same tag. Building that image reproducibly is covered under containerized GIS test runtimes.
# .gitlab-ci.yml
default:
image: registry.example.com/gis-test:gdal3.9.2-proj9.4.1 # pinned, never :latest
stages: [validate, test, gate, report]
cache:
key:
files: [pyproject.toml] # cache invalidates when deps change
paths: [.cache/pip]
The validate Stage: Fail Fast and Cheap
The validate stage exists to reject the unambiguous failures before any spatial maths runs. It declares needs: [] so it starts immediately, and it runs only the fast spatial assertion types — schema presence, CRS declaration, geometry validity — that finish in seconds.
validate:crs-and-schema:
stage: validate
needs: []
script:
- pytest -q -m "crs or schema" --maxfail=1
# tests/test_crs.py — the kind of exact check the validate stage owns
import geopandas as gpd, pytest
@pytest.mark.crs
def test_declared_srid_matches_contract():
gdf = gpd.read_file("data/parcels.gpkg")
assert gdf.crs is not None, "layer has no declared CRS"
assert gdf.crs.to_epsg() == 3857, f"unexpected SRID {gdf.crs.to_epsg()}"
The test Stage: Run Only When Spatial Paths Change
Spatial assertions are worth running only when spatial code or data changed, so scope the test stage with rules:changes. A change confined to documentation should not spend runner minutes on a topology audit — the same gate-depth-by-change logic as scoping rules for map data validation.
test:geometry:
stage: test
rules:
- if: $CI_MERGE_REQUEST_IID
changes: ["src/**/*.py", "data/**/*.gpkg", "tests/fixtures/**"]
script:
- pytest -q -m "geometry or topology" --junitxml=report.xml
artifacts:
when: always
reports:
junit: report.xml
The gate Stage: Enforce on the Merge Request
The gate stage turns results into an enforced decision. Restrict it to merge-request pipelines with a rules: if guard, and configure the project’s merge checks to require a successful pipeline so a red gate physically blocks the merge — GitLab’s equivalent of a required status check.
gate:spatial:
stage: gate
rules:
- if: $CI_MERGE_REQUEST_IID
script:
- echo "All spatial validation stages passed — merge permitted."
Reporting Geometry Failures as JUnit
The report stage publishes JUnit XML so failures render inline in the merge request’s Tests tab, and it uploads any serialized failing geometry as an artifact. As with any spatial gate, never write raw coordinates into the job log — attach them as a downloadable artifact, in line with the security boundaries around location data.
report:artifacts:
stage: report
when: always # publish evidence even when earlier stages failed
script: ["ls -la artifacts/ || true"]
artifacts:
when: always
paths: [artifacts/failed_geometry.geojson]
reports:
junit: report.xml
Stages Are a Contract About Ordering, Not a Folder Structure
The most common way a GitLab spatial pipeline goes wrong is treating stages as categories — a stage per kind of test — rather than as an ordering guarantee. The distinction matters because stages carry a real cost: nothing in stage N starts until everything in stage N−1 finishes, so every stage boundary is a synchronisation point that idles runners.
A stage boundary earns its place only when the later work genuinely depends on the earlier work’s outcome. Validating the CRS before running geometry assertions earns one, because a CRS mismatch makes every geometric result meaningless. Running attribute checks after geometry checks does not, because neither informs the other, and putting them in separate stages simply makes the pipeline longer.
| Ordering | Justified? | Why |
|---|---|---|
| Contract before geometry | Yes | A CRS or schema failure invalidates every geometric result |
| Geometry before topology | Yes | Set-level rules are undefined on invalid input |
| Geometry before attributes | No | Independent; run them concurrently |
| Everything before the gate | Yes | The gate aggregates the verdict |
| Reporting after everything | Yes, and always run it | Evidence must survive a failure |
The reporting stage is the exception that should always exist and should always run. Configured to execute regardless of upstream results, it is what guarantees the JUnit report, the quarantine artefact and the run summary survive a failure — which is precisely when they are needed. A reporting job that only runs on success publishes evidence exclusively for the runs where nobody needs it.
Scoping Jobs to the Changes That Matter
A pipeline that runs everything on every change is simple and slow; one that runs nothing unless a spatial file changed is fast and dangerous. The workable middle is to scope by what a change can affect, and to be deliberately generous about the boundary.
Three rules keep the scoping honest. Include the configuration: a change to the tolerance file affects every spatial rule, so it must trigger the full spatial suite even though no geometry code moved. Include the environment definition: a change to the image, the lockfile or the PROJ pin can change every geometric result, and scoping it out is how an engine upgrade slips through unvalidated. Never scope out the contract checks: they are cheap enough to run on everything, and they are the ones that catch a schema change made in an unexpected place.
| Change touches | Run |
|---|---|
| A geometry module | Full spatial suite |
| The tolerance or scope configuration | Full spatial suite — the rules themselves changed |
| The image, lockfile or engine pin | Full spatial suite plus the version assertions |
| Documentation only | Contract checks only |
| Unrelated application code | Contract checks only |
The asymmetry is intentional: the cost of running the suite unnecessarily is a few minutes, and the cost of skipping it wrongly is a regression that reaches production with a green pipeline behind it. When the two are weighed honestly, generous scoping wins almost every time — and the cases where it does not are the ones where the suite is too slow, which is a different problem to fix.
Common Failure Modes and Gotchas
- Floating
image:tags.gis-test:latestmakes the pipeline non-deterministic — a rebuilt base can change GEOS and flip a previously green gate. Pin the exact GDAL/PROJ tag. - Missing
needs: []on validate. Without it, the fast stage waits on the DAG and stops being fast feedback. - Unscoped test stage. A
testjob withoutrules:changesruns a full topology audit on documentation-only merge requests, wasting runner minutes. gaterunning on branch pipelines. Without anif: $CI_MERGE_REQUEST_IIDguard the gate runs where there is no merge request to gate, producing confusing red pipelines.- Cache keyed on the branch. Keying the pip cache on the branch instead of
pyproject.tomlre-installs the heavy GDAL/PROJ wheels on every unrelated change. - JUnit not marked
when: always. If the report artifact only uploads on success, the one run you most need evidence from — the failing one — has none.
Frequently Asked Questions
Should the spatial suite run in a merge-request pipeline or a branch pipeline?
The merge-request pipeline, because it tests the result of merging rather than the branch in isolation. Spatial pipelines are unusually exposed to interaction defects — a schema change on one branch and a transformation change on another interact through data, not through code — so a branch pipeline can pass twice and produce a broken default branch. Where both run, make the merge-request one the required check.
How do we stop the pipeline definition itself from becoming unreviewable?
Keep the job bodies in scripts and use the configuration only for wiring. A pipeline definition that contains conditionals, retries and inline shell accumulates logic nobody tests; one that names a script per job stays readable and lets the script be run locally. The same discipline that applies to a workflow file applies here, and it matters more in a spatial project because environment handling attracts inline complexity.
What belongs in the runner image versus in the job?
Everything version-pinned belongs in the image, including the PROJ data package, so a job never resolves a spatial dependency at run time. The job should install only the project itself, from a lockfile. Teams that install GDAL in the job pay the install on every run and accept that two runs a week apart can differ.
How should the gate treat a failure in an upstream data source?
As an infrastructure result rather than a validation result, with a distinct exit and a message that says so. Reporting an unreachable object store as a failing spatial rule sends an engineer to look at geometry, and the resulting investigation is entirely wasted. Separating the two is a small change to error handling that pays back the first time it fires.
Is it worth publishing the report when the pipeline passes?
Yes, and for a specific reason: a report published only on failure has no baseline. Counts, drift measurements and timings from successful runs are what make a later failure interpretable — without them, the first red run is the first data point, and nobody can say whether the value is unusual or normal.
How do we keep a scheduled pipeline from being ignored?
Give it an owner and route its failures somewhere a person reads, rather than to the pipeline list. A scheduled job whose failures appear only in a page nobody visits is decoration within a month. Where the schedule exists to detect drift, the failure should reach the same place an alert would, because that is what it is.
Where the pipeline definition should stop
A pipeline is a scheduler, and the temptation is to let it become an application. Every incident adds a step, every special case adds a rule, and within a year the configuration encodes business logic that no test covers. Three boundaries hold the line.
The pipeline decides what runs and when, and nothing else. It calls scripts; it does not contain them. It sets the image, the cache key and the artefact paths; it does not compute thresholds or interpret results. Where a decision needs logic — which fixtures to load, how to classify a failure, whether a drift value is acceptable — that logic belongs in code that can be run and tested locally.
The second boundary is one source of truth for anything referenced twice. An image tag, a Python version, a marker expression: if it appears in more than one job, it belongs in a variable or a template. A pipeline where the image tag is written in five places will eventually run four of them on one version and one on another, and the resulting failure is attributed to the data.
The third is no logic in a rule that a person cannot restate. A rules expression combining several variables and a change filter is fast to write and, six months later, nobody can say what triggers the job. When a rule needs more than one condition, a comment stating its intent in a sentence is not optional — it is the only documentation that will exist.
Making the pipeline legible to the people it blocks
A gate is read by whoever it stops, and that person is usually not the one who wrote it. Two small investments carry most of the legibility. The first is job names that say what failed rather than which tool ran: validate:crs-and-schema tells a reader something, pytest-1 does not. The second is a job summary written by the job itself, stating in one line what it checked and what it found, so the merge request shows a sentence rather than an exit code.
Both are trivial to add and neither survives without deliberate attention, because the default in every CI system is to name things after the mechanism. The test of whether it is working: can a developer who has never opened the pipeline configuration tell, from the merge request alone, what they need to change? If not, the gate is correct and unusable, which in practice means it will be overridden.
The same reasoning applies to the schedule: a shared template can carry the nightly job too, so every project gets drift detection without configuring it separately.
Conclusion
A GitLab CI spatial gate is an ordered pipeline: a fast-failing validate stage, a change-scoped test stage, an enforced gate stage, and an always-on report stage — all inside a pinned GDAL/PROJ image. Structured this way, the pipeline gives seconds-fast feedback on the cheap failures, spends expensive spatial computation only when spatial code changed, and blocks the merge on a real regression with inline evidence. For the platform-neutral architecture behind these stages, return to CI/CD spatial quality gates.
Reusing the pipeline across projects
Once several repositories gate on spatial rules, the configurations diverge quickly. Including a shared template and overriding a small number of variables keeps the shape identical while letting each project name its own paths, markers and image tag.
What belongs in the shared template is the structure: the stage list, the artefact handling, the report job, the rules that decide when the spatial jobs run at all. What belongs in each project is the specifics. The boundary to watch is the number of variables the template accepts — past a handful, the template has become a parameterised copy of every caller and the sharing has stopped paying for itself.
There is one further benefit worth noting. A shared template makes an improvement propagate: adding the quarantine artefact once means every project gains it at the next pipeline run, which is the kind of leverage that is impossible when each repository owns its own copy.