1785 lines
82 KiB
JavaScript
1785 lines
82 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,
|
|
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);
|
|
}
|
|
}
|