Specs / Specs

GHA Integration

04 — GitHub Integration

GitHub talks to FlareDispatch through two trigger modes. Pick whichever fits — most teams end up running both, side by side, against the same Dispatcher.

ModeHow a run is triggeredGHA workflow file?GHA minutes per executionShared secret to rotate?
Action modeA GHA workflow calls openhackersclub/flare-dispatch-action; or any external caller HMAC-signs and POSTs the dispatch endpointyes (or none, for direct POST)~10 s per dispatchyes — FLAREDISPATCH_HMAC
Webhook modeThe FlareDispatch GitHub App webhook fires the Dispatcher directlyno0no — only the App’s own webhook secret

Both modes hit the same Dispatcher, share the same dedup discipline, and report results through the same check-run callback. The rest of this doc is one section per mode, then the parts they share (check-runs callback, dedup, secrets, failure handling).

Pieces

PieceRoleUsed by
Dispatcher WorkerSingle receiver for both modes. See 01-architecture § Dispatcher Worker.both
FlareDispatch GitHub AppOwns the check-runs API token (writes results back). In Webhook mode, also the trigger source.both for callback; Webhook mode for trigger
openhackersclub/flare-dispatch-actionComposite Action — HMAC-signs the body, POSTs the dispatch, then exits or polls.Action mode only

Installing only the GHA Action still requires the App (for check-run writes). Running only Webhook mode does not require the Action.


Action mode

A GHA workflow — or any HMAC-signing HTTP caller — POSTs /v1/dispatch/:run to start an execution.

When to use

  • The run needs to interleave with other GHA jobs (lint → run → deploy).
  • You want GHA’s native trigger filters (paths:, branches:, workflow_dispatch).
  • The caller lives outside GitHub entirely — a cron service, another CI system, a local debugging script. Same HMAC, same body shape; no GHA wrapper required.

Workflow snippet

# .github/workflows/ci.yml
- uses: openhackersclub/flare-dispatch-action@v1
  with:
    run: playwright-e2e
    endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
    hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
    inputs: |
      { "baseURL": "https://staging.example.com", "shards": 4 }
    mode: fire-and-forget    # default; "await" also supported

Inputs

InputRequiredDefaultNotes
runyesRun slug. Must exist on the target deploy.
endpointyeshttps://runs.<your-domain>
hmac-secretyesShared HMAC secret. Same value as the Worker’s HMAC_SECRET.
inputsno{}JSON or YAML mapping. Validated against the run’s Schema on the Worker side.
modenofire-and-forgetfire-and-forget returns 202 immediately; await polls until terminal.
timeoutno30mAwait sub-mode poll ceiling.
check-namenoflare-dispatch/<run>Overrides the check-run name.

Outputs

OutputNotes
execution-idULID of the execution on CF. Always set — the Dispatcher returns it in the 202.
check-run-idGitHub check-run id. Await sub-mode only — the check-run is opened by the Workflow after dispatch, so it is not known at 202 time; the polling action reads it once the Workflow has created it.
conclusionAwait sub-mode only: success / failure / neutral / timed_out / cancelled.
summary-urlLink to the check-run page on github.com.

Dispatch body

{
  "run": "playwright-e2e",
  "github": {
    "repo": "owner/name",
    "ref": "refs/pull/42/head",
    "sha": "abc123...",
    "pr_number": 42,
    "actor": "octocat",
    "installation_id": 12345
  },
  "inputs": { "baseURL": "...", "shards": 4 },
  "trigger": { "workflow_run_id": 678901, "job_id": 234567 }
}

HMAC-SHA256 signed; the signature goes in X-FlareDispatch-Signature: sha256=<hex>, verified with a constant-time comparison. Direct (non-GHA) callers must also supply an Idempotency-Key header so receiver-level dedup applies; the GHA Action generates one per dispatch.

Fire-and-forget sub-mode (default)

sequenceDiagram
  participant GHA as GHA workflow
  participant ACT as flare-dispatch-action
  participant DSP as Dispatcher
  GHA->>ACT: step starts
  ACT->>DSP: POST /v1/dispatch (HMAC-signed)
  DSP-->>ACT: 202 Accepted, returns executionId
  ACT-->>GHA: step exits success
  Note over DSP: Workflow runs asynchronously, result reported via check-run

The GHA step succeeds the moment dispatch is accepted — it has done its job. The check run is the actual PR signal. In branch protection, require the check-run name (e.g. flare-dispatch/playwright-e2e), not the GHA job. Zero GHA minutes are spent for the execution duration. This is the recommended sub-mode.

