CI/CD Spatial Quality Gates

CI/CD Spatial Quality Gates is the engineering discipline that turns spatial validation from a manual review step into an automated, blocking part of the deployment pipeline. It takes the predicates, tolerance policies and fixtures defined across geospatial QA fundamentals and architecture and gives them a place to run on every push: a set of gates that a change must pass before it can merge or ship. Where the fundamentals answer what to assert and spatial test pattern design answers how to write the checks, this body of work answers where and when they execute — which stages run pre-merge, which run nightly, how the runtime is pinned so results are reproducible across machines, and how outcomes become observable metrics rather than console noise. A gate is only trustworthy when the same geometry, the same GEOS build and the same PROJ grid produce the same verdict on a laptop and on a runner; everything here exists to guarantee that.

What This Discipline Covers

Continuous integration for spatial data differs from ordinary CI in three ways that make a naïve pytest step unreliable. First, the answer depends on the binary stack — a self-intersection repair or a datum transform can shift by metres between two GEOS or PROJ builds, so an unpinned runtime produces flaky gates. Second, the fixtures are large and slow, so cost-splitting the suite across pre-merge and scheduled tiers is not optional. Third, the tools that install the spatial stack (system GDAL, wheels, conda) each fail differently in a container, so runtime reproducibility is its own engineering problem. This section covers the concrete platforms teams gate on — the GitHub Actions spatial testing workflow, the GitLab CI spatial gates stages, and containerized GIS test runtimes — plus the decision framework for choosing spatial testing tools when more than one library can enforce the same rule.

Gate Architecture

A spatial quality gate is a deployment control point: a stage that reads a candidate change, runs a bounded set of deterministic checks, and returns a pass/fail that the platform enforces on the merge or release. The architecture that keeps gates both fast and thorough splits checks by cost and pins the runtime that evaluates them. Lightweight, exact checks — schema, CRS declaration, geometry validity — run on every push and must finish in well under a minute so they never become the reason a pull request stalls. Expensive, fixture-heavy checks — full topology audits, spatial joins, CRS round-trip drift — run on a schedule against the larger generated collections, where a multi-minute runtime is acceptable.

Spatial CI/CD gate architecture: pinned runtime, two lanes, metrics sink A commit flows into a pinned runtime container that fixes GDAL, GEOS and PROJ versions. Within the container a fast pre-merge lane runs schema, CRS and validity checks and feeds a merge gate; a scheduled lane runs topology audits, spatial joins and CRS round-trip drift against larger fixtures. Both lanes emit structured outcomes to a metrics sink where measured drift becomes a service-level objective. Pinned runtime · GDAL / GEOS / PROJ fixed Commit Pre-merge · every push Schema · CRS validity · fast · exact Merge ✓ Scheduled · nightly Topology · joins large fixtures CRS round-trip drift vs budget Metrics sink Prometheus · OTel drift = SLO

The container boundary in that diagram is load-bearing. Both lanes run inside the same pinned runtime so that a topology verdict emitted by the nightly lane is reproducible by the pre-merge lane and by an engineer debugging locally. This is why the discipline treats the runtime image as a versioned artifact in its own right, rebuilt deliberately rather than floating on whatever the base image resolves to at build time — the mechanics of which are the subject of the container runtime work below.

Pre-Merge Gates on GitHub Actions and GitLab CI

The first stage that a change hits is the pre-merge gate, and its whole value is speed with exactness: it must catch the cheap, unambiguous failures — a wrong geometry type, a missing CRS, an invalid ring — before a human reviewer spends attention on the diff. On GitHub, this is a required status check wired through GitHub Actions spatial testing, where the practical guide to gating pull requests with pytest-geo in GitHub Actions shows how to fail the merge on a failing spatial assertion. On GitLab, the equivalent is a pipeline stage described under GitLab CI spatial gates, where configuring GitLab CI spatial validation stages covers ordering validate → test → gate so a schema failure short-circuits before any expensive spatial maths runs.

The recurring cost problem on both platforms is installing the spatial stack quickly. GDAL and PROJ wheels are large and their transitive binary dependencies are slow to resolve, so an uncached install can dominate a job that should take seconds — the caching strategy in caching GDAL/PROJ wheels in GitHub Actions is what keeps the fast lane fast.

Gate tier Trigger Typical budget Checks it runs
Pre-merge Every push / PR < 60 s Schema, CRS declaration, geometry validity, small-fixture topology
Merge queue On merge to main 1–3 min Cross-format parity, attribute contracts, index correctness
Nightly Scheduled 5–30 min Full topology audit, spatial joins, CRS round-trip drift, raster alignment
Release Tag / deploy Variable Service-level fixtures, regression baselines against production sample

