Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions actions/flare-dispatch-action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ branch protection can't gate it. The action reads `pull_request.head.sha` from
the event payload so the verdict lands on the commit the author pushed, falling
back to `GITHUB_SHA` for push events. The same head SHA keys the
`Idempotency-Key`, so a step re-run collapses onto one execution per head commit.
An `inputs.checkLabel` joins the key (`<run>-<label>-<repo>-<sha12>`), so two
steps dispatching one run with different labels stay two executions.

## Collecting signals (`collect-command`)

Expand Down
22 changes: 22 additions & 0 deletions actions/flare-dispatch-action/dispatch.bats
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,25 @@ setup() {
bash -c 'source "$SCRIPT"; set +o pipefail; printf "%s" "$J" | signals_invalid_reason'
[[ "$output" == *'source exceeds 120 chars'* ]]
}

# --- compute_targets: idempotency key ----------------------------------------

@test "compute_targets keys an unlabelled dispatch as run-repo-sha12" {
run env GITHUB_REPOSITORY=owner/name INPUT_RUN=worker-deploy ENDPOINT=https://d.example \
bash -c 'source "$SCRIPT"; SHA=0123456789abcdef; INPUTS="{\"repo\":\"owner/name\"}"; compute_targets; echo "$IDEMPOTENCY_KEY"'
[ "$status" -eq 0 ]
[ "$output" = "worker-deploy-owner_name-0123456789ab" ]
}

@test "compute_targets folds checkLabel into the key so labelled dispatches stay distinct" {
run env GITHUB_REPOSITORY=owner/name INPUT_RUN=worker-deploy ENDPOINT=https://d.example \
bash -c 'source "$SCRIPT"; SHA=0123456789abcdef; INPUTS="{\"checkLabel\":\"containers\"}"; compute_targets; echo "$IDEMPOTENCY_KEY"'
[ "$status" -eq 0 ]
[ "$output" = "worker-deploy-containers-owner_name-0123456789ab" ]
}

@test "compute_targets ignores a non-string checkLabel" {
run env GITHUB_REPOSITORY=owner/name INPUT_RUN=check ENDPOINT=https://d.example \
bash -c 'source "$SCRIPT"; SHA=0123456789abcdef; INPUTS="{\"checkLabel\":7}"; compute_targets; echo "$IDEMPOTENCY_KEY"'
[ "$output" = "check-owner_name-0123456789ab" ]
}
21 changes: 16 additions & 5 deletions actions/flare-dispatch-action/dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#
# POST ${endpoint}/v1/dispatch/${run}
# X-FlareDispatch-Signature: sha256=<hex over the RAW body bytes>
# Idempotency-Key: <run>-<repo>-<sha12>
# Idempotency-Key: <run>[-<checkLabel>]-<repo>-<sha12>
# 202 { executionId, detailsUrl?, logsUrl? } → outputs, success
# 401 → HMAC drift, no retry
# 400 / 404 → config bug, no retry
Expand Down Expand Up @@ -300,14 +300,25 @@ sign_body() {
}

# --- idempotency key + URL ---------------------------------------------------
# {run}-{repo}-{sha12} so a re-run of the same step collapses onto one execution
# at the receiver. Randomized fallback when repo/sha are absent (local act runs).
# {run}[-{checkLabel}]-{repo}-{sha12} so a re-run of the same step collapses
# onto one execution at the receiver, while two steps dispatching one run with
# different `checkLabel`s stay two executions — the label names a separate
# check-run, so collapsing them would leave the second check never posted.
# Randomized fallback when repo/sha are absent (local act runs).
# Sets $IDEMPOTENCY_KEY and $URL.

# Print `-<checkLabel>` when the inputs JSON on stdin carries a string
# `checkLabel`, else nothing. Pure.
check_label_suffix() {
jq -r 'if (.checkLabel | type) == "string" and (.checkLabel | length) > 0
then "-\(.checkLabel)" else "" end'
}

