Files

304 lines
10 KiB
JavaScript

import crypto from "node:crypto";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
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, deadline) {
const configuredTimeout = Math.max(1, numberEnv("RELEASE_FETCH_TIMEOUT_SECONDS", 30)) * 1000;
const remaining = deadline ? Math.max(1, deadline - Date.now()) : configuredTimeout;
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
signal: AbortSignal.timeout(Math.min(configuredTimeout, remaining)),
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
bytes,
text: () => bytes.toString("utf8"),
};
}
async function fetchJson(baseUrl, assetPath, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url, deadline);
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}`, {
cause: 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);
}
function isMutableReleaseFile(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:index\.html|release-(?:entry|manifest)\.json|manifest(?:\.json|\.webmanifest)|registerSW\.js|sw\.js)$/i.test(
normalized
);
}
function isContentAddressedAsset(assetPath) {
const normalized = String(assetPath || "").replace(/^\/+/, "");
return /(?:^|\/)(?:workbox-)?[^/]*[-.][A-Za-z0-9_-]{8}\.(?:css|gif|ico|jpe?g|js|json|map|mp3|ogg|png|svg|webp|woff2?)$/i.test(
normalized
);
}
export function verifyCachePolicy(assetPath, response) {
if (!booleanEnv("RELEASE_REQUIRE_CACHE_HEADERS")) {
return;
}
const cacheControl = response.headers.get("cache-control") || "";
const mutable = isMutableReleaseFile(assetPath);
if (mutable && !/(?:no-store|no-cache|max-age=0)/i.test(cacheControl)) {
throw new Error(
`${assetPath} must be served with a revalidating Cache-Control policy (got ${cacheControl || "missing"})`
);
}
if (!mutable && isContentAddressedAsset(assetPath) && !/immutable/i.test(cacheControl)) {
throw new Error(`${assetPath} must be served with immutable caching (got ${cacheControl || "missing"})`);
}
}
async function verifyAsset(baseUrl, assetPath, expectedHash, deadline) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url, deadline);
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})`);
}
verifyCachePolicy(assetPath, result.response);
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, deadline) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath), deadline);
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 (!containsVueAppRoot(body)) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
export function containsVueAppRoot(body) {
return /<div\b[^>]*\bid=(["'])app\1[^>]*>/i.test(String(body));
}
async function runWithConcurrency(values, concurrency, operation) {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex;
nextIndex += 1;
await operation(values[index]);
}
});
await Promise.all(workers);
}
async function verifyRelease(baseUrl, deadline) {
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", deadline);
const releaseEntry = await fetchJson(baseUrl, "release-entry.json", deadline);
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, deadline);
}
const assetUrls = unique([
"index.html",
"release-manifest.json",
"release-entry.json",
manifest.entry,
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]);
const concurrency = Math.max(1, Math.min(32, numberEnv("RELEASE_VERIFY_CONCURRENCY", 8)));
await runWithConcurrency(assetUrls, concurrency, async (assetUrl) => {
await verifyAsset(
baseUrl,
assetUrl,
manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`],
deadline
);
});
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, deadline);
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(Math.min(pollIntervalSeconds * 1000, Math.max(0, deadline - Date.now())));
}
}
throw lastError || new Error("Release upload verification timed out.");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
}