From dfab743a719e9ccecc5296cfcc471314751154e4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 11 Jul 2026 20:30:51 +0200 Subject: [PATCH] fix: harden cross-platform release builds Skip direct POSIX entrypoint execution on Windows CI and prevent test-support modules from entering clean build or npm package output. --- package.json | 5 +- src/buildContents.test.ts | 111 +++++++++++++++++++++++ src/docker/piWebDockerEntrypoint.test.ts | 5 +- tsconfig.build.json | 2 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/buildContents.test.ts diff --git a/package.json b/package.json index b21f6db..8330ea6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "files": [ "dist", + "!dist/**/*.testSupport.*", "install.sh", "README.md", "LICENSE", @@ -29,7 +30,7 @@ "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "dev:plugins": "node scripts/build-plugins.mjs --watch", - "build": "tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build", + "build": "npm run clean && tsc -p tsconfig.build.json && npm run build:plugin-api && npm run build:plugins && vite build", "build:plugin-api": "tsc -p tsconfig.plugin-api.json", "build:plugins": "tsc -p tsconfig.plugins.json && node scripts/build-plugins.mjs", "capture:screenshots": "node scripts/capture-screenshots.mjs", @@ -40,7 +41,7 @@ "verify": "npm run typecheck && npm run lint && npm run knip && npm test", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts", - "clean": "rm -rf dist", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "prepack": "npm run build", "pack:dry": "npm pack --dry-run", "prepublishOnly": "npm run verify", diff --git a/src/buildContents.test.ts b/src/buildContents.test.ts new file mode 100644 index 0000000..2ce3a53 --- /dev/null +++ b/src/buildContents.test.ts @@ -0,0 +1,111 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("production build contents", () => { + it("keeps test-support modules out of the TypeScript build graph", () => { + const buildConfig = readBuildConfig(); + const program = ts.createProgram({ rootNames: buildConfig.fileNames, options: buildConfig.options }); + const projectSources = program.getSourceFiles() + .map((sourceFile) => normalizePath(relative(repoRoot, sourceFile.fileName))) + .filter((path) => path.startsWith("src/")); + + expect(projectSources).toContain("src/server/app.ts"); + expect(projectSources.filter(isTestSupportPath)).toEqual([]); + }); + + it("keeps test-support artifacts out of the npm tarball", async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "pi-web-package-contents-")); + try { + const fixtureDist = join(fixtureRoot, "dist", "server"); + await mkdir(fixtureDist, { recursive: true }); + await Promise.all([ + copyFile(join(repoRoot, "package.json"), join(fixtureRoot, "package.json")), + writeFile(join(fixtureDist, "app.js"), "export {};\n", "utf8"), + writeFile(join(fixtureDist, "app.testSupport.js"), "export {};\n", "utf8"), + writeFile(join(fixtureDist, "app.testSupport.js.map"), "{}\n", "utf8"), + ]); + + const npmExecPath = process.env["npm_execpath"]; + if (npmExecPath === undefined || npmExecPath.length === 0) { + throw new Error("npm_execpath is required to verify npm package contents"); + } + const stdout = await execUtf8(process.execPath, [npmExecPath, "pack", "--dry-run", "--json", "--ignore-scripts"], fixtureRoot); + const packagedFiles = packageFilePaths(stdout); + + expect(packagedFiles).toContain("dist/server/app.js"); + expect(packagedFiles.filter(isTestSupportPath)).toEqual([]); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + }); +}); + +function readBuildConfig(): ts.ParsedCommandLine { + const configPath = join(repoRoot, "tsconfig.build.json"); + const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(formatDiagnostics([diagnostic])); + }, + }); + if (config === undefined) throw new Error(`Unable to parse ${configPath}`); + if (config.errors.length > 0) throw new Error(formatDiagnostics(config.errors)); + return config; +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => repoRoot, + getNewLine: () => "\n", + }); +} + +function normalizePath(path: string): string { + return path.split(sep).join("/"); +} + +function isTestSupportPath(path: string): boolean { + return path.includes(".testSupport."); +} + +function execUtf8(file: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + execFile(file, args, { cwd, encoding: "utf8" }, (error, stdout) => { + if (error !== null) { + reject(error instanceof Error ? error : new Error("Command failed")); + return; + } + resolvePromise(stdout); + }); + }); +} + +function packageFilePaths(output: string): string[] { + const parsed: unknown = JSON.parse(output); + if (!Array.isArray(parsed) || parsed.length !== 1) throw new Error("npm pack returned an unexpected result"); + + const packResult: unknown = parsed[0]; + if (!isRecord(packResult)) throw new Error("npm pack result was not an object"); + const filesValue = packResult["files"]; + if (!Array.isArray(filesValue)) throw new Error("npm pack result did not include files"); + const files: unknown[] = filesValue; + + return files.map((file) => { + if (!isRecord(file) || typeof file["path"] !== "string") { + throw new Error("npm pack returned an invalid file entry"); + } + return file["path"]; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/docker/piWebDockerEntrypoint.test.ts b/src/docker/piWebDockerEntrypoint.test.ts index bd3147d..6d0eeb8 100644 --- a/src/docker/piWebDockerEntrypoint.test.ts +++ b/src/docker/piWebDockerEntrypoint.test.ts @@ -10,7 +10,10 @@ const execFile = promisify(execFileCallback); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); describe("pi-web-docker entrypoint", () => { - it("streams detached helper logs inline after scheduling runtime updates", async () => { + // The entrypoint intentionally supports POSIX hosts, so Windows CI cannot execute it directly. + const posixHostIt = it.skipIf(process.platform === "win32"); + + posixHostIt("streams detached helper logs inline after scheduling runtime updates", async () => { const tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-entrypoint-")); try { const runtimeRoot = join(tempDir, "runtime"); diff --git a/tsconfig.build.json b/tsconfig.build.json index 05cc306..1e1e613 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,5 +8,5 @@ "sourceMap": true }, "include": ["src/cli.ts", "src/server/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.testSupport.ts"] }