Release Log Tools v0.1.0

This commit is contained in:
2026-09-01 14:43:03 +02:00
commit 84a9978aa8
57 changed files with 10013 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(root, "src/toolbox/manifest.source.json");
const outputPath = path.join(root, "public/toolbox-app.json");
const source = JSON.parse(await readFile(sourcePath, "utf8"));
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const versionSource = await readFile(path.join(root, "src/version.ts"), "utf8");
const appVersion = /^export const APP_VERSION = "([^"]+)";$/mu.exec(
versionSource,
)?.[1];
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
if (source.version !== pkg.version || appVersion !== pkg.version)
throw new Error("Version identity drift");
if (
source.id !== "de.add-ideas." + pkg.name ||
source.source?.repository !== repository ||
source.source?.license !== "GPL-3.0-or-later"
)
throw new Error("Manifest source identity is invalid");
const serialized = await format(JSON.stringify(source), {
filepath: outputPath,
});
if (process.argv.includes("--check")) {
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
throw new Error("public/toolbox-app.json is stale");
console.log("Toolbox manifest is synchronized");
} else {
await writeFile(outputPath, serialized);
console.log("Generated public/toolbox-app.json");
}
+165
View File
@@ -0,0 +1,165 @@
#!/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 pkg = 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/" + pkg.name + "-" + pkg.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("Unsafe release target");
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
throw new Error("Release output exists");
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/PRIVACY-SECURITY.md",
]) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink())
throw new Error("Missing release file: " + name);
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
const repository = "https://git.add-ideas.de/lotobo/" + pkg.name;
if (
manifest.id !== "de.add-ideas." + pkg.name ||
manifest.version !== pkg.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg" ||
manifest.source?.repository !== repository
)
throw new Error("Packaged manifest identity is invalid");
if (
/\b(?:src|href)=["']\//iu.test(
await readFile(path.join(input, "index.html"), "utf8"),
)
)
throw new Error("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.localeCompare(b.name),
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? prefix + "/" + entry.name : entry.name;
if (entry.isSymbolicLink())
throw new Error("Symlink in release: " + 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.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(), pkg.name + "-release-"),
);
const publicationRoot = await mkdtemp(
path.join(path.dirname(output), "." + pkg.name + "-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");
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) + "\nSHA-256 " + digest);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
await rm(publicationRoot, { recursive: true, force: true });
}
+70
View File
@@ -0,0 +1,70 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const destination = path.join(root, "public");
for (const name of [
"LICENSE",
"README.md",
"CHANGELOG.md",
"CONTRIBUTING.md",
"SECURITY.md",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
]) {
await readFile(path.join(root, name));
await cp(path.join(root, name), path.join(destination, name));
}
for (const directory of ["LICENSES", "docs"]) {
const output = path.join(destination, directory);
await rm(output, { recursive: true, force: true });
await cp(path.join(root, directory), output, { recursive: true });
}
const lock = JSON.parse(
await readFile(path.join(root, "package-lock.json"), "utf8"),
);
const sections = [];
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
([a], [b]) => a.localeCompare(b),
)) {
if (!location.includes("node_modules/") || locked.dev === true) continue;
const packageDirectory = path.join(root, location);
const details = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
);
const candidates = (await readdir(packageDirectory))
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
.sort();
const texts = [];
for (const candidate of candidates) {
try {
texts.push(
"--- " +
candidate +
" ---\n" +
(await readFile(path.join(packageDirectory, candidate), "utf8")),
);
} catch {
/* directory */
}
}
sections.push(
"=".repeat(78) +
"\n" +
details.name +
"@" +
details.version +
"\nDeclared licence: " +
(details.license ?? locked.license ?? "See upstream") +
"\n" +
"=".repeat(78) +
"\n" +
(texts.join("\n\n") || "See upstream package metadata."),
);
}
await mkdir(path.join(destination, "LICENSES"), { recursive: true });
await writeFile(
path.join(destination, "LICENSES/npm-runtime-licenses.txt"),
sections.join("\n\n").trimEnd() + "\n",
);
console.log("Prepared release documentation");
+66
View File
@@ -0,0 +1,66 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"dist",
);
const prefix = "/deep/nested/log/";
const types = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".md", "text/markdown; charset=utf-8"],
[".txt", "text/plain; charset=utf-8"],
[".wasm", "application/wasm"],
[".png", "image/png"],
[".jpg", "image/jpeg"],
[".webp", "image/webp"],
]);
const headers = {
"Content-Security-Policy":
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy":
"camera=(), microphone=(), geolocation=(), usb=(), payment=()",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
};
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const relative = decodeURIComponent(url.pathname).startsWith(prefix)
? decodeURIComponent(url.pathname).slice(prefix.length)
: decodeURIComponent(url.pathname).replace(/^\/+/, "");
const normalized = path.posix.normalize(relative || "index.html");
if (
normalized === ".." ||
normalized.startsWith("../") ||
path.isAbsolute(normalized)
) {
response.writeHead(400).end("Bad request");
return;
}
let file = path.join(root, normalized);
if ((await stat(file).catch(() => null))?.isDirectory())
file = path.join(file, "index.html");
const content = await readFile(file);
response.writeHead(200, {
"Content-Type":
types.get(path.extname(file)) ?? "application/octet-stream",
"Cache-Control": "no-cache",
...headers,
});
response.end(content);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
});
server.listen(4182, "127.0.0.1", () => console.log("Test server ready"));