|
| 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