Skip to content

Commit 4ff4869

Browse files
authored
feat: make OSS license audit runnable locally (#1021)
* oss license audit * fix error in license audit * make license audit runnable locally
1 parent 9921abc commit 4ff4869

File tree

3 files changed

+206
-134
lines changed

3 files changed

+206
-134
lines changed

.github/workflows/license-audit.yml

Lines changed: 70 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ on:
1010
- "scripts/fetchLicenses.mjs"
1111
- "scripts/summarizeLicenses.mjs"
1212
- "scripts/npmLicenseMap.json"
13+
- "scripts/licenseAuditPrompt.txt"
14+
- "scripts/runLicenseAudit.sh"
1315
workflow_dispatch:
1416

1517
jobs:
@@ -40,80 +42,21 @@ jobs:
4042
- name: Summarize licenses
4143
run: node scripts/summarizeLicenses.mjs
4244

45+
- name: Read audit prompt
46+
id: read-prompt
47+
run: |
48+
{
49+
echo 'PROMPT<<PROMPT_EOF'
50+
cat scripts/licenseAuditPrompt.txt
51+
echo 'PROMPT_EOF'
52+
} >> "$GITHUB_OUTPUT"
53+
4354
- name: Audit licenses with Claude
4455
uses: anthropics/claude-code-action@v1
4556
with:
4657
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
4758
claude_args: '--allowedTools "Bash,Read,Write,Glob,Grep,WebFetch"'
48-
prompt: |
49-
You are a license compliance auditor. Your job is to review the OSS dependency
50-
licenses in this repository and produce a structured audit result.
51-
52-
## Steps
53-
54-
1. Read the file `oss-licenses.json` in the repo root.
55-
56-
2. Identify every package whose `license` field is:
57-
- `"UNKNOWN"`
58-
- A non-standard SPDX string (e.g., `"SEE LICENSE IN LICENSE"`, `"UNLICENSED"`,
59-
`"SEE LICENSE IN ..."`, or any value that is not a recognized SPDX identifier)
60-
- An object instead of a string (e.g., `{"type":"MIT","url":"..."}`)
61-
62-
3. For each such package, try to resolve the actual license:
63-
- Use WebFetch to visit `https://www.npmjs.com/package/<package-name>` and look
64-
for license information on the npm page.
65-
- If the npm page is inconclusive, check the package's `repository` or `homepage`
66-
URL (from oss-licenses.json) via WebFetch to find a LICENSE file.
67-
- If the license field is an object like `{"type":"MIT","url":"..."}`, extract the
68-
`type` field as the resolved license.
69-
70-
4. Identify all copyleft-licensed packages. Classify them as:
71-
- **Strong copyleft**: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0, SSPL-1.0, EUPL-1.1,
72-
EUPL-1.2, CPAL-1.0, OSL-3.0 (and any `-only` or `-or-later` variants)
73-
- **Weak copyleft**: LGPL-2.0, LGPL-2.1, LGPL-3.0, MPL-2.0, CC-BY-SA-3.0,
74-
CC-BY-SA-4.0 (and any `-only` or `-or-later` variants)
75-
76-
5. Write a file called `license-audit-result.json` in the repo root with this structure:
77-
```json
78-
{
79-
"status": "PASS or FAIL",
80-
"failReasons": ["list of reasons if FAIL, empty array if PASS"],
81-
"summary": {
82-
"totalPackages": 0,
83-
"resolvedCount": 0,
84-
"unresolvedCount": 0,
85-
"strongCopyleftCount": 0,
86-
"weakCopyleftCount": 0
87-
},
88-
"resolved": [
89-
{ "name": "pkg-name", "version": "1.0.0", "originalLicense": "...", "resolvedLicense": "MIT", "source": "npm page / GitHub repo / extracted from object" }
90-
],
91-
"unresolved": [
92-
{ "name": "pkg-name", "version": "1.0.0", "license": "UNKNOWN", "reason": "why it could not be resolved" }
93-
],
94-
"copyleft": {
95-
"strong": [
96-
{ "name": "pkg-name", "version": "1.0.0", "license": "GPL-3.0" }
97-
],
98-
"weak": [
99-
{ "name": "pkg-name", "version": "1.0.0", "license": "MPL-2.0" }
100-
]
101-
}
102-
}
103-
```
104-
105-
6. Set `status` to `"FAIL"` if `unresolvedCount > 0` OR `strongCopyleftCount > 0`.
106-
Otherwise set it to `"PASS"`.
107-
108-
7. If the status is FAIL, populate `failReasons` with human-readable explanations, e.g.:
109-
- "2 packages have unresolvable licenses: pkg-a, pkg-b"
110-
- "1 package uses strong copyleft license: pkg-c (GPL-3.0)"
111-
112-
## Important Notes
113-
- Do NOT modify any source files. Only write `license-audit-result.json`.
114-
- Be thorough: check every non-standard license, not just a sample.
115-
- If a package's license object has a `type` field, that counts as resolved.
116-
- Weak copyleft licenses (LGPL, MPL) are flagged but do NOT cause a FAIL.
59+
prompt: ${{ steps.read-prompt.outputs.PROMPT }}
11760

11861
- name: Validate audit result
11962
run: |
@@ -148,86 +91,79 @@ jobs:
14891
with:
14992
script: |
15093
const fs = require('fs');
151-
const path = 'license-audit-result.json';
94+
const resultPath = 'license-audit-result.json';
15295
153-
if (!fs.existsSync(path)) {
154-
const body = `## License Audit\n\n:x: Audit failed to produce results. Check the workflow logs for details.`;
155-
const comments = await github.rest.issues.listComments({
156-
owner: context.repo.owner,
157-
repo: context.repo.repo,
158-
issue_number: context.issue.number,
159-
});
160-
const existing = comments.data.find(c => c.body.startsWith('## License Audit'));
161-
if (existing) {
162-
await github.rest.issues.updateComment({
163-
owner: context.repo.owner,
164-
repo: context.repo.repo,
165-
comment_id: existing.id,
166-
body,
167-
});
168-
} else {
169-
await github.rest.issues.createComment({
170-
owner: context.repo.owner,
171-
repo: context.repo.repo,
172-
issue_number: context.issue.number,
173-
body,
174-
});
175-
}
176-
return;
177-
}
96+
// Determine if we should comment
97+
let shouldComment = false;
98+
let body = '';
17899
179-
const result = JSON.parse(fs.readFileSync(path, 'utf-8'));
180-
const icon = result.status === 'PASS' ? ':white_check_mark:' : ':x:';
100+
if (!fs.existsSync(resultPath)) {
101+
shouldComment = true;
102+
body = `## License Audit\n\n:x: Audit failed to produce results. Check the workflow logs for details.`;
103+
} else {
104+
const result = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
105+
const hasWeakCopyleft = result.summary.weakCopyleftCount > 0;
106+
const isFail = result.status === 'FAIL';
181107
182-
let body = `## License Audit\n\n`;
183-
body += `${icon} **Status: ${result.status}**\n\n`;
184-
body += `| Metric | Count |\n|---|---|\n`;
185-
body += `| Total packages | ${result.summary.totalPackages} |\n`;
186-
body += `| Resolved (non-standard) | ${result.summary.resolvedCount} |\n`;
187-
body += `| Unresolved | ${result.summary.unresolvedCount} |\n`;
188-
body += `| Strong copyleft | ${result.summary.strongCopyleftCount} |\n`;
189-
body += `| Weak copyleft | ${result.summary.weakCopyleftCount} |\n`;
108+
if (!isFail && !hasWeakCopyleft) {
109+
return;
110+
}
190111
191-
if (result.failReasons && result.failReasons.length > 0) {
192-
body += `\n### Fail Reasons\n\n`;
193-
for (const reason of result.failReasons) {
194-
body += `- ${reason}\n`;
112+
shouldComment = true;
113+
const icon = isFail ? ':x:' : ':warning:';
114+
115+
body = `## License Audit\n\n`;
116+
body += `${icon} **Status: ${result.status}**\n\n`;
117+
body += `| Metric | Count |\n|---|---|\n`;
118+
body += `| Total packages | ${result.summary.totalPackages} |\n`;
119+
body += `| Resolved (non-standard) | ${result.summary.resolvedCount} |\n`;
120+
body += `| Unresolved | ${result.summary.unresolvedCount} |\n`;
121+
body += `| Strong copyleft | ${result.summary.strongCopyleftCount} |\n`;
122+
body += `| Weak copyleft | ${result.summary.weakCopyleftCount} |\n`;
123+
124+
if (result.failReasons && result.failReasons.length > 0) {
125+
body += `\n### Fail Reasons\n\n`;
126+
for (const reason of result.failReasons) {
127+
body += `- ${reason}\n`;
128+
}
195129
}
196-
}
197130
198-
if (result.unresolved && result.unresolved.length > 0) {
199-
body += `\n### Unresolved Packages\n\n`;
200-
body += `| Package | Version | License | Reason |\n|---|---|---|---|\n`;
201-
for (const pkg of result.unresolved) {
202-
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` | ${pkg.reason} |\n`;
131+
if (result.unresolved && result.unresolved.length > 0) {
132+
body += `\n### Unresolved Packages\n\n`;
133+
body += `| Package | Version | License | Reason |\n|---|---|---|---|\n`;
134+
for (const pkg of result.unresolved) {
135+
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` | ${pkg.reason} |\n`;
136+
}
203137
}
204-
}
205138
206-
if (result.copyleft && result.copyleft.strong && result.copyleft.strong.length > 0) {
207-
body += `\n### Strong Copyleft Packages\n\n`;
208-
body += `| Package | Version | License |\n|---|---|---|\n`;
209-
for (const pkg of result.copyleft.strong) {
210-
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
139+
if (result.copyleft && result.copyleft.strong && result.copyleft.strong.length > 0) {
140+
body += `\n### Strong Copyleft Packages\n\n`;
141+
body += `| Package | Version | License |\n|---|---|---|\n`;
142+
for (const pkg of result.copyleft.strong) {
143+
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
144+
}
211145
}
212-
}
213146
214-
if (result.copyleft && result.copyleft.weak && result.copyleft.weak.length > 0) {
215-
body += `\n### Weak Copyleft Packages (informational)\n\n`;
216-
body += `| Package | Version | License |\n|---|---|---|\n`;
217-
for (const pkg of result.copyleft.weak) {
218-
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
147+
if (result.copyleft && result.copyleft.weak && result.copyleft.weak.length > 0) {
148+
body += `\n### Weak Copyleft Packages (informational)\n\n`;
149+
body += `| Package | Version | License |\n|---|---|---|\n`;
150+
for (const pkg of result.copyleft.weak) {
151+
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.license}\` |\n`;
152+
}
219153
}
220-
}
221154
222-
if (result.resolved && result.resolved.length > 0) {
223-
body += `\n<details><summary>Resolved Packages (${result.resolved.length})</summary>\n\n`;
224-
body += `| Package | Version | Original | Resolved | Source |\n|---|---|---|---|---|\n`;
225-
for (const pkg of result.resolved) {
226-
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.originalLicense}\` | \`${pkg.resolvedLicense}\` | ${pkg.source} |\n`;
155+
if (result.resolved && result.resolved.length > 0) {
156+
body += `\n<details><summary>Resolved Packages (${result.resolved.length})</summary>\n\n`;
157+
body += `| Package | Version | Original | Resolved | Source |\n|---|---|---|---|---|\n`;
158+
for (const pkg of result.resolved) {
159+
body += `| ${pkg.name} | ${pkg.version} | \`${pkg.originalLicense}\` | \`${pkg.resolvedLicense}\` | ${pkg.source} |\n`;
160+
}
161+
body += `\n</details>\n`;
227162
}
228-
body += `\n</details>\n`;
229163
}
230164
165+
if (!shouldComment) return;
166+
231167
const comments = await github.rest.issues.listComments({
232168
owner: context.repo.owner,
233169
repo: context.repo.repo,

scripts/licenseAuditPrompt.txt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
You are a license compliance auditor. Your job is to review the OSS dependency
2+
licenses in this repository and produce a structured audit result.
3+
4+
## Steps
5+
6+
1. Read the file `oss-licenses.json` in the repo root.
7+
8+
2. Identify every package whose `license` field is:
9+
- `"UNKNOWN"`
10+
- A non-standard SPDX string (e.g., `"SEE LICENSE IN LICENSE"`, `"UNLICENSED"`,
11+
`"SEE LICENSE IN ..."`, or any value that is not a recognized SPDX identifier)
12+
- An object instead of a string (e.g., `{"type":"MIT","url":"..."}`)
13+
14+
3. For each such package, try to resolve the actual license:
15+
- Use WebFetch to visit `https://www.npmjs.com/package/<package-name>` and look
16+
for license information on the npm page.
17+
- If the npm page is inconclusive, check the package's `repository` or `homepage`
18+
URL (from oss-licenses.json) via WebFetch to find a LICENSE file.
19+
- If the license field is an object like `{"type":"MIT","url":"..."}`, extract the
20+
`type` field as the resolved license.
21+
22+
4. Identify all copyleft-licensed packages. Classify them as:
23+
- **Strong copyleft**: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0, SSPL-1.0, EUPL-1.1,
24+
EUPL-1.2, CPAL-1.0, OSL-3.0 (and any `-only` or `-or-later` variants)
25+
- **Weak copyleft**: LGPL-2.0, LGPL-2.1, LGPL-3.0, MPL-2.0, CC-BY-SA-3.0,
26+
CC-BY-SA-4.0 (and any `-only` or `-or-later` variants)
27+
28+
5. Write a file called `license-audit-result.json` in the repo root with this structure:
29+
```json
30+
{
31+
"status": "PASS or FAIL",
32+
"failReasons": ["list of reasons if FAIL, empty array if PASS"],
33+
"summary": {
34+
"totalPackages": 0,
35+
"resolvedCount": 0,
36+
"unresolvedCount": 0,
37+
"strongCopyleftCount": 0,
38+
"weakCopyleftCount": 0
39+
},
40+
"resolved": [
41+
{ "name": "pkg-name", "version": "1.0.0", "originalLicense": "...", "resolvedLicense": "MIT", "source": "npm page / GitHub repo / extracted from object" }
42+
],
43+
"unresolved": [
44+
{ "name": "pkg-name", "version": "1.0.0", "license": "UNKNOWN", "reason": "why it could not be resolved" }
45+
],
46+
"copyleft": {
47+
"strong": [
48+
{ "name": "pkg-name", "version": "1.0.0", "license": "GPL-3.0" }
49+
],
50+
"weak": [
51+
{ "name": "pkg-name", "version": "1.0.0", "license": "MPL-2.0" }
52+
]
53+
}
54+
}
55+
```
56+
57+
6. Set `status` to `"FAIL"` if `unresolvedCount > 0` OR `strongCopyleftCount > 0`.
58+
Otherwise set it to `"PASS"`.
59+
60+
7. If the status is FAIL, populate `failReasons` with human-readable explanations, e.g.:
61+
- "2 packages have unresolvable licenses: pkg-a, pkg-b"
62+
- "1 package uses strong copyleft license: pkg-c (GPL-3.0)"
63+
64+
## Important Notes
65+
- Do NOT modify any source files. Only write `license-audit-result.json`.
66+
- Be thorough: check every non-standard license, not just a sample.
67+
- If a package's license object has a `type` field, that counts as resolved.
68+
- Weak copyleft licenses (LGPL, MPL) are flagged but do NOT cause a FAIL.

scripts/runLicenseAudit.sh

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
6+
7+
cd "$REPO_ROOT"
8+
9+
echo "=== License Audit ==="
10+
echo ""
11+
12+
# Step 1: Fetch licenses
13+
echo "1. Fetching licenses from npm registry..."
14+
node scripts/fetchLicenses.mjs
15+
echo ""
16+
17+
# Step 2: Summarize licenses
18+
echo "2. Summarizing licenses..."
19+
node scripts/summarizeLicenses.mjs
20+
echo ""
21+
22+
# Step 3: Run Claude audit
23+
echo "3. Running Claude license audit..."
24+
if ! command -v claude &> /dev/null; then
25+
echo "Error: 'claude' CLI is not installed. Install it with: npm install -g @anthropic-ai/claude-code"
26+
exit 1
27+
fi
28+
29+
PROMPT_FILE="$SCRIPT_DIR/licenseAuditPrompt.txt"
30+
if [ ! -f "$PROMPT_FILE" ]; then
31+
echo "Error: Prompt file not found at $PROMPT_FILE"
32+
exit 1
33+
fi
34+
35+
claude --dangerously-skip-permissions -p "$(cat "$PROMPT_FILE")" \
36+
--allowedTools "Bash,Read,Write,Glob,Grep,WebFetch"
37+
echo ""
38+
39+
# Step 4: Validate result
40+
echo "4. Validating audit result..."
41+
if [ ! -f license-audit-result.json ]; then
42+
echo "Error: license-audit-result.json was not created by the audit step"
43+
exit 1
44+
fi
45+
46+
STATUS=$(node -e "const r = require('./license-audit-result.json'); console.log(r.status)")
47+
UNRESOLVED=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.unresolvedCount)")
48+
STRONG=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.strongCopyleftCount)")
49+
WEAK=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.weakCopyleftCount)")
50+
RESOLVED=$(node -e "const r = require('./license-audit-result.json'); console.log(r.summary.resolvedCount)")
51+
52+
echo ""
53+
echo "== License Audit Result: $STATUS =="
54+
echo ""
55+
echo " Resolved: $RESOLVED"
56+
echo " Unresolved: $UNRESOLVED"
57+
echo " Strong copyleft: $STRONG"
58+
echo " Weak copyleft: $WEAK"
59+
60+
if [ "$STATUS" = "FAIL" ]; then
61+
echo ""
62+
echo "FAIL reasons:"
63+
node -e "const r = require('./license-audit-result.json'); r.failReasons.forEach(r => console.log(' - ' + r))"
64+
exit 1
65+
fi
66+
67+
echo ""
68+
echo "License audit passed."

0 commit comments

Comments
 (0)