Skip to content

Commit 646dfe7

Browse files
committed
ci: add a dependency-free license gate and AI security review
The license gate reads package-lock.json directly instead of installing license-checker, so the check itself adds nothing to the supply chain. The tree is dev-only today (nothing from node_modules reaches Pages), so this finds nothing now by design - it is here so it has teeth the moment a real dependency lands. Verified locally: 0 GPL/AGPL, LGPL passes intentionally. The AI security review job gates on CLAUDE_API_KEY being present and emits a notice instead of failing when it is unset, so forks and fresh clones from this template stay green. Action pinned by commit SHA, bumped via the existing Dependabot actions group.
1 parent ad0d57a commit 646dfe7

4 files changed

Lines changed: 86 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
ci:
1515
runs-on: ubuntu-latest
1616
steps:
17-
- uses: actions/checkout@v6
17+
- uses: actions/checkout@v7
1818
with:
1919
fetch-depth: 0
2020

@@ -51,10 +51,17 @@ jobs:
5151
- name: Audit high-severity vulnerabilities
5252
run: npm audit --audit-level=high
5353

54+
# Reads package-lock.json directly — no extra dependency to install, so
55+
# the license gate itself adds nothing to the supply chain. Currently the
56+
# tree is dev-only (nothing from node_modules ships to Pages), but the
57+
# gate is here so it has teeth the moment you add real dependencies.
58+
- name: Check licenses
59+
run: npm run license:check
60+
5461
# `eslint --cache` writes to .eslintcache at cwd; persist it across
5562
# runs so warm CI lints incrementally instead of rescanning the tree.
5663
- name: Restore ESLint cache
57-
uses: actions/cache@v5
64+
uses: actions/cache@v6
5865
with:
5966
path: .eslintcache
6067
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.js', 'package-lock.json') }}-${{ hashFiles('src/**/*.js', 'functions/**/*.js', 'tests/**/*.js') }}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: AI Security Review
2+
3+
on:
4+
pull_request:
5+
6+
permissions:
7+
pull-requests: write
8+
contents: read
9+
10+
jobs:
11+
ai-review:
12+
runs-on: ubuntu-latest
13+
steps:
14+
# Gate the job on the secret being set so forks / fresh clones don't
15+
# red-fail. The `secrets` context can't be used directly in `if:`, so we
16+
# route through a step output. When CLAUDE_API_KEY is unset, the job
17+
# emits a notice and skips the actual analysis steps.
18+
- name: Check CLAUDE_API_KEY presence
19+
id: gate
20+
env:
21+
API_KEY: ${{ secrets.CLAUDE_API_KEY }}
22+
run: |
23+
set -euo pipefail
24+
if [ -z "${API_KEY:-}" ]; then
25+
echo "::notice::CLAUDE_API_KEY secret is not set — skipping AI security review. Set the secret in repo settings to enable."
26+
echo "skip=true" >> "$GITHUB_OUTPUT"
27+
else
28+
echo "skip=false" >> "$GITHUB_OUTPUT"
29+
fi
30+
31+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
32+
if: steps.gate.outputs.skip == 'false'
33+
with:
34+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
35+
fetch-depth: 2
36+
37+
# Pinned to a specific commit SHA. Bumped via Dependabot (group: actions).
38+
- name: AI security review
39+
if: steps.gate.outputs.skip == 'false'
40+
uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
41+
with:
42+
comment-pr: true
43+
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"scripts": {
88
"dev": "wrangler pages dev src --port 3000",
99
"build": "node scripts/check-build-output.cjs",
10+
"license:check": "node scripts/check-licenses.mjs",
1011
"lint": "eslint --cache src/ functions/ tests/",
1112
"pretest": "node scripts/guard-tests-exist.cjs",
1213
"test": "node --test",

scripts/check-licenses.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { readFileSync } from "node:fs";
2+
3+
const lockfile = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
4+
const packages = lockfile.packages ?? {};
5+
const bannedLicense = /(^|[^A-Z])(?:AGPL|GPL)-(?:2\.0|3\.0)(?:-only|-or-later)?([^A-Z]|$)/;
6+
const counts = new Map();
7+
const blocked = [];
8+
9+
for (const [path, meta] of Object.entries(packages)) {
10+
if (!path || !meta || typeof meta !== "object") {
11+
continue;
12+
}
13+
const license = meta.license;
14+
if (typeof license !== "string" || license.trim() === "") {
15+
continue;
16+
}
17+
counts.set(license, (counts.get(license) ?? 0) + 1);
18+
if (bannedLicense.test(license)) {
19+
blocked.push({ path, license });
20+
}
21+
}
22+
23+
for (const [license, count] of [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) {
24+
console.log(`${license}: ${count}`);
25+
}
26+
27+
if (blocked.length > 0) {
28+
console.error("Blocked licenses found:");
29+
for (const item of blocked) {
30+
console.error(`- ${item.path}: ${item.license}`);
31+
}
32+
process.exit(1);
33+
}

0 commit comments

Comments
 (0)