332 lines
18 KiB
JavaScript
332 lines
18 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { verifyDeployReadiness } from "./verify-deploy-readiness.mjs";
|
|
|
|
const fixtures = [];
|
|
|
|
function writeFixtureFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function createFixture(overrides = {}) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "deploy-readiness-"));
|
|
fixtures.push(rootDir);
|
|
|
|
for (const relativePath of [
|
|
"package.json",
|
|
".gitea/workflows/deploy.yml",
|
|
".gitea/workflows/ci.yml",
|
|
"docs/deployment.md",
|
|
"docs/ops/asset-cdn.md",
|
|
"docs/observability/sentry-selfhost.md",
|
|
"docs/worker.md",
|
|
"docs/ops/worker-queues.md",
|
|
"scripts/coolify-ensure-worker-groups.mjs",
|
|
"apps/worker/scripts/build.mjs",
|
|
"apps/worker/src/deploy-health.ts",
|
|
"apps/mcp/scripts/build.mjs",
|
|
"apps/mcp/src/deploy-health.ts",
|
|
"Dockerfile",
|
|
"start.sh",
|
|
"scripts/verify-migrator-role.mjs",
|
|
]) {
|
|
writeFixtureFile(rootDir, relativePath, overrides[relativePath] ?? fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"));
|
|
}
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
function packageJsonWithoutWorkflowOutboxTarget(target) {
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
|
packageJson.scripts["verify:workflow-outbox-native"] = packageJson.scripts["verify:workflow-outbox-native"]
|
|
.split(/\s+/)
|
|
.filter((part) => part !== target)
|
|
.join(" ");
|
|
return `${JSON.stringify(packageJson, null, 2)}\n`;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-deploy-readiness", () => {
|
|
it("accepts the repository deploy readiness contract", () => {
|
|
const result = spawnSync(process.execPath, ["scripts/verify-deploy-readiness.mjs"], {
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Deploy readiness contract verified.");
|
|
});
|
|
|
|
it("fails when the MCP stage and promote gates stop consuming readiness", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replace('if [ "$RESOURCE_KIND" = "mcp" ]; then', 'if [ "$RESOURCE_KIND" = "api" ]; then');
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain('.gitea deploy workflow requires if [ "$RESOURCE_KIND" = "mcp" ]; then at least 2 times (found 1)');
|
|
});
|
|
|
|
it("fails when the exact deployment revision is no longer checked out", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replaceAll('git checkout --detach "$IMAGE_TAG"', "git checkout --detach main");
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain('.gitea deploy workflow missing git checkout --detach "$IMAGE_TAG"');
|
|
});
|
|
|
|
it("fails when staging builds bypass the isolated deploy builder", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replace('docker buildx build --builder "$DEPLOY_BUILDER_NAME" --push --target', "docker buildx build --push --target");
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain(
|
|
'.gitea deploy workflow missing docker buildx build --builder "$DEPLOY_BUILDER_NAME" --push --target'
|
|
);
|
|
});
|
|
|
|
it("fails when either Hocuspocus rollout stops consuming Docker readiness", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replace('if [ "$RESOURCE_KIND" = "hocuspocus" ]; then', 'if [ "$RESOURCE_KIND" = "hocuspocus-disabled" ]; then');
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain('.gitea deploy workflow requires if [ "$RESOURCE_KIND" = "hocuspocus" ]; then at least 2 times (found 1)');
|
|
});
|
|
|
|
it("fails when the migration entrypoint skips the semantic role probe", () => {
|
|
const entrypoint = fs.readFileSync(path.join(process.cwd(), "start.sh"), "utf8").replace(" node /myapp/scripts/verify-migrator-role.mjs\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ "start.sh": entrypoint }));
|
|
|
|
expect(result.failures).toContain("migration entrypoint missing node /myapp/scripts/verify-migrator-role.mjs");
|
|
});
|
|
|
|
it("fails when the migration entrypoint skips reviewed Prisma baseline adoption", () => {
|
|
const entrypoint = fs
|
|
.readFileSync(path.join(process.cwd(), "start.sh"), "utf8")
|
|
.replace(" node /myapp/scripts/adopt-prisma-baseline.mjs --adopt\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ "start.sh": entrypoint }));
|
|
|
|
expect(result.failures).toContain("migration entrypoint missing node /myapp/scripts/adopt-prisma-baseline.mjs --adopt");
|
|
});
|
|
|
|
it("fails when the migration entrypoint skips post-migration Prisma schema verification", () => {
|
|
const entrypoint = fs
|
|
.readFileSync(path.join(process.cwd(), "start.sh"), "utf8")
|
|
.replace(" node /myapp/scripts/adopt-prisma-baseline.mjs --verify-current-schema\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ "start.sh": entrypoint }));
|
|
|
|
expect(result.failures).toContain("migration entrypoint missing node /myapp/scripts/adopt-prisma-baseline.mjs --verify-current-schema");
|
|
});
|
|
|
|
it("fails when the migration entrypoint skips the post-migration permission catalog sync", () => {
|
|
const entrypoint = fs
|
|
.readFileSync(path.join(process.cwd(), "start.sh"), "utf8")
|
|
.replace(" node /myapp/packages/db/dist/deploy-permission-catalog-sync.server.js\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ "start.sh": entrypoint }));
|
|
|
|
expect(result.failures).toContain(
|
|
"migration entrypoint missing node /myapp/packages/db/dist/deploy-permission-catalog-sync.server.js"
|
|
);
|
|
});
|
|
|
|
it("fails when the migrator role probe stops rejecting superusers", () => {
|
|
const probe = fs
|
|
.readFileSync(path.join(process.cwd(), "scripts/verify-migrator-role.mjs"), "utf8")
|
|
.replace("roles[0].isSuperuser !== false", "roles[0].isSuperuser !== true");
|
|
const result = verifyDeployReadiness(createFixture({ "scripts/verify-migrator-role.mjs": probe }));
|
|
|
|
expect(result.failures).toContain("migrator role probe missing roles[0].isSuperuser !== false");
|
|
});
|
|
|
|
it("checks worker runbook matrix rows against the Coolify provisioner contract", () => {
|
|
const result = verifyDeployReadiness(createFixture());
|
|
|
|
expect(result.failures).toEqual([]);
|
|
expect(result.passes).toContain("docs/worker.md documents worker-plugin as plugin on health 9105 and admin 9205");
|
|
expect(result.passes).toContain("docs/ops/worker-queues.md documents worker-mail as mail on health 9102 and admin 9202");
|
|
expect(result.passes).toContain("Coolify worker provisioner sets worker-general OUTBOX_DISPATCHER_REQUIRED=true");
|
|
expect(result.passes).toContain("Coolify worker provisioner sets worker-general WORKFLOW_TRIGGER_OUTBOX_REQUIRED=true");
|
|
expect(result.passes).toContain("Coolify worker provisioner sets worker-general DATABASE_RUNTIME_ROLE=hivegrid_worker");
|
|
expect(result.passes).toContain("Coolify worker provisioner sets worker-general RLS_RUNTIME_MODE=required");
|
|
expect(result.passes).toContain("Coolify worker provisioner rejects privileged worker env DIRECT_URL");
|
|
expect(result.passes).toContain("Coolify worker provisioner rejects a non-worker DATABASE_URL role");
|
|
expect(result.passes).toContain("Coolify worker provisioner detects missing worker-general API_ACCESS_TOKEN");
|
|
expect(result.passes).toContain("Coolify worker provisioner detects missing worker-general WORKFLOW_TRIGGER_EXECUTOR_URL");
|
|
expect(result.passes).toContain("Coolify worker provisioner binds worker deployment health to the immutable image SHA");
|
|
expect(result.passes).toContain("Coolify worker provisioner accepts enabled SHA-bound cmd health configuration");
|
|
expect(result.passes).toContain("Coolify worker provisioner detects stale worker health_check_enabled");
|
|
expect(result.passes).toContain("Coolify worker provisioner detects stale worker health_check_type");
|
|
expect(result.passes).toContain("Coolify worker provisioner detects stale worker health_check_command");
|
|
});
|
|
|
|
it("fails when the worker provisioner validates an unused legacy env source", () => {
|
|
const provisioner = fs
|
|
.readFileSync(path.join(process.cwd(), "scripts/coolify-ensure-worker-groups.mjs"), "utf8")
|
|
.replace(
|
|
'const workerEnvSourceUuid = needsProvisioningOrRepair ? resolveWorkerEnvSourceUuid(apps, allowed) : "";',
|
|
"const workerEnvSourceUuid = resolveWorkerEnvSourceUuid(apps, allowed);"
|
|
);
|
|
const result = verifyDeployReadiness(createFixture({ "scripts/coolify-ensure-worker-groups.mjs": provisioner }));
|
|
|
|
expect(result.failures).toContain(
|
|
'Coolify worker provisioner missing const workerEnvSourceUuid = needsProvisioningOrRepair ? resolveWorkerEnvSourceUuid(apps, allowed) : "";'
|
|
);
|
|
});
|
|
|
|
it("fails when either stage or promotion stops enforcing the SHA-bound worker command", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replace(
|
|
'EXPECTED_HEALTH_COMMAND="node apps/worker/dist/deploy-health.js $IMAGE_TAG $EXPECTED_WORKER_GROUP"',
|
|
'EXPECTED_HEALTH_COMMAND="node apps/worker/dist/health.js"'
|
|
);
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain(
|
|
'.gitea deploy workflow requires EXPECTED_HEALTH_COMMAND="node apps/worker/dist/deploy-health.js $IMAGE_TAG $EXPECTED_WORKER_GROUP" at least 2 times (found 1)'
|
|
);
|
|
});
|
|
|
|
it("fails when the worker build omits the deployment health entrypoint", () => {
|
|
const workerBuild = fs.readFileSync(path.join(process.cwd(), "apps/worker/scripts/build.mjs"), "utf8").replace(', "src/deploy-health.ts"', "");
|
|
const result = verifyDeployReadiness(createFixture({ "apps/worker/scripts/build.mjs": workerBuild }));
|
|
|
|
expect(result.failures).toContain('worker build missing "src/deploy-health.ts"');
|
|
});
|
|
|
|
it("fails when worker deployment health stops checking the exact image revision", () => {
|
|
const deployHealth = fs
|
|
.readFileSync(path.join(process.cwd(), "apps/worker/src/deploy-health.ts"), "utf8")
|
|
.replace("imageRevision !== expectedRevision || env.APP_REVISION !== expectedRevision", "!env.APP_REVISION");
|
|
const result = verifyDeployReadiness(createFixture({ "apps/worker/src/deploy-health.ts": deployHealth }));
|
|
|
|
expect(result.failures).toContain(
|
|
"worker deployment health entrypoint missing imageRevision !== expectedRevision || env.APP_REVISION !== expectedRevision"
|
|
);
|
|
});
|
|
|
|
it("fails when MCP command health stops being bound to the staged revision", () => {
|
|
const workflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/deploy.yml"), "utf8")
|
|
.replace(
|
|
'EXPECTED_MCP_HEALTH_COMMAND="node apps/mcp/dist/deploy-health.js $IMAGE_TAG"',
|
|
'EXPECTED_MCP_HEALTH_COMMAND="node apps/mcp/dist/deploy-health.js staging"'
|
|
);
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/deploy.yml": workflow }));
|
|
|
|
expect(result.failures).toContain(
|
|
'.gitea deploy workflow requires EXPECTED_MCP_HEALTH_COMMAND="node apps/mcp/dist/deploy-health.js $IMAGE_TAG" at least 2 times (found 1)'
|
|
);
|
|
});
|
|
|
|
it("fails when image-baked revision proof is removed from a runtime target", () => {
|
|
const dockerfile = fs
|
|
.readFileSync(path.join(process.cwd(), "Dockerfile"), "utf8")
|
|
.replace("RUN printf '%s\\n' \"$APP_REVISION\" > /myapp/.image-revision", "");
|
|
const result = verifyDeployReadiness(createFixture({ Dockerfile: dockerfile }));
|
|
|
|
expect(result.failures).toContain("Dockerfile requires RUN printf '%s\\n' \"$APP_REVISION\" > /myapp/.image-revision at least 3 times (found 2)");
|
|
});
|
|
|
|
it("fails when Gitea CI stops running the workflow outbox native gate", () => {
|
|
const ciWorkflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/ci.yml"), "utf8")
|
|
.replace(" - name: Verify workflow outbox native readiness\n run: pnpm run verify:workflow-outbox-native\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/ci.yml": ciWorkflow }));
|
|
|
|
expect(result.failures).toContain(".gitea CI workflow missing pnpm run verify:workflow-outbox-native");
|
|
});
|
|
|
|
it("fails when Gitea CI stops running the deploy readiness gate", () => {
|
|
const ciWorkflow = fs
|
|
.readFileSync(path.join(process.cwd(), ".gitea/workflows/ci.yml"), "utf8")
|
|
.replace(" - name: Verify deploy readiness\n run: pnpm run verify:deploy-readiness\n", "");
|
|
const result = verifyDeployReadiness(createFixture({ ".gitea/workflows/ci.yml": ciWorkflow }));
|
|
|
|
expect(result.failures).toContain(".gitea CI workflow missing pnpm run verify:deploy-readiness");
|
|
});
|
|
|
|
it("fails when web workflow producer inventory coverage falls out of the workflow outbox native gate", () => {
|
|
const target = "apps/web/app/modules/workflowEngine/routes/workflow-engine/workflow-producer-inventory.test.ts";
|
|
const result = verifyDeployReadiness(createFixture({ "package.json": packageJsonWithoutWorkflowOutboxTarget(target) }));
|
|
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${target}`);
|
|
});
|
|
|
|
it("fails when worker workflow consumer or bootstrap coverage falls out of the workflow outbox native gate", () => {
|
|
const consumerTarget = "apps/worker/src/jobs/workflow-trigger.test.ts";
|
|
const bootstrapTarget = "apps/worker/src/jobs/workflow-trigger-bootstrap.test.ts";
|
|
const boundaryTarget = "apps/worker/src/jobs/workflow-trigger-boundary.test.ts";
|
|
const storeTarget = "apps/worker/src/jobs/workflow-trigger-store.test.ts";
|
|
const healthTarget = "apps/worker/src/health.test.ts";
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
|
packageJson.scripts["verify:workflow-outbox-native"] = packageJson.scripts["verify:workflow-outbox-native"]
|
|
.split(/\s+/)
|
|
.filter((part) => ![consumerTarget, bootstrapTarget, boundaryTarget, storeTarget, healthTarget].includes(part))
|
|
.join(" ");
|
|
|
|
const result = verifyDeployReadiness(createFixture({ "package.json": `${JSON.stringify(packageJson, null, 2)}\n` }));
|
|
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${consumerTarget}`);
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${bootstrapTarget}`);
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${boundaryTarget}`);
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${storeTarget}`);
|
|
expect(result.failures).toContain(`package.json verify:workflow-outbox-native missing ${healthTarget}`);
|
|
});
|
|
|
|
it("fails when the worker runbook drifts from managed Coolify ports", () => {
|
|
const workerDocs = fs
|
|
.readFileSync(path.join(process.cwd(), "docs/worker.md"), "utf8")
|
|
.replace(
|
|
"| `worker-mail` | `WORKER_QUEUE_GROUP=mail` | none | `9102` | `9202` |",
|
|
"| `worker-mail` | `WORKER_QUEUE_GROUP=mail` | none | `9101` | `9202` |"
|
|
);
|
|
const result = verifyDeployReadiness(createFixture({ "docs/worker.md": workerDocs }));
|
|
|
|
expect(result.failures).toContain("docs/worker.md missing worker-mail row with WORKER_QUEUE_GROUP=mail, health port 9102, and admin port 9202");
|
|
});
|
|
|
|
it("fails when canonical worker fail-loud readiness guidance is removed", () => {
|
|
const workerQueueDocs = fs
|
|
.readFileSync(path.join(process.cwd(), "docs/ops/worker-queues.md"), "utf8")
|
|
.replace(/`TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true`/g, "`TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=false`");
|
|
const result = verifyDeployReadiness(createFixture({ "docs/ops/worker-queues.md": workerQueueDocs }));
|
|
|
|
expect(result.failures).toContain("canonical worker queue docs missing TRANSACTIONAL_MAIL_OUTBOX_REQUIRED=true");
|
|
});
|
|
|
|
it("fails when operator-owned deploy/backlog status markers drift", () => {
|
|
const deploymentDocs = fs
|
|
.readFileSync(path.join(process.cwd(), "docs/deployment.md"), "utf8")
|
|
.replace("B-012 is not a repository code blocker", "B-012 is a repository blocker");
|
|
const assetCdnRunbook = fs.readFileSync(path.join(process.cwd(), "docs/ops/asset-cdn.md"), "utf8").replace("remain parked", "are active");
|
|
const sentryRunbook = fs
|
|
.readFileSync(path.join(process.cwd(), "docs/observability/sentry-selfhost.md"), "utf8")
|
|
.replace("B-008 is closed", "B-008 pending");
|
|
const result = verifyDeployReadiness(
|
|
createFixture({
|
|
"docs/deployment.md": deploymentDocs,
|
|
"docs/ops/asset-cdn.md": assetCdnRunbook,
|
|
"docs/observability/sentry-selfhost.md": sentryRunbook,
|
|
})
|
|
);
|
|
|
|
expect(result.failures).toContain("deployment docs missing B-012 is not a repository code blocker");
|
|
expect(result.failures).toContain("asset CDN runbook missing remain parked");
|
|
expect(result.failures).toContain("Sentry self-host runbook missing B-008 is closed");
|
|
});
|
|
});
|