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.

The guarantee each stage hands to the next Four stages left to right, each with the guarantee it provides. The validate stage guarantees that the schema matches the contract, that a coordinate reference system is declared and is the expected one, and that every geometry is individually valid, so nothing downstream needs to re-check any of them. The test stage guarantees that set-level topology rules hold and that outputs survive a round trip through their target formats. The gate stage guarantees that the aggregate verdict has been computed and recorded against the merge request. The report stage guarantees that the evidence — JUnit results, the quarantine artefact and the run summary — is published regardless of the outcome above it. validate schema matches the contract CRS declared and expected every geometry individually valid nothing later re-checks these test set-level topology holds outputs round-trip cleanly attributes within contract assumes validate passed gate verdict computed recorded against the MR merge decision enforced aggregates, does not re-run report JUnit published quarantine artefact uploaded run summary written runs whatever happened Writing the guarantee down is what stops a later job from defensively re-checking an earlier one — the usual reason a pipeline quietly doubles in length. If a stage cannot state its guarantee in one sentence, it is a folder rather than a stage.

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.

Fail fast in validate, run everything in test Two stages compared. In the validate stage a coordinate reference system mismatch is shown cascading: every geometric check after it would fail as a consequence, so stopping at the first failure reports a single cause rather than a page of downstream noise. In the test stage the rules are shown as independent, so running all of them reports the complete extent of the problem in one pass, whereas stopping early would hide the remaining violations and force another run to discover them. A closing note gives the deciding question to ask of any stage: would the later failures be consequences of this one? validate — stop at the first failure CRS mismatch area check fails distance check fails topology fails all consequences Reporting all four hides the one that matters. Stopping at the first names the cause. test — run everything overlap rule fails gap rule passes parity fails attributes pass Independent rules. Stopping early hides two of these and buys a second pipeline run to find them. The deciding question for any stage: would the later failures be consequences of this one?

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.

A needs graph starts the fast job immediately Two timelines. Under plain stage ordering, the validate job cannot start until the stage begins, so its result arrives later than necessary despite depending on nothing. Under a needs-based directed graph, validate declares an empty dependency list and starts as soon as the pipeline is created, delivering feedback seconds after the push; the test and gate jobs still wait, because their dependencies on earlier results are genuine. The saving is marked as the interval between the two validate completions. Plain stage ordering validate test gate push validate waits for the stage to begin, though it depends on nothing needs-based graph validate · needs: [] test gate push saved The later jobs still wait, because their dependencies are real. Only the job that depends on nothing gets to start immediately. On a busy runner fleet this is the difference between seconds and minutes for the feedback engineers actually read.

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.

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.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.