240 lines
7.6 KiB
JavaScript
240 lines
7.6 KiB
JavaScript
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('<div id="app"></div>')) {
|
|
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);
|
|
});
|