feat: release Colour Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
#!/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 });
|
||||
}
|
||||
Reference in New Issue
Block a user