From 0726c5599bd71ad4d8c55f373f172875441d2464 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 9 Oct 2026 13:57:51 +0200 Subject: [PATCH] Check dependency licenses in CI Add a workflow that installs the package into a clean environment and checks every dependency it pulls in against a list of allowed licenses. A dependency passes only if every license it declares, in its License-Expression, its classifiers and its License field, is on the list, so a package that declares two licenses needs both allowed. One that declares none fails. Dependencies reviewed by hand are listed as exceptions together with the licenses they declared at the time of review. When a new release of one of them declares something else, the check fails again and the review is repeated. Exceptions that no longer match an installed package, or that would pass without one, are reported so the list stays short. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/check_licenses.py | 194 ++++++++++++++++++++++++++++++ .github/workflows/licenses.yml | 26 ++++ 2 files changed, 220 insertions(+) create mode 100644 .github/scripts/check_licenses.py create mode 100644 .github/workflows/licenses.yml diff --git a/.github/scripts/check_licenses.py b/.github/scripts/check_licenses.py new file mode 100644 index 0000000..8abb807 --- /dev/null +++ b/.github/scripts/check_licenses.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Check that every installed dependency is distributed under an allowed license. + +Run it with the interpreter of an environment that holds the project, its +dependencies and nothing else: + + python -m venv ../license-check + ../license-check/bin/python -m pip install . + ../license-check/bin/python .github/scripts/check_licenses.py + +Every license a distribution declares must be allowed: the License-Expression, +each "License ::" classifier, and the free-text License field. A distribution +that declares none fails. Operators in an expression are not interpreted, so +"A OR B" needs both A and B to be allowed. + +A distribution that fails and has been reviewed by hand goes in EXCEPTIONS, +together with the licenses it declared at the time. If those change, the check +fails again, so the review is repeated. +""" + +import importlib.metadata +import re +import sys + +PROJECT = "cryptnox-id-cli" + +ALLOWED_SPDX = { + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CNRI-Python", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "PSF-2.0", + "Python-2.0", + "Unlicense", + "Zlib", +} + +ALLOWED_CLASSIFIERS = { + "Apache Software License", + "BSD License", + "ISC License (ISCL)", + "MIT License", + "MIT No Attribution License (MIT-0)", + "Mozilla Public License 2.0 (MPL 2.0)", + "Public Domain", + "Python Software Foundation License", + "Repoze Public License", + "The Unlicense (Unlicense)", + "zlib/libpng License", +} + +# Values of the free-text License field that are not SPDX expressions. +ALLOWED_TEXT = { + "Apache 2.0", + "Apache License 2.0", + "BSD, Public Domain", + "BSD-derived (http://www.repoze.org/LICENSE.txt)", + "BSD-like (http://repoze.org/license.html)", + "Python Software Foundation License", +} + +# Reviewed by hand: name -> (licenses declared at review time, reason). +EXCEPTIONS = { + "pyscard": ( + {"GNU Lesser General Public License v2 or later (LGPLv2+)"}, + "used as a library, unmodified", + ), +} + +# Installers, not dependencies of the project. +TOOLING = {"pip", "setuptools", "wheel"} + +OPERATORS = {"AND", "OR", "WITH"} + + +def normalize(name): + """Distribution name in the canonical form of PEP 503.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def declared_licenses(metadata): + """(source, value) for every license a distribution declares.""" + found = [] + expression = (metadata.get("License-Expression") or "").strip() + if expression: + found.append(("expression", expression)) + for classifier in metadata.get_all("Classifier") or []: + parts = [part.strip() for part in classifier.split("::")] + # "License :: OSI Approved" on its own names no license + if len(parts) > 1 and parts[0] == "License" and parts[-1] != "OSI Approved": + found.append(("classifier", parts[-1])) + text = (metadata.get("License") or "").strip() + if text and text.upper() != "UNKNOWN": + found.append(("field", text)) + return found + + +def spdx_allowed(expression): + """True when every identifier in an SPDX-style expression is allowed.""" + allowed = {identifier.lower() for identifier in ALLOWED_SPDX} + identifiers = [ + token for token in re.findall(r"[^\s()]+", expression) if token.upper() not in OPERATORS + ] + return bool(identifiers) and all(token.lower() in allowed for token in identifiers) + + +def is_allowed(source, value): + if source == "classifier": + return value in ALLOWED_CLASSIFIERS + if source == "field" and value in ALLOWED_TEXT: + return True + return spdx_allowed(value) + + +def installed_distributions(path=None): + """One distribution per name; the first found on the path wins, as for imports.""" + distributions = {} + for distribution in importlib.metadata.distributions(path=path or sys.path): + name = distribution.metadata["Name"] + if name: + distributions.setdefault(normalize(name), distribution) + return distributions + + +def check(distributions): + """Returns (report lines, failure count, notices).""" + lines, failures, notices = [], 0, [] + exceptions = {normalize(name): entry for name, entry in EXCEPTIONS.items()} + for key in sorted(distributions): + if key in TOOLING or key == normalize(PROJECT): + continue + distribution = distributions[key] + label = f"{distribution.metadata['Name']} {distribution.version}" + licenses = declared_licenses(distribution.metadata) + shown = "; ".join(value.splitlines()[0][:60] for _, value in licenses) or "none declared" + rejected = [value for source, value in licenses if not is_allowed(source, value)] + + if key in exceptions: + recorded, reason = exceptions[key] + if {value for _, value in licenses} == recorded: + lines.append(f"ok {label:<36} {shown} (reviewed: {reason})") + if licenses and not rejected: + notices.append(f"{label} passes without its exception; the entry can go") + else: + failures += 1 + was = "; ".join(sorted(recorded)) or "none" + lines.append( + f"FAILED {label:<36} {shown} " + f"(declared licenses changed since review; recorded: {was})" + ) + elif not licenses: + failures += 1 + lines.append(f"FAILED {label:<36} declares no license") + elif rejected: + failures += 1 + lines.append(f"FAILED {label:<36} {shown} (not allowed: {'; '.join(rejected)})") + else: + lines.append(f"ok {label:<36} {shown}") + + for name in sorted(exceptions): + if name not in distributions: + notices.append( + f"exception for {name} matches no installed distribution; the entry can go" + ) + return lines, failures, notices + + +def main(argv): + path = [argv[argv.index("--path") + 1]] if "--path" in argv else None + distributions = installed_distributions(path) + lines, failures, notices = check(distributions) + print("\n".join(lines)) + for notice in notices: + print("notice: " + notice) + checked = len(lines) + if failures: + print( + f"\n{failures} of {checked} distributions have a license that is not allowed. " + "Replace the dependency, or review its license and add it to ALLOWED_* or " + f"EXCEPTIONS in {__file__}." + ) + return 1 + print(f"\nAll {checked} distributions have allowed licenses.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml new file mode 100644 index 0000000..5f253bf --- /dev/null +++ b/.github/workflows/licenses.yml @@ -0,0 +1,26 @@ +name: Dependency licenses + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + licenses: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential swig libpcsclite-dev + # A separate environment, so the check sees what the package pulls in and + # nothing that happens to be installed on the runner + - run: python -m venv ../license-check + - run: ../license-check/bin/python -m pip install --upgrade pip + - run: ../license-check/bin/python -m pip install . + - run: ../license-check/bin/python .github/scripts/check_licenses.py