#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CI_RUNNER_LABEL, GIT_HOST, HOST_OVERRIDE_VALIDATION_PATTERN as INFRA_HOST_OVERRIDE_VALIDATION_PATTERN, HOST_OVERRIDE_WRITE_PATTERN as INFRA_HOST_OVERRIDE_WRITE_PATTERN, PREFLIGHT_DATABASE_URL as INFRA_PREFLIGHT_DATABASE_URL, escapeRegex, } from "./infra-config.mjs"; const WORKFLOW_PATH = ".gitea/workflows/deploy.yml"; const REQUIRED_RUNNER = CI_RUNNER_LABEL; const REQUIRED_TARGETS = ["web", "api", "worker"]; const PREFLIGHT_JOB = "deploy-preflight"; const DEPLOY_JOB = "stage-or-promote"; const PREFLIGHT_DATABASE_URL = INFRA_PREFLIGHT_DATABASE_URL; const DATABASE_URL_SECRET = "${{ secrets.DATABASE_URL }}"; const PREFLIGHT_REQUIRED_COMMANDS = [ "node scripts/verify-gitea-deploy-workflow.mjs", "node scripts/verify-deploy-readiness.mjs", "node scripts/verify-docker-baseline.mjs", "node scripts/verify-docker-non-root.mjs", "node --check scripts/coolify-deploy.mjs", "node --check scripts/coolify-ensure-worker-groups.mjs", "bash -n scripts/coolify-prepull-images.sh", "bash -n scripts/ci/install-cosign.sh", ]; const PREFLIGHT_FORBIDDEN_HEAVY_COMMANDS = [ { label: "pnpm install --frozen-lockfile", pattern: /\bpnpm\s+install\s+--frozen-lockfile\b/ }, { label: "pnpm run verify:i18n", pattern: /\bpnpm\s+run\s+verify:i18n\b/ }, { label: "pnpm run typecheck", pattern: /\bpnpm\s+(?:run\s+)?typecheck\b/ }, { label: "pnpm run lint", pattern: /\bpnpm\s+(?:run\s+)?lint\b/ }, { label: "pnpm run test", pattern: /\bpnpm\s+(?:run\s+)?test(?:\s|$)/ }, { label: "pnpm run test:foundation", pattern: /\bpnpm\s+run\s+test:foundation\b/ }, { label: "pnpm run test:packages", pattern: /\bpnpm\s+run\s+test:packages\b/ }, { label: "pnpm run build", pattern: /\bpnpm\s+(?:run\s+)?build\b/ }, { label: "docker build --target web", pattern: /\bdocker\s+(?:buildx\s+build|build)\b[\s\S]*?(?:^|\s)--target(?:=|\s+)web(?:\s|$)/ }, { label: "docker build --target api", pattern: /\bdocker\s+(?:buildx\s+build|build)\b[\s\S]*?(?:^|\s)--target(?:=|\s+)api(?:\s|$)/ }, { label: "docker build --target worker", pattern: /\bdocker\s+(?:buildx\s+build|build)\b[\s\S]*?(?:^|\s)--target(?:=|\s+)worker(?:\s|$)/ }, { label: "pnpm run verify:docker-runtime-smoke", pattern: /\bpnpm\s+run\s+verify:docker-runtime-smoke\b/ }, { label: "pnpm run verify:docker-api-runtime-smoke", pattern: /\bpnpm\s+run\s+verify:docker-api-runtime-smoke\b/ }, { label: "pnpm run verify:docker-worker-runtime-smoke", pattern: /\bpnpm\s+run\s+verify:docker-worker-runtime-smoke\b/ }, ]; const USERNAME_SECRET = "${{ secrets.REGISTRY_USER }}"; const PASSWORD_SECRET = "${{ secrets.REGISTRY_PAT }}"; const FORBIDDEN_E2E_PATTERN = /\b(playwright|cypress|browser|test:e2e|e2e)\b/i; const COOLIFY_TOKEN_SECRET = "${{ secrets.COOLIFY_TOKEN }}"; const COOLIFY_API_URL_VAR = "${{ vars.COOLIFY_API_URL }}"; const COOLIFY_RESOURCE_UUIDS_VAR = "${{ vars.COOLIFY_RESOURCE_UUIDS }}"; const PRODUCTION_SEED_ARTIFACT_PATTERN = /packages\/db\/dist\/production-seed\.server\.js/; const PRODUCTION_SEED_COMMAND_PATTERN = /\bNODE_ENV=production\s+node\s+packages\/db\/dist\/production-seed\.server\.js\b/; const COOLIFY_MIGRATION_SLOT_PATTERN = /pre_deployment_command:\s*process\.env\.MIGRATION_COMMAND/; const COOLIFY_SEED_SLOT_PATTERN = /post_deployment_command:\s*process\.env\.SEED_COMMAND/; const LITERAL_COOLIFY_URL_PATTERN = /https?:\/\/[^\s'\"]*(?:coolify|webhook)[^\s'\"]*/i; const LITERAL_PRODUCTION_REGISTRY_PATTERN = /\b(?:ghcr\.io|registry\.docker\.com|docker\.io|registry\.[A-Za-z0-9.-]+|[A-Za-z0-9.-]+:\d{2,5})\/[A-Za-z0-9._/-]+/i; const SECRET_SHAPED_LITERAL_PATTERNS = [ /\bghp_[A-Za-z0-9_]{20,}\b/, /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, /\bglpat-[A-Za-z0-9_-]{20,}\b/, /\b(?:password|token|secret)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{16,}/i, /\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\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; const REQUIRED_DEPENDENT_IMAGE_TAG_PATCH_RESOURCES = [ "API_RESOURCE_UUID", "WORKER_GENERAL_RESOURCE_UUID", "WORKER_MAIL_RESOURCE_UUID", "WORKER_IMPORT_RESOURCE_UUID", "WORKER_WEBHOOK_RESOURCE_UUID", "WORKER_PLUGIN_RESOURCE_UUID", ]; const COSIGN_INSTALLER_PATH = "scripts/ci/install-cosign.sh"; function verifiesLiveHealthRevision(stepText) { return ( /\/healthz/.test(stepText) && /HEALTH_JSON="\$\(mktemp\)"/.test(stepText) && /payload\.revision/.test(stepText) && /payload\.service/.test(stepText) && /HEALTH_REVISION="\$\(node -e/.test(stepText) && /HEALTH_SERVICE="\$\(node -e/.test(stepText) && /\[\s*-n\s+"\$EXPECTED_SERVICE"\s*\]\s*&&\s*\[\s*"\$HEALTH_SERVICE"\s*!=\s*"\$EXPECTED_SERVICE"\s*\]/.test(stepText) && /\[\s*"\$HEALTH_REVISION"\s*!=\s*"\$IMAGE_TAG"\s*\]/.test(stepText) ); } function readWorkflow(rootDir) { return fs.readFileSync(path.join(rootDir, WORKFLOW_PATH), "utf8"); } function checkWorkflowExists(rootDir) { return fs.existsSync(path.join(rootDir, WORKFLOW_PATH)); } function getJobSection(content, jobName, analysis) { const lines = getWorkflowAnalysis(content, analysis).lines; 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 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 hasCheckoutStep(section) { return /uses:\s*actions\/checkout@v\d+/i.test(section); } function hasJobLevelEnvValue(section, envName, envValue) { const stepsIndex = section.search(/^ steps:\s*$/m); const beforeSteps = stepsIndex === -1 ? section : section.slice(0, stepsIndex); const escaped = envValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`^\\s*${envName}:\\s*${escaped}\\s*$`, "m").test(beforeSteps); } function getRunBlocksFromLines(lines) { const blocks = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const multilineRun = line.match(/^(\s*)run:\s*[>|]\s*$/); if (!multilineRun) { const inlineRun = line.match(/^\s*run:\s*(.+)$/); if (inlineRun) { blocks.push({ line: line.trim(), command: inlineRun[1], lineNumber: index + 1 }); } continue; } const baseIndent = multilineRun[1].length; const commandLines = []; for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { const nextLine = lines[nextIndex]; if (nextLine.trim().length === 0) { commandLines.push(""); continue; } const indent = nextLine.match(/^\s*/)[0].length; if (indent <= baseIndent) { break; } commandLines.push(nextLine.trim()); } blocks.push({ line: line.trim(), command: commandLines.join("\n"), lineNumber: index + 1 }); } return blocks; } function stripExecutableShellLine(line) { return line.replace(/\s+#.*$/, "").trim(); } function getWorkflowLinesFromLines(lines) { return lines.map((line, index) => ({ line, lineNumber: index + 1 })); } function getStepBlocksFromLines(lines) { const steps = []; for (let index = 0; index < lines.length; index += 1) { const stepMatch = lines[index].match(/^(\s*)-\s+name:\s*(.*)$/); if (!stepMatch) { continue; } const baseIndent = stepMatch[1].length; const stepLines = [lines[index]]; for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { const nextLine = lines[nextIndex]; if (nextLine.trim().length === 0) { stepLines.push(nextLine); continue; } const indent = nextLine.match(/^\s*/)[0].length; if (indent <= baseIndent && /^\s*-\s+name:\s*/.test(nextLine)) { break; } if (indent <= baseIndent && /^\S/.test(nextLine)) { break; } stepLines.push(nextLine); } steps.push({ text: stepLines.join("\n"), lineNumber: index + 1 }); } return steps; } function createWorkflowAnalysis(content) { const lines = content.split(/\r?\n/); return { content, lines, workflowLines: getWorkflowLinesFromLines(lines), runBlocks: getRunBlocksFromLines(lines), stepBlocks: getStepBlocksFromLines(lines), }; } function getWorkflowAnalysis(content, analysis) { return analysis?.content === content ? analysis : createWorkflowAnalysis(content); } function getRunBlocks(content, analysis) { return getWorkflowAnalysis(content, analysis).runBlocks; } function getWorkflowLines(content, analysis) { return getWorkflowAnalysis(content, analysis).workflowLines; } function getStepBlocks(content, analysis) { return getWorkflowAnalysis(content, analysis).stepBlocks; } function imageTagPatchLoopIncludesAllDependentResources(stepText) { const loopLine = stepText .split(/\r?\n/) .map((line) => line.trim()) .find((line) => /^for RESOURCE_UUID in\b/.test(line) && /API_RESOURCE_UUID/.test(line) && /WORKER_/.test(line)) ?? ""; return REQUIRED_DEPENDENT_IMAGE_TAG_PATCH_RESOURCES.every((resourceName) => new RegExp(`\\$${resourceName}\\b`).test(loopLine)); } function getStepName(stepText) { return stepText.match(/^\s*-\s+name:\s*(.*)$/m)?.[1]?.trim() ?? ""; } 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 nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { const nextLine = lines[nextIndex]; const indent = nextLine.match(/^\s*/)[0].length; if (nextLine.trim().length > 0 && indent <= runIndent) { break; } commandLines.push(nextLine); } return commandLines.join("\n"); } return ""; } function getExecutableShellLines(runText) { return runText .split(/\r?\n/) .map((line) => stripExecutableShellLine(line)) .filter(Boolean); } function listForbiddenPreflightCommands(preflightSection, analysis) { const matches = new Set(); for (const block of getRunBlocks(preflightSection, analysis)) { const command = getExecutableShellLines(block.command).join("\n"); for (const { label, pattern } of PREFLIGHT_FORBIDDEN_HEAVY_COMMANDS) { if (pattern.test(command)) { matches.add(label); } } } return [...matches]; } function getStepForRunCommand(content, predicate, analysis) { return ( getStepBlocks(content, analysis).find(({ text }) => { const lines = getExecutableShellLines(getRunTextFromStep(text)); const block = lines.join("\n"); return lines.some((line) => predicate(line)) || predicate(block) || predicate(text); }) ?? null ); } function getRequiredStepSofteningReason(step) { if (!step) { return ""; } if (/^\s*continue-on-error:\s*true\s*$/im.test(step.text) || /^\s*if:\s*(?:always\(\)|\$\{\{\s*always\(\)\s*\}\})\s*$/im.test(step.text)) { return "continue-on-error or always()"; } const lines = getExecutableShellLines(getRunTextFromStep(step.text)); 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 hasPushBranchTrigger(content, branch) { return new RegExp(`on:\\s*[\\s\\S]*?push:\\s*[\\s\\S]*?branches:\\s*\\[[^\\]]*\\b${branch}\\b`, "i").test(content); } function hasDockerTargetBuild(content, target, analysis) { return getRunBlocks(content, analysis).some(({ command }) => { if (!/\bdocker\s+(?:buildx\s+build|build)\b/.test(command)) { return false; } return new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(command); }); } function getDockerTargetBuildLine(content, target, analysis) { const block = getRunBlocks(content, analysis).find(({ command }) => { if (!/\bdocker\s+(?:buildx\s+build|build)\b/.test(command)) { return false; } return ( new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(command) || (/for TARGET in web api worker/.test(command) && /--target\s+"\$TARGET"/.test(command)) ); }); return block?.lineNumber ?? 0; } function hasDockerTargetPush(content, target, analysis) { return getRunBlocks(content, analysis).some(({ command }) => { if (!/\bdocker\s+buildx\s+build\b/.test(command) || !/(?:^|\s)--push(?:\s|$)/.test(command)) { return false; } return new RegExp(`(?:^|\\s)--target(?:=|\\s+)${target}(?:\\s|$)`).test(command); }); } export function checkWorkflowRunners(content, analysis) { const passes = []; const failures = []; const runnerLines = getWorkflowLines(content, analysis).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 checkDeployPreflightContract(content, analysis) { const passes = []; const failures = []; const preflightSection = getJobSection(content, PREFLIGHT_JOB, analysis); const deploySection = getJobSection(content, DEPLOY_JOB, analysis); const preflightAnalysis = preflightSection ? createWorkflowAnalysis(preflightSection) : null; const deployAnalysis = deploySection ? createWorkflowAnalysis(deploySection) : null; if (!preflightSection) { failures.push(`${WORKFLOW_PATH}: missing deploy preflight job "${PREFLIGHT_JOB}"`); } else { passes.push(`${WORKFLOW_PATH}: deploy preflight job "${PREFLIGHT_JOB}" is defined`); if (hasCheckoutStep(preflightSection)) passes.push(`${WORKFLOW_PATH}: deploy preflight checks out the repository`); else failures.push(`${WORKFLOW_PATH}: deploy preflight must check out the repository`); if (hasJobLevelEnvValue(preflightSection, "DATABASE_URL", DATABASE_URL_SECRET)) { failures.push(`${WORKFLOW_PATH}: deploy preflight must not expose secrets.DATABASE_URL at job scope`); } else { passes.push(`${WORKFLOW_PATH}: deploy preflight does not expose production database credentials`); } for (const command of PREFLIGHT_REQUIRED_COMMANDS) { if (!preflightSection.includes(command)) { failures.push(`${WORKFLOW_PATH}: deploy preflight missing command "${command}"`); continue; } const step = getStepForRunCommand(preflightSection, (line) => line.includes(command), preflightAnalysis); const softeningReason = getRequiredStepSofteningReason(step); if (softeningReason) { failures.push(`${WORKFLOW_PATH}: deploy preflight command "${command}" must not use ${softeningReason}`); } else { passes.push(`${WORKFLOW_PATH}: deploy preflight includes command "${command}"`); } } const forbiddenCommands = listForbiddenPreflightCommands(preflightSection, preflightAnalysis); if (forbiddenCommands.length > 0) { failures.push(`${WORKFLOW_PATH}: deploy preflight must stay lightweight and not run heavy CI commands: ${forbiddenCommands.join(", ")}`); } else { passes.push(`${WORKFLOW_PATH}: deploy preflight stays lightweight and leaves full CI gates to the CI workflow`); } } if (!deploySection) { failures.push(`${WORKFLOW_PATH}: missing deploy job "${DEPLOY_JOB}"`); } else if (hasNeeds(deploySection, PREFLIGHT_JOB)) { passes.push(`${WORKFLOW_PATH}: deploy job "${DEPLOY_JOB}" needs "${PREFLIGHT_JOB}"`); } else { failures.push(`${WORKFLOW_PATH}: deploy job "${DEPLOY_JOB}" must declare needs: ${PREFLIGHT_JOB}`); } if (deploySection) { const deploySteps = getStepBlocks(deploySection, deployAnalysis); const productionHealthStep = deploySteps.find(({ text }) => /\bWait for Coolify production health\b/i.test(text)); const dependentDeployStep = deploySteps.find(({ text }) => /\bTrigger Coolify production dependent deploys\b/i.test(text)); const prepareMigrationStatusStep = deploySteps.find(({ text }) => /\bPrepare production migration status checkout\b/i.test(text)); const cacheMigrationStatusStep = deploySteps.find(({ text }) => /\bCache production migration pnpm store\b/i.test(text)); const installMigrationStatusStep = deploySteps.find(({ text }) => /\bInstall production migration dependencies\b/i.test(text)); const migrationStatusStep = deploySteps.find(({ text }) => /\bVerify production migration status after web deploy\b/i.test(text)); const hasPromoteMigrationCheckout = prepareMigrationStatusStep && /if \[ "\$DEPLOY_ACTION" != "promote" \]/.test(prepareMigrationStatusStep.text) && /git\s+checkout\s+--detach\s+"\$IMAGE_TAG"/.test(prepareMigrationStatusStep.text) && /CHECKOUT_SHA="\$\(git\s+rev-parse\s+HEAD\)"/.test(prepareMigrationStatusStep.text) && /\[\s*"\$CHECKOUT_SHA"\s*!=\s*"\$IMAGE_TAG"\s*\]/.test(prepareMigrationStatusStep.text) && /pnpm\s+config\s+set\s+store-dir\s+\.pnpm-store/.test(prepareMigrationStatusStep.text) && !prepareMigrationStatusStep.text.includes(`DATABASE_URL: ${DATABASE_URL_SECRET}`); const hasPromoteMigrationCache = cacheMigrationStatusStep && /uses:\s*actions\/cache@v4/.test(cacheMigrationStatusStep.text) && /path:\s*\.pnpm-store/.test(cacheMigrationStatusStep.text) && /pnpm-promote-\$\{\{\s*hashFiles\('pnpm-lock\.yaml'\)\s*\}\}/.test(cacheMigrationStatusStep.text) && !cacheMigrationStatusStep.text.includes(`DATABASE_URL: ${DATABASE_URL_SECRET}`); const hasPromoteMigrationInstall = installMigrationStatusStep && /if \[ "\$DEPLOY_ACTION" != "promote" \]/.test(installMigrationStatusStep.text) && installMigrationStatusStep.text.includes("pnpm install --frozen-lockfile --prefer-offline") && !installMigrationStatusStep.text.includes(`DATABASE_URL: ${DATABASE_URL_SECRET}`); const hasPromoteMigrationStatus = migrationStatusStep && /if \[ "\$DEPLOY_ACTION" != "promote" \]/.test(migrationStatusStep.text) && migrationStatusStep.text.includes("pnpm run verify:db-migrate-status") && !migrationStatusStep.text.includes("pnpm install --frozen-lockfile --prefer-offline") && migrationStatusStep.text.includes(`DATABASE_URL: ${DATABASE_URL_SECRET}`); if ( hasPromoteMigrationCheckout && hasPromoteMigrationCache && hasPromoteMigrationInstall && hasPromoteMigrationStatus && productionHealthStep && dependentDeployStep && prepareMigrationStatusStep.lineNumber > productionHealthStep.lineNumber && cacheMigrationStatusStep.lineNumber > prepareMigrationStatusStep.lineNumber && installMigrationStatusStep.lineNumber > cacheMigrationStatusStep.lineNumber && migrationStatusStep.lineNumber > installMigrationStatusStep.lineNumber && migrationStatusStep.lineNumber < dependentDeployStep.lineNumber ) { passes.push( `${WORKFLOW_PATH}: promote verifies production migration status after web deploy and before dependent deploys with cached pnpm store, secret-free install, and step-scoped DATABASE_URL` ); } else { failures.push( `${WORKFLOW_PATH}: promote must check out IMAGE_TAG, cache .pnpm-store, install dependencies without DATABASE_URL, and run pnpm run verify:db-migrate-status with step-scoped DATABASE_URL after production web health and before dependent deploys` ); } } return { passes, failures, observations: [] }; } export function checkDeploymentConcurrency(content) { const passes = []; const failures = []; if (/^concurrency:\s*$/m.test(content) && /^\s+group:\s*boilerplate-deploy\s*$/m.test(content) && /^\s+cancel-in-progress:\s*false\s*$/m.test(content)) { passes.push(`${WORKFLOW_PATH}: deployment concurrency serializes staging and production promotion`); } else { failures.push(`${WORKFLOW_PATH}: deployment workflow must define concurrency group boilerplate-deploy with cancel-in-progress: false`); } return { passes, failures, observations: [] }; } export function checkForbiddenCommands(content, analysis) { const passes = []; const failures = []; const e2eBlock = getRunBlocks(content, analysis).find(({ command }) => FORBIDDEN_E2E_PATTERN.test(command.replace(/\btoolctrl\.browser-automation\b/g, ""))); if (e2eBlock) { failures.push(`${WORKFLOW_PATH}:${e2eBlock.lineNumber}: forbidden E2E/browser command found: ${e2eBlock.line}`); } else { passes.push(`${WORKFLOW_PATH}: no forbidden E2E/browser commands are present`); } return { passes, failures, observations: [] }; } export function checkDuplicateCoolifySelectedAssignments(content, analysis) { const passes = []; const failures = []; const selectedAssignmentPattern = /^\s*(SELECTED_COOLIFY_(?:API_URL|TOKEN))=/; for (const block of getRunBlocks(content, analysis)) { const counts = new Map(); for (const line of block.command.split("\n")) { const match = line.match(selectedAssignmentPattern); if (!match) continue; counts.set(match[1], (counts.get(match[1]) ?? 0) + 1); } for (const [name, count] of counts) { if (count > 1) { failures.push(`${WORKFLOW_PATH}:${block.lineNumber}: ${name} is assigned ${count} times in one run block`); } } } if (failures.length === 0) { passes.push(`${WORKFLOW_PATH}: Coolify selected environment assignments are not duplicated within run blocks`); } return { passes, failures, observations: [] }; } export function checkRequiredStepHardening(content, analysis) { const passes = []; const failures = []; const requiredSteps = [ { label: "deployment action resolution", predicate: (command) => /\bDEPLOY_ACTION=stage\b/.test(command) && /\bDEPLOY_ACTION=promote\b/.test(command), }, { label: "registry login", predicate: (command) => /\bdocker\s+login\b/.test(command), }, { label: "staged promotion provenance", predicate: (command) => /\bgit\s+merge-base\s+--is-ancestor\s+"\$IMAGE_TAG"\s+origin\/main\b/.test(command) && /\bdocker\s+buildx\s+imagetools\s+inspect\b/.test(command), }, { label: "Docker image publication", predicate: (command) => /\bdocker\s+buildx\s+build\b/.test(command) && /(?:^|\s)--push(?:\s|$)/.test(command), }, { label: "Coolify migration command configuration", predicate: (command) => /pre_deployment_command/.test(command) && /\bprisma\s+migrate\s+deploy\b/.test(command) && PRODUCTION_SEED_ARTIFACT_PATTERN.test(command), }, { label: "Coolify deploy trigger", predicate: (command) => /coolify-deploy\.mjs\s+trigger/.test(command), }, { label: "production image promotion", predicate: (command) => /\bdocker\s+buildx\s+imagetools\s+create\b/.test(command) && /--tag\s+"\$IMAGE:main"/.test(command), }, ]; for (const { label, predicate } of requiredSteps) { const step = getStepForRunCommand(content, predicate, analysis); if (!step) { failures.push(`${WORKFLOW_PATH}: required ${label} step is missing`); continue; } const softeningReason = getRequiredStepSofteningReason(step); if (softeningReason) { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: required ${label} step must not use ${softeningReason}`); } else { passes.push(`${WORKFLOW_PATH}:${step.lineNumber}: required ${label} step is not softened`); } } return { passes, failures, observations: [] }; } export function checkDatabaseMigrationDeployContract(content, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const migrationStep = steps.find(({ text }) => /\bConfigure Coolify staging migration command\b/i.test(text)); const productionMigrationStep = steps.find(({ text }) => /\bConfigure Coolify production migration command\b/i.test(text)); if (!migrationStep) { failures.push(`${WORKFLOW_PATH}: missing Coolify staging migration command configuration step`); return { passes, failures, observations: [] }; } if (!productionMigrationStep) { failures.push(`${WORKFLOW_PATH}: missing Coolify production migration command configuration step`); } else if ( COOLIFY_MIGRATION_SLOT_PATTERN.test(productionMigrationStep.text) && COOLIFY_SEED_SLOT_PATTERN.test(productionMigrationStep.text) && /\bprisma\s+migrate\s+deploy\b/.test(productionMigrationStep.text) && PRODUCTION_SEED_COMMAND_PATTERN.test(productionMigrationStep.text) && /ALLOWED_RESOURCE_UUIDS="\$(?:RESOLVED_COOLIFY_RESOURCE_UUIDS|COOLIFY_RESOURCE_UUIDS)"/.test(productionMigrationStep.text) && /docker_registry_image_tag:\s*process\.env\.IMAGE_TAG/.test(productionMigrationStep.text) ) { passes.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production pre-deployment migration and post-deployment guarded seed command targets the main web application` ); } else { failures.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production migration command must patch production web app migrations, post-deployment guarded production seed, and immutable image tag` ); } if (COOLIFY_MIGRATION_SLOT_PATTERN.test(migrationStep.text) && /\bprisma\s+migrate\s+deploy\b/.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify pre-deployment migration command is present`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must set pre_deployment_command to prisma migrate deploy`); } if (COOLIFY_SEED_SLOT_PATTERN.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify post-deployment guarded seed command is present`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must set post_deployment_command to the guarded seed`); } if (/cd\s+\/myapp\b/.test(migrationStep.text) && !/cd\s+\/myapp\/packages\/db\b/.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command runs from the web image app root`); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must run from /myapp so prisma.config.ts resolves in the final web image` ); } if (/\btsx\b/.test(migrationStep.text) || /node_modules\/\.pnpm\/node_modules\/\.bin/.test(migrationStep.text)) { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must not use pnpm store-internal .bin paths`); } else if (/\bpnpm\s+exec\s+prisma\b/.test(migrationStep.text) && PRODUCTION_SEED_COMMAND_PATTERN.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command uses production runtime-resolvable commands`); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must use pnpm exec for prisma and NODE_ENV=production node packages/db/dist/production-seed.server.js` ); } if (PRODUCTION_SEED_COMMAND_PATTERN.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify guarded seed uses built DB package output`); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must run the guarded production seed from built DB output in post_deployment_command` ); } if (/\bcurl\b[\s\S]*--request PATCH[\s\S]*\/applications\/\$WEB_RESOURCE_UUID/.test(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command is patched through the application API`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must be patched through the application API`); } if ( /\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/web/.test(migrationStep.text) && /ALLOWED_RESOURCE_UUIDS="\$(?:RESOLVED_COOLIFY_STAGING_RESOURCE_UUIDS|COOLIFY_STAGING_RESOURCE_UUIDS)"/.test(migrationStep.text) && /docker_registry_image_tag:\s*process\.env\.IMAGE_TAG/.test(migrationStep.text) ) { passes.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command targets the configured staging web application and immutable image tag` ); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging migration command must target the configured staging web application and immutable image tag` ); } if ( /COOLIFY_STAGING_RESOURCE_UUIDS/.test(migrationStep.text) && /\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/api/.test(migrationStep.text) && /\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/worker/.test(migrationStep.text) && /WORKER_GENERAL_RESOURCE_UUID/.test(migrationStep.text) && /WORKER_MAIL_RESOURCE_UUID/.test(migrationStep.text) && /WORKER_IMPORT_RESOURCE_UUID/.test(migrationStep.text) && /WORKER_WEBHOOK_RESOURCE_UUID/.test(migrationStep.text) && /WORKER_PLUGIN_RESOURCE_UUID/.test(migrationStep.text) && /WORKER_QUEUE_GROUP/.test(migrationStep.text) && /worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/.test(migrationStep.text) ) { passes.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging deploy resources are checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } if ( /STAGING_WEB_RESOURCE_UUID=\$WEB_RESOURCE_UUID/.test(migrationStep.text) && /STAGING_WORKER_RESOURCE_UUIDS=\$WORKER_RESOURCE_UUIDS/.test(migrationStep.text) && /STAGING_DEPENDENT_RESOURCE_UUIDS=\$DEPENDENT_RESOURCE_UUIDS/.test(migrationStep.text) ) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging deploy stores web and dependent resource groups separately`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging deploy must split web and dependent resources before triggering deploys`); } if (imageTagPatchLoopIncludesAllDependentResources(migrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging image-tag patch covers api and all separate worker applications`); } else { failures.push( `${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify staging image-tag patch must cover api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } if ( productionMigrationStep && /COOLIFY_RESOURCE_UUIDS/.test(productionMigrationStep.text) && /\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/api/.test(productionMigrationStep.text) && /\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/worker/.test(productionMigrationStep.text) && /WORKER_GENERAL_RESOURCE_UUID/.test(productionMigrationStep.text) && /WORKER_MAIL_RESOURCE_UUID/.test(productionMigrationStep.text) && /WORKER_IMPORT_RESOURCE_UUID/.test(productionMigrationStep.text) && /WORKER_WEBHOOK_RESOURCE_UUID/.test(productionMigrationStep.text) && /WORKER_PLUGIN_RESOURCE_UUID/.test(productionMigrationStep.text) && /WORKER_QUEUE_GROUP/.test(productionMigrationStep.text) && /worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/.test(productionMigrationStep.text) ) { passes.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production deploy resources are checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } else if (productionMigrationStep) { failures.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } if ( productionMigrationStep && /PRODUCTION_WEB_RESOURCE_UUID=\$WEB_RESOURCE_UUID/.test(productionMigrationStep.text) && /PRODUCTION_WORKER_RESOURCE_UUIDS=\$WORKER_RESOURCE_UUIDS/.test(productionMigrationStep.text) && /PRODUCTION_DEPENDENT_RESOURCE_UUIDS=\$DEPENDENT_RESOURCE_UUIDS/.test(productionMigrationStep.text) ) { passes.push(`${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production deploy stores web and dependent resource groups separately`); } else if (productionMigrationStep) { failures.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production deploy must split web and dependent resources before triggering deploys` ); } if (productionMigrationStep && imageTagPatchLoopIncludesAllDependentResources(productionMigrationStep.text)) { passes.push(`${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production image-tag patch covers api and all separate worker applications`); } else if (productionMigrationStep) { failures.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production image-tag patch must cover api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications` ); } const firstCoolifyStep = steps.find( ({ text }) => /\bTrigger Coolify staging deploy\b/i.test(text) || /coolify-deploy\.mjs\s+trigger/.test(text) || /\bcurl\b[\s\S]*\/deploy\?uuid=/.test(text) ); const productionCoolifyStep = steps.find(({ text }) => /\bTrigger Coolify production deploy\b/i.test(text)); const lastImageBuildLine = Math.max(...REQUIRED_TARGETS.map((target) => getDockerTargetBuildLine(content, target, analysis))); if (lastImageBuildLine > 0 && migrationStep.lineNumber > lastImageBuildLine) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command configuration runs after Docker image publication`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command configuration must run after Docker image publication`); } if (firstCoolifyStep && migrationStep.lineNumber < firstCoolifyStep.lineNumber) { passes.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command configuration runs before Coolify deploy`); } else { failures.push(`${WORKFLOW_PATH}:${migrationStep.lineNumber}: Coolify migration command configuration must run before Coolify deploy`); } if (productionMigrationStep && productionCoolifyStep && productionMigrationStep.lineNumber < productionCoolifyStep.lineNumber) { passes.push(`${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production migration command configuration runs before production deploy`); } else if (productionMigrationStep) { failures.push( `${WORKFLOW_PATH}:${productionMigrationStep.lineNumber}: Coolify production migration command configuration must run before production deploy` ); } const stagingDeployStep = steps.find(({ text }) => /\bTrigger Coolify staging deploy\b/i.test(text)); const stagingWaitStep = steps.find(({ text }) => /\bWait for Coolify staging health\b/i.test(text)); const stagingDependentDeployStep = steps.find(({ text }) => /\bTrigger Coolify staging dependent deploys\b/i.test(text)); const productionDeployStep = steps.find(({ text }) => /\bTrigger Coolify production deploy\b/i.test(text)); const productionWaitStep = steps.find(({ text }) => /\bWait for Coolify production health\b/i.test(text)); const productionDependentDeployStep = steps.find(({ text }) => /\bTrigger Coolify production dependent deploys\b/i.test(text)); if (stagingDeployStep && /coolify-deploy\.mjs\s+trigger\s+"\$STAGING_WEB_RESOURCE_UUID"/.test(stagingDeployStep.text)) { passes.push(`${WORKFLOW_PATH}:${stagingDeployStep.lineNumber}: Coolify staging deploy triggers the migration-bearing web app first`); } else if (stagingDeployStep) { failures.push(`${WORKFLOW_PATH}:${stagingDeployStep.lineNumber}: Coolify staging deploy must trigger only STAGING_WEB_RESOURCE_UUID first`); } if (stagingDeployStep && /deploy\?uuid=\$COOLIFY_STAGING_RESOURCE_UUIDS/.test(stagingDeployStep.text)) { failures.push(`${WORKFLOW_PATH}:${stagingDeployStep.lineNumber}: Coolify staging deploy must not trigger all resources before migrations complete`); } if ( stagingDependentDeployStep && stagingWaitStep && stagingDependentDeployStep.lineNumber > stagingWaitStep.lineNumber && /for RESOURCE_UUID in \$\(printf "%s" "\$STAGING_DEPENDENT_RESOURCE_UUIDS" \| tr ',' ' '\)/.test(stagingDependentDeployStep.text) && /coolify-deploy\.mjs\s+trigger\s+"\$RESOURCE_UUID"/.test(stagingDependentDeployStep.text) ) { passes.push(`${WORKFLOW_PATH}:${stagingDependentDeployStep.lineNumber}: Coolify staging dependent resources deploy after web health`); } else { failures.push(`${WORKFLOW_PATH}: Coolify staging dependent resources must deploy only after the web migration app is healthy`); } if ( stagingDependentDeployStep && (/deploy\?uuid=\$STAGING_DEPENDENT_RESOURCE_UUIDS/.test(stagingDependentDeployStep.text) || /coolify-deploy\.mjs\s+trigger\s+"\$STAGING_DEPENDENT_RESOURCE_UUIDS"/.test(stagingDependentDeployStep.text)) ) { failures.push(`${WORKFLOW_PATH}:${stagingDependentDeployStep.lineNumber}: Coolify staging dependent resources must deploy one UUID per request`); } if (productionDeployStep && /coolify-deploy\.mjs\s+trigger\s+"\$PRODUCTION_WEB_RESOURCE_UUID"/.test(productionDeployStep.text)) { passes.push(`${WORKFLOW_PATH}:${productionDeployStep.lineNumber}: Coolify production deploy triggers the migration-bearing web app first`); } else if (productionDeployStep) { failures.push(`${WORKFLOW_PATH}:${productionDeployStep.lineNumber}: Coolify production deploy must trigger only PRODUCTION_WEB_RESOURCE_UUID first`); } if (productionDeployStep && /deploy\?uuid=\$COOLIFY_RESOURCE_UUIDS/.test(productionDeployStep.text)) { failures.push(`${WORKFLOW_PATH}:${productionDeployStep.lineNumber}: Coolify production deploy must not trigger all resources before migrations complete`); } if ( productionDependentDeployStep && productionWaitStep && productionDependentDeployStep.lineNumber > productionWaitStep.lineNumber && /for RESOURCE_UUID in \$\(printf "%s" "\$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" \| tr ',' ' '\)/.test(productionDependentDeployStep.text) && /coolify-deploy\.mjs\s+trigger\s+"\$RESOURCE_UUID"/.test(productionDependentDeployStep.text) ) { passes.push(`${WORKFLOW_PATH}:${productionDependentDeployStep.lineNumber}: Coolify production dependent resources deploy after web health`); } else { failures.push(`${WORKFLOW_PATH}: Coolify production dependent resources must deploy only after the web migration app is healthy`); } if ( productionDependentDeployStep && (/deploy\?uuid=\$PRODUCTION_DEPENDENT_RESOURCE_UUIDS/.test(productionDependentDeployStep.text) || /coolify-deploy\.mjs\s+trigger\s+"\$PRODUCTION_DEPENDENT_RESOURCE_UUIDS"/.test(productionDependentDeployStep.text)) ) { failures.push(`${WORKFLOW_PATH}:${productionDependentDeployStep.lineNumber}: Coolify production dependent resources must deploy one UUID per request`); } return { passes, failures, observations: [] }; } export function checkCoolifyStagingHealthWaitContract(content, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const triggerStep = steps.find( ({ text }) => /\bTrigger Coolify staging deploy\b/i.test(text) || /coolify-deploy\.mjs\s+trigger/.test(text) || /\bcurl\b[\s\S]*\/deploy\?uuid=/.test(text) ); const waitStep = steps.find(({ text }) => /\bWait for Coolify staging health\b/i.test(text)); if (!waitStep) { failures.push(`${WORKFLOW_PATH}: missing Coolify staging health wait step`); return { passes, failures, observations: [] }; } if (/applications\/\$RESOURCE_UUID/.test(waitStep.text) && /running:healthy/.test(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait polls application status`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait must poll application status until running:healthy`); } if (verifiesLiveHealthRevision(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait verifies live revision when a health URL exists`); } else { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait must verify /healthz revision against IMAGE_TAG when a health URL exists` ); } if (/has no health URL; live revision probe skipped["']/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait must not pass-open missing health URLs for HTTP resources`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait fails missing health URLs for HTTP resources`); } if (/verify_live_revision/.test(waitStep.text) && !/verify_live_revision\s+"\$APP_JSON"\s+"[^"]+"\s+required/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging web health wait must require a live health URL`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging web health wait requires a live health URL`); } if ( /verify_live_revision\s+"\$APP_JSON"\s+"[^"]+"\s+required\s+(?:"\$IMAGE_TAG"|staging)\s+web/.test(waitStep.text) || /EXPECTED_SERVICE="\$\{5:-web\}"/.test(waitStep.text) ) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging web health wait verifies the web service identity`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging web health wait must verify service=web`); } if (/echo\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text) || /printf\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait must not print resource UUIDs`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait redacts resource UUIDs`); } if (triggerStep && waitStep.lineNumber > triggerStep.lineNumber) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait runs after deploy trigger`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify staging health wait must run after deploy trigger`); } return { passes, failures, observations: [] }; } export function checkCoolifyProductionHealthWaitContract(content, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const triggerStep = steps.find(({ text }) => /\bTrigger Coolify production deploy\b/i.test(text)); const waitStep = steps.find(({ text }) => /\bWait for Coolify production health\b/i.test(text)); if (!waitStep) { failures.push(`${WORKFLOW_PATH}: missing Coolify production health wait step`); return { passes, failures, observations: [] }; } if (/applications\/\$RESOURCE_UUID/.test(waitStep.text) && /running:healthy/.test(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait polls application status`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait must poll application status until running:healthy`); } if (verifiesLiveHealthRevision(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait verifies live revision when a health URL exists`); } else { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait must verify /healthz revision against IMAGE_TAG when a health URL exists` ); } if (/has no health URL; live revision probe skipped["']/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait must not pass-open missing health URLs for HTTP resources`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait fails missing health URLs for HTTP resources`); } if (/verify_live_revision/.test(waitStep.text) && !/verify_live_revision\s+"\$APP_JSON"\s+"[^"]+"\s+required/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production web health wait must require a live health URL`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production web health wait requires a live health URL`); } if ( /verify_live_revision\s+"\$APP_JSON"\s+"[^"]+"\s+required\s+(?:"\$IMAGE_TAG"|main)\s+web/.test(waitStep.text) || /EXPECTED_SERVICE="\$\{5:-web\}"/.test(waitStep.text) ) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production web health wait verifies the web service identity`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production web health wait must verify service=web`); } if (/echo\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text) || /printf\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait must not print resource UUIDs`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait redacts resource UUIDs`); } if (triggerStep && waitStep.lineNumber > triggerStep.lineNumber) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait runs after deploy trigger`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: Coolify production health wait must run after deploy trigger`); } return { passes, failures, observations: [] }; } function checkCoolifyDependentHealthWaitContract(content, options, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const deployStep = steps.find(({ text }) => new RegExp(`\\b${options.deployStepName}\\b`, "i").test(text)); const waitStep = steps.find(({ text }) => new RegExp(`\\b${options.waitStepName}\\b`, "i").test(text)); if (!waitStep) { failures.push(`${WORKFLOW_PATH}: missing ${options.label} dependent health wait step`); return { passes, failures, observations: [] }; } if (/applications\/\$RESOURCE_UUID/.test(waitStep.text) && /running:healthy/.test(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait polls application status`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must poll application status until running:healthy`); } if ( /DEPLOYMENT_WAIT_PIDS=/.test(waitStep.text) && /node scripts\/coolify-deploy\.mjs wait "\$DEPLOYMENT_UUID"[\s\S]*&/.test(waitStep.text) && /for PID in \$DEPLOYMENT_WAIT_PIDS; do[\s\S]*wait "\$PID"/.test(waitStep.text) ) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent deployment waits fan out and aggregate failures`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent deployment waits must fan out with PID aggregation`); } if ( new RegExp(`for RESOURCE_UUID in \\$\\(printf "%s" "\\$${options.resourceEnv}" \\| tr ',' ' '\\)`).test(waitStep.text) && /check_dependent_resource "\$RESOURCE_UUID" "\$RESOURCE_INDEX" &/.test(waitStep.text) && /RESOURCE_WAIT_PIDS=/.test(waitStep.text) && /for PID in \$RESOURCE_WAIT_PIDS; do[\s\S]*wait "\$PID"/.test(waitStep.text) ) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait checks resources concurrently with PID aggregation`); } else { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must fan out ${options.resourceEnv} one UUID per worker and aggregate failures` ); } if (verifiesLiveHealthRevision(waitStep.text)) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait verifies live revision when a health URL exists`); } else { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must verify /healthz revision against IMAGE_TAG when a health URL exists` ); } if ( new RegExp(`verify_live_revision\\s+"\\$APP_JSON"\\s+"[^"]+"\\s+auto\\s+(?:"\\$IMAGE_TAG"|${options.expectedAlias})\\s+api`).test(waitStep.text) || /EXPECTED_SERVICE="api"/.test(waitStep.text) || /EXPECTED_SERVICE="\$\{5:-api\}"/.test(waitStep.text) ) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait verifies API service identity`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must verify service=api for HTTP dependent resources`); } if ( /HTTP_RESOURCE/.test(waitStep.text) && /WORKER_RESOURCE/.test(waitStep.text) && new RegExp(`${options.label.includes("staging") ? "STAGING" : "PRODUCTION"}_WORKER_RESOURCE_UUIDS`).test(waitStep.text) && /docker exec /.test(waitStep.text) && /docker ps --filter name=\$RESOURCE_UUID/.test(waitStep.text) && /node apps\/worker\/dist\/health\.js/.test(waitStep.text) && /WORKER_REVISION/.test(waitStep.text) && /WORKER_ENABLED/.test(waitStep.text) && /workerEnabled/.test(waitStep.text) && /worker health reports workerEnabled=false/.test(waitStep.text) && /WORKER_STATUS/.test(waitStep.text) && /\[\s*"\$WORKER_STATUS"\s*!=\s*"ready"\s*\]/.test(waitStep.text) && /worker health status is not ready/.test(waitStep.text) && /WORKER_GROUP/.test(waitStep.text) && /WORKER_QUEUES/.test(waitStep.text) && /worker group does not match expected queue group/.test(waitStep.text) && /worker queues do not match expected queue group/.test(waitStep.text) && /EXPECTED_WORKER_QUEUES="[^"]*toolctrl\.email/.test(waitStep.text) && /EXPECTED_WORKER_QUEUES="[^"]*toolctrl\.notifications[^"]*toolctrl\.maintenance[^"]*toolctrl\.file-scan/.test(waitStep.text) && /EXPECTED_WORKER_QUEUES="[^"]*toolctrl\.imports[^"]*toolctrl\.exports/.test(waitStep.text) && /EXPECTED_WORKER_QUEUES="[^"]*toolctrl\.ai-chatbot[^"]*toolctrl\.browser-automation/.test(waitStep.text) && /EXPECTED_WORKER_QUEUES="[^"]*toolctrl\.plugin-invoke/.test(waitStep.text) && /for EXPECTED_WORKER_QUEUE in \$EXPECTED_WORKER_QUEUES/.test(waitStep.text) && /worker live revision matches image tag/.test(waitStep.text) && /is an HTTP resource but has no health URL/.test(waitStep.text) && /CONFIGURED_IMAGE_TAG/.test(waitStep.text) && /EXPECTED_IMAGE_ALIAS/.test(waitStep.text) && new RegExp(`EXPECTED_IMAGE_ALIAS="\\$\\{4:-${options.expectedAlias}\\}"`).test(waitStep.text) && /\[\s*"\$CONFIGURED_IMAGE_TAG"\s*!=\s*"\$EXPECTED_IMAGE_ALIAS"\s*\]/.test(waitStep.text) ) { passes.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait handles HTTP resources, worker live readiness, and non-HTTP alias tags explicitly` ); } else { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources` ); } if ( /worker execute endpoint unavailable; configured image tag matches expected alias/.test(waitStep.text) || /worker execute output unavailable; configured image tag matches expected alias/.test(waitStep.text) || /worker execute output is not JSON; configured image tag matches expected alias/.test(waitStep.text) ) { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must not pass-open worker execute failures based on image tag only` ); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait fails worker execute failures closed`); } if (/manual runtime revision verification required after deploy/.test(waitStep.text)) { failures.push( `${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must not pass worker revision verification to a manual step` ); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait enforces worker revision automatically`); } if (/has no health URL; live revision probe skipped["']/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must not pass-open missing health URLs for HTTP resources`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait fails missing health URLs for HTTP resources`); } if (/echo\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text) || /printf\s+["'][^"']*\$RESOURCE_UUID/.test(waitStep.text)) { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must not print resource UUIDs`); } else { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait redacts resource UUIDs`); } if (deployStep && waitStep.lineNumber > deployStep.lineNumber) { passes.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait runs after dependent deploy trigger`); } else { failures.push(`${WORKFLOW_PATH}:${waitStep.lineNumber}: ${options.label} dependent health wait must run after dependent deploy trigger`); } return { passes, failures, observations: [] }; } export function checkCoolifyStagingDependentHealthWaitContract(content, analysis) { return checkCoolifyDependentHealthWaitContract( content, { label: "Coolify staging", deployStepName: "Trigger Coolify staging dependent deploys", waitStepName: "Wait for Coolify staging dependent health", resourceEnv: "STAGING_DEPENDENT_RESOURCE_UUIDS", expectedAlias: "staging", }, analysis ); } export function checkCoolifyProductionDependentHealthWaitContract(content, analysis) { return checkCoolifyDependentHealthWaitContract( content, { label: "Coolify production", deployStepName: "Trigger Coolify production dependent deploys", waitStepName: "Wait for Coolify production dependent health", resourceEnv: "PRODUCTION_DEPENDENT_RESOURCE_UUIDS", expectedAlias: "main", }, analysis ); } export function checkCoolifyTriggerContract(content, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const workflowLines = getWorkflowLines(content, analysis); const coolifySteps = steps.filter( ({ text }) => /\bTrigger Coolify\b/i.test(text) || /coolify-deploy\.mjs\s+trigger/.test(text) || /\bcurl\b[\s\S]*\bdeploy\b/i.test(text) ); const literalCoolifyLine = workflowLines.find(({ line }) => LITERAL_COOLIFY_URL_PATTERN.test(line)); if (literalCoolifyLine) { failures.push( `${WORKFLOW_PATH}:${literalCoolifyLine.lineNumber}: literal Coolify/webhook URL is forbidden; use ${COOLIFY_API_URL_VAR} with ${COOLIFY_TOKEN_SECRET}` ); } else { passes.push(`${WORKFLOW_PATH}: no literal Coolify/webhook URLs are present`); } if (coolifySteps.length === 0) { failures.push(`${WORKFLOW_PATH}: missing Coolify trigger command`); return { passes, failures, observations: [] }; } const deployTriggerSteps = coolifySteps.filter(({ text }) => /coolify-deploy\.mjs\s+trigger/.test(text) || /\/deploy\?uuid=/.test(text)); for (const step of deployTriggerSteps) { const softeningReason = getRequiredStepSofteningReason(step); if (softeningReason) { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger must not use ${softeningReason}`); } else { passes.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger is not softened`); } if (/(?:--request|-X)\s+POST\b/.test(step.text) || /coolify-deploy\.mjs\s+trigger/.test(step.text)) { passes.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger uses POST`); } else { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger must use POST`); } if ( /DEPLOYMENT_UUID="\$\(node scripts\/coolify-deploy\.mjs trigger/.test(step.text) || /_DEPLOYMENT_UUID="\$\(node scripts\/coolify-deploy\.mjs trigger/.test(step.text) || /\/deploy\?uuid=[^\n]+["']?\s*>\/dev\/null/.test(step.text) ) { passes.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger suppresses raw response output`); } else { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: Coolify deploy trigger must suppress raw response output`); } } if (hasPushBranchTrigger(content, "main")) { passes.push(`${WORKFLOW_PATH}: main-branch push trigger is defined for staging deployment`); } else { failures.push(`${WORKFLOW_PATH}: deployment workflow must run on push to main`); } if (hasPushBranchTrigger(content, "staging")) { failures.push(`${WORKFLOW_PATH}: staging branch trigger is forbidden; staging must deploy from main artifacts`); } else { passes.push(`${WORKFLOW_PATH}: staging branch trigger is not used`); } const workflowDispatchInputs = content.match(/workflow_dispatch:\s*[\s\S]*?inputs:\s*([\s\S]*?)\n(?:env:|jobs:)/i)?.[1] ?? ""; if ( /^\s*action:/m.test(workflowDispatchInputs) && /^\s*staged_sha:/m.test(workflowDispatchInputs) && /^\s*backup_confirmed:/m.test(workflowDispatchInputs) && /\bDEPLOY_ACTION=stage\b/.test(content) && /\bDEPLOY_ACTION=promote\b/.test(content) ) { passes.push(`${WORKFLOW_PATH}: workflow_dispatch supports stage and promote actions with explicit staged_sha and backup confirmation`); } else { failures.push(`${WORKFLOW_PATH}: workflow_dispatch must support stage and promote actions with explicit staged_sha and backup confirmation`); } if ( /\[ "\$EVENT_NAME" = "workflow_dispatch" \] && \[ "\$ACTION" = "stage" \]/.test(content) && /Stage must be dispatched from refs\/heads\/main/.test(content) && /\[ "\$REF" != "refs\/heads\/main" \]/.test(content) ) { passes.push(`${WORKFLOW_PATH}: workflow_dispatch stage is restricted to refs/heads/main`); } else { failures.push(`${WORKFLOW_PATH}: workflow_dispatch stage must be restricted to refs/heads/main`); } const usesApiDeployContract = content.includes(COOLIFY_TOKEN_SECRET) && content.includes(COOLIFY_API_URL_VAR) && content.includes(COOLIFY_RESOURCE_UUIDS_VAR) && content.includes("${{ vars.COOLIFY_STAGING_API_URL }}") && content.includes("${{ secrets.COOLIFY_STAGING_TOKEN }}") && content.includes("${{ vars.COOLIFY_STAGING_RESOURCE_UUIDS }}") && /Authorization:\s*Bearer\s*\$(?:COOLIFY_TOKEN|COOLIFY_STAGING_TOKEN|SELECTED_COOLIFY_TOKEN)/.test(content) && /coolify-deploy\.mjs\s+trigger\s+"\$STAGING_WEB_RESOURCE_UUID"/.test(content) && /for RESOURCE_UUID in \$\(printf "%s" "\$STAGING_DEPENDENT_RESOURCE_UUIDS" \| tr ',' ' '\)/.test(content) && /coolify-deploy\.mjs\s+trigger\s+"\$PRODUCTION_WEB_RESOURCE_UUID"/.test(content) && /for RESOURCE_UUID in \$\(printf "%s" "\$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" \| tr ',' ' '\)/.test(content) && !/deploy\?uuid=\$STAGING_DEPENDENT_RESOURCE_UUIDS/.test(content) && !/deploy\?uuid=\$PRODUCTION_DEPENDENT_RESOURCE_UUIDS/.test(content); if (usesApiDeployContract) { passes.push(`${WORKFLOW_PATH}: Coolify trigger uses production and staging Coolify configuration slots`); } else { failures.push(`${WORKFLOW_PATH}: Coolify trigger must use production and staging Coolify configuration slots`); } if (!/COOLIFY_STAGING_(?:API_URL|TOKEN).*:-\$COOLIFY_(?:API_URL|TOKEN)/.test(content)) { passes.push(`${WORKFLOW_PATH}: staging Coolify configuration cannot fall back to production credentials`); } else { failures.push(`${WORKFLOW_PATH}: staging Coolify configuration must not fall back to production Coolify URL or token`); } const stagingCoolifySteps = steps.filter((step) => /staging/i.test(getStepName(step.text)) && /coolify/i.test(step.text)); const stagingCoolifyLeaks = stagingCoolifySteps.filter((step) => /\$\{?\{?\s*(?:vars\.COOLIFY_API_URL|secrets\.COOLIFY_TOKEN)\s*\}?\}?|\$COOLIFY_API_URL\b|\$COOLIFY_TOKEN\b/.test(step.text) ); if (stagingCoolifyLeaks.length === 0) { passes.push(`${WORKFLOW_PATH}: staging Coolify steps do not reference production Coolify variables`); } else { for (const step of stagingCoolifyLeaks) { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: staging Coolify steps must not reference production Coolify URL or token variables`); } } const productionCoolifySteps = steps.filter((step) => /production/i.test(getStepName(step.text)) && /coolify/i.test(step.text)); const productionCoolifyLeaks = productionCoolifySteps.filter((step) => /\$\{?\{?\s*(?:vars\.COOLIFY_STAGING_API_URL|secrets\.COOLIFY_STAGING_TOKEN)\s*\}?\}?|\$COOLIFY_STAGING_API_URL\b|\$COOLIFY_STAGING_TOKEN\b/.test(step.text) ); if (productionCoolifyLeaks.length === 0) { passes.push(`${WORKFLOW_PATH}: production Coolify steps do not reference staging Coolify variables`); } else { for (const step of productionCoolifyLeaks) { failures.push(`${WORKFLOW_PATH}:${step.lineNumber}: production Coolify steps must not reference staging Coolify URL or token variables`); } } const firstCoolifyLine = Math.min(...coolifySteps.map(({ lineNumber }) => lineNumber)); const lastImageBuildLine = Math.max(...REQUIRED_TARGETS.map((target) => getDockerTargetBuildLine(content, target, analysis))); if (lastImageBuildLine > 0 && firstCoolifyLine > lastImageBuildLine) { passes.push(`${WORKFLOW_PATH}: staging Coolify trigger runs after Docker image publication steps`); } else { failures.push(`${WORKFLOW_PATH}: staging Coolify trigger must run after all Docker image publication steps`); } if (/imagetools\s+create\s+--tag\s+"\$IMAGE:main"\s+"\$IMAGE:\$IMAGE_TAG"/.test(content)) { passes.push(`${WORKFLOW_PATH}: promote retags the verified full-SHA images to production aliases without rebuilding`); } else { failures.push(`${WORKFLOW_PATH}: promote must retag the verified full-SHA images to production aliases without rebuilding`); } return { passes, failures, observations: [] }; } export function checkRegistryLogin(content) { const passes = []; const failures = []; if (/\bdocker\s+login\b/.test(content)) { passes.push(`${WORKFLOW_PATH}: registry login command is present`); } else { failures.push(`${WORKFLOW_PATH}: missing registry login command`); } if (content.includes(USERNAME_SECRET)) { passes.push(`${WORKFLOW_PATH}: registry login uses REGISTRY_USER secret`); } else { failures.push(`${WORKFLOW_PATH}: registry login must use ${USERNAME_SECRET}`); } if (content.includes(PASSWORD_SECRET)) { passes.push(`${WORKFLOW_PATH}: registry login uses REGISTRY_PAT secret`); } else { failures.push(`${WORKFLOW_PATH}: registry login must use ${PASSWORD_SECRET}`); } return { passes, failures, observations: [] }; } export function checkDockerTargets(content, analysis) { const passes = []; const failures = []; for (const target of REQUIRED_TARGETS) { if (hasDockerTargetBuild(content, target, analysis) || /for TARGET in web api worker/.test(content)) { passes.push(`${WORKFLOW_PATH}: builds Docker target "${target}"`); } else { failures.push(`${WORKFLOW_PATH}: missing Docker build for target "${target}"`); } if ( hasDockerTargetPush(content, target, analysis) || (/for TARGET in web api worker/.test(content) && /\bdocker\s+buildx\s+build\b[\s\S]*--push/.test(content)) ) { passes.push(`${WORKFLOW_PATH}: pushes Docker target "${target}"`); } else { failures.push(`${WORKFLOW_PATH}: Docker target "${target}" must be pushed with docker buildx build --push`); } } if (/--build-arg\s+APP_REVISION="\$SHA"/.test(content)) { passes.push(`${WORKFLOW_PATH}: Docker image publication passes APP_REVISION build arg`); } else { failures.push(`${WORKFLOW_PATH}: Docker image publication must pass --build-arg APP_REVISION="$SHA"`); } const usesStableRegistryBuildCache = /CACHE_REF="\$REGISTRY\/\$IMAGE_NAMESPACE\/\$IMAGE_PREFIX\/build-cache:\$TARGET"/.test(content) && /--cache-from\s+"type=registry,ref=\$CACHE_REF"/.test(content) && /--cache-to\s+"type=registry,ref=\$CACHE_REF,mode=max(?:,[^"]*)?"/.test(content); if (usesStableRegistryBuildCache) { passes.push(`${WORKFLOW_PATH}: staging Docker builds use stable registry cache refs per image namespace and target`); } else { failures.push( `${WORKFLOW_PATH}: staging Docker builds must use --cache-from/--cache-to with a stable registry cache ref under $REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/build-cache:$TARGET` ); } const buildStep = getStepBlocks(content, analysis).find(({ text }) => /\bBuild and push staging images\b/i.test(getStepName(text)))?.text ?? ""; const usesParallelStagingBuilds = /\bBUILD_PIDS=\(\)/.test(buildStep) && /\bBUILD_TARGETS=\(\)/.test(buildStep) && /\)\s*&/.test(buildStep) && /\bBUILD_PIDS\+=\("\$!"\)/.test(buildStep) && /for INDEX in "\$\{!BUILD_PIDS\[@\]\}"; do/.test(buildStep) && /if ! wait "\$\{BUILD_PIDS\[\$INDEX\]\}"; then/.test(buildStep) && /\bBUILD_FAILED=1\b/.test(buildStep) && /exit "\$BUILD_FAILED"/.test(buildStep); if (usesParallelStagingBuilds) { passes.push(`${WORKFLOW_PATH}: staging Docker image builds run in parallel and collect per-target failures`); } else { failures.push(`${WORKFLOW_PATH}: staging Docker image builds must run web/api/worker in parallel and collect per-target failures before tagging`); } const viteBuildArgPattern = /--build-arg\s+VITE_[A-Z0-9_]+=/; if (/\bWEB_BUILD_ARGS=\(\)/.test(buildStep) && /if\s+\[\s+"\$TARGET"\s+=\s+"web"\s+\]/.test(buildStep) && viteBuildArgPattern.test(buildStep)) { passes.push(`${WORKFLOW_PATH}: Vite build args are scoped to the web Docker target`); } else { failures.push(`${WORKFLOW_PATH}: Vite build args must be scoped to the web Docker target so api/worker caches stay reusable`); } return { passes, failures, observations: [] }; } export function checkImageVariables(content, analysis) { const passes = []; const failures = []; if (/\bCONTAINER_IMAGE_PREFIX\b|\bCONTAINER_REGISTRY_NAMESPACE\b|\bCONTAINER_REGISTRY_USERNAME\b|\bCONTAINER_REGISTRY_PASSWORD\b/.test(content)) { failures.push(`${WORKFLOW_PATH}: legacy generic registry variables are forbidden; use Gitea registry host with REGISTRY_USER and REGISTRY_PAT`); } const giteaRegistryHostPattern = new RegExp(`\\bREGISTRY:\\s*${escapeRegex(GIT_HOST)}\\b`); const giteaRegistryLoginPattern = new RegExp(`\\bdocker\\s+login\\s+${escapeRegex(GIT_HOST)}\\b`); const usesGiteaRegistry = giteaRegistryHostPattern.test(content) || giteaRegistryLoginPattern.test(content); const usesNamespaceVariable = /\bIMAGE_NAMESPACE\b/.test(content); const usesImagePrefix = /\bIMAGE_PREFIX\b/.test(content); if (usesGiteaRegistry && usesNamespaceVariable && usesImagePrefix) { passes.push(`${WORKFLOW_PATH}: image references use the Gitea Package Registry namespace and boilerplate target path`); } else { failures.push(`${WORKFLOW_PATH}: image references must use ${GIT_HOST} with IMAGE_NAMESPACE and IMAGE_PREFIX`); } if (/\$IMAGE_PREFIX\/\$TARGET/.test(content)) { passes.push(`${WORKFLOW_PATH}: image paths match existing Coolify boilerplate/{web|api|worker} resources`); } else { failures.push(`${WORKFLOW_PATH}: image paths must match existing Coolify boilerplate/{web|api|worker} resources`); } const literalRegistryLine = getWorkflowLines(content, analysis).find( ({ line }) => !line.includes(PREFLIGHT_DATABASE_URL) && LITERAL_PRODUCTION_REGISTRY_PATTERN.test(line) ); if (literalRegistryLine) { failures.push(`${WORKFLOW_PATH}:${literalRegistryLine.lineNumber}: literal production registry/image URL is forbidden`); } else { passes.push(`${WORKFLOW_PATH}: no literal production registry/image URLs are present`); } return { passes, failures, observations: [] }; } export function checkTagStrategy(content, analysis) { const passes = []; const failures = []; if (/\bSHA:\s*\$\{\{\s*(?:gitea|github)\.sha\s*\}\}/i.test(content) && content.includes('IMAGE_TAG="$SHA"') && content.includes("^[0-9a-f]{40}$")) { passes.push(`${WORKFLOW_PATH}: full SHA image tag generation is defined`); } else { failures.push(`${WORKFLOW_PATH}: missing full SHA image tag generation`); } if (/CHECKOUT_SHA="\$\(git rev-parse HEAD\)"/.test(content) && /\[\s*"\$CHECKOUT_SHA"\s*!=\s*"\$SHA"\s*\]/.test(content)) { passes.push(`${WORKFLOW_PATH}: checked-out source revision is verified against the Gitea SHA before image publication`); } else { failures.push(`${WORKFLOW_PATH}: deploy workflow must verify git rev-parse HEAD matches SHA before image publication`); } const usesExplicitStagedSha = /\bDISPATCH_STAGED_SHA:\s*\$\{\{\s*(?:gitea|github)\.event\.inputs\.staged_sha\s*\}\}/.test(content) && /STAGED_SHA="\$\{DISPATCH_STAGED_SHA:-\}"/.test(content) && /Promote must be dispatched from refs\/heads\/main/.test(content) && /Promote requires staged_sha as the full 40-character staged commit SHA/.test(content) && /DISPATCH_BACKUP_CONFIRMED:\s*\$\{\{\s*(?:gitea|github)\.event\.inputs\.backup_confirmed\s*\}\}/.test(content) && /BACKUP_CONFIRMED="\$\{DISPATCH_BACKUP_CONFIRMED:-false\}"/.test(content) && /Promote requires backup_confirmed=true after production backup or restore-point readiness is confirmed/.test(content) && /if ! printf '%s' "\$STAGED_SHA" \| grep -Eq '\^\[0-9a-f\]\{40\}\$'/.test(content) && /IMAGE_TAG="\$STAGED_SHA"/.test(content); if (usesExplicitStagedSha) { passes.push(`${WORKFLOW_PATH}: promote uses explicit full staged SHA image tag with backup confirmation`); } else { failures.push(`${WORKFLOW_PATH}: promote must require staged_sha, backup confirmation, and use staged_sha as the full image tag`); } const verifiesPromotedImageProvenance = /fetch-depth:\s*0/.test(content) && /\bgit\s+cat-file\s+-e\s+"\$IMAGE_TAG\^\{commit\}"/.test(content) && /\bgit\s+merge-base\s+--is-ancestor\s+"\$IMAGE_TAG"\s+origin\/main\b/.test(content) && /for TARGET in web api worker; do[\s\S]*docker\s+buildx\s+imagetools\s+inspect\s+"\$IMAGE:\$IMAGE_TAG"\s+--format[\s\S]*docker\s+buildx\s+imagetools\s+inspect\s+"\$IMAGE:staging"\s+--format[\s\S]*SHA_DIGEST[\s\S]*STAGING_DIGEST[\s\S]*done/.test( content ); if (verifiesPromotedImageProvenance) { passes.push(`${WORKFLOW_PATH}: promote verifies staged commit ancestry and staging image digests before retagging`); } else { failures.push(`${WORKFLOW_PATH}: promote must verify staged commit ancestry and staging image digests before retagging`); } const pushesStagingAliasDuringBuild = getRunBlocks(content, analysis).some(({ command }) => command.split("\n").some((line) => /\bdocker\s+buildx\s+build\b/.test(line) && /--tag\s+"\$IMAGE:staging"/.test(line)) ); const createsStagingAliasAfterBuild = /\bdocker\s+buildx\s+imagetools\s+create\s+--tag\s+"\$IMAGE:staging"\s+"\$IMAGE:\$IMAGE_TAG"/.test(content); if (pushesStagingAliasDuringBuild) { failures.push(`${WORKFLOW_PATH}: staging aliases must be retagged only after all staging target builds succeed`); } else { passes.push(`${WORKFLOW_PATH}: staging aliases are not pushed from individual target builds`); } if (/--tag\s+"\$IMAGE:\$IMAGE_TAG"/.test(content) && createsStagingAliasAfterBuild && /--tag\s+"\$IMAGE:main"/.test(content)) { passes.push(`${WORKFLOW_PATH}: full SHA, staging, and promoted main tag generation is present`); } else { failures.push(`${WORKFLOW_PATH}: missing full SHA, staging, and promoted main tag generation`); } if (/:latest\b/.test(content)) { failures.push(`${WORKFLOW_PATH}: mutable latest tag is forbidden; use full SHA plus main tags`); } else { passes.push(`${WORKFLOW_PATH}: mutable latest tag is not used`); } return { passes, failures, observations: [] }; } export function checkSentryDeployMarkerContract(content, analysis) { const passes = []; const failures = []; const steps = getStepBlocks(content, analysis); const sentryStep = steps.find(({ text }) => /\bMark Sentry deploy\b/i.test(text)); if (!sentryStep) { failures.push(`${WORKFLOW_PATH}: missing Sentry deploy marker step`); return { passes, failures, observations: [] }; } if ( /\bDEPLOY_ACTION"\s*=\s*"promote"/.test(sentryStep.text) && /\bSENTRY_ENVIRONMENT=production\b/.test(sentryStep.text) && /RELEASE_SHA="\$\{IMAGE_TAG\}"/.test(sentryStep.text) ) { passes.push(`${WORKFLOW_PATH}:${sentryStep.lineNumber}: production Sentry deploy marker uses promoted IMAGE_TAG`); } else { failures.push(`${WORKFLOW_PATH}:${sentryStep.lineNumber}: production Sentry deploy marker must release IMAGE_TAG for promote`); } if ( /\bSENTRY_ENVIRONMENT=staging\b/.test(sentryStep.text) && /RELEASE_SHA="\$\{SHA\}"/.test(sentryStep.text) && /boilerplate-api/.test(sentryStep.text) && /boilerplate-worker/.test(sentryStep.text) && /boilerplate-web/.test(sentryStep.text) ) { passes.push(`${WORKFLOW_PATH}:${sentryStep.lineNumber}: Sentry deploy marker covers staging SHA and all runtime projects`); } else { failures.push(`${WORKFLOW_PATH}:${sentryStep.lineNumber}: Sentry deploy marker must cover staging SHA and api, worker, and web projects`); } return { passes, failures, observations: [] }; } export function checkSecretRedaction(content, analysis) { const passes = []; const failures = []; const workflowLines = getWorkflowLines(content, analysis); const secretLine = workflowLines.find(({ line }) => { if (line.includes("${{ secrets.")) { return false; } return SECRET_SHAPED_LITERAL_PATTERNS.some((pattern) => pattern.test(line)); }); if (secretLine) { failures.push(`${WORKFLOW_PATH}:${secretLine.lineNumber}: literal secret-shaped value is forbidden`); } else { passes.push(`${WORKFLOW_PATH}: no literal secret-shaped values are present`); } const unsafeSecretEcho = workflowLines.find(({ line }) => { if (!/\$\{\{\s*secrets\./.test(line)) { return false; } if (/\bdocker\s+login\b/.test(line) && /--password-stdin\b/.test(line)) { return false; } return /\b(?:echo|printf|env|printenv|set\s+-x)\b/.test(line); }); if (unsafeSecretEcho) { failures.push(`${WORKFLOW_PATH}:${unsafeSecretEcho.lineNumber}: workflow must not print secret contexts or token environment`); } else { passes.push(`${WORKFLOW_PATH}: workflow does not print secret contexts`); } const secretEnvNames = new Set(); for (const { line } of workflowLines) { const match = line.match(/^\s*([A-Z0-9_]+):\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}\s*$/); if (match) { secretEnvNames.add(match[1]); } } const unsafeSecretEnvLine = workflowLines.find(({ line }) => { if (/\bset\s+-x\b/.test(line)) { return true; } for (const name of secretEnvNames) { const envRef = new RegExp(`\\$\\{?${name}\\}?`); if (!envRef.test(line)) continue; if (/Authorization:\s*Bearer\s*\$/.test(line) || /\bdocker\s+login\b/.test(line) || /--password-stdin\b/.test(line)) { return false; } if (/\b(?:echo|printf|env|printenv)\b/.test(line) || /[?&](?:token|key|secret)=\$/.test(line)) { return true; } } return false; }); if (unsafeSecretEnvLine) { failures.push(`${WORKFLOW_PATH}:${unsafeSecretEnvLine.lineNumber}: workflow must not print or query-string secret-backed environment variables`); } else { passes.push(`${WORKFLOW_PATH}: workflow does not print secret-backed environment variables`); } 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 TOOLCTRL_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}: TOOLCTRL_GITEA_HOST_IP must be validated before writing to /etc/hosts`); } else { passes.push(`${WORKFLOW_PATH}: internal host override validates TOOLCTRL_GITEA_HOST_IP before /etc/hosts writes`); } return { passes, failures, observations: [] }; } export function checkCosignInstaller(rootDir, content) { const passes = []; const failures = []; if (/releases\/latest\/download\/cosign-linux-amd64/.test(content)) { failures.push(`${WORKFLOW_PATH}: cosign installer must not use mutable releases/latest download URLs`); } else { passes.push(`${WORKFLOW_PATH}: cosign install avoids mutable releases/latest download URLs`); } if (!/bash\s+scripts\/ci\/install-cosign\.sh/.test(content)) { failures.push(`${WORKFLOW_PATH}: cosign install must use ${COSIGN_INSTALLER_PATH}`); } else { passes.push(`${WORKFLOW_PATH}: cosign install uses ${COSIGN_INSTALLER_PATH}`); } const installerPath = path.join(rootDir, COSIGN_INSTALLER_PATH); if (!fs.existsSync(installerPath)) { failures.push(`${COSIGN_INSTALLER_PATH}: missing pinned cosign installer`); return { passes, failures, observations: [] }; } const installer = fs.readFileSync(installerPath, "utf8"); if (/COSIGN_VERSION="\$\{COSIGN_VERSION:-v[0-9]+\.[0-9]+\.[0-9]+\}"/.test(installer)) { passes.push(`${COSIGN_INSTALLER_PATH}: cosign version is pinned`); } else { failures.push(`${COSIGN_INSTALLER_PATH}: must pin COSIGN_VERSION to a concrete release`); } if (/COSIGN_SHA256="\$\{COSIGN_SHA256:-[a-f0-9]{64}\}"/.test(installer) && /sha256sum --check --status/.test(installer)) { passes.push(`${COSIGN_INSTALLER_PATH}: cosign download is verified by SHA256`); } else { failures.push(`${COSIGN_INSTALLER_PATH}: must verify the cosign download with a pinned SHA256`); } return { passes, failures, observations: [] }; } export function verifyGiteaDeployWorkflow(rootDir = process.cwd()) { if (!checkWorkflowExists(rootDir)) { return { passes: [], failures: [`${WORKFLOW_PATH}: missing required Gitea deploy workflow file`], observations: [] }; } let content; try { content = readWorkflow(rootDir); } catch (error) { return { passes: [], failures: [`${WORKFLOW_PATH}: failed to read workflow: ${error.message}`], observations: [] }; } const analysis = createWorkflowAnalysis(content); const checks = [ checkWorkflowRunners(content, analysis), checkDeployPreflightContract(content, analysis), checkDeploymentConcurrency(content), checkForbiddenCommands(content, analysis), checkDuplicateCoolifySelectedAssignments(content, analysis), checkRequiredStepHardening(content, analysis), checkDatabaseMigrationDeployContract(content, analysis), checkCoolifyTriggerContract(content, analysis), checkCoolifyStagingHealthWaitContract(content, analysis), checkCoolifyStagingDependentHealthWaitContract(content, analysis), checkCoolifyProductionHealthWaitContract(content, analysis), checkCoolifyProductionDependentHealthWaitContract(content, analysis), checkRegistryLogin(content), checkDockerTargets(content, analysis), checkImageVariables(content, analysis), checkTagStrategy(content, analysis), checkSentryDeployMarkerContract(content, analysis), checkSecretRedaction(content, analysis), checkGiteaVariableNames(content), checkCosignInstaller(rootDir, 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 formatGiteaDeployWorkflowResult(result) { const status = result.failures.length > 0 ? "FAIL" : "PASS"; return [ `${status} Gitea deploy 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 = verifyGiteaDeployWorkflow(process.cwd()); const output = formatGiteaDeployWorkflowResult(result); if (result.failures.length > 0) { process.stderr.write(output); process.exitCode = 1; } else { process.stdout.write(output); } }