Syncing Synthetic Data Generation with GitHub Actions

A GitHub Actions runner that generates synthetic geometry without an explicit projection contract will silently rewrite every coordinate — this page shows how to gate the workflow so coordinate reference system drift and topology violations become loud, blocking failures instead of corruption discovered three stages downstream.

Part of CI/CD Integration for Spatial Data: where the parent area covers the full commit-to-promotion lifecycle for spatial artifacts, this page resolves one concrete task — wiring a synthetic generation job into GitHub Actions so that projection, topology, and statistical fidelity are enforced inside the runner before any artifact is published, and the exact failures that make naive runners untrustworthy.

Root Cause: Why Runners Silently Shift Projections

Synthetic spatial generators inherit environment-level GDAL and PROJ defaults rather than an explicit contract. PROJ carries the transformation database that defines what a coordinate means, so when a GitHub Actions runner cannot resolve PROJ_LIB, ships a mismatched PROJ database version, or falls back to a different GDAL minor release than the developer’s machine, the generator quietly emits geometry in EPSG:4326 or a local planar approximation instead of the intended projection. This is the classic coordinate reference system drift that the data-contract layer exists to forbid, surfacing here because the runner is exactly where the contract is least likely to be pinned.

The output is well-formed GeoJSON. It passes column-level schema assertions. It may even render. But it is geometrically wrong by hundreds of metres, and the error only appears when a buffer self-intersects, a spatial join returns zero matches, or a Voronoi tessellation inverts a ring. A second, correlated failure rides along: distorted spatial densities corrupt the differential privacy budget accounting, because noise calibrated against one density surface is mis-scaled when the geometry shifts. The runner must therefore treat geometric integrity as a non-negotiable gating condition, not a post-hoc observation. Three gates do that work: an explicit projection contract, topology validation, and a statistical-plus-privacy check, executed in sequence with a hard stop on the first violation.

GitHub Actions sync job: provision and generate, then three sequential blocking gates, then promote — any gate failure routes to an if-failure diagnostics upload Left to right: a Provision step (checkout@v4, setup-python with pip cache, pinned GDAL, PROJ and GeoPandas on a pinned ubuntu-24.04 image) feeds a Generate step (generate_spatial.py emitting synthetic.geojson in EPSG:3857), which feeds Gate 1 (CRS contract — pyproj CRS.equals on full PROJ strings, raising CRS_DRIFT), which on pass feeds Gate 2 (Topology — is_valid, buffer(0) repair, drop slivers), which on pass feeds Gate 3 (Distribution and privacy — two-sample KS test plus the epsilon-delta budget attestation). Only after all three gates pass does the artifact promote to a versioned, content-hash-stamped store. Three dashed failure arrows drop from the gates to a single diagnostics step guarded by if: failure(), which uploads the geojson, validation report and CRS audit log, fails the job with a non-zero exit, and promotes nothing. A legend marks solid arrows as pass and dashed arrows as the hard-stop failure path. pass hard-stop failure path (if: failure()) Provision ubuntu-24.04 · checkout@v4 setup-python · pip cache pinned GDAL·PROJ·deps PROJ_LIB resolves Generate generate_spatial.py --crs EPSG:3857 emits synthetic.geojson GATE 1 · HARD STOP CRS contract CRS.equals() on PROJ str not just EPSG code raises CRS_DRIFT GATE 2 · HARD STOP Topology is_valid · buffer(0) drop sub-floor slivers raises TOPOLOGY_FAILURE GATE 3 · HARD STOP Distribution + privacy two-sample KS test (ε, δ) budget attestation raises DISTRIBUTION_DRIFT Promote versioned artifact store content-hash stamped snapped coords · stable hash pass pass promote Upload diagnostics — if: failure() geojson + validation_report.json + crs_audit.log · retention 30d non-zero exit — build red, nothing promoted first gate violation → diagnostics, no promotion

Minimal Reproducer: Confirming the Drift

Before adding gates, reproduce the silent shift so the fix is verifiable. The snippet below generates a point in a metric projection, then re-reads it through a runner whose PROJ data directory is unset — the round-trip lands the coordinate in the wrong place without raising.

python
import os
import geopandas as gpd
from shapely.geometry import Point

# Author intent: a point in Web Mercator metres.
gdf = gpd.GeoDataFrame(geometry=[Point(-13_167_000, 4_038_000)], crs="EPSG:3857")
gdf.to_file("synthetic.geojson", driver="GeoJSON")

# GeoJSON RFC 7946 stores WGS84; a runner that re-reads and assumes the file
# is already in the target CRS skips the reprojection and is off by ~10^7 m.
back = gpd.read_file("synthetic.geojson")
print("declared CRS :", back.crs)               # EPSG:4326, not 3857
print("PROJ data    :", os.environ.get("PROJ_LIB", "<unset on this runner>"))
print("x coordinate :", back.geometry.x.iloc[0])  # ~-118.3 degrees, silently

