## 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>
259 lines
9.5 KiB
JavaScript
259 lines
9.5 KiB
JavaScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import JSZip from "jszip";
|
|
|
|
import {
|
|
collectDistInventory,
|
|
createReleaseArchive,
|
|
validateReleaseMetadata,
|
|
} from "../../scripts/release/package-dist.mjs";
|
|
|
|
const COMMIT_SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
const BUILD_ID = "123456-2";
|
|
|
|
function hashMetadata(contents) {
|
|
const bytes = Buffer.from(contents);
|
|
return {
|
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
};
|
|
}
|
|
|
|
async function writeFixtureDist(root, options = {}) {
|
|
const distDirectory = path.join(root, "dist");
|
|
const files = {
|
|
".htaccess": "RewriteEngine On\n",
|
|
"index.html": '<div id="app"></div>\n',
|
|
"assets/main.js": "console.log('release');\n",
|
|
"assets/main.css": "body { color: #123; }\n",
|
|
...(options.files || {}),
|
|
};
|
|
const releaseEntry = {
|
|
entry: "assets/main.js",
|
|
css: ["assets/main.css"],
|
|
};
|
|
files["release-entry.json"] = `${JSON.stringify(releaseEntry, null, 2)}\n`;
|
|
|
|
for (const [relativePath, contents] of Object.entries(files)) {
|
|
const filePath = path.join(distDirectory, ...relativePath.split("/"));
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, contents);
|
|
}
|
|
|
|
const assetUrls = ["index.html", "release-entry.json", "assets/main.js", "assets/main.css"];
|
|
if (options.extraAssetUrl) {
|
|
assetUrls.push(options.extraAssetUrl);
|
|
}
|
|
const assetHashes = {};
|
|
for (const assetUrl of assetUrls) {
|
|
const contents = files[assetUrl];
|
|
assetHashes[assetUrl] = contents === undefined ? hashMetadata("missing") : hashMetadata(contents);
|
|
}
|
|
|
|
const manifest = {
|
|
schema_version: 1,
|
|
build_id: options.buildId || BUILD_ID,
|
|
commit_sha: options.commitSha || COMMIT_SHA,
|
|
created_at: "2026-07-20T00:00:00.000Z",
|
|
entry: releaseEntry.entry,
|
|
css: releaseEntry.css,
|
|
index_asset_urls: ["assets/main.js", "assets/main.css"],
|
|
pwa_asset_urls: [],
|
|
asset_urls: assetUrls,
|
|
asset_hashes: options.assetHashes || assetHashes,
|
|
};
|
|
await fs.writeFile(path.join(distDirectory, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
return distDirectory;
|
|
}
|
|
|
|
describe("release dist packager", () => {
|
|
let temporaryRoot;
|
|
|
|
beforeEach(async () => {
|
|
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pleno-package-dist-test-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(temporaryRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates a verified archive with a top-level dist, dotfiles, checksum, inventory, and GitHub outputs", async () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot, {
|
|
files: {
|
|
"assets.json": "{}\n",
|
|
"assets/z-last.svg": "<svg></svg>\n",
|
|
"assets/a-first.json": "{}\n",
|
|
},
|
|
});
|
|
const outputDirectory = path.join(temporaryRoot, "artifacts");
|
|
const githubOutput = path.join(temporaryRoot, "github-output.txt");
|
|
const emptyPath = path.join(temporaryRoot, "empty-path");
|
|
await fs.writeFile(githubOutput, "existing=value\n");
|
|
await fs.mkdir(emptyPath);
|
|
|
|
const originalPath = process.env.PATH;
|
|
let result;
|
|
try {
|
|
process.env.PATH = emptyPath;
|
|
result = await createReleaseArchive({
|
|
distDirectory,
|
|
outputDirectory,
|
|
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.release_id).toBe(`${COMMIT_SHA}-123456-2`);
|
|
expect(result.archive_sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
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(writtenInventory).toEqual(await collectDistInventory(distDirectory));
|
|
expect(writtenInventory.files.map((file) => file.path)).toEqual(
|
|
[...writtenInventory.files.map((file) => file.path)].sort()
|
|
);
|
|
|
|
const outputText = await fs.readFile(githubOutput, "utf8");
|
|
expect(outputText).toContain(`build_id=${BUILD_ID}\n`);
|
|
expect(outputText).toContain(`archive_name=${result.archive_name}\n`);
|
|
expect(outputText).toContain(`archive_path=${result.archive_path}\n`);
|
|
expect(outputText).toContain(`checksum_path=${result.checksum_path}\n`);
|
|
expect(outputText).toContain(`inventory_path=${result.inventory_path}\n`);
|
|
expect(outputText).toContain(`release_id=${result.release_id}\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 () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot, {
|
|
commitSha: "fedcba9876543210fedcba9876543210fedcba98",
|
|
buildId: "654321-1",
|
|
});
|
|
|
|
await expect(
|
|
validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID })
|
|
).rejects.toThrow(/does not exactly match/);
|
|
});
|
|
|
|
it("rejects missing or tampered files recorded in release-manifest.json", async () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot);
|
|
await fs.writeFile(path.join(distDirectory, "assets/main.js"), "tampered\n");
|
|
|
|
await expect(
|
|
validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID })
|
|
).rejects.toThrow(/integrity mismatch for assets\/main\.js/);
|
|
});
|
|
|
|
it("rejects traversal paths declared by release metadata", async () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot, { extraAssetUrl: "../outside.js" });
|
|
|
|
await expect(
|
|
validateReleaseMetadata(distDirectory, { expectedCommitSha: COMMIT_SHA, expectedBuildId: BUILD_ID })
|
|
).rejects.toThrow(/unsafe path segment/);
|
|
});
|
|
|
|
it("rejects server-executable files anywhere in dist", async () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot, {
|
|
files: { "uploads/payload.php.jpg": "<?php echo 'unsafe';" },
|
|
});
|
|
|
|
await expect(collectDistInventory(distDirectory)).rejects.toThrow(
|
|
/server-executable file: uploads\/payload\.php\.jpg/
|
|
);
|
|
});
|
|
|
|
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 () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot);
|
|
await fs.symlink(path.join(distDirectory, "index.html"), path.join(distDirectory, "linked-index.html"));
|
|
|
|
await expect(collectDistInventory(distDirectory)).rejects.toThrow(/symbolic link: linked-index\.html/);
|
|
});
|
|
|
|
it("requires the release build id to match the GitHub run identity", async () => {
|
|
const distDirectory = await writeFixtureDist(temporaryRoot);
|
|
|
|
await expect(
|
|
createReleaseArchive({
|
|
distDirectory,
|
|
outputDirectory: path.join(temporaryRoot, "artifacts"),
|
|
expectedCommitSha: COMMIT_SHA,
|
|
expectedBuildId: BUILD_ID,
|
|
runId: "123456",
|
|
runAttempt: "1",
|
|
})
|
|
).rejects.toThrow(/must equal GITHUB_RUN_ID-GITHUB_RUN_ATTEMPT/);
|
|
});
|
|
});
|