## 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>
491 lines
18 KiB
JavaScript
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;
|
|
});
|
|
}
|