Topology Validation Gates in GitHub Actions

A topology gate is a GitHub Actions check that opens every changed vector layer, finds invalid geometries, writes each violation onto the pull request diff, and fails so the merge cannot proceed. This page is a focused companion to CI/CD validation pipelines for spatial repositories.

Concept & Context

Topology validity is the one spatial defect that is both common and invisible. A self-intersecting parcel boundary, a polygon with an unclosed ring, or a bowtie created when two branches snapped a shared vertex differently all render fine in a map viewer yet break spatial joins, area statistics, and tiling jobs downstream. Text-based review cannot see them because the change lives inside a binary GeoPackage, and a human skimming a pull request has no way to spot a ring that crosses itself. The gate exists to make that defect loud.

The broader pipeline described in the validation pipelines guide runs several checks β€” CRS, schema, file size, topology β€” in fail-fast order. This page zooms into the topology stage alone and, crucially, into how to surface its findings. A gate that only fails is a gate reviewers resent; a gate that annotates the exact feature and coordinate is one they thank you for. GitHub’s workflow-command protocol lets a plain Python script write ::error lines that the platform renders as inline annotations on the diff, so the fix is a click away rather than an archaeology expedition through CI logs.

Core Validation Steps

The gate follows five steps, each mapped directly to a block in the implementation below.

  1. Trigger narrowly. A paths filter restricts the workflow to pull requests that actually touch .gpkg or .geojson files, so unrelated commits never spin up a runner.
  2. Resolve changed layers. Diff the head commit against the merge base and keep only the changed vector files. The full-history checkout makes that diff possible.
  3. Validate topology. For each changed file and each layer inside it, run is_valid on every geometry and capture explain_validity output β€” the human-readable reason and the offending coordinate β€” for anything that fails.
  4. Annotate the diff. For each violation, print a ::error file=...:: workflow command so GitHub attaches the message to the file in the pull request review view.
  5. Fail the check run. Exit non-zero when any violation was found; a required status check then blocks the merge button.

Working Implementation

The workflow YAML and the Python validator it invokes are shown below, kept clearly separate. The YAML handles checkout, environment, and change detection; the Python does the geometry work and emits annotations.

# .github/workflows/topology-gate.yml
name: Topology Validation Gate
on:
  pull_request:
    branches: [main]
    paths: ["**/*.gpkg", "**/*.geojson"]

jobs:
  topology:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout with full history
        uses: actions/checkout@v4
        with:
          fetch-depth: 0          # needed to diff against the merge base

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"

      - name: Install pinned spatial toolchain
        run: pip install geopandas==0.14.4 shapely==2.0.4 fiona==1.9.6

      - name: Restore DVC artifacts
        run: |
          pip install dvc==3.51.2
          dvc pull --quiet
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.DVC_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DVC_SECRET }}

      - name: List changed vector layers
        id: changed
        run: |
          git diff --name-only origin/${{ github.base_ref }} HEAD \
            | grep -E '\.(gpkg|geojson)$' > changed.txt || true
          echo "files=$(tr '\n' ' ' < changed.txt)" >> "$GITHUB_OUTPUT"

      - name: Run topology gate
        if: steps.changed.outputs.files != ''
        run: python check_topology.py ${{ steps.changed.outputs.files }}
#!/usr/bin/env python3
"""check_topology.py β€” fail a GitHub Actions run on invalid geometries and
annotate each violation on the pull request diff via workflow commands."""
import sys
import geopandas as gpd
import fiona
from shapely.validation import explain_validity


def annotate(path: str, layer: str, feature_idx, reason: str) -> None:
    """Emit a GitHub Actions error annotation bound to the changed file.

    The ::error file= workflow command renders inline in the PR diff view.
    Newlines are escaped as %0A per the workflow-command spec so the
    multi-line detail survives.
    """
    title = f"Invalid geometry in layer '{layer}'"
    message = f"feature {feature_idx}: {reason}".replace("\n", "%0A")
    print(f"::error file={path},title={title}::{message}")


def check_layer(path: str, layer: str) -> int:
    gdf = gpd.read_file(path, layer=layer)
    if len(gdf) == 0:
        # An empty layer usually means dvc pull restored a pointer, not bytes.
        print(f"::error file={path}::layer '{layer}' has zero features "
              f"(check that DVC artifacts were pulled)")
        return 1

    violations = 0
    validity = gdf.geometry.is_valid
    for idx in gdf.index[~validity]:
        geom = gdf.geometry.loc[idx]
        reason = explain_validity(geom)          # e.g. "Self-intersection[151.2, -33.8]"
        annotate(path, layer, idx, reason)
        violations += 1

    status = "PASS" if violations == 0 else f"FAIL ({violations})"
    print(f"  {path} :: {layer} -> {status} [{len(gdf)} features]")
    return violations


def main(paths: list[str]) -> int:
    total = 0
    for path in paths:
        try:
            layers = fiona.listlayers(path)
        except Exception as exc:                 # unreadable file is itself a failure
            print(f"::error file={path}::could not open file: {exc}")
            total += 1
            continue
        for layer in layers:
            total += check_layer(path, layer)

    if total:
        print(f"::error::Topology gate failed: {total} invalid geometries found")
        return 1
    print("Topology gate passed: all changed layers valid")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

The key detail is annotate. GitHub parses any line beginning with ::error file= and, when the referenced path is part of the pull request diff, pins the message to that file. Escaping newlines as %0A keeps the feature index, reason, and coordinate together in a single annotation. Because explain_validity returns the offending coordinate, the reviewer sees exactly where the ring folds over itself.

Validation & Output Verification

Confirm the gate behaves correctly before you make it a required check, so you never discover a false pass in production.

  • Force a known-bad fixture. Commit a GeoPackage containing a deliberate bowtie polygon on a test branch, open a pull request, and verify the run fails and an annotation appears on the file. python check_topology.py fixtures/bowtie.gpkg should print a Self-intersection line and exit 1.
  • Verify the exit code locally. Run python check_topology.py good.gpkg; echo $? and confirm it prints 0, then repeat with the bad fixture and confirm 1. The exit code is the entire contract with CI.
  • Assert the annotation format. Pipe the run output through grep '::error file=' and check the path matches a file in the diff; a mistyped path yields a log line but no inline annotation.
  • Confirm the required check. In branch protection, verify the topology job name is listed under required status checks. A green-looking merge button on a failing run means the rule is missing.

Failure Modes

Symptom: the run fails with fatal: bad revision 'origin/main' at the diff step. Root cause: the checkout was shallow, so the merge base is unreachable. Fix: set fetch-depth: 0 on actions/checkout.

Symptom: every layer reports zero features and the gate fails. Root cause: dvc pull did not restore the real bytes, usually a missing remote credential, so GeoPandas opened an empty placeholder. Fix: add the DVC remote secrets to the workflow and confirm the pull step succeeds.

Symptom: the run exits 1 but the pull request merges anyway. Root cause: the job is not a required status check. Fix: add the job name to branch protection so a red run blocks the merge, exactly as covered in the parent validation pipelines guide.

Symptom: violations show in the log but no inline annotation appears. Root cause: the file= path in the ::error command does not match a path in the pull request diff, or the file was not part of this change. Fix: emit the repository-relative path exactly as git diff reports it.

Back to CI/CD Validation Pipelines for Spatial Repositories