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:
Jeppe B
2026-07-20 13:53:49 +02:00
committed by GitHub
parent 6b8f3ff806
commit a930bd35a5
17 changed files with 3665 additions and 106 deletions
File diff suppressed because it is too large Load Diff
+473
View File
@@ -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 };
}
+47
View File
@@ -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);
});
+40
View File
@@ -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);
});
+400
View File
@@ -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;
});
}
+87 -26
View File
@@ -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);
});
}