diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index 4326b147..54969499 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -13,34 +13,21 @@ describe("CI workflow", () => { expect(workflow).toContain("pnpm typecheck"); expect(workflow).toContain("pnpm test"); expect(workflow).toContain("needs: check"); - expect(workflow).toContain( - "gitea.event_name == 'push' && gitea.ref_name == 'main'", - ); - expect(workflow).toContain("worktree add --detach"); + expect(workflow).toContain("gitea.event_name == 'push'"); + expect(workflow).toContain("gitea.ref_name == 'main'"); expect(workflow).toContain("/api/health"); expect(workflow).not.toContain("github.ref"); }); it("uses Gitea SHA for check checkout", () => { - expect(workflow).toContain("gitea.sha"); expect(workflow).toContain("SKIP_ENV_VALIDATION=1"); }); - it("keeps tag release in the same workflow", () => { + it("includes tag triggers", () => { expect(workflow).toContain('tags:\n - "v*"'); - expect(workflow).toContain("Creating release for"); - expect(workflow).toContain("startsWith(gitea.ref_name, 'v')"); }); - it("applies CMS migrations on tag release before build", () => { - const release = workflow.slice(workflow.indexOf("\n release:")); - expect(release).toContain("pnpm db:migrate"); - expect(release).not.toContain("pnpm prisma:generate"); - expect(release).toContain("pnpm db:migrate"); - expect(release).toContain("src/db/schema.ts"); - const migrateAt = release.indexOf("pnpm db:migrate"); - const buildAt = release.indexOf("pnpm build"); - expect(migrateAt).toBeGreaterThan(-1); - expect(buildAt).toBeGreaterThan(migrateAt); + it("runs knip for unused code detection", () => { + expect(workflow).toContain("pnpm knip"); }); }); diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index b2791d8d..3152e398 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -1,200 +1,65 @@ -import { spawnSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -const liveVar = "${" + "LIVE}"; -const stageVar = "${" + "STAGE}"; -const userVar = "${" + "DEPLOY_USER}"; -const groupVar = "${" + "DEPLOY_GROUP}"; -const cutoverStartedVar = "${" + "CUTOVER_STARTED}"; - -function toContainLiteral(workflow: string, literal: string) { - return workflow.indexOf(literal) >= 0; -} - describe("production deploy workflow", () => { const workflow = readFileSync( resolve(process.cwd(), ".gitea/workflows/ci.yaml"), "utf8", ); const deployStart = workflow.indexOf("\n deploy:"); - const afterDeploy = workflow.indexOf("\n release:", deployStart + 1); - const deployJob = - afterDeploy > deployStart - ? workflow.slice(deployStart, afterDeploy) - : workflow.slice(deployStart); + const deployJob = deployStart > -1 ? workflow.slice(deployStart) : ""; it("is gated behind the check job", () => { expect(deployJob).toContain("needs: check"); - expect(deployJob).toContain( - "gitea.event_name == 'push' && gitea.ref_name == 'main'", - ); + expect(deployJob).toContain("gitea.event_name == 'push'"); + expect(deployJob).toContain("gitea.ref_name == 'main'"); }); - it("builds in a stage worktree while preserving live .env and storage", () => { - expect(deployJob).toContain("worktree add --detach"); - expect(deployJob).toContain("/var/tmp/atom-nexst-stage-"); - expect(toContainLiteral(deployJob, `ln -sfn "${liveVar}/.env"`)).toBe(true); - expect(deployJob).toContain("-e storage"); - expect(deployJob).toContain("DATABASE_POOL_SIZE="); - expect(deployJob).toContain("REDIS_URL is unset"); - expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1"); - expect(deployJob).toContain("pnpm install --frozen-lockfile"); + it("runs on self-hosted runner for Docker access", () => { + expect(deployJob).toContain("runs-on: self-hosted"); }); - it("preserves runtime furni assets when cleaning the live checkout", () => { - const cleanStart = deployJob.indexOf("git clean -fd \\"); - const commandLines: string[] = []; - for (const line of deployJob.slice(cleanStart).split(/\r?\n/)) { - commandLines.push(line); - if (!line.trimEnd().endsWith("\\")) break; - } - const cleanLines = commandLines.join(" "); - const excludes = Array.from( - cleanLines.matchAll(/-e\s+([^\s\\]+)/g), - ).flatMap(([, pattern]) => ["-e", pattern]); - const work = mkdtempSync(resolve(tmpdir(), "epicnext-deploy-clean-")); - const runtimeFiles = [ - "public/swf/dcr/hof_furni/icons/runtime_icon.png", - "public/swf/dcr/hof_furni/swf/runtime.swf", - "public/nitro-assets/bundled/furniture/runtime.nitro", - ]; - - try { - spawnSync("git", ["init", "--quiet"], { cwd: work }); - for (const file of [...runtimeFiles, "remove-me.tmp"]) { - const absolute = resolve(work, file); - mkdirSync(resolve(absolute, ".."), { recursive: true }); - writeFileSync(absolute, "runtime"); - } - - const clean = spawnSync("git", ["clean", "-fd", ...excludes], { - cwd: work, - encoding: "utf8", - }); - - expect(clean.status, clean.stderr).toBe(0); - for (const file of runtimeFiles) { - expect(existsSync(resolve(work, file)), file).toBe(true); - } - expect(existsSync(resolve(work, "remove-me.tmp"))).toBe(false); - } finally { - rmSync(work, { recursive: true, force: true }); - } + it("builds Docker image with production settings", () => { + expect(deployJob).toContain("docker build"); + expect(deployJob).toContain("-t epicnext-cms:latest"); + expect(deployJob).toContain("--build-arg NODE_OPTIONS"); }); - it("reclaims ownership before git operations so www-data files can be overwritten", () => { - const chownCmd = `sudo chown -R "${userVar}:${groupVar}"`; - expect(toContainLiteral(workflow, chownCmd)).toBe(true); - const reclaimAt = deployJob.indexOf(chownCmd); - expect( - toContainLiteral(deployJob, `git -C "${liveVar}" fetch origin --prune`), - ).toBe(true); - const fetchAt = deployJob.indexOf( - `git -C "${liveVar}" fetch origin --prune`, - ); - expect(reclaimAt).toBeGreaterThan(-1); - expect(fetchAt).toBeGreaterThan(reclaimAt); + it("stops and removes previous container before starting new one", () => { + expect(deployJob).toContain("docker stop epicnext-cms-app"); + expect(deployJob).toContain("docker rm epicnext-cms-app"); }); - it("avoids nuclear src wipe and verifies live src after cutover reset", () => { - expect(deployJob).not.toContain("rm -rf src"); - expect(deployJob).not.toContain("Nuclear-replacing src/"); - expect(deployJob).toContain("no-skip-worktree"); - expect(deployJob).toContain("no-assume-unchanged"); - expect(deployJob).toContain("Verified live src/ matches HEAD"); - expect(deployJob).toContain("ls-files -v"); - expect(deployJob).not.toContain("git ls-files -z"); - expect(deployJob).toContain("pnpm typecheck"); + it("starts container with host networking and restart policy", () => { + expect(deployJob).toContain("--restart always"); + expect(deployJob).toContain("--net=host"); + expect(deployJob).toContain("--name epicnext-cms-app"); }); - it("migrates while the current app is online, then swaps the built artifact", () => { - expect(deployJob).toContain("mv .next .next.prev"); - expect(toContainLiteral(deployJob, `mv "${stageVar}/.next" .next`)).toBe( - true, - ); - expect( - toContainLiteral(deployJob, `mv "${stageVar}/node_modules" node_modules`), - ).toBe(true); - expect(deployJob).toContain("mv node_modules node_modules.prev"); - expect(deployJob).toContain("Rolling back .next to previous artifact"); - expect(deployJob).toContain( - "Rolling back node_modules to previous artifact", - ); - const buildAt = deployJob.indexOf("pnpm build"); - const stopAt = deployJob.indexOf("pm2 stop next"); - const migrateAt = deployJob.indexOf("pnpm db:migrate"); - const resetAt = deployJob.indexOf("git reset --hard origin/main"); - const startLabelAt = deployJob.indexOf("Starting PM2"); - const startAt = deployJob.indexOf( - "pm2 start pnpm --name next -- start", - startLabelAt, - ); - expect(buildAt).toBeGreaterThan(-1); - expect(migrateAt).toBeGreaterThan(buildAt); - expect(resetAt).toBeGreaterThan(migrateAt); - expect(stopAt).toBeGreaterThan(resetAt); - expect(startLabelAt).toBeGreaterThan(stopAt); - expect(startAt).toBeGreaterThan(startLabelAt); - expect(deployJob.match(/pm2 stop next/g)).toHaveLength(1); - expect(deployJob.slice(stopAt, startAt)).not.toContain("sleep "); + it("runs HTTP health check after deployment", () => { + expect(deployJob).toContain("/api/health"); + expect(deployJob).toContain('"database":true'); + expect(deployJob).toContain("HEALTH_OK=0"); }); - it("does not restart the healthy app when deployment fails before cutover", () => { - const handlerStart = deployJob.indexOf("error_handler() {"); - const handlerEnd = deployJob.indexOf("trap 'error_handler", handlerStart); - const handler = deployJob.slice(handlerStart, handlerEnd); - const cutoverGuardAt = handler.indexOf( - `if [ "${cutoverStartedVar}" = "1" ]; then`, - ); - const restartAt = handler.indexOf("pm2 restart next", cutoverGuardAt); - const stageCleanupAt = handler.indexOf(`if [ -n "${stageVar}"`, restartAt); - - expect(handlerStart).toBeGreaterThan(-1); - expect(cutoverGuardAt).toBeGreaterThan(-1); - expect(restartAt).toBeGreaterThan(cutoverGuardAt); - expect(stageCleanupAt).toBeGreaterThan(restartAt); - expect(handler.slice(restartAt, stageCleanupAt)).toContain( - "\n fi", - ); + it("cleans up old Docker images after deploy", () => { + expect(deployJob).toContain("docker image prune -f"); }); it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => { expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES"); }); - it("runs typecheck before build in the stage", () => { - const typecheckAt = deployJob.indexOf("pnpm typecheck"); - const buildAt = deployJob.indexOf("pnpm build"); - expect(typecheckAt).toBeGreaterThan(-1); - expect(buildAt).toBeGreaterThan(typecheckAt); + it("does not use pnpm test in deploy", () => { expect(deployJob).not.toContain("pnpm test"); }); - it("exports APP_VERSION from git for the deployment ID fallback", () => { - const exportCmd = `export APP_VERSION="$(git -C "${liveVar}" rev-parse --short origin/main)"`; - expect(toContainLiteral(workflow, exportCmd)).toBe(true); + it("reports deploy logs on health check failure", () => { + expect(deployJob).toContain("docker logs epicnext-cms-app"); }); - it("runs an HTTP health check before declaring deploy success", () => { - expect(workflow).toContain("/api/health"); - expect(workflow).toContain('"database":true'); - expect(deployJob).toContain("export PORT="); - expect(deployJob).toContain("free_tcp_port"); - const startAt = deployJob.indexOf("pm2 start pnpm --name next -- start"); - const healthAt = deployJob.indexOf("/api/health"); - const successAt = deployJob.indexOf("--- Deployed successfully ---"); - expect(startAt).toBeGreaterThan(-1); - expect(healthAt).toBeGreaterThan(startAt); - expect(successAt).toBeGreaterThan(healthAt); + it("produces meaningful error on health failure", () => { + expect(deployJob).toContain("ERROR: Health check failed!"); }); });