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.
Prerequisites
- A GitHub repository containing the spatial layers and the
pytestsuite from the parent guide on continuous integration. This page assumes those tests already exist. - A pinned dependency file. Commit a
requirements.txt(oruv.lock) pinninggeopandas==0.14.*,shapely==2.0.*,pyproj==3.6.*,fiona==1.9.*, andpytest==8.*. Caching keys off this file’s hash, so it must exist. - GeoPandas installed from wheels, not source. On
ubuntu-latestthe default binary wheels bundle GDAL (Geospatial Data Abstraction Library), GEOS, and PROJ. Do notapt-get install gdal-bin python3-gdaland thenpip 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:
- Continuous Integration for Spatial Validation — the parent workflow this page implements concretely
- Docker-Based PostGIS Validation Containers — attaching an ephemeral spatial database as a CI service
- Implementing Shapely Geometry Checks in Python — the vectorized checks the matrix jobs execute