#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; function fail(message) { throw new Error(message); } function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); if (result.error) throw result.error; if (result.status !== 0) { fail(`${command} failed: ${(result.stderr || result.stdout).trim()}`); } return result.stdout.trim(); } function buildRuntime(cwd) { if (process.platform === "win32") { run( process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build"], cwd, ); return; } run("npm", ["run", "build"], cwd); } function assertCleanRevision(repositoryRoot, expectedRevision) { const revision = run("git", ["rev-parse", "HEAD"], repositoryRoot).toLowerCase(); if (!/^[0-9a-f]{40}$/.test(revision)) fail("Git did not return a full revision"); if (expectedRevision && revision !== expectedRevision) { fail("Repository revision changed during release packaging"); } if (run("git", ["status", "--porcelain"], repositoryRoot) !== "") { fail("Application releases require a clean Git checkout"); } return revision; } function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } function normalizedRelative(root, path) { const value = relative(root, path).split(sep).join("/"); if ( value.length === 0 || value.startsWith("../") || value.includes("\\") || value.split("/").some((part) => part === "" || part === "." || part === "..") ) { fail(`Invalid runtime path: ${value}`); } return value; } function runtimePathAllowed(path) { return ( path === "REVISION" || path === "package.json" || path === "package-lock.json" || path.startsWith("dist/client/") || path.startsWith("dist/server/") ); } function regularFiles(root) { const files = []; const pending = [root]; while (pending.length > 0) { const directory = pending.pop(); for (const entry of readdirSync(directory, { withFileTypes: true })) { const absolute = join(directory, entry.name); const metadata = lstatSync(absolute); if (metadata.isSymbolicLink()) fail(`Runtime contains a link: ${absolute}`); if (metadata.isDirectory()) { pending.push(absolute); } else if (metadata.isFile()) { files.push(absolute); } else { fail(`Runtime contains an unsupported file type: ${absolute}`); } } } return files.sort((left, right) => normalizedRelative(root, left).localeCompare(normalizedRelative(root, right)), ); } function copyRuntimeFile(sourceRoot, targetRoot, source) { const path = normalizedRelative(sourceRoot, source); if (!runtimePathAllowed(path)) fail(`Unexpected runtime file: ${path}`); const target = join(targetRoot, ...path.split("/")); mkdirSync(dirname(target), { recursive: true }); copyFileSync(source, target); } function recordsFor(root) { return regularFiles(root).map((absolute) => { const path = normalizedRelative(root, absolute); if (!runtimePathAllowed(path)) fail(`Unexpected packaged file: ${path}`); return { path, size: statSync(absolute).size, sha256: sha256(absolute) }; }); } function compareRecords(expected, actual) { if (expected.length !== actual.length) fail("Extracted runtime file count differs"); for (let index = 0; index < expected.length; index += 1) { const left = expected[index]; const right = actual[index]; if ( left.path !== right.path || left.size !== right.size || left.sha256 !== right.sha256 ) { fail(`Extracted runtime differs at ${left.path}`); } } } function validateArchiveEntries(entries) { for (const rawEntry of entries.split(/\r?\n/)) { let entry = rawEntry; while (entry.startsWith("./")) entry = entry.slice(2); entry = entry.replace(/\/$/, ""); if (entry.length === 0) continue; if ( entry.startsWith("/") || entry.includes("\\") || entry.split("/").some((part) => part === "" || part === "." || part === "..") ) { fail(`Invalid archive entry: ${rawEntry}`); } if ( entry !== "REVISION" && entry !== "package.json" && entry !== "package-lock.json" && entry !== "dist" && !entry.startsWith("dist/client") && !entry.startsWith("dist/server") ) { fail(`Unexpected archive entry: ${rawEntry}`); } } } if (process.argv.length !== 3) { fail("Usage: node scripts/package-app-release.mjs "); } const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); const outputRoot = resolve(process.cwd(), process.argv[2]); const relativeOutput = relative(repositoryRoot, outputRoot); if (relativeOutput === "" || (!relativeOutput.startsWith("..") && !isAbsolute(relativeOutput))) { fail("Output directory must be outside the repository"); } if (existsSync(outputRoot)) fail("Output directory must not already exist"); const revision = assertCleanRevision(repositoryRoot); for (const required of ["package.json", "package-lock.json"]) { if (!existsSync(join(repositoryRoot, required))) fail(`Missing release input: ${required}`); } buildRuntime(repositoryRoot); assertCleanRevision(repositoryRoot, revision); for (const required of ["dist/client", "dist/server"]) { if (!existsSync(join(repositoryRoot, required))) fail(`Missing built runtime path: ${required}`); } const releaseId = revision.slice(0, 7); const temporaryRoot = mkdtempSync(join(tmpdir(), `screener-app-${releaseId}-`)); const runtimeRoot = join(temporaryRoot, "runtime"); const verifyRoot = join(temporaryRoot, "verify"); try { mkdirSync(runtimeRoot, { recursive: true }); for (const source of [ join(repositoryRoot, "package.json"), join(repositoryRoot, "package-lock.json"), ...regularFiles(join(repositoryRoot, "dist")), ]) { copyRuntimeFile(repositoryRoot, runtimeRoot, source); } assertCleanRevision(repositoryRoot, revision); writeFileSync(join(runtimeRoot, "REVISION"), `${revision}\n`, "ascii"); const records = recordsFor(runtimeRoot); const manifestName = `screener-${releaseId}.manifest.tsv`; const artifactName = `screener-${releaseId}-runtime.tar.gz`; const descriptorName = `screener-${releaseId}.release.json`; mkdirSync(outputRoot, { recursive: false, mode: 0o700 }); const manifestPath = join(outputRoot, manifestName); const artifactPath = join(outputRoot, artifactName); const descriptorPath = join(outputRoot, descriptorName); writeFileSync( manifestPath, records.map((record) => `${record.sha256}\t${record.size}\t${record.path}\n`).join(""), "ascii", ); run("tar", ["-czf", artifactPath, "-C", runtimeRoot, "."], repositoryRoot); validateArchiveEntries(run("tar", ["-tzf", artifactPath], repositoryRoot)); mkdirSync(verifyRoot); run("tar", ["-xzf", artifactPath, "-C", verifyRoot], repositoryRoot); compareRecords(records, recordsFor(verifyRoot)); const html = readFileSync(join(runtimeRoot, "dist/client/index.html"), "utf8"); const mainAssetMatch = html.match(/]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/); if (!mainAssetMatch) fail("Built client HTML has no unique main asset"); const mainAsset = mainAssetMatch[1]; if (!existsSync(join(runtimeRoot, "dist/client", ...mainAsset.split("/")))) { fail("Built client main asset is missing"); } const descriptor = { schema: 1, revision, releaseId, artifact: artifactName, artifactSha256: sha256(artifactPath), manifest: manifestName, manifestSha256: sha256(manifestPath), fileCount: records.length, mainAsset, }; writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`, "ascii"); process.stdout.write(`${JSON.stringify({ descriptor: descriptorPath, ...descriptor })}\n`); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); }