返回 CodeWhale
compare-deployed-facts.mjs
根目录 / web / scripts / compare-deployed-facts.mjs
1 #!/usr/bin/env node
2 /**
3 * Compare the public deployment receipt with an exact local/source revision.
4 * No Cloudflare or GitHub credentials are required. By default this is a
5 * report-only command; --require-current turns drift/unavailability into a
6 * failing post-deploy gate.
7 */
8 import { execFileSync } from "node:child_process";
9 import { buildFacts, REPO_ROOT } from "./facts-lib.mjs";
10
11 function parseArgs(argv) {
12 const out = {
13 baseUrl: "https://codewhale.net",
14 expectedRevision: process.env.CODEWHALE_SOURCE_REVISION || process.env.GITHUB_SHA || null,
15 requireCurrent: false,
16 attempts: null,
17 retryDelayMs: 3_000,
18 };
19 for (let index = 0; index < argv.length; index += 1) {
20 const arg = argv[index];
21 if (arg === "--require-current") {
22 out.requireCurrent = true;
23 } else if (arg === "--base-url") {
24 out.baseUrl = argv[++index];
25 } else if (arg === "--expected-revision") {
26 out.expectedRevision = argv[++index];
27 } else if (arg === "--attempts") {
28 out.attempts = Number(argv[++index]);
29 } else if (arg === "--retry-delay-ms") {
30 out.retryDelayMs = Number(argv[++index]);
31 } else {
32 throw new Error(`unknown argument: ${arg}`);
33 }
34 }
35 out.attempts ??= out.requireCurrent ? 6 : 1;
36 return out;
37 }
38
39 function localRevision() {
40 try {
41 return execFileSync("git", ["rev-parse", "HEAD"], {
42 cwd: REPO_ROOT,
43 encoding: "utf8",
44 stdio: ["ignore", "pipe", "ignore"],
45 }).trim();
46 } catch {
47 return null;
48 }
49 }
50
51 function delay(ms) {
52 return new Promise((resolve) => setTimeout(resolve, ms));
53 }
54
55 async function fetchReceipt(url) {
56 try {
57 const response = await fetch(`${url}?receipt=${Date.now()}`, {
58 cache: "no-store",
59 headers: {
60 Accept: "application/json",
61 "User-Agent": "codewhale-deploy-facts-check",
62 },
63 });
64 if (!response.ok) {
65 return { error: `HTTP ${response.status}` };
66 }
67 return { receipt: await response.json() };
68 } catch (error) {
69 return { error: error instanceof Error ? error.message : String(error) };
70 }
71 }
72
73 function compare(expected, receipt) {
74 const differences = [];
75 const deployed = receipt?.deployed;
76 if (receipt?.schemaVersion !== 1 || !deployed || typeof deployed !== "object") {
77 return [{ field: "receipt", expected: "schemaVersion=1", deployed: null }];
78 }
79
80 const checks = [
81 ["sourceRevision", expected.sourceRevision, deployed.sourceRevision],
82 ["version", expected.version, deployed.version],
83 ["providerCount", expected.providerCount, deployed.providerCount],
84 ["toolCount", expected.toolCount, deployed.toolCount],
85 [
86 "latestPublishedRelease.tag",
87 expected.latestPublishedRelease?.tag ?? null,
88 receipt.latestPublishedRelease?.tag ?? null,
89 ],
90 ];
91 for (const [field, expectedValue, deployedValue] of checks) {
92 if (expectedValue !== deployedValue) {
93 differences.push({ field, expected: expectedValue, deployed: deployedValue });
94 }
95 }
96 return differences;
97 }
98
99 async function main() {
100 const args = parseArgs(process.argv.slice(2));
101 let baseUrl;
102 try {
103 baseUrl = new URL(args.baseUrl);
104 } catch {
105 throw new Error(`invalid --base-url: ${args.baseUrl}`);
106 }
107 if (!/^https?:$/.test(baseUrl.protocol)) {
108 throw new Error("--base-url must use http or https");
109 }
110
111 const expectedRevision = args.expectedRevision || localRevision();
112 if (!expectedRevision || !/^[0-9a-f]{40}$/i.test(expectedRevision)) {
113 throw new Error("expected revision must be an exact 40-character Git SHA");
114 }
115 if (!Number.isInteger(args.attempts) || args.attempts < 1 || args.attempts > 20) {
116 throw new Error("--attempts must be an integer from 1 to 20");
117 }
118 if (!Number.isFinite(args.retryDelayMs) || args.retryDelayMs < 0 || args.retryDelayMs > 30_000) {
119 throw new Error("--retry-delay-ms must be between 0 and 30000");
120 }
121
122 const facts = buildFacts();
123 const expected = {
124 sourceRevision: expectedRevision,
125 version: facts.version,
126 providerCount: facts.providers.length,
127 toolCount: facts.toolCount,
128 latestPublishedRelease: facts.latestPublishedRelease,
129 };
130 const endpoint = new URL("/api/facts", baseUrl).toString();
131 let last = { error: "not attempted" };
132 let differences = [];
133
134 for (let attempt = 1; attempt <= args.attempts; attempt += 1) {
135 last = await fetchReceipt(endpoint);
136 differences = last.receipt ? compare(expected, last.receipt) : [];
137 if (last.receipt && differences.length === 0) break;
138 if (attempt < args.attempts) await delay(args.retryDelayMs);
139 }
140
141 const status = last.receipt
142 ? differences.length === 0
143 ? "current"
144 : "drift"
145 : "unavailable";
146 const report = {
147 schemaVersion: 1,
148 checkedAt: new Date().toISOString(),
149 endpoint,
150 status,
151 expected,
152 deployed: last.receipt ?? null,
153 differences,
154 error: last.error ?? null,
155 deploymentAttempted: false,
156 };
157 console.log(JSON.stringify(report, null, 2));
158
159 if (args.requireCurrent && status !== "current") {
160 process.exitCode = 1;
161 }
162 }
163
164 main().catch((error) => {
165 console.error(`[compare-deployed-facts] ERROR: ${error.message}`);
166 process.exitCode = 1;
167 });
168
168 lines Plain Text