// Build reproducibility of @hm-design/sw (2026-10-19). // Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories // outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links // the repository node_modules (same locked tools), builds each twice and packs it. // dist must be byte-identical everywhere; the tarball must be identical for every LF checkout. // Usage: node packages/sw/tests/build-reproducibility.mjs import { execFileSync } from "node:child_process" import { createHash } from "node:crypto" import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" const packageRoot = fileURLToPath(new URL("../", import.meta.url)) const repoRoot = path.resolve(packageRoot, "../..") const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) } const work = path.resolve(process.argv[2] ?? "") const evidence = path.resolve(process.argv[3] ?? "") if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass ") await mkdir(work, { recursive: true }) if ((await readdir(work)).length) throw new Error(`${work} is not empty`) await mkdir(evidence, { recursive: true }) const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim() const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` } const sha = data => createHash("sha256").update(data).digest("hex") // SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by // packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`. const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name) async function copySource(target, crlf) { const sw = path.join(target, "packages/sw") for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) { await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true }) } await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json")) if (crlf) { const convert = async directory => { for (const item of await readdir(directory, { withFileTypes: true })) { const file = path.join(directory, item.name) if (item.isDirectory()) await convert(file) else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n")) } } await convert(sw) } // Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below). await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction") return sw } async function hashes(sw) { const out = {} const walk = async directory => { for (const item of await readdir(directory, { withFileTypes: true })) { const file = path.join(directory, item.name) if (item.isDirectory()) await walk(file) else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file)) } } await walk(path.join(sw, "dist")) return out } const npm = process.platform === "win32" ? "npm.cmd" : "npm" const runs = [] const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]] for (const [name, crlf] of layouts) { const target = path.join(work, name) const sw = await copySource(target, crlf) for (const attempt of [1, 2]) { execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" }) const files = await hashes(sw) const packDir = path.join(target, `pack-${attempt}`) await mkdir(packDir) execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" }) const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz")) runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files }) } } // Leave the junctions behind as junctions only (never recurse into the repository node_modules). for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true }) const reference = runs[0] const differences = runs.slice(1).map(run => ({ run: `${run.checkout} #${run.attempt}`, tarballSame: run.tarballSha256 === reference.tarballSha256, differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]), })) // Index and working tree of every package file must be LF (or binary) so a fresh checkout matches. const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n") const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line)) // dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF // checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical. const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0 const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences } await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n") console.log(report.status, JSON.stringify(differences)) if (!pass) process.exitCode = 1