compute_targets() {
if [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "$SHA" ]; then
local repo_safe="${GITHUB_REPOSITORY//\//_}"
IDEMPOTENCY_KEY="${INPUT_RUN}-${repo_safe}-${SHA:0:12}"
local repo_safe="${GITHUB_REPOSITORY//\//_}" label
label="$(check_label_suffix <<<"${INPUTS:-null}")"
IDEMPOTENCY_KEY="${INPUT_RUN}${label}-${repo_safe}-${SHA:0:12}"
else
IDEMPOTENCY_KEY="${INPUT_RUN}-$(date +%s)-${RANDOM}"
fi
Expand Down
29 changes: 29 additions & 0 deletions apps/dispatcher/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// Plus the artifact endpoint (streams the R2 object) and /health.

import { describe, expect, it } from "vitest";
import { checkRunNameFor } from "./check-name";
import { handleRequest } from "./router";
import { fingerprint, sign } from "./hmac";
import { makeFakeEnv, makeFakeKv, makeFakeR2, makeFakeWorkflow } from "./test-helpers";
Expand Down Expand Up @@ -651,6 +652,34 @@ describe("POST /v1/dispatch/:run — dedup", () => {
expect(workflow.calls[0]!.id).toBe("check_owner_test-repo_abc123def456");
});

it("a labelled worker-deploy dispatch keeps its label through decode — own check-run name, own execution", async () => {
// A second deploy of one commit (e.g. container Workers after an image
// build) must not post under the webhook deploy's `flare-dispatch/worker-deploy`.
// The label reaches the Workflow only if the run's schema declares it —
// an undeclared key is stripped by the decode, and the check-run would
// then be named as if unlabelled.
const { env, workflow } = fixture();
const bodyText = JSON.stringify({
...validBody,
run: "worker-deploy",
inputs: {
repo: "owner/test-repo",
sha: "abc123def456",
command: "pnpm deploy:containers",
checkLabel: "containers",
},
});
const res = await handleRequest(await dispatchRequest("worker-deploy", bodyText), env);
expect(res.status).toBe(202);

const params = workflow.calls[0]!.params as { inputs: Record<string, unknown> };
expect(params.inputs.checkLabel).toBe("containers");
expect(checkRunNameFor("worker-deploy", params.inputs)).toBe(
"flare-dispatch/worker-deploy:containers",
);
expect(workflow.calls[0]!.id).toBe("worker-deploy_containers_owner_test-repo_abc123def456");
});

it("without IDEMPOTENCY_KV bound, semantic id is still used — duplicate Workflow.create is the dedup", async () => {
const { env, workflow } = fixture();
const bodyText = JSON.stringify(validBody);
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-cf/src/step-runner-cf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ describe("buildStepConfig", () => {
});
});

it("maps retries: 0 to limit 0 — a never-retry step does not fall back to CF's default retries", () => {
// `worker-deploy`'s exec step relies on this: a falsy check here would drop
// the policy and hand a deploy command CF's default retries.
expect(buildStepConfig({ timeoutSec: 1020, retries: 0 })).toEqual({
timeout: "1020 seconds",
retries: { limit: 0, delay: "5 seconds", backoff: "exponential" },
});
});

it("treats timeoutSec: 0 as set (a deliberate zero timeout)", () => {
// 0 !== undefined, so it is honored — a run that asks for 0 gets "0 seconds"
// rather than silently falling back to CF's default.
Expand Down
69 changes: 69 additions & 0 deletions runs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,72 @@ Use it when the stages are independent (different feature unifications of one
tree, say). Leave it at 1 when a later stage consumes an earlier one's output,
which sharing a container is the only way to express. A value above the stage
count is clamped to it.

## `worker-deploy` — continuous deploy on default-branch push

Webhook mode fires on `check_suite.requested` for the default branch, resolves
everything a push payload cannot carry from CONFIG_KV, and posts
`flare-dispatch/worker-deploy`. No command key → the run no-ops green.

```bash
wrangler kv key put --binding=CONFIG_KV \
"worker-deploy.command:owner/repo" "pnpm build && pnpm exec wrangler deploy"
wrangler kv key put --binding=CONFIG_KV \
"worker-deploy.secrets:owner/repo" "CLOUDFLARE_API_TOKEN" # Worker-secret NAMES
wrangler kv key put --binding=CONFIG_KV \
"worker-deploy.timeoutSec:owner/repo" "1500" # positive integer
```

`timeoutSec` precedence is dispatch value → `worker-deploy.timeoutSec:<repo>` → 900. A malformed value degrades to 900.

**A deploy is never step-retried.** The `exec` step runs with `retries: 0` and a
Workflow step timeout of the exec timeout + 120s, so the sandbox's deadline —
not the platform's 600s step default — ends a slow deploy, and a failure is
reported once instead of re-publishing every Worker on a second attempt.

### A second deploy of the same commit (`checkLabel`)

A repo that deploys part of its stack after other CI work — container-backed
Workers after an image build, say — dispatches `worker-deploy` a second time in
Action mode with a `checkLabel`. It posts `flare-dispatch/worker-deploy:<label>`
beside the webhook's check instead of overwriting it, and runs as its own
execution (the label is part of both the Action's `Idempotency-Key` and the
direct-dispatch instance id).

A labelled dispatch without `command` reads only
`worker-deploy.command:<repo>:<label>` — never the unlabelled key, which would
re-run the webhook's deploy. `worker-deploy.timeoutSec:<repo>:<label>` falls
back to the repo key.

```yaml
- uses: fractalboxdev/flare-dispatch/actions/flare-dispatch-action@<sha>
with:
run: worker-deploy
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"checkLabel": "containers",
"failOnNonZeroExit": true
}
```

A webhook dispatch and an Action dispatch of one push are always two executions:
the webhook's instance id is `worker-deploy_<repo_>_<sha12>`, the Action's is
`worker-deploy[-<label>]-<repo_>-<sha12>`.

### Deploy ordering

Two rapid pushes deploy in completion order, not push order. A command that
guards against an older push landing last compares `HEAD` to the branch tip —
but the checkout's `origin` carries no credential (the sandbox scrubs it after
the clone), so `git ls-remote origin` works only on a repo readable
anonymously. Make the guard fail closed when the lookup fails, or a private repo
skips every deploy green:

```bash
tip=$(git ls-remote origin refs/heads/main) && [ -n "$tip" ] || exit 1
[ "${tip%%[[:space:]]*}" = "$(git rev-parse HEAD)" ] || exit 0
```
142 changes: 141 additions & 1 deletion runs/worker-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
// a non-zero exit into `AcceptanceFailed`
// (f) trigger mapping — the `check_suite.requested` payload maps to inputs;
// the gate admits only the default branch
// (g) step opts — the exec step carries `retries: 0` and a step
// timeout of the exec timeout + headroom
// (h) timeout key — `worker-deploy.timeoutSec:<repo>` sets the webhook
// exec timeout; a dispatched value wins
// (i) checkLabel — a labelled dispatch reads only its labelled command
//
// Plus the standard determinism source guard.
//
Expand All @@ -26,7 +31,7 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { it } from "@effect/vitest";
import { Cause, Effect, Exit, Option } from "effect";
import { Cause, Effect, Either, Exit, Option, Schema } from "effect";
import { describe, expect } from "vitest";
import { makeCFRuntimeTest } from "@fractalboxdev/flare-dispatch-core/testing";
import { workerDeploy } from "./worker-deploy";
Expand Down Expand Up @@ -278,6 +283,141 @@ describe("worker-deploy", () => {
},
);

// --- exec StepOpts — a deploy is never step-retried --------------------------

const execStepOf = (steps: ReadonlyArray<{ name: string; metadata?: Record<string, unknown> }>) =>
steps.find((s) => s.name === "exec");

it.effect("exec StepOpts — retries 0 and a step timeout above the default exec timeout", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
});
return Effect.gen(function* () {
yield* workerDeploy.run(baseInput);
const exec = execStepOf(handles.executions.steps);
// Unset, the step inherits CF Workflows' 600s timeout + default
// retries, and a deploy over 600s publishes twice.
expect(exec?.metadata?.["stepOpts.retries"]).toBe(0);
expect(exec?.metadata?.["stepOpts.timeoutSec"]).toBe(900 + 120);
expect(exec?.metadata?.["stepOpts.retryOn"]).toBeUndefined();
}).pipe(Effect.provide(layer));
});

