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>
This commit is contained in:
Jeppe B
2026-07-21 16:43:19 +00:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent a01902356d
commit fd26b0ee81
4 changed files with 229 additions and 32 deletions
+110 -20
View File
@@ -1,14 +1,14 @@
import crypto from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import JSZip from "jszip";
const execFileAsync = promisify(execFile);
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;
@@ -100,6 +100,7 @@ export async function collectDistInventory(distDirectory) {
}
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);
@@ -122,7 +123,17 @@ export async function collectDistInventory(distDirectory) {
}
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,
@@ -238,23 +249,111 @@ function assertMatchingInventories(expected, actual, label) {
}
}
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) {
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 });
const entries = stdout.split(/\r?\n/).filter(Boolean);
if (entries.length === 0) {
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 archiveEntry of entries) {
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) {
@@ -330,25 +429,16 @@ export async function createReleaseArchive({
const sourceRoot = path.join(temporaryDirectory, "source");
const copiedDistDirectory = path.join(sourceRoot, "dist");
const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
const extractionRoot = path.join(temporaryDirectory, "extracted");
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 execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], {
cwd: sourceRoot,
maxBuffer: 10 * 1024 * 1024,
});
await validateArchiveEntries(temporaryArchivePath);
await fsp.mkdir(extractionRoot);
await execFileAsync("unzip", ["-q", temporaryArchivePath, "-d", extractionRoot], {
maxBuffer: 10 * 1024 * 1024,
});
const extractedInventory = await collectDistInventory(path.join(extractionRoot, "dist"));
assertMatchingInventories(inventory, extractedInventory, "round-trip extracted archive");
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);