Harden hosted releases and mobile store gates (#205)
This commit is contained in:
@@ -44,10 +44,7 @@ const writeOutput = (result) => {
|
||||
|
||||
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||
const output = outputText(result);
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
/Playwright does not support .* on /i.test(output)
|
||||
);
|
||||
return result.status !== 0 && /Playwright does not support .* on /i.test(output);
|
||||
};
|
||||
|
||||
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||
@@ -78,7 +75,7 @@ console.warn(
|
||||
[
|
||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||
"The self-hosted runner image must provide the required browser system libraries.",
|
||||
"The runner image must provide the required browser system libraries.",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const roleShards = new Map([
|
||||
["superuser", 2],
|
||||
["admin", 2],
|
||||
["customer", 1],
|
||||
["subuser", 1],
|
||||
]);
|
||||
|
||||
export function expectedStoreGateJobs(platform) {
|
||||
const normalized = String(platform || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const browser = normalized === "android" ? "Chromium" : normalized === "apple" ? "WebKit" : null;
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(`Unsupported store platform: ${platform || "<empty>"}`);
|
||||
}
|
||||
|
||||
return [...roleShards].flatMap(([role, shardTotal]) =>
|
||||
Array.from(
|
||||
{ length: shardTotal },
|
||||
(_, index) => `E2E-full-${browser}-mobile-${role}-shard-${index + 1}-of-${shardTotal}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isNewerJobExecution(candidate, current) {
|
||||
const candidateAttempt = Number(candidate?.run_attempt || 0);
|
||||
const currentAttempt = Number(current?.run_attempt || 0);
|
||||
if (candidateAttempt !== currentAttempt) {
|
||||
return candidateAttempt > currentAttempt;
|
||||
}
|
||||
return Number(candidate?.id || 0) > Number(current?.id || 0);
|
||||
}
|
||||
|
||||
export function evaluateStoreGate(platform, jobs) {
|
||||
const required = expectedStoreGateJobs(platform);
|
||||
const latestByName = new Map();
|
||||
|
||||
for (const job of jobs || []) {
|
||||
if (job?.name && (!latestByName.has(job.name) || isNewerJobExecution(job, latestByName.get(job.name)))) {
|
||||
latestByName.set(job.name, job);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = required.flatMap((name) => {
|
||||
const job = latestByName.get(name);
|
||||
if (!job) {
|
||||
return [`${name}:missing`];
|
||||
}
|
||||
if (job.status !== "completed" || job.conclusion !== "success") {
|
||||
return [`${name}:${job.status || "unknown"}/${job.conclusion || "none"}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return { required, failures, passed: failures.length === 0 };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (!value.startsWith("--")) {
|
||||
throw new Error(`Unexpected argument: ${value}`);
|
||||
}
|
||||
const [rawName, inlineValue] = value.slice(2).split("=", 2);
|
||||
const nextValue = inlineValue ?? argv[index + 1];
|
||||
if (inlineValue === undefined) {
|
||||
index += 1;
|
||||
}
|
||||
args[rawName] = nextValue;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function required(value, name) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createGitHubClient({ apiUrl, repository, token, fetchImpl = fetch }) {
|
||||
const request = async (path) => {
|
||||
const response = await fetchImpl(`${apiUrl}/repos/${repository}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${response.status} for ${path}: ${await response.text()}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
return { request };
|
||||
}
|
||||
|
||||
function validateRun(run, { sourceSha, defaultBranch }) {
|
||||
if (run?.name !== "Automated Tests") {
|
||||
throw new Error(`Run ${run?.id || "<unknown>"} is not the Automated Tests workflow.`);
|
||||
}
|
||||
if (run.head_sha !== sourceSha) {
|
||||
throw new Error(`Run ${run.id} tested ${run.head_sha || "<unknown>"}, not ${sourceSha}.`);
|
||||
}
|
||||
if (run.event !== "push" || run.head_branch !== defaultBranch) {
|
||||
throw new Error(`Run ${run.id} is not a push run for ${defaultBranch}.`);
|
||||
}
|
||||
if (run.status !== "completed") {
|
||||
throw new Error(`Run ${run.id} is not complete.`);
|
||||
}
|
||||
if (run.conclusion !== "success") {
|
||||
throw new Error(`Run ${run.id} did not succeed (${run.conclusion || "none"}).`);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function resolveTestRun(client, { runId, sourceSha, defaultBranch }) {
|
||||
if (runId) {
|
||||
const run = await client.request(`/actions/runs/${encodeURIComponent(runId)}`);
|
||||
return validateRun(run, { sourceSha, defaultBranch });
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
branch: defaultBranch,
|
||||
event: "push",
|
||||
status: "completed",
|
||||
per_page: "100",
|
||||
});
|
||||
const response = await client.request(`/actions/workflows/tests.yml/runs?${query}`);
|
||||
const run = response.workflow_runs?.find((candidate) => candidate.head_sha === sourceSha);
|
||||
if (!run) {
|
||||
throw new Error(`No completed Automated Tests push run was found for ${sourceSha} on ${defaultBranch}.`);
|
||||
}
|
||||
return validateRun(run, { sourceSha, defaultBranch });
|
||||
}
|
||||
|
||||
async function readAllAttemptJobs(client, runId) {
|
||||
const jobs = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const query = new URLSearchParams({ filter: "all", per_page: "100", page: String(page) });
|
||||
const response = await client.request(`/actions/runs/${encodeURIComponent(runId)}/jobs?${query}`);
|
||||
const pageJobs = response.jobs || [];
|
||||
jobs.push(...pageJobs);
|
||||
if (pageJobs.length < 100) {
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyStoreTestGate({
|
||||
platform,
|
||||
sourceSha,
|
||||
runId,
|
||||
defaultBranch = "master",
|
||||
apiUrl = "https://api.github.com",
|
||||
repository,
|
||||
token,
|
||||
fetchImpl,
|
||||
}) {
|
||||
const normalizedSha = required(sourceSha, "source SHA").toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/u.test(normalizedSha)) {
|
||||
throw new Error("source SHA must be a full lowercase commit SHA.");
|
||||
}
|
||||
|
||||
const client = createGitHubClient({
|
||||
apiUrl: required(apiUrl, "GitHub API URL").replace(/\/$/u, ""),
|
||||
repository: required(repository, "GitHub repository"),
|
||||
token: required(token, "GitHub token"),
|
||||
fetchImpl,
|
||||
});
|
||||
const run = await resolveTestRun(client, {
|
||||
runId: String(runId || "").trim(),
|
||||
sourceSha: normalizedSha,
|
||||
defaultBranch: required(defaultBranch, "default branch"),
|
||||
});
|
||||
const jobs = await readAllAttemptJobs(client, run.id);
|
||||
const result = evaluateStoreGate(platform, jobs);
|
||||
|
||||
if (!result.passed) {
|
||||
throw new Error(`${platform} store test gate failed for run ${run.id}: ${result.failures.join(", ")}`);
|
||||
}
|
||||
|
||||
return { ...result, runId: run.id, sourceSha: normalizedSha };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const result = await verifyStoreTestGate({
|
||||
platform: args.platform,
|
||||
sourceSha: args["source-sha"] || process.env.STORE_SOURCE_SHA,
|
||||
runId: args["run-id"] || process.env.TEST_WORKFLOW_RUN_ID,
|
||||
defaultBranch: args["default-branch"] || process.env.DEFAULT_BRANCH || "master",
|
||||
apiUrl: process.env.GITHUB_API_URL,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
token: process.env.GH_TOKEN,
|
||||
});
|
||||
console.log(
|
||||
`${args.platform} store gate passed for Automated Tests run ${result.runId}: ${result.required.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -18,10 +18,6 @@ const REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
"RELEASE_ARCHIVE_PATH",
|
||||
"RELEASE_INVENTORY_PATH",
|
||||
"RELEASE_EXPECTED_COMMIT",
|
||||
@@ -33,10 +29,6 @@ const ROLLBACK_REQUIRED_ENV = [
|
||||
"PRODUCTION_FTP_PASSWORD",
|
||||
"PRODUCTION_FTP_PATH",
|
||||
"PRODUCTION_ACTIVATION_KEY",
|
||||
"PRODUCTION_CPANEL_USER",
|
||||
"PRODUCTION_CPANEL_API_TOKEN",
|
||||
"PRODUCTION_CPANEL_API_URL",
|
||||
"PRODUCTION_CPANEL_PATH",
|
||||
];
|
||||
|
||||
export class DeploymentError extends Error {
|
||||
@@ -153,16 +145,29 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
requireString(env, name);
|
||||
}
|
||||
|
||||
const cpanelUser = requireString(env, "PRODUCTION_CPANEL_USER");
|
||||
if (!SAFE_COMPONENT.test(cpanelUser)) {
|
||||
throw new DeploymentError("PRODUCTION_CPANEL_USER contains unsupported characters.");
|
||||
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 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.");
|
||||
@@ -178,7 +183,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
throw new DeploymentError("PRODUCTION_ACTIVATION_KEY must be a 64-character hexadecimal key.");
|
||||
}
|
||||
if (rollbackOnly) {
|
||||
return { ftp, cpanel, activationKey };
|
||||
return { ftp, activationKey, ...(cpanel ? { cpanel } : {}) };
|
||||
}
|
||||
|
||||
const checksumPath = env.RELEASE_ARCHIVE_SHA256_PATH || env.RELEASE_CHECKSUM_PATH;
|
||||
@@ -224,7 +229,7 @@ export function readDeploymentConfig(env = process.env, options = {}) {
|
||||
|
||||
return {
|
||||
ftp,
|
||||
cpanel,
|
||||
...(cpanel ? { cpanel } : {}),
|
||||
activationKey,
|
||||
archivePath: path.resolve(requireString(env, "RELEASE_ARCHIVE_PATH")),
|
||||
checksumPath: path.resolve(checksumPath),
|
||||
@@ -824,17 +829,6 @@ export async function assertReleaseTargetExists(client, config, target) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCurrentLink(client, config) {
|
||||
const entries = await client.list(config.cpanel.root);
|
||||
const current = findEntry(entries, "current");
|
||||
if (!current) {
|
||||
throw new DeploymentError("cPanel does not have a current frontend release pointer.");
|
||||
}
|
||||
if (current.type !== "link") {
|
||||
throw new DeploymentError("cPanel current is not a symbolic link.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function capturePublishedReleaseTarget(config, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
||||
const manifestUrl = new URL("release-manifest.json", config.frontendUrl);
|
||||
@@ -909,17 +903,6 @@ export async function assertExpectedCommitCurrent(config, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertArchiveNamesAvailable(client, config, archiveName, checksumName) {
|
||||
const archiveRoot = containedRemotePath(config.cpanel.root, "archives");
|
||||
if (!findEntry(await client.list(config.cpanel.root), "archives")) {
|
||||
throw new DeploymentError("cPanel deployment archives directory is missing; bootstrap is incomplete.");
|
||||
}
|
||||
const entries = await client.list(archiveRoot);
|
||||
if (findEntry(entries, archiveName) || findEntry(entries, checksumName)) {
|
||||
throw new DeploymentError("The immutable release archive name already exists on cPanel.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPublicVerification(config, options = {}) {
|
||||
const runner = options.runner || defaultProcessRunner;
|
||||
const verifier = fileURLToPath(new URL("./verify-upload.mjs", import.meta.url));
|
||||
@@ -991,23 +974,16 @@ export function emitDeploymentOutputs(values, env = process.env, fsApi = fs) {
|
||||
}
|
||||
|
||||
export async function deployRelease(config, dependencies = {}) {
|
||||
const client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const verify = dependencies.verify || runPublicVerification;
|
||||
const prune = dependencies.prune || pruneInactiveReleases;
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const capturePrevious = dependencies.capturePrevious || capturePublishedReleaseTarget;
|
||||
const captureActive = dependencies.captureActive || capturePublishedReleaseTarget;
|
||||
const checkCurrent = dependencies.checkCurrent || assertExpectedCommitCurrent;
|
||||
|
||||
await checkCurrent(config, dependencies);
|
||||
await assertCurrentLink(client, config);
|
||||
const previousTarget = await capturePrevious(config, dependencies);
|
||||
await assertReleaseTargetExists(client, config, previousTarget);
|
||||
|
||||
const archiveName = safeArchiveName(config.archivePath);
|
||||
const checksumName = `${archiveName}.sha256`;
|
||||
await assertArchiveNamesAvailable(client, config, archiveName, checksumName);
|
||||
const uploaded = await transport.uploadArchive();
|
||||
await checkCurrent(config, dependencies);
|
||||
const expectedNewTarget = `releases/${config.releaseId}/dist`;
|
||||
@@ -1033,7 +1009,6 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
|
||||
try {
|
||||
await assertReleaseTargetExists(client, config, newTarget);
|
||||
await transport.verifyRelease(newTarget);
|
||||
await verify(config, dependencies);
|
||||
} catch (error) {
|
||||
@@ -1050,28 +1025,19 @@ export async function deployRelease(config, dependencies = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
await assertCurrentLink(client, config);
|
||||
let removed = [];
|
||||
let retentionWarning = "";
|
||||
try {
|
||||
removed = await prune(client, config, [newTarget, previousTarget], {
|
||||
removeRelease: async (releaseId) => {
|
||||
await transport.removeArchives([releaseId]);
|
||||
await transport.removeRelease(releaseId);
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
retentionWarning = "Verified deployment succeeded, but old release retention cleanup failed.";
|
||||
}
|
||||
return { previousTarget, activeTarget: newTarget, removed, retentionWarning };
|
||||
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 client = dependencies.client || new CpanelFilemanClient(config, dependencies);
|
||||
const transport = dependencies.transport || createLftpTransport(config, dependencies);
|
||||
const publish = dependencies.publish || emitDeploymentOutputs;
|
||||
const safeTarget = validateReleaseTarget(target);
|
||||
await assertReleaseTargetExists(client, config, safeTarget);
|
||||
await transport.activateExisting(safeTarget);
|
||||
publish({
|
||||
RELEASE_ACTIVE_TARGET: safeTarget,
|
||||
|
||||
@@ -440,6 +440,14 @@ function validateShardSelection(role, matchingTests, shardTests) {
|
||||
}
|
||||
}
|
||||
|
||||
export function requireMatchingTests(role, project, matchingTests) {
|
||||
if (matchingTests.length === 0) {
|
||||
throw new Error(`Full Playwright slice resolved zero ${role} tests for ${project}.`);
|
||||
}
|
||||
|
||||
return matchingTests;
|
||||
}
|
||||
|
||||
async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
const args = ["test", `--project=${project}`, `--test-list=${testListPath}`, ...forwardedArgs];
|
||||
|
||||
@@ -487,10 +495,7 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
`Resolved ${matchingTests.length} ${options.role} test(s) out of ${classifiedTests.length} listed test(s) for ${options.project}.`
|
||||
);
|
||||
|
||||
if (matchingTests.length === 0) {
|
||||
console.log(`No ${options.role} tests matched for ${options.project}. Nothing to run.`);
|
||||
return;
|
||||
}
|
||||
requireMatchingTests(options.role, options.project, matchingTests);
|
||||
|
||||
if (options.listOnly && !options.shard.explicit) {
|
||||
for (const testEntry of matchingTests) {
|
||||
|
||||
Reference in New Issue
Block a user