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.

Why a topology defect survives human review Two map panes. The left pane draws a parcel ring that closes cleanly. The right pane draws the same parcel after two branches snapped the shared vertex differently, producing a bowtie where the ring crosses itself; the crossing point is marked. Both panes look almost identical at map scale, which is why the defect passes review. VALID RING v0 Every edge meets the next one exactly once; is_valid returns True. BOWTIE AFTER MERGE self-intersection The ring crosses itself, so area and spatial joins silently go wrong. Both shapes fill the same footprint on a basemap. Only explain_validity names the crossing.

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.
The five stages of the topology gate A left-to-right chain of five stages: a paths filter narrows the trigger, a diff against the merge base resolves the changed layers, is_valid runs over every geometry, each violation is printed as a workflow command, and a non-zero exit blocks the merge. Trigger paths filter on .gpkg changed files Resolve diff vs merge base layer list Validate is_valid per geometry violations Annotate ::error on the diff check run Block non-zero exit Each stage maps to one block of the workflow file below.

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.
How a violation reaches the reviewer A sequence between the pull request, the Actions runner, the validator script and the reviewer. The runner checks out full history, the validator emits a workflow command per invalid geometry, GitHub renders it as an inline annotation, and the required check keeps the merge button disabled until the geometry is fixed. Pull request Runner Validator Reviewer opens with a changed .gpkg hands over the changed layers explain_validity per geometry ::error file=…,line=1 one command per invalid feature inline annotation on the diff exit 1 β€” merge stays blocked The exit code blocks the merge; the workflow commands are what make the block actionable.

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