Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/license-consistency.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: license consistency

# Every publishable package here must ship the license text it declares.
#
# On 2026-09-03 all 49 `sdk-typescript/packages/*` manifests declared Apache-2.0 while the
# LICENSE file beside each of them was the MIT text, and so did `sdk-python/` and two of the
# three Rust crates. The repository ROOT LICENSE was Apache-2.0 the whole time, which is why
# it went unseen: every existing check looked at the root, and npm/pip/cargo pack the LICENSE
# from the PACKAGE directory. `@wave-av/workflow-sdk@1.0.6` is on npm today carrying the MIT
# text from a source tree that declares Apache-2.0.
#
# Runs on every PR: the gate reads only files in the repo, needs nothing installed, and
# finishes in about a second, so it is safe to require.

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
licenses:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: actions/setup-node@v4
with:
node-version: "22"

# Dependency-free by design — this repo has no root package.json, so the gate must
# run against a bare checkout with nothing installed.
- name: Declared license vs shipped license text
run: node scripts/license-consistency.mjs
154 changes: 154 additions & 0 deletions scripts/license-consistency.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* license-consistency — every publishable package in this monorepo must ship the license
* text it declares.
*
* WHY: on 2026-09-03 all 49 `sdk-typescript/packages/*` manifests declared `Apache-2.0`
* while the `LICENSE` file sitting beside each of them was the **MIT** text, and so did
* `sdk-python/` (whose `pyproject.toml` also declares Apache-2.0). npm and PyPI pack the
* LICENSE from the package directory, so `@wave-av/workflow-sdk@1.0.6` is on the public
* registry today carrying the MIT text under a source tree that declares Apache-2.0. The
* repository ROOT LICENSE was Apache-2.0 the whole time — which is exactly why nobody saw
* it: every check anyone had looked at the root.
*
* This gate reads the license TEXT next to each manifest and names it, then requires the
* declaration to match. Comparing declarations to each other cannot catch this class of
* defect; only reading the file can.
*
* Usage: node scripts/license-consistency.mjs [--json]
* Exit 0 when every declared license matches its shipped text; 1 otherwise.
*
* Dependency-free on purpose: this repo has no root package.json, so the gate must run
* with nothing installed.
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');

/** Name a license from its text. Ordered so Apache's appendix cannot be read as MIT. */
export function detectSpdxFromText(text) {
if (typeof text !== 'string' || !text.trim()) return 'UNKNOWN';
if (/apache\s+license\s*\n?\s*version\s+2\.0/i.test(text)) return 'Apache-2.0';
if (/mozilla public license\s*,?\s*(version\s+)?2\.0/i.test(text)) return 'MPL-2.0';
if (/\bbsd\b.*\blicense\b/i.test(text) && /neither the name of/i.test(text)) return 'BSD-3-Clause';
if (/permission to use, copy, modify,? and\/or distribute/i.test(text)) return 'ISC';
if (/\bmit license\b/i.test(text)) return 'MIT';
if (/permission is hereby granted, free of charge/i.test(text)) return 'MIT';
return 'UNKNOWN';
}