Containerized, Version-Pinned Runtimes

The reason the same test can pass locally and fail in CI almost always traces back to the binary geometry stack. Shapely and GeoPandas call into GEOS; pyproj calls into PROJ; fiona and pyogrio call into GDAL. Each of those C libraries has its own version and, in PROJ’s case, a separately versioned datum-grid database. A make_valid result, a buffer’s vertex count, or a datum shift can differ across builds, so a gate that does not pin the stack is not deterministic. The containerized GIS test runtimes work treats the image as the unit of reproducibility: pinning GDAL/PROJ versions in Docker test images fixes the exact library and grid versions, and reproducible conda environments for spatial CI does the same for teams that resolve the stack through conda-forge rather than system packages.

Version pinning has a numeric consequence worth stating explicitly. If a transform’s ground error is εtransform\varepsilon_{\text{transform}} and your gate’s tolerance budget is τ\tau, an engine upgrade that changes the transform can push the observed error past the budget:

εobserved>τ    gate fails after an unpinned upgrade\varepsilon_{\text{observed}} > \tau \iff \text{gate fails after an unpinned upgrade}

Recording the engine versions in every gate outcome is what lets a team answer “did an engine change?” in one query instead of a bisect. The version fields belong in the structured log alongside the measured drift.

Choosing the Right Tool for a Gate

More than one library can often enforce the same spatial rule, and the right choice at gate-writing time depends on where the data already lives and how expensive the check is. A topology rule can be evaluated in-process with Shapely or pushed to the database with PostGIS; a schema-and-domain contract can be expressed as a pytest assertion or as a Great Expectations suite; a spatial join in a test can be backed by an in-memory R-tree or a database GiST index. The choosing spatial testing tools decision work compares the realistic options head to head: pytest-geo vs Great Expectations for spatial validation, Shapely vs PostGIS for in-pipeline topology checks, and R-tree vs GiST index performance in test environments.

Choosing where a spatial check runs, by data location and rule type Starting from where the data lives, a check is placed server-side when the data is already in PostGIS, or in-process when it is held as GeoDataFrames. In-process checks then split: schema-and-domain contracts favour Great Expectations while geometry predicates favour pytest with Shapely. Where is the data? already in PostGIS in-process frames Server-side check PostGIS · GiST · ST_ predicates In-process check split by rule type Great Expectations pytest + Shapely contract predicate

Fixtures and Determinism in the Pipeline

A gate is reproducible only if its inputs are. Spatial fixtures must be content-addressed — hash the serialized geometry, CRS and attribute table — so a regression test provably runs against identical state across CI runs, and so a change to a fixture is a reviewable event rather than a silent shift. The generated collections that feed the nightly tier come from the test data generation and mocking strategies work; the edge-case set that the fast lane consumes should include at least one fixture per known failure class — anti-meridian crossings, polar features, degenerate and empty geometries, mixed Z/M coordinates. Keeping fixtures small for the pre-merge tier and large for the scheduled tier is what lets the two lanes share assertions while meeting different time budgets, and the async execution for large datasets patterns make the large-fixture tier tractable.

Observability: Drift as a Service-Level Objective

A passing suite is not the same as a trustworthy one. The gate should export its outcomes as metrics — counters for pass/fail by stage, histograms of measured drift against the tolerance budget — so that spatial accuracy becomes a service-level objective rather than a binary build status. Pair the metrics with a structured log schema so any failure is reproducible from a single log line.

Field Example Purpose
gate pre-merge Which gate tier emitted the event
check topology.no_overlaps The specific assertion that ran
geometry_hash sha256:9f2a… Content address of the input fixture
tolerance / measured 0.01 m / 0.004 m Budget enforced and value observed
gdal / geos / proj 3.8.4 / 3.12.1 / 9.4.0 Pinned engine versions
duration_ms 840 Runtime, to keep the fast lane honest

The engine-version fields are not optional: when a nightly gate regresses, they answer the first question — did the stack change — without a re-run.

Budgeting the Pre-Merge Gate

A gate has a time budget whether or not anyone writes it down, and the budget is set by human behaviour rather than by engineering. Below roughly five minutes, engineers wait for the result and act on it. Between five and fifteen they context-switch and come back. Past fifteen they open the next task, and the gate stops being a feedback mechanism and becomes an obstacle discovered later.

Designing to that budget means deciding, explicitly, what does not run pre-merge. The allocation below is a workable starting point for a spatial pipeline, and its shape matters more than its exact numbers: the fast, cheap, high-yield checks get the bulk of the coverage, and the expensive ones are represented by a sample rather than omitted entirely.

