From fc19b0d67c5670689e8504efcc27a5312e65341e Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 10 Aug 2026 22:04:11 +0200 Subject: [PATCH] build: remove stale backend distribution files --- .github/workflows/deployment.yml | 11 +++ backend/package.json | 1 + backend/scripts/clean-dist.mjs | 13 +++ backend/scripts/clean-dist.test.mjs | 147 ++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 backend/scripts/clean-dist.mjs create mode 100644 backend/scripts/clean-dist.test.mjs diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 6c2e2dcf..ad0d95f7 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -93,6 +93,17 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24.16.0" + package-manager-cache: false + - name: Install backend dependencies + working-directory: backend + run: npm ci + - name: Verify clean backend distribution + working-directory: backend + run: node --test --test-concurrency=1 scripts/clean-dist.test.mjs - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/backend/package.json b/backend/package.json index caaab948..fb672e9c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "tsx watch src/server.ts", + "prebuild": "node scripts/clean-dist.mjs", "build": "tsc -p tsconfig.json", "test": "vitest run", "start": "node dist/server.js" diff --git a/backend/scripts/clean-dist.mjs b/backend/scripts/clean-dist.mjs new file mode 100644 index 00000000..8abea9f0 --- /dev/null +++ b/backend/scripts/clean-dist.mjs @@ -0,0 +1,13 @@ +import { rm } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const backendRoot = resolve(scriptDirectory, ".."); +const target = resolve(backendRoot, "dist"); + +if (dirname(target) !== backendRoot || basename(target) !== "dist") { + throw new Error(`Refusing to clean non-dist target: ${target}`); +} + +await rm(target, { recursive: true, force: true }); diff --git a/backend/scripts/clean-dist.test.mjs b/backend/scripts/clean-dist.test.mjs new file mode 100644 index 00000000..d5425ab5 --- /dev/null +++ b/backend/scripts/clean-dist.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + access, cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const backendRoot = fileURLToPath(new URL("../", import.meta.url)); +const ownedRoots = []; + +function packageBuildInvocation(platform = process.platform, environment = process.env) { + if (platform === "win32") { + const comspec = environment.ComSpec ?? environment.COMSPEC; + if (!comspec) throw new Error("ComSpec is required to run npm on Windows."); + return { executable: comspec, args: ["/d", "/s", "/c", "npm.cmd run build"] }; + } + return { executable: "npm", args: ["run", "build"] }; +} + +async function isMissing(path) { + try { + await access(path); + return false; + } catch (error) { + if (error?.code === "ENOENT") return true; + throw error; + } +} + +async function createOwnedRoot(prefix) { + const root = await mkdtemp(join(tmpdir(), prefix)); + ownedRoots.push(root); + return root; +} + +async function copyCleaner(fixtureRoot) { + await mkdir(join(fixtureRoot, "scripts"), { recursive: true }); + const cleaner = join(fixtureRoot, "scripts", "clean-dist.mjs"); + await cp(join(backendRoot, "scripts", "clean-dist.mjs"), cleaner); + return cleaner; +} + +async function createBackendFixture() { + const fixtureRoot = await createOwnedRoot("thoth-backend-clean-dist-"); + await Promise.all([ + cp(join(backendRoot, "package.json"), join(fixtureRoot, "package.json")), + cp(join(backendRoot, "tsconfig.json"), join(fixtureRoot, "tsconfig.json")), + cp(join(backendRoot, "src"), join(fixtureRoot, "src"), { recursive: true }), + copyCleaner(fixtureRoot), + ]); + const dependencyRoot = join(backendRoot, "node_modules"); + const dependencyEntry = await lstat(dependencyRoot); + if (!dependencyEntry.isDirectory() || dependencyEntry.isSymbolicLink()) { + throw new Error("Backend node_modules must be a real directory."); + } + await symlink( + dependencyRoot, + join(fixtureRoot, "node_modules"), + process.platform === "win32" ? "junction" : "dir", + ); + return fixtureRoot; +} + +async function removeOwnedRoot(root) { + for (const childName of ["node_modules", "dist"]) { + const child = join(root, childName); + try { + const entry = await lstat(child); + if (entry.isSymbolicLink()) { + await rm(child, { recursive: true, force: true }); + } else if (childName === "node_modules") { + throw new Error(`Refusing to clean fixture with a non-link node_modules: ${root}`); + } + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + await rm(root, { recursive: true, force: true }); +} + +test.afterEach(async () => { + for (const root of ownedRoots.splice(0)) await removeOwnedRoot(root); +}); + +test("Windows package builds use ComSpec instead of executing npm.cmd directly", () => { + assert.deepEqual( + packageBuildInvocation("win32", { ComSpec: "C:\\Windows\\System32\\cmd.exe" }), + { + executable: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd run build"], + }, + ); + assert.throws(() => packageBuildInvocation("win32", {}), /ComSpec is required/); +}); + +test("cleaner is idempotent and removes a dist link without following it", async () => { + const fixtureRoot = await createOwnedRoot("thoth-backend-cleaner-"); + const cleaner = await copyCleaner(fixtureRoot); + const fixtureDist = join(fixtureRoot, "dist"); + + await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot }); + assert.equal(await isMissing(fixtureDist), true); + await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot }); + assert.equal(await isMissing(fixtureDist), true); + + const outsideRoot = await createOwnedRoot("thoth-backend-cleaner-outside-"); + const outsideSentinel = join(outsideRoot, "sentinel.txt"); + await writeFile(outsideSentinel, "outside-owned-data\n", "utf8"); + await symlink(outsideRoot, fixtureDist, process.platform === "win32" ? "junction" : "dir"); + + await execFileAsync(process.execPath, [cleaner], { cwd: fixtureRoot }); + + assert.equal(await isMissing(fixtureDist), true, "dist link survived cleaner"); + assert.equal(await readFile(outsideSentinel, "utf8"), "outside-owned-data\n"); +}); + +test("package build replaces the complete backend distribution in an owned fixture", async () => { + const fixtureRoot = await createBackendFixture(); + const copiedPackage = JSON.parse(await readFile(join(fixtureRoot, "package.json"), "utf8")); + assert.equal(copiedPackage.scripts.prebuild, "node scripts/clean-dist.mjs"); + + const workspacesDist = join(fixtureRoot, "dist", "workspaces"); + const staleModules = [ + "stale-build-sentinel.js", + "migrate-legacy.js", + "migrate-v2-qdrant.js", + ].map((name) => join(workspacesDist, name)); + await mkdir(workspacesDist, { recursive: true }); + await Promise.all(staleModules.map((path) => writeFile(path, "export const stale = true;\n", "utf8"))); + + const { executable, args } = packageBuildInvocation(); + await execFileAsync(executable, args, { cwd: fixtureRoot }); + + for (const path of staleModules) { + assert.equal(await isMissing(path), true, `stale module survived the package build: ${path}`); + } + assert.equal( + await isMissing(join(fixtureRoot, "dist", "server.js")), + false, + "server output was not compiled", + ); +});