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
204 changes: 70 additions & 134 deletions .github/workflows/license-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
- "scripts/fetchLicenses.mjs"
- "scripts/summarizeLicenses.mjs"
- "scripts/npmLicenseMap.json"
- "scripts/licenseAuditPrompt.txt"
- "scripts/runLicenseAudit.sh"
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -40,80 +42,21 @@ jobs:
- name: Summarize licenses
run: node scripts/summarizeLicenses.mjs

- name: Read audit prompt
id: read-prompt
run: |
{
echo 'PROMPT<<PROMPT_EOF'
cat scripts/licenseAuditPrompt.txt
echo 'PROMPT_EOF'
} >> "$GITHUB_OUTPUT"

- name: Audit licenses with Claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: '--allowedTools "Bash,Read,Write,Glob,Grep,WebFetch"'
prompt: |
You are a license compliance auditor. Your job is to review the OSS dependency
licenses in this repository and produce a structured audit result.

## Steps

1. Read the file `oss-licenses.json` in the repo root.

2. Identify every package whose `license` field is:
- `"UNKNOWN"`
- A non-standard SPDX string (e.g., `"SEE LICENSE IN LICENSE"`, `"UNLICENSED"`,
`"SEE LICENSE IN ..."`, or any value that is not a recognized SPDX identifier)
- An object instead of a string (e.g., `{"type":"MIT","url":"..."}`)

3. For each such package, try to resolve the actual license:
- Use WebFetch to visit `https://www.npmjs.com/package/<package-name>` and look
for license information on the npm page.
- If the npm page is inconclusive, check the package's `repository` or `homepage`
URL (from oss-licenses.json) via WebFetch to find a LICENSE file.
- If the license field is an object like `{"type":"MIT","url":"..."}`, extract the
`type` field as the resolved license.

4. Identify all copyleft-licensed packages. Classify them as:
- **Strong copyleft**: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0, SSPL-1.0, EUPL-1.1,
EUPL-1.2, CPAL-1.0, OSL-3.0 (and any `-only` or `-or-later` variants)
- **Weak copyleft**: LGPL-2.0, LGPL-2.1, LGPL-3.0, MPL-2.0, CC-BY-SA-3.0,
CC-BY-SA-4.0 (and any `-only` or `-or-later` variants)

5. Write a file called `license-audit-result.json` in the repo root with this structure:
```json
{
"status": "PASS or FAIL",
"failReasons": ["list of reasons if FAIL, empty array if PASS"],
"summary": {
"totalPackages": 0,
"resolvedCount": 0,
"unresolvedCount": 0,
"strongCopyleftCount": 0,
"weakCopyleftCount": 0
},
"resolved": [
{ "name": "pkg-name", "version": "1.0.0", "originalLicense": "...", "resolvedLicense": "MIT", "source": "npm page / GitHub repo / extracted from object" }
],
"unresolved": [
{ "name": "pkg-name", "version": "1.0.0", "license": "UNKNOWN", "reason": "why it could not be resolved" }
],
"copyleft": {
"strong": [
{ "name": "pkg-name", "version": "1.0.0", "license": "GPL-3.0" }
],
"weak": [
{ "name": "pkg-name", "version": "1.0.0", "license": "MPL-2.0" }
]
}
}
```

6. Set `status` to `"FAIL"` if `unresolvedCount > 0` OR `strongCopyleftCount > 0`.
Otherwise set it to `"PASS"`.

7. If the status is FAIL, populate `failReasons` with human-readable explanations, e.g.:
- "2 packages have unresolvable licenses: pkg-a, pkg-b"
- "1 package uses strong copyleft license: pkg-c (GPL-3.0)"

## Important Notes
- Do NOT modify any source files. Only write `license-audit-result.json`.
- Be thorough: check every non-standard license, not just a sample.
- If a package's license object has a `type` field, that counts as resolved.
- Weak copyleft licenses (LGPL, MPL) are flagged but do NOT cause a FAIL.
prompt: ${{ steps.read-prompt.outputs.PROMPT }}

- name: Validate audit result
run: |
Expand Down Expand Up @@ -148,86 +91,79 @@ jobs:
with:
script: |
const fs = require('fs');
const path = 'license-audit-result.json';
const resultPath = 'license-audit-result.json';

