Skip to content

Commit ba973c4

Browse files
committed
fix(governance): tolerate verbatim header-less LICENSE in consistency check
check-licence-consistency.sh required an SPDX-License-Identifier header inside the LICENSE file and failed without one. But the estate template ships LICENSE as plain, unmodified MPL-2.0 text with no header (SPDX identifiers belong in source files, not in the canonical upstream licence text), so the `licence-consistency` governance job went red on every PR in repos using the template — hypatia, hermeneia and gitbot-fleet all carry a header-less verbatim MPL-2.0 LICENSE. Establish the licence identity from EITHER the SPDX header (when present) OR the body-text classification (for a verbatim, header-less file). The manifest cross-check now runs against that identity, so manifest mismatch is still caught with or without a header. When a header IS present the body-vs-header drift checks are unchanged (SPDX=MPL-2.0 + body=PMPL still fails). Only a header-less file whose body matches no known template is now reported as an error. This is a script-logic change only — no LICENSE text or SPDX header is edited (owner licence guardrail honoured). Verified against hypatia/standards/hermeneia/gitbot-fleet (all pass) plus five negative fixtures (PMPL-under-MPL-header drift, unidentifiable body, manifest mismatch with and without header, missing LICENSE) — all still fail loudly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wGTeLwiBGJ5rETC3QT4Pm
1 parent 5b631ff commit ba973c4

1 file changed

Lines changed: 81 additions & 66 deletions

File tree

scripts/check-licence-consistency.sh

Lines changed: 81 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@
66
#
77
# Verifies that a repo's licensing story is internally consistent:
88
# (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
9+
# (2) The LICENSE body text classifies to a known licence template.
10+
# (3) The licence identity is established by EITHER an SPDX-License-Identifier
11+
# header on the LICENSE file's first few lines, OR — for a verbatim,
12+
# header-less licence file — the body-text classification itself. The
13+
# estate template ships LICENSE as plain, unmodified MPL-2.0 text with no
14+
# SPDX header (SPDX identifiers belong in *source* files, not in the
15+
# canonical upstream licence text), so a header-less verbatim MPL-2.0
16+
# LICENSE is consistent — not a finding.
17+
# (4) If a build manifest declares a licence, it matches that identity.
18+
# (5) When an SPDX header IS present, the body text must agree with it (loose
1319
# check — catches the common drift of SPDX=MPL-2.0 but body=PMPL).
1420
#
1521
# Exit codes:
@@ -20,8 +26,6 @@
2026
# Wired into governance-reusable.yml as the `licence-consistency` job.
2127
#
2228
# 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).
2529

2630
set -u
2731

@@ -48,29 +52,82 @@ done
4852
if [ -z "$lic_file" ]; then
4953
emit ERROR "No LICENSE / LICENCE / COPYING file at repo root."
5054
emit ERROR "Estate default is MPL-2.0 — see docs/audits/2026-05-26-estate-licence-debt.md."
51-
failed=1
5255
# Cannot proceed with remaining checks without a file.
53-
exit "$failed"
56+
exit 1
5457
fi
5558
emit OK "LICENSE file found: $lic_file"
5659

