Rebuilding Tiles on GitHub Push with repository_dispatch

Part of the Webhook-Triggered Updates guide.

Operative rule: decouple the data-source event from CI by relaying it as a single repository_dispatch event — verify the incoming signature, then let the dispatch’s client_payload carry exactly what the rebuild needs, so the data source never knows anything about your build steps.

How repository_dispatch Works

A repository_dispatch event is a way to start a GitHub Actions workflow from outside the repository, over the REST API, carrying an arbitrary JSON client_payload. Where a push starts a build because code changed, repository_dispatch starts one because data changed somewhere else — a row landed in a database, a file was uploaded, an upstream feed published. You POST to the repository’s dispatches endpoint with an event_type string and a client_payload object; GitHub matches the event_type against any workflow that declares on: repository_dispatch: types: [...] and runs it. The event is fire-and-forget: the API returns 204 No Content whether or not a workflow matched, which is why the event_type and the workflow’s types list must agree exactly.

The value of this indirection is that the data source and the CI pipeline stay ignorant of each other. The data source only knows how to sign and send one webhook; the workflow only knows how to read a client_payload. Between them sits a thin relay whose entire job is trust: it verifies an HMAC signature over the raw body so that only a caller holding the shared secret can trigger a rebuild, then forwards the event with a server-held token that the public data source never sees. That same signature-verify pattern underpins other event sources — see triggering map refresh via Supabase webhooks for the database-native variant. When no external event exists and you just want a wall-clock cadence, reach for scheduled map rebuild workflows instead.

Signed webhook relayed as a repository_dispatch event A data source posts a signed change event to a relay. The relay verifies the HMAC signature, rejects on mismatch, and on success calls the dispatches API with a client_payload. GitHub Actions matches the event_type and runs the gated tile-rebuild job. Data source signs raw body HMAC-SHA256 Verify relay compare_digest holds dispatch token Actions API repository_dispatch event_type match Rebuild tiles job POST payload 401 on bad signature The data source knows nothing about the build; the workflow knows nothing about the data source.

Production-Ready Implementation

Two pieces cooperate: a relay that verifies the signature and fires the dispatch, and the workflow that receives it. The relay below is framework-agnostic Python — drop the handler into any WSGI/ASGI app or a serverless function.

from __future__ import annotations

import hashlib
import hmac
import json
import os
import urllib.request

# Shared secret with the data source; NOT the GitHub token.
_WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()
# Fine-grained token with "Contents: read/write" on the target repo only.
_DISPATCH_TOKEN = os.environ["DISPATCH_TOKEN"]
_REPO = os.environ["TARGET_REPO"]  # e.g. "acme/geo-dashboard"
_EVENT_TYPE = "data-changed"       # must match the workflow's types: list


def _signature_ok(raw_body: bytes, header_sig: str) -> bool:
    """Constant-time verify an HMAC-SHA256 signature of the raw request body."""
    expected = "sha256=" + hmac.new(_WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    # compare_digest avoids leaking match position via timing.
    return hmac.compare_digest(expected, header_sig or "")


def _fire_dispatch(client_payload: dict[str, object]) -> int:
    """Call the repository_dispatch endpoint with a server-held token."""
    body = json.dumps(
        {"event_type": _EVENT_TYPE, "client_payload": client_payload}
    ).encode()
    req = urllib.request.Request(
        f"https://api.github.com/repos/{_REPO}/dispatches",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {_DISPATCH_TOKEN}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "Content-Type": "application/json",
        },
    )
    with urllib.request.urlopen(req) as resp:
        return resp.status  # 204 on success


def handle_webhook(raw_body: bytes, header_sig: str) -> tuple[int, str]:
    """Entry point: verify, then relay as a repository_dispatch event."""
    if not _signature_ok(raw_body, header_sig):
        return 401, "invalid signature"

    event = json.loads(raw_body)
    # Forward only what the rebuild needs — never the whole upstream payload.
    payload = {
        "dataset": event.get("dataset", "default"),
        "changed_at": event.get("updated_at"),
        "reason": "external-data-change",
    }
    status = _fire_dispatch(payload)
    return (202, "rebuild dispatched") if status == 204 else (502, "dispatch failed")

