Configuring GitLab CI Spatial Validation Stages

A GitLab spatial gate lives or dies by the order of its stages: run the cheap, exact checks first so a missing CRS or malformed schema fails in seconds, and reserve the expensive topology and join audits for after. This guide sits beneath GitLab CI spatial gates and walks a complete .gitlab-ci.yml from an empty file to an enforced merge-request gate — the stage list, the DAG that keeps the fast stage fast, the rules:changes scoping that skips spatial work on non-spatial edits, and the JUnit reporting that renders failures inline. The specific thing this page pins down is the exact YAML, because the ordering and the rules guards are where a working config differs from one that either runs everything on every commit or never blocks anything.

Why stage ordering is the whole design

GitLab runs stages sequentially and jobs within a stage in parallel, so the stage list encodes the cost tiers directly. If validity and CRS checks share a stage with a full topology audit, the audit runs even when the schema is already broken — wasted minutes and noisier failures. Splitting them so validate gates test, and test gates the merge decision, means the pipeline stops at the earliest, cheapest signal. A needs: DAG on top of that lets the fast validate job start without waiting on the stage barrier, giving developers feedback in seconds.

Stage configuration reference

Stage needs rules guard Runs
validate [] always on MR Schema, CRS, validity
test [validate:*] changes: spatial paths Geometry, topology, parity
gate [test:*] if: $CI_MERGE_REQUEST_IID Merge decision
report none (always) when: always JUnit + artifacts

Step-by-step implementation

The configuration targets a pinned GDAL/PROJ image, pytest 7+ and GitLab’s merge-request pipelines.

Step 1 — Declare stages and a pinned default image

# .gitlab-ci.yml
default:
  image: registry.example.com/gis-test:gdal3.9.2-proj9.4.1   # never :latest

stages: [validate, test, gate, report]

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

cache:
  key: { files: [pyproject.toml] }
  paths: [.cache/pip]

Step 2 — validate: fast, DAG-started, always on MR

validate:schema-crs:
  stage: validate
  needs: []                               # start immediately, no stage wait
  rules:
    - if: $CI_MERGE_REQUEST_IID
  script:
    - pip install -e '.[test]'
    - pytest -q -m "schema or crs" --maxfail=1

Step 3 — test: scoped to spatial changes, emits JUnit

test:geometry-topology:
  stage: test
  needs: ["validate:schema-crs"]
  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 }
    paths: [artifacts/]

Step 4 — gate: the enforced merge decision

gate:spatial:
  stage: gate
  needs: ["test:geometry-topology"]
  rules:
    - if: $CI_MERGE_REQUEST_IID
  script:
    - echo "Spatial validation passed — merge permitted."

Then, in the project’s merge-request settings, enable “Pipelines must succeed” so a failed gate job blocks the merge button.

Step 5 — report: always publish evidence

report:evidence:
  stage: report
  needs: []
  when: always                            # runs even if test failed
  script: ["ls -la artifacts/ || true"]
  artifacts:
    when: always
    paths: [artifacts/failed_geometry.geojson]

Verification pattern

Push a branch that deliberately breaks a geometry and open a merge request; the pipeline should stop at test, mark the pipeline failed, and disable the merge. Locally, reproduce the exact stage command to confirm the exit code before pushing:

pytest -q -m "geometry or topology" --junitxml=report.xml; echo "exit=$?"

A non-zero exit is what GitLab converts into a failed job and, with “Pipelines must succeed” enabled, a blocked merge.

Failure modes and edge cases

  1. Documentation-only MRs blocked forever. If test has no rules and always runs, a docs change waits on a full topology audit; scope it with changes: so unrelated edits skip the spatial work.
  2. gate on branch pipelines. Without if: $CI_MERGE_REQUEST_IID, the gate runs on plain branch pushes where there is no MR to gate, producing confusing failures.
  3. Missing needs barrier. Omitting needs on gate lets it run before test finishes in a DAG pipeline, passing before the assertions complete.
  4. JUnit only on success. Without when: always on the report artifact, the failing run — the one you need evidence from — uploads nothing.
  5. Branch-keyed cache. Keying the pip cache on the branch re-installs the heavy spatial wheels on every unrelated change; key on pyproject.toml instead.

Conclusion

A working GitLab spatial gate is a four-stage .gitlab-ci.ymlvalidate fast and DAG-started, test scoped to spatial changes and emitting JUnit, gate guarded to merge requests, and report always publishing evidence — inside a pinned image. That ordering gives seconds-fast feedback, spends heavy computation only when spatial code changed, and blocks the merge on a real regression. For the platform context, return to GitLab CI spatial gates.