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
- Documentation-only MRs blocked forever. If
testhas norulesand always runs, a docs change waits on a full topology audit; scope it withchanges:so unrelated edits skip the spatial work. gateon branch pipelines. Withoutif: $CI_MERGE_REQUEST_IID, the gate runs on plain branch pushes where there is no MR to gate, producing confusing failures.- Missing
needsbarrier. Omittingneedsongatelets it run beforetestfinishes in a DAG pipeline, passing before the assertions complete. - JUnit only on success. Without
when: alwayson the report artifact, the failing run — the one you need evidence from — uploads nothing. - Branch-keyed cache. Keying the pip cache on the branch re-installs the heavy spatial wheels on every unrelated change; key on
pyproject.tomlinstead.
Conclusion
A working GitLab spatial gate is a four-stage .gitlab-ci.yml — validate 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.