## Summary - Preserve lftp stdout/stderr when the process exits non-zero. - Surface bounded, whitespace-normalized diagnostics through the deploy error. - Redact FTPS host, username, password, path, URL userinfo, and encoded secret forms. ## Verification - `vitest run tests/unit/cpanel-deploy.spec.js` (25/25) - `node --check scripts/release/cpanel-deploy-lib.mjs` - `git diff --check` This is the prerequisite diagnostic repair for failed Frontend Release run 29854900889. Production was not switched during that failure.
1070 lines
40 KiB
JavaScript
1070 lines
40 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("'", `'\\''`)}'`;
|
|
}
|
|
|
|
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 {
|
|
await runner("lftp", ["-f", "/dev/stdin"], {
|
|
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([
|
|
`mkdir -p ${lftpQuote("activation-requests")}`,
|
|
`mkdir -p ${lftpQuote("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([
|
|
`mkdir -p ${lftpQuote("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 };
|
|
}
|