859 lines
41 KiB
JavaScript
859 lines
41 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const WORKFLOW_PATH = ".github/workflows/ci.yml";
|
|
const SENTRY_REUSABLE_WORKFLOW_PATH = ".github/workflows/_sentry-release.yml";
|
|
const REQUIRED_PNPM_VERSION = "10.33.0";
|
|
const WEB_SOURCEMAPS_ARTIFACT_NAME = "web-client-sourcemaps";
|
|
const WEB_SOURCEMAPS_PATH = "apps/web/build/client";
|
|
const WEB_SOURCEMAPS_VALIDATE_COMMAND = "test -n \"$(find apps/web/build/client -type f -name '*.map' -print -quit)\"";
|
|
const REQUIRED_COMMANDS_IN_ORDER = [
|
|
"pnpm run verify:github-ci-workflow",
|
|
"pnpm run verify:gitea-ci-workflow",
|
|
"pnpm run verify:deploy-workflow",
|
|
"pnpm run verify:deploy-readiness",
|
|
"pnpm run verify:ci-baseline",
|
|
"pnpm run verify:source-health",
|
|
"pnpm run verify:eslint-baseline",
|
|
"pnpm run verify:env-drift",
|
|
"pnpm run verify:health-baseline",
|
|
"pnpm run verify:api-inventory",
|
|
"pnpm run verify:tenant-isolation-report",
|
|
"pnpm run verify:sidebar-routes",
|
|
"pnpm run verify:i18n",
|
|
"pnpm run verify:tenant-auth-baseline",
|
|
"pnpm run verify:onboarding-flow",
|
|
"pnpm run verify:phase-c-boundaries",
|
|
"pnpm run verify:core-build-without-plugins-smoke",
|
|
"pnpm run verify:removed-modules",
|
|
"pnpm run verify:ui-visual",
|
|
"pnpm run test:hot-swap",
|
|
"pnpm run verify:a11y-coverage",
|
|
"pnpm run test:a11y",
|
|
"pnpm run verify:porting-docs-baseline",
|
|
"pnpm run verify:production-docs",
|
|
"pnpm run verify:release-docs",
|
|
"pnpm run verify:docs-links",
|
|
"pnpm typecheck",
|
|
"pnpm lint",
|
|
"pnpm run test:foundation",
|
|
"pnpm run test:packages",
|
|
"pnpm run test:mobile",
|
|
"pnpm test",
|
|
"pnpm run build:ci",
|
|
"pnpm run verify:bundle-size",
|
|
"pnpm run verify:production-seed-artifact",
|
|
];
|
|
const SERVICE_JOB_CONTRACTS = {
|
|
api: [
|
|
"pnpm run verify:openapi-security",
|
|
"pnpm --filter @toolctrl/api typecheck",
|
|
"pnpm exec vitest run apps/api/src",
|
|
"pnpm --filter @toolctrl/api build",
|
|
],
|
|
worker: [
|
|
"pnpm --filter @toolctrl/worker typecheck",
|
|
"pnpm --filter @toolctrl/worker exec vitest run",
|
|
"pnpm --filter @toolctrl/worker build",
|
|
"pnpm --filter @toolctrl/worker health",
|
|
],
|
|
};
|
|
const DATABASE_JOB = "database";
|
|
const DOCKER_SMOKE_JOB = "docker-smoke";
|
|
const DATABASE_STATUS_COMMAND = "pnpm run verify:db-migrate-status";
|
|
const DATABASE_GENERATE_COMMAND = "pnpm run db:generate";
|
|
const DATABASE_URL_SECRET = "${{ secrets.DATABASE_URL }}";
|
|
const DATABASE_URL_DUMMY = "postgresql://toolctrl:toolctrl@localhost:5432/toolctrl?schema=public";
|
|
const DOCKER_COMPOSE_CONFIG_COMMAND = "docker compose config";
|
|
const DOCKER_WEB_ARTIFACT_COMMAND = "docker run --rm --entrypoint /bin/sh toolctrl-web:smoke -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/build/server-runtime/server.js'";
|
|
const DOCKER_WEB_RUNTIME_COMMAND = "bash scripts/docker-web-runtime-smoke.sh toolctrl-web:smoke";
|
|
const DOCKER_API_RUNTIME_COMMAND = "bash scripts/docker-api-runtime-smoke.sh toolctrl-api:smoke";
|
|
const DOCKER_WORKER_RUNTIME_COMMAND = "bash scripts/docker-worker-runtime-smoke.sh toolctrl-worker:smoke";
|
|
const DOCKER_SMOKE_REVISION_ARG_PATTERN = /(?:^|\s)--build-arg(?:=|\s+)APP_REVISION=ci-smoke-revision(?:\s|$)/;
|
|
const DOCKER_SMOKE_TARGET_CONTRACTS = {
|
|
web: { tag: "toolctrl-web:smoke", scope: "toolctrl-web-smoke" },
|
|
api: { tag: "toolctrl-api:smoke", scope: "toolctrl-api-smoke" },
|
|
worker: { tag: "toolctrl-worker:smoke", scope: "toolctrl-worker-smoke" },
|
|
};
|
|
const FORBIDDEN_DATABASE_MUTATION_PATTERN = /\b(?:prisma\s+migrate\s+deploy|migrate:deploy|db:deploy|db:push|prisma\s+db\s+push|seed)\b/i;
|
|
const FORBIDDEN_DOCKER_PUBLISH_PATTERN = /\b(?:docker\s+(?:login|push)|docker\s+buildx\s+build[^\n]*--push|docker\s+build[^\n]*--push|publish)\b/i;
|
|
const FORBIDDEN_E2E_PATTERN = /\b(playwright|cypress|browser|test:e2e|e2e)\b/i;
|
|
|
|
function readWorkflow(rootDir) {
|
|
return fs.readFileSync(path.join(rootDir, WORKFLOW_PATH), "utf8");
|
|
}
|
|
|
|
function workflowExists(rootDir) {
|
|
return fs.existsSync(path.join(rootDir, WORKFLOW_PATH));
|
|
}
|
|
|
|
function readOptionalFile(rootDir, relativePath) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
if (!fs.existsSync(absolutePath)) return null;
|
|
return fs.readFileSync(absolutePath, "utf8");
|
|
}
|
|
|
|
function getJobSection(content, jobName) {
|
|
const lines = content.split(/\r?\n/);
|
|
const startIndex = lines.findIndex((line) => new RegExp(`^ ${jobName}:\\s*$`).test(line));
|
|
if (startIndex === -1) return null;
|
|
|
|
const sectionLines = [lines[startIndex]];
|
|
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
if (/^ [A-Za-z0-9_-]+:\s*$/.test(line)) break;
|
|
sectionLines.push(line);
|
|
}
|
|
return sectionLines.join("\n");
|
|
}
|
|
|
|
function getExecutableRunCommandEntries(section) {
|
|
const lines = section.split(/\r?\n/);
|
|
const entries = [];
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
const inline = line.match(/^\s*run:\s*(.+)\s*$/);
|
|
if (inline && inline[1] !== "|") {
|
|
const command = stripExecutableShellLine(inline[1]);
|
|
if (command) entries.push({ command, line });
|
|
continue;
|
|
}
|
|
|
|
if (/^\s*run:\s*\|\s*$/.test(line)) {
|
|
const commandLines = [];
|
|
const baseIndent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
for (let inner = index + 1; inner < lines.length; inner += 1) {
|
|
const candidate = lines[inner];
|
|
const indent = candidate.match(/^(\s*)/)?.[1].length ?? 0;
|
|
if (candidate.trim().length > 0 && indent <= baseIndent) break;
|
|
const commandLine = stripExecutableShellLine(candidate);
|
|
if (commandLine) {
|
|
commandLines.push(commandLine);
|
|
}
|
|
index = inner;
|
|
}
|
|
if (commandLines.length > 0) {
|
|
entries.push({ command: commandLines.join("\n"), line: `run: ${commandLines.join("\n")}` });
|
|
}
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function stripExecutableShellLine(line) {
|
|
return line.replace(/\s+#.*$/, "").trim();
|
|
}
|
|
|
|
function getAllStepBlocks(section) {
|
|
const lines = section.split(/\r?\n/);
|
|
const steps = [];
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const stepMatch = lines[index].match(/^(\s*)-\s+/);
|
|
if (!stepMatch) continue;
|
|
|
|
const stepIndent = stepMatch[1].length;
|
|
const stepLines = [lines[index]];
|
|
for (let inner = index + 1; inner < lines.length; inner += 1) {
|
|
const candidate = lines[inner];
|
|
const indent = candidate.match(/^(\s*)/)?.[1].length ?? 0;
|
|
if (candidate.trim().length > 0 && indent <= stepIndent && /^\s*-\s+/.test(candidate)) break;
|
|
stepLines.push(candidate);
|
|
index = inner;
|
|
}
|
|
|
|
const text = stepLines.join("\n");
|
|
steps.push(text);
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
function getStepBlocks(section) {
|
|
return getAllStepBlocks(section).filter((step) => /^\s*run:\s*/m.test(step));
|
|
}
|
|
|
|
function getRunTextFromStep(step) {
|
|
const lines = step.split(/\r?\n/);
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const runMatch = lines[index].match(/^(\s*)run:\s*(.*)$/);
|
|
if (!runMatch) continue;
|
|
|
|
const runIndent = runMatch[1].length;
|
|
const value = runMatch[2].trim();
|
|
if (!/^[|>][+-]?$/.test(value)) return value;
|
|
|
|
const commandLines = [];
|
|
for (let inner = index + 1; inner < lines.length; inner += 1) {
|
|
const candidate = lines[inner];
|
|
const indent = candidate.match(/^(\s*)/)?.[1].length ?? 0;
|
|
if (candidate.trim().length > 0 && indent <= runIndent) break;
|
|
commandLines.push(candidate);
|
|
}
|
|
return commandLines.join("\n");
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getExecutableShellLines(runText) {
|
|
return runText
|
|
.split(/\r?\n/)
|
|
.map((line) => stripExecutableShellLine(line))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function matchesExecutableCommand(actual, expected) {
|
|
return actual
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.some((line) => line === expected);
|
|
}
|
|
|
|
function countExecutableCommandOccurrences(section, expectedCommand) {
|
|
return getExecutableRunCommandEntries(section).reduce((count, entry) => {
|
|
const matches = entry.command
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line === expectedCommand).length;
|
|
return count + matches;
|
|
}, 0);
|
|
}
|
|
|
|
function hasCorepackSetup(section) {
|
|
return /\bcorepack\s+enable\b/i.test(section);
|
|
}
|
|
|
|
function hasPinnedPnpm(section) {
|
|
return new RegExp(`\\bcorepack\\s+prepare\\s+pnpm@${REQUIRED_PNPM_VERSION.replaceAll(".", "\\.")}\\s+--activate\\b`).test(section);
|
|
}
|
|
|
|
function hasFrozenInstall(section) {
|
|
return /pnpm\s+install\s+--frozen-lockfile\b/.test(section);
|
|
}
|
|
|
|
function hasPnpmStoreSetup(section) {
|
|
return /pnpm\s+config\s+set\s+store-dir\b/.test(section) || /path:\s*\.pnpm-store\b/.test(section);
|
|
}
|
|
|
|
function hasCheckoutStep(section) {
|
|
return /uses:\s*actions\/checkout@v\d+/i.test(section);
|
|
}
|
|
|
|
function getNeedsValues(section) {
|
|
const inlineNeeds = section.match(/^ needs:[ \t]*\[([^\]]+)\][ \t]*$/m);
|
|
if (inlineNeeds) {
|
|
return inlineNeeds[1]
|
|
.split(",")
|
|
.map((value) => value.trim().replace(/^['"]|['"]$/g, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const singleNeeds = section.match(/^ needs:[ \t]*([^#\n]+)[ \t]*$/m);
|
|
if (singleNeeds) {
|
|
return [singleNeeds[1].trim().replace(/^['"]|['"]$/g, "")].filter(Boolean);
|
|
}
|
|
|
|
const blockNeeds = section.match(/^ needs:[ \t]*\n((?: -[ \t]*[^\n]+\n?)+)/m);
|
|
if (blockNeeds) {
|
|
return blockNeeds[1]
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^ -\s*([^\s#]+)/)?.[1]?.replace(/^['"]|['"]$/g, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function hasNeeds(section, requiredNeed) {
|
|
return getNeedsValues(section).includes(requiredNeed);
|
|
}
|
|
|
|
function getStepForRunCommand(section, predicate) {
|
|
return (
|
|
getStepBlocks(section).find((step) => {
|
|
const lines = getExecutableShellLines(getRunTextFromStep(step));
|
|
const block = lines.join("\n");
|
|
return lines.some((line) => predicate(line)) || predicate(block);
|
|
}) ?? ""
|
|
);
|
|
}
|
|
|
|
function hasUploadSourcemapsArtifactStep(section) {
|
|
return getAllStepBlocks(section).some(
|
|
(step) =>
|
|
/uses:\s*actions\/upload-artifact@v4/i.test(step) &&
|
|
new RegExp(`^\\s*name:\\s*${escapeRegExp(WEB_SOURCEMAPS_ARTIFACT_NAME)}\\s*$`, "m").test(step) &&
|
|
new RegExp(`^\\s*path:\\s*${escapeRegExp(WEB_SOURCEMAPS_PATH)}\\s*$`, "m").test(step) &&
|
|
/^\s*if-no-files-found:\s*error\s*$/m.test(step) &&
|
|
/^\s*retention-days:\s*7\s*$/m.test(step),
|
|
);
|
|
}
|
|
|
|
function hasReusableSourcemapsArtifactContract(content) {
|
|
return (
|
|
/^\s+sourcemaps-artifact:\s*$/m.test(content) &&
|
|
/uses:\s*actions\/download-artifact@v4/i.test(content) &&
|
|
/^\s*if:\s*\$\{\{\s*inputs\.sourcemaps-artifact\s*!=\s*'none'\s*\}\}\s*$/m.test(content) &&
|
|
/^\s*name:\s*\$\{\{\s*inputs\.sourcemaps-artifact\s*\}\}\s*$/m.test(content) &&
|
|
/^\s*path:\s*\$\{\{\s*inputs\.sourcemaps-path\s*\}\}\s*$/m.test(content)
|
|
);
|
|
}
|
|
|
|
function getRequiredStepSofteningReason(step) {
|
|
if (!step) return "";
|
|
if (/^\s*continue-on-error:\s*true\s*$/im.test(step) || /^\s*if:\s*(?:always\(\)|\$\{\{\s*always\(\)\s*\}\})\s*$/im.test(step)) {
|
|
return "continue-on-error or always()";
|
|
}
|
|
|
|
const lines = getExecutableShellLines(getRunTextFromStep(step));
|
|
const block = lines.join("\n");
|
|
const lastLine = lines.at(-1) ?? "";
|
|
if (/\bset\s+(?:\+e|\+o\s+errexit)\b/.test(block)) return "shell success masking";
|
|
if (/(^|[^|])\|\|\s*(?:true|:|exit\s+0)(?:\s|$)/.test(block)) return "shell success masking";
|
|
if (/;\s*(?:true|:|exit\s+0)\s*$/.test(lastLine)) return "shell success masking";
|
|
if (/^(?:true|:|exit\s+0)$/.test(lastLine)) return "shell success masking";
|
|
|
|
return "";
|
|
}
|
|
|
|
function getRequiredCommandSofteningReason(section, command) {
|
|
const step = getStepForRunCommand(section, (line) => line.includes(command));
|
|
return getRequiredStepSofteningReason(step);
|
|
}
|
|
|
|
function hasStepEnvValue(section, command, envName, envValue) {
|
|
const commandIndex = section.indexOf(`run: ${command}`);
|
|
if (commandIndex === -1) return false;
|
|
const afterCommand = section.slice(commandIndex);
|
|
const nextStep = afterCommand.slice(1).search(/\n\s*-\s+name:/);
|
|
const step = nextStep === -1 ? afterCommand : afterCommand.slice(0, nextStep + 1);
|
|
const escaped = envValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(`^\\s*${envName}:\\s*${escaped}\\s*$`, "m").test(step);
|
|
}
|
|
|
|
function hasJobLevelEnvValue(section, envName) {
|
|
const stepsIndex = section.search(/^ steps:\s*$/m);
|
|
const beforeSteps = stepsIndex === -1 ? section : section.slice(0, stepsIndex);
|
|
return new RegExp(`^\\s*${envName}:\\s*`, "m").test(beforeSteps);
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function getActionInputText(step, inputName) {
|
|
const lines = step.split(/\r?\n/);
|
|
const inputPattern = new RegExp(`^(\\s*)${escapeRegExp(inputName)}:\\s*(.*)$`);
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const match = lines[index].match(inputPattern);
|
|
if (!match) continue;
|
|
|
|
const inputIndent = match[1].length;
|
|
const value = match[2].trim();
|
|
if (!/^[|>][+-]?$/.test(value)) return value.replace(/^['"]|['"]$/g, "");
|
|
|
|
const values = [];
|
|
for (let inner = index + 1; inner < lines.length; inner += 1) {
|
|
const candidate = lines[inner];
|
|
const indent = candidate.match(/^(\s*)/)?.[1].length ?? 0;
|
|
if (candidate.trim().length > 0 && indent <= inputIndent) break;
|
|
const stripped = candidate.trim();
|
|
if (stripped) values.push(stripped.replace(/^[-\s]+/, ""));
|
|
}
|
|
return values.join("\n");
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
function getDockerBuildActionStep(section, target) {
|
|
return (
|
|
getAllStepBlocks(section).find((step) => {
|
|
return /uses:\s*docker\/build-push-action@v\d+/i.test(step) && getActionInputText(step, "target") === target;
|
|
}) ?? ""
|
|
);
|
|
}
|
|
|
|
function hasActionInputValue(step, inputName, expectedValue) {
|
|
return getActionInputText(step, inputName) === expectedValue;
|
|
}
|
|
|
|
function actionInputIncludesLine(step, inputName, expectedLine) {
|
|
return getActionInputText(step, inputName)
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.includes(expectedLine);
|
|
}
|
|
|
|
function cacheInputIncludesToken(step, inputName, token) {
|
|
const value = getActionInputText(step, inputName);
|
|
return new RegExp(`(?:^|[\\s,])${escapeRegExp(token)}(?:$|[\\s,])`).test(value);
|
|
}
|
|
|
|
function cliCommandIncludesToken(command, token) {
|
|
return new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?:\\s|$)`).test(command);
|
|
}
|
|
|
|
function validateDockerBuildActionStep(step, target, contract) {
|
|
const failures = [];
|
|
if (!hasActionInputValue(step, "context", ".")) {
|
|
failures.push(`build for target "${target}" must use Docker context "."`);
|
|
}
|
|
if (!actionInputIncludesLine(step, "build-args", "APP_REVISION=ci-smoke-revision")) {
|
|
failures.push(`build for target "${target}" must pass build-args: APP_REVISION=ci-smoke-revision`);
|
|
}
|
|
if (!hasActionInputValue(step, "tags", contract.tag)) {
|
|
failures.push(`build for target "${target}" must preserve tag "${contract.tag}"`);
|
|
}
|
|
if (!hasActionInputValue(step, "load", "true")) {
|
|
failures.push(`build for target "${target}" must set load: true for smoke validation`);
|
|
}
|
|
if (!hasActionInputValue(step, "push", "false")) {
|
|
failures.push(`build for target "${target}" must set push: false`);
|
|
}
|
|
if (!cacheInputIncludesToken(step, "cache-from", "type=gha") || !cacheInputIncludesToken(step, "cache-from", `scope=${contract.scope}`)) {
|
|
failures.push(`build for target "${target}" must set cache-from: type=gha with scope=${contract.scope}`);
|
|
}
|
|
if (!cacheInputIncludesToken(step, "cache-to", "type=gha") || !cacheInputIncludesToken(step, "cache-to", "mode=max") || !cacheInputIncludesToken(step, "cache-to", `scope=${contract.scope}`)) {
|
|
failures.push(`build for target "${target}" must set cache-to: type=gha,mode=max with scope=${contract.scope}`);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function validateDockerBuildxCommand(command, target, contract) {
|
|
const failures = [];
|
|
if (!/\bdocker\s+buildx\s+build\b/.test(command)) {
|
|
failures.push(`build for target "${target}" must use Docker Buildx`);
|
|
}
|
|
if (!DOCKER_SMOKE_REVISION_ARG_PATTERN.test(command)) {
|
|
failures.push(`build for target "${target}" must pass --build-arg APP_REVISION=ci-smoke-revision`);
|
|
}
|
|
if (!new RegExp(`(?:^|\\s)(?:--tag|-t)(?:=|\\s+)${escapeRegExp(contract.tag)}(?:\\s|$)`).test(command)) {
|
|
failures.push(`build for target "${target}" must preserve tag "${contract.tag}"`);
|
|
}
|
|
if (!cliCommandIncludesToken(command, "--load")) {
|
|
failures.push(`build for target "${target}" must pass --load for smoke validation`);
|
|
}
|
|
if (!new RegExp(`(?:^|\\s)--cache-from(?:=|\\s+)type=gha(?:,[^\\s]*scope=${escapeRegExp(contract.scope)}[^\\s]*)?(?:\\s|$)`).test(command)) {
|
|
failures.push(`build for target "${target}" must pass --cache-from type=gha with scope=${contract.scope}`);
|
|
}
|
|
if (!new RegExp(`(?:^|\\s)--cache-to(?:=|\\s+)type=gha(?=[^\\s]*(?:,|^)mode=max)(?=[^\\s]*(?:,|^)scope=${escapeRegExp(contract.scope)})(?:[^\\s]*)(?:\\s|$)`).test(command)) {
|
|
failures.push(`build for target "${target}" must pass --cache-to type=gha,mode=max with scope=${contract.scope}`);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function hasSupersededRunConcurrency(content) {
|
|
return (
|
|
/^concurrency:\s*$/m.test(content) &&
|
|
/^\s+group:\s*\$\{\{\s*github\.workflow\s*\}\}-\$\{\{\s*github\.event\.pull_request\.number\s*\|\|\s*github\.ref\s*\}\}\s*$/m.test(content) &&
|
|
/^\s+cancel-in-progress:\s*true\s*$/m.test(content)
|
|
);
|
|
}
|
|
|
|
export function verifyGithubCiWorkflow(rootDir = process.cwd()) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const observations = [];
|
|
|
|
if (!workflowExists(rootDir)) {
|
|
return { passes, failures: [`${WORKFLOW_PATH}: missing required GitHub CI workflow file`], observations };
|
|
}
|
|
|
|
const content = readWorkflow(rootDir);
|
|
const allRunEntries = getExecutableRunCommandEntries(content);
|
|
const forbiddenE2eEntries = allRunEntries.filter((entry) => FORBIDDEN_E2E_PATTERN.test(entry.command));
|
|
if (forbiddenE2eEntries.length > 0) {
|
|
forbiddenE2eEntries.forEach((entry) => failures.push(`${WORKFLOW_PATH}: contains forbidden E2E/browser command: ${entry.line}`));
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: no forbidden E2E/browser commands are present`);
|
|
}
|
|
|
|
for (const trigger of ["push", "pull_request"]) {
|
|
if (new RegExp(`^\\s*${trigger}\\s*:`, "m").test(content)) {
|
|
passes.push(`${WORKFLOW_PATH}: trigger "${trigger}" is defined`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: missing required trigger "${trigger}"`);
|
|
}
|
|
|
|
}
|
|
|
|
if (hasSupersededRunConcurrency(content)) {
|
|
passes.push(`${WORKFLOW_PATH}: CI concurrency cancels superseded pull request and branch runs`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: CI workflow must define concurrency by workflow plus PR/ref with cancel-in-progress: true`);
|
|
}
|
|
|
|
const qualitySection = getJobSection(content, "quality");
|
|
if (!qualitySection) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required quality job "quality"`);
|
|
} else {
|
|
if (hasCorepackSetup(qualitySection)) passes.push(`${WORKFLOW_PATH}: quality job enables Corepack`);
|
|
else failures.push(`${WORKFLOW_PATH}: quality job missing Corepack setup`);
|
|
if (hasPinnedPnpm(qualitySection)) passes.push(`${WORKFLOW_PATH}: quality job pins pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
else failures.push(`${WORKFLOW_PATH}: quality job must pin pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
if (hasFrozenInstall(qualitySection)) passes.push(`${WORKFLOW_PATH}: quality job installs with frozen lockfile`);
|
|
else failures.push(`${WORKFLOW_PATH}: quality job missing "pnpm install --frozen-lockfile"`);
|
|
|
|
const commands = getExecutableRunCommandEntries(qualitySection);
|
|
let previousIndex = -1;
|
|
for (const command of REQUIRED_COMMANDS_IN_ORDER) {
|
|
const commandIndex = commands.findIndex((entry) => matchesExecutableCommand(entry.command, command));
|
|
const commandOccurrences = countExecutableCommandOccurrences(qualitySection, command);
|
|
const softeningReason = getRequiredCommandSofteningReason(qualitySection, command);
|
|
if (commandIndex === -1) {
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: quality command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job missing required command "${command}"`);
|
|
}
|
|
} else if (commandOccurrences > 1) {
|
|
failures.push(`${WORKFLOW_PATH}: quality command "${command}" must run exactly once to avoid redundant CI gates`);
|
|
} else if (commandIndex < previousIndex) {
|
|
failures.push(`${WORKFLOW_PATH}: quality command "${command}" must run after previous gates`);
|
|
} else if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: quality command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: quality command "${command}" is ordered`);
|
|
previousIndex = commandIndex;
|
|
}
|
|
}
|
|
|
|
const sourcemapsValidationStep = getStepForRunCommand(qualitySection, (line) => line === WEB_SOURCEMAPS_VALIDATE_COMMAND);
|
|
if (sourcemapsValidationStep) {
|
|
const softeningReason = getRequiredStepSofteningReason(sourcemapsValidationStep);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: quality web source-map validation must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: quality job validates web source-map files before Sentry artifact upload`);
|
|
}
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job must validate web source-map files before Sentry artifact upload`);
|
|
}
|
|
|
|
if (hasUploadSourcemapsArtifactStep(qualitySection)) {
|
|
passes.push(`${WORKFLOW_PATH}: quality job uploads web source maps as artifact "${WEB_SOURCEMAPS_ARTIFACT_NAME}"`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job must upload ${WEB_SOURCEMAPS_PATH} as artifact "${WEB_SOURCEMAPS_ARTIFACT_NAME}" with if-no-files-found: error`);
|
|
}
|
|
}
|
|
|
|
for (const [jobName, commands] of Object.entries(SERVICE_JOB_CONTRACTS)) {
|
|
const section = getJobSection(content, jobName);
|
|
if (!section) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required service job "${jobName}"`);
|
|
continue;
|
|
}
|
|
passes.push(`${WORKFLOW_PATH}: service job "${jobName}" is defined`);
|
|
if (hasNeeds(section, "quality")) failures.push(`${WORKFLOW_PATH}: service job "${jobName}" must not need "quality" so PR CI can run it in parallel`);
|
|
else passes.push(`${WORKFLOW_PATH}: service job "${jobName}" does not need "quality"`);
|
|
if (hasCheckoutStep(section)) passes.push(`${WORKFLOW_PATH}: service job "${jobName}" checks out the repository`);
|
|
else failures.push(`${WORKFLOW_PATH}: service job "${jobName}" missing checkout step`);
|
|
if (hasCorepackSetup(section)) passes.push(`${WORKFLOW_PATH}: service job "${jobName}" enables Corepack`);
|
|
else failures.push(`${WORKFLOW_PATH}: service job "${jobName}" missing Corepack setup`);
|
|
if (hasPinnedPnpm(section)) passes.push(`${WORKFLOW_PATH}: service job "${jobName}" pins pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
else failures.push(`${WORKFLOW_PATH}: service job "${jobName}" must pin pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
if (hasFrozenInstall(section)) passes.push(`${WORKFLOW_PATH}: service job "${jobName}" installs with frozen lockfile`);
|
|
else failures.push(`${WORKFLOW_PATH}: service job "${jobName}" missing "pnpm install --frozen-lockfile"`);
|
|
|
|
const jobCommands = getExecutableRunCommandEntries(section);
|
|
for (const command of commands) {
|
|
if (jobCommands.some((entry) => matchesExecutableCommand(entry.command, command))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, command);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: service job "${jobName}" command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: service job "${jobName}" includes command "${command}"`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, command);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: service job "${jobName}" command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: service job "${jobName}" missing command "${command}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const databaseSection = getJobSection(content, DATABASE_JOB);
|
|
if (!databaseSection) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required database job "${DATABASE_JOB}"`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" is defined`);
|
|
if (hasNeeds(databaseSection, "quality")) failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must not need "quality" so PR CI can run it in parallel`);
|
|
else passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" does not need "quality"`);
|
|
if (hasCheckoutStep(databaseSection)) passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" checks out the repository`);
|
|
else failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing checkout step`);
|
|
if (hasCorepackSetup(databaseSection)) passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" enables Corepack`);
|
|
else failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing Corepack setup`);
|
|
if (hasPinnedPnpm(databaseSection)) passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" pins pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
else failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must pin pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
if (hasFrozenInstall(databaseSection)) passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" installs with frozen lockfile`);
|
|
else failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing "pnpm install --frozen-lockfile"`);
|
|
if (getExecutableRunCommandEntries(databaseSection).some((entry) => matchesExecutableCommand(entry.command, DATABASE_STATUS_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(databaseSection, DATABASE_STATUS_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_STATUS_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" includes command "${DATABASE_STATUS_COMMAND}"`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(databaseSection, DATABASE_STATUS_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_STATUS_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing command "${DATABASE_STATUS_COMMAND}"`);
|
|
}
|
|
}
|
|
if (getExecutableRunCommandEntries(databaseSection).some((entry) => matchesExecutableCommand(entry.command, DATABASE_GENERATE_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(databaseSection, DATABASE_GENERATE_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_GENERATE_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" includes command "${DATABASE_GENERATE_COMMAND}"`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(databaseSection, DATABASE_GENERATE_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_GENERATE_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing command "${DATABASE_GENERATE_COMMAND}"`);
|
|
}
|
|
}
|
|
if (hasStepEnvValue(databaseSection, DATABASE_STATUS_COMMAND, "DATABASE_URL", DATABASE_URL_SECRET)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" scopes DATABASE_URL to the migrate-status step`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set DATABASE_URL: ${DATABASE_URL_SECRET} on the migrate-status step`);
|
|
}
|
|
if (hasStepEnvValue(databaseSection, DATABASE_GENERATE_COMMAND, "DATABASE_URL", DATABASE_URL_DUMMY)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" gives db:generate a deterministic dummy DATABASE_URL`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set DATABASE_URL: ${DATABASE_URL_DUMMY} on the db:generate step`);
|
|
}
|
|
if (hasJobLevelEnvValue(databaseSection, "DATABASE_URL")) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must not set DATABASE_URL at job scope`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" does not expose DATABASE_URL at job scope`);
|
|
}
|
|
for (const entry of getExecutableRunCommandEntries(databaseSection)) {
|
|
if (FORBIDDEN_DATABASE_MUTATION_PATTERN.test(entry.command)) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" contains forbidden mutation command: ${entry.line}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const dockerSection = getJobSection(content, DOCKER_SMOKE_JOB);
|
|
if (!dockerSection) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required Docker smoke job "${DOCKER_SMOKE_JOB}"`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" is defined`);
|
|
for (const need of ["quality", "api", "worker", "database"]) {
|
|
if (hasNeeds(dockerSection, need)) passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" needs "${need}"`);
|
|
else failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must declare needs including "${need}"`);
|
|
}
|
|
const dockerCommands = getExecutableRunCommandEntries(dockerSection);
|
|
if (hasCheckoutStep(dockerSection)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" checks out the repository`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing checkout step`);
|
|
}
|
|
if (hasCorepackSetup(dockerSection)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must not run Corepack setup`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" does not run Corepack setup`);
|
|
}
|
|
if (hasPinnedPnpm(dockerSection)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must not run "corepack prepare pnpm@${REQUIRED_PNPM_VERSION}"`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" does not run "corepack prepare pnpm@${REQUIRED_PNPM_VERSION}"`);
|
|
}
|
|
if (hasPnpmStoreSetup(dockerSection)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must not configure or cache the pnpm store`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" does not configure or cache the pnpm store`);
|
|
}
|
|
if (hasFrozenInstall(dockerSection)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must not run "pnpm install --frozen-lockfile"`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" does not run "pnpm install --frozen-lockfile"`);
|
|
}
|
|
if (/docker\/setup-buildx-action@v3/.test(dockerSection)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" sets up Docker Buildx`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must set up Docker Buildx`);
|
|
}
|
|
for (const [target, contract] of Object.entries(DOCKER_SMOKE_TARGET_CONTRACTS)) {
|
|
const targetBuildPattern = new RegExp(`\\bdocker\\s+(?:build|buildx\\s+build)\\b[^\\n]*--target\\s+${target}\\b`);
|
|
const targetBuild = dockerCommands.find((entry) => targetBuildPattern.test(entry.command));
|
|
const buildActionStep = getDockerBuildActionStep(dockerSection, target);
|
|
if (buildActionStep) {
|
|
const softeningReason = getRequiredStepSofteningReason(buildActionStep);
|
|
const actionFailures = validateDockerBuildActionStep(buildActionStep, target, contract);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" build for target "${target}" must not use ${softeningReason}`);
|
|
} else if (actionFailures.length > 0) {
|
|
for (const failure of actionFailures) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" ${failure}`);
|
|
}
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" builds Docker target "${target}"`);
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" loads Docker target "${target}" for smoke validation`);
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" uses GitHub Actions cache for Docker target "${target}"`);
|
|
}
|
|
} else if (targetBuild) {
|
|
const softeningReason = getRequiredStepSofteningReason(getStepForRunCommand(dockerSection, (line) => targetBuildPattern.test(line)));
|
|
const commandFailures = validateDockerBuildxCommand(targetBuild.command, target, contract);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" build for target "${target}" must not use ${softeningReason}`);
|
|
} else if (commandFailures.length > 0) {
|
|
for (const failure of commandFailures) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" ${failure}`);
|
|
}
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" builds Docker target "${target}"`);
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" loads Docker target "${target}" for smoke validation`);
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" uses GitHub Actions cache for Docker target "${target}"`);
|
|
}
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing Docker build for target "${target}"`);
|
|
}
|
|
}
|
|
if (dockerCommands.some((entry) => matchesExecutableCommand(entry.command, DOCKER_COMPOSE_CONFIG_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_COMPOSE_CONFIG_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_COMPOSE_CONFIG_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates Docker Compose config`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_COMPOSE_CONFIG_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_COMPOSE_CONFIG_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_COMPOSE_CONFIG_COMMAND}"`);
|
|
}
|
|
}
|
|
if (dockerCommands.some((entry) => matchesExecutableCommand(entry.command, DOCKER_WEB_ARTIFACT_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WEB_ARTIFACT_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WEB_ARTIFACT_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates web Docker artifacts`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WEB_ARTIFACT_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WEB_ARTIFACT_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_WEB_ARTIFACT_COMMAND}"`);
|
|
}
|
|
}
|
|
if (dockerCommands.some((entry) => matchesExecutableCommand(entry.command, DOCKER_WEB_RUNTIME_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WEB_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WEB_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates web Docker runtime`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WEB_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WEB_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_WEB_RUNTIME_COMMAND}"`);
|
|
}
|
|
}
|
|
if (dockerCommands.some((entry) => matchesExecutableCommand(entry.command, DOCKER_API_RUNTIME_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_API_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_API_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates API Docker runtime`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_API_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_API_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_API_RUNTIME_COMMAND}"`);
|
|
}
|
|
}
|
|
if (dockerCommands.some((entry) => matchesExecutableCommand(entry.command, DOCKER_WORKER_RUNTIME_COMMAND))) {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WORKER_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WORKER_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates worker Docker runtime`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(dockerSection, DOCKER_WORKER_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_WORKER_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_WORKER_RUNTIME_COMMAND}"`);
|
|
}
|
|
}
|
|
for (const entry of dockerCommands) {
|
|
if (FORBIDDEN_DOCKER_PUBLISH_PATTERN.test(entry.command)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" contains forbidden publish command: ${entry.line}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sentryReusableContent = readOptionalFile(rootDir, SENTRY_REUSABLE_WORKFLOW_PATH);
|
|
if (!sentryReusableContent) {
|
|
failures.push(`${SENTRY_REUSABLE_WORKFLOW_PATH}: missing reusable Sentry release workflow`);
|
|
} else if (hasReusableSourcemapsArtifactContract(sentryReusableContent)) {
|
|
passes.push(`${SENTRY_REUSABLE_WORKFLOW_PATH}: reusable workflow downloads requested source-map artifacts before upload`);
|
|
} else {
|
|
failures.push(`${SENTRY_REUSABLE_WORKFLOW_PATH}: reusable workflow must accept sourcemaps-artifact and download it to inputs.sourcemaps-path`);
|
|
}
|
|
|
|
const sentryWebSection = getJobSection(content, "sentry-release-web");
|
|
if (!sentryWebSection) {
|
|
failures.push(`${WORKFLOW_PATH}: missing Sentry web release job "sentry-release-web"`);
|
|
} else if (
|
|
new RegExp(`^\\s*sourcemaps-path:\\s*${escapeRegExp(WEB_SOURCEMAPS_PATH)}\\s*$`, "m").test(sentryWebSection) &&
|
|
new RegExp(`^\\s*sourcemaps-artifact:\\s*${escapeRegExp(WEB_SOURCEMAPS_ARTIFACT_NAME)}\\s*$`, "m").test(sentryWebSection)
|
|
) {
|
|
passes.push(`${WORKFLOW_PATH}: Sentry web release consumes artifact "${WEB_SOURCEMAPS_ARTIFACT_NAME}"`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Sentry web release must pass sourcemaps-path ${WEB_SOURCEMAPS_PATH} and sourcemaps-artifact ${WEB_SOURCEMAPS_ARTIFACT_NAME}`);
|
|
}
|
|
|
|
return { passes, failures, observations };
|
|
}
|
|
|
|
export function formatGithubCiWorkflowResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} GitHub CI workflow verification`,
|
|
...result.passes.map((pass) => `PASS ${pass}`),
|
|
...result.observations.map((observation) => `OBSERVE ${observation}`),
|
|
...result.failures.map((failure) => `FAIL ${failure}`),
|
|
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = verifyGithubCiWorkflow(process.cwd());
|
|
const output = formatGithubCiWorkflowResult(result);
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|