if (!fs.existsSync(path)) {
const body = `## License Audit\n\n:x: Audit failed to produce results. Check the workflow logs for details.`;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.data.find(c => c.body.startsWith('## License Audit'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
return;
}
// Determine if we should comment
let shouldComment = false;
let body = '';

const result = JSON.parse(fs.readFileSync(path, 'utf-8'));
const icon = result.status === 'PASS' ? ':white_check_mark:' : ':x:';
if (!fs.existsSync(resultPath)) {
shouldComment = true;
body = `## License Audit\n\n:x: Audit failed to produce results. Check the workflow logs for details.`;
} else {
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
const hasWeakCopyleft = result.summary.weakCopyleftCount > 0;
const isFail = result.status === 'FAIL';

let body = `## License Audit\n\n`;
body += `${icon} **Status: ${result.status}**\n\n`;
body += `| Metric | Count |\n|---|---|\n`;
body += `| Total packages | ${result.summary.totalPackages} |\n`;
body += `| Resolved (non-standard) | ${result.summary.resolvedCount} |\n`;
body += `| Unresolved | ${result.summary.unresolvedCount} |\n`;
body += `| Strong copyleft | ${result.summary.strongCopyleftCount} |\n`;
body += `| Weak copyleft | ${result.summary.weakCopyleftCount} |\n`;
if (!isFail && !hasWeakCopyleft) {
return;
}

if (result.failReasons && result.failReasons.length > 0) {
body += `\n### Fail Reasons\n\n`;
for (const reason of result.failReasons) {
body += `- ${reason}\n`;
shouldComment = true;
const icon = isFail ? ':x:' : ':warning:';

body = `## License Audit\n\n`;
body += `${icon} **Status: ${result.status}**\n\n`;
body += `| Metric | Count |\n|---|---|\n`;
body += `| Total packages | ${result.summary.totalPackages} |\n`;
body += `| Resolved (non-standard) | ${result.summary.resolvedCount} |\n`;
body += `| Unresolved | ${result.summary.unresolvedCount} |\n`;
body += `| Strong copyleft | ${result.summary.strongCopyleftCount} |\n`;
body += `| Weak copyleft | ${result.summary.weakCopyleftCount} |\n`;

if (result.failReasons && result.failReasons.length > 0) {
body += `\n### Fail Reasons\n\n`;
for (const reason of result.failReasons) {
body += `- ${reason}\n`;
}
}
}

if (result.unresolved && result.unresolved.length > 0) {
body += `\n### Unresolved Packages\n\n`;
body += `| Package | Version | License | Reason |\n|---|---|---|---|\n`;
for (const pkg of result.unresolved) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` | ${pkg.reason} |\n`;
if (result.unresolved && result.unresolved.length > 0) {
body += `\n### Unresolved Packages\n\n`;
body += `| Package | Version | License | Reason |\n|---|---|---|---|\n`;
for (const pkg of result.unresolved) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` | ${pkg.reason} |\n`;
}
}
}

if (result.copyleft && result.copyleft.strong && result.copyleft.strong.length > 0) {
body += `\n### Strong Copyleft Packages\n\n`;
body += `| Package | Version | License |\n|---|---|---|\n`;
for (const pkg of result.copyleft.strong) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
if (result.copyleft && result.copyleft.strong && result.copyleft.strong.length > 0) {
body += `\n### Strong Copyleft Packages\n\n`;
body += `| Package | Version | License |\n|---|---|---|\n`;
for (const pkg of result.copyleft.strong) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
}
}
}

if (result.copyleft && result.copyleft.weak && result.copyleft.weak.length > 0) {
body += `\n### Weak Copyleft Packages (informational)\n\n`;
body += `| Package | Version | License |\n|---|---|---|\n`;
for (const pkg of result.copyleft.weak) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
if (result.copyleft && result.copyleft.weak && result.copyleft.weak.length > 0) {
body += `\n### Weak Copyleft Packages (informational)\n\n`;
body += `| Package | Version | License |\n|---|---|---|\n`;
for (const pkg of result.copyleft.weak) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
}
}
}

if (result.resolved && result.resolved.length > 0) {
body += `\n<details><summary>Resolved Packages (${result.resolved.length})</summary>\n\n`;
body += `| Package | Version | Original | Resolved | Source |\n|---|---|---|---|---|\n`;
for (const pkg of result.resolved) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.originalLicense}\` | \`${pkg.resolvedLicense}\` | ${pkg.source} |\n`;
if (result.resolved && result.resolved.length > 0) {
body += `\n<details><summary>Resolved Packages (${result.resolved.length})</summary>\n\n`;
body += `| Package | Version | Original | Resolved | Source |\n|---|---|---|---|---|\n`;
for (const pkg of result.resolved) {
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.originalLicense}\` | \`${pkg.resolvedLicense}\` | ${pkg.source} |\n`;
}
body += `\n</details>\n`;
}
body += `\n</details>\n`;
}

if (!shouldComment) return;

const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
Expand Down
68 changes: 68 additions & 0 deletions scripts/licenseAuditPrompt.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
You are a license compliance auditor. Your job is to review the OSS dependency
licenses in this repository and produce a structured audit result.

## Steps

1. Read the file `oss-licenses.json` in the repo root.

2. Identify every package whose `license` field is:
- `"UNKNOWN"`
- A non-standard SPDX string (e.g., `"SEE LICENSE IN LICENSE"`, `"UNLICENSED"`,
`"SEE LICENSE IN ..."`, or any value that is not a recognized SPDX identifier)
- An object instead of a string (e.g., `{"type":"MIT","url":"..."}`)

