Accepted native Client lifecycle and Windows capture aspect phase. All Web/Server and three-platform Client checks passed.
257 lines
8.3 KiB
JavaScript
257 lines
8.3 KiB
JavaScript
#!/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 === "LICENSE" ||
|
|
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 !== "LICENSE" &&
|
|
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 <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.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 ["LICENSE", "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, "LICENSE"),
|
|
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(/<script[^>]+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 });
|
|
}
|