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
+34
View File
@@ -82,6 +82,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"jimp": "0.22.12", "jimp": "0.22.12",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
@@ -10087,6 +10088,12 @@
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.5", "version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -10994,6 +11001,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -11062,6 +11081,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -13627,6 +13655,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+1
View File
@@ -154,6 +154,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"jimp": "0.22.12", "jimp": "0.22.12",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"jszip": "^3.10.1",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
+110 -20
View File
@@ -1,14 +1,14 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { execFile } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import JSZip from "jszip";
const execFileAsync = promisify(execFile);
const SHA256_PATTERN = /^[0-9a-f]{64}$/i; const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/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 = 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; /(?:^|\.)(?: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 = []; const files = [];
let totalBytes = 0;
const walk = async (relativeDirectory = "") => { const walk = async (relativeDirectory = "") => {
const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root; const absoluteDirectory = relativeDirectory ? path.join(root, relativeDirectory) : root;
const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths); const names = (await fsp.readdir(absoluteDirectory)).sort(comparePaths);
@@ -122,7 +123,17 @@ export async function collectDistInventory(distDirectory) {
} }
assertSafeFileName(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); 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({ files.push({
path: `dist/${relativePath}`, path: `dist/${relativePath}`,
bytes: contents.length, 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) { async function validateArchiveEntries(archivePath) {
const { stdout } = await execFileAsync("unzip", ["-Z1", archivePath], { maxBuffer: 10 * 1024 * 1024 }); let archive;
const entries = stdout.split(/\r?\n/).filter(Boolean); try {
if (entries.length === 0) { 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."); 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; const normalizedEntry = archiveEntry.endsWith("/") ? archiveEntry.slice(0, -1) : archiveEntry;
if (normalizedEntry === "dist") { if (normalizedEntry === "dist") {
if (!entry.dir) {
throw new Error("release archive contains a file at the dist root path.");
}
continue; continue;
} }
if (!normalizedEntry.startsWith("dist/")) { if (!normalizedEntry.startsWith("dist/")) {
throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`); throw new Error(`release archive contains an entry outside dist/: ${archiveEntry}`);
} }
assertSafeRelativePath(normalizedEntry, "archive entry"); 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) { function appendGithubOutputs(values, githubOutput = process.env.GITHUB_OUTPUT) {
@@ -330,25 +429,16 @@ export async function createReleaseArchive({
const sourceRoot = path.join(temporaryDirectory, "source"); const sourceRoot = path.join(temporaryDirectory, "source");
const copiedDistDirectory = path.join(sourceRoot, "dist"); const copiedDistDirectory = path.join(sourceRoot, "dist");
const temporaryArchivePath = path.join(temporaryDirectory, archiveName); const temporaryArchivePath = path.join(temporaryDirectory, archiveName);
const extractionRoot = path.join(temporaryDirectory, "extracted");
await fsp.mkdir(sourceRoot, { recursive: true }); await fsp.mkdir(sourceRoot, { recursive: true });
await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true }); await fsp.cp(resolvedDistDirectory, copiedDistDirectory, { recursive: true, errorOnExist: true });
const copiedInventory = await collectDistInventory(copiedDistDirectory); const copiedInventory = await collectDistInventory(copiedDistDirectory);
assertMatchingInventories(inventory, copiedInventory, "archive source"); assertMatchingInventories(inventory, copiedInventory, "archive source");
await execFileAsync("zip", ["-X", "-q", "-r", temporaryArchivePath, "dist"], { await createZipArchive(copiedDistDirectory, copiedInventory, temporaryArchivePath);
cwd: sourceRoot, const archive = await validateArchiveEntries(temporaryArchivePath);
maxBuffer: 10 * 1024 * 1024, const archivedInventory = await collectArchiveInventory(archive);
}); assertMatchingInventories(inventory, archivedInventory, "round-trip archive");
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");
const archiveContents = await fsp.readFile(temporaryArchivePath); const archiveContents = await fsp.readFile(temporaryArchivePath);
const archiveSha256 = sha256(archiveContents); const archiveSha256 = sha256(archiveContents);
+84 -12
View File
@@ -1,9 +1,9 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import JSZip from "jszip";
import { import {
collectDistInventory, collectDistInventory,
@@ -90,25 +90,50 @@ describe("release dist packager", () => {
}); });
const outputDirectory = path.join(temporaryRoot, "artifacts"); const outputDirectory = path.join(temporaryRoot, "artifacts");
const githubOutput = path.join(temporaryRoot, "github-output.txt"); const githubOutput = path.join(temporaryRoot, "github-output.txt");
const emptyPath = path.join(temporaryRoot, "empty-path");
await fs.writeFile(githubOutput, "existing=value\n"); await fs.writeFile(githubOutput, "existing=value\n");
await fs.mkdir(emptyPath);
const result = await createReleaseArchive({ const originalPath = process.env.PATH;
distDirectory, let result;
outputDirectory, try {
expectedCommitSha: COMMIT_SHA, process.env.PATH = emptyPath;
expectedBuildId: BUILD_ID, result = await createReleaseArchive({
runId: "123456", distDirectory,
runAttempt: "2", outputDirectory,
githubOutput, expectedCommitSha: COMMIT_SHA,
}); expectedBuildId: BUILD_ID,
runId: "123456",
runAttempt: "2",
githubOutput,
});
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
}
expect(result.archive_name).toBe(`pleno-vue-${COMMIT_SHA}-123456-2.zip`); expect(result.archive_name).toBe(`pleno-vue-${COMMIT_SHA}-123456-2.zip`);
expect(result.release_id).toBe(`${COMMIT_SHA}-123456-2`); expect(result.release_id).toBe(`${COMMIT_SHA}-123456-2`);
expect(result.archive_sha256).toMatch(/^[0-9a-f]{64}$/); expect(result.archive_sha256).toMatch(/^[0-9a-f]{64}$/);
expect(execFileSync("unzip", ["-Z1", result.archive_path], { encoding: "utf8" })).toContain("dist/.htaccess"); const writtenInventory = JSON.parse(await fs.readFile(result.inventory_path, "utf8"));
const archive = await JSZip.loadAsync(await fs.readFile(result.archive_path), { checkCRC32: true });
expect(archive.file("dist/.htaccess")).not.toBeNull();
expect(
Object.values(archive.files)
.filter((entry) => !entry.dir)
.map((entry) => entry.name)
.sort()
).toEqual(writtenInventory.files.map((file) => file.path));
expect(archive.files["dist/"].dir).toBe(true);
expect(archive.files["dist/assets/"].dir).toBe(true);
for (const entry of Object.values(archive.files)) {
expect(Number(entry.unixPermissions) & 0o777).toBe(entry.dir ? 0o755 : 0o644);
}
expect(await fs.readFile(result.checksum_path, "utf8")).toBe(`${result.archive_sha256} ${result.archive_name}\n`); expect(await fs.readFile(result.checksum_path, "utf8")).toBe(`${result.archive_sha256} ${result.archive_name}\n`);
const writtenInventory = JSON.parse(await fs.readFile(result.inventory_path, "utf8"));
expect(writtenInventory).toEqual(await collectDistInventory(distDirectory)); expect(writtenInventory).toEqual(await collectDistInventory(distDirectory));
expect(writtenInventory.files.map((file) => file.path)).toEqual( expect(writtenInventory.files.map((file) => file.path)).toEqual(
[...writtenInventory.files.map((file) => file.path)].sort() [...writtenInventory.files.map((file) => file.path)].sort()
@@ -124,6 +149,43 @@ describe("release dist packager", () => {
expect(outputText).toContain(`archive_sha256=${result.archive_sha256}\n`); expect(outputText).toContain(`archive_sha256=${result.archive_sha256}\n`);
}); });
it("creates byte-identical archives independently of source mtimes", async () => {
const distDirectory = await writeFixtureDist(temporaryRoot);
const options = {
distDirectory,
expectedCommitSha: COMMIT_SHA,
expectedBuildId: BUILD_ID,
runId: "123456",
runAttempt: "2",
};
const originalTimezone = process.env.TZ;
let first;
let second;
try {
process.env.TZ = "UTC";
first = await createReleaseArchive({
...options,
outputDirectory: path.join(temporaryRoot, "first"),
});
await fs.utimes(path.join(distDirectory, "index.html"), new Date(1_000_000), new Date(2_000_000));
process.env.TZ = "America/New_York";
second = await createReleaseArchive({
...options,
outputDirectory: path.join(temporaryRoot, "second"),
});
} finally {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
}
expect(second.archive_sha256).toBe(first.archive_sha256);
expect(await fs.readFile(second.archive_path)).toEqual(await fs.readFile(first.archive_path));
});
it("rejects release metadata that does not identify the exact tested commit and build", async () => { it("rejects release metadata that does not identify the exact tested commit and build", async () => {
const distDirectory = await writeFixtureDist(temporaryRoot, { const distDirectory = await writeFixtureDist(temporaryRoot, {
commitSha: "fedcba9876543210fedcba9876543210fedcba98", commitSha: "fedcba9876543210fedcba9876543210fedcba98",
@@ -162,6 +224,16 @@ describe("release dist packager", () => {
); );
}); });
it("rejects an oversized sparse file before loading it into memory", async () => {
const distDirectory = await writeFixtureDist(temporaryRoot);
const oversizedPath = path.join(distDirectory, "oversized.bin");
const handle = await fs.open(oversizedPath, "w");
await handle.truncate(256 * 1024 * 1024 + 1);
await handle.close();
await expect(collectDistInventory(distDirectory)).rejects.toThrow(/268435456-byte packaging limit/);
});
it("rejects symbolic links instead of following them into the archive", async () => { it("rejects symbolic links instead of following them into the archive", async () => {
const distDirectory = await writeFixtureDist(temporaryRoot); const distDirectory = await writeFixtureDist(temporaryRoot);
await fs.symlink(path.join(distDirectory, "index.html"), path.join(distDirectory, "linked-index.html")); await fs.symlink(path.join(distDirectory, "index.html"), path.join(distDirectory, "linked-index.html"));