Gating Pull Requests with pytest-geo in GitHub Actions
A spatial test suite only prevents regressions if a failing assertion physically stops the merge. This guide sits beneath GitHub Actions spatial testing and shows exactly how to turn a pytest run of your spatial checks into a required status check on GitHub, so a pull request that breaks topology, CRS or schema validation cannot be merged until it is fixed. The parts that trip teams up are not the assertions themselves — those come from spatial assertion types — but the wiring: making the check appear on the pull request, ensuring a failing test produces a non-zero exit that the platform sees, and configuring branch protection to require the right job.
Why a green suite still lets regressions through
Three configuration gaps let a spatial regression merge even when tests exist. First, the workflow never runs on the pull request because it is triggered only on push to a branch, so GitHub has no check to require. Second, a test failure does not propagate: a continue-on-error step, a piped command that swallows the exit code, or a shell that returns the last command’s status masks the failure. Third — the most common — the suite runs and reports, but branch protection was never told to require it, so the red check is advisory. Closing all three is what makes the gate real.
Configuration reference
| Setting | Value that gates | Value that silently fails open |
|---|---|---|
| Trigger | on: pull_request |
on: push to a feature branch only |
| Step exit | run: pytest (bare) |
`run: pytest |
| Aggregation | Required check points at one summary job | Required check points at a matrix cell that may be dropped |
| Branch protection | “Require status checks to pass” enabled | Check exists but not marked required |
| Annotations | pytest + a reporter that emits ::error:: |
Plain output, no inline diff annotations |
Step-by-step implementation
The workflow below targets pytest 7+, Shapely 2.x and GeoPandas 0.14+, and produces a single required check named spatial-gate.
Step 1 — Trigger on the pull request
# .github/workflows/spatial-gate.yml
name: spatial-gate
on:
pull_request:
branches: [main] # runs on every PR targeting main
Step 2 — Run the suite so failures propagate
Run pytest as a bare step. Do not append || true, and if you pipe output, set shell: bash with pipefail so the pytest exit code — not tee’s — decides the step.
jobs:
spatial-gate:
runs-on: ubuntu-24.04
container: { image: "ghcr.io/acme/gis-test:gdal3.9.2-proj9.4.1" }
steps:
- uses: actions/checkout@v4
- run: pip install -e '.[test]'
- name: Run spatial assertions
run: pytest -q -m "not slow" --junitxml=report.xml
Step 3 — Emit inline annotations on the diff
A small conftest.py hook turns each failure into a GitHub ::error:: annotation so the reviewer sees it on the changed line, not only in the log.
# conftest.py — annotate failures inline on the PR
import os
def pytest_runtest_logreport(report):
if report.failed and os.environ.get("GITHUB_ACTIONS"):
loc = report.location # (path, lineno, domain)
print(f"::error file={loc[0]},line={(loc[1] or 0) + 1}::{report.nodeid} failed")
Step 4 — Make branch protection require the job
In the repository settings, under branch protection for main, enable “Require status checks to pass before merging” and select spatial-gate. Point it at this summary job, never at an individual matrix cell — if you later drop that engine from a matrix, a cell-level required check silently disappears and the gate opens.
Verification pattern
Prove the gate blocks by opening a throwaway pull request that introduces an invalid geometry and confirming the check goes red and the merge button is disabled. A local dry run of the exact command the workflow uses catches most issues before you push:
pytest -q -m "not slow" --junitxml=report.xml; echo "exit=$?"
An exit=1 here is what GitHub converts into a failing required check. If the suite exits 0 on known-bad data, the propagation is broken — fix Step 2 before trusting the gate.
Failure modes and edge cases
- Fork pull requests lack secrets. A
pull_requestfrom a fork runs without repository secrets; if your suite needs a database DSN, usepull_request_targetcautiously or provide a service container, or the gate errors on forks rather than gating. - Skipped tests read as passing. If fixtures are missing on the runner,
pytestmay skip the spatial tests and exit0; assert collection with--strict-markersand a minimum test count so an empty run cannot pass. - Path filters hide the check. Adding
paths:to the trigger so the workflow skips non-spatial changes means the required check never reports on those PRs — GitHub then blocks the merge waiting for a check that will never run. Use a passing “no-op” job for filtered paths. - Matrix cell as the required check. Requiring
spatial-gate (gdal3.8)instead of the summary job couples the gate to one engine version. continue-on-errorupstream. A lenient earlier step that installs the stack withcontinue-on-error: truecan let the suite run against a broken environment and pass trivially.
Conclusion
A pull request gate is three correct choices — trigger on pull_request, let the pytest exit code propagate, and mark the summary job required in branch protection — plus inline annotations so failures land on the diff. With those in place, a spatial regression cannot reach main. For the broader workflow shape this fits into, return to GitHub Actions spatial testing.