Recipes / Wall-clock

Nightly e2e

The simplest Schedule-mode shape: a Cloudflare Cron Trigger fires the run every night, and it dispatches the shipped playwright-e2e run against a fixed list of environments. Static targets — no enumeration, no GHA minutes, no workflow file.

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.
  • Action pattern A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR. (a GHA on: schedule step as the GHA-native alternative)
Use case
Nightly Playwright suite across your deployed environments
Files
nightly-e2e.run.ts

Recipe: nightly end-to-end suite

A Schedule-mode run that exercises your deployed environments every night — the simplest shape Schedule mode takes.

Why Schedule mode

A nightly suite has no GitHub trigger. It is not a PR push (Webhook mode) and not a GHA job (Action mode) — it is the clock. Schedule mode is the trigger: a Cloudflare Cron Trigger fires the run on a wall-clock cadence, zero GHA minutes, no .github/workflows/ file.

The simplest Schedule-mode shape — static targets

pr-review-sweep has to discover its targets — a cron tick names no repo or PR, so its first step enumerates open PRs via the github capability. nightly-e2e doesn’t: the environments it tests are a fixed list (ENVIRONMENTS at the top of nightly-e2e.run.ts) known at deploy time.

So there is no enumerate step. schedules[].inputs produces the whole input from the cron tick, and the run body just fans out the shipped playwright-e2e run — one child per environment — with sharded over the static list.

Schedule-mode runTarget resolution
nightly-e2eStatic — a fixed ENVIRONMENTS list in the recipe
pr-review-sweepEnumeratedgithub.openPullRequests() at run time
scheduled-depsEnumeratedgithub.repositories() at run time

When the target set is static, Schedule mode is just schedules + a fixed input. Reach for github enumeration only when the targets genuinely vary tick to tick.

Flow

flowchart LR
  CRON[Cron Trigger<br/>0 2 * * *] -->|scheduled| SW[nightly-e2e<br/>scheduling Workflow]
  SW --> FAN[fan out · static list]
  FAN --> E1[playwright-e2e · staging]
  FAN --> E2[playwright-e2e · canary]

Install

  1. Deploy FlareDispatch and install the GitHub App — specs/05-byoc.md.
  2. Copy nightly-e2e.run.ts into your repo’s runs/ directory and edit the ENVIRONMENTS list.
  3. Add the cron to wrangler.jsonc"triggers": { "crons": ["0 2 * * *"] } — matching the run’s schedules[].cron, and wrangler deploy.
  4. At 02:00 UTC the Dispatcher’s scheduled() handler instantiates nightly-e2e; each environment gets a flare-dispatch/playwright-e2e check.

ref in each ENVIRONMENTS entry is a branch — a nightly suite tracks the branch tip, so sandbox.git.clone resolving the ref is intended. Pin a SHA instead if you need a frozen suite.

Source

This recipe ships its source as a typed Effect-TS run — the recommended Schedule-mode shape.

recipes/nightly-e2e/nightly-e2e.run.ts
// Recipe: nightly end-to-end suite
//
// The simplest Schedule-mode shape. A Cloudflare Cron Trigger fires this run
// every night; it dispatches the shipped `playwright-e2e` run against each
// long-lived environment. No GitHub event, no PR, no enumeration — the
// targets are a fixed list known at deploy time, so `schedules[].inputs`
// produces the whole input directly.
//
// Contrast with recipes/ai-code-review/pr-review-sweep.run.ts, which must
// *discover* its targets (open PRs) at run time. When the target set is
// static, Schedule mode is just a `schedules` block plus a fixed input —
// there is no `enumerate` step.
//
// Mode: Schedule mode — a Cloudflare Cron Trigger drives the Dispatcher's
//       scheduled() handler. See specs/04-gha-integration.md § Schedule mode.
// DSL:  `schedules` on defineRun (specs/03-dsl.md § schedules); `spawnChildRun`
//       to dispatch the shipped playwright-e2e run; `sharded` to fan out.

import { Effect, Schema } from "effect";
import { defineRun, step, io, spawnChildRun } from "@flare-dispatch/core";
import { sharded } from "@flare-dispatch/core/primitives";

// The environments to exercise nightly. This list is the recipe's entire
// configuration — edit it for your deploys. `ref` is the branch whose test
// suite to run; a nightly suite tracks the branch tip, so `sandbox.git.clone`
// resolving the ref (rather than a pinned SHA) is the intended behaviour.
const ENVIRONMENTS = [
  {
    name: "staging",
    repo: "openhackersclub/flare-dispatch",
    ref: "main",
    baseURL: "https://staging.example.com",
  },
  {
    name: "canary",
    repo: "openhackersclub/flare-dispatch",
    ref: "main",
    baseURL: "https://canary.example.com",
  },
] 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({
  firedAt: Schema.Number, // controller.scheduledTime, epoch ms
  shardsPerEnv: Schema.Number,
});

const Output = Schema.Struct({
  environments: Schema.Number,
  dispatched: Schema.Number,
});

export const nightlyE2E = defineRun({
  name: "nightly-e2e",
  version: "1.0.0",

  // 02:00 UTC daily. This expression MUST also appear in wrangler.jsonc
  // `triggers.crons` — see specs/05-byoc.md § Wrangler config.
  schedules: [
    {
      cron: "0 2 * * *",
      // One run per calendar day; a duplicate cron delivery collapses.
      idempotencyKey: ({ firedAt }) => `nightly-e2e:${isoDate(firedAt)}`,
      // A cron tick names no target — but here the targets are static, so
      // `inputs` is the whole input. No enumeration step is needed.
      inputs: ({ firedAt }) => ({ firedAt, shardsPerEnv: 4 }),
    },
  ],

  inputs: Input,
  outputs: Output,
  limits: { maxDurationSec: 3000, maxConcurrency: ENVIRONMENTS.length },

  run: (input) =>
    Effect.gen(function* () {
      yield* step("log-scope", () =>
        io.log("info", `nightly-e2e: ${ENVIRONMENTS.length} environment(s)`, {
          firedAt: input.firedAt,
        }),
      );

      // Fan out the shipped `playwright-e2e` run, one child per environment.
      // The list is static, so this is a plain count-and-index `sharded` over
      // ENVIRONMENTS — no `github` enumeration. Each child shards the suite
      // itself (`shardsPerEnv`); this run only fans out across environments.
      yield* step("dispatch", () =>
        sharded({
          count: ENVIRONMENTS.length,
          body: ({ index }) => {
            const env = ENVIRONMENTS[index - 1];
            return spawnChildRun({
              run: "playwright-e2e",
              // Semantic id — one playwright-e2e execution per env per day.
              instanceId: `playwright-e2e:${env.name}:${isoDate(input.firedAt)}`,
              input: {
                repo: env.repo,
                sha: env.ref, // branch ref — git.clone checks out the tip
                baseURL: env.baseURL,
                shards: input.shardsPerEnv,
              },
            });
          },
        }),
      );

      return {
        environments: ENVIRONMENTS.length,
        dispatched: ENVIRONMENTS.length,
      };
    }),
});