3. For each such package, try to resolve the actual license:
- Use WebFetch to visit `https://www.npmjs.com/package/<package-name>` and look
for license information on the npm page.
- If the npm page is inconclusive, check the package's `repository` or `homepage`
URL (from oss-licenses.json) via WebFetch to find a LICENSE file.
- If the license field is an object like `{"type":"MIT","url":"..."}`, extract the
`type` field as the resolved license.

4. Identify all copyleft-licensed packages. Classify them as:
- **Strong copyleft**: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0, SSPL-1.0, EUPL-1.1,
EUPL-1.2, CPAL-1.0, OSL-3.0 (and any `-only` or `-or-later` variants)
- **Weak copyleft**: LGPL-2.0, LGPL-2.1, LGPL-3.0, MPL-2.0, CC-BY-SA-3.0,
CC-BY-SA-4.0 (and any `-only` or `-or-later` variants)

5. Write a file called `license-audit-result.json` in the repo root with this structure:
```json
{
"status": "PASS or FAIL",
"failReasons": ["list of reasons if FAIL, empty array if PASS"],
"summary": {
"totalPackages": 0,
"resolvedCount": 0,
"unresolvedCount": 0,
"strongCopyleftCount": 0,
"weakCopyleftCount": 0
},
"resolved": [
{ "name": "pkg-name", "version": "1.0.0", "originalLicense": "...", "resolvedLicense": "MIT", "source": "npm page / GitHub repo / extracted from object" }
],
"unresolved": [
{ "name": "pkg-name", "version": "1.0.0", "license": "UNKNOWN", "reason": "why it could not be resolved" }
],
"copyleft": {
"strong": [
{ "name": "pkg-name", "version": "1.0.0", "license": "GPL-3.0" }
],
"weak": [
{ "name": "pkg-name", "version": "1.0.0", "license": "MPL-2.0" }
]
}
}
```

6. Set `status` to `"FAIL"` if `unresolvedCount > 0` OR `strongCopyleftCount > 0`.
Otherwise set it to `"PASS"`.

7. If the status is FAIL, populate `failReasons` with human-readable explanations, e.g.:
- "2 packages have unresolvable licenses: pkg-a, pkg-b"
- "1 package uses strong copyleft license: pkg-c (GPL-3.0)"

## Important Notes
- Do NOT modify any source files. Only write `license-audit-result.json`.
- Be thorough: check every non-standard license, not just a sample.
- If a package's license object has a `type` field, that counts as resolved.
- Weak copyleft licenses (LGPL, MPL) are flagged but do NOT cause a FAIL.
68 changes: 68 additions & 0 deletions scripts/runLicenseAudit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

cd "$REPO_ROOT"

echo "=== License Audit ==="
echo ""

# Step 1: Fetch licenses
echo "1. Fetching licenses from npm registry..."
node scripts/fetchLicenses.mjs
echo ""

# Step 2: Summarize licenses
echo "2. Summarizing licenses..."
node scripts/summarizeLicenses.mjs
echo ""

# Step 3: Run Claude audit
echo "3. Running Claude license audit..."
if ! command -v claude &> /dev/null; then
echo "Error: 'claude' CLI is not installed. Install it with: npm install -g @anthropic-ai/claude-code"
exit 1
fi

PROMPT_FILE="$SCRIPT_DIR/licenseAuditPrompt.txt"
if [ ! -f "$PROMPT_FILE" ]; then
echo "Error: Prompt file not found at $PROMPT_FILE"
exit 1
fi

claude --dangerously-skip-permissions -p "$(cat "$PROMPT_FILE")" \
--allowedTools "Bash,Read,Write,Glob,Grep,WebFetch"
echo ""

# Step 4: Validate result
echo "4. Validating audit result..."
if [ ! -f license-audit-result.json ]; then
echo "Error: license-audit-result.json was not created by the audit step"
exit 1
fi

STATUS=$(node -e "const r = require('./license-audit-result.json'); console.log(r.status)")
UNRESOLVED=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.unresolvedCount)")
STRONG=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.strongCopyleftCount)")
WEAK=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.weakCopyleftCount)")
RESOLVED=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.resolvedCount)")

echo ""
echo "== License Audit Result: $STATUS =="
echo ""
echo " Resolved: $RESOLVED"
echo " Unresolved: $UNRESOLVED"
echo " Strong copyleft: $STRONG"
echo " Weak copyleft: $WEAK"

if [ "$STATUS" = "FAIL" ]; then
echo ""
echo "FAIL reasons:"
node -e "const r = require('./license-audit-result.json'); r.failReasons.forEach(r => console.log(' - ' + r))"
exit 1
fi

echo ""
echo "License audit passed."
Loading