Stage Budget What runs What is deferred to nightly
Container start ~30 s Pinned image, warm layer cache Image rebuild
Dependency restore ~20 s Cached wheels keyed on a lockfile hash Fresh resolution
Schema and CRS contract ~15 s Every feature, every file
Geometry validity ~60 s Every feature
Topology rules ~90 s One coherent spatial subset Full-coverage dissolve
Cross-format parity ~45 s Primary output format only All output formats
Service-level checks Nothing Tiles, routing, joins at volume
Where the pre-merge minutes go, against how people behave A horizontal stacked bar representing the pre-merge gate's elapsed time, divided into segments for container start, dependency restore, schema and CRS contract checks, geometry validity, topology rules on a coherent subset, and cross-format parity on the primary format. The total sits at about four minutes. Two dashed vertical lines mark behavioural thresholds: at five minutes an engineer stops waiting and context-switches, and at fifteen minutes they move to another task entirely, at which point the gate has stopped functioning as feedback. A separate block outside the bar represents service-level checks, labelled as deferred to the nightly run. Pre-merge elapsed time container deps CRS validity topology · subset parity 0 1 min 2 min 3 min 4 min 5 min waiting ends switch away Past 15 minutes the engineer has started another task — the gate is no longer feedback, only an obstacle found later. Deferred to nightly: service-level tile generation, routing, spatial joins at volume, full-coverage dissolve, every output format. Deferred is not dropped — each has a scheduled run whose failures are triaged the next morning rather than blocking a merge. The design decision is which checks are represented by a sample pre-merge rather than which are omitted. A topology rule on one coherent subset catches most regressions in ninety seconds; the same rule on the full coverage catches slightly more and costs the whole budget.

Two decisions inside that budget deserve to be made deliberately. Cache what is deterministic, rebuild what is not — the wheel cache keyed on a lockfile hash is safe because the key changes when the content does, whereas caching a built fixture without hashing its generator is how a stale artefact survives for weeks. And fail fast on the cheap stages: ordering the gate so that a CRS mismatch fails in fifteen seconds rather than after the validity pass turns a large class of red builds from three minutes into fifteen seconds.

Where a Gate Should Stop the Line

Not every failing check should block a merge, and treating them all identically is the fastest way to have the gate routed around. Three responses are available, and the choice should follow from who can act and how quickly.

Block the merge when the failure is caused by the change under review and the author can fix it: a schema contract broken by their migration, a geometry rule violated by their transformation, a parity check failing on the format they altered. This is the only category where blocking is both fair and effective.

Warn on the merge request when the failure is real but not attributable to the author — an upstream dataset drifted, a fixture aged, a threshold is marginal. A comment gives the information without stopping unrelated work, and it accumulates visibly enough that someone eventually fixes it.

Alert a team, off the merge path, when the failure belongs to a producer or to the platform. A merge queue is the wrong place to surface someone else’s data problem, and putting it there trains everyone to ignore red.

The test that decides between them is short: can the person who triggered this run fix it in this change? If yes, block. If no, do not — regardless of how serious the finding is. Severity determines who gets alerted and how loudly; attributability determines whether the merge stops.

Security and Governance at the Gate

The gate is also a trust boundary. A pull request can carry a hostile fixture — a crafted WKT/WKB payload designed to exploit the parser or, when concatenated into SQL, to inject — so the runner must treat fixture geometry as untrusted input. The defensive parsing patterns live under security boundaries in spatial QA, and the gate must never echo raw coordinates into its logs, since a single point can re-identify an individual. Governance also decides which gates a change must pass: a documentation change need not run the full nightly spatial audit, while a change to a transform pipeline must. Matching gate depth to what actually changed keeps CI from becoming the bottleneck teams route around, following the same classification logic as scoping rules for map data validation.

Gating Data Changes, Not Only Code Changes

Most CI thinking assumes the artefact under review is a code change. In a spatial platform a large share of incidents come from data changes that no pull request accompanied — a supplier delivered a new extract, a reference layer was updated, a boundary set was re-issued. A gate wired only to code merges is blind to all of it.

The fix is to treat an incoming dataset as a reviewable artefact with its own gate. The delivery lands in a staging location, the same contract and validity checks run against it, and promotion to the serving location happens only on a pass. The mechanics are identical to a merge gate; what changes is the trigger and who receives the failure.

