diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index db9a5acc46..c2c3c92d39 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -48,7 +48,12 @@ jobs: REDIS_URL: "redis://127.0.0.1:6379?connect_timeout=2" AUTH_SECRET: "ci-test-secret-key-that-is-long-enough" BCRYPT_ROUNDS: 4 - run: pnpm test --maxWorkers=1 + run: | + if [ -x /usr/bin/time ]; then + /usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2 + else + time pnpm test --maxWorkers=2 + fi - name: Knip (unused files/exports) run: pnpm knip @@ -69,132 +74,8 @@ jobs: repository: ${{ gitea.repository }} token: ${{ gitea.token }} - - name: Provide production env for build - run: | - # De Next.js-build bakt NEXT_PUBLIC_* in de client-bundle en - # valideert DATABASE_URL/HOTEL_NAME (zie src/env.ts — validatie - # overslaan is verboden voor productie). .env staat niet in git, - # dus kopieer de productie-.env van de host in de build-context. - # Hij belandt alleen in de wegwerp-builder-stage, niet in de - # runtime-image (die krijgt env via -e flags bij docker run). - cp /var/www/atom-nexst/.env .env - - - name: Build image - run: | - # --network=host is vereist: deze host heeft Docker iptables - # uitgeschakeld, dus build-containers op het bridge-netwerk - # hebben geen outbound internet (npm/pnpm zouden hangen). - DOCKER_BUILDKIT=1 docker build \ - --network=host \ - --progress=plain \ - --build-arg NODE_OPTIONS="--max-old-space-size=1536" \ - --cache-from epicnext-cms:latest \ - -t epicnext-cms:latest . - - - name: Run migrations - run: | - # Draai DB-migraties van deze commit op de host (de slimme - # runtime-image heeft geen source/tsx, zie docker-compose.yml). - # Idempotent: al toegepaste migraties worden overgeslagen. - # Gebruikt .env uit de eerdere stap (productie-DB). Vóór het - # vervangen van de container, zodat het schema klaarstaat. - pnpm install --frozen-lockfile - pnpm db:migrate - - - name: Deploy container + - name: Build, deploy and smoke test shell: bash - run: | - # Maak poort 3002 vrij: stop zowel de vorige CI-container als de - # compose-container (beide draaien op het host-netwerk). - docker stop epicnext-cms-app 2>/dev/null || true - docker rm epicnext-cms-app 2>/dev/null || true - docker stop epicnext-cms 2>/dev/null || true - docker rm epicnext-cms 2>/dev/null || true - - # Geef de productie-env 1-op-1 door. GEEN env-file-flag: `docker run` - # behoudt letterlijke quotes uit het bestand (DATABASE_URL="..." → - # ongeldige URL en crash), terwijl de shell ze correct stript. - # Sourcen + elke sleutel met -e doorgeven geeft de container exact - # dezelfde waarden als waarmee de image gebouwd is. - set -a - # shellcheck disable=SC1091 - . /var/www/atom-nexst/.env - set +a - ENV_ARGS=() - while IFS='=' read -r key _; do - case "$key" in - ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; - esac - ENV_ARGS+=(-e "$key") - done < /var/www/atom-nexst/.env - - # Zelfde env + volumes als docker-compose.yml, zodat de CI-container - # functioneel gelijk is aan de compose-container die hij vervangt. - docker run -d \ - --name epicnext-cms-app \ - --restart always \ - --net=host \ - "${ENV_ARGS[@]}" \ - -v /var/www/atom-nexst/public/nitro-assets:/app/public/nitro-assets \ - -v /var/www/atom-nexst/public/swf:/app/public/swf \ - -v /var/www/atom-nexst/storage:/app/storage \ - -v /var/www/Gamedata:/var/www/Gamedata \ - epicnext-cms:latest - - - name: Health check - run: | - for i in $(seq 1 30); do - if curl -sf --max-time 5 http://127.0.0.1:3002/api/health \ - | grep -q '"database":true'; then - echo "Deploy OK" - exit 0 - fi - echo "Waiting... ($i/30)" - sleep 3 - done - - echo "ERROR: Health check failed" >&2 - docker logs epicnext-cms-app --tail 50 >&2 || true - echo "Rolling back to compose container..." >&2 - docker stop epicnext-cms-app 2>/dev/null || true - docker rm epicnext-cms-app 2>/dev/null || true - docker compose -f /var/www/atom-nexst/docker-compose.yml up -d --no-build 2>&1 || true - exit 1 - - - name: Prune old Docker cache - if: always() - run: | - # Keep recently used layers and cache mounts for subsequent deploys. - # The age filter preserves the last 72 hours; keep-storage is a - # cleanup target, not a hard limit on recent cache disk usage. - docker builder prune -af --filter "until=72h" --keep-storage=2g || true - # Only old dangling images; application volumes and networks persist. - docker image prune -f --filter "until=168h" || true - - # ───────────────────────────────────────────── - # E2E smoke against the freshly deployed container. - # Runs after a successful deploy on main/master only - # (on PRs the deploy job is skipped, so this is skipped too). - # Public read-only endpoints only — safe against production. - # ───────────────────────────────────────────── - e2e: - needs: deploy - if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master') - runs-on: self-hosted - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - repository: ${{ gitea.repository }} - token: ${{ gitea.token }} - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Install Playwright browser - run: pnpm exec playwright install chromium - - - name: Smoke test deployed app env: - PLAYWRIGHT_BASE_URL: "http://127.0.0.1:3002" - run: pnpm test:e2e + DEPLOY_BRANCH: ${{ gitea.ref_name }} + run: bash scripts/ci-deploy.sh diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh new file mode 100644 index 0000000000..f280006344 --- /dev/null +++ b/scripts/ci-deploy.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# One lock covers build, migrations, cutover, health checks and smoke tests. +set -Eeuo pipefail + +deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}" +branch="${DEPLOY_BRANCH:-main}" +case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac +exec 9>"$deploy_dir/.deploy.lock" +flock -w 1800 9 + +sha="$(git rev-parse HEAD)" +[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; } +image="epicnext-cms:$sha" +previous_name="" +previous_image="" +backup_name="epicnext-cms-rollback" +cutover_started=0 +candidate_attempted=0 +backup_created=0 + +is_current() { + local head + head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2 + head="${head%%[[:space:]]*}" + if [ "$head" != "$sha" ]; then + echo "Skipping superseded commit $sha (branch now at $head)" + return 1 + fi +} + +check_current() { + local status=0 + is_current || status=$? + case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac +} + +healthy() { + local attempt + for attempt in $(seq 1 30); do + if curl -sf --max-time 5 http://127.0.0.1:3002/api/health | grep -q '"database":true'; then return 0; fi + sleep 3 + done + return 1 +} + +finish() { + local status=$? + trap - EXIT + if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then + echo "Deployment failed; restoring previous container" >&2 + docker logs epicnext-cms-app --tail 50 >&2 || true + if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi + if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi + if [ -n "$previous_name" ]; then + if docker start "$previous_name" && healthy; then + echo "Rollback verified: $previous_image" + else + echo "ERROR: previous container could not be restored to healthy state" >&2 + fi + else + echo "No previous container exists; rollback is unavailable" >&2 + fi + fi + exit "$status" +} +trap finish EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +check_current +cp "$deploy_dir/.env" .env +pnpm install --frozen-lockfile +# Prepare browser before cutover so installation failures cannot interrupt the site. +pnpm exec playwright install chromium + +echo "Building $image" +DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest -t "$image" . +check_current +pnpm db:migrate +check_current + +# Prefer the active CI container, or the active legacy compose container. +for name in epicnext-cms-app epicnext-cms; do + if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then + previous_name="$name" + break + fi +done +if [ -z "$previous_name" ]; then + for name in epicnext-cms-app epicnext-cms; do + if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi + done +fi +if docker inspect "$backup_name" >/dev/null 2>&1; then + echo "Unresolved rollback container exists; refusing to overwrite it" >&2 + exit 1 +fi +if [ -n "$previous_name" ]; then + previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")" + docker tag "$previous_image" epicnext-cms:previous +fi +# Remove a stopped leftover CI container when the compose container is active. +if [ "$previous_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then + docker rm epicnext-cms-app +fi + +cutover_started=1 +if [ -n "$previous_name" ]; then + docker stop "$previous_name" + docker rename "$previous_name" "$backup_name" + backup_created=1 +fi +candidate_attempted=1 +( + set -a + # shellcheck disable=SC1091 + . "$deploy_dir/.env" + set +a + ENV_ARGS=() + while IFS='=' read -r key _; do + case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac + ENV_ARGS+=(-e "$key") + done < "$deploy_dir/.env" + docker run -d --name epicnext-cms-app --restart always --net=host \ + "${ENV_ARGS[@]}" \ + -v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \ + -v "$deploy_dir/public/swf:/app/public/swf" \ + -v "$deploy_dir/storage:/app/storage" \ + -v /var/www/Gamedata:/var/www/Gamedata \ + "$image" +) +healthy +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e +# Publish the latest alias only after health and browser checks pass. +docker tag "$image" epicnext-cms:latest +cutover_started=0 +if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi + +echo "Deployment verified: $sha" +# Retain the current and previous releases; do not remove arbitrary named tags. +while IFS= read -r tag; do + if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then + tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)" + if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi + fi +done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms) +docker builder prune -af --filter "until=72h" --keep-storage=2g || true +docker image prune -f --filter "until=168h" || true diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 609bcec9f8..671ce9cfc3 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -18,6 +18,10 @@ set -uo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$DIR" || exit 2 +# Share the CI lock before pulling or touching the live application. +exec 9>"$DIR/.deploy.lock" +flock -w 1800 9 || exit 2 + LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}" PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002) diff --git a/src/lib/ci-deploy.test.ts b/src/lib/ci-deploy.test.ts new file mode 100644 index 0000000000..f91a09e215 --- /dev/null +++ b/src/lib/ci-deploy.test.ts @@ -0,0 +1,129 @@ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); +const bash = + process.platform === "win32" + ? ((process.env.PATH ?? "") + .split(delimiter) + .flatMap((dir) => [ + join(dir, "bash.exe"), + join(dirname(dir), "bin", "bash.exe"), + join(dirname(dirname(dir)), "bin", "bash.exe"), + ]) + .find((path) => existsSync(path)) ?? "bash") + : "bash"; +const sha = "a".repeat(40); +function simulate(scenario: string, previous = "epicnext-cms-app") { + const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-")); + try { + mkdirSync(join(dir, "production")); + writeFileSync(join(dir, "production", ".env"), 'HOTEL_NAME="Test Hotel"\n'); + if (previous) writeFileSync(join(dir, previous), "old\n"); + const result = spawnSync(bash, [resolve(root, "scripts/ci-deploy.sh")], { + cwd: dir, + encoding: "utf8", + timeout: 15000, + env: { + ...process.env, + BASH_ENV: resolve(root, "src/test/ci-deploy-harness.sh"), + TEST_DIR: dir.replaceAll("\\", "/"), + CMS_DEPLOY_DIR: join(dir, "production").replaceAll("\\", "/"), + TEST_SHA: sha, + SCENARIO: scenario, + DEPLOY_BRANCH: "main", + }, + }); + if (result.error) throw result.error; + return { + status: result.status, + output: result.stdout + result.stderr, + calls: existsSync(join(dir, "calls")) + ? readFileSync(join(dir, "calls"), "utf8") + : "", + app: existsSync(join(dir, "epicnext-cms-app")) + ? readFileSync(join(dir, "epicnext-cms-app"), "utf8").trim() + : null, + previousRestored: previous ? existsSync(join(dir, previous)) : false, + backup: existsSync(join(dir, "epicnext-cms-rollback")), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("deployment transaction", () => { + it("publishes the commit image only after migrations, health and smoke tests", () => { + const result = simulate("success"); + expect(result.status, result.output).toBe(0); + expect(result.app).toBe("new"); + expect(result.backup).toBe(false); + expect(result.calls.indexOf("pnpm db:migrate")).toBeLessThan( + result.calls.indexOf("docker stop"), + ); + expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan( + result.calls.indexOf( + `docker tag epicnext-cms:${sha} epicnext-cms:latest`, + ), + ); + expect(result.calls).toContain( + "docker tag sha256:old epicnext-cms:previous", + ); + }); + it.each(["run-failure", "health-failure", "smoke-failure"])( + "restores the exact previous container after %s", + (scenario) => { + const result = simulate(scenario); + expect(result.status).not.toBe(0); + expect(result.app).toBe("old"); + expect(result.output).toContain("Rollback verified: sha256:old"); + expect(result.calls).not.toContain( + `docker tag epicnext-cms:${sha} epicnext-cms:latest`, + ); + }, + ); + it("restores the legacy compose container on failure", () => { + const result = simulate("smoke-failure", "epicnext-cms"); + expect(result.status).not.toBe(0); + expect(result.app).toBeNull(); + expect(result.previousRestored).toBe(true); + expect(result.calls).toContain("docker start epicnext-cms"); + }); + it.each([ + "lock-failure", + "remote-failure", + "build-failure", + "migration-failure", + ])("leaves the active container untouched after %s", (scenario) => { + const result = simulate(scenario); + expect(result.status).not.toBe(0); + expect(result.app).toBe("old"); + expect(result.calls).not.toContain("docker stop"); + }); + it.each(["stale", "superseded"])( + "skips a %s commit without touching production", + (scenario) => { + const result = simulate(scenario); + expect(result.status, result.output).toBe(0); + expect(result.app).toBe("old"); + expect(result.calls).not.toContain("pnpm db:migrate"); + expect(result.calls).not.toContain("docker stop"); + }, + ); + it("reports a failed first deployment without inventing a rollback", () => { + const result = simulate("smoke-failure", ""); + expect(result.status).not.toBe(0); + expect(result.output).toContain("No previous container exists"); + expect(result.app).toBeNull(); + }); +}); diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index 23f874f0df..6d000c9f2e 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -58,10 +58,10 @@ describe("CI workflow", () => { expect(workflow).toContain('tags: ["v*"]'); }); - it("has e2e job that smoke-tests the deployed app", () => { - expect(workflow).toContain("e2e:"); - expect(workflow).toContain("needs: deploy"); - expect(workflow).toContain("pnpm test:e2e"); - expect(workflow).toContain("PLAYWRIGHT_BASE_URL"); + it("smoke-tests the deployed app within the deployment transaction", () => { + expect(workflow).toContain("bash scripts/ci-deploy.sh"); + const deploy = readFileSync("scripts/ci-deploy.sh", "utf8"); + expect(deploy).toContain("pnpm test:e2e"); + expect(deploy).toContain("PLAYWRIGHT_BASE_URL"); }); }); diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index f441d59373..b649a09084 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -1,104 +1,33 @@ import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; -const workflow = readFileSync( - resolve(process.cwd(), ".gitea/workflows/ci.yaml"), - "utf8", -); -const deployStart = workflow.indexOf("\n deploy:"); -const e2eStart = workflow.indexOf("\n e2e:"); -const deployJob = - deployStart > -1 - ? workflow.slice(deployStart, e2eStart > deployStart ? e2eStart : undefined) - : ""; - -describe("deploy job", () => { - it("runs on self-hosted for Docker access", () => { - expect(deployJob).toContain("runs-on: self-hosted"); - }); - - it("has checkout step", () => { - expect(deployJob).toContain("actions/checkout@v4"); - }); - - it("builds Docker image with BuildKit", () => { - expect(deployJob).toContain("DOCKER_BUILDKIT=1"); - expect(deployJob).toContain("docker build"); - expect(deployJob).toContain("-t epicnext-cms:latest"); - }); - - it("builds on host network for registry access", () => { - expect(deployJob).toContain("--network=host"); - }); - - it("stops old container before starting new one", () => { - expect(deployJob).toContain("docker stop epicnext-cms-app"); - expect(deployJob).toContain("docker rm epicnext-cms-app"); - }); - - it("starts container with correct settings", () => { - expect(deployJob).toContain("--restart always"); - expect(deployJob).toContain("--net=host"); - expect(deployJob).toContain("--name epicnext-cms-app"); - }); - - it("provides production .env to the build", () => { - expect(deployJob).toContain("cp /var/www/atom-nexst/.env .env"); - }); - - it("runs container with production env and volumes", () => { - expect(deployJob).toContain(". /var/www/atom-nexst/.env"); - // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal shell snippet - expect(deployJob).toContain('"${ENV_ARGS[@]}"'); - expect(deployJob).toContain("/var/www/Gamedata:/var/www/Gamedata"); - expect(deployJob).toContain("/app/storage"); - }); - - it("does not use --env-file (it keeps literal quotes)", () => { - expect(deployJob).not.toContain("--env-file"); - }); - - it("frees port 3002 by stopping the compose container", () => { - expect(deployJob).toContain("docker stop epicnext-cms 2>/dev/null || true"); - }); - - it("rolls back to compose on health check failure", () => { - expect(deployJob).toContain("docker compose"); - }); - - it("runs database migrations before replacing the container", () => { - expect(deployJob).toContain("pnpm db:migrate"); - expect(deployJob.indexOf("pnpm db:migrate")).toBeLessThan( - deployJob.indexOf("docker stop epicnext-cms-app"), - ); - }); - - it("runs health check", () => { - expect(deployJob).toContain("/api/health"); - expect(deployJob).toContain('"database":true'); - }); - - it("preserves recent build cache and avoids pruning application volumes", () => { - expect(deployJob).toContain( - 'docker builder prune -af --filter "until=72h" --keep-storage=2g', - ); - expect(deployJob).toContain('docker image prune -f --filter "until=168h"'); - expect(deployJob).not.toContain("docker volume prune"); - expect(deployJob).not.toContain("docker network prune"); - expect(deployJob).not.toContain("docker container prune"); - expect(deployJob).not.toContain("docker image prune -af"); - }); - - it("does not run pnpm test in deploy", () => { - expect(deployJob).not.toContain("pnpm test"); - }); - - it("shows docker logs on failure", () => { - expect(deployJob).toContain("docker logs epicnext-cms-app"); - }); - - it("exits with error on health check failure", () => { - expect(deployJob).toContain("exit 1"); - }); +const workflow = readFileSync(".gitea/workflows/ci.yaml", "utf8"); +const deploy = readFileSync("scripts/ci-deploy.sh", "utf8"); +it("uses two test workers and runs smoke checks in the deployment transaction", () => { + expect(workflow).toContain("pnpm test --maxWorkers=2"); + expect(workflow).not.toContain("\n e2e:"); + expect(workflow).toContain("bash scripts/ci-deploy.sh"); + expect(deploy).toContain( + "PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e", + ); +}); +it("locks CI and scheduled deployments using the same production lock", () => { + expect(deploy).toContain('exec 9>"$deploy_dir/.deploy.lock"'); + expect(deploy).toContain("flock -w 1800 9"); + const scheduled = readFileSync("scripts/docker-update.sh", "utf8"); + expect(scheduled).toContain('exec 9>"$DIR/.deploy.lock"'); + expect(scheduled.indexOf("flock -w 1800 9")).toBeLessThan( + scheduled.indexOf("git pull --ff-only"), + ); +}); +it("preserves production runtime configuration and recent cache", () => { + expect(deploy).toContain("--net=host"); + expect(deploy).toContain("--restart always"); + expect(deploy).toContain("/var/www/Gamedata:/var/www/Gamedata"); + expect(deploy).toContain("/app/storage"); + expect(deploy).not.toContain("--env-file"); + expect(deploy).toContain( + 'docker builder prune -af --filter "until=72h" --keep-storage=2g', + ); + expect(deploy).not.toContain("docker volume prune"); }); diff --git a/src/test/ci-deploy-harness.sh b/src/test/ci-deploy-harness.sh new file mode 100644 index 0000000000..afc73b9914 --- /dev/null +++ b/src/test/ci-deploy-harness.sh @@ -0,0 +1,44 @@ +# Sourced only by the deployment simulation tests; no external services are used. +git() { + if [ "$1" = rev-parse ]; then printf '%s\n' "$TEST_SHA"; return; fi + local n=0 + if [ -f "$TEST_DIR/remote-count" ]; then read -r n < "$TEST_DIR/remote-count"; fi + n=$((n+1)); printf '%s\n' "$n" > "$TEST_DIR/remote-count" + if [ "$SCENARIO" = remote-failure ]; then return 1; fi + if [ "$SCENARIO" = stale ] || { [ "$SCENARIO" = superseded ] && [ "$n" -gt 1 ]; }; then + printf '%s\trefs/heads/main\n' bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + else printf '%s\trefs/heads/main\n' "$TEST_SHA"; fi +} +flock() { echo "lock" >> "$TEST_DIR/calls"; [ "$SCENARIO" != lock-failure ]; } +pnpm() { + echo "pnpm $*" >> "$TEST_DIR/calls" + if [ "$1" = db:migrate ] && [ "$SCENARIO" = migration-failure ]; then return 1; fi + if [ "$1" = test:e2e ] && [ "$SCENARIO" = smoke-failure ]; then return 1; fi + return 0 +} +curl() { + if [ "$SCENARIO" = health-failure ] && [ "$(cat "$TEST_DIR/epicnext-cms-app" 2>/dev/null)" = new ]; then return 1; fi + echo '{"database":true}' +} +sleep() { :; } +docker() { + echo "docker $*" >> "$TEST_DIR/calls" + local name="${@: -1}" + case "$1" in + inspect) + [ -f "$TEST_DIR/$name" ] || return 1 + if [ "${3:-}" = '{{.State.Running}}' ]; then echo true + elif [ "${3:-}" = '{{.Image}}' ]; then echo sha256:old + fi ;; + build) [ "$SCENARIO" != build-failure ] ;; + stop) return 0 ;; + rename) mv "$TEST_DIR/$2" "$TEST_DIR/$3" ;; + run) + echo new > "$TEST_DIR/epicnext-cms-app" + [ "$SCENARIO" != run-failure ] ;; + start) [ -f "$TEST_DIR/$2" ] ;; + rm) rm -f "$TEST_DIR/$name" ;; + *) return 0 ;; + esac +} +export -f git flock pnpm curl sleep docker