GitHub Actions Spatial Testing

GitHub Actions is where most teams enforce their first spatial quality gate, because a workflow can be wired as a required status check that physically blocks a merge until the spatial assertions pass. This pattern sits beneath CI/CD spatial quality gates and covers the concrete mechanics: how to install the GDAL/PROJ stack quickly, how to run the fast pre-merge tier on every push, how to matrix across engine versions so an upgrade cannot silently change a result, and how to surface a failed geometry as a downloadable artifact instead of a wall of console text. The reason spatial CI on Actions needs its own treatment is that the default actions/setup-python step does not solve the hard part — the binary geometry libraries behind Shapely, GeoPandas and pyproj — and getting that wrong is what produces “passes locally, fails in CI” reports.

GitHub Actions spatial workflow from pull request to required status check A pull request triggers a workflow. A setup job restores a spatial-wheel cache. A matrix then fans out across two GDAL and PROJ pairs; each matrix job runs pytest-geo and uploads failed-geometry artifacts. A required status check aggregates the matrix result and gates the merge. Pull request Restore cache spatial wheels GDAL 3.8 · PROJ 9.3 pytest-geo GDAL 3.9 · PROJ 9.4 pytest-geo Artifacts failed geometry Required check

Every spatial workflow decomposes into the same four responsibilities: provision the binary stack reproducibly, run assertions as ordered tiers, matrix across the engine versions you support, and report failures as reviewable artifacts. The sections below take each in turn, with the two hardest — gating pull requests with pytest-geo and caching GDAL/PROJ wheels — expanded in their own worked guides.

Workflow Responsibility Reference

Responsibility Actions primitive What it prevents Typical budget
Provision stack Container image: or cached wheels Engine-version drift between runs 5–40 s
Run assertions pytest step, ordered markers Slow checks blocking fast feedback < 60 s (pre-merge)
Matrix engines strategy.matrix An upgrade silently changing a result ×N job time
Report failures actions/upload-artifact Non-reproducible console-only failures negligible
Enforce Branch protection required check Merging a spatial regression 0

Provisioning the Spatial Stack

The single most consequential choice in a spatial workflow is how the geometry libraries reach the runner. Three approaches dominate, and they trade reproducibility against speed. A container image: on the job gives exact GDAL, GEOS and PROJ versions with no install step, at the cost of pulling the image. Pinned wheels via pip install are fast when cached but only reproducible if every binary dependency is pinned. System packages via apt are fastest to write but least reproducible, because the distribution can bump GDAL under you. For a gate that must be deterministic, prefer a container or fully pinned wheels — the details of building that image live under containerized GIS test runtimes.

# .github/workflows/spatial.yml — container gives an exact, pinned stack
jobs:
  spatial:
    runs-on: ubuntu-24.04
    container:
      image: ghcr.io/acme/gis-test:gdal3.9.2-proj9.4.1   # pinned, not :latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -e '.[test]'      # app + pinned test extras
      - run: pytest -q -m "geometry and not slow"   # fast tier only

Ordered Assertion Tiers

Spatial assertions must run cheapest-first so a missing CRS never blocks behind a million-vertex Hausdorff comparison. Express the tiers with pytest markers and select them per gate: the pre-merge job runs not slow, the nightly job runs everything. The spatial assertion types taxonomy maps directly onto these markers — metadata and validity are fast, geometric tolerance checks are moderate, cross-engine joins are slow.

# conftest.py — register markers so unknown-marker warnings do not mask real ones
def pytest_configure(config):
    for m in ("geometry", "topology", "crs", "slow"):
        config.addinivalue_line("markers", f"{m}: spatial test tier")
# tests/test_geometry.py
import pytest, geopandas as gpd
from shapely import is_valid

@pytest.mark.geometry
def test_all_geometries_valid():
    gdf = gpd.read_file("tests/fixtures/parcels.gpkg")
    invalid = gdf[~gdf.geometry.map(is_valid)]
    assert invalid.empty, f"{len(invalid)} invalid geometries: {list(invalid.index)}"

Matrix Testing Across Engine Versions

Because GEOS and PROJ can change a result across builds, matrix the workflow over the engine versions your production runtimes actually use. A matrix that spans the version you run today and the version you plan to upgrade to turns an upgrade from a surprise into a green or red check before you cut over.

strategy:
  fail-fast: false          # let every cell report, do not cancel on first red
  matrix:
    include:
      - { image: "ghcr.io/acme/gis-test:gdal3.8.4-proj9.3.1" }
      - { image: "ghcr.io/acme/gis-test:gdal3.9.2-proj9.4.1" }
container:
  image: ${{ matrix.image }}

Set fail-fast: false so a failure in one engine pair does not cancel the others — when an upgrade regresses, you want to see exactly which version pair broke, not just that something did.

Reporting Failed Geometry as Artifacts

A spatial failure is far more debuggable as a file than as a stack trace. Serialize the offending features to GeoJSON on failure and upload them, so a reviewer can open the geometry in a viewer rather than reconstruct it from coordinates in a log — and so no raw coordinate needs to be pasted into a public log, which matters for the security boundaries around location PII.

# conftest.py — dump failing geometry for artifact upload
import json, os, pytest

@pytest.fixture
def geom_recorder():
    failures = []
    yield failures
    if failures:
        os.makedirs("artifacts", exist_ok=True)
        with open("artifacts/failed_geometry.geojson", "w") as fh:
            json.dump({"type": "FeatureCollection", "features": failures}, fh)
      - name: Upload failed geometry
        if: failure()
        uses: actions/upload-artifact@v4
        with: { name: failed-geometry, path: artifacts/ }

Enforcing the Gate with Branch Protection

The workflow only becomes a gate when branch protection marks it required. Point the required check at the aggregating job (not each matrix cell) so the rule reads “spatial suite passed” regardless of how many engine pairs ran. This is the same enforcement model whether the assertions are written with pytest or delegated to Great Expectations — the choice between them is covered in choosing spatial testing tools.

Common Failure Modes and Gotchas

  1. setup-python without the binary stack. Installing Python does not install GDAL; the pip install shapely wheel bundles its own GEOS, but fiona/pyogrio need GDAL present. Use a container or a spatial-aware install action.
  2. :latest image tags. A floating tag makes the runtime non-deterministic; a rebuild of :latest can change GEOS and break a previously green gate. Always pin.
  3. fail-fast: true on a matrix. The default cancels sibling jobs on the first failure, hiding which engine pair actually regressed.
  4. Uncached wheels in the fast lane. A cold GDAL/PROJ install can dominate a sub-minute gate; without a cache the pre-merge tier stops being fast — see the caching guide.
  5. Raw coordinates in logs. Echoing failing geometry into the job log can leak location PII; write it to an artifact instead.
  6. Required check pointed at a matrix cell. If branch protection requires one specific cell, dropping that engine from the matrix silently disables the gate.

Conclusion

A GitHub Actions spatial gate is four disciplined choices: a pinned binary stack, cost-ordered assertion tiers, a matrix over the engine versions you support, and failures reported as artifacts — all enforced by a required status check. Built this way, the workflow blocks a spatial regression at the pull request rather than discovering it in production, and it does so reproducibly. For the platform-neutral gate architecture these workflows implement, return to CI/CD spatial quality gates.