Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
242 lines
7.6 KiB
JavaScript
242 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}`, {
|
|
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);
|
|
}
|
|
|
|
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);
|
|
});
|