GitHub Actions for Scheduled Spatial Data Refresh

This page solves one exact task: standing up a scheduled GitHub Actions workflow that runs a lightweight spatial refresh on a cron — pulling demographic updates, revalidating coordinates, and publishing checkable artifacts — without provisioning an orchestration cluster.

Not every refresh needs Airflow. A daily freshness check that re-pulls changed ACS rows, revalidates a coordinate set, and publishes a small artifact is a single linear job, and a scheduled GitHub Actions workflow runs it for free on managed runners while doubling as the CI that gates code changes. The heavy, multi-stage production graph belongs in Airflow — see orchestrating spatial pipelines with Airflow — but this cron-triggered workflow is the right tool for the lightweight end of the spectrum.

Prerequisites

Before wiring up the workflow you need:

  • A repository with Actions enabled and permission to add workflow files under .github/workflows/.
  • Repository or environment secrets for the credentials the job needs — a Census API key and the object-store keys the publish step writes with. Never hard-code these in the YAML.
  • Python packages the job installs: geopandas, shapely, pyproj, pandas, and requests. Pin them in a requirements.txt so the cached environment is reproducible.
  • A defined artifact target. The job publishes to the same GeoParquet data lake the rest of the platform reads, or attaches artifacts to the run for inspection.
Where a CI runner is the right scheduler, and where it stops being one A CI workflow suits refreshes that finish inside the job timeout, need no shared state between runs, fan out no wider than the runner limit and tolerate minute-level scheduling drift. Beyond any of those — long routing rebuilds, backfills over hundreds of partitions, or dependencies between tasks — a dedicated orchestrator earns its operational cost. A CI runner is a scheduler until one of four limits bites dimension CI workflow handles move to an orchestrator when runtime minutes to about an hour a routing rebuild runs for hours shape a linear sequence of steps branches rejoin and retry apart fan-out a matrix of tens of jobs hundreds of partitions per run state none between runs backfills, watermarks, catch-up The attraction is real: the refresh lives beside the code that defines it, reviews go through the same pull request, and there is no scheduler to operate. That is worth a lot for a nightly extract. The trap is growth: a workflow that quietly acquires dependencies between jobs has become an orchestrator without any of the tooling one provides.

Configuration and execution parameters

The table lists the workflow settings that govern when the job runs, how it fans out, and how it fails. GitHub-hosted runners impose hard limits that shape the design.

Parameter Setting Notes
on.schedule.cron "0 6 * * *" Daily 06:00 UTC. Cron is always UTC; there is no timezone field.
on.workflow_dispatch enabled Manual trigger for backfills and testing without waiting for the cron.
timeout-minutes 30 Kills a hung runner. Hosted jobs hard-cap at 6 hours regardless.
strategy.matrix.region [midwest, south, west, northeast] One parallel job per region.
strategy.fail-fast false One region’s failure must not cancel the others.
strategy.max-parallel 4 Bounds concurrent API load so the Census endpoint is not throttled.
concurrency.group refresh-${{ github.ref }} Prevents overlapping scheduled runs from racing.
permissions contents: read Least-privilege token; grant only what the job needs.
Artifact retention 14 days Keep long enough to compare against the next run’s checksum.

Two facts about hosted runners drive the design. Scheduled crons run in UTC only — a “6 AM” job means 06:00 UTC, not local time — and the cron scheduler can lag by several minutes under load, so never assume exact-minute execution. And a job that runs longer than timeout-minutes is killed with no partial credit, which is why heavy routing work stays in Airflow and only lightweight validation and publishing runs here.

Annotated implementation

The workflow below runs daily, fans out over regions with a matrix, caches the pip environment so geospatial wheels are not rebuilt each run, injects secrets as environment variables, runs a Python refresh step, and uploads a checksummed artifact. Secrets are referenced through the secrets context and never echoed.

yaml
name: scheduled-spatial-refresh

on:
  schedule:
    - cron: "0 6 * * *"        # daily 06:00 UTC (cron is always UTC)
  workflow_dispatch: {}         # allow manual runs for backfills

permissions:
  contents: read                # least privilege

concurrency:
  group: refresh-${{ github.ref }}
  cancel-in-progress: false     # let an in-flight refresh finish

