Add atomic cPanel frontend deployment (#178)
Build and archive the tested frontend, upload it through dedicated FTPS credentials, and atomically activate it through cPanel after CI succeeds.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { DeploymentError, deriveCpanelRoot, normalizeRemoteRoot } from "./cpanel-deploy-lib.mjs";
|
||||
|
||||
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
function required(env, name) {
|
||||
const value = env[name];
|
||||
const hasControlCharacter =
|
||||
typeof value === "string" &&
|
||||
Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 31 || code === 127;
|
||||
});
|
||||
if (typeof value !== "string" || !value || hasControlCharacter) {
|
||||
throw new DeploymentError(`Missing or invalid ${name}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function httpsUrl(value, name) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
|
||||
}
|
||||
if (url.protocol !== "https:" || url.username || url.password) {
|
||||
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
|
||||
}
|
||||
return url.href.endsWith("/") ? url.href : `${url.href}/`;
|
||||
}
|
||||
|
||||
export function readRootConfig(env = process.env) {
|
||||
const user = required(env, "PRODUCTION_CPANEL_USER");
|
||||
if (!SAFE_COMPONENT.test(user)) {
|
||||
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
||||
}
|
||||
const webroot = normalizeRemoteRoot(env.PRODUCTION_CPANEL_WEBROOT || "public_html", "PRODUCTION_CPANEL_WEBROOT");
|
||||
if (webroot.includes("/")) {
|
||||
throw new DeploymentError("PRODUCTION_CPANEL_WEBROOT must be one account-home entry.");
|
||||
}
|
||||
return {
|
||||
user,
|
||||
token: required(env, "PRODUCTION_CPANEL_API_TOKEN"),
|
||||
apiUrl: httpsUrl(required(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
|
||||
deploymentRoot: deriveCpanelRoot(required(env, "PRODUCTION_CPANEL_PATH"), user),
|
||||
webroot,
|
||||
frontendUrl: httpsUrl(env.PRODUCTION_FRONTEND_URL || "https://truckwash.io", "PRODUCTION_FRONTEND_URL"),
|
||||
};
|
||||
}
|
||||
|
||||
function entryName(entry) {
|
||||
return String(entry?.file || entry?.name || entry?.basename || "");
|
||||
}
|
||||
|
||||
function normalizedType(entry) {
|
||||
const value = String(entry?.type || entry?.filetype || "").toLowerCase();
|
||||
if (value.includes("link") || entry?.islink) return "link";
|
||||
if (value.includes("dir") || entry?.isdir) return "dir";
|
||||
if (value.includes("file") || entry?.isfile) return "file";
|
||||
return value || "unknown";
|
||||
}
|
||||
|
||||
function normalizeAccountPath(config, value) {
|
||||
const normalized = String(value || "")
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
const homePrefix = `home/${config.user}/`;
|
||||
return normalized.startsWith(homePrefix) ? normalized.slice(homePrefix.length) : normalized;
|
||||
}
|
||||
|
||||
function safeAccountPath(value, label = "cPanel path") {
|
||||
const normalized = String(value || "").replace(/^\/+/, "");
|
||||
if (!normalized || /[\\,]/.test(normalized) || normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))) {
|
||||
throw new DeploymentError(`${label} is unsafe.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function responseError(payload) {
|
||||
return (
|
||||
payload?.cpanelresult?.error ||
|
||||
payload?.cpanelresult?.event?.reason ||
|
||||
payload?.result?.errors?.[0] ||
|
||||
"unknown cPanel error"
|
||||
);
|
||||
}
|
||||
|
||||
export class CpanelAccountClient {
|
||||
constructor(config, options = {}) {
|
||||
this.config = config;
|
||||
this.fetch = options.fetchImpl || globalThis.fetch;
|
||||
this.timeoutMs = options.timeoutMs || 30_000;
|
||||
this.allowedMutable = new Set(options.allowedMutable || []);
|
||||
}
|
||||
|
||||
redact(value) {
|
||||
let result = String(value || "");
|
||||
for (const secret of [this.config.user, this.config.token, this.config.apiUrl].filter(Boolean)) {
|
||||
result = result.replaceAll(secret, "[redacted]");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async request(url) {
|
||||
let response;
|
||||
try {
|
||||
response = await this.fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `cpanel ${this.config.user}:${this.config.token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DeploymentError("cPanel request failed.", { cause: error });
|
||||
}
|
||||
if (!response.ok) throw new DeploymentError(`cPanel returned HTTP ${response.status}.`);
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new DeploymentError("cPanel returned invalid JSON.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async api2(functionName, parameters) {
|
||||
const url = new URL("json-api/cpanel", this.config.apiUrl);
|
||||
url.searchParams.set("cpanel_jsonapi_user", this.config.user);
|
||||
url.searchParams.set("cpanel_jsonapi_apiversion", "2");
|
||||
url.searchParams.set("cpanel_jsonapi_module", "Fileman");
|
||||
url.searchParams.set("cpanel_jsonapi_func", functionName);
|
||||
for (const [name, value] of Object.entries(parameters)) url.searchParams.set(name, String(value));
|
||||
const payload = await this.request(url);
|
||||
const result = payload?.cpanelresult;
|
||||
const failed = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
|
||||
if (result?.event?.result !== 1 || failed) {
|
||||
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseError(payload))}`);
|
||||
}
|
||||
return [...(result?.data || []), ...(result?.files || []), ...(result?.dirs || [])];
|
||||
}
|
||||
|
||||
async list(directory) {
|
||||
const dir = directory === "." ? `/home/${this.config.user}` : safeAccountPath(directory);
|
||||
return await this.api2("listfiles", {
|
||||
dir,
|
||||
filelist: 0,
|
||||
needmime: 0,
|
||||
showdotfiles: 1,
|
||||
types: "dir|file|link",
|
||||
});
|
||||
}
|
||||
|
||||
async domains() {
|
||||
const url = new URL("execute/DomainInfo/domains_data", this.config.apiUrl);
|
||||
url.searchParams.set("format", "list");
|
||||
const payload = await this.request(url);
|
||||
if (payload?.result?.status !== 1) {
|
||||
throw new DeploymentError(`cPanel DomainInfo failed: ${responseError(payload)}`);
|
||||
}
|
||||
if (!Array.isArray(payload.result.data)) {
|
||||
throw new DeploymentError("cPanel DomainInfo returned an unexpected data shape.");
|
||||
}
|
||||
return payload.result.data;
|
||||
}
|
||||
|
||||
assertMutable(remotePath) {
|
||||
const safe = safeAccountPath(remotePath);
|
||||
if (!this.allowedMutable.has(safe)) {
|
||||
throw new DeploymentError(`cPanel mutation outside the explicit restore allowlist: ${safe}.`);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
async rename(source, destination) {
|
||||
return await this.api2("fileop", {
|
||||
op: "rename",
|
||||
sourcefiles: this.assertMutable(source),
|
||||
destfiles: this.assertMutable(destination),
|
||||
doubledecode: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function find(entries, name) {
|
||||
return entries.find((entry) => entryName(entry) === name);
|
||||
}
|
||||
|
||||
function publicEntry(entry) {
|
||||
if (!entry) return null;
|
||||
return {
|
||||
name: entryName(entry),
|
||||
type: normalizedType(entry),
|
||||
mode: String(entry.mode || entry.permissions || ""),
|
||||
modified: String(entry.mtime || entry.modified || ""),
|
||||
size: String(entry.size ?? entry.filesize ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function domainRoot(config, domain) {
|
||||
return normalizeAccountPath(config, domain?.documentroot || domain?.document_root || domain?.docroot || "");
|
||||
}
|
||||
|
||||
function recoveryPattern(webroot) {
|
||||
return new RegExp(`^${webroot}[-._](?:recovery|backup|before[-._]atomic)[-._][A-Za-z0-9._-]+$`, "i");
|
||||
}
|
||||
|
||||
function inspectedDomain(config, domain, index) {
|
||||
const name = String(domain?.domain || domain?.servername || "").trim();
|
||||
const documentRoot = domainRoot(config, domain);
|
||||
if (!name || !documentRoot) {
|
||||
throw new DeploymentError(`cPanel DomainInfo returned incomplete domain data at index ${index}.`);
|
||||
}
|
||||
return {
|
||||
domain: name,
|
||||
type: String(domain?.domain_type || domain?.type || ""),
|
||||
documentRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function auditRoot(config, options = {}) {
|
||||
const client = options.client || new CpanelAccountClient(config, options);
|
||||
const [homeEntries, deploymentEntries, domains] = await Promise.all([
|
||||
client.list("."),
|
||||
client.list(config.deploymentRoot),
|
||||
client.domains(),
|
||||
]);
|
||||
const currentEntry = find(deploymentEntries, "current");
|
||||
const currentPath = `${config.deploymentRoot}/current`;
|
||||
const requiredReleaseFiles = ["index.html", ".htaccess", "release-manifest.json", "release-entry.json"];
|
||||
const current = {
|
||||
path: currentPath,
|
||||
type: normalizedType(currentEntry),
|
||||
accessible: false,
|
||||
missingFiles: requiredReleaseFiles,
|
||||
error: "",
|
||||
};
|
||||
if (currentEntry && normalizedType(currentEntry) === "link") {
|
||||
try {
|
||||
const currentEntries = await client.list(currentPath);
|
||||
current.accessible = true;
|
||||
current.missingFiles = requiredReleaseFiles.filter((name) => !find(currentEntries, name));
|
||||
} catch (error) {
|
||||
current.error = error instanceof Error ? error.message : "Could not follow the current link.";
|
||||
}
|
||||
} else {
|
||||
current.error = currentEntry
|
||||
? "The deployment current entry is not a symbolic link."
|
||||
: "The deployment current entry is missing.";
|
||||
}
|
||||
const root = publicEntry(find(homeEntries, config.webroot));
|
||||
const rootAccess = { accessible: false, error: "" };
|
||||
let rootEntries = [];
|
||||
if (root) {
|
||||
try {
|
||||
rootEntries = await client.list(config.webroot);
|
||||
rootAccess.accessible = true;
|
||||
} catch (error) {
|
||||
rootAccess.error = error instanceof Error ? error.message : "Could not inspect the primary webroot.";
|
||||
}
|
||||
} else {
|
||||
rootAccess.error = "The primary webroot entry is missing.";
|
||||
}
|
||||
const recoveryCandidates = homeEntries
|
||||
.map((entry) => publicEntry(entry))
|
||||
.filter((entry) => entry && recoveryPattern(config.webroot).test(entry.name))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const domainRoots = domains
|
||||
.map((domain, index) => inspectedDomain(config, domain, index))
|
||||
.sort((left, right) =>
|
||||
[left.domain, left.documentRoot, left.type]
|
||||
.join("\0")
|
||||
.localeCompare([right.domain, right.documentRoot, right.type].join("\0"))
|
||||
);
|
||||
const nestedDomainRoots = domainRoots.filter(({ documentRoot }) => documentRoot.startsWith(`${config.webroot}/`));
|
||||
const state = {
|
||||
webroot: root,
|
||||
webrootAccess: rootAccess,
|
||||
webrootEntries: rootEntries.map((entry) => publicEntry(entry)).filter(Boolean),
|
||||
current,
|
||||
recoveryCandidates,
|
||||
domainRoots,
|
||||
nestedDomainRoots,
|
||||
};
|
||||
const stateToken = crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
||||
return {
|
||||
...state,
|
||||
stateToken,
|
||||
rootTargetVerified: false,
|
||||
healthy: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyFrontend(config, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||
const checks = [
|
||||
["", "html"],
|
||||
["index.html", "html"],
|
||||
["release-manifest.json", "json"],
|
||||
["guest/book/wash", "html"],
|
||||
];
|
||||
for (const [pathname, expected] of checks) {
|
||||
const requestedUrl = new URL(pathname, config.frontendUrl);
|
||||
const response = await fetchImpl(requestedUrl, {
|
||||
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
|
||||
});
|
||||
if (!response.ok) throw new DeploymentError(`Live ${pathname || "/"} returned HTTP ${response.status}.`);
|
||||
if (response.url && new URL(response.url).origin !== requestedUrl.origin) {
|
||||
throw new DeploymentError(`Live ${pathname || "/"} redirected outside the production frontend origin.`);
|
||||
}
|
||||
const body = await response.text();
|
||||
if (body.includes("Index of /")) throw new DeploymentError("The production root still exposes a directory index.");
|
||||
if (expected === "json") {
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(body);
|
||||
} catch {
|
||||
throw new DeploymentError(`Live ${pathname} did not return JSON.`);
|
||||
}
|
||||
if (!/^[a-f0-9]{40}$/i.test(String(manifest?.commit_sha || "")) || !String(manifest?.build_id || "")) {
|
||||
throw new DeploymentError(`Live ${pathname} did not identify a packaged frontend release.`);
|
||||
}
|
||||
} else if (!/<div\s+id=["']app["']/i.test(body) || !/<title>[^<]*truck\s*wash/i.test(body)) {
|
||||
throw new DeploymentError(`Live ${pathname || "/"} did not return the frontend HTML shell.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function homeNames(client) {
|
||||
return new Set((await client.list(".")).map((entry) => entryName(entry)).filter(Boolean));
|
||||
}
|
||||
|
||||
async function renameWithReconciliation(client, source, destination) {
|
||||
try {
|
||||
await client.rename(source, destination);
|
||||
return;
|
||||
} catch (error) {
|
||||
let names;
|
||||
try {
|
||||
names = await homeNames(client);
|
||||
} catch (inspectionError) {
|
||||
throw new DeploymentError(`Could not reconcile the cPanel rename from ${source} to ${destination}.`, {
|
||||
cause: new AggregateError([error, inspectionError]),
|
||||
});
|
||||
}
|
||||
const sourceExists = names.has(source);
|
||||
const destinationExists = names.has(destination);
|
||||
if (!sourceExists && destinationExists) return;
|
||||
if (sourceExists && !destinationExists) {
|
||||
throw new DeploymentError(`cPanel did not rename ${source} to ${destination}; the source remains in place.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw new DeploymentError(`The cPanel rename from ${source} to ${destination} left an ambiguous account state.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function reinstatePreRestoreState(client, webroot, recovery, failed) {
|
||||
const errors = [];
|
||||
let names;
|
||||
try {
|
||||
names = await homeNames(client);
|
||||
} catch (error) {
|
||||
return [error];
|
||||
}
|
||||
|
||||
const restored = () => names.has(webroot) && names.has(recovery) && !names.has(failed);
|
||||
if (restored()) return errors;
|
||||
|
||||
if (names.has(webroot) && !names.has(recovery) && names.has(failed)) {
|
||||
try {
|
||||
await renameWithReconciliation(client, webroot, recovery);
|
||||
names = await homeNames(client);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (!names.has(webroot) && names.has(recovery) && names.has(failed)) {
|
||||
try {
|
||||
await renameWithReconciliation(client, failed, webroot);
|
||||
names = await homeNames(client);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (!restored()) {
|
||||
errors.push(
|
||||
new DeploymentError(
|
||||
`Automatic rollback could not prove the required entries: ${webroot}, ${recovery}, and no ${failed}.`
|
||||
)
|
||||
);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function restoreRoot(config, recovery, expectedStateToken, confirmation, options = {}) {
|
||||
const safeRecovery = safeAccountPath(recovery, "recovery path");
|
||||
if (!recoveryPattern(config.webroot).test(safeRecovery)) {
|
||||
throw new DeploymentError(
|
||||
"Recovery path must name a retained public_html recovery, backup, or before-atomic entry."
|
||||
);
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedStateToken)) {
|
||||
throw new DeploymentError("Restore requires the exact state token emitted by the audit.");
|
||||
}
|
||||
const expectedConfirmation = `RESTORE ${safeRecovery} TO ${config.webroot} STATE ${expectedStateToken}`;
|
||||
if (confirmation !== expectedConfirmation) {
|
||||
throw new DeploymentError(`Confirmation must exactly match: ${expectedConfirmation}`);
|
||||
}
|
||||
const runId = String(
|
||||
options.runId || [process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT].filter(Boolean).join("-") || Date.now()
|
||||
);
|
||||
if (!SAFE_COMPONENT.test(runId)) throw new DeploymentError("Restore run ID is unsafe.");
|
||||
const failed = `${config.webroot}.failed-${runId}`;
|
||||
const allowedMutable = [config.webroot, safeRecovery, failed];
|
||||
const client = options.client || new CpanelAccountClient(config, { ...options, allowedMutable });
|
||||
if (client.allowedMutable instanceof Set) {
|
||||
for (const item of allowedMutable) client.allowedMutable.add(item);
|
||||
}
|
||||
const before = await auditRoot(config, { ...options, client });
|
||||
if (before.stateToken !== expectedStateToken) {
|
||||
throw new DeploymentError("The cPanel webroot changed after the audit; run a new audit before restoring.");
|
||||
}
|
||||
if (!before.recoveryCandidates.some((entry) => entry.name === safeRecovery)) {
|
||||
throw new DeploymentError("The requested recovery entry does not exist in the current cPanel state.");
|
||||
}
|
||||
if (!before.webroot)
|
||||
throw new DeploymentError("The current public_html entry is missing; refusing an ambiguous restore.");
|
||||
if (!new Set(["dir", "link"]).has(before.webroot.type)) {
|
||||
throw new DeploymentError("The current public_html entry type is unknown; refusing an ambiguous restore.");
|
||||
}
|
||||
if (!before.webrootAccess.accessible && before.webroot.type !== "link") {
|
||||
throw new DeploymentError(
|
||||
"The current public_html directory could not be inspected; only a top-level symbolic link may use unreadable-root recovery."
|
||||
);
|
||||
}
|
||||
if (before.nestedDomainRoots.length > 0) {
|
||||
throw new DeploymentError(
|
||||
`Refusing to replace public_html while nested domain document roots exist: ${before.nestedDomainRoots
|
||||
.map(({ domain, documentRoot }) => `${domain}=${documentRoot}`)
|
||||
.join(", ")}.`
|
||||
);
|
||||
}
|
||||
if (find(await client.list("."), failed)) {
|
||||
throw new DeploymentError(`The displaced-state path ${failed} already exists; refusing to overwrite it.`);
|
||||
}
|
||||
|
||||
try {
|
||||
await renameWithReconciliation(client, config.webroot, failed);
|
||||
await renameWithReconciliation(client, safeRecovery, config.webroot);
|
||||
await (options.verify || verifyFrontend)(config, options);
|
||||
} catch (error) {
|
||||
const rollbackErrors = await reinstatePreRestoreState(client, config.webroot, safeRecovery, failed);
|
||||
if (rollbackErrors.length > 0) {
|
||||
throw new DeploymentError(
|
||||
"The retained webroot failed and automatic rollback was incomplete; both retained cPanel entries were preserved for manual recovery.",
|
||||
{ cause: new AggregateError([error, ...rollbackErrors]) }
|
||||
);
|
||||
}
|
||||
throw new DeploymentError("The retained webroot failed live verification; the pre-restore state was reinstated.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return { restored: safeRecovery, displaced: failed };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { DeploymentError } from "./cpanel-deploy-lib.mjs";
|
||||
import { auditRoot, readRootConfig, restoreRoot } from "./cpanel-root-lib.mjs";
|
||||
|
||||
async function writeReport(report) {
|
||||
const reportPath = process.env.CPANEL_ROOT_REPORT_PATH;
|
||||
if (!reportPath) return;
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2] || "audit";
|
||||
if (!new Set(["audit", "restore"]).has(mode) || process.argv.length > 3) {
|
||||
throw new DeploymentError("Usage: cpanel-root.mjs [audit|restore]");
|
||||
}
|
||||
const config = readRootConfig(process.env);
|
||||
if (mode === "audit") {
|
||||
const report = await auditRoot(config);
|
||||
await writeReport(report);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await fs.appendFile(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`healthy=${report.healthy}\nstate_token=${report.stateToken}\nrecovery_count=${report.recoveryCandidates.length}\n`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await restoreRoot(
|
||||
config,
|
||||
process.env.CPANEL_ROOT_RECOVERY || "",
|
||||
process.env.CPANEL_ROOT_STATE_TOKEN || "",
|
||||
process.env.CPANEL_ROOT_CONFIRMATION || ""
|
||||
);
|
||||
await writeReport(result);
|
||||
console.log(`Restored ${result.restored}; retained displaced state as ${result.displaced}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : "Unknown cPanel root failure.");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { DeploymentError, deployRelease, readDeploymentConfig, rollbackRelease } from "./cpanel-deploy-lib.mjs";
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2] || "deploy";
|
||||
if (!new Set(["deploy", "--rollback"]).has(mode) || process.argv.length > 3) {
|
||||
throw new DeploymentError("Usage: deploy-cpanel.mjs [--rollback]");
|
||||
}
|
||||
|
||||
const config = readDeploymentConfig(process.env);
|
||||
if (mode === "--rollback") {
|
||||
const target = process.env.RELEASE_ROLLBACK_TARGET;
|
||||
if (!target) {
|
||||
throw new DeploymentError("RELEASE_ROLLBACK_TARGET is required for --rollback.");
|
||||
}
|
||||
const result = await rollbackRelease(config, target);
|
||||
console.log(`Frontend rollback completed: active release ${result.activeTarget.split("/")[1]}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deployRelease(config);
|
||||
console.log(
|
||||
`Frontend deployment completed: active release ${config.releaseId}; retained rollback release ${
|
||||
result.previousTarget.split("/")[1]
|
||||
}.`
|
||||
);
|
||||
if (result.removed.length > 0) {
|
||||
console.log(`Pruned ${result.removed.length} inactive release(s).`);
|
||||
}
|
||||
if (result.retentionWarning) {
|
||||
console.warn(result.retentionWarning);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown deployment failure.";
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import crypto from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
||||
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
|
||||
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
|
||||
|
||||
function comparePaths(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function sha256(contents) {
|
||||
return crypto.createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(relativePath, label = "release path") {
|
||||
if (typeof relativePath !== "string" || relativePath.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
if (relativePath.includes("\\") || /[\0\r\n]/.test(relativePath)) {
|
||||
throw new Error(`${label} contains unsafe characters: ${JSON.stringify(relativePath)}`);
|
||||
}
|
||||
if (path.posix.isAbsolute(relativePath)) {
|
||||
throw new Error(`${label} must be relative: ${relativePath}`);
|
||||
}
|
||||
|
||||
const segments = relativePath.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error(`${label} contains an unsafe path segment: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeFileName(relativePath) {
|
||||
assertSafeRelativePath(relativePath, "dist file path");
|
||||
if (relativePath !== ".htaccess" && SERVER_EXECUTABLE_EXTENSION_PATTERN.test(path.posix.basename(relativePath))) {
|
||||
throw new Error(`dist contains a server-executable file: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function manifestFilePath(distDirectory, assetUrl, label) {
|
||||
if (typeof assetUrl !== "string" || assetUrl.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl) || assetUrl.includes("?") || assetUrl.includes("#")) {
|
||||
throw new Error(`${label} must reference a local release file: ${assetUrl}`);
|
||||
}
|
||||
|
||||
let decodedPath;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(assetUrl.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
throw new Error(`${label} is not valid URL-encoded text: ${assetUrl}`);
|
||||
}
|
||||
assertSafeFileName(decodedPath);
|
||||
|
||||
const absolutePath = path.resolve(distDirectory, ...decodedPath.split("/"));
|
||||
const root = path.resolve(distDirectory);
|
||||
if (!absolutePath.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`${label} escapes dist: ${assetUrl}`);
|
||||
}
|
||||
return { absolutePath, relativePath: decodedPath };
|
||||
}
|
||||
|
||||
async function readJson(filePath, label) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await fsp.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(`${label} could not be read: ${error instanceof Error ? error.message : error}`, { cause: error });
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(contents);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} does not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertStringArray(value, label) {
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
|
||||
throw new Error(`${label} must be an array of non-empty strings.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function collectDistInventory(distDirectory) {
|
||||
const root = path.resolve(distDirectory);
|
||||
const rootStat = await fsp.lstat(root).catch(() => null);
|
||||
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
||||
throw new Error(`dist directory does not exist or is not a real directory: ${root}`);
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const walk = async (relativeDirectory = "") => {
|
||||
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
|
||||
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
|
||||
|
||||
for (const name of names) {
|
||||
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
||||
assertSafeRelativePath(relativePath, "dist path");
|
||||
const absolutePath = path.join(root, ...relativePath.split("/"));
|
||||
const stat = await fsp.lstat(absolutePath);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`dist contains a symbolic link: ${relativePath}`);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
await walk(relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`dist contains an unsupported filesystem entry: ${relativePath}`);
|
||||
}
|
||||
|
||||
assertSafeFileName(relativePath);
|
||||
const contents = await fsp.readFile(absolutePath);
|
||||
files.push({
|
||||
path: `dist/${relativePath}`,
|
||||
bytes: contents.length,
|
||||
sha256: sha256(contents),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await walk();
|
||||
if (files.length === 0) {
|
||||
throw new Error("dist does not contain any files.");
|
||||
}
|
||||
|
||||
files.sort((left, right) => comparePaths(left.path, right.path));
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateReleaseMetadata(distDirectory, { expectedCommitSha, expectedBuildId }) {
|
||||
if (typeof expectedCommitSha !== "string" || !COMMIT_SHA_PATTERN.test(expectedCommitSha)) {
|
||||
throw new Error("RELEASE_COMMIT_SHA must be the full 40-character hexadecimal commit SHA.");
|
||||
}
|
||||
if (typeof expectedBuildId !== "string" || expectedBuildId.length === 0) {
|
||||
throw new Error("RELEASE_BUILD_ID is required.");
|
||||
}
|
||||
|
||||
const root = path.resolve(distDirectory);
|
||||
const manifest = await readJson(path.join(root, "release-manifest.json"), "release-manifest.json");
|
||||
const releaseEntry = await readJson(path.join(root, "release-entry.json"), "release-entry.json");
|
||||
|
||||
if (manifest.schema_version !== 1) {
|
||||
throw new Error(`release-manifest.json schema_version must be 1, got ${JSON.stringify(manifest.schema_version)}.`);
|
||||
}
|
||||
if (String(manifest.commit_sha || "").toLowerCase() !== expectedCommitSha.toLowerCase()) {
|
||||
throw new Error(
|
||||
`release-manifest.json commit_sha ${
|
||||
manifest.commit_sha || "(missing)"
|
||||
} does not exactly match ${expectedCommitSha}.`
|
||||
);
|
||||
}
|
||||
if (manifest.build_id !== expectedBuildId) {
|
||||
throw new Error(
|
||||
`release-manifest.json build_id ${manifest.build_id || "(missing)"} does not exactly match ${expectedBuildId}.`
|
||||
);
|
||||
}
|
||||
if (typeof releaseEntry.entry !== "string" || releaseEntry.entry.length === 0) {
|
||||
throw new Error("release-entry.json entry must be a non-empty string.");
|
||||
}
|
||||
|
||||
const entryCss = assertStringArray(releaseEntry.css || [], "release-entry.json css");
|
||||
const manifestCss = assertStringArray(manifest.css || [], "release-manifest.json css");
|
||||
if (releaseEntry.entry !== manifest.entry || JSON.stringify(entryCss) !== JSON.stringify(manifestCss)) {
|
||||
throw new Error("release-entry.json entry/css does not match release-manifest.json.");
|
||||
}
|
||||
|
||||
const assetUrls = assertStringArray(manifest.asset_urls, "release-manifest.json asset_urls");
|
||||
if (!manifest.asset_hashes || typeof manifest.asset_hashes !== "object" || Array.isArray(manifest.asset_hashes)) {
|
||||
throw new Error("release-manifest.json asset_hashes must be an object.");
|
||||
}
|
||||
|
||||
const requiredAssets = ["index.html", "release-entry.json", releaseEntry.entry, ...entryCss];
|
||||
for (const requiredAsset of requiredAssets) {
|
||||
if (!assetUrls.includes(requiredAsset)) {
|
||||
throw new Error(`release-manifest.json asset_urls is missing required asset ${requiredAsset}.`);
|
||||
}
|
||||
}
|
||||
for (const assetUrl of assetUrls) {
|
||||
if (!Object.hasOwn(manifest.asset_hashes, assetUrl)) {
|
||||
throw new Error(`release-manifest.json asset_hashes is missing ${assetUrl}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const hashEntries = Object.entries(manifest.asset_hashes).sort(([left], [right]) => comparePaths(left, right));
|
||||
if (hashEntries.length === 0) {
|
||||
throw new Error("release-manifest.json asset_hashes must not be empty.");
|
||||
}
|
||||
|
||||
for (const [assetUrl, expected] of hashEntries) {
|
||||
const { absolutePath } = manifestFilePath(root, assetUrl, `release asset ${assetUrl}`);
|
||||
const stat = await fsp.lstat(absolutePath).catch(() => null);
|
||||
if (!stat?.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(`release asset is missing or is not a regular file: ${assetUrl}`);
|
||||
}
|
||||
if (
|
||||
!expected ||
|
||||
typeof expected !== "object" ||
|
||||
typeof expected.sha256 !== "string" ||
|
||||
!SHA256_PATTERN.test(expected.sha256) ||
|
||||
!Number.isSafeInteger(expected.bytes) ||
|
||||
expected.bytes < 0
|
||||
) {
|
||||
throw new Error(`release-manifest.json contains invalid hash metadata for ${assetUrl}.`);
|
||||
}
|
||||
|
||||
const contents = await fsp.readFile(absolutePath);
|
||||
const actualHash = sha256(contents);
|
||||
if (contents.length !== expected.bytes || actualHash !== expected.sha256.toLowerCase()) {
|
||||
throw new Error(
|
||||
`release asset integrity mismatch for ${assetUrl}: expected ${expected.bytes} bytes/${expected.sha256}, got ${contents.length} bytes/${actualHash}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { manifest, releaseEntry };
|
||||
}
|
||||
|
||||
function assertMatchingInventories(expected, actual, label) {
|
||||
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
||||
throw new Error(`${label} does not match the validated dist inventory.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateArchiveEntries(archivePath) {
|
||||
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
|
||||
const entries = stdout.split(/\r?\n/).filter(Boolean);
|
||||
if (entries.length === 0) {
|
||||
throw new Error("release archive is empty.");
|
||||
}
|
||||
|
||||
for (const archiveEntry of entries) {
|
||||
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
|
||||
if (normalizedEntry === "dist") {
|
||||
continue;
|
||||
}
|
||||
if (!normalizedEntry.startsWith("dist/")) {
|
||||
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
|
||||
}
|
||||
assertSafeRelativePath(normalizedEntry, "archive entry");
|
||||
}
|
||||
}
|
||||
|
||||
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
|
||||
if (!githubOutput) {
|
||||
return;
|
||||
}
|
||||
const lines = Object.entries(values).map(([key, value]) => {
|
||||
const normalizedValue = String(value);
|
||||
if (/\r|\n/.test(normalizedValue)) {
|
||||
throw new Error(`GitHub output ${key} contains a newline.`);
|
||||
}
|
||||
return `${key}=${normalizedValue}`;
|
||||
});
|
||||
fs.appendFileSync(githubOutput, `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
export async function createReleaseArchive({
|
||||
distDirectory = "dist",
|
||||
outputDirectory = "release-artifacts",
|
||||
expectedCommitSha,
|
||||
expectedBuildId,
|
||||
runId,
|
||||
runAttempt,
|
||||
githubOutput,
|
||||
} = {}) {
|
||||
if (!/^\d+$/.test(String(runId || "")) || !/^\d+$/.test(String(runAttempt || ""))) {
|
||||
throw new Error("GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be numeric.");
|
||||
}
|
||||
|
||||
const normalizedCommitSha = String(expectedCommitSha || "").toLowerCase();
|
||||
const expectedRunBuildId = `${runId}-${runAttempt}`;
|
||||
if (expectedBuildId !== expectedRunBuildId) {
|
||||
throw new Error(`RELEASE_BUILD_ID must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT (${expectedRunBuildId}).`);
|
||||
}
|
||||
|
||||
const resolvedDistDirectory = path.resolve(distDirectory);
|
||||
const resolvedOutputDirectory = path.resolve(outputDirectory);
|
||||
if (
|
||||
resolvedOutputDirectory === resolvedDistDirectory ||
|
||||
resolvedOutputDirectory.startsWith(`${resolvedDistDirectory}${path.sep}`)
|
||||
) {
|
||||
throw new Error("RELEASE_OUTPUT_DIR must not be inside dist.");
|
||||
}
|
||||
|
||||
const inventory = await collectDistInventory(resolvedDistDirectory);
|
||||
for (const requiredPath of [
|
||||
"dist/.htaccess",
|
||||
"dist/index.html",
|
||||
"dist/release-entry.json",
|
||||
"dist/release-manifest.json",
|
||||
]) {
|
||||
if (!inventory.files.some((file) => file.path === requiredPath)) {
|
||||
throw new Error(`dist is missing required release file: ${requiredPath.slice("dist/".length)}`);
|
||||
}
|
||||
}
|
||||
await validateReleaseMetadata(resolvedDistDirectory, { expectedCommitSha: normalizedCommitSha, expectedBuildId });
|
||||
|
||||
const releaseId = `${normalizedCommitSha}-${runId}-${runAttempt}`;
|
||||
const archiveName = `pleno-vue-${releaseId}.zip`;
|
||||
const archivePath = path.join(resolvedOutputDirectory, archiveName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
const inventoryPath = path.join(resolvedOutputDirectory, `pleno-vue-${releaseId}.inventory.json`);
|
||||
|
||||
await fsp.mkdir(resolvedOutputDirectory, { recursive: true });
|
||||
for (const outputPath of [archivePath, checksumPath, inventoryPath]) {
|
||||
if (await fsp.lstat(outputPath).catch(() => null)) {
|
||||
throw new Error(`refusing to overwrite existing release artifact: ${outputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryDirectory = await fsp.mkdtemp(path.join(path.dirname(resolvedOutputDirectory), ".pleno-release-"));
|
||||
try {
|
||||
const sourceRoot = path.join(temporaryDirectory, "source");
|
||||
const copiedDistDirectory = path.join(sourceRoot, "dist");
|
||||
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
|
||||
const extractionRoot = path.join(temporaryDirectory, "extracted");
|
||||
await fsp.mkdir(sourceRoot, { recursive: true });
|
||||
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
|
||||
|
||||
const copiedInventory = await collectDistInventory(copiedDistDirectory);
|
||||
assertMatchingInventories(inventory, copiedInventory, "archive source");
|
||||
|
||||
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
|
||||
cwd: sourceRoot,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await validateArchiveEntries(temporaryArchivePath);
|
||||
|
||||
await fsp.mkdir(extractionRoot);
|
||||
await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist"));
|
||||
assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive");
|
||||
|
||||
const archiveContents = await fsp.readFile(temporaryArchivePath);
|
||||
const archiveSha256 = sha256(archiveContents);
|
||||
await fsp.copyFile(temporaryArchivePath, archivePath, fs.constants.COPYFILE_EXCL);
|
||||
await fsp.writeFile(checksumPath, `${archiveSha256} ${archiveName}\n`, { flag: "wx" });
|
||||
await fsp.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" });
|
||||
|
||||
const outputs = {
|
||||
build_id: expectedBuildId,
|
||||
archive_name: archiveName,
|
||||
archive_path: archivePath,
|
||||
checksum_path: checksumPath,
|
||||
inventory_path: inventoryPath,
|
||||
release_id: releaseId,
|
||||
archive_sha256: archiveSha256,
|
||||
};
|
||||
appendGithubOutputs(outputs, githubOutput);
|
||||
return { ...outputs, inventory };
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
[archivePath, checksumPath, inventoryPath].map((outputPath) => fsp.unlink(outputPath).catch(() => {}))
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await createReleaseArchive({
|
||||
distDirectory: process.env.RELEASE_DIST_DIR || "dist",
|
||||
outputDirectory: process.env.RELEASE_OUTPUT_DIR || "release-artifacts",
|
||||
expectedCommitSha: process.env.RELEASE_COMMIT_SHA,
|
||||
expectedBuildId: process.env.RELEASE_BUILD_ID,
|
||||
runId: process.env.GITHUB_RUN_ID,
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
|
||||
});
|
||||
console.log(
|
||||
`Validated and packaged ${result.inventory.files.length} files as ${result.archive_name} (${result.archive_sha256}).`
|
||||
);
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
if (isCli) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -40,12 +41,15 @@ function pathUrl(baseUrl, assetPath) {
|
||||
return new URL(path, baseUrl).href;
|
||||
}
|
||||
|
||||
async function fetchBytes(url) {
|
||||
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 {
|
||||
@@ -55,9 +59,9 @@ async function fetchBytes(url) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(baseUrl, assetPath) {
|
||||
async function fetchJson(baseUrl, assetPath, deadline) {
|
||||
const url = pathUrl(baseUrl, assetPath);
|
||||
const result = await fetchBytes(url);
|
||||
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}`);
|
||||
@@ -98,9 +102,39 @@ function shouldRejectHtml(assetPath) {
|
||||
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
||||
}
|
||||
|
||||
async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function verifyCachePolicy(assetPath, response) {
|
||||
if (!booleanEnv("RELEASE_REQUIRE_CACHE_HEADERS")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheControl = response.headers.get("cache-control") || "";
|
||||
if (isMutableReleaseFile(assetPath) && !/(?: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 (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);
|
||||
const result = await fetchBytes(url, deadline);
|
||||
const contentType = result.response.headers.get("content-type") || "";
|
||||
|
||||
if (!result.response.ok) {
|
||||
@@ -112,6 +146,7 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||
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) {
|
||||
@@ -120,8 +155,8 @@ async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyShell(baseUrl, shellPath) {
|
||||
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
|
||||
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();
|
||||
|
||||
@@ -134,23 +169,41 @@ async function verifyShell(baseUrl, shellPath) {
|
||||
if (body.replace(/\s+/g, "").length < 40) {
|
||||
throw new Error(`${shellPath} returned an empty app shell`);
|
||||
}
|
||||
if (!body.includes('<div id="app"></div>')) {
|
||||
if (!containsVueAppRoot(body)) {
|
||||
throw new Error(`${shellPath} did not include the Vue app root`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyRelease(baseUrl) {
|
||||
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");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
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}`);
|
||||
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}`);
|
||||
@@ -165,12 +218,15 @@ async function verifyRelease(baseUrl) {
|
||||
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()));
|
||||
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);
|
||||
await verifyShell(baseUrl, shellPath, deadline);
|
||||
}
|
||||
|
||||
const assetUrls = unique([
|
||||
"index.html",
|
||||
"release-manifest.json",
|
||||
"release-entry.json",
|
||||
manifest.entry,
|
||||
@@ -180,12 +236,15 @@ async function verifyRelease(baseUrl) {
|
||||
...(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(/^\/+/, "")}`]);
|
||||
}
|
||||
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,
|
||||
@@ -212,7 +271,7 @@ async function main() {
|
||||
while (Date.now() <= deadline) {
|
||||
attempt += 1;
|
||||
try {
|
||||
const result = await verifyRelease(baseUrl);
|
||||
const result = await verifyRelease(baseUrl, deadline);
|
||||
appendGithubEnv({
|
||||
RELEASE_VERIFIED_BUILD_ID: result.build_id,
|
||||
RELEASE_VERIFIED_COMMIT: result.commit_sha,
|
||||
@@ -228,14 +287,16 @@ async function main() {
|
||||
if (Date.now() > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(pollIntervalSeconds * 1000);
|
||||
await sleep(Math.min(pollIntervalSeconds * 1000, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user