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

  1. Fork pull requests lack secrets. A pull_request from a fork runs without repository secrets; if your suite needs a database DSN, use pull_request_target cautiously or provide a service container, or the gate errors on forks rather than gating.
  2. Skipped tests read as passing. If fixtures are missing on the runner, pytest may skip the spatial tests and exit 0; assert collection with --strict-markers and a minimum test count so an empty run cannot pass.
  3. 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.
  4. Matrix cell as the required check. Requiring spatial-gate (gdal3.8) instead of the summary job couples the gate to one engine version.
  5. continue-on-error upstream. A lenient earlier step that installs the stack with continue-on-error: true can 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.