Await sub-mode

- uses: openhackersclub/flare-dispatch-action@v1
  with:
    run: cdp-acceptance
    mode: await
    timeout: 20m

The Action polls GET /v1/executions/:id every 10 s until the execution reaches a terminal state, then mirrors the conclusion as its own GHA step status.

Use only when a follow-up GHA step needs the result inline — e.g. a deploy gate that consumes the acceptance execution’s exact output. Avoid for runs longer than ~5 minutes: polling burns GHA minutes that fire-and-forget would not.


Webhook mode

The FlareDispatch GitHub App webhook fires the Dispatcher’s /v1/webhooks/github directly. No GHA workflow file is involved.

When to use

  • The run should execute on every push without burning GHA minutes (PR review, smoke).
  • The trigger isn’t a code push — deployment_status.success for E2E gating, check_run.rerequested for re-runs, Cron Triggers for scheduled runs (release notes, dependency scans).
  • You want one less shared secret to rotate.
  • Users shouldn’t have to touch .github/workflows/ to onboard the run.

Trigger config lives in the run

The receiver-side gates that GHA on: filters would normally provide instead live in the run’s triggers:

// runs/pr-review.ts
export const prReview = defineRun({
  name: "pr-review",
  triggers: [
    {
      event: "pull_request",
      actions: ["opened", "synchronize", "ready_for_review"],
      idempotencyKey: ({ payload }) =>
        `pr-review:${payload.repository.full_name}:${payload.pull_request.number}:${payload.pull_request.head.sha}`,
      gate: ({ payload }) =>
        // skip drafts unless explicitly labelled, skip dependabot
        (!payload.pull_request.draft || hasLabel(payload, "request-ai-review"))
        && !hasLabel(payload, "skip-ai-review")
        && payload.pull_request.user.login !== "dependabot[bot]",
      inputs: ({ payload }) => ({
        repo: payload.repository.full_name,
        pr: payload.pull_request.number,
        headSha: payload.pull_request.head.sha,
        installationId: payload.installation.id,
      }),
    },
  ],
  // ...inputs, outputs, run as usual
});

On each delivery the receiver verifies the App webhook signature (X-Hub-Signature-256), evaluates every matching triggers entry across all registered runs, dedupes (see § Receiver dedup), and fires whichever runs’ gates pass. Multiple runs may subscribe to the same event. The Dispatcher meets GitHub’s 10-second webhook ack window with margin — all LLM calls, Octokit fetches, and container starts happen inside the Workflow, never on the receiver path.


Check-runs callback (shared by both modes)

Whatever triggered the execution, the Dispatcher reports the result through the FlareDispatch App’s check-runs API. App credentials are exchanged for short-lived installation tokens (1-hour TTL), cached in INSTALL_TOKEN_KV with a 55-minute TTL so the token survives Worker recycles mid-execution.

sequenceDiagram
  participant W as Workflow
  participant D as Dispatcher
  participant KV as KV config
  participant GH as GitHub API
  W->>D: createCheckRun for repo, sha, name
  D->>KV: look up installation_id for repo
  D->>GH: POST access_tokens, JWT-signed
  GH-->>D: installation token, 1 hour TTL
  D->>GH: POST check-runs, status in_progress
  GH-->>D: returns check_run_id
  D-->>W: returns check_run_id
  Note over W: execution proceeds
  W->>D: updateCheckRun with conclusion and summary
  D->>GH: PATCH check-runs by id

A token is per-installation, not per-repo; one installation covers every repo the App is installed on for that org.

Summary content

A successful execution renders as:

✓ playwright-e2e — 24 passed, 0 failed, 1 flaky (3m 42s)

| Shard | Passed | Failed | Duration |
|-------|--------|--------|----------|
| 1/4   | 6      | 0      | 51s      |
| 2/4   | 6      | 0      | 49s      |
| 3/4   | 6      | 0      | 53s      |
| 4/4   | 6      | 0      | 48s      |

