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//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 }; }