Running OSM Validation in GitHub Actions Jump to heading

A validation suite that only runs on somebody’s laptop protects nothing, and one that downloads a continental extract on every pull request protects nothing either because it will be turned off within a fortnight.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

The workflow splits along a single axis: what runs on every change, and what runs against real data.

The pull-request job answers “did this change break the checks?”. It runs against a committed fixture, takes under two minutes, and blocks the merge. Its correctness depends on the fixture being representative enough to exercise the rules — which means a fixture chosen for its awkwardness, containing the multipolygon with a missing role and the relation that nests, rather than a tidy city centre.

The scheduled job answers “is the real data still healthy?”. It runs against an actual extract, takes as long as it takes, and does not block anything because there is nothing to block. Its output is a report and, on failure, an issue or an alert.

Two mechanics make both affordable. Caching the downloaded extract keyed on the upstream file’s date avoids re-downloading unchanged inputs, which on a several-gigabyte file is most of the job’s runtime. Artifacts carry the metrics document out of the run so the next scheduled execution can compare against it, which is the cheapest available history store for a pipeline that does not have a database to hand.

Two jobs, two questions, two very different budgets Two panels. The pull-request job runs on every change against a small committed fixture, completes in under two minutes, blocks the merge when it fails, and answers whether the change broke the rules. Its weakness is that a fixture cannot show real data volumes or genuine upstream surprises. The scheduled job runs daily against a real extract, takes tens of minutes, blocks nothing because there is nothing to block, and answers whether the real data is still healthy. Its output is a report and, on failure, an issue with the metrics attached. Pull request versus schedule On pull request Committed fixture, small Under two minutes Blocks the merge Cannot see real volumes On a schedule A real extract Tens of minutes Blocks nothing Opens an issue on failure Running the second on every pull request is the usual mistake, and the usual outcome is that somebody removes the workflow.
Each job is cheap for its own question and unaffordable for the other one's.

Runnable solution Jump to heading

yaml
name: osm-validation

on:
  pull_request:
  schedule:
    - cron: "17 4 * * *"     # off the hour: shared runners are busiest at :00
  workflow_dispatch:

permissions:
  contents: read
  issues: write              # only the scheduled job needs this

jobs:
  fixture:
    name: Rules against the committed fixture
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements-dev.txt
      - name: Structural and rule checks
        run: |
          python -m osmqa.validate \
            --input tests/fixtures/awkward-city.osm.pbf \
            --rules rules/ \
            --metrics-out fixture-metrics.json \
            --fail-on structural,rules
      - name: Summarise
        if: always()
        run: python -m osmqa.summarise fixture-metrics.json >> "$GITHUB_STEP_SUMMARY"

  real-data:
    name: Statistical checks against a real extract
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12", cache: pip }
      - run: pip install -r requirements-dev.txt

      - name: Resolve the upstream extract date
        id: upstream
        run: |
          date=$(curl -sI "$EXTRACT_URL" | awk 'tolower($1)=="last-modified:"{print $4$3$5}')
          echo "date=${date:-unknown}" >> "$GITHUB_OUTPUT"
        env:
          EXTRACT_URL: ${{ vars.EXTRACT_URL }}

      # Key on the upstream date, not on a run number: an unchanged extract
      # is the common case and re-downloading it is most of this job.
      - name: Cache the extract
        id: cache
        uses: actions/cache@v4
        with:
          path: data/extract.osm.pbf
          key: extract-${{ steps.upstream.outputs.date }}

      - name: Download if the cache missed
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          mkdir -p data
          curl -fsSL --retry 3 -o data/extract.osm.pbf "$EXTRACT_URL"
        env:
          EXTRACT_URL: ${{ vars.EXTRACT_URL }}

      # Metric history lives in artifacts: the cheapest store available to a
      # pipeline with no database of its own.
      - name: Restore previous metrics
        uses: actions/download-artifact@v4
        continue-on-error: true
        with: { name: osm-metrics, path: history }

      - name: Validate
        id: validate
        run: |
          python -m osmqa.validate \
            --input data/extract.osm.pbf \
            --rules rules/ \
            --history history/metrics.json \
            --metrics-out metrics.json \
            --report-out report.md \
            --fail-on structural,critical

      - name: Publish the report
        if: always()
        run: cat report.md >> "$GITHUB_STEP_SUMMARY"

      # Only record history from a PASSING run. Metrics from a degraded run
      # teach the rolling band that the degradation is normal.
      - name: Record metrics as history
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: osm-metrics
          path: metrics.json
          retention-days: 90
          overwrite: true

      - name: Open an issue on failure
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            const body = require('fs').readFileSync('report.md', 'utf8');
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `OSM validation failed (run ${context.runNumber})`,
              body,
              labels: ['osm-quality'],
            });

Step-by-step walkthrough Jump to heading

  1. Separate the two jobs by trigger. The if: github.event_name != 'pull_request' guard is what keeps a pull request from waiting on a continental download.
  2. Commit an awkward fixture. A tidy extract passes everything; the useful fixture contains the cases the rules exist for, and it belongs in version control beside them.
  3. Key the cache on the upstream file’s date. Keying on a run identifier never hits; keying on the date hits on every run where the extract has not changed, which is most of them.
  4. Fail the fixture job on rule failures. This is the part that blocks a merge, and it should be strict because it runs against data you control.
  5. Fail the real-data job only on structural and critical checks. A statistical wobble in real data on a Tuesday is not a reason to open an incident.
  6. Restore history with continue-on-error. The first run has no previous artifact, and a workflow that fails on its own first execution gets deleted.
  7. Record history only from passing runs. Otherwise a degraded run establishes the degradation as the new normal, which defeats the entire relative-check layer.
  8. Write to the step summary. A report nobody has to click through to is read considerably more often than one they do.