it.effect("exec StepOpts — the step timeout tracks a dispatched timeoutSec", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
});
return Effect.gen(function* () {
yield* workerDeploy.run({ ...baseInput, timeoutSec: 1500 });
const exec = execStepOf(handles.executions.steps);
expect(exec?.metadata?.["stepOpts.timeoutSec"]).toBe(1500 + 120);
expect(exec?.metadata?.["stepOpts.retries"]).toBe(0);
expect(handles.sandbox.execs.find((e) => e.command === DEPLOY_CMD)?.timeoutSec).toBe(1500);
}).pipe(Effect.provide(layer));
});

// --- worker-deploy.timeoutSec:<repo> — the webhook-mode timeout knob --------

const webhookInput = {
repo: "owner/name",
sha: "abc123",
secrets: [] as readonly string[],
install: false,
failOnNonZeroExit: true,
};

it.effect("timeoutSec — a webhook dispatch reads `worker-deploy.timeoutSec:<repo>`", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
config: {
"worker-deploy.command:owner/name": DEPLOY_CMD,
"worker-deploy.timeoutSec:owner/name": "1500",
},
});
return Effect.gen(function* () {
yield* workerDeploy.run(webhookInput);
expect(handles.sandbox.execs.find((e) => e.command === DEPLOY_CMD)?.timeoutSec).toBe(1500);
expect(execStepOf(handles.executions.steps)?.metadata?.["stepOpts.timeoutSec"]).toBe(
1500 + 120,
);
}).pipe(Effect.provide(layer));
});

