|
10 | 10 | - "scripts/fetchLicenses.mjs" |
11 | 11 | - "scripts/summarizeLicenses.mjs" |
12 | 12 | - "scripts/npmLicenseMap.json" |
| 13 | + - "scripts/licenseAuditPrompt.txt" |
| 14 | + - "scripts/runLicenseAudit.sh" |
13 | 15 | workflow_dispatch: |
14 | 16 |
|
15 | 17 | jobs: |
@@ -40,80 +42,21 @@ jobs: |
40 | 42 | - name: Summarize licenses |
41 | 43 | run: node scripts/summarizeLicenses.mjs |
42 | 44 |
|
| 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 | +
|
43 | 54 | - name: Audit licenses with Claude |
44 | 55 | uses: anthropics/claude-code-action@v1 |
45 | 56 | with: |
46 | 57 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} |
47 | 58 | 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 }} |
117 | 60 |
|
118 | 61 | - name: Validate audit result |
119 | 62 | run: | |
@@ -148,86 +91,79 @@ jobs: |
148 | 91 | with: |
149 | 92 | script: | |
150 | 93 | const fs = require('fs'); |
151 | | - const path = 'license-audit-result.json'; |
| 94 | + const resultPath = 'license-audit-result.json'; |
152 | 95 |
|
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 = ''; |
178 | 99 |
|
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'; |
181 | 107 |
|
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 | + } |
190 | 111 |
|
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 | + } |
195 | 129 | } |
196 | | - } |
197 | 130 |
|
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 | + } |
203 | 137 | } |
204 | | - } |
205 | 138 |
|
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 | + } |
211 | 145 | } |
212 | | - } |
213 | 146 |
|
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 | + } |
219 | 153 | } |
220 | | - } |
221 | 154 |
|
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`; |
227 | 162 | } |
228 | | - body += `\n</details>\n`; |
229 | 163 | } |
230 | 164 |
|
| 165 | + if (!shouldComment) return; |
| 166 | +
|
231 | 167 | const comments = await github.rest.issues.listComments({ |
232 | 168 | owner: context.repo.owner, |
233 | 169 | repo: context.repo.repo, |
|
0 commit comments