Recipes / Wall-clock

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.
Use case
Nightly dependency audit across every installed repo
Files
scheduled-deps.run.ts

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]
ConcernHow the run handles it
A cron tick names no repoThe 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 poolThe 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-scanEach 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

  1. Deploy FlareDispatch and install the GitHub App on the repos to audit — specs/05-byoc.md.
  2. Copy scheduled-deps.run.ts into runs/ (and security-scan.run.ts from the security-scan recipe, if not already present).
  3. Add the cron to wrangler.jsonc"triggers": { "crons": ["0 4 * * *"] } — matching schedules[].cron, and wrangler deploy.
  4. At 04:00 UTC the Dispatcher’s scheduled() handler instantiates scheduled-deps; every active repo gets a flare-dispatch/security-scan check.

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.

recipes/scheduled-deps/scheduled-deps.run.ts
// 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 };
    }),
});