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
11 changes: 9 additions & 2 deletions deploy/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -80,7 +83,11 @@ async function resolve(): Promise<void> {
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 deployTarget = process.env.SITE_DEPLOY_TARGET || undefined;
const recorded =
(
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);
if (errors.length > 0) failWith(errors);
Expand Down
40 changes: 29 additions & 11 deletions deploy/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecordedReceipt[]> {
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<RecordedReceipt[]> {
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;
}
}
Expand Down
11 changes: 7 additions & 4 deletions deploy/lib/receipt.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };
export type ProbeSummary = { ok: boolean; matched: number; mismatched: string[]; errors: string[]; attempts: number };
Expand Down Expand Up @@ -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<Receipt>): string[] {
Expand Down
81 changes: 81 additions & 0 deletions deploy/test/github.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, { state: string; description: string | null }[]>): 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);
});
6 changes: 5 additions & 1 deletion deploy/test/receipt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading