Files
pleno-vue/scripts/release/cpanel-deploy-lib.mjs
Jeppe BandJeppe Bundgaard fc67e7cf0b Fix idempotent FTPS directory setup (#217)
The production FTPS deploy now authenticates, but lftp exits when cPanel
returns 550 File exists for pre-created deployment directories. Use lftp
mkdir -p -f for the archive and activation directories so retries remain
idempotent while subsequent upload operations still surface real access
failures.\n\nVerification:\n- vitest tests/unit/cpanel-deploy.spec.js
(26 passed)\n- eslint scripts/release/cpanel-deploy-lib.mjs
tests/unit/cpanel-deploy.spec.js\n- git diff --check

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-23 00:00:48 +02:00

1077 lines
41 KiB
JavaScript

import crypto from "node:crypto";
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { collectDistInventory } from "./package-dist.mjs";
const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const SAFE_HOST = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(?::[0-9]{1,5})?$/;
const SHA256 = /^[a-f0-9]{64}$/i;
const succeeded = (value) => value === 1 || value === "1" || value === true;
const REQUIRED_ENV = [
"PRODUCTION_FTP_HOST",
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
"RELEASE_ARCHIVE_PATH",
"RELEASE_INVENTORY_PATH",
"RELEASE_EXPECTED_COMMIT",
"RELEASE_EXPECTED_BUILD_ID",
];
const ROLLBACK_REQUIRED_ENV = [
"PRODUCTION_FTP_HOST",
"PRODUCTION_FTP_USER",
"PRODUCTION_FTP_PASSWORD",
"PRODUCTION_FTP_PATH",
"PRODUCTION_ACTIVATION_KEY",
];
export class DeploymentError extends Error {
constructor(message, options) {
super(message, options);
this.name = "DeploymentError";
}
}
function hasControlCharacters(value) {
return Array.from(String(value)).some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function requireString(env, name) {
const value = env[name];
if (typeof value !== "string" || value.length === 0) {
throw new DeploymentError(`Missing required environment variable ${name}.`);
}
if (hasControlCharacters(value)) {
throw new DeploymentError(`${name} contains unsupported control characters.`);
}
return value;
}
export function validateReleaseId(value, label = "release ID") {
if (typeof value !== "string" || value.length > 180 || !SAFE_COMPONENT.test(value)) {
throw new DeploymentError(`${label} must be a safe filename component.`);
}
return value;
}
export function normalizeRemoteRoot(value, name = "remote path") {
if (typeof value !== "string" || !value || value === "/") {
throw new DeploymentError(`${name} must identify a dedicated deployment directory.`);
}
if (/[\\,]/.test(value) || hasControlCharacters(value)) {
throw new DeploymentError(`${name} contains unsupported characters.`);
}
const components = value.split("/").filter(Boolean);
if (components.length === 0 || components.some((component) => !SAFE_COMPONENT.test(component))) {
throw new DeploymentError(`${name} must contain only safe path components.`);
}
return components.join("/");
}
function normalizeFtpRoot(value) {
if (value === "/") {
return "/";
}
const normalized = normalizeRemoteRoot(value, "PRODUCTION_FTP_PATH");
return value.startsWith("/") ? `/${normalized}` : normalized;
}
export function deriveCpanelRoot(cpanelPath, cpanelUser) {
const normalized = normalizeRemoteRoot(cpanelPath, "PRODUCTION_CPANEL_PATH");
const homePrefix = `home/${cpanelUser}/`;
if (normalized.startsWith(homePrefix)) {
const relative = normalized.slice(homePrefix.length);
return normalizeRemoteRoot(relative);
}
if (normalized === `home/${cpanelUser}`) {
throw new DeploymentError("PRODUCTION_CPANEL_PATH may not be the cPanel account home directory.");
}
if (normalized.startsWith("home/")) {
throw new DeploymentError("PRODUCTION_CPANEL_PATH is not inside the configured cPanel account home.");
}
return normalized;
}
export function containedRemotePath(root, ...components) {
const normalizedRoot = normalizeRemoteRoot(root);
const normalizedComponents = components.flatMap((component) => String(component).split("/"));
for (const component of normalizedComponents) {
validateReleaseId(component, "remote path component");
}
const result = [normalizedRoot, ...normalizedComponents].join("/");
if (result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
throw new DeploymentError("Remote path escaped the deployment root.");
}
return result;
}
function validateHttpsUrl(value, name) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new DeploymentError(`${name} must be a valid HTTPS URL.`);
}
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new DeploymentError(`${name} must be a credential-free HTTPS URL.`);
}
return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`;
}
function parseRetentionCount(value) {
if (value === undefined || value === "") {
return 5;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isSafeInteger(parsed) || parsed < 2 || parsed > 25 || String(parsed) !== value) {
throw new DeploymentError("RELEASE_RETAIN_COUNT must be an integer between 2 and 25.");
}
return parsed;
}
export function readDeploymentConfig(env = process.env, options = {}) {
const rollbackOnly = options.rollbackOnly === true;
for (const name of rollbackOnly ? ROLLBACK_REQUIRED_ENV : REQUIRED_ENV) {
requireString(env, name);
}
const cpanelNames = [
"PRODUCTION_CPANEL_USER",
"PRODUCTION_CPANEL_API_TOKEN",
"PRODUCTION_CPANEL_API_URL",
"PRODUCTION_CPANEL_PATH",
];
const configuredCpanelNames = cpanelNames.filter((name) => env[name] !== undefined && env[name] !== "");
let cpanel = null;
if (configuredCpanelNames.length > 0) {
if (configuredCpanelNames.length !== cpanelNames.length) {
throw new DeploymentError("Set all PRODUCTION_CPANEL_* values together or omit them from the FTPS release path.");
}
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
if (!SAFE_COMPONENT.test(cpanelUser)) {
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
}
cpanel = {
user: cpanelUser,
token: requireString(env, "PRODUCTION_CPANEL_API_TOKEN"),
apiUrl: validateHttpsUrl(requireString(env, "PRODUCTION_CPANEL_API_URL"), "PRODUCTION_CPANEL_API_URL"),
root: deriveCpanelRoot(requireString(env, "PRODUCTION_CPANEL_PATH"), cpanelUser),
};
}
const host = requireString(env, "PRODUCTION_FTP_HOST");
if (!SAFE_HOST.test(host) || host.includes("..")) {
throw new DeploymentError("PRODUCTION_FTP_HOST must be a hostname with an optional port.");
}
const ftp = {
host,
user: requireString(env, "PRODUCTION_FTP_USER"),
password: requireString(env, "PRODUCTION_FTP_PASSWORD"),
root: normalizeFtpRoot(requireString(env, "PRODUCTION_FTP_PATH")),
};
const activationKey = requireString(env, "PRODUCTION_ACTIVATION_KEY").toLowerCase();
if (!SHA256.test(activationKey)) {
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
}
if (rollbackOnly) {
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
}
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
if (!checksumPath) {
throw new DeploymentError("Missing required environment variable RELEASE_ARCHIVE_SHA256_PATH.");
}
if (hasControlCharacters(checksumPath)) {
throw new DeploymentError("RELEASE_ARCHIVE_SHA256_PATH contains unsupported control characters.");
}
const expectedCommit = requireString(env, "RELEASE_EXPECTED_COMMIT");
if (!/^[a-f0-9]{40}$/i.test(expectedCommit)) {
throw new DeploymentError("RELEASE_EXPECTED_COMMIT must be a full Git commit hash.");
}
const expectedBuildId = requireString(env, "RELEASE_EXPECTED_BUILD_ID");
validateReleaseId(expectedBuildId, "RELEASE_EXPECTED_BUILD_ID");
const releaseId = validateReleaseId(
env.RELEASE_ID || `${expectedCommit.toLowerCase()}-${expectedBuildId}`,
"RELEASE_ID"
);
const frontendUrl = env.PRODUCTION_FRONTEND_URL || env.RELEASE_BASE_URL || env.PLAYWRIGHT_BASE_URL;
if (!frontendUrl) {
throw new DeploymentError("Set PRODUCTION_FRONTEND_URL, RELEASE_BASE_URL, or PLAYWRIGHT_BASE_URL.");
}
const githubRepository = env.RELEASE_GITHUB_REPOSITORY || "";
const githubToken = env.RELEASE_GITHUB_TOKEN || "";
let github = null;
if (githubRepository || githubToken) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(githubRepository)) {
throw new DeploymentError("RELEASE_GITHUB_REPOSITORY must use the owner/repository format.");
}
if (!githubToken || hasControlCharacters(githubToken)) {
throw new DeploymentError("RELEASE_GITHUB_TOKEN is missing or contains unsupported characters.");
}
github = {
repository: githubRepository,
token: githubToken,
apiUrl: validateHttpsUrl(env.GITHUB_API_URL || "https://api.github.com/", "GITHUB_API_URL"),
};
}
return {
ftp,
...(cpanel ? { cpanel } : {}),
activationKey,
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
checksumPath: path.resolve(checksumPath),
inventoryPath: path.resolve(requireString(env, "RELEASE_INVENTORY_PATH")),
expectedCommit: expectedCommit.toLowerCase(),
expectedBuildId,
releaseId,
frontendUrl: validateHttpsUrl(frontendUrl, "PRODUCTION_FRONTEND_URL"),
retainCount: parseRetentionCount(env.RELEASE_RETAIN_COUNT),
github,
};
}
function lftpQuote(value) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
function ensureLftpDirectory(value) {
return `mkdir -p -f ${lftpQuote(value)}`;
}
export function buildLftpScript(config, commands) {
const { host, user, password, root } = config.ftp;
const lines = [
"set cmd:fail-exit yes",
"set cmd:interactive no",
"set ftp:ssl-allow yes",
"set ftp:ssl-force yes",
"set ftp:ssl-protect-data yes",
"set ftp:list-options -a",
"set ssl:verify-certificate yes",
"set ssl:check-hostname yes",
"set net:max-retries 3",
"set net:timeout 20",
`open -u ${lftpQuote(user)},${lftpQuote(password)} ${lftpQuote(`ftp://${host}`)}`,
`cd ${lftpQuote(root)}`,
...commands,
"bye",
];
return `${lines.join("\n")}\n`;
}
export async function defaultProcessRunner(command, args, options = {}) {
return await new Promise((resolve, reject) => {
const inherit = options.inherit === true;
const child = spawn(command, args, {
env: options.env || process.env,
stdio: inherit ? "inherit" : ["pipe", "pipe", "pipe"],
});
const output = [];
const errors = [];
if (!inherit) {
child.stdout.on("data", (chunk) => output.push(chunk));
child.stderr.on("data", (chunk) => errors.push(chunk));
child.stdin.end(options.input || "");
}
child.on("error", (error) => {
reject(new DeploymentError(`${options.label || command} could not start.`, { cause: error }));
});
child.on("close", (code) => {
if (code !== 0) {
const error = new DeploymentError(`${options.label || command} failed with exit code ${code}.`);
error.stdout = Buffer.concat(output).toString("utf8");
error.stderr = Buffer.concat(errors).toString("utf8");
reject(error);
return;
}
resolve({
stdout: Buffer.concat(output).toString("utf8"),
stderr: Buffer.concat(errors).toString("utf8"),
});
});
});
}
async function fileSha256(filePath, fsApi = fs) {
return await new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256");
const stream = fsApi.createReadStream(filePath);
stream.on("error", reject);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function safeArchiveName(filePath) {
const name = path.basename(filePath);
if (!SAFE_COMPONENT.test(name) || !name.endsWith(".zip")) {
throw new DeploymentError("RELEASE_ARCHIVE_PATH must name a safe .zip file.");
}
return name;
}
async function readExpectedChecksum(config, fsApi = fsPromises) {
let content;
try {
content = await fsApi.readFile(config.checksumPath, "utf8");
} catch (error) {
throw new DeploymentError("Could not read RELEASE_ARCHIVE_SHA256_PATH.", { cause: error });
}
const match = content.trim().match(/^([a-f0-9]{64})(?:\s+\*?([^\s]+))?$/i);
if (!match) {
throw new DeploymentError("Release checksum sidecar is not valid SHA-256 output.");
}
if (match[2] && path.basename(match[2]) !== safeArchiveName(config.archivePath)) {
throw new DeploymentError("Release checksum sidecar names a different archive.");
}
return match[1].toLowerCase();
}
function validateInventoryPath(relativePath) {
if (
typeof relativePath !== "string" ||
!relativePath.startsWith("dist/") ||
relativePath.includes("\\") ||
hasControlCharacters(relativePath) ||
relativePath.split("/").some((part) => !part || part === "." || part === "..")
) {
throw new DeploymentError("Release inventory contains an unsafe path.");
}
}
async function readExpectedInventory(config, fsApi = fsPromises) {
let inventory;
try {
inventory = JSON.parse(await fsApi.readFile(config.inventoryPath, "utf8"));
} catch (error) {
throw new DeploymentError("Could not read a valid RELEASE_INVENTORY_PATH.", { cause: error });
}
if (inventory?.schema_version !== 1 || !Array.isArray(inventory.files) || inventory.files.length === 0) {
throw new DeploymentError("Release inventory does not use the supported schema.");
}
let previousPath = "";
for (const file of inventory.files) {
validateInventoryPath(file?.path);
if (
file.path <= previousPath ||
!Number.isSafeInteger(file.bytes) ||
file.bytes < 0 ||
typeof file.sha256 !== "string" ||
!SHA256.test(file.sha256)
) {
throw new DeploymentError("Release inventory contains invalid or unsorted file metadata.");
}
previousPath = file.path;
}
return inventory;
}
function releaseIdentityFromTarget(target) {
const safeTarget = validateReleaseTarget(target);
const releaseId = safeTarget.split("/")[1];
const match = releaseId.match(/^([a-f0-9]{40})-(.+)$/i);
if (!match) {
throw new DeploymentError("Release target does not contain a full commit and build identity.");
}
validateReleaseId(match[2], "release target build ID");
return { target: safeTarget, releaseId, commit: match[1].toLowerCase(), buildId: match[2] };
}
function parseActivationResult(content, requestId, expectedTarget) {
const values = new Map();
for (const line of String(content).trim().split("\n")) {
const separator = line.indexOf("=");
if (separator <= 0) throw new DeploymentError("Server-side activation returned an invalid result.");
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (values.has(key) || !new Set(["schema_version", "request_id", "status", "target", "message"]).has(key)) {
throw new DeploymentError("Server-side activation returned an invalid result.");
}
values.set(key, value);
}
if (
values.size !== 5 ||
values.get("schema_version") !== "1" ||
values.get("request_id") !== requestId ||
values.get("target") !== expectedTarget ||
!SAFE_COMPONENT.test(values.get("message") || "")
) {
throw new DeploymentError("Server-side activation result did not match the requested release.");
}
if (values.get("status") !== "success") {
throw new DeploymentError(`Server-side release activation failed during ${values.get("message")}.`);
}
}
export function createLftpTransport(config, dependencies = {}) {
const runner = dependencies.runner || defaultProcessRunner;
const fsApi = dependencies.fs || fsPromises;
const streamFs = dependencies.streamFs || fs;
const activationTimeoutMs = dependencies.activationTimeoutMs || 180_000;
const activationPollIntervalMs = dependencies.activationPollIntervalMs || 5_000;
const activationRequestTtlSeconds = dependencies.activationRequestTtlSeconds || 150;
const now = dependencies.now || Date.now;
const sleep =
dependencies.sleep || (async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds)));
function redactDiagnostic(value) {
let result = String(value || "");
const escapedHost = config.ftp.host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result.replace(new RegExp(escapedHost, "gi"), "[redacted]");
const rawSecrets = [config.ftp.user, config.ftp.password, config.ftp.root]
.filter((secret) => secret && secret !== "/")
.flatMap((secret) => [secret, encodeURIComponent(secret)]);
const secrets = [...new Set(rawSecrets)].sort((left, right) => right.length - left.length);
for (const secret of secrets) {
result = result.replaceAll(secret, "[redacted]");
}
result = result.replace(/\b(?:ftp|ftps):\/\/[^\s@]+@/gi, "ftps://[redacted]@");
return result.replace(/\s+/g, " ").trim().slice(0, 2_000);
}
async function run(commands) {
try {
// lftp reads commands from standard input when no command source is
// specified. Avoid `-f /dev/stdin`: some hosted runners expose that
// path as a non-reopenable pipe, causing lftp's `source` command to fail.
await runner("lftp", [], {
input: buildLftpScript(config, commands),
label: "FTPS operation",
});
} catch (error) {
const diagnostic = redactDiagnostic(
[error?.stderr, error?.stdout, error instanceof Error ? error.message : error].filter(Boolean).join(" ")
);
const suffix = diagnostic ? `: ${diagnostic}` : ".";
throw new DeploymentError(`Secure FTPS operation failed${suffix}`, { cause: error });
}
}
async function requestActivation(identity, action, archive = {}) {
const requestId = validateReleaseId(
dependencies.activationRequestId || `gha-${String(process.env.GITHUB_RUN_ID || now())}-${crypto.randomUUID()}`,
"activation request ID"
);
const archiveName =
action === "stage" ? validateReleaseId(archive.archiveName, "activation archive name") : "unused";
const archiveSha256 = action === "stage" ? String(archive.sha256 || "").toLowerCase() : "unused";
if (action === "stage" && (!archiveName.endsWith(".zip") || !SHA256.test(archiveSha256))) {
throw new DeploymentError("Activation archive metadata is invalid.");
}
if (!Number.isSafeInteger(activationRequestTtlSeconds) || activationRequestTtlSeconds < 60) {
throw new DeploymentError("Activation request TTL must be at least 60 seconds.");
}
const expiresAt = Math.floor(now() / 1000) + activationRequestTtlSeconds;
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-activation-"));
const requestPath = path.join(temporaryDirectory, `${requestId}.request`);
const resultPath = path.join(temporaryDirectory, `${requestId}.result`);
const requestBody = [
"schema_version=1",
`request_id=${requestId}`,
`action=${action}`,
`release_id=${identity.releaseId}`,
`commit_sha=${identity.commit}`,
`build_id=${identity.buildId}`,
`archive_name=${archiveName}`,
`archive_sha256=${archiveSha256}`,
`expires_at=${expiresAt}`,
"",
].join("\n");
const requestHmac = crypto
.createHmac("sha256", Buffer.from(config.activationKey, "hex"))
.update(requestBody)
.digest("hex");
const request = `${requestBody}request_hmac=${requestHmac}\n`;
try {
await fsApi.writeFile(requestPath, request, { mode: 0o600 });
const remoteRequest = `activation-requests/${requestId}.request`;
const remoteResult = `activation-results/${requestId}.result`;
let queueError;
try {
await run([
ensureLftpDirectory("activation-requests"),
ensureLftpDirectory("activation-results"),
`rm -f ${lftpQuote(`${remoteRequest}.part`)}`,
`put ${lftpQuote(requestPath)} -o ${lftpQuote(`${remoteRequest}.part`)}`,
`mv ${lftpQuote(`${remoteRequest}.part`)} ${lftpQuote(remoteRequest)}`,
]);
} catch (error) {
// The server may have committed the final rename before the FTPS
// connection failed. Poll through the request lifetime so an
// ambiguous upload cannot activate after this workflow exits.
queueError = error;
}
const deadline = now() + activationTimeoutMs;
let resultDownloaded = false;
while (now() < deadline) {
try {
await run([`get ${lftpQuote(remoteResult)} -o ${lftpQuote(resultPath)}`]);
resultDownloaded = true;
break;
} catch {
await sleep(activationPollIntervalMs);
}
}
if (!resultDownloaded) {
throw new DeploymentError(
queueError
? "Could not confirm whether the account-scoped activation request was queued before it expired."
: "Timed out waiting for the account-scoped release activator.",
queueError ? { cause: queueError } : undefined
);
}
parseActivationResult(await fsApi.readFile(resultPath, "utf8"), requestId, identity.target);
return identity.target;
} finally {
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
}
}
return {
async uploadArchive() {
const archiveName = safeArchiveName(config.archivePath);
const checksumName = `${archiveName}.sha256`;
const expectedHash = await readExpectedChecksum(config, fsApi);
let localHash;
try {
localHash = await fileSha256(config.archivePath, streamFs);
} catch (error) {
throw new DeploymentError("Could not hash the release archive.", { cause: error });
}
if (localHash !== expectedHash) {
throw new DeploymentError("Release archive does not match its SHA-256 sidecar.");
}
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-ftps-"));
const downloadedArchive = path.join(temporaryDirectory, archiveName);
const downloadedChecksum = path.join(temporaryDirectory, checksumName);
try {
await run([
ensureLftpDirectory("archives"),
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
`put ${lftpQuote(config.archivePath)} -o ${lftpQuote(`archives/${archiveName}.part`)}`,
`put ${lftpQuote(config.checksumPath)} -o ${lftpQuote(`archives/${checksumName}.part`)}`,
`get ${lftpQuote(`archives/${archiveName}.part`)} -o ${lftpQuote(downloadedArchive)}`,
`get ${lftpQuote(`archives/${checksumName}.part`)} -o ${lftpQuote(downloadedChecksum)}`,
]);
const remoteHash = await fileSha256(downloadedArchive, streamFs);
const remoteChecksum = await fsApi.readFile(downloadedChecksum, "utf8");
if (remoteHash !== expectedHash || remoteChecksum !== (await fsApi.readFile(config.checksumPath, "utf8"))) {
throw new DeploymentError("Remote release archive verification failed.");
}
await run([
`mv ${lftpQuote(`archives/${checksumName}.part`)} ${lftpQuote(`archives/${checksumName}`)}`,
`mv ${lftpQuote(`archives/${archiveName}.part`)} ${lftpQuote(`archives/${archiveName}`)}`,
]);
return {
archiveName,
checksumName,
sha256: expectedHash,
};
} catch (error) {
try {
await run([
`rm -f ${lftpQuote(`archives/${archiveName}.part`)}`,
`rm -f ${lftpQuote(`archives/${checksumName}.part`)}`,
]);
} catch {
// The original error is more actionable; stale .part files are safe and overwritten on retry.
}
throw error;
} finally {
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
}
},
async verifyRelease(target) {
const safeTarget = validateReleaseTarget(target);
const expectedInventory = await readExpectedInventory(config, fsApi);
const temporaryDirectory = await fsApi.mkdtemp(path.join(os.tmpdir(), "pleno-release-verify-"));
const distDirectory = path.join(temporaryDirectory, "dist");
try {
await run([`mirror --verbose=0 --parallel=4 ${lftpQuote(safeTarget)} ${lftpQuote(distDirectory)}`]);
let actualInventory;
try {
actualInventory = await collectDistInventory(distDirectory);
} catch (error) {
throw new DeploymentError("Could not inventory the extracted cPanel release.", {
cause: error,
});
}
if (JSON.stringify(actualInventory) !== JSON.stringify(expectedInventory)) {
throw new DeploymentError("Extracted cPanel release does not match the validated inventory.");
}
return actualInventory;
} finally {
await fsApi.rm(temporaryDirectory, { recursive: true, force: true });
}
},
async stageAndActivate(uploaded) {
const identity = {
target: `releases/${config.releaseId}/dist`,
releaseId: config.releaseId,
commit: config.expectedCommit,
buildId: config.expectedBuildId,
};
return await requestActivation(identity, "stage", uploaded);
},
async activateExisting(target) {
return await requestActivation(releaseIdentityFromTarget(target), "switch");
},
async removeRelease(releaseId) {
const safeReleaseId = validateReleaseId(releaseId, "release retention ID");
await run([`rm -r -f ${lftpQuote(`releases/${safeReleaseId}`)}`]);
},
async removeArchives(releaseIds) {
const safeReleaseIds = Array.from(
new Set(releaseIds.map((releaseId) => validateReleaseId(releaseId, "archive retention ID")))
);
if (safeReleaseIds.length === 0) {
return;
}
await run(
safeReleaseIds.flatMap((releaseId) => [
`rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip`)}`,
`rm -f ${lftpQuote(`archives/pleno-vue-${releaseId}.zip.sha256`)}`,
])
);
},
};
}
function responseMessage(payload) {
const result = payload?.cpanelresult;
return (
result?.error ||
result?.event?.reason ||
result?.data?.find?.((item) => item?.err || item?.reason)?.err ||
result?.data?.find?.((item) => item?.err || item?.reason)?.reason ||
payload?.result?.errors?.find?.(Boolean) ||
payload?.result?.messages?.find?.(Boolean) ||
payload?.errors?.find?.(Boolean) ||
payload?.messages?.find?.(Boolean) ||
payload?.error ||
payload?.message ||
"unknown server error"
);
}
function uapiResult(payload) {
return payload?.result && typeof payload.result === "object" ? payload.result : payload;
}
function responseShape(payload) {
const result = uapiResult(payload);
const rootKeys = payload && typeof payload === "object" ? Object.keys(payload).sort().join(",") : typeof payload;
const resultKeys = result && typeof result === "object" ? Object.keys(result).sort().join(",") : typeof result;
const dataType = Array.isArray(result?.data) ? "array" : typeof result?.data;
return `response shape root=[${rootKeys}] result=[${resultKeys}] statusType=${typeof result?.status} dataType=${dataType}`;
}
export class CpanelFilemanClient {
constructor(config, options = {}) {
this.config = config;
this.root = normalizeRemoteRoot(config.cpanel.root);
this.fetch = options.fetchImpl || globalThis.fetch;
this.timeoutMs = options.timeoutMs || 30_000;
}
redact(value) {
let result = String(value || "");
const secrets = [
this.config.ftp.host,
this.config.ftp.user,
this.config.ftp.password,
this.config.ftp.root,
this.config.cpanel.root,
this.config.cpanel.user,
this.config.cpanel.token,
].filter(Boolean);
for (const secret of secrets) {
result = result.replaceAll(secret, "[redacted]");
}
return result;
}
assertContained(remotePath) {
const normalized = String(remotePath).replace(/^\/+/, "");
if (normalized !== this.root && !normalized.startsWith(`${this.root}/`)) {
throw new DeploymentError("cPanel operation escaped the deployment root.");
}
if (
/[\\,]/.test(normalized) ||
hasControlCharacters(normalized) ||
normalized.split("/").some((part) => !SAFE_COMPONENT.test(part))
) {
throw new DeploymentError("cPanel operation used an unsafe path.");
}
return normalized;
}
async request(endpoint, operation) {
let response;
try {
response = await this.fetch(endpoint, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `cpanel ${this.config.cpanel.user}:${this.config.cpanel.token}`,
},
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
throw new DeploymentError(`cPanel ${operation} request failed.`, { cause: error });
}
if (!response.ok) {
throw new DeploymentError(`cPanel ${operation} returned HTTP ${response.status}.`);
}
try {
return await response.json();
} catch (error) {
throw new DeploymentError(`cPanel ${operation} returned invalid JSON.`, { cause: error });
}
}
async call(functionName, parameters) {
const endpoint = new URL("json-api/cpanel", this.config.cpanel.apiUrl);
endpoint.searchParams.set("cpanel_jsonapi_user", this.config.cpanel.user);
endpoint.searchParams.set("cpanel_jsonapi_apiversion", "2");
endpoint.searchParams.set("cpanel_jsonapi_module", "Fileman");
endpoint.searchParams.set("cpanel_jsonapi_func", functionName);
for (const [name, value] of Object.entries(parameters)) {
endpoint.searchParams.set(name, String(value));
}
const payload = await this.request(endpoint, `Fileman ${functionName}`);
const result = payload?.cpanelresult;
const failedItem = result?.data?.find?.((item) => item?.result === 0 || item?.result === false);
if (!succeeded(result?.event?.result) || failedItem) {
throw new DeploymentError(`cPanel Fileman ${functionName} failed: ${this.redact(responseMessage(payload))}`);
}
if (Array.isArray(result?.data)) {
return result.data;
}
return [...(result?.files || []), ...(result?.dirs || [])];
}
async fileOp(operation, source, destination) {
const parameters = {
op: operation,
sourcefiles: this.assertContained(source),
doubledecode: 0,
};
if (destination !== undefined) {
parameters.destfiles = this.assertContained(destination);
}
return await this.call("fileop", parameters);
}
async list(directory) {
const endpoint = new URL("execute/Fileman/list_files", this.config.cpanel.apiUrl);
endpoint.searchParams.set("dir", this.assertContained(directory));
endpoint.searchParams.set("include_mime", "0");
endpoint.searchParams.set("include_permissions", "1");
endpoint.searchParams.set("limit_to_list", "0");
endpoint.searchParams.set("show_hidden", "1");
endpoint.searchParams.set("types", "dir|file|link");
const payload = await this.request(endpoint, "Fileman list_files");
const result = uapiResult(payload);
if (!succeeded(result?.status)) {
const error = responseMessage(payload);
const detail = error === "unknown server error" ? `${error}; ${responseShape(payload)}` : error;
throw new DeploymentError(`cPanel Fileman list_files failed: ${this.redact(detail)}`);
}
if (!Array.isArray(result.data)) {
throw new DeploymentError("cPanel Fileman list_files returned an unexpected data shape.");
}
return result.data;
}
async remove(source) {
return await this.fileOp("trash", source);
}
}
function entryName(entry) {
return String(entry?.file || entry?.name || entry?.basename || "");
}
function findEntry(entries, name) {
return entries.find((entry) => entryName(entry) === name);
}
export function targetRelativeToRoot(root, target) {
const normalizedRoot = normalizeRemoteRoot(root);
const normalizedTarget = String(target || "")
.replaceAll("\\", "/")
.replace(/\/+$/, "");
const withoutLeading = normalizedTarget.replace(/^\/+/, "");
if (withoutLeading.startsWith(`${normalizedRoot}/`)) {
return withoutLeading.slice(normalizedRoot.length + 1);
}
const marker = `/${normalizedRoot}/`;
const index = normalizedTarget.lastIndexOf(marker);
return index >= 0 ? normalizedTarget.slice(index + marker.length) : "";
}
export function validateReleaseTarget(value) {
const parts = String(value || "").split("/");
if (parts.length !== 3 || parts[0] !== "releases" || parts[2] !== "dist") {
throw new DeploymentError("Release target was not a contained releases/<id>/dist path.");
}
validateReleaseId(parts[1], "release target ID");
return parts.join("/");
}
export async function assertReleaseTargetExists(client, config, target) {
const safeTarget = validateReleaseTarget(target);
const releaseId = safeTarget.split("/")[1];
const releasesRoot = containedRemotePath(config.cpanel.root, "releases");
if (!findEntry(await client.list(releasesRoot), releaseId)) {
throw new DeploymentError("Captured rollback release directory does not exist on cPanel.");
}
const releaseRoot = containedRemotePath(releasesRoot, releaseId);
if (!findEntry(await client.list(releaseRoot), "dist")) {
throw new DeploymentError("Captured rollback release does not contain a dist directory.");
}
}
export async function capturePublishedReleaseTarget(config, options = {}) {
const fetchImpl = options.fetchImpl || globalThis.fetch;
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
let response;
try {
response = await fetchImpl(manifestUrl, {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
});
} catch (error) {
throw new DeploymentError("Could not capture the currently published release identity.", {
cause: error,
});
}
if (!response.ok) {
throw new DeploymentError(`Current release-manifest.json returned HTTP ${response.status}; refusing deployment.`);
}
let manifest;
try {
manifest = await response.json();
} catch (error) {
throw new DeploymentError("Current release-manifest.json was not valid JSON.", { cause: error });
}
const commit = String(manifest?.commit_sha || "").toLowerCase();
const buildId = String(manifest?.build_id || "");
if (!/^[a-f0-9]{40}$/.test(commit)) {
throw new DeploymentError("Current release-manifest.json did not contain a full commit SHA.");
}
validateReleaseId(buildId, "current release build ID");
return validateReleaseTarget(`releases/${commit}-${buildId}/dist`);
}
export async function assertExpectedCommitCurrent(config, options = {}) {
if (!config.github) {
throw new DeploymentError("RELEASE_GITHUB_REPOSITORY and RELEASE_GITHUB_TOKEN are required for deployment.");
}
const fetchImpl = options.fetchImpl || globalThis.fetch;
const [owner, repository] = config.github.repository.split("/");
const endpoint = new URL(
`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/master`,
config.github.apiUrl
);
let response;
try {
response = await fetchImpl(endpoint, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${config.github.token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(options.timeoutMs || 30_000),
});
} catch (error) {
throw new DeploymentError("Could not confirm the current master commit with GitHub.", {
cause: error,
});
}
if (!response.ok) {
throw new DeploymentError(`GitHub branch check returned HTTP ${response.status}; refusing deployment.`);
}
let payload;
try {
payload = await response.json();
} catch (error) {
throw new DeploymentError("GitHub branch check returned invalid JSON.", { cause: error });
}
const currentCommit = String(payload?.commit?.sha || "").toLowerCase();
if (currentCommit !== config.expectedCommit) {
throw new DeploymentError(
`Release commit is stale; master is ${currentCommit || "unknown"}, expected ${config.expectedCommit}.`
);
}
}
export async function runPublicVerification(config, options = {}) {
const runner = options.runner || defaultProcessRunner;
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
await runner(process.execPath, [verifier], {
inherit: true,
label: "public release verification",
env: {
...process.env,
RELEASE_BASE_URL: config.frontendUrl,
RELEASE_EXPECTED_COMMIT: config.expectedCommit,
RELEASE_EXPECTED_BUILD_ID: config.expectedBuildId,
RELEASE_STRICT_BUILD_ID: "1",
RELEASE_WAIT_INITIAL_SECONDS: process.env.RELEASE_WAIT_INITIAL_SECONDS || "0",
},
});
}
function entryMtime(entry) {
const value = Number(entry?.mtime ?? entry?.modified ?? entry?.mtime_epoch);
return Number.isFinite(value) && value > 0 ? value : null;
}
export async function pruneInactiveReleases(client, config, protectedTargets, options = {}) {
const releasesRoot = containedRemotePath(config.cpanel.root, "releases");
const entries = await client.list(releasesRoot);
const releases = entries
.map((entry) => ({ id: entryName(entry), mtime: entryMtime(entry) }))
.filter(({ id }) => SAFE_COMPONENT.test(id));
if (releases.some(({ mtime }) => mtime === null)) {
return [];
}
const protectedIds = new Set(
protectedTargets.filter(Boolean).map((target) => validateReleaseTarget(target).split("/")[1])
);
const sorted = releases.sort((left, right) => right.mtime - left.mtime);
const keepIds = new Set([...protectedIds, ...sorted.slice(0, config.retainCount).map(({ id }) => id)]);
const removed = [];
const removeRelease =
options.removeRelease || (async (id) => await client.remove(containedRemotePath(releasesRoot, id)));
for (const { id } of sorted) {
if (!keepIds.has(id)) {
await removeRelease(id);
removed.push(id);
}
}
return removed;
}
export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
const pairs = Object.entries(values).filter(([, value]) => value !== undefined && value !== "");
if (env.GITHUB_ENV && pairs.length > 0) {
fsApi.appendFileSync(env.GITHUB_ENV, `${pairs.map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
}
if (env.GITHUB_OUTPUT && pairs.length > 0) {
const outputNames = {
RELEASE_ROLLBACK_TARGET: "rollback_target",
RELEASE_ACTIVE_TARGET: "active_target",
RELEASE_DEPLOYED_RELEASE_ID: "release_id",
};
const output = pairs
.filter(([key]) => outputNames[key])
.map(([key, value]) => `${outputNames[key]}=${value}`)
.join("\n");
if (output) {
fsApi.appendFileSync(env.GITHUB_OUTPUT, `${output}\n`);
}
}
}
export async function deployRelease(config, dependencies = {}) {
const transport = dependencies.transport || createLftpTransport(config, dependencies);
const verify = dependencies.verify || runPublicVerification;
const publish = dependencies.publish || emitDeploymentOutputs;
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
await checkCurrent(config, dependencies);
const previousTarget = await capturePrevious(config, dependencies);
const uploaded = await transport.uploadArchive();
await checkCurrent(config, dependencies);
const expectedNewTarget = `releases/${config.releaseId}/dist`;
let newTarget;
try {
newTarget = await transport.stageAndActivate(uploaded);
} catch (error) {
let observedTarget;
try {
observedTarget = await captureActive(config, dependencies);
} catch {
throw error;
}
if (observedTarget !== expectedNewTarget) {
throw error;
}
newTarget = observedTarget;
}
publish({
RELEASE_ROLLBACK_TARGET: previousTarget,
RELEASE_ACTIVE_TARGET: newTarget,
RELEASE_DEPLOYED_RELEASE_ID: config.releaseId,
});
try {
await transport.verifyRelease(newTarget);
await verify(config, dependencies);
} catch (error) {
try {
await transport.activateExisting(previousTarget);
} catch (rollbackError) {
throw new DeploymentError(
"Release verification failed and automatic rollback also failed; production requires immediate attention.",
{ cause: new AggregateError([error, rollbackError]) }
);
}
throw new DeploymentError("Release verification failed; the previous release was restored.", {
cause: error,
});
}
return {
previousTarget,
activeTarget: newTarget,
removed: [],
retentionWarning:
"Verified deployment succeeded; inactive release cleanup is deferred because hosted runners cannot use the cPanel metadata API.",
};
}
export async function rollbackRelease(config, target, dependencies = {}) {
const transport = dependencies.transport || createLftpTransport(config, dependencies);
const publish = dependencies.publish || emitDeploymentOutputs;
const safeTarget = validateReleaseTarget(target);
await transport.activateExisting(safeTarget);
publish({
RELEASE_ACTIVE_TARGET: safeTarget,
RELEASE_DEPLOYED_RELEASE_ID: safeTarget.split("/")[1],
});
return { activeTarget: safeTarget };
}