Why the cache key must come from upstream rather than from the run Four steps. The workflow issues a HEAD request against the extract URL and reads the Last-Modified header, which identifies the upstream file rather than this execution. That value becomes the cache key, so every run against an unchanged extract resolves to the same key. On a hit, the download step is skipped entirely and the job proceeds straight to validation, saving what is usually the great majority of its runtime. On a miss, meaning the provider has published a new extract, the file is downloaded once and cached under the new key for every subsequent run that day. Cache keyed on the upstream file HEAD upstream read Last-Modified identifies the file form the key unchanged means same key not the run number hit: skip download most runs most of the runtime miss: fetch once a new extract cached for the rest Keying on a run number or a commit hash produces a cache that never hits and a job that downloads several gigabytes every night.
The whole saving comes from the key naming the input rather than the execution.
Seven workflow decisions and what each one is protecting against A grid of seven workflow choices against what each protects and the consequence of the obvious alternative. Splitting jobs by trigger protects pull-request latency, where running everything everywhere leads to the workflow being removed. An awkward fixture protects rule coverage, where a tidy extract passes every rule regardless. An upstream-derived cache key protects runtime, where a run-scoped key never hits. Strict failure on the fixture protects the merge gate. Lenient failure on real data protects against nightly false alarms. Tolerant history restore protects the first execution. Uploading history only on success protects the baseline from degradation. Decision, protection, alternative Protects The alternative costs Split by trigger pull request latency the workflow gets removed Awkward fixture rule coverage everything passes anyway Upstream cache key job runtime a cache that never hits Strict on fixture the merge gate regressions merge Lenient on real data trust in alerts nightly false alarms Tolerant restore the first run a workflow born failing History on success the baseline degradation normalised The last row is the quietest: nothing fails, the band simply widens around whatever the pipeline is producing now.
Each row is one line of YAML and one specific way the workflow stops being useful.

Verification Jump to heading

  • A broken rule fails the pull request. Introduce a deliberate rule failure and confirm the merge is blocked.
  • The cache hits. Run the scheduled job twice without an upstream change and confirm the second skips the download.
  • History is compared. Confirm the second scheduled run reports deltas against the first.
  • A failing run does not poison history. Force a failure and confirm no artifact is uploaded.
  • The summary is readable. Open the run’s summary page and confirm the report renders without needing an artifact download.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Pull requests take forty minutes Real-data job running on every PR Guard it with an event-name condition
Cache never hits Key derived from the run or the commit Key on the upstream file’s date or ETag
First run of the workflow fails Download of a history artifact that does not exist Add continue-on-error to the restore
Thresholds drift toward degradation History recorded from failing runs Upload the artifact only on success
Runner runs out of disk Default runner too small for the extract Use a larger runner or clip the extract first
Nobody sees failures Report only in an artifact Write it to the step summary and open an issue
Scheduled job queues for an hour Cron set on the hour Offset the schedule off the hour

Specification reference Jump to heading

actions/cache restores an entry whose key matches exactly, falling back to restore-keys prefixes when no exact match exists; a cache entry is immutable once written under a key. Workflow runs triggered by schedule use the default branch’s workflow file. Writing to the file named by GITHUB_STEP_SUMMARY renders Markdown on the run’s summary page. See the GitHub Actions documentation for caching, events that trigger workflows, and job summaries.

Frequently Asked Questions Jump to heading

Should the extract be committed rather than downloaded?

Only the fixture, and only if it is small. A few megabytes of PBF in the repository is a reasonable price for a deterministic, offline, always-available test input, and it makes the pull-request job independent of any network. A real extract is far too large to commit and is the wrong input for the pull-request job anyway, since its content changes underneath you and a failure could mean either a code regression or an upstream change.

Are artifacts really a sensible place to keep metric history?

For a pipeline with nowhere better, yes, with two caveats: retention is finite, so a ninety-day window is the most history you get, and overwriting a named artifact loses the series rather than keeping it. If you need longer history or trend analysis, write metrics to a small table or an object store instead. The artifact approach is the version that works today without provisioning anything, which is often the difference between having history and not.

How do you stop the scheduled job from being disabled for inactivity?

GitHub disables scheduled workflows in repositories with no activity for sixty days, which for a stable pipeline is entirely plausible. The reliable answers are either a repository that sees regular commits anyway, or triggering the run from an external scheduler through workflow_dispatch rather than relying on schedule. Discovering the silent disablement during an incident is a memorable way to learn this.

Should validation run before or after the pipeline's own build?

After, against the output, because that is what consumers read. Validating the input as well is worth doing and belongs in the same workflow as an earlier step, so a truncated extract fails in ten seconds rather than after the processing. What does not work is validating only the input and assuming the pipeline preserved its quality, which is precisely the assumption a parser regression violates.

Up one level: Continuous QA for OSM Pipelines.