Harden hosted releases and mobile store gates (#205)

This commit is contained in:
Jeppe B
2026-07-21 18:17:47 +02:00
committed by GitHub
parent 0692cb3aea
commit a01902356d
13 changed files with 613 additions and 299 deletions
+2 -5
View File
@@ -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")
);
+213
View File
@@ -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;
});
}
+31 -65
View File
@@ -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,
+9 -4
View File
@@ -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) {