Skip to content

Commit 9212cf9

Browse files
committed
ci: check dependency licenses
Add a locked runtime dependency license scan with an explicit permissive allowlist and package-specific reviewed exceptions. Fail on unknown licenses, exception drift, and stale exceptions. Closes #822 Signed-off-by: Nabin Mulepati <nmulepati@nvidia.com>
1 parent c278922 commit 9212cf9

5 files changed

Lines changed: 370 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,28 @@ jobs:
249249
- name: Check license headers
250250
run: make check-license-headers
251251

252+
dependency-licenses:
253+
name: Check Dependency Licenses
254+
needs: validate-dispatch
255+
runs-on: ubuntu-latest
256+
257+
steps:
258+
- name: Checkout code
259+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
260+
261+
- name: Install uv
262+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
263+
with:
264+
version: "latest"
265+
python-version: "3.11"
266+
enable-cache: true
267+
268+
- name: Install locked runtime dependencies
269+
run: uv sync --all-packages --no-dev --locked
270+
271+
- name: Check dependency licenses
272+
run: make check-dependency-licenses
273+
252274
# ===========================================================================
253275
# Summary Job for Branch Protection
254276
# This job creates status checks matching the old job naming convention

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ help:
8888
@echo " check-fern-docs-locally - Install deps, generate Fern artifacts, and run fern check"
8989
@echo " serve-fern-docs-locally - Generate local Fern artifacts and serve Fern docs"
9090
@echo " check-license-headers - Check if all files have license headers"
91+
@echo " check-dependency-licenses - Check runtime dependency license compatibility"
9192
@echo " update-license-headers - Add license headers to all files"
9293
@echo ""
9394
@echo "⚡ Performance:"
@@ -449,9 +450,13 @@ show-versions:
449450
@uv run python -c "from data_designer.interface._version import __version__; print(f' data-designer: {__version__}')" 2>/dev/null || echo " data-designer: (not installed)"
450451

451452
# ==============================================================================
452-
# LICENSE HEADERS
453+
# LICENSE CHECKS
453454
# ==============================================================================
454455

456+
check-dependency-licenses:
457+
@echo "🔍 Checking runtime dependency licenses..."
458+
uv run --no-sync python $(REPO_PATH)/scripts/check_dependency_licenses.py
459+
455460
check-license-headers:
456461
@echo "🔍 Checking license headers in all files..."
457462
uv run python $(REPO_PATH)/scripts/update_license_headers.py --check
@@ -746,7 +751,7 @@ clean-test-coverage:
746751
.PHONY: bench-cli-startup bench-cli-startup-verbose \
747752
build build-config build-engine build-interface \
748753
check-all check-all-fix check-config check-engine check-interface \
749-
check-fern-docs check-fern-docs-locally check-fern-release-version check-fern-theme-access check-license-headers \
754+
check-dependency-licenses check-fern-docs check-fern-docs-locally check-fern-release-version check-fern-theme-access check-license-headers \
750755
clean clean-dist clean-notebooks clean-pycache clean-test-coverage \
751756
convert-execute-notebooks \
752757
coverage coverage-config coverage-engine coverage-interface \

