From f165b7a623cb053e436af924887a664b2676ecbe Mon Sep 17 00:00:00 2001 From: Claudio Ortega Date: Sun, 9 Aug 2026 22:01:57 -0700 Subject: [PATCH 1/2] ci: enforce reviewed runtime license policy --- .github/workflows/main.yml | 19 ++- .github/workflows/release-build.yml | 1 + docs/development/dependency-license-review.md | 46 +++++++ license-policy.toml | 39 ++++++ pyproject.toml | 1 + scripts/check_licenses.py | 121 ++++++++++++++++++ tests/license_test.py | 37 ++++++ 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 docs/development/dependency-license-review.md create mode 100644 license-policy.toml create mode 100644 scripts/check_licenses.py create mode 100644 tests/license_test.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1ac503a..ef48440 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -116,7 +116,7 @@ jobs: uvx --from check-wheel-contents==0.6.3 check-wheel-contents dist/*.whl security: - name: Dependency audit + name: Dependency and license audit runs-on: ubuntu-latest steps: - name: Check out repository @@ -128,12 +128,29 @@ jobs: version: 0.12.3 enable-cache: true + - name: Install locked runtime and security environment + run: | + uv python install 3.11 + uv sync --group security --python 3.11 + - name: Export locked runtime dependencies run: uv export --no-dev --no-hashes --no-emit-project --output-file requirements-audit.txt - name: Audit known vulnerabilities run: uvx --from pip-audit==2.10.1 pip-audit --requirement requirements-audit.txt + - name: Enforce reviewed runtime license policy + run: uv run --python 3.11 python scripts/check_licenses.py --output dependency-licenses.json + + - name: Upload dependency evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dependency-evidence + path: | + dependency-licenses.json + requirements-audit.txt + if-no-files-found: error + docs: name: Documentation runs-on: ubuntu-latest diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ef11a25..ab1868f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -55,6 +55,7 @@ jobs: mkdir -p artifact/evidence uv export --no-dev --no-hashes --no-emit-project --output-file artifact/evidence/requirements.txt uvx --from pip-audit==2.10.1 pip-audit --requirement artifact/evidence/requirements.txt --format cyclonedx-json --output artifact/evidence/sbom.cdx.json + uv run --python 3.11 python scripts/check_licenses.py --output artifact/evidence/dependency-licenses.json - name: Install and import the built wheel in a clean environment run: | diff --git a/docs/development/dependency-license-review.md b/docs/development/dependency-license-review.md new file mode 100644 index 0000000..48e4c1e --- /dev/null +++ b/docs/development/dependency-license-review.md @@ -0,0 +1,46 @@ +# Runtime dependency license review + +Snapshot date: 2026-08-09. This is an engineering control and inventory, not +legal advice or a substitute for EL-BID review. + +## Scope and result + +`scripts/check_licenses.py` walks installed distribution metadata from the +UrbanPy project through its active runtime requirements. It excludes development +extras and emits a deterministic JSON inventory. Against the locked 0.3 alpha +environment, 89 runtime distributions have an explicit reviewed disposition. + +The exact accepted metadata strings live in `license-policy.toml`. A new package +or a changed license string fails closed so a maintainer must investigate it. +This repository check complements, and does not remove or dismiss, the external +FOSSA License Compliance check. FOSSA findings remain blocking until EL-BID +records an acceptable disposition in that system. + +## Package-specific dispositions + +- `defopt` 7.0.0 reports no license in wheel metadata. Its official repository + license is MIT; the policy records that source rather than globally allowing + `UNKNOWN`. +- `text-unidecode` 1.3 offers GPL/GPLv2+ or Artistic terms in its official + license file. UrbanPy relies on the GPLv2+ option, which permits use under + GPLv3. The package override records that selection explicitly. + +The runtime closure otherwise reports permissive, public-domain, weak-copyleft, +or GPL-compatible alternatives covered by the reviewed exact policy. Compound +expressions and legacy classifier strings are retained in the output so the +evidence does not overstate metadata quality. + +## Release evidence + +Normal CI uploads the locked requirements export and dependency-license JSON. +The release build additionally stores them beside the CycloneDX SBOM and +attested wheel/sdist. The publish job promotes that bundle unchanged. + +## Maintainer obligations + +- Review every policy change and the corresponding primary license source. +- Reconcile FOSSA's complete finding set, including source, development, + generated, and vendored assets that are outside the runtime closure. +- Obtain EL-BID legal approval when license compatibility or data licensing is + ambiguous; do not encode an automated guess as approval. +- Preserve license notices required by distributions and data providers. diff --git a/license-policy.toml b/license-policy.toml new file mode 100644 index 0000000..01f40f8 --- /dev/null +++ b/license-policy.toml @@ -0,0 +1,39 @@ +# This is an engineering gate, not legal advice. EL-BID maintainers own final +# dependency-license approval. Exact metadata changes fail closed for review. +[policy] +allowed = [ + "0BSD", + "0BSD OR CC0-1.0", + "Apache Software License", + "Apache Software License; BSD License", + "Apache-2.0", + "Apache-2.0 OR BSD-2-Clause", + "BSD License", + "BSD License; GNU General Public License (GPL); Public Domain", + "BSD-2-Clause", + "BSD-3-Clause", + "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0", + "BSD-3-Clause AND ISC", + "GNU General Public License v2 or later (GPLv2+)", + "GPL-3.0-only", + "ISC License (ISCL)", + "MIT", + "MIT License", + "MIT OR AFL-2.1", + "Mozilla Public License 2.0 (MPL 2.0)", + "MPL-2.0", + "MPL-2.0 AND MIT", + "PSF-2.0", + "Python Software Foundation License", + "The Unlicense (Unlicense)", +] + +[packages.defopt] +license = "MIT" +source = "https://github.com/anntzer/defopt/blob/main/LICENSE.txt" +disposition = "Version 7.0.0 omits license metadata; the official source license is MIT." + +[packages.text-unidecode] +license = "GNU General Public License v2 or later (GPLv2+)" +source = "https://github.com/kmike/text-unidecode/blob/master/LICENSE" +disposition = "The package offers GPL/GPLv2+ or Artistic terms; UrbanPy relies on the GPLv2+ option, which can be used under GPLv3." diff --git a/pyproject.toml b/pyproject.toml index 4b60854..47376c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ include = [ "/CONTRIBUTING.md", "/GOVERNANCE.md", "/LICENSE", + "/license-policy.toml", "/README.md", "/RELEASING.md", "/SECURITY.md", diff --git a/scripts/check_licenses.py b/scripts/check_licenses.py new file mode 100644 index 0000000..b0140cc --- /dev/null +++ b/scripts/check_licenses.py @@ -0,0 +1,121 @@ +"""Audit installed UrbanPy runtime dependency license metadata.""" + +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import sys +import tomllib +from collections import deque +from pathlib import Path +from typing import Any + +from packaging.markers import default_environment +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + + +def runtime_closure(root: str = "urbanpy") -> list[importlib.metadata.Distribution]: + installed = { + canonicalize_name(dist.metadata["Name"]): dist + for dist in importlib.metadata.distributions() + if dist.metadata.get("Name") + } + pending = deque([canonicalize_name(root)]) + visited: set[str] = set() + result: list[importlib.metadata.Distribution] = [] + environment = default_environment() | {"extra": ""} + while pending: + name = pending.popleft() + if name in visited: + continue + visited.add(name) + distribution = installed.get(name) + if distribution is None: + raise ValueError(f"runtime dependency {name!r} is not installed") + result.append(distribution) + for value in distribution.requires or (): + requirement = Requirement(value) + if requirement.marker and not requirement.marker.evaluate(environment): + continue + pending.append(canonicalize_name(requirement.name)) + return sorted(result, key=lambda item: canonicalize_name(item.metadata["Name"])) + + +def observed_license(distribution: importlib.metadata.Distribution) -> str: + expression = distribution.metadata.get("License-Expression") + if expression: + return expression.strip() + classifiers = sorted( + { + value.rsplit(" :: ", 1)[-1].strip() + for value in distribution.metadata.get_all("Classifier", []) + if value.startswith("License :: ") + } + ) + if classifiers: + return "; ".join(classifiers) + value = distribution.metadata.get("License") + return value.strip() if value and value.strip() else "UNKNOWN" + + +def audit(policy_path: Path) -> tuple[list[dict[str, Any]], list[str]]: + with policy_path.open("rb") as source: + policy = tomllib.load(source) + allowed = set(policy["policy"]["allowed"]) + overrides = { + canonicalize_name(name): value + for name, value in policy.get("packages", {}).items() + } + report: list[dict[str, Any]] = [] + violations: list[str] = [] + for distribution in runtime_closure(): + name = distribution.metadata["Name"] + observed = observed_license(distribution) + override = overrides.get(canonicalize_name(name)) + effective = override["license"] if override else observed + approved = effective in allowed + record = { + "approved": approved, + "effective_license": effective, + "name": name, + "observed_license": observed, + "version": distribution.version, + } + if override: + record["disposition"] = override["disposition"] + record["source"] = override["source"] + report.append(record) + if not approved: + violations.append(f"{name} {distribution.version}: {observed}") + return report, violations + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--policy", type=Path, default=Path("license-policy.toml")) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + report, violations = audit(args.policy) + except (OSError, KeyError, ValueError) as error: + print(f"license audit failed: {error}", file=sys.stderr) + return 2 + payload = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + else: + print(payload, end="") + if violations: + print("unapproved runtime dependency licenses:", file=sys.stderr) + for violation in violations: + print(f"- {violation}", file=sys.stderr) + return 1 + print(f"approved {len(report)} runtime package license records") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/license_test.py b/tests/license_test.py new file mode 100644 index 0000000..c6de822 --- /dev/null +++ b/tests/license_test.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from packaging.utils import canonicalize_name + +from scripts.check_licenses import audit, observed_license, runtime_closure + + +def test_runtime_dependency_closure_has_an_approved_license_disposition(): + report, violations = audit(Path("license-policy.toml")) + + assert not violations + assert report + assert report == sorted(report, key=lambda item: canonicalize_name(item["name"])) + assert all(item["approved"] for item in report) + assert {item["name"].casefold() for item in report} >= { + "geopandas", + "h3", + "osmnx", + "pydantic", + "urbanpy", + } + + +def test_runtime_closure_excludes_development_only_packages(): + names = {item.metadata["Name"].casefold() for item in runtime_closure()} + + assert "pytest" not in names + assert "sphinx" not in names + + +def test_every_runtime_distribution_exposes_or_has_a_reviewed_license(): + observed = { + item.metadata["Name"]: observed_license(item) for item in runtime_closure() + } + + assert observed["urbanpy"] == "GPL-3.0-only" + assert observed["defopt"] == "UNKNOWN" From f05720ca896cdfbb4c861a41c722a53380ce6582 Mon Sep 17 00:00:00 2001 From: Claudio Ortega Date: Sun, 9 Aug 2026 22:03:54 -0700 Subject: [PATCH 2/2] ci: expose failing Sonar gate conditions --- .github/workflows/build.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a41a00b..5e9c04e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,3 +44,16 @@ jobs: args: >- -Dsonar.qualitygate.wait=true -Dsonar.qualitygate.timeout=300 + + - name: Report failed quality-gate conditions + if: failure() && github.event_name == 'pull_request' + env: + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + curl --fail --silent --show-error --user "$SONAR_TOKEN:" \ + --get "$SONAR_HOST_URL/api/qualitygates/project_status" \ + --data-urlencode "projectKey=EL-BID_urbanpy" \ + --data-urlencode "pullRequest=$PULL_REQUEST_NUMBER" \ + | jq '{status: .projectStatus.status, conditions: [.projectStatus.conditions[] | {metricKey, status, actualValue, errorThreshold, comparator}]}'