From eccc02346a94c2cc018d991d38be6deaa8a9ceb5 Mon Sep 17 00:00:00 2001 From: Joe Harris Date: Wed, 14 Oct 2026 16:10:41 -0400 Subject: [PATCH 1/3] Scope the deployed baseline to the deploying target --- deploy/cli.ts | 11 ++++- deploy/lib/github.ts | 40 +++++++++++++----- deploy/lib/receipt.ts | 11 +++-- deploy/test/github.test.ts | 81 +++++++++++++++++++++++++++++++++++++ deploy/test/receipt.test.ts | 6 ++- 5 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 deploy/test/github.test.ts diff --git a/deploy/cli.ts b/deploy/cli.ts index 96f6af5..5a5e29c 100644 --- a/deploy/cli.ts +++ b/deploy/cli.ts @@ -53,7 +53,10 @@ type Resolution = { }; async function currentDeployed(): Promise<{ receipt: Receipt; sha: string } | null> { - const recorded = await recordedReceipts(githubApi(), SITE_REPO); + const target = process.env.SITE_DEPLOY_TARGET || undefined; + const recorded = await recordedReceipts(githubApi(), SITE_REPO, "github-pages", target, (runId) => + downloadReceipt(SITE_REPO, runId), + ); if (recorded.length === 0) return null; return { receipt: verifiedReceipt(recorded[0], (runId) => downloadReceipt(SITE_REPO, runId)), sha: recorded[0].sha }; } @@ -80,7 +83,11 @@ async function resolve(): Promise { if (mode === "rollback") { const runId = process.env.ROLLBACK_RUN_ID ?? ""; if (!/^\d+$/.test(runId)) failWith(["rollback needs a numeric receipt run id"]); - const recorded = (await recordedReceipts(githubApi(), SITE_REPO)).find((entry) => entry.runId === runId) ?? null; + const target = process.env.SITE_DEPLOY_TARGET || undefined; + const recorded = + ( + await recordedReceipts(githubApi(), SITE_REPO, "github-pages", target, (id) => downloadReceipt(SITE_REPO, id)) + ).find((entry) => entry.runId === runId) ?? null; const target = recorded ? verifiedReceipt(recorded, (id) => downloadReceipt(SITE_REPO, id)) : null; const errors = rollbackTargetErrors(target, runId); if (errors.length > 0) failWith(errors); diff --git a/deploy/lib/github.ts b/deploy/lib/github.ts index f5f3c8e..7cb3ae7 100644 --- a/deploy/lib/github.ts +++ b/deploy/lib/github.ts @@ -29,19 +29,37 @@ export function githubApi(token = process.env.GH_TOKEN ?? process.env.GITHUB_TOK }; } -export type RecordedReceipt = { deploymentId: number; sha: string; runId: string }; +export type RecordedReceipt = { deploymentId: number; sha: string; runId: string; target: string | null }; -export async function recordedReceipts(api: Api, repo: string, environment = "github-pages"): Promise { - const deployments = await api(`repos/${repo}/deployments?environment=${environment}&per_page=100`); - if (deployments.status !== 200) throw new Error(`listing deployments failed closed (HTTP ${deployments.status})`); +export async function recordedReceipts( + api: Api, + repo: string, + environment = "github-pages", + target?: string, + load?: (runId: string) => Receipt, +): Promise { const recorded: RecordedReceipt[] = []; - for (const deployment of deployments.body as { id: number }[]) { - const statuses = await api(`repos/${repo}/deployments/${deployment.id}/statuses?per_page=100`); - if (statuses.status !== 200) throw new Error(`reading deployment ${deployment.id} statuses failed closed (HTTP ${statuses.status})`); - for (const status of statuses.body as { state: string; description: string | null }[]) { - const parsed = status.state === "success" ? parseStatusDescription(status.description) : null; - if (parsed) { - recorded.push({ deploymentId: deployment.id, ...parsed }); + for (let page = 1; ; page += 1) { + const deployments = await api(`repos/${repo}/deployments?environment=${environment}&per_page=100&page=${page}`); + if (deployments.status !== 200) throw new Error(`listing deployments failed closed (HTTP ${deployments.status})`); + const rows = deployments.body as { id: number }[]; + if (rows.length === 0) break; + for (const deployment of rows) { + const statuses = await api(`repos/${repo}/deployments/${deployment.id}/statuses?per_page=100`); + if (statuses.status !== 200) + throw new Error(`reading deployment ${deployment.id} statuses failed closed (HTTP ${statuses.status})`); + for (const status of statuses.body as { state: string; description: string | null }[]) { + const parsed = status.state === "success" ? parseStatusDescription(status.description) : null; + if (!parsed) continue; + const resolved = + parsed.target ?? + (load + ? verifiedReceipt({ deploymentId: deployment.id, sha: parsed.sha, runId: parsed.runId, target: null }, load) + .target + : null); + if (target === undefined || resolved === target) { + recorded.push({ deploymentId: deployment.id, sha: parsed.sha, runId: parsed.runId, target: resolved }); + } break; } } diff --git a/deploy/lib/receipt.ts b/deploy/lib/receipt.ts index ae1fcff..fcee0a2 100644 --- a/deploy/lib/receipt.ts +++ b/deploy/lib/receipt.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; export const RECEIPT_SCHEMA = "site-deploy-receipt/v1"; -export const RECEIPT_STATUS = /^site-deploy receipt sha256:([0-9a-f]{64}) run:(\d+)$/; +export const RECEIPT_STATUS = /^site-deploy receipt sha256:([0-9a-f]{64}) run:(\d+)(?: target:(\S+))?$/; export type GateSummary = { ok: boolean; results: Record }; export type ProbeSummary = { ok: boolean; matched: number; mismatched: string[]; errors: string[]; attempts: number }; @@ -66,12 +66,15 @@ export function receiptSha(receipt: Receipt): string { } export function statusDescription(receipt: Receipt): string { - return `site-deploy receipt sha256:${receiptSha(receipt)} run:${receipt.run_id}`; + return `site-deploy receipt sha256:${receiptSha(receipt)} run:${receipt.run_id} target:${receipt.target}`; } -export function parseStatusDescription(description: string | null | undefined): { sha: string; runId: string } | null { +export function parseStatusDescription( + description: string | null | undefined, +): { sha: string; runId: string; target?: string } | null { const match = RECEIPT_STATUS.exec(description ?? ""); - return match ? { sha: match[1], runId: match[2] } : null; + if (!match) return null; + return { sha: match[1], runId: match[2], ...(match[3] ? { target: match[3] } : {}) }; } export function receiptErrors(receipt: Partial): string[] { diff --git a/deploy/test/github.test.ts b/deploy/test/github.test.ts new file mode 100644 index 0000000..31dd138 --- /dev/null +++ b/deploy/test/github.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { recordedReceipts, type Api } from "../lib/github.ts"; +import { receiptSha, statusDescription, type Receipt } from "../lib/receipt.ts"; +import { receipt } from "./helpers.ts"; + +const REPO = "BenchBox-dev/benchbox-site"; + +function productionReceipt(runId: string, coreSha: string): Receipt { + return receipt({ run_id: runId, target: "production", core_sha: coreSha, deployment_id: null }); +} + +function rehearsalReceipt(runId: string, coreSha: string): Receipt { + return receipt({ run_id: runId, target: "rehearsal", core_sha: coreSha, deployment_id: null }); +} + +function stubApi(pages: { id: number }[][], statuses: Record): Api { + return async (route) => { + const list = route.match(/deployments\?environment=github-pages&per_page=100&page=(\d+)/); + if (list) { + const page = pages[Number(list[1]) - 1] ?? []; + return { status: 200, body: page }; + } + const one = route.match(/deployments\/(\d+)\/statuses/); + if (one) return { status: 200, body: statuses[Number(one[1])] ?? [] }; + throw new Error(`unexpected route ${route}`); + }; +} + +test("a newer rehearsal receipt does not change the production baseline", async () => { + const rehearsal = rehearsalReceipt("109", "b".repeat(40)); + const production = productionReceipt("108", "a".repeat(40)); + const api = stubApi([[{ id: 9 }, { id: 8 }]], { + 9: [{ state: "success", description: statusDescription(rehearsal) }], + 8: [{ state: "success", description: statusDescription(production) }], + }); + const load = (runId: string) => (runId === "109" ? rehearsal : production); + const forProduction = await recordedReceipts(api, REPO, "github-pages", "production", load); + assert.deepEqual( + forProduction.map((entry) => entry.runId), + ["108"], + ); + assert.equal(forProduction[0].target, "production"); + const forRehearsal = await recordedReceipts(api, REPO, "github-pages", "rehearsal", load); + assert.deepEqual( + forRehearsal.map((entry) => entry.runId), + ["109"], + ); +}); + +test("a description without a target resolves it from the verified receipt", async () => { + const production = productionReceipt("108", "a".repeat(40)); + const legacy = `site-deploy receipt sha256:${receiptSha(production)} run:108`; + const api = stubApi([[{ id: 8 }]], { 8: [{ state: "success", description: legacy }] }); + let loaded: string[] = []; + const load = (runId: string) => { + loaded.push(runId); + return production; + }; + const forProduction = await recordedReceipts(api, REPO, "github-pages", "production", load); + assert.deepEqual(loaded, ["108"]); + assert.deepEqual( + forProduction.map((entry) => entry.runId), + ["108"], + ); + assert.equal(forProduction[0].target, "production"); + const forRehearsal = await recordedReceipts(api, REPO, "github-pages", "rehearsal", load); + assert.deepEqual(forRehearsal, []); +}); + +test("rollback lookup finds a receipt beyond the first page", async () => { + const production = productionReceipt("105", "a".repeat(40)); + const first = Array.from({ length: 100 }, (_, index) => ({ id: 1000 + index })); + const api = stubApi([first, [{ id: 5 }]], { + 5: [{ state: "success", description: statusDescription(production) }], + }); + const recorded = await recordedReceipts(api, REPO, "github-pages", "production", () => production); + const found = recorded.find((entry) => entry.runId === "105") ?? null; + assert.ok(found); + assert.equal(found.deploymentId, 5); +}); diff --git a/deploy/test/receipt.test.ts b/deploy/test/receipt.test.ts index 0dabeff..7d72bc3 100644 --- a/deploy/test/receipt.test.ts +++ b/deploy/test/receipt.test.ts @@ -15,7 +15,11 @@ test("the deployment status description round-trips and fits GitHub's limit", () const value = receipt({ run_id: "37884131752" }); const description = statusDescription(value); assert.ok(description.length <= 140); - assert.deepEqual(parseStatusDescription(description), { sha: receiptSha(value), runId: "37884131752" }); + assert.deepEqual(parseStatusDescription(description), { + sha: receiptSha(value), + runId: "37884131752", + target: "rehearsal", + }); assert.equal(parseStatusDescription("deployed"), null); }); From a54903c3f3f5754f19e1447f1bfcf9ea791c5eea Mon Sep 17 00:00:00 2001 From: Joe Harris Date: Wed, 14 Oct 2026 22:59:18 -0400 Subject: [PATCH 2/3] Fix rollback receipt selection for target-scoped deploys --- deploy/cli.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/cli.ts b/deploy/cli.ts index 5a5e29c..20f5dc2 100644 --- a/deploy/cli.ts +++ b/deploy/cli.ts @@ -53,8 +53,8 @@ type Resolution = { }; async function currentDeployed(): Promise<{ receipt: Receipt; sha: string } | null> { - const target = process.env.SITE_DEPLOY_TARGET || undefined; - const recorded = await recordedReceipts(githubApi(), SITE_REPO, "github-pages", target, (runId) => + const deployTarget = process.env.SITE_DEPLOY_TARGET || undefined; + const recorded = await recordedReceipts(githubApi(), SITE_REPO, "github-pages", deployTarget, (runId) => downloadReceipt(SITE_REPO, runId), ); if (recorded.length === 0) return null; @@ -83,10 +83,10 @@ async function resolve(): Promise { if (mode === "rollback") { const runId = process.env.ROLLBACK_RUN_ID ?? ""; if (!/^\d+$/.test(runId)) failWith(["rollback needs a numeric receipt run id"]); - const target = process.env.SITE_DEPLOY_TARGET || undefined; + const deployTarget = process.env.SITE_DEPLOY_TARGET || undefined; const recorded = ( - await recordedReceipts(githubApi(), SITE_REPO, "github-pages", target, (id) => downloadReceipt(SITE_REPO, id)) + await recordedReceipts(githubApi(), SITE_REPO, "github-pages", deployTarget, (id) => downloadReceipt(SITE_REPO, id)) ).find((entry) => entry.runId === runId) ?? null; const target = recorded ? verifiedReceipt(recorded, (id) => downloadReceipt(SITE_REPO, id)) : null; const errors = rollbackTargetErrors(target, runId); From b0e1231fc20795139ed2d4b19ea82dc7948a2f35 Mon Sep 17 00:00:00 2001 From: Joe Harris Date: Wed, 14 Oct 2026 22:59:31 -0400 Subject: [PATCH 3/3] Keep the deploy baseline target scoped --- deploy/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/cli.ts b/deploy/cli.ts index 20f5dc2..6a02424 100644 --- a/deploy/cli.ts +++ b/deploy/cli.ts @@ -53,8 +53,8 @@ type Resolution = { }; async function currentDeployed(): Promise<{ receipt: Receipt; sha: string } | null> { - const deployTarget = process.env.SITE_DEPLOY_TARGET || undefined; - const recorded = await recordedReceipts(githubApi(), SITE_REPO, "github-pages", deployTarget, (runId) => + const target = process.env.SITE_DEPLOY_TARGET || undefined; + const recorded = await recordedReceipts(githubApi(), SITE_REPO, "github-pages", target, (runId) => downloadReceipt(SITE_REPO, runId), ); if (recorded.length === 0) return null;