it.effect("timeoutSec — a dispatched value wins over the config key", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
config: { "worker-deploy.timeoutSec:owner/name": "1500" },
});
return Effect.gen(function* () {
yield* workerDeploy.run({ ...baseInput, timeoutSec: 300 });
expect(handles.sandbox.execs.find((e) => e.command === DEPLOY_CMD)?.timeoutSec).toBe(300);
}).pipe(Effect.provide(layer));
});

it.effect("timeoutSec — a malformed config value degrades to the 900s default", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
config: {
"worker-deploy.command:owner/name": DEPLOY_CMD,
"worker-deploy.timeoutSec:owner/name": "25m",
},
});
return Effect.gen(function* () {
yield* workerDeploy.run(webhookInput);
expect(handles.sandbox.execs.find((e) => e.command === DEPLOY_CMD)?.timeoutSec).toBe(900);
}).pipe(Effect.provide(layer));
});

// --- checkLabel — a second deploy of one commit -----------------------------

it.effect(
"checkLabel — a command-less labelled dispatch reads ONLY its labelled command key",
() => {
const LABELLED_CMD = "pnpm deploy:containers";
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 }, [LABELLED_CMD]: { exitCode: 0 } },
config: {
"worker-deploy.command:owner/name": DEPLOY_CMD,
"worker-deploy.command:owner/name:containers": LABELLED_CMD,
"worker-deploy.timeoutSec:owner/name": "1500",
},
});
return Effect.gen(function* () {
yield* workerDeploy.run({ ...webhookInput, checkLabel: "containers" });
expect(handles.sandbox.execs.map((e) => e.command)).toContain(LABELLED_CMD);
expect(handles.sandbox.execs.map((e) => e.command)).not.toContain(DEPLOY_CMD);
// The timeout, unlike the command, falls back to the repo key.
expect(handles.sandbox.execs.find((e) => e.command === LABELLED_CMD)?.timeoutSec).toBe(
1500,
);
}).pipe(Effect.provide(layer));
},
);

it.effect(
"checkLabel — no labelled command no-ops rather than re-running the unlabelled deploy",
() => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { [DEPLOY_CMD]: { exitCode: 0 } },
config: { "worker-deploy.command:owner/name": DEPLOY_CMD },
});
return Effect.gen(function* () {
const result = yield* workerDeploy.run({ ...webhookInput, checkLabel: "containers" });
expect(result.skippedReason).toBe("not-configured");
expect(handles.sandbox.execs).toHaveLength(0);
}).pipe(Effect.provide(layer));
},
);

it("checkLabel — the input schema keeps a valid label and rejects a malformed one", () => {
const decode = Schema.decodeUnknownEither(workerDeploy.inputs);
const ok = decode({ repo: "owner/name", sha: "abc123", checkLabel: "containers" });
expect(Either.isRight(ok) && ok.right.checkLabel).toBe("containers");
expect(Either.isLeft(decode({ repo: "owner/name", sha: "abc123", checkLabel: "a b" }))).toBe(
true,
);
});

// --- Webhook trigger — check_suite as the default-branch push signal --------

const checkSuitePayload = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
Expand Down
Loading
Loading