Files
SnapOtter/scripts/check-production-licenses.mjs
T

166 lines
6.5 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const policyPath = path.join(root, "config/production-license-policy.json");
const noticesPath = path.join(root, "THIRD_PARTY_NOTICES.md");
function packageLabels(entries) {
return entries
.flatMap((entry) => {
const versions = Array.isArray(entry.versions)
? [...entry.versions].map(String).sort().join(",")
: "unknown";
return `${String(entry.name ?? "unnamed")}@${versions}`;
})
.sort();
}
export function validateInventory(inventory, policy) {
const allowed = new Set(policy.allowedExpressions ?? []);
const denied = new Set(policy.deniedExpressions ?? []);
const violations = [];
for (const expression of Object.keys(inventory).sort()) {
const entries = Array.isArray(inventory[expression]) ? inventory[expression] : [];
const packages = packageLabels(entries).join(", ");
const declaredLicenses = new Set(entries.map((entry) => entry.license));
if (declaredLicenses.size !== 1 || !declaredLicenses.has(expression)) {
violations.push(`inventory group ${expression} has inconsistent package license metadata`);
}
if (/^(unknown|unlicensed)$/i.test(expression)) {
violations.push(`unknown license expression ${expression}: ${packages}`);
} else if (denied.has(expression)) {
violations.push(`denied license expression ${expression}: ${packages}`);
} else if (!allowed.has(expression)) {
violations.push(`unapproved license expression ${expression}: ${packages}`);
}
}
for (const expression of denied) {
if (allowed.has(expression)) {
violations.push(`policy expression is both allowed and denied: ${expression}`);
}
}
return violations.sort();
}
// Native bindings ship one package per platform and arch, so `pnpm licenses
// list` returns whichever set the current machine installed. Rendering those
// verbatim makes the notices file describe the developer's laptop: it is
// regenerated on macOS, then the Linux CI runner resolves the x64 bindings
// instead and fails the very check that produced it.
//
// Collapse each family to its base name for the notices. The policy check above
// still sees every package the current platform installed, so an unacceptable
// license in a binding is caught wherever it is installed; only the attribution
// list is normalized, and the upstream project is still credited once.
// Three shapes in the wild: a suffix on the package name
// (@img/sharp-darwin-arm64), the libc glued to the OS (@img/sharp-linuxmusl-arm64),
// and the whole unscoped name (@esbuild/darwin-arm64), which collapses to the
// scope alone.
const PLATFORM_SUFFIX =
/[-/](?:darwin|linux(?:musl)?|win32|freebsd|openbsd|android|sunos)(?:-(?:x64|arm64|arm|ia32|ppc64|s390x|riscv64|loong64))?(?:-(?:musl|gnu|gnueabihf|msvc))?$/;
// Packages that only exist on one platform, rather than shipping a build per
// platform. fsevents is macOS-only by design and simply absent on Linux, so it
// cannot be normalized into a shared family and has to be named.
const PLATFORM_ONLY_PACKAGES = new Set(["fsevents"]);
function platformFamily(name) {
return String(name).replace(PLATFORM_SUFFIX, "");
}
export function renderNotices(inventory) {
const lines = [
"# Third-Party Production Node Dependency Notices",
"",
"This file is generated from the frozen pnpm production dependency graph.",
"This inventory covers Node packages only; exact release artifacts publish separate full SBOMs.",
"Run `pnpm check:production-node-licenses -- --write-notices` after an intentional dependency change.",
"Package authors retain all rights granted by their respective licenses.",
"",
];
for (const expression of Object.keys(inventory).sort()) {
lines.push(`## ${expression}`, "");
const families = new Map();
for (const entry of inventory[expression]) {
if (PLATFORM_ONLY_PACKAGES.has(entry.name)) continue;
const name = platformFamily(entry.name);
const family = families.get(name) ?? { name, versions: new Set(), homepage: entry.homepage };
for (const version of entry.versions) family.versions.add(String(version));
family.homepage ??= entry.homepage;
families.set(name, family);
}
const entries = [...families.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
for (const entry of entries) {
const versions = [...entry.versions].sort().join(",");
const label = `${entry.name}@${versions}`;
lines.push(entry.homepage ? `- [${label}](${entry.homepage})` : `- ${label}`);
}
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}
function readPolicy() {
const policy = JSON.parse(readFileSync(policyPath, "utf8"));
if (
policy.schemaVersion !== 1 ||
!Array.isArray(policy.allowedExpressions) ||
!Array.isArray(policy.deniedExpressions)
) {
throw new Error("production license policy has an unsupported schema");
}
return policy;
}
function readInventory() {
return JSON.parse(
execFileSync("pnpm", ["licenses", "list", "--prod", "--json"], {
cwd: root,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
}),
);
}
function main() {
const policy = readPolicy();
const inventory = readInventory();
const violations = validateInventory(inventory, policy);
if (violations.length > 0) {
for (const violation of violations) console.error(`ERROR: ${violation}`);
process.exitCode = 1;
return;
}
const expectedNotices = renderNotices(inventory);
if (process.argv.includes("--write-notices")) {
writeFileSync(noticesPath, expectedNotices);
console.log(`Updated ${path.relative(root, noticesPath)}`);
return;
}
if (!existsSync(noticesPath) || readFileSync(noticesPath, "utf8") !== expectedNotices) {
console.error(
"ERROR: THIRD_PARTY_NOTICES.md is stale; review the dependency change, then run pnpm check:production-node-licenses -- --write-notices",
);
process.exitCode = 1;
return;
}
const packageCount = Object.values(inventory).reduce((sum, entries) => sum + entries.length, 0);
console.log(
`Production Node license policy passed: ${packageCount} packages across ${Object.keys(inventory).length} expressions`,
);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main();