TL;DR: A GitHub Actions workflow for a geocoding pipeline should install dependencies, restore a geocode response cache with actions/cache, run a Python accuracy-regression check against a stored baseline (exiting non-zero on a match-rate or precision drop), matrix the check across providers, and upload the JSON report with actions/upload-artifact. This page is part of automating CI/CD sync for spatial enrichment.
The workflow
The workflow below runs on pull requests against cached fixtures for fast, deterministic feedback, and nightly on a schedule to refresh fixtures from live providers. Provider API keys are read from repository secrets and never printed. The accuracy check is fanned across a provider matrix so a regression in one provider fails only that leg.
name: geocode-accuracy-gate
on:
pull_request:
paths:
- "src/**"
- "config/**"
- "fixtures/**"
schedule:
- cron: "0 3 * * *" # nightly full run against live providers
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: geocode-gate-${{ github.ref }}
cancel-in-progress: true
jobs:
accuracy-check:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
provider: [here, mapbox, nominatim]
env:
# PR runs stay offline against cached fixtures; scheduled runs go live.
GEOCODE_MODE: ${{ github.event_name == 'schedule' && 'live' || 'cached' }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[ci]"
- name: Restore geocode response cache
uses: actions/cache@v4
with:
path: .geocode_cache
key: geocode-${{ matrix.provider }}-${{ hashFiles('fixtures/reference_sample.jsonl') }}
restore-keys: |
geocode-${{ matrix.provider }}-
- name: Run accuracy-regression check
env:
HERE_API_KEY: ${{ secrets.HERE_API_KEY }}
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
run: |
python -m pipeline.ci_check \
--provider "${{ matrix.provider }}" \
--samples fixtures/reference_sample.jsonl \
--baseline "fixtures/baseline.${{ matrix.provider }}.json" \
--report "report.${{ matrix.provider }}.json" \
--mode "${GEOCODE_MODE}"
- name: Upload accuracy report
if: always()
uses: actions/upload-artifact@v4
with:
name: accuracy-report-${{ matrix.provider }}
path: report.${{ matrix.provider }}.json
retention-days: 30
Step breakdown
| Step / key | Purpose | Failure behaviour |
|---|---|---|
on.pull_request |
Fast gate on code, config, or fixture changes | Blocks merge if the check exits non-zero |
on.schedule (cron) |
Nightly live-provider run to catch upstream drift | Alerts via a failed run; does not block merges |
concurrency |
Cancels superseded runs on the same ref | Saves runner minutes and quota |
strategy.matrix.provider |
Isolates each provider’s accuracy check | fail-fast: false lets other providers finish |
GEOCODE_MODE env |
Switches between cached fixtures and live calls | Cached on PR, live on schedule |
setup-python + cache: pip |
Reproducible interpreter and fast installs | Fails early if the version is unavailable |
actions/cache (.geocode_cache) |
Restores recorded provider responses | Cache miss falls back to restore-keys prefix |
ci_check step |
Runs the regression gate against the baseline | Non-zero exit fails the job |
secrets.*_API_KEY |
Injects provider keys for live runs only | Absent secret is an empty string, harmless in cached mode |
upload-artifact (if: always()) |
Persists the report even on failure | Report is available for post-mortem and trend charts |
Secrets handling for API keys
Store each provider key as a repository or organization secret (Settings → Secrets and variables → Actions) and reference it only through the env block of the step that needs it. Three rules keep keys safe:
- Never echo a secret. GitHub masks known secret values in logs, but constructing a URL with the key inline can still leak it through error messages. Pass keys as environment variables the Python process reads with
os.environ, not as command-line arguments that show up inpsor logs. - Scope the token. Set
permissions: contents: readat the workflow level so a compromised step cannot push commits or open releases. - Guard forks. Secrets are not exposed to workflows triggered by pull requests from forks. Because the pull-request gate runs in
cachedmode, it needs no keys — the offline fixture cache is exactly what makes the fork case safe.
The Python entrypoint it calls
The workflow calls a small argparse wrapper around the regression logic from the parent guide. It selects cached or live mode, runs the check, writes the report, and exits with the check’s code so the job status reflects the result.
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from pipeline.gate import Sample, check_regression # from the parent guide
from pipeline.geocoders import build_cached_geocoder, build_live_geocoder
def load_samples(path: Path) -> list[Sample]:
"""Load newline-delimited JSON reference records into Sample objects."""
samples: list[Sample] = []
with path.open(encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
record = json.loads(line)
samples.append(
Sample(
address=record["address"],
expected_precision=record["expected_precision"],
)
)
return samples
def main(argv: list[str] | None = None) -> int:
"""Parse args, run the accuracy gate, and return its exit code."""
parser = argparse.ArgumentParser(description="Geocoding accuracy regression gate")
parser.add_argument("--provider", required=True)
parser.add_argument("--samples", type=Path, required=True)
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--mode", choices=("cached", "live"), default="cached")
args = parser.parse_args(argv)
if args.mode == "cached":
geocode = build_cached_geocoder(args.provider, cache_dir=Path(".geocode_cache"))
else:
geocode = build_live_geocoder(args.provider, cache_dir=Path(".geocode_cache"))
samples = load_samples(args.samples)
code = check_regression(
samples=samples,
geocode=geocode,
baseline_path=args.baseline,
)
# Persist a machine-readable report regardless of pass/fail.
args.report.write_text(
json.dumps({"provider": args.provider, "mode": args.mode, "exit_code": code}),
encoding="utf-8",
)
return code
if __name__ == "__main__":
sys.exit(main())
The build_cached_geocoder reads recorded responses keyed by the SHA-256 of the normalized address and raises on a miss, guaranteeing the pull-request gate never touches the network. The build_live_geocoder calls the provider and writes each response into the same cache, so a nightly run populates the fixtures the next pull request will consume.
Vectorized pandas variant
To chart the accuracy trend across providers after the matrix finishes, download the report artifacts and reduce them into one frame:
import json
from pathlib import Path
import pandas as pd
def collect_reports(report_dir: Path) -> pd.DataFrame:
"""Load every report.*.json under a directory into a tidy DataFrame."""
rows = [json.loads(p.read_text()) for p in report_dir.glob("report.*.json")]
df = pd.DataFrame(rows)
df["passed"] = df["exit_code"] == 0
return df.sort_values("provider").reset_index(drop=True)
Edge cases
Cache miss on a fixture change
When fixtures/reference_sample.jsonl changes, the hashFiles cache key changes and the restore falls back to the restore-keys prefix, which returns a stale cache missing the new addresses. In cached mode the geocoder raises on those misses and the job fails clearly. Regenerate fixtures by running the nightly live job (or workflow_dispatch) once after adding sample addresses, then commit the refreshed cache or let the next scheduled run populate it.
A provider outage during the nightly run
The scheduled live leg can fail because a provider is down, not because accuracy regressed. Because fail-fast: false isolates each matrix leg, the other providers still report, and the failed leg is an alert rather than a merge blocker. Treat scheduled failures as tickets and inspect the uploaded report before assuming a real regression — the same reasoning behind routing flaky providers through fallback chains for failed lookups.
Integration note
This workflow is the executable form of the gate described in automating CI/CD sync for spatial enrichment: the YAML decides when the check runs and with what secrets, while the Python entrypoint reuses the baseline-comparison logic from that guide. The baseline and reference sample it consumes come from building regression corpora for geocoding accuracy. Once merges are gated, pair this with a scheduled data refresh so the enriched table stays current, not just correct.
Related
- Automating CI/CD sync for spatial enrichment — the parent guide covering the gate logic, blue-green swaps, and incremental sync this workflow drives.
- Scheduling incremental geocode refresh with cron — the scheduled counterpart that keeps the enriched dataset in sync after a merge passes this gate.
- Building regression corpora for geocoding accuracy — how the reference sample and baseline consumed by this workflow are assembled.