Files
Piik/scripts/package-app-release.mjs
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

265 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
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";
import { writeServerLicenseNotices } from "./package-licenses.mjs";
import { tarExecutable } from "./archive-tool.mjs";
// The Hosted deployment target. deploy/release-app.sh runs the archived binary
// as the service user, so the release is always built for linux/amd64, and
// CGO_ENABLED=0 keeps it self-contained.
const SERVER_TARGET = { goos: "linux", goarch: "amd64" };
const SERVER_NAME = "screener-server";
// Everything the archive may contain. deploy/release-app.sh enforces the same
// four names in its entry allowlist and manifest pattern; both change together.
const RUNTIME_PATHS = ["REVISION", "LICENSE", "THIRD-PARTY-NOTICES.txt", SERVER_NAME];
// The Vite output the server embeds. Its index.html names the main asset the
// deployment postflight proves over HTTP.
const CLIENT_DIST = "internal/server/webassets/dist";
function fail(message) {
throw new Error(message);
}
function run(command, args, cwd, environment = process.env) {
const result = spawnSync(command, args, {
cwd,
env: environment,
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 buildClient(cwd) {
if (process.platform === "win32") {
run(
process.env.ComSpec || "cmd.exe",
["/d", "/s", "/c", "npm run build:client"],
cwd,
);
return;
}
run("npm", ["run", "build:client"], 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 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 recordsFor(root) {
return regularFiles(root).map((absolute) => {
const path = normalizedRelative(root, absolute);
if (!RUNTIME_PATHS.includes(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 (!RUNTIME_PATHS.includes(entry)) fail(`Unexpected archive entry: ${rawEntry}`);
}
}
// mainAsset is the hashed entry script Vite named in the embedded index.html.
// The deployment postflight proves it over HTTP, so it is read from the same
// build the binary embeds rather than from the archive.
function mainAssetOf(distributionRoot) {
const html = readFileSync(join(distributionRoot, "index.html"), "utf8");
const match = html.match(/<script[^>]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/);
if (!match) fail("Built client HTML has no unique main asset");
if (!existsSync(join(distributionRoot, ...match[1].split("/")))) {
fail("Built client main asset is missing");
}
return match[1];
}
if (process.argv.length !== 3) {
fail("Usage: node scripts/package-app-release.mjs <new-output-directory>");
}
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.split(/[\\/]/)[0] !== ".." && !isAbsolute(relativeOutput))) {
fail("Output directory must be outside the repository");
}
if (existsSync(outputRoot)) fail("Output directory must not already exist");
const revision = assertCleanRevision(repositoryRoot);
if (!existsSync(join(repositoryRoot, "LICENSE"))) fail("Missing release input: LICENSE");
buildClient(repositoryRoot);
assertCleanRevision(repositoryRoot, revision);
const mainAsset = mainAssetOf(join(repositoryRoot, CLIENT_DIST));
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 });
copyFileSync(join(repositoryRoot, "LICENSE"), join(runtimeRoot, "LICENSE"));
const goCommand = process.env.SCREENER_GO?.trim() || "go";
const serverPath = join(runtimeRoot, SERVER_NAME);
run(goCommand, [
"build",
"-trimpath",
"-ldflags",
`-s -w -X main.BuildRevision=${revision}`,
"-o",
serverPath,
"./cmd/screener-server",
], repositoryRoot, {
...process.env,
GOOS: SERVER_TARGET.goos,
GOARCH: SERVER_TARGET.goarch,
CGO_ENABLED: "0",
});
chmodSync(serverPath, 0o755);
writeServerLicenseNotices(
repositoryRoot,
join(runtimeRoot, "THIRD-PARTY-NOTICES.txt"),
goCommand,
SERVER_TARGET,
);
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",
);
const tar = tarExecutable();
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 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 });
}