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.

Four-stage GitLab CI spatial pipeline: validate, test, gate, report A left-to-right pipeline. The validate stage runs schema and CRS checks and fails fast. The test stage runs geometry and topology assertions. The gate stage aggregates the merge decision. The report stage publishes a JUnit artifact of failures. Arrows connect each stage to the next. validate schema · CRS · fail fast test geometry · topology gate merge decision report JUnit artifact

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.

Branch pipeline versus merge-request pipeline Two arrangements. In the branch pipeline, each branch is tested at its own head: branch A carrying a schema change passes, branch B carrying a transformation change passes, and yet the default branch breaks once both are merged, because the two changes interact through the data rather than through the code. In the merge-request pipeline, the merge result itself is built and tested, so the interaction between the two changes is exercised before the merge is permitted. A recommendation marks the merge-request pipeline as the one branch protection should require. Branch pipeline — tests each head in isolation branch A · schema change passes branch B · transform change passes default branch after merging broken — nothing tested the pair they interact through the data, not through the code Merge-request pipeline — tests the merge result A merged into target the artefact that will exist the interaction is exercised before the merge is allowed require this one the branch pipeline can stay advisory Spatial pipelines are unusually exposed to this, because two changes can be individually correct and jointly wrong through the data they share.

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
Stage boundaries as synchronisation points Two pipelines drawn as timelines. The over-divided pipeline has five sequential stages — contract, geometry, attributes, parity and gate — each waiting for the previous to complete entirely, so independent checks are serialised and the total duration is the sum of all five. The dependency-shaped pipeline has three stages: contract first, then a single stage in which geometry, attribute and parity jobs run concurrently, then the gate, with a report stage configured to run whatever the outcome. Its total duration is markedly shorter because only genuine dependencies force a wait. A stage per category — independent checks serialised contractgeometryattributesparitygate total = the sum of every stage, because each waits for the last to finish completely Stages shaped by real dependencies contract geometryattributesparity — one stage, concurrent jobs gate report · always total is shorter — only genuine dependencies force a wait, and the report runs whatever happened upstream Ask of every boundary: does the later job need the earlier job’s outcome? If not, it is one stage.

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

  1. Floating image: tags. gis-test:latest makes the pipeline non-deterministic — a rebuilt base can change GEOS and flip a previously green gate. Pin the exact GDAL/PROJ tag.
  2. Missing needs: [] on validate. Without it, the fast stage waits on the DAG and stops being fast feedback.
  3. Unscoped test stage. A test job without rules:changes runs a full topology audit on documentation-only merge requests, wasting runner minutes.
  4. gate running on branch pipelines. Without an if: $CI_MERGE_REQUEST_IID guard the gate runs where there is no merge request to gate, producing confusing red pipelines.
  5. Cache keyed on the branch. Keying the pip cache on the branch instead of pyproject.toml re-installs the heavy GDAL/PROJ wheels on every unrelated change.
  6. 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.