1701 lines
73 KiB
JavaScript
1701 lines
73 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
CI_RUNNER_LABEL,
|
|
dockerImageTag,
|
|
HOST_OVERRIDE_VALIDATION_PATTERN as INFRA_HOST_OVERRIDE_VALIDATION_PATTERN,
|
|
HOST_OVERRIDE_WRITE_PATTERN as INFRA_HOST_OVERRIDE_WRITE_PATTERN,
|
|
} from "./infra-config.mjs";
|
|
|
|
const WORKFLOW_PATH = ".gitea/workflows/ci.yml";
|
|
const SENTRY_REUSABLE_WORKFLOW_PATH = ".gitea/workflows/_sentry-release.yml";
|
|
const REQUIRED_PNPM_VERSION = "11.12.0";
|
|
const QUALITY_GROUP_RUNNER_PATH = "scripts/ci/run-quality-groups.sh";
|
|
const QUALITY_GROUP_RUNNER_COMMAND = `bash ${QUALITY_GROUP_RUNNER_PATH}`;
|
|
const WEB_SOURCEMAPS_ARTIFACT_NAME = "web-client-sourcemaps";
|
|
const WEB_SOURCEMAPS_PATH = "apps/web/build-sourcemaps";
|
|
const WEB_SOURCEMAPS_VALIDATE_COMMAND =
|
|
"test -n \"$(find apps/web/build-sourcemaps/core -type f -name '*.map' -print -quit)\" && test -n \"$(find apps/web/build-sourcemaps/portal -type f -name '*.map' -print -quit)\"";
|
|
const REQUIRED_COMMANDS_IN_ORDER = [
|
|
"pnpm run verify:gitea-ci-workflow",
|
|
"pnpm run verify:deploy-workflow",
|
|
"pnpm run verify:deploy-readiness",
|
|
"pnpm run verify:licenses",
|
|
"pnpm audit --prod --audit-level high",
|
|
"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:docs-links",
|
|
QUALITY_GROUP_RUNNER_COMMAND,
|
|
"pnpm run build:ci",
|
|
"pnpm run verify:runtime-closures -- --runtime=web",
|
|
"pnpm run verify:production-seed-artifact",
|
|
];
|
|
// The former monolithic "quality" job is split into parallel jobs to cut CI wallclock.
|
|
// The static-verify gate is itself split into two jobs (verify-1 + verify-2) so the heavy
|
|
// UI/a11y checks run side by side instead of serially. Every command in
|
|
// REQUIRED_COMMANDS_IN_ORDER must still run exactly once across these jobs, and the required
|
|
// relative order is preserved WITHIN each owning job (cross-job order no longer matters
|
|
// because the jobs run in parallel). The two heavy UI checks (ui-visual + hot-swap) live in
|
|
// verify-1, the two heavy accessibility checks (a11y-coverage + a11y smoke) in verify-2.
|
|
const QUALITY_SPLIT_JOBS = ["verify-1", "verify-2", "typecheck-test", "build"];
|
|
const QUALITY_JOB_BY_COMMAND = {
|
|
"pnpm run verify:gitea-ci-workflow": "verify-1",
|
|
"pnpm run verify:deploy-workflow": "verify-1",
|
|
"pnpm run verify:deploy-readiness": "verify-1",
|
|
"pnpm run verify:licenses": "verify-1",
|
|
"pnpm audit --prod --audit-level high": "verify-1",
|
|
"pnpm run verify:ci-baseline": "verify-1",
|
|
"pnpm run verify:source-health": "verify-1",
|
|
"pnpm run verify:eslint-baseline": "verify-1",
|
|
"pnpm run verify:env-drift": "verify-1",
|
|
"pnpm run verify:health-baseline": "verify-1",
|
|
"pnpm run verify:api-inventory": "verify-1",
|
|
"pnpm run verify:tenant-isolation-report": "verify-2",
|
|
"pnpm run verify:sidebar-routes": "verify-2",
|
|
"pnpm run verify:i18n": "verify-2",
|
|
"pnpm run verify:tenant-auth-baseline": "verify-2",
|
|
"pnpm run verify:onboarding-flow": "verify-2",
|
|
"pnpm run verify:phase-c-boundaries": "verify-2",
|
|
"pnpm run verify:core-build-without-plugins-smoke": "build",
|
|
"pnpm run verify:removed-modules": "verify-2",
|
|
"pnpm run verify:ui-visual": "verify-1",
|
|
"pnpm run test:hot-swap": "verify-1",
|
|
"pnpm run verify:a11y-coverage": "verify-2",
|
|
"pnpm run test:a11y": "verify-2",
|
|
"pnpm run verify:docs-links": "verify-2",
|
|
[QUALITY_GROUP_RUNNER_COMMAND]: "typecheck-test",
|
|
"pnpm run build:ci": "build",
|
|
"pnpm run verify:runtime-closures -- --runtime=web": "build",
|
|
"pnpm run verify:production-seed-artifact": "build",
|
|
};
|
|
const SERVICE_JOB_CONTRACTS = {
|
|
api: {
|
|
label: "api",
|
|
commands: [
|
|
"pnpm run verify:openapi-security",
|
|
"pnpm --filter @hivegrid/api typecheck",
|
|
"pnpm exec vitest run apps/api/src",
|
|
"pnpm --filter @hivegrid/api build",
|
|
"pnpm run verify:runtime-closures -- --runtime=api",
|
|
],
|
|
},
|
|
mcp: {
|
|
label: "mcp",
|
|
commands: [
|
|
"pnpm --filter @hivegrid/mcp typecheck",
|
|
"pnpm --filter @hivegrid/mcp test",
|
|
"pnpm --filter @hivegrid/mcp build",
|
|
"pnpm run verify:runtime-closures -- --runtime=mcp",
|
|
],
|
|
},
|
|
hocuspocus: {
|
|
label: "hocuspocus",
|
|
commands: [
|
|
"pnpm --filter @hivegrid/hocuspocus typecheck",
|
|
"pnpm --filter @hivegrid/hocuspocus test",
|
|
"pnpm --filter @hivegrid/hocuspocus build",
|
|
"pnpm run verify:runtime-closures -- --runtime=hocuspocus",
|
|
],
|
|
},
|
|
worker: {
|
|
label: "worker",
|
|
commands: [
|
|
"pnpm --filter @hivegrid/worker typecheck",
|
|
"pnpm --filter @hivegrid/worker exec vitest run",
|
|
"pnpm run verify:workflow-outbox-native",
|
|
"pnpm --filter @hivegrid/worker build",
|
|
"pnpm run verify:runtime-closures -- --runtime=worker",
|
|
"pnpm --filter @hivegrid/worker health",
|
|
],
|
|
},
|
|
};
|
|
const DATABASE_JOB = "database";
|
|
const DATABASE_STATUS_COMMAND = "pnpm run verify:db-migrate-status:staging";
|
|
const DATABASE_GENERATE_COMMAND = "pnpm run db:generate";
|
|
const DATABASE_PARITY_COMMAND = "pnpm run verify:migrations-parity";
|
|
const DATABASE_BASELINE_EQUIVALENCE_COMMAND = "pnpm run verify:prisma-baseline-equivalence";
|
|
const DATABASE_TENANT_POLICY_COMMAND = "pnpm run verify:tenant-policy-contract";
|
|
const DATABASE_RLS_READINESS_COMMAND = "pnpm run verify:rls-release-readiness";
|
|
const DATABASE_RLS_LIVE_COMMAND = "pnpm run verify:rls-live";
|
|
const DATABASE_RLS_MIGRATION_CHAIN_PARITY_ENV = "HIVEGRID_RLS_REQUIRE_MIGRATION_CHAIN_PARITY";
|
|
const DATABASE_RLS_MIGRATION_CHAIN_PARITY_VALUE = '"1"';
|
|
const STAGING_SSH_HOST_VAR = "${{ vars.COOLIFY_STAGING_SSH_HOST }}";
|
|
const STAGING_SSH_USER_VAR = "${{ vars.COOLIFY_STAGING_SSH_USER }}";
|
|
const STAGING_WEB_RESOURCE_UUID_VAR = "${{ vars.COOLIFY_STAGING_WEB_RESOURCE_UUID }}";
|
|
const STAGING_SSH_KEY_SECRET = "${{ secrets.COOLIFY_STAGING_SSH_KEY }}";
|
|
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 DOCKER_SMOKE_JOB = "docker-smoke";
|
|
const DOCKER_SMOKE_SUITE_PATH = "scripts/ci/docker-smoke-suite.sh";
|
|
const DOCKER_SMOKE_TIMEOUT_RUNNER_PATH = "scripts/lib/run-command-with-timeout.mjs";
|
|
// Empty: docker-smoke intentionally has no needs so it runs in parallel with the quality
|
|
// phase (it builds images from source, no cross-job artifact dependency). See ci.yml.
|
|
const DOCKER_SMOKE_NEEDS = [];
|
|
const DOCKER_SMOKE_ROOT_TARGETS = ["web", "api", "mcp", "worker", "portal-domain-controller"];
|
|
const DOCKER_SMOKE_TARGETS = [...DOCKER_SMOKE_ROOT_TARGETS, "hocuspocus"];
|
|
const DOCKER_WEB_TAG = dockerImageTag("web");
|
|
const DOCKER_API_TAG = dockerImageTag("api");
|
|
const DOCKER_WORKER_TAG = dockerImageTag("worker");
|
|
const DOCKER_MCP_TAG = dockerImageTag("mcp");
|
|
const DOCKER_HOCUSPOCUS_TAG = dockerImageTag("hocuspocus");
|
|
const DOCKER_WEB_ARTIFACT_COMMAND = `docker run --rm --entrypoint /bin/sh ${DOCKER_WEB_TAG} -lc 'test -f /myapp/apps/web/build/server/index.js && test -d /myapp/apps/web/build/client && test -f /myapp/apps/web/build-portal/server/index.js && test -d /myapp/apps/web/build-portal/client && test -f /myapp/build/server-runtime/server.js'`;
|
|
const DOCKER_WEB_RUNTIME_COMMAND = `bash scripts/docker-web-runtime-smoke.sh ${DOCKER_WEB_TAG}`;
|
|
const DOCKER_API_RUNTIME_COMMAND = `bash scripts/docker-api-runtime-smoke.sh ${DOCKER_API_TAG}`;
|
|
const DOCKER_WORKER_RUNTIME_COMMAND = `bash scripts/docker-worker-runtime-smoke.sh ${DOCKER_WORKER_TAG}`;
|
|
const DOCKER_MCP_RUNTIME_COMMAND = `bash scripts/docker-mcp-runtime-smoke.sh ${DOCKER_MCP_TAG}`;
|
|
const DOCKER_HOCUSPOCUS_RUNTIME_COMMAND = `bash scripts/docker-hocuspocus-runtime-smoke.sh ${DOCKER_HOCUSPOCUS_TAG}`;
|
|
const DOCKER_SMOKE_REVISION_ARG_PATTERN = /(?:^|\s)--build-arg(?:=|\s+)APP_REVISION=0123456789abcdef0123456789abcdef01234567(?:\s|$)/;
|
|
const DOCKER_SMOKE_HOCUSPOCUS_ARG_PATTERN = /(?:^|\s)--build-arg(?:=|\s+)VITE_HOCUSPOCUS_URL=wss:\/\/collab\.invalid(?:\s|$)/;
|
|
const DOCKER_SMOKE_FAKE_REVISION = "0123456789abcdef0123456789abcdef01234567";
|
|
const REQUIRED_RUNNER = CI_RUNNER_LABEL;
|
|
const FORBIDDEN_E2E_PATTERN = /\b(playwright|cypress|browser|test:e2e|e2e)\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_GITEA_HOST_OVERRIDE_PATTERN = /\bGITEA_HOST_IP\b|\bvars\.GITEA_HOST_IP\b/;
|
|
const HOST_OVERRIDE_WRITE_PATTERN = INFRA_HOST_OVERRIDE_WRITE_PATTERN;
|
|
const HOST_OVERRIDE_VALIDATION_PATTERN = INFRA_HOST_OVERRIDE_VALIDATION_PATTERN;
|
|
|
|
function readWorkflow(rootDir) {
|
|
return fs.readFileSync(path.join(rootDir, WORKFLOW_PATH), "utf8");
|
|
}
|
|
|
|
function readOptionalFile(rootDir, relativePath) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
return null;
|
|
}
|
|
return fs.readFileSync(absolutePath, "utf8");
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function includesLineMatching(content, pattern) {
|
|
return content.split(/\r?\n/).some((line) => pattern.test(line));
|
|
}
|
|
|
|
function checkWorkflowExists(rootDir) {
|
|
return fs.existsSync(path.join(rootDir, WORKFLOW_PATH));
|
|
}
|
|
|
|
function getJobSection(content, jobName) {
|
|
const lines = content.split(/\r?\n/);
|
|
const jobStartPattern = new RegExp(`^ ${jobName}:\\s*$`);
|
|
const startIndex = lines.findIndex((line) => jobStartPattern.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 hasCheckoutStep(section) {
|
|
return /uses:\s*actions\/checkout@(?:v\d+|[0-9a-f]{40})/.test(section);
|
|
}
|
|
|
|
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 countOccurrences(source, token) {
|
|
return source.split(token).length - 1;
|
|
}
|
|
|
|
function checkWorkflowConcurrency(content) {
|
|
const concurrencyBlock = content.match(/^concurrency:\s*\n((?:^[ \t].*(?:\n|$))*)/mu)?.[1] ?? "";
|
|
if (
|
|
/^\s*group:\s*\$\{\{\s*github\.workflow\s*\}\}-\$\{\{\s*github\.event\.pull_request\.number\s*\|\|\s*github\.ref\s*\}\}\s*$/m.test(concurrencyBlock) &&
|
|
/^\s*cancel-in-progress:\s*true\s*$/m.test(concurrencyBlock)
|
|
) {
|
|
return { passes: [`${WORKFLOW_PATH}: CI concurrency cancels superseded pull request and branch runs`], failures: [], observations: [] };
|
|
}
|
|
return {
|
|
passes: [],
|
|
failures: [`${WORKFLOW_PATH}: CI workflow must define concurrency by workflow plus PR/ref with cancel-in-progress: true`],
|
|
observations: [],
|
|
};
|
|
}
|
|
|
|
function hasNeedsQuality(section) {
|
|
return getNeedsValues(section).includes("quality");
|
|
}
|
|
|
|
function getNeedsValues(section) {
|
|
const inlineNeeds = section.match(/^ needs:\s*\[([^\]]+)\]\s*$/m);
|
|
if (inlineNeeds) {
|
|
return inlineNeeds[1]
|
|
.split(",")
|
|
.map((value) => value.trim().replace(/^['\"]|['\"]$/g, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const singleNeeds = section.match(/^ needs:\s*([A-Za-z0-9_-]+)\s*$/m);
|
|
if (singleNeeds) {
|
|
return [singleNeeds[1]];
|
|
}
|
|
|
|
const blockNeeds = section.match(/^ needs:\s*\n((?: -\s*[^\n]+\n?)+)/m);
|
|
if (blockNeeds) {
|
|
return blockNeeds[1]
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^ -\s*([^\s#]+)/)?.[1]?.replace(/^['\"]|['\"]$/g, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function hasDockerBuildxSetupAction(section) {
|
|
return /uses:\s*docker\/setup-buildx-action@(?:v\d+|[0-9a-f]{40})/.test(section);
|
|
}
|
|
|
|
function getRunCommands(section) {
|
|
const lines = section.split(/\r?\n/);
|
|
const commands = [];
|
|
|
|
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();
|
|
const lineNumber = index + 1;
|
|
|
|
if (/^[|>][+-]?$/.test(value)) {
|
|
const blockLines = [];
|
|
for (let blockIndex = index + 1; blockIndex < lines.length; blockIndex += 1) {
|
|
const blockLine = lines[blockIndex];
|
|
const blockIndent = blockLine.match(/^(\s*)/)?.[1].length ?? 0;
|
|
|
|
if (blockLine.trim() !== "" && blockIndent <= runIndent) {
|
|
break;
|
|
}
|
|
|
|
blockLines.push(blockLine.trim());
|
|
index = blockIndex;
|
|
}
|
|
|
|
commands.push({ lineNumber, command: blockLines.join("\n") });
|
|
continue;
|
|
}
|
|
|
|
commands.push({ lineNumber, command: value });
|
|
}
|
|
|
|
return commands;
|
|
}
|
|
|
|
function stripExecutableShellLine(line) {
|
|
return line.replace(/\s+#.*$/, "").trim();
|
|
}
|
|
|
|
function getExecutableRunCommandEntries(section) {
|
|
return getRunCommands(section).flatMap(({ lineNumber, command }) =>
|
|
command
|
|
.split(/\r?\n/)
|
|
.map((line, offset) => ({ lineNumber: lineNumber + offset, command: stripExecutableShellLine(line) }))
|
|
.filter(({ command }) => command.length > 0 && !command.startsWith("#"))
|
|
);
|
|
}
|
|
|
|
function getExecutableRunCommandBlocks(section) {
|
|
return getRunCommands(section)
|
|
.map(({ command }) =>
|
|
command
|
|
.split(/\r?\n/)
|
|
.map((line) => stripExecutableShellLine(line))
|
|
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
.join("\n")
|
|
)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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 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@(?:v[34]|[0-9a-f]{40})/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@(?:v[34]|[0-9a-f]{40})/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 matchesExecutableCommand(executableCommand, expectedCommand) {
|
|
return executableCommand === expectedCommand;
|
|
}
|
|
|
|
function countExecutableCommandOccurrences(section, expectedCommand) {
|
|
return getExecutableRunCommandEntries(section).filter(({ command }) => command === expectedCommand).length;
|
|
}
|
|
|
|
function hasDockerTargetBuild(section, target) {
|
|
return getExecutableRunCommandEntries(section).some(
|
|
({ command }) => /\bdocker\s+(?:buildx\s+build|build)\b/.test(command) && new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(command)
|
|
);
|
|
}
|
|
|
|
function getDockerTargetBuild(section, target) {
|
|
return getExecutableRunCommandEntries(section).find(
|
|
({ command }) => /\bdocker\s+(?:buildx\s+build|build)\b/.test(command) && new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(command)
|
|
)?.command;
|
|
}
|
|
|
|
function hasDockerComposeConfig(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => /\bdocker\s+compose\s+config\b/.test(command));
|
|
}
|
|
|
|
function hasWebDockerArtifactValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_WEB_ARTIFACT_COMMAND);
|
|
}
|
|
|
|
function hasWebDockerRuntimeValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_WEB_RUNTIME_COMMAND);
|
|
}
|
|
|
|
function hasApiDockerRuntimeValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_API_RUNTIME_COMMAND);
|
|
}
|
|
|
|
function hasWorkerDockerRuntimeValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_WORKER_RUNTIME_COMMAND);
|
|
}
|
|
|
|
function hasMcpDockerRuntimeValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_MCP_RUNTIME_COMMAND);
|
|
}
|
|
|
|
function hasHocuspocusDockerRuntimeValidation(section) {
|
|
return getExecutableRunCommandEntries(section).some(({ command }) => command === DOCKER_HOCUSPOCUS_RUNTIME_COMMAND);
|
|
}
|
|
|
|
function hasSerializedRootDockerTargetBuildsWithParallelHocuspocus(section) {
|
|
return getRunCommands(section).some(({ command }) => {
|
|
const lines = command
|
|
.split(/\r?\n/u)
|
|
.map((line) => stripExecutableShellLine(line))
|
|
.filter(Boolean);
|
|
const rootBuildIndexes = DOCKER_SMOKE_ROOT_TARGETS.map((target) =>
|
|
lines.findIndex(
|
|
(line) =>
|
|
/\bdocker\s+(?:buildx\s+build|build)\b/u.test(line) &&
|
|
new RegExp(`(?:^|\\s)--target(?:=|\\s+)${escapeRegExp(target)}(?:\\s|$)`, "u").test(line)
|
|
)
|
|
);
|
|
if (rootBuildIndexes.some((index) => index === -1)) return false;
|
|
if (rootBuildIndexes.some((index, position) => position > 0 && index <= rootBuildIndexes[position - 1])) return false;
|
|
|
|
const firstRootBuild = rootBuildIndexes[0];
|
|
const lastRootBuild = rootBuildIndexes.at(-1);
|
|
const rootGroupOpen = lines.lastIndexOf("(", firstRootBuild);
|
|
const rootGroupClose = lines.indexOf(") &", lastRootBuild + 1);
|
|
const rootBuildsAreSerialized = rootBuildIndexes.every((index) => !/&\s*$/u.test(lines[index]));
|
|
const rootGroupIsFailFast = lines.slice(rootGroupOpen + 1, firstRootBuild).includes("set -euo pipefail");
|
|
|
|
const hocuspocusBuildIndex = lines.findIndex(
|
|
(line) =>
|
|
/\bdocker\s+(?:buildx\s+build|build)\b/u.test(line) &&
|
|
/(?:^|\s)--target(?:=|\s+)hocuspocus(?:\s|$)/u.test(line) &&
|
|
/&\s*$/u.test(line)
|
|
);
|
|
const rootPidCaptureIndex = lines.indexOf("root_targets_pid=$!");
|
|
const hocuspocusPidCaptureIndex = lines.indexOf("hocuspocus_pid=$!");
|
|
const statusInitializationIndex = lines.indexOf("status=0");
|
|
const rootWaitIndex = lines.indexOf('wait "$root_targets_pid" || status=$?');
|
|
const hocuspocusWaitIndex = lines.indexOf('wait "$hocuspocus_pid" || status=$?');
|
|
const failureExitIndex = lines.indexOf('exit "$status"');
|
|
const capturesAndWaitsInOrder =
|
|
rootPidCaptureIndex === rootGroupClose + 1 &&
|
|
hocuspocusBuildIndex === rootPidCaptureIndex + 1 &&
|
|
hocuspocusPidCaptureIndex === hocuspocusBuildIndex + 1 &&
|
|
statusInitializationIndex === hocuspocusPidCaptureIndex + 1 &&
|
|
rootWaitIndex === statusInitializationIndex + 1 &&
|
|
hocuspocusWaitIndex === rootWaitIndex + 1 &&
|
|
failureExitIndex === hocuspocusWaitIndex + 1 &&
|
|
failureExitIndex === lines.length - 1;
|
|
|
|
return (
|
|
rootGroupOpen !== -1 &&
|
|
rootGroupClose > lastRootBuild &&
|
|
rootBuildsAreSerialized &&
|
|
rootGroupIsFailFast &&
|
|
hocuspocusBuildIndex > rootGroupClose &&
|
|
capturesAndWaitsInOrder
|
|
);
|
|
});
|
|
}
|
|
|
|
function includesCommand(section, command) {
|
|
return getExecutableRunCommandEntries(section).some(({ command: executableCommand }) => matchesExecutableCommand(executableCommand, command));
|
|
}
|
|
|
|
function getRequiredStepSofteningReason(step, { allowedIf = [] } = {}) {
|
|
if (!step) {
|
|
return "";
|
|
}
|
|
|
|
if (/^\s*continue-on-error:\s*true\s*$/im.test(step)) {
|
|
return "continue-on-error or always()";
|
|
}
|
|
const ifValue = step
|
|
.match(/^\s*if:\s*([^\n]+)\s*$/im)?.[1]
|
|
?.trim()
|
|
.replace(/^\$\{\{\s*/, "")
|
|
.replace(/\s*\}\}$/, "")
|
|
.trim();
|
|
if (ifValue && !allowedIf.includes(ifValue)) {
|
|
return "conditional execution";
|
|
}
|
|
|
|
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));
|
|
const allowedIf = command === DATABASE_STATUS_COMMAND ? ["gitea.event_name != 'pull_request'"] : [];
|
|
return getRequiredStepSofteningReason(step, { allowedIf });
|
|
}
|
|
|
|
function getRunCommandLines(section) {
|
|
return getExecutableRunCommandBlocks(section).map((command) => `run: ${command}`);
|
|
}
|
|
|
|
function hasStepLevelEnvValue(section, command, envName, envValue) {
|
|
const lines = section.split(/\r?\n/);
|
|
const commandIndex = lines.findIndex((line) => /^\s*run:\s*/.test(line) && line.includes(command));
|
|
|
|
if (commandIndex === -1) {
|
|
return false;
|
|
}
|
|
|
|
const commandIndent = lines[commandIndex].match(/^(\s*)/)?.[1].length ?? 0;
|
|
const stepIndent = Math.max(commandIndent - 2, 0);
|
|
let nextStepIndex = lines.length;
|
|
|
|
for (let index = commandIndex + 1; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
if (new RegExp(`^\\s{${stepIndent}}-\\s+`).test(line)) {
|
|
nextStepIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const stepRemainder = lines.slice(commandIndex + 1, nextStepIndex).join("\n");
|
|
const escapedEnvValue = envValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return new RegExp(`^\\s*${envName}:\\s*${escapedEnvValue}\\s*$`, "m").test(stepRemainder);
|
|
}
|
|
|
|
function hasJobLevelEnvValue(section, envName) {
|
|
const firstStepIndex = section.search(/^ steps:\s*$/m);
|
|
const beforeSteps = firstStepIndex === -1 ? section : section.slice(0, firstStepIndex);
|
|
return new RegExp(`^\\s*${envName}:\\s*`, "m").test(beforeSteps);
|
|
}
|
|
|
|
export function checkWorkflowTriggers(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (includesLineMatching(content, /^\s*push\s*:/m)) {
|
|
failures.push(`${WORKFLOW_PATH}: trigger "push" is forbidden; main pushes are reserved for the deploy workflow`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: trigger "push" is disabled so deploy is not queued behind CI on main`);
|
|
}
|
|
|
|
if (includesLineMatching(content, /^\s*pull_request\s*:/m)) {
|
|
passes.push(`${WORKFLOW_PATH}: trigger "pull_request" is defined`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: missing required trigger "pull_request"`);
|
|
}
|
|
|
|
if (includesLineMatching(content, /^\s*workflow_dispatch\s*:/m)) {
|
|
passes.push(`${WORKFLOW_PATH}: trigger "workflow_dispatch" is defined`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: missing required trigger "workflow_dispatch" for manual CI runs`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkPnpmSetup(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
// Each split quality job runs its own setup (checkout + corepack + pinned pnpm +
|
|
// frozen install), so every one of them must carry the pnpm bootstrap.
|
|
for (const jobName of QUALITY_SPLIT_JOBS) {
|
|
const section = getJobSection(content, jobName);
|
|
|
|
if (!section) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required quality job "${jobName}"`);
|
|
continue;
|
|
}
|
|
|
|
if (hasCorepackSetup(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: quality job "${jobName}" Corepack setup is present`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job "${jobName}" missing Corepack setup`);
|
|
}
|
|
|
|
if (hasPinnedPnpm(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: quality job "${jobName}" pnpm@${REQUIRED_PNPM_VERSION} is pinned`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job "${jobName}" pnpm must be pinned to pnpm@${REQUIRED_PNPM_VERSION}`);
|
|
}
|
|
|
|
if (hasFrozenInstall(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: quality job "${jobName}" command "pnpm install --frozen-lockfile" is present`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: quality job "${jobName}" missing required command "pnpm install --frozen-lockfile"`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkOrderedQualityGates(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
// Resolve each split job's section once. The required commands are partitioned across
|
|
// these jobs via QUALITY_JOB_BY_COMMAND; missing jobs surface as missing commands here
|
|
// (and are also reported by checkPnpmSetup).
|
|
const sections = {};
|
|
for (const jobName of QUALITY_SPLIT_JOBS) {
|
|
sections[jobName] = getJobSection(content, jobName) ?? "";
|
|
}
|
|
// Duplicate detection is global across the split jobs so a gate cannot run twice in CI.
|
|
const combinedSection = QUALITY_SPLIT_JOBS.map((jobName) => sections[jobName]).join("\n");
|
|
// Order is tracked per owning job: the canonical relative order must hold WITHIN each
|
|
// job. Across jobs the order is irrelevant because they run in parallel.
|
|
const previousIndexByJob = Object.fromEntries(QUALITY_SPLIT_JOBS.map((jobName) => [jobName, -1]));
|
|
|
|
for (const command of REQUIRED_COMMANDS_IN_ORDER) {
|
|
const jobName = QUALITY_JOB_BY_COMMAND[command];
|
|
const section = sections[jobName] ?? "";
|
|
const executableCommands = getExecutableRunCommandEntries(section);
|
|
const commandIndex = executableCommands.findIndex((entry) => matchesExecutableCommand(entry.command, command));
|
|
const commandOccurrences = countExecutableCommandOccurrences(combinedSection, command);
|
|
const softeningReason = getRequiredCommandSofteningReason(section, command);
|
|
|
|
if (commandIndex === -1) {
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: missing required command "${command}"`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (commandOccurrences > 1) {
|
|
failures.push(`${WORKFLOW_PATH}: command "${command}" must run exactly once to avoid redundant CI gates`);
|
|
continue;
|
|
}
|
|
|
|
if (commandIndex < previousIndexByJob[jobName]) {
|
|
failures.push(`${WORKFLOW_PATH}: command "${command}" must run after previous quality gates`);
|
|
continue;
|
|
}
|
|
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: command "${command}" must not use ${softeningReason}`);
|
|
continue;
|
|
}
|
|
|
|
passes.push(`${WORKFLOW_PATH}: command "${command}" is ordered`);
|
|
previousIndexByJob[jobName] = commandIndex;
|
|
}
|
|
|
|
// The web build, source-map validation and artifact upload all live in the "build" job.
|
|
const buildSection = sections.build ?? "";
|
|
const sourcemapsValidationStep = getStepForRunCommand(buildSection, (line) => line === WEB_SOURCEMAPS_VALIDATE_COMMAND);
|
|
if (sourcemapsValidationStep) {
|
|
const softeningReason = getRequiredStepSofteningReason(sourcemapsValidationStep);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: web source-map validation must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: web source-map files are validated before Sentry artifact upload`);
|
|
}
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: missing web source-map validation before Sentry artifact upload`);
|
|
}
|
|
|
|
if (hasUploadSourcemapsArtifactStep(buildSection)) {
|
|
passes.push(`${WORKFLOW_PATH}: web source maps are uploaded as artifact "${WEB_SOURCEMAPS_ARTIFACT_NAME}"`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: must upload ${WEB_SOURCEMAPS_PATH} as artifact "${WEB_SOURCEMAPS_ARTIFACT_NAME}" with if-no-files-found: error`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkQualityGroupRunner(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const source = readOptionalFile(rootDir, QUALITY_GROUP_RUNNER_PATH);
|
|
|
|
if (!source) {
|
|
return { passes, failures: [`${QUALITY_GROUP_RUNNER_PATH}: missing parallel quality group runner`], observations: [] };
|
|
}
|
|
|
|
const prismaPrepareIndex = source.indexOf("pnpm run db:generate");
|
|
const firstGroupStartIndex = source.indexOf('start_group "typecheck-lint" run_typecheck_lint');
|
|
if (prismaPrepareIndex >= 0 && firstGroupStartIndex > prismaPrepareIndex) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: Prisma client generation completes before parallel test readers start`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: must generate the Prisma client before starting parallel quality groups`);
|
|
}
|
|
|
|
const typecheckLintSource = source.match(/run_typecheck_lint\(\)\s*\{([\s\S]*?)^\}/m)?.[1] ?? "";
|
|
const typecheckLintCommands = [
|
|
"pnpm --filter @hivegrid/portal-domain-controller typecheck",
|
|
"pnpm --filter @hivegrid/web typegen",
|
|
"node ../../scripts/typecheck-web.mjs",
|
|
"pnpm lint",
|
|
];
|
|
const typecheckLintIndexes = typecheckLintCommands.map((command) => typecheckLintSource.indexOf(command));
|
|
if (typecheckLintIndexes.every((index) => index >= 0) && typecheckLintIndexes.every((index, position) => position === 0 || index > typecheckLintIndexes[position - 1])) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: typecheck-lint group preserves controller typecheck, generated web typecheck, then lint order`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: typecheck-lint group must run controller typecheck, web typegen/typecheck, and lint in order`);
|
|
}
|
|
|
|
const foundationPackagesSource = source.match(/run_foundation_packages\(\)\s*\{([\s\S]*?)^\}/m)?.[1] ?? "";
|
|
const foundationCommand = "pnpm run test:foundation --maxWorkers=2";
|
|
const packageCommand = "PNPM_CONFIG_WORKSPACE_CONCURRENCY=2 pnpm run test:packages --maxWorkers=1";
|
|
if (
|
|
foundationPackagesSource.indexOf(foundationCommand) >= 0 &&
|
|
foundationPackagesSource.indexOf(packageCommand) > foundationPackagesSource.indexOf(foundationCommand)
|
|
) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: foundation/package tests preserve order with bounded Vitest and workspace concurrency`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: foundation/package tests must keep the exact bounded worker and workspace-concurrency commands`);
|
|
}
|
|
|
|
const coreTestsSource = source.match(/run_core_tests\(\)\s*\{([\s\S]*?)^\}/m)?.[1] ?? "";
|
|
if (/^\s*pnpm run test --maxWorkers=4\s*$/m.test(coreTestsSource)) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: core-tests group runs the canonical suite with four workers`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: core-tests group must run pnpm run test --maxWorkers=4`);
|
|
}
|
|
|
|
const requiredStarts = [
|
|
'start_group "typecheck-lint" run_typecheck_lint',
|
|
'start_group "foundation-packages" run_foundation_packages',
|
|
'start_group "core-tests" run_core_tests',
|
|
];
|
|
const hasExactlyThreeStarts = (source.match(/^start_group\s+/gm) ?? []).length === 3 && requiredStarts.every((line) => source.includes(line));
|
|
const startsInBackground = /start_group\(\)[\s\S]*?\)\s*&[\s\S]*?QUALITY_GROUP_NAMES\+=\("\$name"\)[\s\S]*?QUALITY_GROUP_PIDS\+=\("\$!"\)/.test(source);
|
|
if (hasExactlyThreeStarts && startsInBackground) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: exactly three named quality groups start concurrently with captured PIDs`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: must start exactly three named background groups and capture every PID`);
|
|
}
|
|
|
|
const aggregatesEveryResult =
|
|
/for index in "\$\{!QUALITY_GROUP_PIDS\[@\]\}"; do/.test(source) &&
|
|
/if wait "\$pid"; then/.test(source) &&
|
|
/QUALITY_FAILED=1/.test(source) &&
|
|
/exit "\$QUALITY_FAILED"/.test(source) &&
|
|
/\[quality-groups\] PASS \$name/.test(source) &&
|
|
/\[quality-groups\] FAIL \$name \(exit \$status\)/.test(source);
|
|
if (aggregatesEveryResult) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: every named PID is waited and any group failure propagates after full aggregation`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: must wait every named PID, report its status, and propagate aggregate failure`);
|
|
}
|
|
|
|
if (/^set -euo pipefail$/m.test(source) && /trap terminate_groups HUP INT TERM/.test(source)) {
|
|
passes.push(`${QUALITY_GROUP_RUNNER_PATH}: strict shell mode and bounded child termination remain enabled`);
|
|
} else {
|
|
failures.push(`${QUALITY_GROUP_RUNNER_PATH}: must use strict shell mode and terminate child groups on interruption`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkSentrySourcemapWorkflow(rootDir) {
|
|
const passes = [];
|
|
const failures = [];
|
|
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`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkServiceJobs(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
for (const [jobName, contract] 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 (hasNeedsQuality(section)) {
|
|
failures.push(`${WORKFLOW_PATH}: service job "${jobName}" must not wait for "quality"; scoped service checks should run in parallel`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: service job "${jobName}" runs independently of "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 command "pnpm install --frozen-lockfile"`);
|
|
}
|
|
|
|
for (const command of contract.commands) {
|
|
if (includesCommand(section, 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 scoped 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 scoped command "${command}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDatabaseJob(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const section = getJobSection(content, DATABASE_JOB);
|
|
|
|
if (!section) {
|
|
return {
|
|
passes,
|
|
failures: [`${WORKFLOW_PATH}: missing required database job "${DATABASE_JOB}"`],
|
|
observations: [],
|
|
};
|
|
}
|
|
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" is defined`);
|
|
|
|
if (hasNeedsQuality(section)) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must not wait for "quality"; database verification should run in parallel`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" runs independently of "quality"`);
|
|
}
|
|
|
|
if (hasCheckoutStep(section)) {
|
|
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(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" enables Corepack`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing Corepack setup`);
|
|
}
|
|
|
|
if (hasPinnedPnpm(section)) {
|
|
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(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" installs with frozen lockfile`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing command "pnpm install --frozen-lockfile"`);
|
|
}
|
|
|
|
if (includesCommand(section, DATABASE_GENERATE_COMMAND)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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 (includesCommand(section, DATABASE_PARITY_COMMAND)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DATABASE_PARITY_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_PARITY_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" includes command "${DATABASE_PARITY_COMMAND}"`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DATABASE_PARITY_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${DATABASE_PARITY_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing command "${DATABASE_PARITY_COMMAND}"`);
|
|
}
|
|
}
|
|
|
|
for (const command of [
|
|
DATABASE_TENANT_POLICY_COMMAND,
|
|
DATABASE_RLS_READINESS_COMMAND,
|
|
DATABASE_BASELINE_EQUIVALENCE_COMMAND,
|
|
DATABASE_RLS_LIVE_COMMAND,
|
|
]) {
|
|
if (includesCommand(section, command)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, command);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" includes command "${command}"`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, command);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" command "${command}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" missing command "${command}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
hasStepLevelEnvValue(
|
|
section,
|
|
DATABASE_RLS_LIVE_COMMAND,
|
|
DATABASE_RLS_MIGRATION_CHAIN_PARITY_ENV,
|
|
DATABASE_RLS_MIGRATION_CHAIN_PARITY_VALUE,
|
|
)
|
|
) {
|
|
passes.push(`${WORKFLOW_PATH}: live RLS gate requires strict migration-chain parity`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: live RLS gate must set ${DATABASE_RLS_MIGRATION_CHAIN_PARITY_ENV}: ${DATABASE_RLS_MIGRATION_CHAIN_PARITY_VALUE}`,
|
|
);
|
|
}
|
|
|
|
if (includesCommand(section, DATABASE_STATUS_COMMAND)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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 (hasStepLevelEnvValue(section, DATABASE_STATUS_COMMAND, "COOLIFY_STAGING_SSH_HOST", STAGING_SSH_HOST_VAR)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" scopes staging SSH host to the migrate-status step`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set COOLIFY_STAGING_SSH_HOST: \${{ vars.COOLIFY_STAGING_SSH_HOST }} on the migrate-status step`
|
|
);
|
|
}
|
|
|
|
if (hasStepLevelEnvValue(section, DATABASE_STATUS_COMMAND, "COOLIFY_STAGING_SSH_USER", STAGING_SSH_USER_VAR)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" scopes staging SSH user to the migrate-status step`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set COOLIFY_STAGING_SSH_USER: \${{ vars.COOLIFY_STAGING_SSH_USER }} on the migrate-status step`
|
|
);
|
|
}
|
|
|
|
if (hasStepLevelEnvValue(section, DATABASE_STATUS_COMMAND, "COOLIFY_STAGING_WEB_RESOURCE_UUID", STAGING_WEB_RESOURCE_UUID_VAR)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" scopes staging web resource UUID to the migrate-status step`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set COOLIFY_STAGING_WEB_RESOURCE_UUID: \${{ vars.COOLIFY_STAGING_WEB_RESOURCE_UUID }} on the migrate-status step`
|
|
);
|
|
}
|
|
|
|
if (hasStepLevelEnvValue(section, DATABASE_STATUS_COMMAND, "COOLIFY_STAGING_SSH_KEY", STAGING_SSH_KEY_SECRET)) {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" scopes staging SSH key to the migrate-status step`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must set COOLIFY_STAGING_SSH_KEY: \${{ secrets.COOLIFY_STAGING_SSH_KEY }} on the migrate-status step`
|
|
);
|
|
}
|
|
|
|
if (section.includes("secrets.DATABASE_URL")) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" must not require direct DATABASE_URL access to private Coolify databases`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" verifies private Coolify databases without exposing DATABASE_URL`);
|
|
}
|
|
|
|
if (hasJobLevelEnvValue(section, "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`);
|
|
}
|
|
|
|
const forbiddenMutationLine = getRunCommandLines(section).find((line) => FORBIDDEN_DATABASE_MUTATION_PATTERN.test(line));
|
|
if (forbiddenMutationLine) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" contains forbidden deploy/seed/schema mutation command: ${forbiddenMutationLine.trim()}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" contains no deploy/seed/schema mutation commands`);
|
|
}
|
|
|
|
const forbiddenE2eLine = getRunCommandLines(section).find((line) => FORBIDDEN_E2E_PATTERN.test(line));
|
|
if (forbiddenE2eLine) {
|
|
failures.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" contains forbidden E2E/browser command: ${forbiddenE2eLine.trim()}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: database job "${DATABASE_JOB}" contains no E2E/browser commands`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
function checkIsolatedDockerSmokeSuite(section, suiteSource) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (!/^ concurrency:\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: run-scoped Docker smokes may execute concurrently across refs`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke must not use job-level concurrency after cache and runtime resources are isolated`);
|
|
}
|
|
|
|
if (/^ DOCKER_SMOKE_RUN_ID:\s*\$\{\{\s*gitea\.run_id\s*\}\}-\$\{\{\s*gitea\.run_attempt\s*\}\}\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke resources are scoped by Gitea run and attempt`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke must set DOCKER_SMOKE_RUN_ID from gitea.run_id and gitea.run_attempt`);
|
|
}
|
|
|
|
if (/^ DOCKER_SMOKE_REVISION:\s*\$\{\{\s*gitea\.sha\s*\}\}\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke uses the triggering Git SHA as image revision`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke must set DOCKER_SMOKE_REVISION from gitea.sha`);
|
|
}
|
|
|
|
if (/^ DOCKER_SMOKE_SUITE_TIMEOUT_SECONDS:\s*["']?2520["']?\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke has an authoritative 42-minute suite hard cap`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke must set the suite hard cap to 2520 seconds`);
|
|
}
|
|
|
|
const suiteStep = getStepForRunCommand(section, (line) => line.includes(`${DOCKER_SMOKE_SUITE_PATH} all`));
|
|
const suiteStepText = getRunTextFromStep(suiteStep);
|
|
const suiteSofteningReason = getRequiredStepSofteningReason(suiteStep);
|
|
if (suiteSofteningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke suite must not use ${suiteSofteningReason}`);
|
|
} else if (
|
|
suiteStepText.includes(`node ${DOCKER_SMOKE_TIMEOUT_RUNNER_PATH}`) &&
|
|
suiteStepText.includes("--stage docker-smoke:suite") &&
|
|
suiteStepText.includes('--timeout-seconds "$DOCKER_SMOKE_SUITE_TIMEOUT_SECONDS"') &&
|
|
suiteStepText.includes("--grace-seconds 45") &&
|
|
suiteStepText.includes(`-- bash ${DOCKER_SMOKE_SUITE_PATH} all`)
|
|
) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke suite is guarded by the portable process-group hard timeout`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke must invoke the isolated suite through ${DOCKER_SMOKE_TIMEOUT_RUNNER_PATH}`);
|
|
}
|
|
|
|
if (suiteSource.includes(DOCKER_SMOKE_FAKE_REVISION)) {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: fixed fake image revisions are forbidden`);
|
|
} else if (suiteSource.includes('--build-arg "APP_REVISION=${DOCKER_SMOKE_REVISION}"')) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: every Docker build receives the concrete workflow revision`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: Docker builds must pass DOCKER_SMOKE_REVISION as APP_REVISION`);
|
|
}
|
|
|
|
if (suiteSource.includes('docker compose --env-file .env.test config --quiet')) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: Compose validation uses a non-secret fixture without rendering resolved values`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: Compose validation must use .env.test and --quiet to prevent credential disclosure`);
|
|
}
|
|
|
|
const imageVariables = {
|
|
web: "WEB_IMAGE",
|
|
worker: "WORKER_IMAGE",
|
|
api: "API_IMAGE",
|
|
mcp: "MCP_IMAGE",
|
|
hocuspocus: "HOCUSPOCUS_IMAGE",
|
|
"portal-domain-controller": "PORTAL_CONTROLLER_IMAGE",
|
|
};
|
|
for (const [target, variable] of Object.entries(imageVariables)) {
|
|
const expected = `${variable}="hivegrid-${target}:smoke-\${DOCKER_SMOKE_RUN_ID}"`;
|
|
if (suiteSource.includes(expected)) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: ${target} image tag is run-specific`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: ${target} image tag must include DOCKER_SMOKE_RUN_ID`);
|
|
}
|
|
}
|
|
|
|
if (/hivegrid-(?:web|worker|api|mcp|hocuspocus|portal-domain-controller):smoke(?:["'\s]|$)/u.test(suiteSource)) {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: global :smoke image tags are forbidden`);
|
|
} else {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: no global Docker smoke image tags remain`);
|
|
}
|
|
|
|
if (
|
|
suiteSource.includes('WEB_ARTIFACT_CONTAINER_NAME="hivegrid-web-artifact-smoke-${DOCKER_SMOKE_RUN_ID}"') &&
|
|
suiteSource.includes('docker run --rm --name "$WEB_ARTIFACT_CONTAINER_NAME"') &&
|
|
suiteSource.includes('docker_smoke_cleanup_container "suite:cleanup:web-artifact" "$WEB_ARTIFACT_CONTAINER_NAME"') &&
|
|
suiteSource.includes("docker_smoke_install_cleanup_traps cleanup")
|
|
) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: artifact one-shot is run-scoped and cleanup-addressable`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: web artifact validation must use a run-scoped named container and bounded cleanup trap`);
|
|
}
|
|
|
|
if (suiteSource.includes('export DOCKER_SMOKE_MIGRATOR_IMAGE="$WEB_IMAGE"')) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: service smokes use the run-specific web migrator image`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: service smokes must receive the run-specific web migrator image`);
|
|
}
|
|
|
|
const allBlock = suiteSource.match(/\n all\)\n([\s\S]*?)\n ;;/u)?.[1] ?? "";
|
|
const buildWave = "run_parallel_wave builds \\\n build_web build_worker build_api build_mcp build_hocuspocus build_portal_controller";
|
|
const runtimeWave =
|
|
"run_parallel_wave runtimes \\\n validate_web validate_worker validate_api validate_mcp validate_hocuspocus validate_portal_controller";
|
|
const buildWaveIndex = allBlock.indexOf(buildWave);
|
|
const runtimeWaveIndex = allBlock.indexOf(runtimeWave);
|
|
if (buildWaveIndex >= 0 && runtimeWaveIndex > buildWaveIndex) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: all images build in parallel before the isolated runtime wave`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: all-target mode must complete the six-target build wave before the six-target runtime wave`);
|
|
}
|
|
|
|
const parallelHelper = suiteSource.match(/run_parallel_wave\(\) \{([\s\S]*?)\n\}/u)?.[1] ?? "";
|
|
if (
|
|
parallelHelper.includes('"$task" &') &&
|
|
parallelHelper.includes('pids+=("$!")') &&
|
|
parallelHelper.includes('for index in "${!pids[@]}"; do') &&
|
|
parallelHelper.includes('if wait "${pids[$index]}"; then') &&
|
|
parallelHelper.includes('return "$status"')
|
|
) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: parallel waves collect every child result and propagate failures`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: parallel waves must track, await, and propagate every child result`);
|
|
}
|
|
|
|
for (const target of ["worker", "api", "mcp", "hocuspocus"]) {
|
|
const branch = suiteSource.match(new RegExp(`\\n ${target}\\)\\n([\\s\\S]*?)\\n ;;`, "u"))?.[1] ?? "";
|
|
if (branch.includes("ensure_web_migrator_image") && branch.includes(`build_and_validate_${target}`)) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: ${target} has an exact single-target retry path`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: ${target} must support a single-target retry with the matching migrator image`);
|
|
}
|
|
}
|
|
|
|
const requiredStages = [
|
|
'docker_smoke_run "compose:config"',
|
|
'docker_smoke_run "build:${target}"',
|
|
'docker_smoke_run "build:hocuspocus"',
|
|
'docker_smoke_run "artifacts:web"',
|
|
'docker_smoke_run_with_grace "runtime:web"',
|
|
'docker_smoke_run_with_grace "runtime:worker"',
|
|
'docker_smoke_run_with_grace "runtime:api"',
|
|
'docker_smoke_run_with_grace "runtime:mcp"',
|
|
'docker_smoke_run_with_grace "runtime:hocuspocus"',
|
|
'docker_smoke_run_with_grace "runtime:portal-domain-controller"',
|
|
];
|
|
const missingStages = requiredStages.filter((stage) => !suiteSource.includes(stage));
|
|
if (missingStages.length === 0) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: every build and runtime phase has a command-level timeout`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: missing timeout stages: ${missingStages.join(", ")}`);
|
|
}
|
|
|
|
const runtimeGraceCount = countOccurrences(suiteSource, '"$DOCKER_SMOKE_RUNTIME_GRACE_SECONDS"');
|
|
if (/DOCKER_SMOKE_RUNTIME_GRACE_SECONDS="\$\{DOCKER_SMOKE_RUNTIME_GRACE_SECONDS:-30\}"/u.test(suiteSource) && runtimeGraceCount === 6) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: runtime termination reserves a bounded cleanup grace window`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: every runtime wrapper must reserve the 30-second cleanup grace window`);
|
|
}
|
|
|
|
const requiredRuntimeScripts = [
|
|
"docker-web-runtime-smoke.sh",
|
|
"docker-worker-runtime-smoke.sh",
|
|
"docker-api-runtime-smoke.sh",
|
|
"docker-mcp-runtime-smoke.sh",
|
|
"docker-hocuspocus-runtime-smoke.sh",
|
|
"docker-portal-domain-controller-runtime-smoke.sh",
|
|
];
|
|
const missingRuntimeScripts = requiredRuntimeScripts.filter((name) => !suiteSource.includes(name));
|
|
if (missingRuntimeScripts.length === 0) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: all Docker runtime surfaces remain covered`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: missing runtime smokes: ${missingRuntimeScripts.join(", ")}`);
|
|
}
|
|
|
|
if (suiteSource.indexOf('docker_smoke_run "compose:config"') < suiteSource.lastIndexOf('case "$TARGET" in')) {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: Compose config fails before any image build`);
|
|
} else {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: Compose config must run before image builds`);
|
|
}
|
|
|
|
if (FORBIDDEN_DOCKER_PUBLISH_PATTERN.test(suiteSource)) {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: registry publishing is forbidden in Docker smoke`);
|
|
} else {
|
|
passes.push(`${DOCKER_SMOKE_SUITE_PATH}: no registry login/push/publish commands are present`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkDockerSmokeJob(content, rootDir = process.cwd()) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const section = getJobSection(content, DOCKER_SMOKE_JOB);
|
|
|
|
if (!section) {
|
|
return {
|
|
passes,
|
|
failures: [`${WORKFLOW_PATH}: missing required Docker smoke job "${DOCKER_SMOKE_JOB}"`],
|
|
observations: [],
|
|
};
|
|
}
|
|
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" is defined`);
|
|
|
|
const needs = getNeedsValues(section);
|
|
for (const requiredNeed of DOCKER_SMOKE_NEEDS) {
|
|
if (needs.includes(requiredNeed)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" needs "${requiredNeed}"`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must declare needs including "${requiredNeed}"`);
|
|
}
|
|
}
|
|
|
|
if (hasCheckoutStep(section)) {
|
|
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(section)) {
|
|
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(section)) {
|
|
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(section)) {
|
|
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(section)) {
|
|
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 (hasDockerBuildxSetupAction(section)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must not use docker/setup-buildx-action`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" does not create an unused Buildx builder`);
|
|
}
|
|
|
|
if (/^ DOCKER_BUILDKIT:\s*["']?1["']?\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" enables BuildKit explicitly with DOCKER_BUILDKIT=1`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must set job-level DOCKER_BUILDKIT: "1"`);
|
|
}
|
|
|
|
if (/^ DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS:\s*["']?90["']?\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" allows loaded-runner commands up to 90 seconds`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must set job-level DOCKER_SMOKE_COMMAND_TIMEOUT_SECONDS: "90"`);
|
|
}
|
|
|
|
if (/^ DOCKER_SMOKE_ARTIFACT_TIMEOUT_SECONDS:\s*["']?180["']?\s*$/m.test(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" allows loaded-runner artifact checks up to 180 seconds`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must set job-level DOCKER_SMOKE_ARTIFACT_TIMEOUT_SECONDS: "180"`);
|
|
}
|
|
|
|
if (section.includes(DOCKER_SMOKE_SUITE_PATH)) {
|
|
const suiteSource = readOptionalFile(rootDir, DOCKER_SMOKE_SUITE_PATH);
|
|
if (suiteSource === null) {
|
|
failures.push(`${DOCKER_SMOKE_SUITE_PATH}: missing required isolated Docker smoke suite`);
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
const isolated = checkIsolatedDockerSmokeSuite(section, suiteSource);
|
|
return {
|
|
passes: [...passes, ...isolated.passes],
|
|
failures: [...failures, ...isolated.failures],
|
|
observations: isolated.observations,
|
|
};
|
|
}
|
|
|
|
for (const target of DOCKER_SMOKE_TARGETS) {
|
|
if (hasDockerTargetBuild(section, target)) {
|
|
const targetBuild = getDockerTargetBuild(section, target) ?? "";
|
|
const targetBuildPattern = (line) =>
|
|
/\bdocker\s+(?:buildx\s+build|build)\b/.test(line) && new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(line);
|
|
const softeningReason = getRequiredStepSofteningReason(getStepForRunCommand(section, targetBuildPattern));
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" build for target "${target}" must not use ${softeningReason}`);
|
|
} else if (!DOCKER_SMOKE_REVISION_ARG_PATTERN.test(targetBuild)) {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" build for target "${target}" must pass --build-arg APP_REVISION=0123456789abcdef0123456789abcdef01234567`
|
|
);
|
|
} else if (target === "web" && !DOCKER_SMOKE_HOCUSPOCUS_ARG_PATTERN.test(targetBuild)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" web build must exercise --build-arg VITE_HOCUSPOCUS_URL=wss://collab.invalid`);
|
|
} else if (target === "hocuspocus" && !/--file(?:=|\s+)apps\/hocuspocus\/Dockerfile(?:\s|$)/u.test(targetBuild)) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must build Hocuspocus from apps/hocuspocus/Dockerfile`);
|
|
} else {
|
|
if (target === "web") {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke web build exercises the public Hocuspocus URL build arg`);
|
|
}
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" builds Docker target "${target}"`);
|
|
}
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing Docker build for target "${target}"`);
|
|
}
|
|
}
|
|
|
|
if (hasSerializedRootDockerTargetBuildsWithParallelHocuspocus(section)) {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" serializes root Docker targets while Hocuspocus builds in parallel`);
|
|
} else {
|
|
failures.push(
|
|
`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" must serialize root Docker targets and propagate failures while Hocuspocus builds in parallel`
|
|
);
|
|
}
|
|
|
|
if (hasDockerComposeConfig(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, "docker compose config");
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "docker compose config" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates Docker Compose config`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, "docker compose config");
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "docker compose config" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "docker compose config"`);
|
|
}
|
|
}
|
|
|
|
if (hasWebDockerArtifactValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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 (hasWebDockerRuntimeValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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 (hasApiDockerRuntimeValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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 (hasWorkerDockerRuntimeValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, 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(section, 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}"`);
|
|
}
|
|
}
|
|
|
|
if (hasMcpDockerRuntimeValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DOCKER_MCP_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_MCP_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates MCP Docker runtime`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DOCKER_MCP_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_MCP_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_MCP_RUNTIME_COMMAND}"`);
|
|
}
|
|
}
|
|
|
|
if (hasHocuspocusDockerRuntimeValidation(section)) {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DOCKER_HOCUSPOCUS_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_HOCUSPOCUS_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" validates Hocuspocus Docker runtime`);
|
|
}
|
|
} else {
|
|
const softeningReason = getRequiredCommandSofteningReason(section, DOCKER_HOCUSPOCUS_RUNTIME_COMMAND);
|
|
if (softeningReason) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" command "${DOCKER_HOCUSPOCUS_RUNTIME_COMMAND}" must not use ${softeningReason}`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" missing command "${DOCKER_HOCUSPOCUS_RUNTIME_COMMAND}"`);
|
|
}
|
|
}
|
|
|
|
const forbiddenLine = getRunCommandLines(section).find((line) => FORBIDDEN_DOCKER_PUBLISH_PATTERN.test(line));
|
|
if (forbiddenLine) {
|
|
failures.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" contains forbidden publish command: ${forbiddenLine.trim()}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: Docker smoke job "${DOCKER_SMOKE_JOB}" contains no registry login/push/publish commands`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkWorkflowRunners(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const runnerLines = content
|
|
.split(/\r?\n/)
|
|
.map((line, index) => ({ line, lineNumber: index + 1 }))
|
|
.filter(({ line }) => /^\s*runs-on:\s*/.test(line));
|
|
|
|
if (runnerLines.length === 0) {
|
|
failures.push(`${WORKFLOW_PATH}: missing required runs-on declarations`);
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
for (const { line, lineNumber } of runnerLines) {
|
|
const runner = line
|
|
.replace(/^\s*runs-on:\s*/, "")
|
|
.trim()
|
|
.replace(/^['\"]|['\"]$/g, "");
|
|
if (runner === REQUIRED_RUNNER) {
|
|
passes.push(`${WORKFLOW_PATH}:${lineNumber}: runner is "${REQUIRED_RUNNER}"`);
|
|
} else {
|
|
failures.push(`${WORKFLOW_PATH}:${lineNumber}: runs-on must be "${REQUIRED_RUNNER}" but found "${runner}"`);
|
|
}
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkForbiddenE2eCommands(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
const forbiddenLine = getRunCommandLines(content).find((line) => FORBIDDEN_E2E_PATTERN.test(line));
|
|
|
|
if (forbiddenLine) {
|
|
failures.push(`${WORKFLOW_PATH}: forbidden E2E/browser command found: ${forbiddenLine.trim()}`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: no forbidden E2E/browser commands are present`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function checkGiteaVariableNames(content) {
|
|
const passes = [];
|
|
const failures = [];
|
|
|
|
if (FORBIDDEN_GITEA_HOST_OVERRIDE_PATTERN.test(content)) {
|
|
failures.push(`${WORKFLOW_PATH}: GITEA_HOST_IP is forbidden because Gitea reserves the GITEA_ variable prefix; use HIVEGRID_GITEA_HOST_IP`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: internal host override avoids the reserved GITEA_ variable prefix`);
|
|
}
|
|
|
|
const hostWrites = [...content.matchAll(HOST_OVERRIDE_WRITE_PATTERN)].length;
|
|
const validations = [...content.matchAll(HOST_OVERRIDE_VALIDATION_PATTERN)].length;
|
|
if (hostWrites > validations) {
|
|
failures.push(`${WORKFLOW_PATH}: HIVEGRID_GITEA_HOST_IP must be validated before writing to /etc/hosts`);
|
|
} else {
|
|
passes.push(`${WORKFLOW_PATH}: internal host override validates HIVEGRID_GITEA_HOST_IP before /etc/hosts writes`);
|
|
}
|
|
|
|
return { passes, failures, observations: [] };
|
|
}
|
|
|
|
export function verifyGiteaCiWorkflow(rootDir = process.cwd()) {
|
|
if (!checkWorkflowExists(rootDir)) {
|
|
return { passes: [], failures: [`${WORKFLOW_PATH}: missing required Gitea CI workflow file`], observations: [] };
|
|
}
|
|
|
|
let content;
|
|
try {
|
|
content = readWorkflow(rootDir);
|
|
} catch (error) {
|
|
return { passes: [], failures: [`${WORKFLOW_PATH}: failed to read workflow: ${error.message}`], observations: [] };
|
|
}
|
|
|
|
const checks = [
|
|
checkWorkflowTriggers(content),
|
|
checkWorkflowConcurrency(content),
|
|
checkPnpmSetup(content),
|
|
checkWorkflowRunners(content),
|
|
checkOrderedQualityGates(content),
|
|
checkQualityGroupRunner(rootDir),
|
|
checkServiceJobs(content),
|
|
checkDatabaseJob(content),
|
|
checkDockerSmokeJob(content, rootDir),
|
|
checkSentrySourcemapWorkflow(rootDir),
|
|
checkForbiddenE2eCommands(content),
|
|
checkGiteaVariableNames(content),
|
|
];
|
|
|
|
return checks.reduce(
|
|
(result, check) => ({
|
|
passes: [...result.passes, ...check.passes],
|
|
failures: [...result.failures, ...check.failures],
|
|
observations: [...result.observations, ...check.observations],
|
|
}),
|
|
{ passes: [], failures: [], observations: [] }
|
|
);
|
|
}
|
|
|
|
export function formatGiteaCiWorkflowResult(result) {
|
|
const status = result.failures.length > 0 ? "FAIL" : "PASS";
|
|
return [
|
|
`${status} Gitea 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 = verifyGiteaCiWorkflow(process.cwd());
|
|
const output = formatGiteaCiWorkflowResult(result);
|
|
|
|
if (result.failures.length > 0) {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
} else {
|
|
process.stdout.write(output);
|
|
}
|
|
}
|