jobs:
  refresh:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      fail-fast: false          # one region failing must not cancel the rest
      max-parallel: 4
      matrix:
        region: [midwest, south, west, northeast]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"          # cache the pip download layer

      - name: Cache geo wheels
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: geo-${{ runner.os }}-${{ hashFiles('requirements.txt') }}

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run refresh
        env:
          CENSUS_API_KEY: ${{ secrets.CENSUS_API_KEY }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: python -m refresh.run --region "${{ matrix.region }}"

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: refresh-${{ matrix.region }}
          path: out/${{ matrix.region }}.parquet
          retention-days: 14

The Python step the workflow invokes is a small, single-purpose refresh: pull the changed demographic rows, revalidate the coordinates against WGS84 bounds and geometry validity, and write a GeoParquet artifact with a checksum for later verification. It asserts a CRS before any spatial operation, exactly as the data validation rules for store coordinates require.

python
# refresh/run.py
import argparse
import hashlib
import os
import geopandas as gpd
from pyproj import CRS

STORAGE_CRS = CRS.from_epsg(4326)


def refresh_region(region: str) -> str:
    api_key = os.environ["CENSUS_API_KEY"]        # injected from repo secrets
    acs = pull_changed_acs(region, api_key)       # small delta, not a full load
    coords = load_coordinates(region)

    if coords.crs is None:                        # fail loudly on missing CRS
        raise ValueError(f"{region}: coordinates missing CRS")
    coords = coords.to_crs(STORAGE_CRS)

    in_bounds = coords.geometry.x.between(-180, 180) & coords.geometry.y.between(-90, 90)
    valid = coords[coords.geometry.is_valid & in_bounds].copy()
    dropped = len(coords) - len(valid)
    if dropped:
        print(f"{region}: dropped {dropped} invalid/out-of-bounds coordinates")

    attributed = valid.merge(acs, on="geoid", how="left")
    out = gpd.GeoDataFrame(attributed, geometry="geometry", crs=STORAGE_CRS)

    path = f"out/{region}.parquet"
    os.makedirs("out", exist_ok=True)
    out.to_parquet(path)

    digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
    print(f"{region}: wrote {len(out)} rows, sha256={digest}")
    return digest


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", required=True)
    args = ap.parse_args()
    refresh_region(args.region)

The demographic delta this job pulls is the incremental counterpart to the full ACS sync; the scheduled workflow keeps the coordinate set fresh between the heavier Airflow runs rather than re-attributing every catchment nightly.

Secrets and credential handling

Credentials are the part most workflows get wrong. Three rules keep the refresh from leaking or over-privileging.

  • Never inline a secret in YAML. Reference it through ${{ secrets.NAME }} and inject it as a step-scoped env value, as the Run refresh step does. Anything committed to the workflow file is in the repository history forever, including on public forks.
  • Scope the token to the job. The top-level permissions: contents: read strips the default write-heavy GITHUB_TOKEN down to what a read-and-publish job actually needs. Grant additional scopes only on the step that requires them.
  • Prefer short-lived credentials over static keys. For the object-store write, an OIDC trust relationship (permissions: id-token: write plus a cloud role assumption) issues a token that expires with the run, so there is no long-lived AWS_SECRET_ACCESS_KEY to rotate or leak. Static keys in secrets are acceptable for lower-risk endpoints like the Census API, whose key is rate-limited rather than write-capable.

The fork boundary matters for scheduled and pull-request workflows alike: a workflow triggered from a fork cannot read the repository’s secrets by design, so a KeyError on CENSUS_API_KEY in a PR check is expected, not a misconfiguration. Gate the credentialed publish step behind the schedule and workflow_dispatch triggers, and let fork PRs run only the validation that needs no secrets.

Short-lived credentials, requested at run time The workflow presents its identity token to the cloud provider, which checks the repository and branch claims against a trust policy and returns a credential valid for one hour. Nothing long-lived is stored in the repository, and revoking access is a change to the trust policy rather than a rotation of secrets. The best stored secret is the one that was never stored workflow run identity token: repo, branch, workflow name presents cloud trust policy accepts only this repo on the default branch issues 1-hour credential write to one prefix, read two others What this removes no long-lived key in repository secrets · no rotation calendar · no credential in a log line · revoking access is an edit to the trust policy, effective on the next run Where a provider offers no such federation, the fallback is a narrowly scoped key in the secret store with a rotation job — and the rotation job is itself a scheduled workflow, which is a good test of the setup.

Caching geospatial dependencies

Geospatial wheels are heavy — geopandas drags in pyproj, shapely, and fiona, each carrying compiled GEOS/PROJ/GDAL binaries — and reinstalling them on every scheduled run wastes minutes of runner time. Two cache layers cut that cost. The cache: "pip" option on setup-python caches pip’s download directory, and the explicit actions/cache step keyed on hashFiles('requirements.txt') persists the resolved wheels across runs so they are only re-fetched when a pinned version changes. Keying on the requirements hash is what makes the cache correct: a dependency bump changes the hash, invalidates the cache, and forces a clean install, while an unchanged manifest reuses the warm cache. Pin exact versions rather than ranges so the cache key is stable and the environment is reproducible from run to run.

Cold and warm runs of the same refresh A cold run spends 3 minutes 40 seconds installing geospatial dependencies, 25 seconds on the checkout and 4 minutes 10 on the actual refresh. With the dependency cache warm the install drops to 22 seconds, cutting total runtime from 8 minutes 15 to 4 minutes 57 without touching the work itself. Half a cold run is spent compiling wheels you already had cold · 8 min 15 s install deps · 3:40 refresh · 4:10 warm · 4 min 57 s refresh · 4:10 — unchanged checkout cached install · 0:22 Key the cache on the lock file, never on the branch: a lock-file key means an unchanged dependency set always hits, and a changed one always misses, which is exactly the behaviour you want. Geospatial wheels are the extreme case — the native libraries behind them are large, and building them from source on every scheduled run is the single most common waste in a spatial CI job. Timings from a nightly refresh on a standard two-core hosted runner.

Failure modes and debugging

Symptom Cause Fix
Job silently stops mid-run Exceeded timeout-minutes or the 6-hour hosted cap Move heavy work to Airflow; keep this job lightweight and bounded.
KeyError on a secret Secret undefined, or a fork PR with no secret access Define the secret at repo/environment scope; forks cannot read secrets by design.
One region cancels all regions fail-fast left at its default of true Set strategy.fail-fast: false.
Cron never fires Scheduled workflows pause after 60 days of repo inactivity Push a commit or manually run to reactivate the schedule.
Wheels rebuild every run (slow) Cache key not keyed on requirements.txt Key the cache on hashFiles('requirements.txt').
Artifact upload fails on large files Artifact exceeds size limits Publish to object storage instead and upload only a checksum manifest.
403 from the object store Credential scope too narrow or wrong environment Grant write scope to the exact bucket/prefix; use environment secrets for prod.

The most surprising failure is the paused schedule: GitHub disables scheduled workflows in a repository with no activity for 60 days, and the cron simply stops firing with no error. A weekly commit, a keepalive job, or an occasional manual workflow_dispatch keeps it live.

Verification

Confirm the workflow did what it should before trusting its output.

  1. Workflow status. The run is green across every matrix leg. A single red region under fail-fast: false means the others succeeded but that region needs triage — check its logs, not the whole run.
  2. Artifact checksum. Compare the sha256 printed by each region against the prior run. An unchanged checksum on a day with no upstream change confirms determinism; an unexpected change flags drift worth investigating.
  3. Row-count log line. Each region logs rows written and coordinates dropped. A sudden spike in drops signals an upstream schema or geometry problem.
  4. Reproduce locally. Run python -m refresh.run --region midwest locally with the same pinned requirements.txt and confirm the checksum matches the runner’s — proof the environment is reproducible and not runner-dependent.
bash
# locally, against the same pinned requirements
python -m refresh.run --region midwest
# -> midwest: wrote 4213 rows, sha256=9f2c...  (must match the workflow's log)

A matching checksum between the runner and a local run is the strongest signal: it proves the cached environment is reproducible and that the refresh depends only on its inputs, not on the runner. Feeding that checksum into a cross-run comparison is exactly how the drift monitoring gate turns a scheduled job into an early-warning system for upstream data problems.

Frequently Asked Questions

Will a scheduled workflow fire exactly on time?

No, and no scheduler on shared infrastructure will. Scheduled runs queue behind whatever else the platform is running, and delays of several minutes are routine at popular times such as the top of the hour. Design the job so lateness is harmless: derive the run’s logical date from the schedule rather than from the wall clock at start, make the work idempotent, and avoid schedules that assume a previous run has already finished. Where a hard deadline genuinely exists, the schedule should trigger earlier than the deadline by more than the worst delay you have observed.

How should a failed scheduled run be surfaced?

Somewhere a person will see it without looking, which usually means a chat channel rather than an email nobody filters. Two properties matter more than the transport: the alert should carry the run’s logical date and the failing step, so it can be acted on without opening the run, and repeated failures of the same job should collapse into one thread rather than one message per night. A refresh that has been failing quietly for a week is the failure mode this exists to prevent.

Can the same workflow serve both the schedule and manual reruns?

Yes, and it should. A manual trigger that takes the logical date as an input, defaulting to today, gives you a backfill mechanism for free and guarantees that the rerun path is the tested path. The alternative — a separate script someone runs locally when the nightly job fails — drifts from the workflow within a month and is invariably the version running when something goes wrong.

← Back to Orchestrating Spatial Pipelines with Airflow