The matching workflow declares the repository_dispatch trigger, reads client_payload, and gates the expensive rebuild behind a cheap condition so unrelated datasets do not burn CI minutes.

name: rebuild-tiles
on:
  repository_dispatch:
    types: [data-changed]   # must equal the relay's _EVENT_TYPE
  workflow_dispatch:        # keep a manual escape hatch for reruns
    inputs:
      dataset:
        description: "Dataset to rebuild"
        default: "default"

jobs:
  rebuild:
    runs-on: ubuntu-latest
    # Gate: only run when a real dataset was named.
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event.client_payload.dataset != ''
    steps:
      - uses: actions/checkout@v4
      - name: Resolve dataset name
        id: vars
        run: |
          DATASET="${{ github.event.client_payload.dataset || inputs.dataset }}"
          echo "dataset=$DATASET" >> "$GITHUB_OUTPUT"
      - name: Rebuild tiles
        run: python build_tiles.py --dataset "${{ steps.vars.outputs.dataset }}"

Alternative Variants

Trigger a manual rerun with the CLI

workflow_dispatch shares the same workflow but is meant for humans and typed inputs. It is the escape hatch when a rebuild needs re-running without a fresh data event:

gh workflow run rebuild-tiles.yml -f dataset=parcels

repository_dispatch vs workflow_dispatch

Aspect repository_dispatch workflow_dispatch
Who triggers machine (external webhook) human (UI / CLI)
Payload arbitrary client_payload JSON fixed, typed inputs
API surface POST /repos/{repo}/dispatches POST .../workflows/{id}/dispatches
Auth token with Contents: write token or interactive session
Best for data-change fan-in from many sources reruns, backfills, manual ops

Keep both on: triggers in one workflow so the automated path and the manual path share identical build steps.

Verification Steps

  • Signature rejection — POST a body with a deliberately wrong signature and confirm the relay returns 401 and fires no dispatch.
  • Happy path end-to-end — send a correctly signed event and confirm the relay returns 202, the dispatch API returns 204, and a run appears under the workflow within seconds.
  • event_type match — temporarily change _EVENT_TYPE to a value absent from the workflow’s types: list; confirm the API still returns 204 but no run starts, proving the match is on the string.
  • Payload plumbing — echo ${{ toJSON(github.event.client_payload) }} in a debug step and confirm dataset and changed_at arrived intact.
  • Gate behaviour — dispatch an event with an empty dataset and confirm the if: condition skips the rebuild job.

Common Errors & Fixes

Dispatch returns 204 but no workflow runs

The API accepted the event but nothing matched it. The two most common causes: the workflow is not on the repository default branch (repository_dispatch only matches workflows on the default branch), or the event_type you sent is not in the workflow’s on.repository_dispatch.types list. Fix: merge the workflow to the default branch and make the types: entry exactly equal to _EVENT_TYPE, including case.

Relay returns 401 for a request you believe is valid

The HMAC is being computed over a re-serialized body rather than the exact bytes received. Any framework that parses JSON and re-encodes it will change whitespace and key order, breaking the digest. Fix: capture the raw request body before any parsing and sign that byte string; only call json.loads after hmac.compare_digest passes.

Rebuild runs but client_payload fields are empty

client_payload is only populated for the repository_dispatch event. When the same workflow is started via workflow_dispatch, those fields are null and the expressions collapse to empty strings. Fix: fall back to inputs.* with the || operator as shown (client_payload.dataset || inputs.dataset) so both trigger paths resolve a value.

403 or resource-not-accessible from the dispatch call

The token lacks write access to the target repository, or a fine-grained token was scoped to the wrong repo. Fix: issue a fine-grained token granting Contents: read and write on exactly the target repository, store it as DISPATCH_TOKEN, and keep it server-side in the relay — never hand it to the public data source.