| 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 |