feat: publish Regex Tools 0.1.0
This commit is contained in:
526
scripts/package-release.mjs
Normal file
526
scripts/package-release.mjs
Normal file
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { zipSync } from "fflate";
|
||||
import { checksumLine, sha256 } from "./checksum-release.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
const gplVersion3TextSha256 =
|
||||
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
||||
const requiredRootFiles = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
];
|
||||
|
||||
function compareText(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function safeArchivePath(fileName) {
|
||||
if (
|
||||
typeof fileName !== "string" ||
|
||||
fileName.length === 0 ||
|
||||
fileName.includes("\0") ||
|
||||
fileName.includes("\\") ||
|
||||
fileName.startsWith("/") ||
|
||||
/^[A-Za-z]:\//u.test(fileName)
|
||||
) {
|
||||
throw new Error(`Invalid release path: ${String(fileName)}`);
|
||||
}
|
||||
const parts = fileName.split("/");
|
||||
if (
|
||||
parts.some(
|
||||
(component) =>
|
||||
component === "" || component === "." || component === "..",
|
||||
) ||
|
||||
path.posix.normalize(fileName) !== fileName
|
||||
) {
|
||||
throw new Error(`Non-canonical release path: ${fileName}`);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function collectDirectory(directory, prefix = "") {
|
||||
const result = [];
|
||||
const visit = async (current) => {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
children.sort((left, right) => compareText(left.name, right.name));
|
||||
for (const child of children) {
|
||||
const absolute = path.join(current, child.name);
|
||||
const details = await lstat(absolute);
|
||||
if (details.isSymbolicLink()) {
|
||||
throw new Error(`Release input contains a symlink: ${absolute}`);
|
||||
}
|
||||
if (details.isDirectory()) {
|
||||
await visit(absolute);
|
||||
} else if (details.isFile()) {
|
||||
const relative = path
|
||||
.relative(directory, absolute)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
result.push({
|
||||
name: safeArchivePath(
|
||||
prefix ? path.posix.join(prefix, relative) : relative,
|
||||
),
|
||||
data: await readFile(absolute),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Release input contains a special file: ${absolute}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
function safePackageDirectory(name, version) {
|
||||
const normalized = `${name.replace(/^@/u, "").replaceAll("/", "__")}-${
|
||||
version
|
||||
}`;
|
||||
if (!/^[A-Za-z0-9._@+-]+$/u.test(normalized)) {
|
||||
throw new Error(`Unsafe package identity: ${name}@${version}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function collectRuntimePackages(packageJson) {
|
||||
const packages = new Map();
|
||||
const visit = async (name, fromDirectory) => {
|
||||
const resolver = createRequire(path.join(fromDirectory, "package.json"));
|
||||
let packagePath;
|
||||
try {
|
||||
packagePath = resolver.resolve(`${name}/package.json`);
|
||||
} catch {
|
||||
const entry = resolver.resolve(name);
|
||||
let directory = path.dirname(entry);
|
||||
while (directory !== path.dirname(directory)) {
|
||||
const candidate = path.join(directory, "package.json");
|
||||
const details = await lstat(candidate).catch(() => null);
|
||||
if (details?.isFile()) {
|
||||
const candidateJson = JSON.parse(await readFile(candidate, "utf8"));
|
||||
if (candidateJson.name === name) {
|
||||
packagePath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
directory = path.dirname(directory);
|
||||
}
|
||||
}
|
||||
if (!packagePath) {
|
||||
throw new Error(`Could not resolve runtime package ${name}.`);
|
||||
}
|
||||
const details = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const key = `${details.name}@${details.version}`;
|
||||
if (packages.has(key)) return;
|
||||
const directory = path.dirname(packagePath);
|
||||
packages.set(key, { directory, details });
|
||||
for (const dependency of Object.keys(details.dependencies ?? {}).sort()) {
|
||||
await visit(dependency, directory);
|
||||
}
|
||||
};
|
||||
for (const dependency of Object.keys(packageJson.dependencies ?? {}).sort()) {
|
||||
await visit(dependency, root);
|
||||
}
|
||||
return [...packages.values()].sort((left, right) =>
|
||||
compareText(
|
||||
`${left.details.name}@${left.details.version}`,
|
||||
`${right.details.name}@${right.details.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectRuntimeLicenceEntries(packageJson) {
|
||||
const packages = await collectRuntimePackages(packageJson);
|
||||
const entries = [];
|
||||
const rows = [
|
||||
"# Runtime npm dependency licences",
|
||||
"",
|
||||
"| Package | Version | Licence | Repository |",
|
||||
"| --- | --- | --- | --- |",
|
||||
];
|
||||
for (const runtimePackage of packages) {
|
||||
const { details, directory } = runtimePackage;
|
||||
const legalFiles = (await readdir(directory, { withFileTypes: true }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(entry.name),
|
||||
)
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
if (legalFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Runtime package ${details.name}@${details.version} has no licence file.`,
|
||||
);
|
||||
}
|
||||
const packageDirectory = safePackageDirectory(
|
||||
details.name,
|
||||
details.version,
|
||||
);
|
||||
for (const legalFile of legalFiles) {
|
||||
entries.push({
|
||||
name: safeArchivePath(
|
||||
`LICENSES/npm/${packageDirectory}/${legalFile.name}`,
|
||||
),
|
||||
data: await readFile(path.join(directory, legalFile.name)),
|
||||
});
|
||||
}
|
||||
const repository =
|
||||
typeof details.repository === "string"
|
||||
? details.repository
|
||||
: (details.repository?.url ?? "");
|
||||
rows.push(
|
||||
`| \`${details.name}\` | ${details.version} | ${
|
||||
details.license ?? "See packaged licence"
|
||||
} | ${String(repository).replaceAll("|", "\\|")} |`,
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: "LICENSES/npm/DEPENDENCIES.md",
|
||||
data: Buffer.from(`${rows.join("\n")}\n`),
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function collectBuildGeneratedLicenceEntries() {
|
||||
const resolver = createRequire(path.join(root, "package.json"));
|
||||
const dependencies = [
|
||||
{
|
||||
name: "rolldown",
|
||||
version: "1.1.5",
|
||||
license: "MIT",
|
||||
legalFile: "LICENSE",
|
||||
},
|
||||
{
|
||||
name: "vite",
|
||||
version: "8.1.5",
|
||||
license: "MIT",
|
||||
legalFile: "LICENSE.md",
|
||||
},
|
||||
];
|
||||
const entries = [];
|
||||
for (const dependency of dependencies) {
|
||||
const packagePath = resolver.resolve(`${dependency.name}/package.json`);
|
||||
const details = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
if (
|
||||
details.name !== dependency.name ||
|
||||
details.version !== dependency.version ||
|
||||
details.license !== dependency.license
|
||||
) {
|
||||
throw new Error(
|
||||
`Build dependency identity drifted: expected ${dependency.name}@${dependency.version} (${dependency.license}).`,
|
||||
);
|
||||
}
|
||||
const legalPath = path.join(
|
||||
path.dirname(packagePath),
|
||||
dependency.legalFile,
|
||||
);
|
||||
const legalDetails = await lstat(legalPath);
|
||||
if (legalDetails.isSymbolicLink() || !legalDetails.isFile()) {
|
||||
throw new Error(
|
||||
`Build dependency legal file is not a regular file: ${legalPath}.`,
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: safeArchivePath(
|
||||
`LICENSES/build/${safePackageDirectory(
|
||||
details.name,
|
||||
details.version,
|
||||
)}/${dependency.legalFile}`,
|
||||
),
|
||||
data: await readFile(legalPath),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanEntry(entry) {
|
||||
if (entry.name.endsWith(".map")) {
|
||||
throw new Error(`Source map is not approved for release: ${entry.name}`);
|
||||
}
|
||||
const components = entry.name.toLowerCase().split("/");
|
||||
if (
|
||||
components.some((component) =>
|
||||
[
|
||||
".env",
|
||||
".npmrc",
|
||||
".git",
|
||||
"credentials",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
].includes(component),
|
||||
) ||
|
||||
/\.(?:key|pem|p12|pfx)$/iu.test(entry.name)
|
||||
) {
|
||||
throw new Error(`Credential-like release path: ${entry.name}`);
|
||||
}
|
||||
if (!/\.(?:css|html|js|json|md|svg|txt)$/iu.test(entry.name)) return;
|
||||
const text = entry.data.toString("utf8");
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(text)) {
|
||||
throw new Error(`Private key material detected in ${entry.name}`);
|
||||
}
|
||||
if (/https?:\/\/[^/@\s]+:[^/@\s]+@/iu.test(text)) {
|
||||
throw new Error(`Credential-bearing URL detected in ${entry.name}`);
|
||||
}
|
||||
if (
|
||||
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/mu.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
throw new Error(`Local absolute path detected in ${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeterministicZip(inputEntries) {
|
||||
const entries = inputEntries
|
||||
.map((entry) => ({
|
||||
name: safeArchivePath(entry.name),
|
||||
data: Buffer.from(entry.data),
|
||||
}))
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
const seen = new Set();
|
||||
const zippable = Object.create(null);
|
||||
let totalBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.name)) {
|
||||
throw new Error(`Duplicate release path: ${entry.name}`);
|
||||
}
|
||||
seen.add(entry.name);
|
||||
totalBytes += entry.data.byteLength;
|
||||
if (entry.data.byteLength > 128 * 1024 * 1024) {
|
||||
throw new Error(`Release entry is too large: ${entry.name}`);
|
||||
}
|
||||
if (totalBytes > 512 * 1024 * 1024) {
|
||||
throw new Error("Release exceeds the 512 MiB uncompressed limit.");
|
||||
}
|
||||
scanEntry(entry);
|
||||
zippable[entry.name] = [
|
||||
new Uint8Array(entry.data),
|
||||
{
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 9,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
return Buffer.from(
|
||||
zipSync(zippable, {
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 9,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectReleaseEntries() {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (
|
||||
packageJson.version !== "0.1.0" ||
|
||||
packageJson.license !== "GPL-3.0-or-later" ||
|
||||
packageJson.repository?.url !==
|
||||
"git+https://git.add-ideas.de/zemion/regex-tools.git"
|
||||
) {
|
||||
throw new Error("Package version, licence or source identity drifted.");
|
||||
}
|
||||
const licence = await readFile(path.join(root, "LICENSE"), "utf8");
|
||||
if (
|
||||
!licence.includes("GNU GENERAL PUBLIC LICENSE") ||
|
||||
!licence.includes("Version 3, 29 June 2007") ||
|
||||
!licence.includes("END OF TERMS AND CONDITIONS") ||
|
||||
sha256(Buffer.from(licence)) !== gplVersion3TextSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"Root LICENSE is not the exact complete GPL version 3 text.",
|
||||
);
|
||||
}
|
||||
const entries = await collectDirectory(path.join(root, "dist"));
|
||||
entries.push(
|
||||
...(await collectDirectory(path.join(root, "LICENSES"), "LICENSES")),
|
||||
);
|
||||
entries.push(...(await collectRuntimeLicenceEntries(packageJson)));
|
||||
entries.push(...(await collectBuildGeneratedLicenceEntries()));
|
||||
for (const fileName of requiredRootFiles) {
|
||||
entries.push({
|
||||
name: fileName,
|
||||
data: await readFile(path.join(root, fileName)),
|
||||
});
|
||||
}
|
||||
const names = new Set(entries.map((entry) => entry.name));
|
||||
for (const required of [
|
||||
"index.html",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSES/build/rolldown-1.1.5/LICENSE",
|
||||
"LICENSES/build/vite-8.1.5/LICENSE.md",
|
||||
]) {
|
||||
if (!names.has(required)) {
|
||||
throw new Error(`Required release file is missing: ${required}`);
|
||||
}
|
||||
}
|
||||
if (![...names].some((name) => name.startsWith("assets/"))) {
|
||||
throw new Error("Release has no production assets.");
|
||||
}
|
||||
if (![...names].some((name) => name.startsWith("LICENSES/"))) {
|
||||
throw new Error("Release has no third-party licence directory.");
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
entries
|
||||
.find((entry) => entry.name === "toolbox-app.json")
|
||||
.data.toString("utf8"),
|
||||
);
|
||||
if (
|
||||
manifest.id !== "de.add-ideas.regex-tools" ||
|
||||
manifest.version !== packageJson.version ||
|
||||
manifest.source?.repository !==
|
||||
"https://git.add-ideas.de/zemion/regex-tools" ||
|
||||
manifest.source?.license !== "GPL-3.0-or-later"
|
||||
) {
|
||||
throw new Error("Packaged manifest identity is inconsistent.");
|
||||
}
|
||||
const indexHtml = entries
|
||||
.find((entry) => entry.name === "index.html")
|
||||
.data.toString("utf8");
|
||||
if (/(?:src|href)\s*=\s*["']\/(?!\/)/iu.test(indexHtml)) {
|
||||
throw new Error("index.html contains a root-absolute asset path.");
|
||||
}
|
||||
return { entries, packageJson };
|
||||
}
|
||||
|
||||
export function parseReleaseArguments(argumentsList) {
|
||||
const allowed = new Set(["--force"]);
|
||||
const unknown = argumentsList.filter((argument) => !allowed.has(argument));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknown.join(", ")}`);
|
||||
}
|
||||
return { force: argumentsList.includes("--force") };
|
||||
}
|
||||
|
||||
async function assertWritableTarget(target, force) {
|
||||
try {
|
||||
const details = await lstat(target);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`Release target is not a regular file: ${target}`);
|
||||
}
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`${path.relative(root, target)} exists; pass --force to replace this exact version.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishReleasePair({
|
||||
archive,
|
||||
archivePath,
|
||||
checksum,
|
||||
checksumPath,
|
||||
archiveExists,
|
||||
checksumExists,
|
||||
}) {
|
||||
const releaseDirectory = path.dirname(archivePath);
|
||||
const stage = await mkdtemp(
|
||||
path.join(releaseDirectory, ".regex-tools-release-"),
|
||||
);
|
||||
const stagedArchive = path.join(stage, path.basename(archivePath));
|
||||
const stagedChecksum = path.join(stage, path.basename(checksumPath));
|
||||
const previousArchive = path.join(stage, "previous-archive");
|
||||
const previousChecksum = path.join(stage, "previous-checksum");
|
||||
let archiveBackedUp = false;
|
||||
let checksumBackedUp = false;
|
||||
let archivePublished = false;
|
||||
let checksumPublished = false;
|
||||
try {
|
||||
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
|
||||
await writeFile(stagedChecksum, checksum, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
if (archiveExists) {
|
||||
await rename(archivePath, previousArchive);
|
||||
archiveBackedUp = true;
|
||||
}
|
||||
if (checksumExists) {
|
||||
await rename(checksumPath, previousChecksum);
|
||||
checksumBackedUp = true;
|
||||
}
|
||||
await rename(stagedArchive, archivePath);
|
||||
archivePublished = true;
|
||||
await rename(stagedChecksum, checksumPath);
|
||||
checksumPublished = true;
|
||||
} catch (error) {
|
||||
if (checksumPublished) await rm(checksumPath, { force: true });
|
||||
if (archivePublished) await rm(archivePath, { force: true });
|
||||
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
|
||||
if (archiveBackedUp) await rename(previousArchive, archivePath);
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseReleaseArguments(process.argv.slice(2));
|
||||
const { entries, packageJson } = await collectReleaseEntries();
|
||||
const archive = createDeterministicZip(entries);
|
||||
const fileName = `regex-tools-${packageJson.version}.zip`;
|
||||
const digest = sha256(archive);
|
||||
const releaseDirectory = path.join(root, "release");
|
||||
await mkdir(releaseDirectory, { recursive: true });
|
||||
const releaseDirectoryDetails = await lstat(releaseDirectory);
|
||||
if (
|
||||
releaseDirectoryDetails.isSymbolicLink() ||
|
||||
!releaseDirectoryDetails.isDirectory()
|
||||
) {
|
||||
throw new Error("release/ must be a real directory.");
|
||||
}
|
||||
const archivePath = path.join(releaseDirectory, fileName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
const archiveExists = await assertWritableTarget(archivePath, options.force);
|
||||
const checksumExists = await assertWritableTarget(
|
||||
checksumPath,
|
||||
options.force,
|
||||
);
|
||||
await publishReleasePair({
|
||||
archive,
|
||||
archivePath,
|
||||
checksum: checksumLine(digest, fileName),
|
||||
checksumPath,
|
||||
archiveExists,
|
||||
checksumExists,
|
||||
});
|
||||
console.log(
|
||||
`Created release/${fileName} (${archive.byteLength} bytes, SHA-256 ${digest})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main();
|
||||
}
|
||||
Reference in New Issue
Block a user