If PROJ_LIB prints <unset> and the declared CRS is not the one you generated in, the runner is a drift source. The fix makes that state impossible to promote.

Fix: Three Gates Inside the Workflow

The solution is a small validation module plus a GitHub Actions workflow that calls it. Each function raises on violation, so a non-zero exit halts the job before promotion.

Gate 1 — Enforce an explicit CRS contract

Generation must reject implicit projections and verify PROJ-string equivalence, not just the EPSG code, because two definitions can share a code yet differ in datum.

python
import geopandas as gpd
from pyproj import CRS

TARGET_CRS = "EPSG:3857"            # Web Mercator: motivated by tile-serving consumers
ALLOWED_EPSG = {3857, 4326, 32633}

def validate_crs_contract(gdf: gpd.GeoDataFrame) -> None:
    if gdf.crs is None:
        raise ValueError("CRS_UNDEFINED: synthetic geometry lacks an explicit projection.")

    actual_epsg = gdf.crs.to_epsg()
    if actual_epsg not in ALLOWED_EPSG:
        raise ValueError(f"CRS_MISMATCH: expected one of {ALLOWED_EPSG}, got {actual_epsg}.")

    # Code equality is not enough — compare full PROJ definitions to catch datum shifts.
    target = CRS.from_user_input(TARGET_CRS)
    if not gdf.crs.equals(target):
        raise ValueError("CRS_DRIFT: EPSG code matches but the PROJ string diverges.")

Gate 2 — Validate topology before promotion

Invalid geometries serialize cleanly and propagate into spatial joins and ML feature stores. Check validity against the OGC Simple Features rules, then enforce planar constraints for any routing or network use.

python
import geopandas as gpd

def validate_topology(gdf: gpd.GeoDataFrame, min_area: float = 1e-6) -> gpd.GeoDataFrame:
    invalid = ~gdf.geometry.is_valid
    if invalid.any():
        ids = gdf.index[invalid].tolist()
        raise ValueError(f"TOPOLOGY_FAILURE: {invalid.sum()} invalid geometries at {ids[:10]}.")

    # Repair near-degenerate rings, then drop slivers below the area floor.
    gdf = gdf.copy()
    gdf.geometry = gdf.geometry.buffer(0)
    gdf = gdf[gdf.geometry.area > min_area]
    return gdf.reset_index(drop=True)

Gate 3 — Gate on distribution fidelity and the privacy budget

Synthetic output must preserve the source distribution while respecting an (ε,δ)(\varepsilon, \delta) budget. A two-sample Kolmogorov–Smirnov test catches over-smoothing or collapse; the same statistical discipline underpins the spatial realism metrics used to score utility elsewhere in the pipeline.

python
import numpy as np
from scipy.stats import ks_2samp

def validate_distribution(source: np.ndarray, synthetic: np.ndarray, alpha: float = 0.05) -> None:
    stat, p_value = ks_2samp(source, synthetic)
    if p_value < alpha:
        raise ValueError(
            f"DISTRIBUTION_DRIFT: KS p-value {p_value:.4f} < alpha {alpha}; "
            "synthetic distribution diverges from source."
        )

Pin the runner, then orchestrate the gates

ubuntu-latest floats the geospatial stack, so pin GDAL, PROJ, and the Python libraries, then run the three gates in sequence and upload diagnostics only on failure.

yaml
name: Sync Synthetic Spatial Generation
on:
  push:
    branches: [main, "release/**"]
  pull_request:
    branches: [main]

permissions:
  contents: read
  checks: write