Property Code gate Data gate
Trigger A merge request A delivery landing in staging
Artefact under review A diff A dataset version
Checks Contract, validity, topology, parity The same checks, unchanged
Failure goes to The author The producing team
On pass Merge Promote to serving
On fail Block the merge Keep serving the previous version

The last row is the one that changes operational life most. A data gate that quarantines a bad delivery and keeps yesterday’s version serving converts what would have been an outage into a delayed update — and the delay is visible, attributable, and fixable by the team that caused it. Without the gate, the same bad delivery reaches consumers and is discovered by them.

Running both gates from the same check definitions is what keeps this affordable. The rules do not care whether they were triggered by a diff or by an arrival, so a single implementation, invoked from two triggers, gives complete coverage of the two ways a spatial platform actually changes.

Failure Attribution: Making Red Mean Something

The most expensive property of a badly-designed gate is not slowness; it is ambiguity. A red build that could mean five different things costs an engineer the same investigation every time, and after enough repetitions the investigation stops happening. Attribution is therefore a design goal, not a reporting nicety.

Four categories cover essentially every red build a spatial gate produces, and each should be distinguishable from the job summary alone, without opening a log.

The change broke a contract. A schema, CRS, or geometry rule failed on data the change produced. The report should name the rule, the feature identifier, the measured value and the threshold — enough to act on without reproducing locally.

The data drifted. The same code now fails against different input. Distinguishing this from the first case requires knowing whether the input changed, which is why recording the input’s content hash in the run is worth the small effort: two runs with the same code and different data hashes make the answer immediate.

The environment moved. An engine version, a grid package, a base image. These should be nearly impossible if the runtime is pinned, and when they do happen the recorded version fields identify them in seconds.

Infrastructure failed. A registry timeout, an unreachable bucket, a runner that ran out of disk. Nothing to do with the data at all, and reporting it in the same colour as a validation failure is what teaches people to re-run first and read second.

Category Distinguishing evidence Right first action
Contract broken by the change Rule name, feature id, value versus threshold Read the diff
Data drifted Input content hash differs, code unchanged Look upstream
Environment moved Engine version fields differ between runs Compare the image
Infrastructure failed Distinct exit code, no validation output Re-run, then escalate

The practical implementation is unglamorous and effective: emit a structured summary at the end of every run carrying the code revision, the input hash, the engine versions, and a category for the failure. Everything above then falls out of one line, and the habit of re-running a red build to see if it goes away — which is where most wasted CI time actually goes — stops paying off.

Frequently Asked Questions

Should the gate run against the merge commit or the branch head?

The merge commit, always. Testing the branch head answers “did this branch work in isolation”, which is not the question a merge gate exists to answer — two branches that each pass can produce a broken result together, and spatial pipelines are unusually prone to it because a schema change in one and a transformation change in the other interact through data rather than through code. Most CI systems can produce the merge result; using it costs nothing and removes an entire class of post-merge surprise.

How do we stop the gate from being skipped under deadline pressure?

By making the skip visible rather than by trying to prevent it. A documented override that records who used it, when, and why is far healthier than an unbreakable gate, because an unbreakable gate that blocks a genuine emergency will be removed entirely and never come back. Reviewing the override log monthly usually reveals one or two checks that are too slow or too noisy, which is exactly the feedback the gate needs.

What belongs in the container image versus in the job?

Anything version-pinned and slow belongs in the image: GDAL, PROJ, GEOS, the grid packages, the database client. Anything that changes per commit belongs in the job. The dividing line is how often it changes — an image rebuilt weekly and a job that installs a lockfile in twenty seconds is a good arrangement; an image rebuilt on every push is a slow arrangement with none of the benefits.

Is it worth running the gate on the default branch as well as on merge requests?

Yes, on a schedule rather than on every push. The scheduled run is what catches drift that no change caused — a base image that moved, an upstream dataset that changed, a certificate that expired — and it distinguishes “our change broke this” from “this was already broken”. Without it, the first failure after a quiet week is always ambiguous.

How should a gate handle a dependency it cannot reach?

Fail with a distinguishable exit, and do not retry silently. An unreachable object store or a missing grid package is an infrastructure fault, not a data fault, and reporting it as a validation failure sends an engineer to look at their geometry. Separating the two in the report — infrastructure red versus data red — is a small change that saves a great deal of misdirected investigation.

Conclusion

CI/CD spatial quality gates are what make spatial validation continuous: deterministic checks, split by cost across pre-merge and scheduled tiers, executed inside a version-pinned runtime, and instrumented so drift is measured rather than merely detected. Wired this way, a spatial regression is caught at the gate that owns it, traced to an engine or fixture change from a single log line, and prevented from ever reaching the map a user sees.