/** The license a manifest declares, or null when it declares none. */
export function declaredLicense(manifestPath) {
const text = readFileSync(manifestPath, 'utf8');
if (manifestPath.endsWith('.json')) {
Comment on lines +31 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: New license-consistency script has no unit tests

The script exports pure, easily-testable functions (detectSpdxFromText, declaredLicense, nearestLicense, audit) but ships with no automated test file — only manual "proving run" transcripts in the PR description. Since this is meant to be a required, long-lived CI gate, a small test suite (e.g. asserting detectSpdxFromText against known Apache/MIT/BSD/ISC sample texts, and nearestLicense ancestor fallback behavior) would prevent silent regressions when the script is later modified.

Was this helpful? React with 👍 / 👎

const pkg = JSON.parse(text);
if (pkg.private === true) return null; // never published
const l = pkg.license;
return typeof l === 'string' ? l : l?.type ?? null;
}
Comment on lines +43 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: declaredLicense doesn't handle SPDX compound expressions in package.json

declaredLicense reads pkg.license as a plain string or {type} object, but npm also allows SPDX expressions like (MIT OR Apache-2.0) and the deprecated licenses: [...] array form. If any manifest is later authored with a compound expression, detectSpdxFromText will never return a matching compound string, so the gate will permanently fail that package even though it's technically valid — a false positive that could block a legitimate release. Not triggered today since all current manifests use plain SPDX-string licenses, but worth a comment or explicit handling (e.g., treat non-simple-identifier strings as unsupported and skip with a warning rather than silently mismatching).

Was this helpful? React with 👍 / 👎

if (manifestPath.endsWith('.gemspec')) {
return text.match(/\.license\s*=\s*["']([^"']+)["']/)?.[1] ?? null;
}
if (manifestPath.endsWith('pyproject.toml')) {
return (
text.match(/^\s*license\s*=\s*\{[^}]*text\s*=\s*["']([^"']+)["']/m)?.[1] ??
text.match(/^\s*license\s*=\s*["']([^"']+)["']/m)?.[1] ??
null
);
}
if (manifestPath.endsWith('Cargo.toml')) {
// Only a crate's own [package] license counts; a workspace Cargo.toml has none.
return text.match(/^\s*license\s*=\s*["']([^"']+)["']/m)?.[1] ?? null;
}
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Cargo.toml license regex is not scoped to the [package] table

declaredLicense for Cargo.toml matches /^\s*license\s*=\s*["']([^"']+)["']/m against the whole file, so if any future dependency/workspace metadata table also defines a top-level license = "..." key before [package], or a crate is restructured, the regex could grab the wrong value. Current crates (sdk-rust/wave, wave-core, wave-x402) are unaffected since their only license key is under [package], but scoping the match to text between [package] and the next [ header would make the gate robust against future Cargo.toml changes.

Was this helpful? React with 👍 / 👎

return null;
}

/**
* The LICENSE that actually travels with a package: the one in its own directory, else the
* nearest ancestor's — the same lookup npm, pip, cargo and gem effectively perform.
*/
export function nearestLicense(dir) {
let cur = dir;
while (cur.startsWith(ROOT)) {
for (const name of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE']) {
const p = join(cur, name);
if (existsSync(p)) return p;
}
if (cur === ROOT) break;
cur = dirname(cur);
}
return null;
}

const MANIFESTS = /^(package\.json|pyproject\.toml|Cargo\.toml|.*\.gemspec)$/;
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.venv']);

export function findManifests(dir = ROOT, out = []) {
for (const entry of readdirSync(dir)) {
if (SKIP.has(entry)) continue;
const p = join(dir, entry);
let s;
try {
s = statSync(p);
} catch {
continue;
}
if (s.isDirectory()) findManifests(p, out);
else if (MANIFESTS.test(entry)) out.push(p);
}
return out;
}

export function audit() {
const rows = [];
for (const manifest of findManifests()) {
const declared = declaredLicense(manifest);
if (!declared) continue; // private, a workspace root, or declares nothing
const licensePath = nearestLicense(dirname(manifest));
const shipped = licensePath ? detectSpdxFromText(readFileSync(licensePath, 'utf8')) : 'MISSING';
rows.push({
manifest: relative(ROOT, manifest),
declared,
license: licensePath ? relative(ROOT, licensePath) : null,
shipped,
ok: shipped === declared,
});
}
rows.sort((a, b) => a.manifest.localeCompare(b.manifest));
return rows;
}

function main() {
const rows = audit();
const bad = rows.filter((r) => !r.ok);

if (process.argv.includes('--json')) {
process.stdout.write(`${JSON.stringify({ rows, failing: bad.length }, null, 2)}\n`);
return bad.length ? 1 : 0;
}

console.log(`license-consistency: checked ${rows.length} publishable packages`);
if (!bad.length) {
console.log('OK — every declared license matches the license text shipped beside it.');
return 0;
}
console.error(`\n${bad.length} package(s) declare a license they do not ship:\n`);
for (const r of bad) {
console.error(
` ${r.manifest}\n` +
` declares : ${r.declared}\n` +
` ships : ${r.shipped}${r.license ? ` (${r.license})` : ' — no LICENSE file found'}`
);
}
console.error(
'\nFix the LICENSE file to match what the manifest already declares. Changing the ' +
'DECLARATION instead is a relicensing decision and is not a CI fix.'
);
return 1;
}

if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
process.exit(main());
}
Loading
Loading