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.

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

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.

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.