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:
co-authored by
Jeppe Bundgaard
parent
a01902356d
commit
fd26b0ee81
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user