Files
pleno-vue/scripts/release/package-dist.mjs
T
Jeppe BandJeppe Bundgaard fd26b0ee81 Package frontend releases without host zip tools (#211)
## Summary

- remove the release packager's undeclared dependency on host `zip` and
`unzip` executables
- create and round-trip validate ZIP artifacts in Node with explicit
paths, permissions, timestamps, CRC checks, and resource limits
- preserve the existing archive filename, checksum, inventory, and
top-level `dist/` contract

## Root cause

After the prebuilt-dist integrity repair passed on master, Frontend
Release reached packaging and failed with `spawn zip ENOENT` on the
self-hosted runner. The workflow never installed or checked either
archive executable.

## Verification

- focused release/deployment tests: 57/57 passed
- packager tests: 9/9 passed, including empty `PATH`, cross-timezone
determinism, exact archive entries, permissions, and oversized
sparse-file rejection
- real production build: 735 files packaged successfully with an empty
`PATH`
- repeated real packaging produced byte-identical archives
- Info-ZIP test/list/checksum validation passed
- extraction under `umask 077`: every directory is `0755`; all 735 files
extracted
- extracted inventory exactly matches the source inventory
- ESLint and Prettier passed

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-21 16:43:19 +00:00

491 lines
18 KiB
JavaScript

import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import JSZip from "jszip";
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const MAX_DIST_FILES = 10_000;
const MAX_DIST_BYTES = 256 * 1024 * 1024;
const SERVER_EXECUTABLE_EXTENSION_PATTERN =
/(?:^|\.)(?:php\d*|phtml|phar|cgi|fcgi|pl|pm|py|rb|sh|bash|zsh|fish|cmd|bat|ps1|exe|com|dll|so|dylib|jsp|jspx|asp|aspx)(?:\.|$)/i;
function comparePaths(left, right) {
return left < right ? -1 : left > right ? 1 : 0;
}
function sha256(contents) {
return crypto.createHash("sha256").update(contents).digest("hex");
}
function assertSafeRelativePath(relativePath, label = "release path") {
if (typeof relativePath !== "string" || relativePath.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (relativePath.includes("\\") || /[\0\r\n]/.test(relativePath)) {
throw new Error(`${label} contains unsafe characters: ${JSON.stringify(relativePath)}`);
}
if (path.posix.isAbsolute(relativePath)) {
throw new Error(`${label} must be relative: ${relativePath}`);
}
const segments = relativePath.split("/");
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error(`${label} contains an unsafe path segment: ${relativePath}`);
}
}
function assertSafeFileName(relativePath) {
assertSafeRelativePath(relativePath, "dist file path");
if (relativePath !== ".htaccess" && SERVER_EXECUTABLE_EXTENSION_PATTERN.test(path.posix.basename(relativePath))) {
throw new Error(`dist contains a server-executable file: ${relativePath}`);
}
}
function manifestFilePath(distDirectory, assetUrl, label) {
if (typeof assetUrl !== "string" || assetUrl.length === 0) {
throw new Error(`${label} must be a non-empty string.`);
}
if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl) || assetUrl.includes("?") || assetUrl.includes("#")) {
throw new Error(`${label} must reference a local release file: ${assetUrl}`);
}
let decodedPath;
try {
decodedPath = decodeURIComponent(assetUrl.replace(/^\/+/, ""));
} catch {
throw new Error(`${label} is not valid URL-encoded text: ${assetUrl}`);
}
assertSafeFileName(decodedPath);
const absolutePath = path.resolve(distDirectory, ...decodedPath.split("/"));
const root = path.resolve(distDirectory);
if (!absolutePath.startsWith(`${root}${path.sep}`)) {
throw new Error(`${label} escapes dist: ${assetUrl}`);
}
return { absolutePath, relativePath: decodedPath };
}
async function readJson(filePath, label) {
let contents;
try {
contents = await fsp.readFile(filePath, "utf8");
} catch (error) {
throw new Error(`${label} could not be read: ${error instanceof Error ? error.message : error}`, { cause: error });
}
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`${label} does not contain valid JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
function assertStringArray(value, label) {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
throw new Error(`${label} must be an array of non-empty strings.`);
}
return value;
}
export async function collectDistInventory(distDirectory) {
const root = path.resolve(distDirectory);
const rootStat = await fsp.lstat(root).catch(() => null);
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error(`dist directory does not exist or is not a real directory: ${root}`);
}
const files = [];
let totalBytes = 0;
const walk = async (relativeDirectory = "") => {
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
for (const name of names) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
assertSafeRelativePath(relativePath, "dist path");
const absolutePath = path.join(root, ...relativePath.split("/"));
const stat = await fsp.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error(`dist contains a symbolic link: ${relativePath}`);
}
if (stat.isDirectory()) {
await walk(relativePath);
continue;
}
if (!stat.isFile()) {
throw new Error(`dist contains an unsupported filesystem entry: ${relativePath}`);
}
assertSafeFileName(relativePath);
if (files.length >= MAX_DIST_FILES) {
throw new Error(`dist contains more than the ${MAX_DIST_FILES}-file packaging limit.`);
}
if (totalBytes + stat.size > MAX_DIST_BYTES) {
throw new Error(`dist exceeds the ${MAX_DIST_BYTES}-byte packaging limit.`);
}
const contents = await fsp.readFile(absolutePath);
totalBytes += contents.length;
if (totalBytes > MAX_DIST_BYTES) {
throw new Error(`dist exceeds the ${MAX_DIST_BYTES}-byte packaging limit.`);
}
files.push({
path: `dist/${relativePath}`,
bytes: contents.length,
sha256: sha256(contents),
});
}
};
await walk();
if (files.length === 0) {
throw new Error("dist does not contain any files.");
}
files.sort((left, right) => comparePaths(left.path, right.path));
return {
schema_version: 1,
files,
};
}
export async function validateReleaseMetadata(distDirectory, { expectedCommitSha, expectedBuildId }) {
if (typeof expectedCommitSha !== "string" || !COMMIT_SHA_PATTERN.test(expectedCommitSha)) {
throw new Error("RELEASE_COMMIT_SHA must be the full 40-character hexadecimal commit SHA.");
}
if (typeof expectedBuildId !== "string" || expectedBuildId.length === 0) {
throw new Error("RELEASE_BUILD_ID is required.");
}
const root = path.resolve(distDirectory);
const manifest = await readJson(path.join(root, "release-manifest.json"), "release-manifest.json");
const releaseEntry = await readJson(path.join(root, "release-entry.json"), "release-entry.json");
if (manifest.schema_version !== 1) {
throw new Error(`release-manifest.json schema_version must be 1, got ${JSON.stringify(manifest.schema_version)}.`);
}
if (String(manifest.commit_sha || "").toLowerCase() !== expectedCommitSha.toLowerCase()) {
throw new Error(
`release-manifest.json commit_sha ${
manifest.commit_sha || "(missing)"
} does not exactly match ${expectedCommitSha}.`
);
}
if (manifest.build_id !== expectedBuildId) {
throw new Error(
`release-manifest.json build_id ${manifest.build_id || "(missing)"} does not exactly match ${expectedBuildId}.`
);
}
if (typeof releaseEntry.entry !== "string" || releaseEntry.entry.length === 0) {
throw new Error("release-entry.json entry must be a non-empty string.");
}
const entryCss = assertStringArray(releaseEntry.css || [], "release-entry.json css");
const manifestCss = assertStringArray(manifest.css || [], "release-manifest.json css");
if (releaseEntry.entry !== manifest.entry || JSON.stringify(entryCss) !== JSON.stringify(manifestCss)) {
throw new Error("release-entry.json entry/css does not match release-manifest.json.");
}
const assetUrls = assertStringArray(manifest.asset_urls, "release-manifest.json asset_urls");
if (!manifest.asset_hashes || typeof manifest.asset_hashes !== "object" || Array.isArray(manifest.asset_hashes)) {
throw new Error("release-manifest.json asset_hashes must be an object.");
}
const requiredAssets = ["index.html", "release-entry.json", releaseEntry.entry, ...entryCss];
for (const requiredAsset of requiredAssets) {
if (!assetUrls.includes(requiredAsset)) {
throw new Error(`release-manifest.json asset_urls is missing required asset ${requiredAsset}.`);
}
}
for (const assetUrl of assetUrls) {
if (!Object.hasOwn(manifest.asset_hashes, assetUrl)) {
throw new Error(`release-manifest.json asset_hashes is missing ${assetUrl}.`);
}
}
const hashEntries = Object.entries(manifest.asset_hashes).sort(([left], [right]) => comparePaths(left, right));
if (hashEntries.length === 0) {
throw new Error("release-manifest.json asset_hashes must not be empty.");
}
for (const [assetUrl, expected] of hashEntries) {
const { absolutePath } = manifestFilePath(root, assetUrl, `release asset ${assetUrl}`);
const stat = await fsp.lstat(absolutePath).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) {
throw new Error(`release asset is missing or is not a regular file: ${assetUrl}`);
}
if (
!expected ||
typeof expected !== "object" ||
typeof expected.sha256 !== "string" ||
!SHA256_PATTERN.test(expected.sha256) ||
!Number.isSafeInteger(expected.bytes) ||
expected.bytes < 0
) {
throw new Error(`release-manifest.json contains invalid hash metadata for ${assetUrl}.`);
}
const contents = await fsp.readFile(absolutePath);
const actualHash = sha256(contents);
if (contents.length !== expected.bytes || actualHash !== expected.sha256.toLowerCase()) {
throw new Error(
`release asset integrity mismatch for ${assetUrl}: expected ${expected.bytes} bytes/${expected.sha256}, got ${contents.length} bytes/${actualHash}.`
);
}
}
return { manifest, releaseEntry };
}
function assertMatchingInventories(expected, actual, label) {
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
throw new Error(`${label} does not match the validated dist inventory.`);
}
}
function zipEntryDate() {
return new Date("2020-01-01T00:00:00.000Z");
}
function archiveDirectoryPaths(inventory) {
const directories = new Set(["dist/"]);
for (const file of inventory.files) {
const segments = file.path.split("/");
for (let depth = 1; depth < segments.length; depth += 1) {
directories.add(`${segments.slice(0, depth).join("/")}/`);
}
}
return Array.from(directories).sort(comparePaths);
}
async function createZipArchive(distDirectory, inventory, archivePath) {
const archive = new JSZip();
for (const directoryPath of archiveDirectoryPaths(inventory)) {
archive.file(directoryPath, null, {
createFolders: false,
date: zipEntryDate(),
dir: true,
unixPermissions: 0o40755,
});
}
for (const file of inventory.files) {
const relativePath = file.path.slice("dist/".length);
assertSafeFileName(relativePath);
const contents = await fsp.readFile(path.join(distDirectory, ...relativePath.split("/")));
archive.file(file.path, contents, {
binary: true,
createFolders: false,
date: zipEntryDate(),
unixPermissions: 0o100644,
});
}
const contents = await archive.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
platform: "UNIX",
streamFiles: false,
});
await fsp.writeFile(archivePath, contents, { flag: "wx" });
}
function archiveEntryName(entry) {
return entry.unsafeOriginalName || entry.name;
}
async function validateArchiveEntries(archivePath) {
let archive;
try {
archive = await JSZip.loadAsync(await fsp.readFile(archivePath), { checkCRC32: true });
} catch (error) {
throw new Error(`release archive could not be read: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
const entries = Object.values(archive.files);
if (entries.length === 0 || entries.every((entry) => entry.dir)) {
throw new Error("release archive is empty.");
}
for (const entry of entries) {
const archiveEntry = archiveEntryName(entry);
const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
if (normalizedEntry === "dist") {
if (!entry.dir) {
throw new Error("release archive contains a file at the dist root path.");
}
continue;
}
if (!normalizedEntry.startsWith("dist/")) {
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
}
assertSafeRelativePath(normalizedEntry, "archive entry");
if (!entry.dir) {
assertSafeFileName(normalizedEntry.slice("dist/".length));
}
}
return archive;
}
async function collectArchiveInventory(archive) {
const files = [];
for (const entry of Object.values(archive.files)) {
if (entry.dir) {
continue;
}
const archiveEntry = archiveEntryName(entry);
const relativePath = archiveEntry.slice("dist/".length);
assertSafeFileName(relativePath);
const contents = await entry.async("nodebuffer");
files.push({
path: `dist/${relativePath}`,
bytes: contents.length,
sha256: sha256(contents),
});
}
files.sort((left, right) => comparePaths(left.path, right.path));
return { schema_version: 1, files };
}
function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
if (!githubOutput) {
return;
}
const lines = Object.entries(values).map(([key, value]) => {
const normalizedValue = String(value);
if (/\r|\n/.test(normalizedValue)) {
throw new Error(`GitHub output ${key} contains a newline.`);
}
return `${key}=${normalizedValue}`;
});
fs.appendFileSync(githubOutput, `${lines.join("\n")}\n`);
}
export async function createReleaseArchive({
distDirectory = "dist",
outputDirectory = "release-artifacts",
expectedCommitSha,
expectedBuildId,
runId,
runAttempt,
githubOutput,
} = {}) {
if (!/^\d+$/.test(String(runId || "")) || !/^\d+$/.test(String(runAttempt || ""))) {
throw new Error("GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be numeric.");
}
const normalizedCommitSha = String(expectedCommitSha || "").toLowerCase();
const expectedRunBuildId = `${runId}-${runAttempt}`;
if (expectedBuildId !== expectedRunBuildId) {
throw new Error(`RELEASE_BUILD_ID must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT (${expectedRunBuildId}).`);
}
const resolvedDistDirectory = path.resolve(distDirectory);
const resolvedOutputDirectory = path.resolve(outputDirectory);
if (
resolvedOutputDirectory === resolvedDistDirectory ||
resolvedOutputDirectory.startsWith(`${resolvedDistDirectory}${path.sep}`)
) {
throw new Error("RELEASE_OUTPUT_DIR must not be inside dist.");
}
const inventory = await collectDistInventory(resolvedDistDirectory);
for (const requiredPath of [
"dist/.htaccess",
"dist/index.html",
"dist/release-entry.json",
"dist/release-manifest.json",
]) {
if (!inventory.files.some((file) => file.path === requiredPath)) {
throw new Error(`dist is missing required release file: ${requiredPath.slice("dist/".length)}`);
}
}
await validateReleaseMetadata(resolvedDistDirectory, { expectedCommitSha: normalizedCommitSha, expectedBuildId });
const releaseId = `${normalizedCommitSha}-${runId}-${runAttempt}`;
const archiveName = `pleno-vue-${releaseId}.zip`;
const archivePath = path.join(resolvedOutputDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const inventoryPath = path.join(resolvedOutputDirectory, `pleno-vue-${releaseId}.inventory.json`);
await fsp.mkdir(resolvedOutputDirectory, { recursive: true });
for (const outputPath of [archivePath, checksumPath, inventoryPath]) {
if (await fsp.lstat(outputPath).catch(() => null)) {
throw new Error(`refusing to overwrite existing release artifact: ${outputPath}`);
}
}
const temporaryDirectory = await fsp.mkdtemp(path.join(path.dirname(resolvedOutputDirectory), ".pleno-release-"));
try {
const sourceRoot = path.join(temporaryDirectory, "source");
const copiedDistDirectory = path.join(sourceRoot, "dist");
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
await fsp.mkdir(sourceRoot, { recursive: true });
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
const copiedInventory = await collectDistInventory(copiedDistDirectory);
assertMatchingInventories(inventory, copiedInventory, "archive source");
await createZipArchive(copiedDistDirectory, copiedInventory, temporaryArchivePath);
const archive = await validateArchiveEntries(temporaryArchivePath);
const archivedInventory = await collectArchiveInventory(archive);
assertMatchingInventories(inventory, archivedInventory, "round-trip archive");
const archiveContents = await fsp.readFile(temporaryArchivePath);
const archiveSha256 = sha256(archiveContents);
await fsp.copyFile(temporaryArchivePath, archivePath, fs.constants.COPYFILE_EXCL);
await fsp.writeFile(checksumPath, `${archiveSha256} ${archiveName}\n`, { flag: "wx" });
await fsp.writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, { flag: "wx" });
const outputs = {
build_id: expectedBuildId,
archive_name: archiveName,
archive_path: archivePath,
checksum_path: checksumPath,
inventory_path: inventoryPath,
release_id: releaseId,
archive_sha256: archiveSha256,
};
appendGithubOutputs(outputs, githubOutput);
return { ...outputs, inventory };
} catch (error) {
await Promise.all(
[archivePath, checksumPath, inventoryPath].map((outputPath) => fsp.unlink(outputPath).catch(() => {}))
);
throw error;
} finally {
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function main() {
const result = await createReleaseArchive({
distDirectory: process.env.RELEASE_DIST_DIR || "dist",
outputDirectory: process.env.RELEASE_OUTPUT_DIR || "release-artifacts",
expectedCommitSha: process.env.RELEASE_COMMIT_SHA,
expectedBuildId: process.env.RELEASE_BUILD_ID,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
});
console.log(
`Validated and packaged ${result.inventory.files.length} files as ${result.archive_name} (${result.archive_sha256}).`
);
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exitCode = 1;
});
}