Running Spatial Validation in GitHub Actions

You have a repository of spatial layers — parcels, roads, zoning polygons — stored as GeoJSON or GeoPackage, and a pytest suite that checks their geometry, schema, and coordinate reference system (CRS). What you lack is the automation that runs those checks on every proposed change and refuses to let a broken feature merge. This page gives you a complete, copy-ready GitHub Actions workflow that does exactly that: it fans out across datasets with a matrix, installs and caches a GeoPandas toolchain, runs the suite, uploads a report artifact, and annotates the pull request with failures. It is the concrete delivery surface for Continuous Integration for Spatial Validation.


GitHub Actions spatial validation job graph A pull request trigger fans out into three parallel matrix jobs — parcels, roads, zoning — each restoring the pip cache, installing GeoPandas, running pytest, and uploading an artifact. All three feed an aggregate gate job that reports the required status check. pull_request trigger matrix job: parcels cache · install · pytest job: roads cache · install · pytest job: zoning cache · install · pytest report artifact + PR annotation gate (needs: *) required check

Prerequisites

  • A GitHub repository containing the spatial layers and the pytest suite from the parent guide on continuous integration. This page assumes those tests already exist.
  • A pinned dependency file. Commit a requirements.txt (or uv.lock) pinning geopandas==0.14.*, shapely==2.0.*, pyproj==3.6.*, fiona==1.9.*, and pytest==8.*. Caching keys off this file’s hash, so it must exist.
  • GeoPandas installed from wheels, not source. On ubuntu-latest the default binary wheels bundle GDAL (Geospatial Data Abstraction Library), GEOS, and PROJ. Do not apt-get install gdal-bin python3-gdal and then pip install gdal — compiling the GDAL Python bindings against a mismatched system header is the single most common cause of a red build. If you need a specific GDAL, use a container (see the sibling Docker-Based PostGIS Validation Containers guide).
  • Branch protection access. You need repository admin rights to mark the resulting status check as required.

Step-by-Step Procedure

Step 1 — Scaffold the workflow and its trigger

Create .github/workflows/spatial-validation.yml. Trigger on pull requests and on pushes to the default branch, scoped to the paths that actually hold spatial data so unrelated code changes do not spend runner minutes.

The workflow YAML below is the whole file; subsequent steps add jobs to it. Because the authoring standard for this site restricts fenced code blocks to Python and SQL, the workflow is rendered here as a Python triple-quoted string you can write straight to disk — run this snippet once and it materializes the YAML.

# scripts/write_workflow.py — emit .github/workflows/spatial-validation.yml
from pathlib import Path

WORKFLOW = r'''
name: spatial-validation

on:
  pull_request:
    paths:
      - "data/**"
      - "fixtures/**"
      - "tests/**"
      - "requirements.txt"
  push:
    branches: [main]

permissions:
  contents: read
  checks: write
  pull-requests: write

jobs:
  validate:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        dataset:
          - data/parcels.geojson
          - data/roads.geojson
          - data/zoning.geojson
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Restore pip cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ runner.os }}-py312-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            pip-${{ runner.os }}-py312-

      - name: Install geospatial toolchain
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Report toolchain versions
        run: python scripts/toolchain_smoke.py

      - name: Run spatial validation
        run: |
          pytest -q tests/ \
            --dataset "${{ matrix.dataset }}" \
            --junitxml="reports/junit-$(basename ${{ matrix.dataset }}).xml"

      - name: Upload validation report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: validation-${{ hashFiles(matrix.dataset) }}
          path: reports/
          if-no-files-found: warn

  gate:
    runs-on: ubuntu-latest
    needs: validate
    if: always()
    steps:
      - name: Require all matrix jobs to pass
        run: |
          if [ "${{ needs.validate.result }}" != "success" ]; then
            echo "Spatial validation failed — merge blocked."
            exit 1
          fi
'''

Path(".github/workflows").mkdir(parents=True, exist_ok=True)
Path(".github/workflows/spatial-validation.yml").write_text(WORKFLOW.lstrip())
print("wrote .github/workflows/spatial-validation.yml")

Verification: run python scripts/write_workflow.py, then python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/spatial-validation.yml'))". A clean exit confirms the YAML parses. Commit the file and open a pull request; the spatial-validation workflow should appear in the Checks tab.

Step 2 — Make the pytest suite matrix-aware

The workflow passes --dataset per matrix entry, so the suite must accept that flag and validate only the named layer. Add a small conftest.py.

# tests/conftest.py
import pytest

def pytest_addoption(parser):
    parser.addoption(
        "--dataset", action="store", default="fixtures/parcels.geojson",
        help="Path to the single spatial layer this job should validate",
    )

@pytest.fixture
def dataset_path(request):
    return request.config.getoption("--dataset")
# tests/test_layer.py
import geopandas as gpd
import shapely

def test_crs_declared(dataset_path):
    gdf = gpd.read_file(dataset_path)
    assert gdf.crs is not None, f"{dataset_path}: CRS undefined"
    assert gdf.crs.to_epsg() == 4326, f"{dataset_path}: expected EPSG:4326"

def test_geometry_validity(dataset_path):
    gdf = gpd.read_file(dataset_path)
    bad = ~shapely.is_valid(gdf.geometry.values)
    reasons = shapely.is_valid_reason(gdf.geometry.values[bad])
    offenders = list(zip(gdf.index[bad].tolist(), reasons))
    assert not offenders, f"{dataset_path}: invalid geometry: {offenders}"

