#!/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/colour-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/colorjs.io-MIT.txt", "LICENSES/npm-runtime-licenses.txt", "docs/ACCESSIBILITY.md", "docs/ARCHITECTURE.md", "docs/COLOUR-MATH.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.colour-tools" || manifest.version !== packageJson.version || manifest.entry !== "./" || manifest.icon !== "./favicon.svg" || manifest.source?.repository !== "https://git.add-ideas.de/lotobo/colour-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( (left, right) => left.name === right.name ? 0 : left.name < right.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(), "colour-release-")); const publicationRoot = await mkdtemp( path.join(path.dirname(output), ".colour-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 }); }