Files
pleno-vue/scripts/release/verify-upload.mjs
T

216 lines
6.9 KiB
JavaScript

import crypto from "node:crypto";
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 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 || "";
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 (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);
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);
});