dorfteich/scripts/check-licenses.mjs
Claude Fable 5 d3289b2167
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m33s
CI / Build container images (pull_request) Successful in 4m38s
CI / Auth e2e pack (pull_request) Successful in 9m14s
CI / Import/export fidelity gate (pull_request) Successful in 1m12s
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Waiting to run
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
#202: SBOM and license report in CI
The release run now generates CycloneDX 1.6 SBOMs with a pinned
anchore/syft container — one per released image (scanned from the
freshly built image tar, OS packages included) and one for the pnpm
workspace (from the lockfile) — plus the full pnpm licenses report, and
attaches everything as build artefacts BEFORE publishing the release,
so a red gate stops the release. Runner constraints dictated the
mechanics (documented in the workflow): the job talks to the HOST
daemon, so files travel into the syft container via docker cp and
images via docker save to a tar copied the same way (syft cannot read
a tar from stdin — verified).

scripts/check-licenses.mjs is the documented license policy: permissive
allowlist, MPL-2.0/CC-BY-4.0 with recorded reasoning, per-package
exception table (khroma: MIT text shipped, metadata missing). CI runs
the gate on every PR (pnpm licenses:check); positive and negative case
tested locally, both SBOM paths tested against real images/lockfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:21:58 +02:00

86 lines
3.0 KiB
JavaScript

// License allowlist gate (issue #202): reads `pnpm licenses list --json`
// from stdin and fails when any dependency's license is outside the
// allowlist below. The allowlist is the documented policy — extending it is
// a deliberate, reviewed act, not a build fix.
//
// Usage: pnpm licenses list --json | node scripts/check-licenses.mjs
// Permissive licenses only, plus two consciously admitted cases:
// - MPL-2.0: file-level copyleft; we consume MPL packages (axe-core,
// dev-only) unmodified, which triggers no obligations beyond source
// availability of the (unmodified) files themselves.
// - CC-BY-4.0: attribution license used for data packages (browser
// compatibility data); attribution is satisfied by the license report.
const ALLOWED = new Set([
'MIT',
'MIT-0',
'ISC',
'Apache-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'0BSD',
'BlueOak-1.0.0',
'CC0-1.0',
'CC-BY-4.0',
'Python-2.0',
'Unlicense',
'MPL-2.0',
'Zlib',
]);
// Packages whose license METADATA is missing or wrong upstream, verified by
// hand against the shipped license text. Key = package name, value = the
// verification note an auditor reads.
const EXCEPTIONS = new Map([
[
'khroma',
'MIT — upstream ships the MIT text as its `license` file but omits the package.json license field (reported as "Unknown")',
],
]);
/** SPDX-light evaluation, sufficient for the expressions pnpm emits today:
* parentheses stripped, OR satisfied by any allowed alternative, AND by all
* parts. Nested mixed expressions would need a real parser — they fail
* closed here, which is the safe direction. */
function isAllowed(expression) {
const clean = expression.replace(/[()]/g, ' ').trim();
if (/\sOR\s/.test(clean)) {
return clean.split(/\s+OR\s+/).some((part) => isAllowed(part));
}
return clean.split(/\s+AND\s+/).every((part) => ALLOWED.has(part.trim()));
}
const input = await new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => (data += chunk));
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
const byLicense = JSON.parse(input);
const violations = [];
let packages = 0;
for (const [license, entries] of Object.entries(byLicense)) {
for (const entry of entries) {
packages += 1;
if (isAllowed(license)) continue;
if (EXCEPTIONS.has(entry.name)) continue;
violations.push({ name: entry.name, license });
}
}
if (violations.length > 0) {
console.error('licenses outside the documented allowlist:');
for (const v of violations) console.error(` ${v.name}: ${v.license}`);
console.error(
'Either the dependency goes, or the allowlist/exception table in scripts/check-licenses.mjs is extended in a reviewed change.',
);
process.exit(1);
}
console.log(
`license gate: ${packages} packages, ${Object.keys(byLicense).length} distinct license expressions, all within the allowlist (${EXCEPTIONS.size} documented exception${EXCEPTIONS.size === 1 ? '' : 's'})`,
);