5760
# ─────────────────────────────────────────────────────────────────────────────
58-
# (2) SPDX header in LICENSE file
61+
# (2) Classify LICENSE body text
62+
# Computed first because a verbatim, header-less licence file establishes
63+
# its identity from the body alone (see (3)).
64+
# ─────────────────────────────────────────────────────────────────────────────
65+
# Use a helper because `grep -c` exits non-zero on zero-matches AND prints "0"
66+
# to stdout, so `|| echo 0` concatenates to "0\n0". Pipe through wc -l which
67+
# always returns a single integer.
68+
count_in() { grep -E "$1" "$lic_file" 2>/dev/null | wc -l; }
69+
count_in_i() { grep -iE "$1" "$lic_file" 2>/dev/null | wc -l; }
70+
has_mpl2_text=$(count_in 'Mozilla Public License Version 2\.0|Mozilla Public License, version 2\.0')
71+
has_pmpl_text=$(count_in_i 'PMPL-1\.0-or-later|Palimpsest License \(PMPL')
72+
has_apache=$(count_in 'Apache License.*Version 2\.0')
73+
has_mit=$(count_in 'MIT License')
74+
has_gpl3=$(count_in 'GNU GENERAL PUBLIC LICENSE.*Version 3')
75+
has_bsd3=$(count_in 'BSD.*3-Clause')
76+
has_proprietary=$(count_in_i 'All Rights Reserved')
77+
78+
body_class="UNKNOWN"
79+
# Order matters: the legally-binding text dominates classification.
80+
if [ "$has_proprietary" -gt 0 ] && [ "$has_mpl2_text" -eq 0 ]; then
81+
body_class="PROPRIETARY"
82+
elif [ "$has_mpl2_text" -gt 0 ]; then
83+
# MPL-2.0 text is present in the body — this is binding even when wrapped
84+
# in a Palimpsest preamble.
85+
body_class="MPL-2.0"
86+
elif [ "$has_pmpl_text" -gt 0 ]; then
87+
body_class="PMPL-1.0"
88+
elif [ "$has_apache" -gt 0 ]; then
89+
body_class="Apache-2.0"
90+
elif [ "$has_mit" -gt 0 ]; then
91+
body_class="MIT"
92+
elif [ "$has_gpl3" -gt 0 ]; then
93+
body_class="GPL-3.0"
94+
elif [ "$has_bsd3" -gt 0 ]; then
95+
body_class="BSD-3-Clause"
96+
fi
97+
98+
# Normalize for loose, case-insensitive licence comparison.
99+
normalize() {
100+
echo "$1" | tr '[:upper:]' '[:lower:]' \
101+
| sed -E 's/-or-later$//;s/^[[:space:]]+|[[:space:]]+$//g'
102+
}
103+
104+
# ─────────────────────────────────────────────────────────────────────────────
105+
# (3) Establish licence identity: SPDX header if present, else verbatim body.
106+
# `effective_lic` is the canonical identity used by the manifest check (4).
59107
# ─────────────────────────────────────────────────────────────────────────────
60108
spdx_header=$(grep -m1 -E '^[[:space:]]*SPDX-License-Identifier:' "$lic_file" 2>/dev/null \
61109
| sed -E 's/^[[:space:]]*SPDX-License-Identifier:[[:space:]]*//' \
62110
| head -c 80 | tr -d '[:space:]')
63111

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
112+
effective_lic=""
113+
if [ -n "$spdx_header" ]; then
69114
emit OK "SPDX header: $spdx_header"
115+
effective_lic="$spdx_header"
116+
elif [ "$body_class" != "UNKNOWN" ]; then
117+
# No SPDX header, but the body is a recognised verbatim licence. This is the
118+
# estate template's canonical shape (plain MPL-2.0 text, no header) and is
119+
# internally consistent — accept it and use the body classification as the
120+
# licence identity for the manifest cross-check below.
121+
emit OK "LICENSE has no SPDX header, but its body is verbatim $body_class text — accepted as a canonical licence file."
122+
effective_lic="$body_class"
123+
else
124+
emit ERROR "LICENSE file has no 'SPDX-License-Identifier:' header and its body matches no known licence template."
125+
emit ERROR "Add an SPDX header, or use a recognised verbatim licence text, so downstream scanners (REUSE, cargo-license, etc.) can identify the licence."
126+
failed=1
70127
fi
71128

72129
# ─────────────────────────────────────────────────────────────────────────────
73-
# (3) Manifest declared licence
130+
# (4) Manifest declared licence vs the established identity
74131
# ─────────────────────────────────────────────────────────────────────────────
75132
manifest_path=""
76133
manifest_decl=""
@@ -120,63 +177,24 @@ elif [ -n "$manifest_decl" ]; then
120177
emit OK "Manifest licence ($manifest_path): $manifest_decl"
121178
fi
122179

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")
180+
if [ -n "$effective_lic" ] && [ -n "$manifest_decl" ]; then
181+
el_norm=$(normalize "$effective_lic")
131182
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."
183+
# mh may contain `MIT OR Apache-2.0` — accept if the licence identity is one of them.
184+
if echo "$mh_norm" | grep -qE "(^|\W)$el_norm(\W|$)"; then
185+
emit OK "Licence identity matches manifest declaration."
135186
else
136-
emit ERROR "SPDX-vs-manifest mismatch: header='$spdx_header' manifest='$manifest_decl' ($manifest_path)."
187+
emit ERROR "Licence-vs-manifest mismatch: licence='$effective_lic' manifest='$manifest_decl' ($manifest_path)."
137188
failed=1
138189
fi
139190
fi
140191

141192
# ─────────────────────────────────────────────────────────────────────────────
142-
# (4) Body text classification vs SPDX header
193+
# (5) When an SPDX header is present, the body text must agree with it.
194+
# Header-less files already derived their identity from the body in (3),
195+
# so there is nothing to cross-check here for them.
143196
# ─────────────────────────────────────────────────────────────────────────────
144197
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.
180198
spdx_norm=$(normalize "$spdx_header")
181199
body_norm=$(echo "$body_class" | tr '[:upper:]' '[:lower:]')
182200

@@ -190,9 +208,6 @@ if [ -n "$spdx_header" ]; then
190208
emit ERROR "SPDX header says MPL-2.0 but LICENSE body text is still PMPL-1.0-or-later."
191209
emit ERROR "Migrate body to canonical MPL-2.0 text (see hyperpolymath/standards docs/audits/2026-05-26-estate-licence-debt.md)."
192210
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."
196211
elif [ "$body_norm" = "$spdx_norm" ]; then
197212
emit OK "LICENSE body text matches SPDX header."
198213
else

0 commit comments

Comments
 (0)