Recipes / Action mode

Test matrix

Fan one test command across N shards via the `matrix-fanout` run — Workflows `createBatch` spawns the children, scale-to-zero between runs.

Use case
Same command fanned out across N shards
Recommended mode
Action
Files
ci.yml, matrix-fanout.run.ts

Source

This recipe ships both triggers — a typed Effect-TS run (*.run.ts) and a GitHub Actions workflow (ci.yml). Use whichever fits; Action mode is the recommended default.

recipes/test-matrix/ci.yml
# 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.
recipes/test-matrix/matrix-fanout.run.ts
// 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 };
    }),
});