From 4326cf025b5775aad89a63fcbc46cab7e9e6d598 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 27 Jun 2026 12:45:33 -0400 Subject: [PATCH 1/2] fix(license): finish the AGPLv3 relicense on two stale surfaces The v2.6.0 relicense (ADR-0009) left two more declarations on the old MIT terms, beyond the ca manifest + README callout fixed in #151: - plugins/ca-sandbox/.claude-plugin/plugin.json declared "license": "MIT" -- the sibling plugin's machine-readable field. Now AGPL-3.0-only (the repo LICENSE is AGPLv3 and ADR-0009 carves out no exception for ca-sandbox). 0.1.1 is untagged, so no version bump is required. - .codearbiter/coding-standards.md still stated "License is MIT; copyright holder codeArbiter contributors". Corrected to AGPL-3.0-only / SUaDtL per ADR-0009. CHANGELOG: ca-sandbox's plugin manifest now declares AGPL-3.0-only (was a stale MIT). Claude-Session: https://claude.ai/code/session_015tNUy37dx7DJGDzmVUB8Wf --- .codearbiter/coding-standards.md | 4 ++-- plugins/ca-sandbox/.claude-plugin/plugin.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.codearbiter/coding-standards.md b/.codearbiter/coding-standards.md index 73d089e2..88699a9d 100644 --- a/.codearbiter/coding-standards.md +++ b/.codearbiter/coding-standards.md @@ -52,8 +52,8 @@ plugin's own authoring gates (`skill-author` v2 house style), not by this file. ## File headers and copyright -- License is MIT; the copyright holder of record is **"codeArbiter contributors"** - (`LICENSE`). +- License is **AGPL-3.0-only**; the copyright holder of record is **SUaDtL** + (`LICENSE`, ADR-0009 — relicensed from MIT at v2.6.0). - **No per-file license or SPDX headers.** The single root `LICENSE` governs; new files carry no copyright header. The `# codeArbiter — …` / `/** … */` purpose comment is documentation, not a license header. diff --git a/plugins/ca-sandbox/.claude-plugin/plugin.json b/plugins/ca-sandbox/.claude-plugin/plugin.json index 729c829d..e501fa3d 100644 --- a/plugins/ca-sandbox/.claude-plugin/plugin.json +++ b/plugins/ca-sandbox/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "description": "Locally-hosted Codespace equivalent for codeArbiter. Pulls an untrusted repo into an ephemeral, isolated Docker container with no host-filesystem access and configurable egress, caches dependencies by content hash, then tears the box down. Requires Docker and nixpacks on PATH.", "version": "0.1.1", "author": { "name": "arbiterForge" }, - "license": "MIT", + "license": "AGPL-3.0-only", "homepage": "https://github.com/arbiterForge/codeArbiter", "repository": "https://github.com/arbiterForge/codeArbiter", "keywords": ["sandbox", "docker", "isolation", "ephemeral", "untrusted-code", "nixpacks", "codespace"] From 762376ce21fbe32fd03b7ca94e4f72fa04446901 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 27 Jun 2026 12:49:12 -0400 Subject: [PATCH 2/2] ci: add a license-consistency check across declaration surfaces A relicense must update several surfaces (both plugin manifests, LICENSE, the README badge + notice) and human review keeps missing some -- the v2.6.0 AGPLv3 relicense left three stale (the ca manifest + README callout fixed in #151, the ca-sandbox manifest fixed in the preceding commit). This adds a mechanical CI guard so a partial relicense fails the build instead of shipping. check_license_consistency.py is stdlib-only with pure functions that degrade to a finding rather than raise. It reads ONLY the enumerated surfaces -- never a repo-wide grep -- so third-party (package-lock) and historical (CHANGELOG/ADR) license mentions can't false-positive. ca's plugin.json `license` is the canonical SPDX; a LICENSE_FAMILIES table maps it to each surface's identifying markers, so the check is license-agnostic (a future relicense adds an entry, not a rewrite). A fixed forbidden-phrase set guards retired commercial-offering prose. Wired as a repo-wide, always-on ci.yml job in the required-checks aggregation. security-reviewer PASS (no untrusted-input / secret / privilege concerns). Spec: .codearbiter/specs/license-consistency-check.md. 23 unit tests cover AC-1..10. Claude-Session: https://claude.ai/code/session_015tNUy37dx7DJGDzmVUB8Wf --- .../specs/license-consistency-check.md | 89 ++++++++ .github/scripts/check_license_consistency.py | 215 ++++++++++++++++++ .github/scripts/test_license_consistency.py | 177 ++++++++++++++ .github/workflows/ci.yml | 19 ++ 4 files changed, 500 insertions(+) create mode 100644 .codearbiter/specs/license-consistency-check.md create mode 100644 .github/scripts/check_license_consistency.py create mode 100644 .github/scripts/test_license_consistency.py diff --git a/.codearbiter/specs/license-consistency-check.md b/.codearbiter/specs/license-consistency-check.md new file mode 100644 index 00000000..9a2d9301 --- /dev/null +++ b/.codearbiter/specs/license-consistency-check.md @@ -0,0 +1,89 @@ +# Spec — license-consistency CI check + +## Problem + +A license change (e.g. the v2.6.0 MIT → AGPLv3 relicense, ADR-0009) must update several +independent surfaces, and human review keeps missing some. This session alone found three drift +instances left behind after the relicense: `plugins/ca/.claude-plugin/plugin.json` still declared +`MIT` (fixed in #151), the top-of-README license notice still used the pre-#149 "available +separately" offering wording (fixed in #151), and `plugins/ca-sandbox/.claude-plugin/plugin.json` +still declares `MIT` (open). There is no mechanical guard, so a partial relicense ships silently. + +## Caller + +The maintainer / release process. "Done" = a dedicated CI check that goes **red** when any project +license-declaration surface disagrees with the canonical license, so a future license change cannot +land on only some surfaces. + +## Scope + +**In scope — the project's own license-declaration surfaces:** +- `plugins/ca/.claude-plugin/plugin.json` `license` field — the **canonical source of truth** (SPDX). +- `plugins/ca-sandbox/.claude-plugin/plugin.json` `license` field — must equal the canonical. +- `LICENSE` file — its text family must match the canonical SPDX. +- `README.md` version-line license badge. +- `README.md` license-notice callout prose. +- A one-field data fix included in this work: set `ca-sandbox`'s manifest `license` to + `AGPL-3.0-only` (decided 2026-06-27; the relicense oversight, same class as ca's). ca-sandbox + 0.1.1 is untagged, so this needs no version bump. + +**Out of scope:** +- Third-party / transitive dependency licenses (`package-lock.json`, `node_modules`) — never read. +- Historical references to a prior license (CHANGELOG entries, ADR-0009 narrative) — never read; the + check reads only the enumerated surface files, never a repo-wide grep. +- `marketplace.json` (carries no `license` field today). +- The CLA's legal substance, and the *choice* of license itself — that stays a human/ADR decision. +- Asserting the full LICENSE text verbatim — only the family-identifying header is checked. + +## Design + +Canonical SPDX is read from `plugins/ca/.claude-plugin/plugin.json`. A small `LICENSE_FAMILIES` +table maps an SPDX id → the markers that identify that license on each surface (LICENSE-file header +substrings, README badge marker, README prose marker). The check is otherwise license-agnostic: a +future relicense changes ca's manifest + adds/uses the family entry, and the check then forces every +other surface to match. Retired commercial-**offering** phrases are a fixed forbidden list (the prose +guard), independent of which license is current. + +Implementation mirrors `_releaselib` / `check_badge_consistency.py`: stdlib-only Python, pure +functions over file contents that **never raise on malformed input** (degrade to a reported finding), +plus a CLI entrypoint, with a unit test file. Wired into `.github/workflows/ci.yml` as a dedicated +job in the required-checks aggregation. + +For `AGPL-3.0-only` the family markers are: LICENSE header contains `GNU AFFERO GENERAL PUBLIC LICENSE` +and `Version 3`; README badge contains `license-AGPL_v3`; README notice contains `AGPLv3`. + +## Acceptance criteria + +1. **Manifest agreement.** Given the two plugin manifests' `license` values and the canonical SPDX, + the check passes iff both equal the canonical; a manifest whose `license` differs (e.g. `MIT`) + fails with a finding naming the file and both values. (pure fn over inputs) +2. **No stale prior-license string.** A manifest `license` equal to a known prior license (`MIT`) + fails specifically as a stale-relicense finding, even if both manifests agreed on it — the + canonical is AGPL-family, so MIT can never pass. (pure fn) +3. **LICENSE-file family match.** Given the LICENSE file text and the canonical SPDX, the check + passes iff the text contains all family-identifying markers for that SPDX (AGPL-3.0-only → + `GNU AFFERO GENERAL PUBLIC LICENSE` + `Version 3`); a LICENSE file missing a marker fails. (pure fn) +4. **README badge match.** Given the README text and canonical SPDX, passes iff the family badge + marker (`license-AGPL_v3`) is present; absent/mismatched badge fails. (pure fn) +5. **README notice names the canonical license.** Given the README text and canonical SPDX, passes + iff the family prose marker (`AGPLv3`) is present in the notice; a notice naming only a prior + license fails. (pure fn) +6. **Prose offering-guard.** Given the README text, fails if it contains any retired offering phrase + in the fixed forbidden set (`available separately`, `offers the same code`); the current + post-#151 README (which uses `separate proprietary terms` / `not offered at this time`) passes. + A finding names the offending phrase. (pure fn) +7. **Unknown canonical SPDX is a clear failure, not a crash.** If ca's manifest declares an SPDX with + no `LICENSE_FAMILIES` entry, the check fails with a "no family mapping" finding (degrade, never + raise) — so adding a relicense without a family entry is caught, not silently passed. (pure fn) +8. **Never raises on malformed input.** Missing file, unparseable JSON, or non-string input yields a + reported finding (and non-zero CLI exit), never an exception. (pure fn) +9. **Live repo passes after the ca-sandbox fix.** Run against the real repo with ca-sandbox set to + `AGPL-3.0-only`, the check exits 0; with ca-sandbox left `MIT`, it exits non-zero naming that file. + (integration over real files) +10. **CI wiring.** `.github/workflows/ci.yml` defines a license-consistency job that runs the check + and is included in the required-checks aggregation, so a red check blocks merge. (structural) + +## Open questions + +None blocking. Both design decisions resolved with the user 2026-06-27: (a) ca-sandbox → AGPL-3.0-only +(fix it; check enforces both manifests); (b) identity + narrow prose guard (AC-6). diff --git a/.github/scripts/check_license_consistency.py b/.github/scripts/check_license_consistency.py new file mode 100644 index 00000000..e3bfaa7c --- /dev/null +++ b/.github/scripts/check_license_consistency.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# codeArbiter — license-consistency CI check. +# +# Guards against a partial relicense: asserts the project's own license- +# declaration surfaces all agree with the canonical SPDX (ca's plugin.json +# `license`). Reads ONLY the enumerated surfaces below — never a repo-wide grep +# — so third-party (package-lock) and historical (CHANGELOG / ADR) license +# mentions can't false-positive. Motivated by the v2.6.0 relicense, which left +# the ca manifest, the README callout, and the ca-sandbox manifest on stale +# declarations that human review kept missing. +# +# Design invariants (mirror the other .github/scripts checks and _*lib.py): +# - Stdlib only; zero side effects at import. +# - Pure functions over synthetic input; filesystem access isolated to the +# named readers (read_manifest_license / read_text). +# - Never raise on malformed input — degrade to a surfaced finding, since a +# CI check that crashes is worse than one that reports a problem. +# +# Public API: +# resolve_family(spdx) -> dict | None +# check_manifest_agreement(canonical_spdx, manifest_licenses) -> list[str] +# check_license_file(family, license_text) -> list[str] +# check_readme_badge(family, readme_text) -> list[str] +# check_readme_notice(family, readme_text) -> list[str] +# check_offering_prose(readme_text) -> list[str] +# read_manifest_license(path) -> str | None +# read_text(path) -> str | None +# evaluate(canonical_spdx, manifest_licenses, license_text, readme_text) -> list[str] +# run_all(repo_root) -> list[str] +# main(argv) -> int + +import json +import os +import sys + +# The canonical license lives in ca's manifest; every other surface must agree. +CANONICAL_MANIFEST = "plugins/ca/.claude-plugin/plugin.json" +MANIFESTS = [ + "plugins/ca/.claude-plugin/plugin.json", + "plugins/ca-sandbox/.claude-plugin/plugin.json", +] +LICENSE_FILE = "LICENSE" +README_FILE = "README.md" + +# Prior licenses the project has moved off of. A manifest still declaring one is +# a stale (incomplete) relicense, called out distinctly from a generic mismatch +# so the failure reads as "this surface wasn't updated", not just "wrong value". +KNOWN_PRIOR_LICENSES = {"MIT"} + +# Retired commercial-OFFERING phrasing. License-agnostic prose guard: the owner +# reserves the right to dual-license but does not offer it, so phrasing that +# implies an active commercial offering must not reappear in the README. +FORBIDDEN_OFFERING_PHRASES = ["available separately", "offers the same code"] + +# Per-SPDX identifying markers on each surface. A future relicense adds an entry +# here (and changes ca's manifest); the check then forces every other surface to +# match — so the check is license-agnostic, not pinned to AGPL. +LICENSE_FAMILIES = { + "AGPL-3.0-only": { + "license_file_markers": ["GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3"], + "badge_marker": "license-AGPL_v3", + "prose_marker": "AGPLv3", + }, +} + + +def resolve_family(spdx): + """Return the family token map for `spdx`, or None if there is no entry.""" + if not isinstance(spdx, str): + return None + return LICENSE_FAMILIES.get(spdx) + + +def check_manifest_agreement(canonical_spdx, manifest_licenses): + """Findings for any manifest whose `license` != `canonical_spdx`. A value in + KNOWN_PRIOR_LICENSES is flagged as a stale relicense; any other mismatch as a + generic disagreement. `manifest_licenses` maps path -> license string.""" + findings = [] + for path, val in sorted(manifest_licenses.items()): + if val == canonical_spdx: + continue + if val in KNOWN_PRIOR_LICENSES: + findings.append( + f"{path}: declares prior license '{val}'; expected canonical " + f"'{canonical_spdx}' (relicense incomplete)") + else: + findings.append( + f"{path}: license '{val}' does not match canonical '{canonical_spdx}'") + return findings + + +def check_license_file(family, license_text): + """Findings if the LICENSE text is missing any family-identifying marker.""" + if not isinstance(family, dict) or not isinstance(license_text, str): + return [f"{LICENSE_FILE}: unreadable, or no license family to match against"] + return [f"{LICENSE_FILE}: missing expected marker '{marker}'" + for marker in family.get("license_file_markers", []) if marker not in license_text] + + +def check_readme_badge(family, readme_text): + """Findings if the README lacks the family's license badge marker.""" + if not isinstance(family, dict) or not isinstance(readme_text, str): + return [f"{README_FILE}: unreadable, or no license family to match against"] + marker = family.get("badge_marker", "") + if marker and marker not in readme_text: + return [f"{README_FILE}: license badge '{marker}' not found"] + return [] + + +def check_readme_notice(family, readme_text): + """Findings if the README never names the canonical license (family prose marker).""" + if not isinstance(family, dict) or not isinstance(readme_text, str): + return [f"{README_FILE}: unreadable, or no license family to match against"] + marker = family.get("prose_marker", "") + if marker and marker not in readme_text: + return [f"{README_FILE}: license notice does not name '{marker}'"] + return [] + + +def check_offering_prose(readme_text): + """Findings for any retired commercial-offering phrase present in the README.""" + if not isinstance(readme_text, str): + return [] + low = readme_text.lower() + return [f"{README_FILE}: retired offering phrase '{p}' present" + for p in FORBIDDEN_OFFERING_PHRASES if p in low] + + +def read_manifest_license(path): + """The `license` field of a JSON manifest, or None if missing / unparseable / absent.""" + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None + val = data.get("license") if isinstance(data, dict) else None + return val if isinstance(val, str) else None + + +def read_text(path): + """File text, or None if missing / unreadable.""" + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError: + return None + + +def evaluate(canonical_spdx, manifest_licenses, license_text, readme_text): + """Aggregate every check over already-read inputs. An unknown canonical SPDX + (no LICENSE_FAMILIES entry) is itself a finding, and the family-dependent + surface checks are skipped (there is nothing to match against).""" + findings = list(check_manifest_agreement(canonical_spdx, manifest_licenses)) + family = resolve_family(canonical_spdx) + if family is None: + findings.append( + f"{CANONICAL_MANIFEST}: canonical license '{canonical_spdx}' has no license " + f"family mapping (no LICENSE_FAMILIES entry) — add one so the surfaces can be checked") + else: + findings += check_license_file(family, license_text) + findings += check_readme_badge(family, readme_text) + findings += check_readme_notice(family, readme_text) + findings += check_offering_prose(readme_text) + return findings + + +def run_all(repo_root): + """Read every surface under `repo_root` and return all findings (empty = consistent). + A missing required surface is a finding, never an exception.""" + canonical = read_manifest_license(os.path.join(repo_root, CANONICAL_MANIFEST)) + if canonical is None: + return [f"{CANONICAL_MANIFEST}: missing or no readable `license` field " + f"(cannot determine the canonical license)"] + + findings = [] + manifest_licenses = {} + for rel in MANIFESTS: + val = read_manifest_license(os.path.join(repo_root, rel)) + if val is None: + findings.append(f"{rel}: missing or no readable `license` field") + else: + manifest_licenses[rel] = val + + license_text = read_text(os.path.join(repo_root, LICENSE_FILE)) + if license_text is None: + findings.append(f"{LICENSE_FILE}: missing or unreadable") + readme_text = read_text(os.path.join(repo_root, README_FILE)) + if readme_text is None: + findings.append(f"{README_FILE}: missing or unreadable") + + findings += evaluate(canonical, manifest_licenses, license_text or "", readme_text or "") + + seen, deduped = set(), [] # a missing-file finding can echo a downstream check finding + for f in findings: + if f not in seen: + seen.add(f) + deduped.append(f) + return deduped + + +def main(argv): + """CLI: `check_license_consistency.py [repo_root]`. Prints findings; exit 1 if any.""" + repo_root = argv[0] if argv else "." + findings = run_all(repo_root) + if findings: + sys.stderr.write("license-consistency check FAILED:\n") + for f in findings: + sys.stderr.write(f" - {f}\n") + return 1 + print("license declarations consistent across all surfaces") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/test_license_consistency.py b/.github/scripts/test_license_consistency.py new file mode 100644 index 00000000..cb5c88bb --- /dev/null +++ b/.github/scripts/test_license_consistency.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""codeArbiter — unit tests for the license-consistency CI check. + +Spec: .codearbiter/specs/license-consistency-check.md. Each test class maps to +one or more acceptance criteria: + + ManifestAgreementTest AC-1, AC-2 — manifest licenses match the canonical; stale prior flagged + LicenseFileTest AC-3 — LICENSE text matches the canonical family + ReadmeBadgeTest AC-4 — README license badge matches the family + ReadmeNoticeTest AC-5 — README notice names the canonical license + OfferingProseTest AC-6 — retired commercial-offering phrases are forbidden + ResolveFamilyTest AC-7 — unknown canonical SPDX degrades to None (no crash) + MalformedInputTest AC-8 — missing file / non-string degrade to a finding, never raise + LiveRepoTest AC-9 — the real repo passes (post ca-sandbox fix) + CiWiringTest AC-10 — ci.yml runs the check and gates merge + +Pure functions over synthetic input; stdlib only. Exit 0 = all pass. +""" + +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) + +import check_license_consistency as lic # noqa: E402 — needs sys.path mutation + +CANON = "AGPL-3.0-only" +CA = "plugins/ca/.claude-plugin/plugin.json" +SANDBOX = "plugins/ca-sandbox/.claude-plugin/plugin.json" + + +class ManifestAgreementTest(unittest.TestCase): + """AC-1/AC-2: every manifest license equals the canonical; a prior license is a stale finding.""" + + def test_all_agree_passes(self): + self.assertEqual( + lic.check_manifest_agreement(CANON, {CA: CANON, SANDBOX: CANON}), []) + + def test_prior_license_in_a_manifest_fails_naming_file(self): + findings = lic.check_manifest_agreement(CANON, {CA: CANON, SANDBOX: "MIT"}) + self.assertTrue(any(SANDBOX in f and "MIT" in f for f in findings)) + + def test_prior_license_flagged_as_stale(self): # AC-2 + findings = lic.check_manifest_agreement(CANON, {CA: "MIT"}) + self.assertTrue(any("prior" in f.lower() for f in findings)) + + def test_nonprior_mismatch_fails(self): + findings = lic.check_manifest_agreement(CANON, {CA: CANON, SANDBOX: "BSD-3-Clause"}) + self.assertTrue(any("does not match" in f for f in findings)) + + +class LicenseFileTest(unittest.TestCase): + """AC-3: the LICENSE text carries every family-identifying token.""" + + def test_agpl_text_passes(self): + fam = lic.resolve_family(CANON) + text = " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n" + self.assertEqual(lic.check_license_file(fam, text), []) + + def test_wrong_license_text_fails(self): + fam = lic.resolve_family(CANON) + text = "MIT License\n\nPermission is hereby granted, free of charge, ...\n" + self.assertTrue(any("AFFERO" in f or "Version 3" in f for f in lic.check_license_file(fam, text))) + + +class ReadmeBadgeTest(unittest.TestCase): + """AC-4: the README license badge encodes the family.""" + + def test_badge_present_passes(self): + fam = lic.resolve_family(CANON) + readme = 'license AGPL v3' + self.assertEqual(lic.check_readme_badge(fam, readme), []) + + def test_badge_absent_fails(self): + fam = lic.resolve_family(CANON) + readme = 'license MIT' + self.assertTrue(any("badge" in f.lower() for f in lic.check_readme_badge(fam, readme))) + + +class ReadmeNoticeTest(unittest.TestCase): + """AC-5: the README notice names the canonical license, not only a prior one.""" + + def test_notice_names_license_passes(self): + fam = lic.resolve_family(CANON) + readme = "codeArbiter is licensed under the GNU AGPLv3, a change from its earlier MIT license." + self.assertEqual(lic.check_readme_notice(fam, readme), []) + + def test_notice_stale_fails(self): + fam = lic.resolve_family(CANON) + readme = "codeArbiter is licensed under the MIT license." + self.assertTrue(lic.check_readme_notice(fam, readme)) + + +class OfferingProseTest(unittest.TestCase): + """AC-6: retired commercial-offering phrases are forbidden in the README.""" + + def test_clean_prose_passes(self): + readme = ("Copyright (C) 2026 SUaDtL, who reserves the right to dual-license under " + "separate proprietary terms; commercial licenses are not offered at this time.") + self.assertEqual(lic.check_offering_prose(readme), []) + + def test_available_separately_fails(self): + readme = "Proprietary and commercial licensing is available separately; see Dual-Licensing." + self.assertTrue(any("available separately" in f for f in lic.check_offering_prose(readme))) + + def test_offers_the_same_code_fails(self): + readme = "The holder retains ownership and offers the same code under separate proprietary terms." + self.assertTrue(any("offers the same code" in f for f in lic.check_offering_prose(readme))) + + def test_non_string_degrades_to_no_finding(self): # never raises + self.assertEqual(lic.check_offering_prose(None), []) + + +class ResolveFamilyTest(unittest.TestCase): + """AC-7: known SPDX resolves to a family dict; unknown degrades to None (no crash).""" + + def test_known_spdx_resolves(self): + self.assertIsNotNone(lic.resolve_family("AGPL-3.0-only")) + + def test_unknown_spdx_returns_none(self): + self.assertIsNone(lic.resolve_family("BSD-3-Clause")) + + +class MalformedInputTest(unittest.TestCase): + """AC-8 (+ AC-7 via the aggregator): degrade to a finding, never raise.""" + + def test_read_manifest_license_missing_file_returns_none(self): + self.assertIsNone(lic.read_manifest_license("/no/such/dir/plugin.json")) + + def test_read_manifest_license_bad_json_returns_none(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + f.write("{ not json ") + path = f.name + try: + self.assertIsNone(lic.read_manifest_license(path)) + finally: + os.unlink(path) + + def test_evaluate_unknown_canonical_reports_not_raises(self): # AC-7 + findings = lic.evaluate("BSD-3-Clause", {CA: "BSD-3-Clause"}, "", "") + self.assertTrue(any("family" in f.lower() for f in findings)) + + def test_run_all_missing_files_reports_not_raises(self): # AC-8 + with tempfile.TemporaryDirectory() as d: + findings = lic.run_all(d) + self.assertTrue(findings) # missing surfaces => findings, not an exception + + +class LiveRepoTest(unittest.TestCase): + """AC-9: the real repository is consistent (after the ca-sandbox manifest fix).""" + + def test_repo_is_consistent(self): + findings = lic.run_all(REPO) + self.assertEqual(findings, [], "live repo license drift: " + "; ".join(findings)) + + +class CiWiringTest(unittest.TestCase): + """AC-10: ci.yml runs the check and includes it in the required-checks aggregation.""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(REPO, ".github", "workflows", "ci.yml"), encoding="utf-8") as fh: + cls.ci = fh.read() + + def test_ci_invokes_the_check(self): + self.assertIn("check_license_consistency.py", self.ci) + + def test_ci_job_is_in_required_aggregation(self): + self.assertIn("license-consistency", self.ci) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c07530ff..1946e958 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ on: - "plugins/ca-sandbox/**" - "**/*.json" - "README.md" + - "LICENSE" - ".github/workflows/ci.yml" - ".github/scripts/**" @@ -396,6 +397,23 @@ jobs: if [ "$fail" -eq 0 ]; then echo "all tracked JSON valid"; fi exit $fail + license-consistency: + name: Repo | [Check] - License Consistency + # Shared infra - the project's own license declarations (both plugin + # manifests, the LICENSE file, and the README badge + notice) must all agree + # with the canonical SPDX, so a partial relicense can't ship (the v2.6.0 + # relicense left three surfaces stale, caught only by human review). Repo-wide + # and always-on: a license edit can ride any PR. See + # specs/license-consistency-check.md. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Check license declarations agree across surfaces + run: python3 .github/scripts/check_license_consistency.py . + ci-passed: name: Repo | [Gate] - CI Passed if: always() @@ -410,6 +428,7 @@ jobs: - badge-consistency - prose-sandbox - manifests + - license-consistency runs-on: ubuntu-latest steps: - name: Require every job to have succeeded or been skipped