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.
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.
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 3857print("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.
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.
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}defvalidate_crs_contract(gdf: gpd.GeoDataFrame)->None:if gdf.crs isNone:raise ValueError("CRS_UNDEFINED: synthetic geometry lacks an explicit projection.")
actual_epsg = gdf.crs.to_epsg()if actual_epsg notin 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)ifnot gdf.crs.equals(target):raise ValueError("CRS_DRIFT: EPSG code matches but the PROJ string diverges.")
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
defvalidate_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)
Synthetic output must preserve the source distribution while respecting an (ε,δ) 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
defvalidate_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.")
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.
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
deftest_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"))deftest_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)deftest_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 syntheticwith 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.
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.
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 (ε,δ) budget together, after the CRS and topology gates have already guaranteed the geometry is correct.