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.
What each stage owes the one after it
A stage is a promise: everything after it may assume its checks passed. Writing that promise down for each stage is what stops later jobs from defensively re-checking things, which is the usual reason a pipeline slowly doubles in length.
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]
Failing fast without failing blind
--maxfail=1 in the validate stage is the right default and the wrong one in the test stage, and the reason is what each stage is for. A validate failure means the input is unusable, so continuing produces a list of consequences rather than a list of causes; stopping at the first is both faster and clearer. A test failure means one rule was violated, and the other rules’ results are still useful — stopping early there hides how much else is wrong and forces a second run to find out.
The same reasoning applies to how a stage reports. A validate failure wants a short, unambiguous message naming the one thing that is wrong. A test failure wants the full JUnit report with every rule’s result, because the shape of the failure set is itself diagnostic — three rules failing on the same feature is a different problem from three rules failing on three unrelated ones, and only a complete report shows which you have.
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.
Keeping the configuration reviewable
A pipeline configuration grows by accretion faster than almost any other file in a repository, because every incident adds a step and nothing ever removes one. Three habits keep it legible. Put job bodies in scripts, so the configuration expresses wiring rather than logic. Use one anchor or template for anything repeated across jobs, so a change to the image or the cache key happens once. And give every job a comment stating what it guarantees, which is the same discipline as the stage guarantees above, applied one level down.
The test of whether it is working is simple: can someone who did not write the pipeline say, from the configuration alone, what blocks a merge and why? If the answer requires reading several jobs and inferring the interaction, the configuration has already outgrown its shape — and the fix is almost always fewer, better-named jobs rather than more comments.
Artefacts that survive the failure
The artefact configuration deserves the same care as the job list, because artefacts are what make a failure diagnosable after the runner is gone. Three settings matter. Publish when the job fails as well as when it succeeds, since the failing run is the one whose evidence is needed. Set an expiry long enough to cover a slow investigation — a report that vanishes after a day is unavailable exactly when someone returns to it on Monday. And keep the quarantine dataset separate from the report, so an engineer can download a few kilobytes of failing geometry without pulling an entire run’s output.
The JUnit report has a second role beyond rendering in the merge request: it is a machine-readable record of which rules ran. Comparing that list across runs is how a silently-skipped job is detected, and it costs nothing beyond keeping the artefact.
Keeping the merge-request rules honest
The rules block is where a pipeline most often stops doing what its author believes. Two habits keep it truthful. Write each rule so that its condition can be stated in one sentence, and put that sentence beside it as a comment; a rule needing a paragraph is two rules. And verify the scoping empirically rather than by reading — open a merge request touching only documentation and confirm the spatial jobs were skipped, then one touching a geometry module and confirm they ran.
That second check takes five minutes and catches the class of mistake that reading never does: a path pattern that matches nothing because a directory was renamed, or one that matches everything because a wildcard is broader than intended. Both leave the pipeline green, and both are invisible until someone looks at which jobs actually ran.
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.