Skip to content

Commit ddfe9e0

Browse files
hyperpolymathclaude
andcommitted
feat(governance): add licence-consistency CI check
Closes Item 1 of the 2026-05-26 estate tech-debt audit follow-up (standards#195/#196/#197). Adds `scripts/check-licence-consistency.sh` — a portable Bash check that verifies, for any caller repo: 1. A LICENSE / LICENCE / COPYING file exists at repo root. 2. The LICENSE file carries an SPDX-License-Identifier header. 3. If a build manifest (Cargo.toml, package.json, pyproject.toml, mix.exs, Project.toml, *.ipkg, *.cabal) declares a licence, it matches the SPDX header. 4. The LICENSE body text classification matches the SPDX header (full-file scan — accepts a Palimpsest preamble that wraps a full MPL-2.0 body, but rejects SPDX=MPL-2.0 with body=PMPL-only). Wires the check into `governance-reusable.yml` as a new `licence-consistency` job — every consumer of the governance bundle inherits the gate automatically. The audit (#196) flagged 4 HIGH-mismatch repos (developer-ecosystem, ephapax, paint-type, and `standards` itself) plus 13 PMPL-1.0-or-later holdouts. Item 2 of the follow-up will migrate those to MPL-2.0 body text. Once that lands, the CI check will keep the estate honest. Self-referential class: `standards` itself initially failed the body-vs- SPDX check, which is why this PR also tightens the body classifier to accept a Palimpsest preamble + canonical MPL-2.0 text body (the existing structure in `LICENSE`). The script still fails on SPDX-vs-manifest mismatches and on missing SPDX headers — those need Item 2 fixes per-repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 431adbb commit ddfe9e0

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,25 @@ jobs:
652652
echo "ERROR: Duplicate CodeQL workflows found"; exit 1
653653
fi
654654
echo "No critical duplicates found"
655+
656+
licence-consistency:
657+
name: Licence consistency
658+
runs-on: ${{ inputs.runs-on }}
659+
permissions:
660+
contents: read
661+
steps:
662+
- name: Checkout caller repository
663+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
664+
with:
665+
repository: ${{ github.repository }}
666+
ref: ${{ github.ref }}
667+
path: caller
668+
- name: Checkout standards (for the check script)
669+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
670+
with:
671+
repository: hyperpolymath/standards
672+
ref: main
673+
path: standards
674+
- name: Run licence-consistency check on caller repo
675+
run: |
676+
bash standards/scripts/check-licence-consistency.sh caller
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
4+
#
5+
# check-licence-consistency.sh
6+
#
7+
# Verifies that a repo's licensing story is internally consistent:
8+
# (1) A LICENSE / LICENCE / COPYING file is present at repo root.
9+
# (2) The LICENSE file carries an SPDX-License-Identifier header on its first
10+
# few lines.
11+
# (3) If a build manifest declares a licence, it matches the SPDX header.
12+
# (4) The LICENSE body text classification matches the SPDX header (loose
13+
# check — catches the common drift of SPDX=MPL-2.0 but body=PMPL).
14+
#
15+
# Exit codes:
16+
# 0 — all checks pass.
17+
# 1 — at least one check failed.
18+
# 2 — invocation / environment error.
19+
#
20+
# Wired into governance-reusable.yml as the `licence-consistency` job.
21+
#
22+
# Estate policy reference: MPL-1.0 / PMPL-1.0 → MPL-2.0 migration target.
23+
# Self-referential class: standards itself is one of 4 repos this check
24+
# initially flags as inconsistent (see docs/audits/2026-05-26-estate-licence-debt.md).
25+
26+
set -u
27+
28+
repo_root="${1:-.}"
29+
cd "$repo_root" || { echo "ERROR: cannot cd to $repo_root" >&2; exit 2; }
30+
31+
failed=0
32+
emit() {
33+
# 1=level (ERROR/WARN/OK), 2=message
34+
printf '[%s] %s\n' "$1" "$2"
35+
}
36+
37+
# ─────────────────────────────────────────────────────────────────────────────
38+
# (1) LICENSE file presence
39+
# ─────────────────────────────────────────────────────────────────────────────
40+
lic_file=""
41+
for cand in LICENSE LICENSE.md LICENSE.txt LICENCE LICENCE.md COPYING; do
42+
if [ -f "$cand" ]; then
43+
lic_file="$cand"
44+
break
45+
fi
46+
done
47+
48+
if [ -z "$lic_file" ]; then
49+
emit ERROR "No LICENSE / LICENCE / COPYING file at repo root."
50+
emit ERROR "Estate default is MPL-2.0 — see docs/audits/2026-05-26-estate-licence-debt.md."
51+
failed=1
52+
# Cannot proceed with remaining checks without a file.
53+
exit "$failed"
54+
fi
55+
emit OK "LICENSE file found: $lic_file"
56+
57+
# ─────────────────────────────────────────────────────────────────────────────
58+
# (2) SPDX header in LICENSE file
59+
# ─────────────────────────────────────────────────────────────────────────────
60+
spdx_header=$(grep -m1 -E '^[[:space:]]*SPDX-License-Identifier:' "$lic_file" 2>/dev/null \
61+
| sed -E 's/^[[:space:]]*SPDX-License-Identifier:[[:space:]]*//' \
62+
| head -c 80 | tr -d '[:space:]')
63+
64+
if [ -z "$spdx_header" ]; then
65+
emit ERROR "LICENSE file has no 'SPDX-License-Identifier:' header on its first few lines."
66+
emit ERROR "Add an SPDX header so downstream scanners (REUSE, cargo-license, etc.) can identify the licence."
67+
failed=1
68+
else
69+
emit OK "SPDX header: $spdx_header"
70+
fi
71+
72+
# ─────────────────────────────────────────────────────────────────────────────
73+
# (3) Manifest declared licence
74+
# ─────────────────────────────────────────────────────────────────────────────
75+
manifest_path=""
76+
manifest_decl=""
77+
78+
if [ -f "Cargo.toml" ]; then
79+
manifest_path="Cargo.toml"
80+
manifest_decl=$(grep -m1 -E '^license[[:space:]]*=' "$manifest_path" 2>/dev/null \
81+
| sed -E 's/^license[[:space:]]*=[[:space:]]*//' \
82+
| tr -d '"' | head -c 80 | sed -E 's/[[:space:]]+$//')
83+
elif [ -f "package.json" ]; then
84+
manifest_path="package.json"
85+
manifest_decl=$(grep -m1 -E '"license"[[:space:]]*:' "$manifest_path" 2>/dev/null \
86+
| sed -E 's/.*"license"[[:space:]]*:[[:space:]]*//' \
87+
| tr -d '",' | head -c 80 | sed -E 's/[[:space:]]+$//')
88+
elif [ -f "pyproject.toml" ]; then
89+
manifest_path="pyproject.toml"
90+
manifest_decl=$(grep -m1 -E '^license[[:space:]]*=' "$manifest_path" 2>/dev/null \
91+
| sed -E 's/^license[[:space:]]*=[[:space:]]*//' \
92+
| tr -d '"{}' | head -c 80 | sed -E 's/[[:space:]]+$//;s/text[[:space:]]*=[[:space:]]*//')
93+
elif [ -f "mix.exs" ]; then
94+
manifest_path="mix.exs"
95+
# mix.exs uses `licenses: ["MPL-2.0"]`
96+
manifest_decl=$(grep -m1 -E 'licenses[[:space:]]*:' "$manifest_path" 2>/dev/null \
97+
| sed -E 's/.*licenses[[:space:]]*:[[:space:]]*\[//' \
98+
| sed -E 's/\].*//' | tr -d '" ' | head -c 80)
99+
elif [ -f "Project.toml" ]; then
100+
manifest_path="Project.toml"
101+
manifest_decl=$(grep -m1 -iE '^license[[:space:]]*=' "$manifest_path" 2>/dev/null \
102+
| sed -E 's/^[Ll]icense[[:space:]]*=[[:space:]]*//' \
103+
| tr -d '"' | head -c 80 | sed -E 's/[[:space:]]+$//')
104+
elif ls ./*.ipkg >/dev/null 2>&1; then
105+
manifest_path="$(ls -1 ./*.ipkg | head -1)"
106+
# ipkg files carry SPDX header in a comment line
107+
manifest_decl=$(grep -hm1 -iE '^[[:space:]]*--[[:space:]]*SPDX-License-Identifier' "$manifest_path" 2>/dev/null \
108+
| sed -E 's/.*SPDX-License-Identifier[[:space:]]*:[[:space:]]*//' \
109+
| head -c 80 | sed -E 's/[[:space:]]+$//')
110+
elif ls ./*.cabal >/dev/null 2>&1; then
111+
manifest_path="$(ls -1 ./*.cabal | head -1)"
112+
manifest_decl=$(grep -hm1 -iE '^license[[:space:]]*:' "$manifest_path" 2>/dev/null \
113+
| sed -E 's/^[lL]icense[[:space:]]*:[[:space:]]*//' \
114+
| head -c 80 | sed -E 's/[[:space:]]+$//')
115+
fi
116+
117+
if [ -n "$manifest_path" ] && [ -z "$manifest_decl" ]; then
118+
emit WARN "Manifest $manifest_path present but no licence field detected."
119+
elif [ -n "$manifest_decl" ]; then
120+
emit OK "Manifest licence ($manifest_path): $manifest_decl"
121+
fi
122+
123+
# Normalize-compare SPDX header vs manifest
124+
normalize() {
125+
echo "$1" | tr '[:upper:]' '[:lower:]' \
126+
| sed -E 's/-or-later$//;s/^[[:space:]]+|[[:space:]]+$//g'
127+
}
128+
129+
if [ -n "$spdx_header" ] && [ -n "$manifest_decl" ]; then
130+
sh_norm=$(normalize "$spdx_header")
131+
mh_norm=$(normalize "$manifest_decl")
132+
# mh may contain `MIT OR Apache-2.0` — accept if sh is one of them
133+
if echo "$mh_norm" | grep -qE "(^|\W)$sh_norm(\W|$)"; then
134+
emit OK "SPDX header matches manifest declaration."
135+
else
136+
emit ERROR "SPDX-vs-manifest mismatch: header='$spdx_header' manifest='$manifest_decl' ($manifest_path)."
137+
failed=1
138+
fi
139+
fi
140+
141+
# ─────────────────────────────────────────────────────────────────────────────
142+
# (4) Body text classification vs SPDX header
143+
# ─────────────────────────────────────────────────────────────────────────────
144+
if [ -n "$spdx_header" ]; then
145+
# Use a helper because `grep -c` exits non-zero on zero-matches AND prints
146+
# "0" to stdout, so `|| echo 0` concatenates to "0\n0". Pipe through wc -l
147+
# which always returns a single integer.
148+
count_in() { grep -E "$1" "$lic_file" 2>/dev/null | wc -l; }
149+
count_in_i() { grep -iE "$1" "$lic_file" 2>/dev/null | wc -l; }
150+
has_mpl2_text=$(count_in 'Mozilla Public License Version 2\.0|Mozilla Public License, version 2\.0')
151+
has_pmpl_text=$(count_in_i 'PMPL-1\.0-or-later|Palimpsest License \(PMPL')
152+
has_apache=$(count_in 'Apache License.*Version 2\.0')
153+
has_mit=$(count_in 'MIT License')
154+
has_gpl3=$(count_in 'GNU GENERAL PUBLIC LICENSE.*Version 3')
155+
has_bsd3=$(count_in 'BSD.*3-Clause')
156+
has_proprietary=$(count_in_i 'All Rights Reserved')
157+
158+
body_class="UNKNOWN"
159+
# Order matters: the legally-binding text dominates classification.
160+
if [ "$has_proprietary" -gt 0 ] && [ "$has_mpl2_text" -eq 0 ]; then
161+
body_class="PROPRIETARY"
162+
elif [ "$has_mpl2_text" -gt 0 ]; then
163+
# MPL-2.0 text is present in the body — this is binding even when wrapped
164+
# in a Palimpsest preamble.
165+
body_class="MPL-2.0"
166+
elif [ "$has_pmpl_text" -gt 0 ]; then
167+
body_class="PMPL-1.0"
168+
elif [ "$has_apache" -gt 0 ]; then
169+
body_class="Apache-2.0"
170+
elif [ "$has_mit" -gt 0 ]; then
171+
body_class="MIT"
172+
elif [ "$has_gpl3" -gt 0 ]; then
173+
body_class="GPL-3.0"
174+
elif [ "$has_bsd3" -gt 0 ]; then
175+
body_class="BSD-3-Clause"
176+
fi
177+
178+
# Compare. SPDX=MPL-2.0 with body=PMPL is the known estate-wide drift the
179+
# 2026-05-26 audit surfaced — fail-loud here so future drift is caught.
180+
spdx_norm=$(normalize "$spdx_header")
181+
body_norm=$(echo "$body_class" | tr '[:upper:]' '[:lower:]')
182+
183+
if [ "$body_class" = "UNKNOWN" ]; then
184+
emit WARN "LICENSE body did not match any known licence template. Manual review recommended."
185+
elif [ "$body_class" = "PROPRIETARY" ] && [ "$spdx_norm" != "licenseref-proprietary" ]; then
186+
emit ERROR "LICENSE body says 'All Rights Reserved' (proprietary) but SPDX header says '$spdx_header'."
187+
emit ERROR "Either flip LICENSE body to the SPDX-declared licence text, or set the SPDX header to LicenseRef-Proprietary."
188+
failed=1
189+
elif [ "$spdx_norm" = "mpl-2.0" ] && [ "$body_class" = "PMPL-1.0" ]; then
190+
emit ERROR "SPDX header says MPL-2.0 but LICENSE body text is still PMPL-1.0-or-later."
191+
emit ERROR "Migrate body to canonical MPL-2.0 text (see hyperpolymath/standards docs/audits/2026-05-26-estate-licence-debt.md)."
192+
failed=1
193+
elif echo "$body_norm" | grep -q "^${spdx_norm}\(-some\)\?$" \
194+
|| [ "$spdx_norm-some" = "$body_norm" ]; then
195+
emit OK "LICENSE body text matches SPDX header."
196+
elif [ "$body_norm" = "$spdx_norm" ]; then
197+
emit OK "LICENSE body text matches SPDX header."
198+
else
199+
emit WARN "LICENSE body classification ($body_class) doesn't obviously match SPDX header ($spdx_header). Spot-check."
200+
fi
201+
fi
202+
203+
# ─────────────────────────────────────────────────────────────────────────────
204+
# Summary
205+
# ─────────────────────────────────────────────────────────────────────────────
206+
if [ "$failed" -eq 0 ]; then
207+
emit OK "Licence consistency check passed."
208+
exit 0
209+
else
210+
emit ERROR "Licence consistency check failed. See messages above."
211+
exit 1
212+
fi

0 commit comments

Comments
 (0)