Files
helper-tools/scripts/package-release.mjs
T
2026-09-01 02:33:45 +02:00

174 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
access,
chmod,
copyFile,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
utimes,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execute = promisify(execFile);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
const output = path.resolve(
root,
argument("--output", `release/helper-tools-${packageJson.version}.zip`),
);
const checksumOutput = `${output}.sha256`;
const force = process.argv.includes("--force");
if (
path.extname(output).toLowerCase() !== ".zip" ||
output === root ||
output === path.parse(output).root
)
throw new Error("Release output is not a safe ZIP target");
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
throw new Error("Release output already exists; use --force to replace it");
const input = path.join(root, "dist");
for (const name of [
"index.html",
"manifest.webmanifest",
"sw.js",
"toolbox-app.json",
"favicon.svg",
"README.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"LICENSE",
"SECURITY.md",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
"LICENSES/README.md",
"LICENSES/npm-runtime-licenses.txt",
"docs/ACCESSIBILITY.md",
"docs/ARCHITECTURE.md",
"docs/API.md",
"docs/PRIVACY-SECURITY.md",
]) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink())
throw new Error(`Release is missing a regular file: ${name}`);
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
if (
manifest.id !== "de.add-ideas.helper-tools" ||
manifest.version !== packageJson.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg" ||
manifest.source?.repository !== "https://git.add-ideas.de/lotobo/helper-tools"
)
throw new Error("Packaged Toolbox manifest identity is invalid");
const html = await readFile(path.join(input, "index.html"), "utf8");
if (/\b(?:src|href)=["']\//iu.test(html))
throw new Error("index.html contains a root-absolute asset reference");
async function collect(directory, prefix = "") {
const files = [];
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1),
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink())
throw new Error(`Release contains a symbolic link: ${relative}`);
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
else if (entry.isFile()) files.push({ absolute, relative });
else throw new Error(`Unsupported release entry: ${relative}`);
}
return files;
}
const sourceFiles = await collect(input);
for (const file of sourceFiles) {
if (
file.relative.endsWith(".map") ||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
file.relative,
) ||
file.relative.startsWith("/") ||
file.relative.split("/").includes("..")
)
throw new Error(`Forbidden release entry: ${file.relative}`);
}
await mkdir(path.dirname(output), { recursive: true });
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), "helper-release-"));
const publicationRoot = await mkdtemp(
path.join(path.dirname(output), ".helper-publish-"),
);
const stagedTree = path.join(stagingRoot, "tree");
const stagedArchive = path.join(stagingRoot, path.basename(output));
try {
await cp(input, stagedTree, { recursive: true });
const timestamp = new Date("1980-01-01T00:00:00.000Z");
for (const file of await collect(stagedTree)) {
await chmod(file.absolute, 0o644);
await utimes(file.absolute, timestamp, timestamp);
}
await execute(
"zip",
[
"-X",
"-q",
"-9",
stagedArchive,
...sourceFiles.map((file) => file.relative),
],
{
cwd: stagedTree,
env: { ...process.env, TZ: "UTC" },
maxBuffer: 1024 * 1024,
},
);
const archive = await readFile(stagedArchive);
const digest = createHash("sha256").update(archive).digest("hex");
const stagedChecksum = `${stagedArchive}.sha256`;
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
mode: 0o644,
});
const publicationArchive = path.join(publicationRoot, path.basename(output));
const publicationChecksum = `${publicationArchive}.sha256`;
await copyFile(stagedArchive, publicationArchive);
await copyFile(stagedChecksum, publicationChecksum);
if (force) {
await rm(output, { force: true });
await rm(checksumOutput, { force: true });
}
await rename(publicationArchive, output);
await rename(publicationChecksum, checksumOutput);
console.log(
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${sourceFiles.length} files)\nSHA-256 ${digest}`,
);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
await rm(publicationRoot, { recursive: true, force: true });
}