Verification: locally run pytest -q tests/ --dataset fixtures/parcels.geojson. It should pass on clean data. The per-feature geometry logic here reuses the vectorized approach from Implementing Shapely Geometry Checks in Python — the CI job is just a runner around those checks.

Step 3 — Add a toolchain smoke check

Before validating data, confirm the runner’s geometry engine is the one you expect. A version drift caught here is far cheaper to diagnose than an unexplained validity difference deep in a test.

# scripts/toolchain_smoke.py
import shapely, pyproj, geopandas, fiona

def main() -> int:
    print(f"geopandas {geopandas.__version__}")
    print(f"shapely   {shapely.__version__}  (GEOS {shapely.geos_version_string})")
    print(f"pyproj    {pyproj.__version__}  (PROJ {pyproj.proj_version_str})")
    print(f"fiona     {fiona.__version__}  (GDAL {fiona.__gdal_version__})")
    # Fail fast if PROJ grids are unreachable — otherwise to_crs yields NaN
    data_dir = pyproj.datadir.get_data_dir()
    assert data_dir, "PROJ data directory not resolved"
    print(f"PROJ data {data_dir}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Verification: the workflow’s “Report toolchain versions” step prints these lines in the job log. Confirm the GEOS and PROJ versions match what you run locally; a mismatch here explains most “passes locally, fails in CI” reports.

Step 4 — Turn failures into PR annotations

A JUnit report is visible in the Checks tab, but inline annotations put the failure on the exact place a reviewer looks. Emit GitHub workflow commands (::error::) from a thin reporter that reads the JSON sidecar.

# scripts/annotate.py — run as a workflow step after pytest with if: always()
import json, sys
from pathlib import Path

def main() -> int:
    report = Path("reports/validation.json")
    if not report.exists():
        return 0
    findings = json.loads(report.read_text()).get("findings", [])
    for f in findings:
        # GitHub renders ::error:: lines as annotations on the PR
        print(f"::error file={f['dataset']}::"
              f"feature {f['feature_id']} failed {f['rule']}: {f['reason']}")
    if findings:
        print(f"::error::{len(findings)} spatial validation finding(s)")
        return 1
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Verification: introduce one invalid feature, push, and open the pull request. The failing feature should surface as a red annotation, the validation-report artifact should be downloadable from the run summary, and the gate job should be red — blocking the merge once the check is required.

Interpreting Results

The Checks tab shows one entry per matrix job plus the aggregate gate. Read them in that order. A single red matrix job tells you which dataset regressed; the JUnit report inside that job tells you which test; the PR annotations tell you which feature. This three-level drill-down is the payoff of the matrix design — a failure is localized to a dataset without re-running the others, because fail-fast: false lets every shard complete.

The uploaded artifact is the durable record. Long after the run’s logs age out, the reports/ bundle holds the JUnit XML and the JSON sidecar keyed by feature identifier. Feed that JSON into the severity model from Categorizing and Prioritizing Spatial Errors if you want warnings to annotate without failing the gate.

A green gate with red annotations is a misconfiguration: it means your reporter printed ::warning:: where it should have printed ::error::, or your gate job did not inspect needs.validate.result. Treat any divergence between annotations and gate color as a bug in the workflow, not in the data.

Gotchas & Edge Cases

The system GDAL on the runner is a trap. ubuntu-latest preinstalls a GDAL that changes across runner image releases. If your job ever calls apt-get install gdal-bin, you have coupled your build to an uncontrolled version. Install everything through pinned Python wheels, or move into a container. This is the number-one source of nondeterministic spatial CI.

fail-fast defaults to true. Without fail-fast: false, the first failing matrix job cancels its siblings, hiding whether other datasets also regressed. For validation you almost always want every shard to run to completion.

Cache poisoning by dataset. Never add data/** to the actions/cache path. Caching datasets means a later run validates a stale copy and reports a false pass. Cache only ~/.cache/pip.

Required checks and matrices interact awkwardly. GitHub requires each matrix job’s context by name, and those names include the matrix value, so they shift when you add a dataset. The gate job that needs: validate gives you one stable context to require, insulating branch protection from matrix churn.

Artifacts need if: always(). If the upload step runs only on success, a failing build — precisely when you most want the report — produces no artifact. Guard the upload with if: always().

Fork pull requests get a read-only token. Annotations and check writes need pull-requests: write and checks: write, which forked-PR tokens do not receive by default. For contributions from forks, use pull_request_target cautiously or accept that annotations only appear on same-repo branches.

When to Escalate

The hosted-runner, wheel-based approach on this page suits repositories whose validation runs in a few minutes on data that fits the runner. Escalate when:

  • Checks need a live database. Cross-feature topology and set-level predicates run far better in PostGIS than in GeoPandas. Provision an ephemeral database with the sibling Docker-Based PostGIS Validation Containers guide and attach it as a service to the job.
  • A specific GDAL/GEOS build is mandatory for reproducibility. When wheel versions cannot be trusted to match production, run the whole job inside your pinned image rather than installing on the bare runner.
  • Feedback comes too late in CI. If contributors routinely push broken GeoJSON, catch it before the runner ever starts by adding Pre-Commit Hooks for GeoJSON Schema Checks locally; CI then becomes the backstop rather than the first line of defense.
  • Datasets outgrow the runner. When a layer no longer fits in the runner’s memory, move whole-dataset validation off the pull request path into a scheduled job on dedicated infrastructure, keeping only diff-scoped checks on the merge gate.

Related:

Back to Continuous Integration for Spatial Validation