import crypto from "node:crypto"; import fs from "node:fs"; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function numberEnv(name, fallback) { const value = Number.parseInt(process.env[name] || "", 10); return Number.isFinite(value) && value >= 0 ? value : fallback; } function booleanEnv(name) { return /^(1|true|yes)$/i.test(process.env[name] || ""); } function appendGithubEnv(values) { const envFile = process.env.GITHUB_ENV; if (!envFile) { return; } const lines = Object.entries(values) .filter(([, value]) => value) .map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`); if (lines.length > 0) { fs.appendFileSync(envFile, `${lines.join("\n")}\n`); } } function requiredUrl() { const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL; if (!value) { throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required."); } return value.endsWith("/") ? value : `${value}/`; } function pathUrl(baseUrl, assetPath) { const path = String(assetPath || "").replace(/^\/+/, ""); return new URL(path, baseUrl).href; } async function fetchBytes(url) { const response = await fetch(url, { headers: { "Cache-Control": "no-cache", Pragma: "no-cache", }, }); const bytes = Buffer.from(await response.arrayBuffer()); return { response, bytes, text: () => bytes.toString("utf8"), }; } async function fetchJson(baseUrl, assetPath) { const url = pathUrl(baseUrl, assetPath); const result = await fetchBytes(url); const contentType = result.response.headers.get("content-type") || ""; if (!result.response.ok) { throw new Error(`${assetPath} returned HTTP ${result.response.status}`); } if (contentType.includes("text/html")) { throw new Error(`${assetPath} was served as HTML`); } if (result.bytes.length === 0) { throw new Error(`${assetPath} was empty`); } try { return JSON.parse(result.text()); } catch (error) { throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`); } } function compareCommit(actual, expected) { if (!expected) { return true; } return actual === expected || actual.startsWith(expected); } function unique(values) { return Array.from(new Set(values.filter(Boolean))); } function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } function shouldRejectHtml(assetPath) { return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath); } async function verifyAsset(baseUrl, assetPath, expectedHash) { const url = pathUrl(baseUrl, assetPath); const result = await fetchBytes(url); const contentType = result.response.headers.get("content-type") || ""; if (!result.response.ok) { throw new Error(`${assetPath} returned HTTP ${result.response.status}`); } if (result.bytes.length === 0) { throw new Error(`${assetPath} was empty`); } if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) { throw new Error(`${assetPath} was served as HTML (${contentType})`); } if (expectedHash?.sha256) { const actualHash = sha256(result.bytes); if (actualHash !== expectedHash.sha256) { throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`); } } } async function verifyShell(baseUrl, shellPath) { const result = await fetchBytes(pathUrl(baseUrl, shellPath)); const contentType = result.response.headers.get("content-type") || ""; const body = result.text(); if (!result.response.ok) { throw new Error(`${shellPath} returned HTTP ${result.response.status}`); } if (!contentType.includes("text/html")) { throw new Error(`${shellPath} did not return HTML (${contentType})`); } if (body.replace(/\s+/g, "").length < 40) { throw new Error(`${shellPath} returned an empty app shell`); } if (!body.includes('
')) { throw new Error(`${shellPath} did not include the Vue app root`); } } async function verifyRelease(baseUrl) { const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || ""; const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || ""; const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID"); const manifest = await fetchJson(baseUrl, "release-manifest.json"); const releaseEntry = await fetchJson(baseUrl, "release-entry.json"); if (!manifest.build_id) { throw new Error("release-manifest.json is missing build_id"); } if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) { throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`); } if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) { throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`); } if (releaseEntry.entry !== manifest.entry) { throw new Error("release-entry.json entry does not match release-manifest.json"); } const releaseCss = JSON.stringify(releaseEntry.css || []); const manifestCss = JSON.stringify(manifest.css || []); if (releaseCss !== manifestCss) { throw new Error("release-entry.json css does not match release-manifest.json"); } const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim())); for (const shellPath of shellPaths) { await verifyShell(baseUrl, shellPath); } const assetUrls = unique([ "release-manifest.json", "release-entry.json", manifest.entry, ...(manifest.css || []), ...(manifest.index_asset_urls || []), ...(manifest.pwa_asset_urls || []), ...(manifest.asset_urls || []), ]); for (const assetUrl of assetUrls) { if (assetUrl === "/index.html") { continue; } await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]); } return { build_id: manifest.build_id, commit_sha: manifest.commit_sha, assets: assetUrls.length, }; } async function main() { const baseUrl = requiredUrl(); const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30); const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300); const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10); if (initialWaitSeconds > 0) { console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`); await sleep(initialWaitSeconds * 1000); } const deadline = Date.now() + timeoutSeconds * 1000; let attempt = 0; let lastError = null; while (Date.now() <= deadline) { attempt += 1; try { const result = await verifyRelease(baseUrl); appendGithubEnv({ RELEASE_VERIFIED_BUILD_ID: result.build_id, RELEASE_VERIFIED_COMMIT: result.commit_sha, }); console.log( `Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}` ); return; } catch (error) { lastError = error; const message = error instanceof Error ? error.message : String(error); console.log(`Release upload not ready on attempt ${attempt}: ${message}`); if (Date.now() > deadline) { break; } await sleep(pollIntervalSeconds * 1000); } } throw lastError || new Error("Release upload verification timed out."); } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); process.exit(1); });