From 1a613705c52d0414ce6d6865cd1a9100999631d8 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Sat, 24 Oct 2026 02:30:40 +0800 Subject: [PATCH] feat(contextful): start contextful runs from tag releases only The registry carries contextful-gate and contextful-cache-warm without webhook triggers, so no pull request or push starts them; both stay dispatchable by name. wrangler.jsonc arms neither contextful-measures nor contextful-protocol. contextful-release keeps its v-tag and dry-run tag triggers. --- apps/dispatcher/src/cron-parity.test.ts | 13 +++++++++- apps/dispatcher/src/pr-close.test.ts | 8 ++++-- apps/dispatcher/src/registry.test.ts | 30 ++++++++++++++++++++++ apps/dispatcher/src/registry.ts | 11 ++++++-- apps/dispatcher/src/routes/webhook.test.ts | 14 +++------- wrangler.jsonc | 5 ++-- 6 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 apps/dispatcher/src/registry.test.ts diff --git a/apps/dispatcher/src/cron-parity.test.ts b/apps/dispatcher/src/cron-parity.test.ts index 71e370b..1270dce 100644 --- a/apps/dispatcher/src/cron-parity.test.ts +++ b/apps/dispatcher/src/cron-parity.test.ts @@ -40,7 +40,7 @@ const readArmedCrons = (): readonly string[] => { * that explains why. Adding a name here is the deliberate act; forgetting to is * what this test catches. */ -const DELIBERATELY_UNARMED = new Set(["release-notes", "product-demo"]); +const DELIBERATELY_UNARMED = new Set(["release-notes", "product-demo", "contextful-measures", "contextful-protocol"]); describe("cron parity", () => { it("every armed cron binds at least one registered run", () => { @@ -98,4 +98,15 @@ describe("cron parity", () => { .map((r) => r.name); expect(armedButListed).toEqual([]); }); + + it("arms no contextful schedule", () => { + // contextful uses FlareDispatch for tag releases only. + const armed = readArmedCrons(); + const contextful = scheduledRuns().filter((r) => r.name.startsWith("contextful-")); + for (const { crons } of contextful) { + for (const cron of crons) expect(armed).not.toContain(cron); + } + expect(contextful.length).toBeGreaterThan(0); + }); + }); diff --git a/apps/dispatcher/src/pr-close.test.ts b/apps/dispatcher/src/pr-close.test.ts index b3c0167..cc11aae 100644 --- a/apps/dispatcher/src/pr-close.test.ts +++ b/apps/dispatcher/src/pr-close.test.ts @@ -1,5 +1,6 @@ import { Effect } from "effect"; import { describe, expect, it } from "vitest"; +import type { Run, TriggerSpec } from "@fractalboxdev/flare-dispatch-core"; import { contextfulGate } from "@fractalboxdev/flare-dispatch-runs"; import { CLOSE_MARKER_PREFIX, @@ -10,7 +11,6 @@ import { isCloseGroupPayload, isCloseMarker, } from "./pr-close"; -import { triggersByEvent } from "./registry"; import { supersededSummary } from "./supersede"; const REPO = "fractalboxdev/contextful"; @@ -28,7 +28,11 @@ const closedPayload = (over: { merged?: boolean; headRepo?: string; action?: str }); describe("closedGroups", () => { - const matches = triggersByEvent("pull_request"); + // The gate's own triggers: the registry carries it without them, so no + // pull request starts or closes one there. + const matches = (contextfulGate.triggers ?? []) + .filter((trigger) => trigger.event === "pull_request") + .map((trigger) => ({ run: contextfulGate as Run, trigger: trigger as TriggerSpec })); it("names the gate's group, ranked one millisecond after the close, for a merged or unmerged close", () => { expect(matches.some(({ run }) => run.name === contextfulGate.name)).toBe(true); diff --git a/apps/dispatcher/src/registry.test.ts b/apps/dispatcher/src/registry.test.ts new file mode 100644 index 0000000..f5f48f7 --- /dev/null +++ b/apps/dispatcher/src/registry.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { lookupRun, triggersByEvent } from "./registry"; + +const CONTEXTFUL_REPO = "fractalboxdev/contextful"; + +describe("contextful runs", () => { + it("only a tag release starts a contextful run from a webhook", () => { + const events = ["pull_request", "push", "pull_request_target", "check_suite", "workflow_dispatch"]; + const started = events.flatMap((event) => + triggersByEvent(event) + .filter((m) => m.run.name.startsWith("contextful-")) + .map((m) => m.run.name), + ); + expect([...new Set(started)]).toEqual(["contextful-release"]); + }); + + it("keeps the gate dispatchable by name", () => { + expect(lookupRun("contextful-gate")).toBeDefined(); + expect(lookupRun("contextful-gate")?.triggers).toEqual([]); + }); + + it("starts a release on a v-tag push", () => { + const sha = "a".repeat(40); + const payload = { repository: { full_name: CONTEXTFUL_REPO }, ref: "refs/tags/v1.2.3", after: sha }; + const fired = triggersByEvent("push").filter( + (m) => m.run.name === "contextful-release" && m.trigger.gate?.({ payload } as never), + ); + expect(fired).toHaveLength(1); + }); +}); diff --git a/apps/dispatcher/src/registry.ts b/apps/dispatcher/src/registry.ts index 2ca3333..687663a 100644 --- a/apps/dispatcher/src/registry.ts +++ b/apps/dispatcher/src/registry.ts @@ -44,6 +44,13 @@ import { workerDeploy, } from "@fractalboxdev/flare-dispatch-runs"; +/** + * A run registered without its webhook triggers: no GitHub event starts it, + * and it stays dispatchable by name. contextful uses FlareDispatch for tag + * releases only, so its pull-request gate and cache warm fire on no push. + */ +const dispatchOnly = (run: Run): Run => ({ ...run, triggers: [] }); + /** name → Run. The single seam new runs are registered through. */ const RUN_REGISTRY: Record> = { [offloadTest.name]: offloadTest as Run, @@ -75,8 +82,8 @@ const RUN_REGISTRY: Record> = { [contextfulReleaseFormula.name]: contextfulReleaseFormula as Run, [oxlint.name]: oxlint as Run, [check.name]: check as Run, - [contextfulGate.name]: contextfulGate as Run, - [contextfulCacheWarm.name]: contextfulCacheWarm as Run, + [contextfulGate.name]: dispatchOnly(contextfulGate) as Run, + [contextfulCacheWarm.name]: dispatchOnly(contextfulCacheWarm) as Run, [vitestShard.name]: vitestShard as Run, [emailOtpLogin.name]: emailOtpLogin as Run, [finopsAudit.name]: finopsAudit as Run, diff --git a/apps/dispatcher/src/routes/webhook.test.ts b/apps/dispatcher/src/routes/webhook.test.ts index 6c1f88c..5a0fc37 100644 --- a/apps/dispatcher/src/routes/webhook.test.ts +++ b/apps/dispatcher/src/routes/webhook.test.ts @@ -551,23 +551,15 @@ describe("POST /v1/webhooks/github — pull request closed", () => { }, }); - it("starts one group close per superseding run and dispatches no run", async () => { + it("starts no group close and dispatches no run, since no registered run supersedes on a pull request", async () => { for (const merged of [true, false]) { const { env, workflow } = fixture(); const res = await handleRequest(await webhookRequest(closed(merged), { event: "pull_request" }), env); expect(res.status).toBe(202); const body = (await res.json()) as { dispatched: unknown[]; closed?: unknown[] }; expect(body.dispatched).toEqual([]); - expect(workflow.calls).toHaveLength(1); - expect(workflow.calls[0]?.params).toEqual({ - closeGroup: { - group: `contextful-gate:${REPO}#42`, - rank: Date.parse("2026-10-19T10:00:05Z") + 1, - repo: REPO, - installation_id: 77, - }, - }); - expect(body.closed).toEqual([{ group: `contextful-gate:${REPO}#42`, instanceId: workflow.calls[0]?.id }]); + expect(workflow.calls).toHaveLength(0); + expect(body.closed ?? []).toEqual([]); } }); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 6945812..caca594 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -42,8 +42,9 @@ "45 5 * * *", // org-spec-audit — daily, after spec-drift-pr "0 6 * * *", // ci-triage-pr — daily "0 7 * * 1", // finops-audit — Mondays - "17 2 * * *", // contextful-measures — daily - "17 3 * * SUN", // contextful-protocol — Sunday at 03:17 UTC + // contextful-measures ("17 2 * * *") and contextful-protocol + // ("17 3 * * SUN") are unarmed: contextful uses FlareDispatch for tag + // releases only. // The verdict reconcile sweep, not a run: re-sends the conclusion of // every finished execution GitHub never received (DELIVERY_SWEEP_CRON, // apps/dispatcher/src/delivery-sweep.ts).