📂 [Full report](https://runs.example.com/v1/artifacts/01J.../playwright-report)
📜 [Logs](https://runs.example.com/v1/executions/01J.../logs)

For a failure, the summary inlines the first N failing test names with stack traces and direct links to per-shard reports. The summary is markdown; GitHub renders it in the check-run detail page.

Inline findings — annotations

The summary is one markdown blob on the check-run detail page. For findings that belong to a specific line of a specific file — a security issue on auth.ts:42, the source location of a failed assertion, an AI reviewer’s comment — the Dispatcher additionally posts GitHub check-run annotations.

A run surfaces these by returning a findings array in its output:

const Finding = Schema.Struct({
  path: Schema.String,                       // repo-relative path
  startLine: Schema.Number,
  endLine: Schema.Number,
  level: Schema.Literal("notice", "warning", "failure"),
  title: Schema.String,
  message: Schema.String,
});

The Dispatcher maps each Finding onto a check-run annotation (annotation_levellevel) and attaches them when it PATCHes the check-run. GitHub’s API caps output.annotations at 50 per request, so the Dispatcher batches: the first 50 land on the closing updateCheckRun, the remainder in follow-up PATCHes of the same check_run_id. Annotations render inline on the PR’s Files changed tab, anchored to the exact lines — the GitHub-native equivalent of a per-line review comment, with no separate PR review thread to manage and nothing to clean up on the next push: a new execution opens a new check-run, so its annotations replace the prior set wholesale.

This keeps the single-surface model intact — the check-run is still the only thing FlareDispatch writes to the PR. Annotations are part of the check-run, not a second channel. A run that produces no line-anchored findings simply omits findings; the summary stands alone, exactly as before.

Re-running

The App listens for check_run.rerequested and check_run.created. Clicking “Re-run failed checks” on a PR fires POST /v1/webhooks/github, which the Dispatcher routes to a new Workflow execution with the same inputs. The run re-executes in place — no GHA workflow re-runs, regardless of which mode originally triggered it.


Receiver dedup (shared by both modes)

Both modes share the same two-layer dedup discipline so a redelivery storm, a double-click on “Re-run failed checks,” or a GHA retry doesn’t produce parallel work or duplicate check-runs.

  1. Receiver-levelIDEMPOTENCY_KV.put(deliveryId, "1", { expirationTtl: 86_400 }) with a get-set guard. The key is X-GitHub-Delivery for App webhooks, or the caller-supplied Idempotency-Key for direct dispatch. A repeat returns 202 immediately — Workflows is never touched.
  2. Workflow-level — the Workflow instanceId is the semantic key: playwright-e2e:{repo}:{sha}, pr-review:{repo}:{pr}:{head_sha}, release-notes:{repo}:{iso_year}-W{iso_week_2digit}. CF Workflows treats a duplicate env.RUNS_WORKFLOW.create({ id }) as a no-op, so two distinct deliveries naming the same head SHA collapse onto one execution.

Secrets the user needs to configure

SecretWhereAction modeWebhook mode
FLAREDISPATCH_ENDPOINT (a URL, not a secret)Repo/org variablerequiredrequired
FLAREDISPATCH_HMACRepo/org secret + Worker secret HMAC_SECRETrequirednot used
GitHub App ID + private keyWorker secrets (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY)required (for the check-run callback)required
App webhook secretWorker secret (GITHUB_WEBHOOK_SECRET)not usedrequired

Users running only Webhook mode never provision or rotate FLAREDISPATCH_HMAC — one less long-lived shared secret. Users running both rotate it on the cadence in 05-byoc § Security posture; the App webhook secret rotates independently from the App settings page.


What the Action does not do

  • It does not execute any run logic. The Action is a thin composite wrapper around a ~30-line bash script — sign request, POST, optionally poll.
  • It does not require the user to manage a GitHub PAT for check-runs. The App handles it.
  • It does not require runs-on: self-hosted. It’s a normal hosted-runner step that finishes in seconds.

Failure handling

FailureBehavior
Dispatcher unreachableAction retries 3× with exponential backoff; then fails the GHA step with a clear message.
HMAC rejectedDispatcher returns 401; Action fails the step (config bug — no retry).
Webhook signature rejectedDispatcher returns 401; GitHub retries per its standard delivery policy, then marks the delivery failed.
Run input doesn’t match SchemaDispatcher returns 400 with the Schema parse error; Action fails the step with the error inlined.
Run not found on the deployDispatcher returns 404; Action fails the step.
Worker quota exhaustedDispatcher returns 429 + Retry-After; Action waits and retries up to 3×.
Run fails mid-execution (await sub-mode)Action mirrors the conclusion; GHA step fails.
Run times out (await sub-mode)Action sets conclusion timed_out; GHA step fails. The Workflow itself continues on CF; the check-run updates independently when it finishes.