dependency-license-policy.toml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Permissive licenses that the Apache Software Foundation classifies as
5+
# "Apache-like" (Category A). Values use SPDX identifiers after normalization.
6+
# https://www.apache.org/legal/resolved.html#category-a
7+
allowed_licenses = [
8+
"Apache-2.0",
9+
"BSD-2-Clause",
10+
"BSD-3-Clause",
11+
"ISC",
12+
"MIT",
13+
"Zlib",
14+
]
15+
16+
# Existing dependencies outside the global whitelist are reviewed by package
17+
# and exact reported license. A license change or a new package with one of
18+
# these licenses must be reviewed explicitly.
19+
[exceptions.certifi]
20+
license = "Mozilla Public License 2.0 (MPL 2.0)"
21+
reason = "Unmodified TLS certificate bundle installed as a separate transitive dependency."
22+
23+
[exceptions.chardet]
24+
license = "GNU Lesser General Public License v2 or later (LGPLv2+)"
25+
reason = "Unmodified character-detection library installed as a separate runtime dependency."
26+
27+
[exceptions.email-validator]
28+
license = "The Unlicense (Unlicense)"
29+
reason = "Existing unmodified validation library installed as a separate runtime dependency."
30+
31+
[exceptions.numpy]
32+
license = "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0"
33+
reason = "Core numerical dependency with bundled components under additional permissive licenses."
34+
35+
[exceptions.pathspec]
36+
license = "Mozilla Public License 2.0 (MPL 2.0)"
37+
reason = "Unmodified transitive dependency of sqlfluff installed as a separate package."
38+
39+
[exceptions.pillow]
40+
license = "MIT-CMU"
41+
reason = "Core image dependency under the permissive MIT-CMU license."
42+
43+
[exceptions.regex]
44+
license = "Apache-2.0 AND CNRI-Python"
45+
reason = "Existing regex dependency containing code under the CNRI Python license."
46+
47+
[exceptions.tqdm]
48+
license = "MPL-2.0 AND MIT"
49+
reason = "Unmodified progress library installed as a separate transitive dependency."
50+
51+
[exceptions.typing_extensions]
52+
license = "PSF-2.0"
53+
reason = "Python typing compatibility dependency under the PSF license."
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import re
9+
import subprocess
10+
import sys
11+
from dataclasses import dataclass
12+
from pathlib import Path
13+
from typing import Any
14+
15+
if sys.version_info >= (3, 11):
16+
import tomllib
17+
else:
18+
import tomli as tomllib
19+
20+
PIP_LICENSES_VERSION = "5.5.5"
21+
POLICY_PATH = Path(__file__).resolve().parents[1] / "dependency-license-policy.toml"
22+
23+
LICENSE_ALIASES = {
24+
"Apache Software License": {"Apache-2.0"},
25+
"BSD License": {"BSD-3-Clause"},
26+
"ISC License (ISCL)": {"ISC"},
27+
"MIT License": {"MIT"},
28+
"Python Software Foundation License": {"PSF-2.0"},
29+
"The Unlicense (Unlicense)": {"Unlicense"},
30+
}
31+
EXPRESSION_OPERATOR = re.compile(r"\s+(?:AND|OR|WITH)\s+|[()]", re.IGNORECASE)
32+
33+
34+
@dataclass(frozen=True)
35+
class ExceptionPolicy:
36+
license: str
37+
reason: str
38+
39+
40+
@dataclass(frozen=True)
41+
class LicensePolicy:
42+
allowed_licenses: frozenset[str]
43+
exceptions: dict[str, ExceptionPolicy]
44+
45+
46+
@dataclass(frozen=True)
47+
class PackageLicense:
48+
name: str
49+
version: str
50+
license: str
51+
license_text: str
52+
53+
54+
def load_policy(path: Path = POLICY_PATH) -> LicensePolicy:
55+
"""Load the dependency-license policy from TOML."""
56+
data = tomllib.loads(path.read_text(encoding="utf-8"))
57+
exceptions = {
58+
name.casefold(): ExceptionPolicy(license=value["license"], reason=value["reason"])
59+
for name, value in data.get("exceptions", {}).items()
60+
}
61+
return LicensePolicy(allowed_licenses=frozenset(data["allowed_licenses"]), exceptions=exceptions)
62+
63+
64+
def looks_like_mit_license(license_text: str) -> bool:
65+
normalized = " ".join(license_text.split()).casefold()
66+
return normalized.startswith("mit license ") and "permission is hereby granted, free of charge" in normalized
67+
68+
69+
def normalized_license_ids(package: PackageLicense) -> frozenset[str]:
70+
"""Normalize pip-licenses output into SPDX-like license identifiers."""
71+
reported = package.license.strip()
72+
if reported.casefold() == "unknown":
73+
return frozenset({"MIT"}) if looks_like_mit_license(package.license_text) else frozenset()
74+
75+
if reported.startswith("MIT License\n") and looks_like_mit_license(reported):
76+
return frozenset({"MIT"})
77+
78+
aliases = LICENSE_ALIASES.get(reported)
79+
if aliases is not None:
80+
return frozenset(aliases)
81+
82+
identifiers: set[str] = set()
83+
for part in reported.split(";"):
84+
stripped = part.strip()
85+
part_aliases = LICENSE_ALIASES.get(stripped)
86+
if part_aliases is not None:
87+
identifiers.update(part_aliases)
88+
continue
89+
90+
tokens = [token.strip() for token in EXPRESSION_OPERATOR.split(stripped) if token.strip()]
91+
if not tokens:
92+
return frozenset()
93+
identifiers.update(tokens)
94+
95+
return frozenset(identifiers)
96+
97+
98+
def parse_report(data: list[dict[str, Any]]) -> list[PackageLicense]:
99+
"""Parse the structured pip-licenses report."""
100+
return [
101+
PackageLicense(
102+
name=str(item["Name"]),
103+
version=str(item["Version"]),
104+
license=str(item["License"]),
105+
license_text=str(item.get("LicenseText", "")),
106+
)
107+
for item in data
108+
]
109+
110+
111+
def evaluate_report(packages: list[PackageLicense], policy: LicensePolicy) -> tuple[list[str], list[str]]:
112+
"""Return policy violations and reviewed-exception descriptions."""
113+
violations: list[str] = []
114+
reviewed: list[str] = []
115+
seen_exceptions: set[str] = set()
116+
117+
for package in sorted(packages, key=lambda item: item.name.casefold()):
118+
package_key = package.name.casefold()
119+
exception = policy.exceptions.get(package_key)
120+
if exception is not None:
121+
seen_exceptions.add(package_key)
122+
if package.license != exception.license:
123+
violations.append(
124+
f"{package.name}=={package.version}: exception expected {exception.license!r}, "
125+
f"but package reports {package.license!r}"
126+
)
127+
else:
128+
reviewed.append(f"{package.name}=={package.version}: {package.license} ({exception.reason})")
129+
continue
130+
131+
identifiers = normalized_license_ids(package)
132+
if not identifiers:
133+
violations.append(f"{package.name}=={package.version}: unknown or unrecognized license {package.license!r}")
134+
elif not identifiers.issubset(policy.allowed_licenses):
135+
disallowed = ", ".join(sorted(identifiers - policy.allowed_licenses))
136+
violations.append(f"{package.name}=={package.version}: disallowed license(s): {disallowed}")
137+
138+
for package_key in sorted(policy.exceptions.keys() - seen_exceptions):
139+
violations.append(f"{package_key}: stale exception; package is not present in the scanned environment")
140+
141+
return violations, reviewed
142+
143+
144+
def collect_report() -> list[PackageLicense]:
145+
"""Run the pinned scanner against the active Python environment."""
146+
command = [
147+
"uv",
148+
"tool",
149+
"run",
150+
"--from",
151+
f"pip-licenses=={PIP_LICENSES_VERSION}",
152+
"pip-licenses",
153+
"--python",
154+
sys.executable,
155+
"--from=mixed",
156+
"--format=json",
157+
"--with-license-file",
158+
"--no-license-path",
159+
]
160+
result = subprocess.run(command, capture_output=True, check=False, text=True)
161+
if result.returncode != 0:
162+
print(result.stderr, file=sys.stderr)
163+
raise RuntimeError(f"pip-licenses exited with status {result.returncode}")
164+
return parse_report(json.loads(result.stdout))
165+
166+
167+
def main() -> int:
168+
parser = argparse.ArgumentParser(description="Check runtime dependencies against the Apache-2.0 license policy")
169+
parser.add_argument("--report", type=Path, help="Read a pip-licenses JSON report instead of running the scanner")
170+
args = parser.parse_args()
171+
172+
if args.report is None:
173+
packages = collect_report()
174+
else:
175+
packages = parse_report(json.loads(args.report.read_text(encoding="utf-8")))
176+
177+
violations, reviewed = evaluate_report(packages, load_policy())
178+
print(f"Checked {len(packages)} installed runtime packages.")
179+
180+
if reviewed:
181+
print("\nReviewed package-specific exceptions:")
182+
for item in reviewed:
183+
print(f" - {item}")
184+
185+
if violations:
186+
print("\nDependency license policy violations:", file=sys.stderr)
187+
for violation in violations:
188+
print(f" - {violation}", file=sys.stderr)
189+
return 1
190+
191+
print("\nAll dependency licenses satisfy the Apache-2.0 compatibility policy.")
192+
return 0
193+
194+
195+
if __name__ == "__main__":
196+
sys.exit(main())

0 commit comments

Comments
 (0)