Scheduled deps audit
A nightly Cron Trigger enumerates every repo the App is installed on via the github capability and fans out the shipped security-scan run per repo — so a freshly disclosed CVE surfaces within a day, not at the next unrelated push.
Fires on Nightly cron — wire it any of these ways:
- Schedule recommended A Cloudflare Cron Trigger fires it on a wall-clock cadence — no GitHub event, no workflow file.
Recipe: scheduled dependency audit
A Schedule-mode run that audits dependencies across every repo the FlareDispatch App is installed on, every night.
Why Schedule mode
A vulnerability disclosure does not wait for your next PR. A dependency that was clean when it merged can be flagged days later — and a PR-triggered scan (security-scan in Action mode) never re-runs on code that isn’t being changed. Schedule mode closes that gap: a Cloudflare Cron Trigger re-audits the whole org on a cadence, so a fresh CVE surfaces within a day rather than at the next unrelated push.
Shape — enumerate, then fan out
Same shape as pr-review-sweep, but the unit of work is a repo, not a PR:
flowchart LR
CRON[Cron Trigger<br/>0 4 * * *] -->|scheduled| SW[scheduled-deps<br/>scheduling Workflow]
SW --> ENUM[enumerate<br/>github.repositories]
ENUM --> FAN[fan out · staggered]
FAN --> S1[security-scan · repo A]
FAN --> S2[security-scan · repo B]
FAN --> Sn[security-scan · repo N]| Concern | How the run handles it |
|---|---|
| A cron tick names no repo | The enumerate step calls github.repositories() (03-dsl § github) — the App-token-backed read surface — filtering out archived repos and ones idle beyond pushedWithinDays. |
| Don’t burst the API / container pool | The fan-out is staggered with step.sleepUntil (03-dsl § Deferred scheduling) across a 30-minute window; the scheduling Workflow hibernates between slots. |
| Don’t double-scan | Each child security-scan is created with a date-windowed semantic instanceId — a duplicate cron delivery or an overlapping manual scan collapses to a no-op create. |
github.repositories() is the repo-level counterpart to github.openPullRequests(): a sweep over PRs enumerates the latter, a sweep over repos enumerates the former.
What it dispatches
The shipped security-scan run, unchanged — see recipes/security-scan/. This run only decides which repos and when; security-scan runs the scanners and posts a flare-dispatch/security-scan check-run per repo. scheduled-deps posts no check-run of its own.
Install
- Deploy FlareDispatch and install the GitHub App on the repos to audit — specs/05-byoc.md.
- Copy
scheduled-deps.run.tsintoruns/(andsecurity-scan.run.tsfrom the security-scan recipe, if not already present). - Add the cron to
wrangler.jsonc—"triggers": { "crons": ["0 4 * * *"] }— matchingschedules[].cron, andwrangler deploy. - At 04:00 UTC the Dispatcher’s
scheduled()handler instantiatesscheduled-deps; every active repo gets aflare-dispatch/security-scancheck.
Edit the SCANNERS list in the run to match your ecosystems — security-scan skips a scanner whose lockfile a repo doesn’t have, so a broad list is safe.
Source
This recipe ships its source as a typed Effect-TS run — the recommended Schedule-mode shape.
// Recipe: scheduled dependency audit
//
// A Schedule-mode run that audits dependencies across *every* repo the
// FlareDispatch App is installed on, every night. A vulnerability does not
// wait for your next PR — a dependency safe at merge time is disclosed days
// later. This run re-scans on a cadence so a new CVE surfaces within a day,
// not at the next push.
//
// Shape: enumerate, then fan out — the same shape as ai-code-review's
// pr-review-sweep, but the unit of work is a *repo*, not a PR. A cron tick
// names no target, so the `enumerate` step discovers them with the `github`
// capability, then the run dispatches the shipped `security-scan` run once
// per repo.
//
// Mode: Schedule mode — specs/04-gha-integration.md § Schedule mode.
// DSL: `schedules` on defineRun; `github.repositories()` to enumerate the
// installed repos (specs/03-dsl.md § github); `sharded` + step.sleepUntil
// to stagger the fan-out under the GitHub API rate limit.
import { Effect, Schema } from "effect";
import { defineRun, step, github, io, spawnChildRun } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";
// The scanners every repo is audited with. `security-scan` skips a scanner
// whose ecosystem the repo doesn't use, so this list can be broad.
const SCANNERS = ["pnpm-audit", "npm-audit", "cargo-audit", "trivy-fs"] as const;
// UTC calendar date — the cron-window dedup key.
const isoDate = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
const Input = Schema.Struct({
// Coarse scope from schedules[].inputs — NOT a target.
pushedWithinDays: Schema.Number, // skip repos with no recent activity
firedAt: Schema.Number,
});
const Output = Schema.Struct({
reposFound: Schema.Number,
dispatched: Schema.Number, // child security-scan executions created
});
export const scheduledDeps = defineRun({
name: "scheduled-deps",
version: "1.0.0",
// Schedule mode: 04:00 UTC daily. Must also appear in wrangler.jsonc
// `triggers.crons` (specs/05-byoc.md § Wrangler config).
schedules: [
{
cron: "0 4 * * *",
idempotencyKey: ({ firedAt }) => `scheduled-deps:${isoDate(firedAt)}`,
inputs: ({ firedAt }) => ({ pushedWithinDays: 90, firedAt }),
},
],
inputs: Input,
outputs: Output,
// Mostly hibernating on staggered sleeps — the ceiling covers the stagger
// window plus enumeration headroom.
limits: { maxDurationSec: 7200, maxConcurrency: 100 },
run: (input) =>
Effect.gen(function* () {
// 1. Enumerate. A cron tick names no repo — discovery is the first
// step. `github.repositories` is the runtime-provided, App-token
// -backed read surface; archived repos and ones idle beyond the
// window are filtered out so the audit doesn't churn on dead code.
const repos = yield* step("enumerate", () =>
github.repositories({
includeArchived: false,
pushedWithinDays: input.pushedWithinDays,
}),
);
yield* step("log-scope", () =>
io.log("info", `dependency audit: ${repos.length} repo(s)`, {
firedAt: input.firedAt,
}),
);
// 2. Fan out the shipped `security-scan` run, one child per repo,
// staggered across 30 min so the enumeration's API budget and the
// container pool never see a burst. Each child keeps a semantic,
// date-windowed instanceId, so a duplicate cron delivery — or an
// overlapping manual scan — collapses to a no-op create.
const STAGGER_MS = 30 * 60_000;
const outcomes = yield* sharded({
count: repos.length,
concurrency: repos.length,
body: ({ index, total }) =>
Effect.gen(function* () {
const repo = repos[index - 1];
const offset = Math.floor((STAGGER_MS / Math.max(total, 1)) * (index - 1));
yield* step.sleepUntil(
`stagger-${repo.repo}`,
input.firedAt + offset,
);
return yield* step(`scan-${repo.repo}`, () =>
spawnChildRun({
run: "security-scan",
instanceId: `security-scan:${repo.repo}:${isoDate(input.firedAt)}`,
input: {
repo: repo.repo,
// Default-branch tip — a scheduled audit tracks the branch,
// so git.clone resolving the ref is intended.
sha: repo.defaultBranch,
scanners: SCANNERS,
failOn: "high",
},
}),
);
}),
});
const dispatched = outcomes.filter((o) => o.created).length;
return { reposFound: repos.length, dispatched };
}),
});