Pay per vCPU-second
Heavy compute is billed at Cloudflare Container rates — per vCPU-second, scale-to-zero between runs — instead of GitHub Actions per-minute. The jobs that dominate your CI bill move to the cheapest place to run them.
FlareDispatch moves agentic code review, Playwright e2e, acceptance suites, sharded matrices, and security scans off GitHub Actions and onto Cloudflare — billed per vCPU-second on your own account. GHA stays the trigger; runs take the heavy compute.
Heavy compute is billed at Cloudflare Container rates — per vCPU-second, scale-to-zero between runs — instead of GitHub Actions per-minute. The jobs that dominate your CI bill move to the cheapest place to run them.
No 6-hour job limit. No 10 GB cache cap. Artifact retention you set, not a fixed 90 days. The limits that push teams onto self-hosted runners simply aren't there.
GitHub Actions stays the trigger and the PR gate. One wrangler deploy into the Cloudflare account you already pay for. Results land as a GitHub Check Run — no new dashboard, no runner fleet to operate.
Hosted runners are easy but billed per minute with hard ceilings; self-hosted and hybrid runners drop the ceilings but hand you a fleet to operate. FlareDispatch runs on serverless Cloudflare primitives — in the account you already own.
| Heavy-CI option | Serverless, scale-to-zero compute | No 6 h / 10 GB ceilings | No runner fleet to operate |
|---|---|---|---|
| GHA hosted runners | — | — | ✓ |
| Self-hosted runners | — | ✓ | — |
| Buildkite Agent (hybrid) | — | ✓ | — |
| FlareDispatch | ✓ | ✓ | ✓ |
// Recipe: AI code review on every PR
//
// A FlareDispatch port of Cloudflare's multi-agent code reviewer —
// https://blog.cloudflare.com/ai-code-review/ — see ./README.md for how the
// blog's design maps onto this run.
//
// Mode: Webhook mode — fires on every pull_request push, zero GHA minutes,
// no workflow file. Drop this file into your repo's `runs/`. An
// Action-mode alternative (./ci.yml) dispatches the same run from a
// GitHub Actions workflow, for repos that cannot install the App.
// DSL: see specs/03-dsl.md (uses `config` + `io.priorExecution`); inline
// findings are posted as check-run annotations — specs/04-gha-integration.md.
// The checkout rides on the `workspace` primitive — 03-dsl § Primitives.
import { Effect, Schema, Match, Option } from "effect";
import { defineRun, step, sandbox, config, io } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
// Local helper — true if the PR carries the given label. The webhook
// payload's pull_request.labels is an array of { name }.
const hasLabel = (
payload: { pull_request: { labels: ReadonlyArray<{ name: string }> } },
name: string,
): boolean => payload.pull_request.labels.some((l) => l.name === name);
// The domain-scoped reviewers, one per concern (blog: "up to seven
// domain-specific agents"). The risk tier selects which subset actually runs
// — a trivial diff never pays for all seven.
const FULL_AGENTS = [
"security",
"performance",
"code-quality",
"documentation",
"release-management",
"compliance",
"agents-md",
] as const;
const LITE_AGENTS = ["security", "code-quality", "performance", "documentation"] as const;
const TRIVIAL_AGENTS = ["code-quality"] as const;
// Each Finding maps 1:1 onto a GitHub check-run annotation — the Dispatcher
// posts these inline on the PR's Files-changed tab, anchored to the exact
// lines (see specs/04-gha-integration.md § Inline findings — annotations).
const Finding = Schema.Struct({
path: Schema.String,
startLine: Schema.Number,
endLine: Schema.Number,
level: Schema.Literal("notice", "warning", "failure"),
title: Schema.String,
message: Schema.String,
});
// The run's output. `findings` becomes the annotation set; the rest renders
// in the check-run summary. Persisted as execution metadata, so the next
// push's re-review can read it back via `io.priorExecution`.
const ReviewOutput = Schema.Struct({
verdict: Schema.Literal("approve", "comment", "request-changes"),
tier: Schema.Literal("trivial", "lite", "full"),
critical: Schema.Number,
warnings: Schema.Number,
suggestions: Schema.Number,
findings: Schema.Array(Finding),
});
export const prReview = defineRun({
name: "pr-review",
version: "2.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",
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}`,
// skip drafts (unless explicitly requested), bots, and opt-outs
gate: ({ payload }) =>
(!payload.pull_request.draft || hasLabel(payload, "request-ai-review")) &&
!hasLabel(payload, "skip-ai-review") &&
!payload.pull_request.user.login.endsWith("[bot]"),
inputs: ({ payload }) => ({
repo: payload.repository.full_name,
sha: payload.pull_request.head.sha,
baseSha: payload.pull_request.base.sha,
pr: payload.pull_request.number,
installationId: payload.installation.id,
}),
},
],
inputs: Schema.Struct({
repo: Schema.String,
sha: Schema.String,
baseSha: Schema.String,
pr: Schema.Number,
// Optional: Webhook mode maps it from `payload.installation.id` above;
// Action mode (./ci.yml) omits it — the Dispatcher resolves the
// installation from the repo→installation KV map when it opens the
// check-run. The run body itself never reads it.
installationId: Schema.optional(Schema.Number),
}),
outputs: ReviewOutput,
// 25-min overall ceiling (blog: "overall (25 min)"); fan-out concurrency is
// bounded by the largest tier's agent count.
limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length },
run: (input) =>
Effect.gen(function* () {
// 1. Check out the PR head. The `review-agent` CLI is baked into the
// run's image, so the checkout needs no dependency install — just a
// container + clone, which is `workspace` with `install` off.
const { container, dir: repoDir } = yield* step("checkout", () =>
workspace({ repo: input.repo, sha: input.sha }),
);
// 2. Build the reviewable diff into DIFF — a *directory* of per-file
// patches (blog: `diff_directory`), with lockfiles, minified assets,
// and generated code stripped so agents never burn tokens on noise.
// Each agent later reads only the slices its domain touches.
const DIFF = "/tmp/diff";
yield* step("prepare-diff", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "diff",
"--base", input.baseSha,
"--exclude", "lockfiles,minified,generated",
"--out", DIFF,
],
}),
);
// 3. Risk tier from diff size + touched paths.
const tierResult = yield* step("classify-risk", () =>
sandbox.exec({
cwd: repoDir,
container,
command: ["review-agent", "risk-tier", "--diff", DIFF],
}),
);
// 4. The tier IS the plan: which agents run, and which coordinator model.
// A trivial diff runs one generalist on a cheap model; a full review
// runs all seven and coordinates on the top-tier model (blog: "risk
// tiers prevent expensive model calls on trivial changes"). The
// `orElse` arm is a deliberate fail-safe — an unrecognized tier string
// escalates to the most thorough review, never the cheapest.
const plan = Match.value(tierResult.stdout.trim()).pipe(
Match.when("trivial", () => ({
tier: "trivial" as const, agents: TRIVIAL_AGENTS, model: "sonnet" as const,
})),
Match.when("lite", () => ({
tier: "lite" as const, agents: LITE_AGENTS, model: "sonnet" as const,
})),
Match.when("full", () => ({
tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
})),
Match.orElse(() => ({
tier: "full" as const, agents: FULL_AGENTS, model: "opus" as const,
})),
);
// 5. Resolve the coordinator model through the control plane. `config`
// is KV-backed: an operator can repoint `opus` at a fallback model in
// seconds — no redeploy — when a provider degrades (blog: Workers+KV
// control plane). The plan's model is the default when no override.
const coordinatorModel = yield* step("resolve-model", () =>
config.get(`pr-review.model.${plan.model}`).pipe(
Effect.map((override) => override ?? plan.model),
),
);
// 6. Load the previous execution's findings for this same PR. On a
// re-review (a new push) the coordinator uses them to auto-resolve
// fixed threads and re-surface unfixed ones (blog: "re-reviews track
// previous findings"). Option.none() on the first push.
const prior = yield* step("load-prior", () =>
io.priorExecution({
family: `pr-review:${input.repo}:${input.pr}`,
outputSchema: ReviewOutput,
}),
);
// 7. Fan out one tightly-scoped agent per domain, in parallel — only the
// agents this tier calls for. The shared per-file patches under DIFF
// keep token use down across the concurrent reviewers; each writes
// findings to /tmp/findings. Code-quality gets a longer leash (blog:
// per-task timeouts — 5 min, 10 min for code quality).
yield* step("review", () =>
Effect.forEach(
plan.agents,
(agent) =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "run", agent,
"--diff", DIFF,
"--tier", plan.tier,
"--out", "/tmp/findings",
],
timeoutSec: agent === "code-quality" ? 600 : 300,
}),
{ concurrency: plan.agents.length },
),
);
// 8. Seed the coordinator with the prior findings, if any. The findings
// set is small in practice (the blog reports ~1.2 findings per
// review), so passing it as a CLI arg stays well under ARG_MAX.
yield* Option.match(prior, {
onNone: () => Effect.void,
onSome: (p) =>
step("seed-prior", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "seed-previous",
"--out", "/tmp/previous.json",
"--json", JSON.stringify(p.output.findings),
],
}),
),
});
// 9. Coordinator dedups + filters the agents' findings into one verdict
// on the resolved model (blog: "coordinator deduplicates and filters
// findings"; "bias toward approval unless critical issues found").
const review = yield* step("coordinate", () =>
sandbox.exec({
cwd: repoDir,
container,
command: [
"review-agent", "coordinate",
"--in", "/tmp/findings",
"--model", coordinatorModel,
...(Option.isSome(prior) ? ["--previous", "/tmp/previous.json"] : []),
"--json",
],
}),
);
// `coordinate --json` emits { verdict, critical, warnings, suggestions,
// findings }. The check-run summary FlareDispatch posts IS the single
// consolidated review; each finding additionally lands as an inline
// check-run annotation. `tier` is stitched in from the plan in step 4.
const coordinated = JSON.parse(review.stdout) as Omit<
Schema.Schema.Type<typeof ReviewOutput>,
"tier"
>;
return { ...coordinated, tier: plan.tier };
}),
}); # Recipe: AI code review — Action-mode alternative
#
# Use case: same `pr-review` run as ./pr-review.run.ts, but dispatched from a
# GitHub Actions workflow instead of the FlareDispatch GitHub App webhook.
#
# Webhook mode (the `triggers` block in ./pr-review.run.ts) is preferred — it
# burns zero GHA minutes and the run's own `gate` handles draft / bot /
# opt-out filtering. Use this Action-mode file when the App is not installed,
# or you want the review dispatch to sit alongside other PR jobs.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: pr-review — defined in ./pr-review.run.ts.
name: ai-code-review
on:
pull_request:
types: [opened, synchronize, ready_for_review]
jobs:
ai-code-review:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: pr-review
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"baseSha": "${{ github.event.pull_request.base.sha }}",
"pr": ${{ github.event.pull_request.number }}
}
# `installationId` is intentionally absent from `inputs`: the Dispatcher
# resolves the installation from the repo→installation KV map when it opens
# the check-run (specs/04-gha-integration.md § Check-runs callback), so the
# Action need not — and cannot reliably — supply it from the GHA context.
#
# Findings post back as inline check-run annotations on the PR's Files-changed
# tab — require the check-run `flare-dispatch/pr-review` in branch protection. # Recipe: browser tests (Playwright e2e) on Cloudflare
#
# Use case: a Playwright suite that is slow and burns GHA minutes. Offload it
# to the shipped `playwright-e2e` run, which shards the suite across the
# Cloudflare Browser Rendering pool. GHA only fires the dispatch (~10 s) and
# the result comes back as a check-run.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: playwright-e2e — see specs/02-runs.md#3-playwright-e2e.
name: e2e
on:
pull_request:
paths: ["apps/**", "packages/**"]
jobs:
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: playwright-e2e
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"baseURL": "https://staging.example.com",
"shards": 4,
"browserMode": "cf-browser-rendering",
"uploadReport": true
}
# Branch protection: require the check-run `flare-dispatch/playwright-e2e`,
# not this GHA job — the job succeeds the moment dispatch is accepted. // Recipe: browser tests — the `playwright-e2e` Run
//
// The typed Run that ./ci.yml dispatches. Shards a Playwright suite with
// Playwright's native --shard flag — one container per shard, all in
// parallel — and uploads a report per shard.
//
// This recipe rides on two primitives — `sharded` (count-and-index fan-out)
// and `workspace` (acquire + clone + cached install) — so the body is just
// "run Playwright on this shard". See specs/03-dsl.md § Primitives.
//
// This is the shipped `playwright-e2e` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 3. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { sharded, workspace } from "@flare-dispatch/core/primitives";
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
baseURL: Schema.String,
shards: Schema.optional(Schema.Number), // default 4
project: Schema.optional(Schema.String), // Playwright project name
});
const Output = Schema.Struct({
shards: Schema.Number,
passed: Schema.Number,
failed: Schema.Number,
shardResults: Schema.Array(
Schema.Struct({
index: Schema.Number,
exitCode: Schema.Number,
reportUri: Schema.String,
}),
),
});
export const playwrightE2E = defineRun({
name: "playwright-e2e",
version: "1.0.0",
image: "registry.cloudflare.com/openhackersclub/flare-dispatch-playwright:latest",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 2400, maxConcurrency: 8, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
const shards = input.shards ?? 4;
const projectArg = input.project ? ["--project", input.project] : [];
// One container per shard, all in parallel. `sharded` hands each shard
// its { index, total }; `workspace` does the per-shard checkout +
// cached install. The suite's Playwright config points at CF Browser
// Rendering — `requiresBrowser` declares the binding.
const shardResults = yield* step("run-shards", () =>
sharded({
count: shards,
body: ({ index, total }) =>
Effect.gen(function* () {
const { container, dir } = yield* workspace({
repo: input.repo,
sha: input.sha,
install: true,
});
const exec = yield* sandbox.exec({
cwd: dir,
container,
env: { BASE_URL: input.baseURL },
command: [
"pnpm", "exec", "playwright", "test",
"--shard", `${index}/${total}`,
...projectArg,
],
});
const reportUri = yield* artifact.upload({
name: `playwright-report-${index}`,
path: `${dir}/playwright-report/`,
container,
signedUrlTTL: "30 days",
});
return { index, exitCode: exec.exitCode, reportUri };
}),
}),
);
const failed = shardResults.filter((r) => r.exitCode !== 0).length;
return { shards, passed: shards - failed, failed, shardResults };
}),
}); # Recipe: sharded test matrix on Cloudflare
#
# Use case: a unit/integration suite that is fast per-shard but long in total.
# Offload it to the shipped `matrix-fanout` run, which spawns N child
# Workflows (one container per shard) and reports a single parent check-run
# that is green only if every shard passes.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: matrix-fanout — see specs/02-runs.md#2-matrix-fanout.
name: test-matrix
on:
pull_request:
jobs:
test-matrix:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: matrix-fanout
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"command": "pnpm test --shard $SHARD_INDEX/$SHARD_TOTAL",
"shards": 8,
"failureBehavior": "wait-all"
}
# Each shard receives SHARD_INDEX / SHARD_TOTAL in its environment.
# Require the check-run `flare-dispatch/matrix-fanout` in branch protection. // Recipe: sharded test matrix — the `matrix-fanout` Run
//
// The typed Run that ./ci.yml dispatches. Runs the same command across N
// shards — one container per shard, all in parallel — and is green only if
// every shard passes. Each shard receives SHARD_INDEX / SHARD_TOTAL in its
// environment so the command can split its own work.
//
// This recipe rides on two primitives — `sharded` (count-and-index fan-out)
// and `workspace` (acquire + clone) — so the body is just "exec the command
// for this shard". See specs/03-dsl.md § Primitives.
//
// This is the shipped `matrix-fanout` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 2. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { sharded, workspace } from "@flare-dispatch/core/primitives";
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
command: Schema.String, // receives SHARD_INDEX, SHARD_TOTAL
shards: Schema.Number, // 2..32
image: Schema.optional(Schema.String),
});
const Output = Schema.Struct({
passed: Schema.Number,
failed: Schema.Number,
shardResults: Schema.Array(
Schema.Struct({
index: Schema.Number,
exitCode: Schema.Number,
durationMs: Schema.Number,
logUri: Schema.String,
}),
),
});
export const matrixFanout = defineRun({
name: "matrix-fanout",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1800, maxConcurrency: 8 },
run: (input) =>
Effect.gen(function* () {
// `sharded` fans the command across N shards, handing each its
// { index, total }; `workspace` does the per-shard container + clone.
const shardResults = yield* step("run-shards", () =>
sharded({
count: input.shards,
body: ({ index, total }) =>
Effect.gen(function* () {
const { container, dir } = yield* workspace({
repo: input.repo,
sha: input.sha,
image: input.image,
});
const exec = yield* sandbox.exec({
cwd: dir,
container,
env: {
SHARD_INDEX: String(index),
SHARD_TOTAL: String(total),
},
command: input.command,
});
const logUri = yield* artifact.upload({
name: `shard-${index}.log`,
path: exec.logPath,
container,
});
return {
index,
exitCode: exec.exitCode,
durationMs: exec.durationMs,
logUri,
};
}),
}),
);
const failed = shardResults.filter((r) => r.exitCode !== 0).length;
return { passed: shardResults.length - failed, failed, shardResults };
}),
}); # Recipe: CDP acceptance tests on Cloudflare
#
# Use case: acceptance tests that boot the app under test and drive it via
# the Chrome DevTools Protocol — asserting on network calls, console errors,
# document counts, heap deltas. Offload to the shipped `cdp-acceptance` run,
# which boots the app in a container and attaches Browser Rendering over CDP.
#
# Mode: Action mode, await — a follow-up deploy gate needs the result inline.
# See specs/04-gha-integration.md#await-sub-mode.
# Run: cdp-acceptance — see specs/02-runs.md#4-cdp-acceptance.
name: acceptance
on:
pull_request:
paths: ["apps/**"]
jobs:
cdp-acceptance:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: cdp-acceptance
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
mode: await
timeout: 30m # must be >= the cdp-acceptance run's maxDurationSec (1800s)
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"appBootCommand": "pnpm dev",
"appPort": 4173,
"testCommand": "pnpm test:acceptance"
}
# In await mode the action mirrors the run's conclusion onto this GHA step,
# so a subsequent deploy job can `needs:` it. // Recipe: CDP acceptance tests — the `cdp-acceptance` Run
//
// The typed Run that ./ci.yml dispatches. Boots the app under test in a
// detached container, attaches Browser Rendering over the Chrome DevTools
// Protocol, runs the acceptance suite, and uploads screenshots + a trace as
// artifacts (see ./README.md — those can be attached to the PR).
//
// This recipe rides on two primitives — `workspace` (acquire + clone +
// cached install) and `bootApp` (detached run + wait-for-port) — so the file
// carries only the CDP-specific logic. See specs/03-dsl.md § Primitives.
//
// This is the shipped `cdp-acceptance` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 4. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, browser, artifact, io } from "@flare-dispatch/core";
import { workspace, bootApp } from "@flare-dispatch/core/primitives";
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
appBootCommand: Schema.String, // e.g. "pnpm dev"
appPort: Schema.Number, // e.g. 4173
testCommand: Schema.String, // e.g. "pnpm test:acceptance"
});
const Output = Schema.Struct({
exitCode: Schema.Number,
reportUri: Schema.String, // HTML report
screenshotsUri: Schema.String, // screenshots + trace — attachable to the PR
});
export const cdpAcceptance = defineRun({
name: "cdp-acceptance",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1800, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
// Acquire a container, clone the target SHA, and install dependencies
// from the R2-backed cache — the whole checkout dance is one primitive.
const { container, dir } = yield* step("checkout", () =>
workspace({ repo: input.repo, sha: input.sha, install: true }),
);
// Boot the app in a detached container and block until its port opens.
yield* step("boot-app", () =>
bootApp({
container,
dir,
command: input.appBootCommand,
port: input.appPort,
timeoutSec: 120,
}),
);
// Attach Browser Rendering over CDP and run the acceptance suite. The
// suite drives the app and writes screenshots/traces under ./artifacts.
const session = yield* step("attach-cdp", () =>
browser.newCDPSession({ targetUrl: `http://localhost:${input.appPort}` }),
);
const exec = yield* step("run-tests", () =>
sandbox.exec({
cwd: dir,
container,
env: { CDP_WS_URL: session.wsEndpoint },
command: input.testCommand,
}),
);
// Upload the report and the screenshots/trace bundle. Both come back as
// signed R2 URLs in the check-run summary; a developer can drop the
// screenshots or the demo recording straight into the PR.
const reportUri = yield* step("upload-report", () =>
artifact.upload({
name: "acceptance-report",
path: `${dir}/playwright-report/`,
container,
signedUrlTTL: "30 days",
}),
);
const screenshotsUri = yield* step("upload-screenshots", () =>
artifact.upload({
name: "screenshots",
path: `${dir}/artifacts/`,
container,
signedUrlTTL: "30 days",
}),
);
yield* io.log("info", `cdp-acceptance exited ${exec.exitCode}`);
return { exitCode: exec.exitCode, reportUri, screenshotsUri };
}),
}); # Recipe: security / dependency scan on Cloudflare
#
# Use case: dependency and vulnerability scanning that you want on every PR
# *and* on a weekly schedule (to catch newly-disclosed CVEs in code that
# hasn't changed). Offload to the shipped `security-scan` run, which runs
# each selected scanner in its own container in parallel.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: security-scan — see specs/02-runs.md#5-security-scan.
name: security-scan
on:
pull_request:
schedule:
- cron: "0 6 * * 1" # every Monday 06:00 UTC
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: security-scan
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"scanners": ["pnpm-audit", "trivy-fs", "gitleaks"],
"failOn": "high"
}
# The scheduled run uses github.sha of the default branch — the same run
# slug, no extra wiring. Findings land in the check-run summary. // Recipe: security / dependency scan — the `security-scan` Run
//
// The typed Run that ./ci.yml dispatches. Runs each selected scanner in its
// own container, in parallel, and fails the check-run if any scanner exits
// non-zero (each scanner is configured to exit non-zero at/above `failOn`).
//
// The scanners are a heterogeneous list, so the fan-out stays plain
// `Effect.forEach` rather than the count-based `sharded` primitive — but each
// scanner's container + checkout is the `workspace` primitive. See
// specs/03-dsl.md § Primitives.
//
// This is the shipped `security-scan` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 5. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
const Scanner = Schema.Literal(
"npm-audit", "pnpm-audit", "cargo-audit", "uv-audit",
"trivy-fs", "grype-fs", "gitleaks",
);
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
scanners: Schema.Array(Scanner),
failOn: Schema.optional(Schema.Literal("any", "high", "critical")), // default "high"
});
const Output = Schema.Struct({
failed: Schema.Boolean,
scannerResults: Schema.Array(
Schema.Struct({
scanner: Schema.String,
exitCode: Schema.Number,
reportUri: Schema.String,
}),
),
});
export const securityScan = defineRun({
name: "security-scan",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1200, maxConcurrency: 4 },
run: (input) =>
Effect.gen(function* () {
const failOn = input.failOn ?? "high";
const scannerResults = yield* step("scan", () =>
Effect.forEach(
input.scanners,
(scanner) =>
Effect.gen(function* () {
const { container, dir } = yield* workspace({
repo: input.repo,
sha: input.sha,
});
const exec = yield* sandbox.exec({
cwd: dir,
container,
env: { FAIL_ON: failOn },
// `scan` is a thin wrapper in the base image that normalizes
// each scanner's flags and exit codes against FAIL_ON.
command: ["scan", scanner],
});
const reportUri = yield* artifact.upload({
name: `${scanner}-report.json`,
path: exec.logPath,
container,
});
return { scanner, exitCode: exec.exitCode, reportUri };
}),
{ concurrency: 4 },
),
);
const failed = scannerResults.some((r) => r.exitCode !== 0);
return { failed, scannerResults };
}),
}); // Recipe: post-deploy smoke test
//
// Use case: after a deploy succeeds, hit the live URL and a few critical
// endpoints; fail a check-run on the deployed SHA if anything is down.
//
// The probe-and-classify loop is the `probeHttp` primitive, so this recipe
// is just trigger config + a one-line health check. See specs/03-dsl.md
// § Primitives.
//
// Mode: Webhook mode — fires on `deployment_status.success`, no GHA workflow
// file. Drop this file into your repo's `runs/` directory; the
// FlareDispatch GitHub App webhook does the rest. An Action-mode
// alternative (./ci.yml) dispatches the same run from a GitHub Actions
// workflow, for repos that cannot install the App.
// DSL: see specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, io } from "@flare-dispatch/core";
import { probeHttp } from "@flare-dispatch/core/primitives";
export const deploySmoke = defineRun({
name: "deploy-smoke",
version: "1.0.0",
// Webhook-mode trigger config — the receiver-side equivalent of a GHA
// `on:` filter. See specs/04-gha-integration.md#webhook-mode.
triggers: [
{
event: "deployment_status",
// only the success transition, only production, and only when GitHub
// actually gave us a URL to probe — `environment_url` is optional in
// the payload, and without it `baseURL` would be empty.
gate: ({ payload }) =>
payload.deployment_status.state === "success" &&
payload.deployment.environment === "production" &&
!!payload.deployment_status.environment_url,
// key on the deployment id, not the commit: the same SHA can be
// deployed many times (rollback-forward, redeploy) and each deploy
// must get its own smoke test rather than collapsing onto the first.
idempotencyKey: ({ payload }) =>
`deploy-smoke:${payload.repository.full_name}:${payload.deployment.id}`,
inputs: ({ payload }) => ({
repo: payload.repository.full_name,
sha: payload.deployment.sha,
baseURL: payload.deployment_status.environment_url,
paths: ["/", "/health", "/api/status"],
}),
},
],
inputs: Schema.Struct({
repo: Schema.String,
sha: Schema.String,
baseURL: Schema.String,
paths: Schema.Array(Schema.String),
}),
outputs: Schema.Struct({
checked: Schema.Number,
failed: Schema.Number,
}),
limits: { maxDurationSec: 300 },
run: (input) =>
Effect.gen(function* () {
// `probeHttp` hits every path in parallel and classifies each as
// healthy or failed (non-2xx/3xx, or a request that never completed —
// see the primitive's note on curl exit codes).
const probe = yield* step("probe", () =>
probeHttp({ baseURL: input.baseURL, paths: input.paths }),
);
yield* io.log(
probe.failed === 0 ? "info" : "error",
`deploy-smoke: ${probe.checked - probe.failed}/${probe.checked} endpoints healthy`,
);
// A non-zero `failed` count fails the check-run on the deployed SHA.
return { checked: probe.checked, failed: probe.failed };
}),
}); # Recipe: post-deploy smoke test — Action-mode alternative
#
# Use case: same `deploy-smoke` run as ./smoke.run.ts, but dispatched from a
# GitHub Actions workflow instead of the FlareDispatch GitHub App webhook.
#
# Webhook mode (the `triggers` block in ./smoke.run.ts) is preferred — it
# burns zero GHA minutes and needs no workflow file. Use this Action-mode
# file when you cannot install the App, or want the smoke test to interleave
# with other jobs on the deployment.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: deploy-smoke — defined in ./smoke.run.ts.
name: deploy-smoke
on:
deployment_status:
jobs:
deploy-smoke:
# GHA cannot filter `deployment_status` by state in `on:`, so gate the job
# here. This `if:` mirrors the webhook `gate` in ./smoke.run.ts — success
# transition, production only, environment_url present.
if: >-
github.event.deployment_status.state == 'success' &&
github.event.deployment.environment == 'production' &&
github.event.deployment_status.environment_url != ''
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: deploy-smoke
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.event.deployment.sha }}",
"baseURL": "${{ github.event.deployment_status.environment_url }}",
"paths": ["/", "/health", "/api/status"]
}
# A non-zero `failed` count fails the check-run `flare-dispatch/deploy-smoke`
# on the deployed SHA — require it in branch protection, not this GHA job. Progressive offloading.
Move CI to Cloudflare one job at a time — the heaviest, most expensive jobs first. GitHub Actions stays the trigger and keeps the cheap jobs; nothing is all-or-nothing.
Runs are typed programs, not YAML.
A run is an Effect-TS program — Schema-typed inputs and outputs, composable steps, tagged errors, exhaustive matching. Fork it, vendor-edit it, unit-test it without booting a container.
BYOC by construction.
Every code path assumes the run is deployed in your own Cloudflare account — no multi-tenant operator, no hosted SaaS to trust. The specs are the contract; the runs are yours.