diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dbbdb8c8..da049a40 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -177,10 +177,15 @@ jobs: cd "${LIVE}" echo "Hard reset live tree to origin/main (no nuclear src wipe)..." git reset --hard origin/main - # Keep env, uploads, and deps we are about to replace from stage. + # Keep env, uploads, runtime-imported furni assets, and deps we are + # about to replace from stage. The app can write furni files while it + # remains online during staging, so cleaning those paths races with + # active imports and can fail with "Directory not empty". git clean -fd \ -e .env -e .env.local -e .env.production -e .env*.local \ -e storage -e public/cache \ + -e public/swf/dcr/hof_furni \ + -e public/nitro-assets/bundled/furniture \ -e node_modules -e node_modules.prev -e .next -e .next.prev if ! git diff --exit-code HEAD -- src >/dev/null; then diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index 005835b3..b2791d8d 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -1,4 +1,13 @@ -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -42,6 +51,47 @@ describe("production deploy workflow", () => { expect(deployJob).toContain("pnpm install --frozen-lockfile"); }); + 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("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);