jobs:
  validate-and-publish:
    runs-on: ubuntu-24.04          # pinned image, not ubuntu-latest
    env:
      TARGET_CRS: "EPSG:3857"
      PRIVACY_EPSILON: "1.0"
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"

      - name: Install pinned geospatial stack
        run: |
          sudo apt-get update
          sudo apt-get install -y gdal-bin libgdal-dev proj-bin libproj-dev
          pip install geopandas==0.14.* pyproj==3.6.* shapely==2.0.* scipy==1.11.*

      - name: Verify environment determinism
        run: |
          gdalinfo --version
          projinfo EPSG:3857 >/dev/null && echo "PROJ db resolves target CRS"
          python -c "import pyproj; print('PROJ data:', pyproj.datadir.get_data_dir())"

      - name: Generate synthetic spatial data
        run: python scripts/generate_spatial.py --out ./output/synthetic.geojson --crs $

      - name: Run spatial validation suite
        run: |
          python -c "
          import geopandas as gpd
          from validation import validate_crs_contract, validate_topology, validate_distribution
          gdf = gpd.read_file('./output/synthetic.geojson')
          validate_crs_contract(gdf)
          gdf = validate_topology(gdf)
          validate_distribution(gdf['attribute'].values, gdf['synthetic_attribute'].values)
          print('ALL_SPATIAL_CHECKS_PASSED')
          "

      - name: Gate on privacy attestation
        run: |
          test -f ./output/compliance_attestation.json || { echo "PRIVACY_GATE_FAILED: missing attestation"; exit 1; }
          python scripts/audit_privacy_budget.py \
            --attestation ./output/compliance_attestation.json \
            --max-epsilon $

      - name: Upload validation diagnostics
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: spatial-validation-diagnostics
          path: |
            ./output/*.geojson
            ./logs/validation_report.json
            ./logs/crs_audit.log
          retention-days: 30

Verification: A CI Assertion That Drift Cannot Pass

A green checkmark is only trustworthy if a known-bad artifact turns it red. Add a pytest case that feeds each gate a deliberately corrupted frame and asserts it raises — this is the test that protects the protection.

python
import geopandas as gpd
import numpy as np
import pytest
from shapely.geometry import Point, Polygon
from validation import validate_crs_contract, validate_topology, validate_distribution


def test_crs_contract_rejects_drift():
    drifted = gpd.GeoDataFrame(geometry=[Point(0, 0)], crs="EPSG:4326")
    # 4326 is allowed but is NOT the target; equals() against the target must fail.
    with pytest.raises(ValueError, match="CRS_DRIFT|CRS_MISMATCH"):
        validate_crs_contract(drifted.to_crs("EPSG:32633"))


def test_topology_gate_blocks_self_intersection():
    bowtie = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)])   # self-intersecting
    gdf = gpd.GeoDataFrame(geometry=[bowtie], crs="EPSG:3857")
    with pytest.raises(ValueError, match="TOPOLOGY_FAILURE"):
        validate_topology(gdf)


def test_distribution_gate_flags_collapse():
    rng = np.random.default_rng(7)
    source = rng.normal(0, 1, 5000)
    collapsed = rng.normal(0, 0.05, 5000)               # mode-collapsed synthetic
    with pytest.raises(ValueError, match="DISTRIBUTION_DRIFT"):
        validate_distribution(source, collapsed)

Run it as its own job (pytest tests/test_gates.py) so the gates are exercised on every commit, not only when generation happens to emit something bad.

Edge Cases and Gotchas

Antimeridian geometries. A synthetic feature that crosses ±180° longitude will pass is_valid yet serialize as a polygon that wraps the wrong way around the globe, exploding its bounding box to the full width of the map. GeoJSON requires antimeridian-spanning geometry to be split into a MultiPolygon at the dateline; add a pre-promotion check that flags any single ring whose longitudinal span exceeds 180° and split it before validate_topology runs.

Null Island and the (0, 0) sink. When generation fails to assign coordinates, many code paths default to Point(0, 0) — a valid geometry off the coast of Africa. The topology gate will not catch it because it is geometrically sound. Add an explicit assertion that no synthetic point falls within a small bounding box around 0, 0 unless your study area legitimately includes it, and treat any pile-up of points there as a generator bug rather than data.

Floating-point precision at datum boundaries. Reprojecting near a UTM zone edge or a datum-transformation grid boundary can produce coordinates that differ in the last few decimal places between GDAL minor versions. Two runners then disagree byte-for-byte, breaking reproducibility tests and privacy attestations. Snap stored coordinates to a fixed grid (for example, six decimal places in degrees or millimetre precision in metres) at serialization so the artifact hash is stable across the pinned-but-not-identical stacks a fleet of runners may carry.

Frequently Asked Questions

Why does the runner drift to EPSG:4326 when I generated in a metric CRS?

Because GeoJSON (RFC 7946) stores coordinates in WGS84, and a runner that re-reads the file without an explicit reprojection assumes the data is already in its working CRS. Combined with an unset PROJ_LIB, the generator never applies the intended transform. The Gate 1 contract forbids promotion of any frame whose full PROJ definition does not equal the declared target, so the drift fails the build instead of shipping.

Is checking the EPSG code enough to catch projection problems?

No. Two CRS definitions can share an EPSG code while differing in datum or axis order, which is exactly the silent shift that corrupts downstream joins. Compare full PROJ strings with pyproj’s CRS.equals, not integer codes, so a datum mismatch raises CRS_DRIFT rather than passing as a match.

Why pin ubuntu-24.04 instead of using ubuntu-latest?

ubuntu-latest floats its GDAL and PROJ packages, and PROJ carries the transformation database that defines coordinate meaning. An unannounced bump can change reprojection output by metres, so a pinned image plus pinned apt and pip versions is the only way to guarantee that two runs of the same commit produce byte-identical geometry.

How does CRS drift corrupt the differential privacy budget?

Noise in a spatial privacy mechanism is calibrated against the density of the geometry it protects. When the projection shifts, areas and densities change, so noise sized for the intended surface is mis-scaled — under-protecting dense regions or destroying utility in sparse ones. Gate 3 therefore validates distribution fidelity and the declared (ε,δ)(\varepsilon, \delta) budget together, after the CRS and topology gates have already guaranteed the geometry is correct.