-
Notifications
You must be signed in to change notification settings - Fork 2
Workflow for the creation of third-party-licenses file by release #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ccce88f
cb5381c
c59516a
3e12af1
6932ede
ddd63ea
40a440f
331db9b
8127bd0
d6a5a9b
c3ef10d
45ac6ae
db09641
016cb75
c0ace78
2a7cec8
a96842e
a169d48
38e34a1
25ada49
6c91397
59e09cb
c215e4a
22ca19e
355fe30
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| #!/usr/bin/env python3 | ||
| """Merge pip-licenses output with a SBOM into one | ||
| THIRD_PARTY_LICENSES.json file. | ||
|
|
||
| Python libary licences created with pip-licenses (incl. LicenseText, URL etc.), | ||
| all other system licences (apt/dpkg, apk, rpm, npm, gem, cargo, | ||
| go modules, ...) created via Syft SBOM. Python entries from | ||
| SBOM are discarded to avoid duplicates. | ||
|
|
||
| Usage: | ||
| merge-licenses.py <pip-licenses.json> <syft.json> <output.json> | ||
| """ | ||
| import json | ||
| import sys | ||
|
|
||
| SOURCE_LABELS = { | ||
| "deb": "apt/dpkg (OS package)", | ||
| "apk": "apk (OS package)", | ||
| "rpm": "rpm (OS package)", | ||
| "npm": "npm", | ||
| "gemspec": "gem", | ||
| "go-module": "go module", | ||
| "rust-crate": "cargo", | ||
| "java-archive": "java (jar)", | ||
| } | ||
|
|
||
| # Ecosystems that are ignored by the Syft SBOM because they are already | ||
| # covered by pip-licenses. | ||
| EXCLUDE_SYFT_TYPES = {"python"} | ||
|
|
||
|
|
||
| def load_pip_licenses(path): | ||
| with open(path) as f: | ||
| data = json.load(f) | ||
| entries = [] | ||
| for pkg in data: | ||
| entries.append({ | ||
| "Name": pkg.get("Name"), | ||
| "Version": pkg.get("Version"), | ||
| "License": pkg.get("License", "UNKNOWN"), | ||
| "Source": "python (pip)", | ||
| "URL": pkg.get("URL"), | ||
| "LicenseText": pkg.get("LicenseText"), | ||
| }) | ||
| return entries | ||
|
|
||
|
|
||
| def extract_license(licenses): | ||
| """Depending on the version, Syft’s ‘licence’ field takes different forms: | ||
| either a list of strings, or a list of objects with a 'value' field. | ||
| Both are handled here.""" | ||
| if not licenses: | ||
| return "UNKNOWN" | ||
| values = [] | ||
| for lic in licenses: | ||
| if isinstance(lic, dict): | ||
| values.append(lic.get("value") or lic.get("spdxExpression") or "UNKNOWN") | ||
| else: | ||
| values.append(str(lic)) | ||
| return ", ".join(sorted(set(values))) if values else "UNKNOWN" | ||
|
|
||
|
|
||
| def load_syft(path, exclude_types=EXCLUDE_SYFT_TYPES): | ||
| with open(path) as f: | ||
| data = json.load(f) | ||
| entries = [] | ||
| for artifact in data.get("artifacts", []): | ||
| pkg_type = artifact.get("type", "unknown") | ||
| if pkg_type in exclude_types: | ||
| continue | ||
| entries.append({ | ||
| "Name": artifact.get("name"), | ||
| "Version": artifact.get("version"), | ||
| "License": extract_license(artifact.get("licenses")), | ||
| "Source": SOURCE_LABELS.get(pkg_type, pkg_type), | ||
| }) | ||
| return entries | ||
|
|
||
|
|
||
| def dedupe(entries): | ||
| seen = set() | ||
| result = [] | ||
| for entry in entries: | ||
| key = (entry.get("Name"), entry.get("Version"), entry.get("Source")) | ||
| if key in seen: | ||
| continue | ||
| seen.add(key) | ||
| result.append(entry) | ||
| return result | ||
|
|
||
|
|
||
| def main(): | ||
| if len(sys.argv) != 4: | ||
| print( | ||
| "Usage: merge-licenses.py <pip-licenses.json> <syft.json> <output.json>", | ||
| file=sys.stderr, | ||
| ) | ||
| sys.exit(1) | ||
|
|
||
| pip_path, syft_path, out_path = sys.argv[1:4] | ||
|
|
||
| entries = [] | ||
| entries += load_pip_licenses(pip_path) | ||
| entries += load_syft(syft_path) | ||
| entries = dedupe(entries) | ||
| entries.sort(key=lambda e: (e.get("Source") or "", (e.get("Name") or "").lower())) | ||
|
|
||
| with open(out_path, "w") as f: | ||
| json.dump(entries, f, indent=2) | ||
|
|
||
| print(f"Wrote {len(entries)} license entries to {out_path}") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| name: Generate Third-Party Licenses | ||
|
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| dockerfile: | ||
| description: "Path to the Dockerfile" | ||
| required: false | ||
| type: string | ||
| requirements: | ||
| description: "Path to the requirements.txt file" | ||
| required: false | ||
| type: string | ||
| pyproject: | ||
| description: "Path to the pyproject.toml file" | ||
| required: false | ||
| type: string | ||
|
|
||
| jobs: | ||
| generate: | ||
| name: Generate THIRD_PARTY_LICENSES.json | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Validate inputs | ||
| id: validate | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| count=0 | ||
| source_type="" | ||
| source_path="" | ||
|
|
||
| if [ -n "${{ inputs.dockerfile }}" ]; then | ||
| count=$((count + 1)) | ||
| source_type="dockerfile" | ||
| source_path="${{ inputs.dockerfile }}" | ||
| fi | ||
| if [ -n "${{ inputs.requirements }}" ]; then | ||
| count=$((count + 1)) | ||
| source_type="requirements" | ||
| source_path="${{ inputs.requirements }}" | ||
| fi | ||
| if [ -n "${{ inputs.pyproject }}" ]; then | ||
| count=$((count + 1)) | ||
| source_type="pyproject" | ||
| source_path="${{ inputs.pyproject }}" | ||
| fi | ||
|
|
||
| if [ "$count" -eq 0 ]; then | ||
| echo "::error::One of the inputs 'dockerfile', 'requirements' or 'pyproject' has to be set." | ||
| exit 1 | ||
| fi | ||
| if [ "$count" -gt 1 ]; then | ||
| echo "::error::Only one of the inputs 'dockerfile', 'requirements' or 'pyproject' may be set." | ||
| exit 1 | ||
| fi | ||
| if [ ! -f "$source_path" ]; then | ||
| echo "::error::File '$source_path' not found." | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "source-type=$source_type" >> "$GITHUB_OUTPUT" | ||
| echo "source-path=$source_path" >> "$GITHUB_OUTPUT" | ||
|
|
||
| # --- Case 1: requirements.txt ------------------------------------- | ||
| - name: Licenses from requirements.txt | ||
| if: inputs.requirements != '' | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| python -m venv .license-venv | ||
| source .license-venv/bin/activate | ||
| pip install --upgrade pip pip-licenses --quiet | ||
| pip install -r "${{ inputs.requirements }}" | ||
| pip-licenses --from=mixed --with-authors --with-urls --with-license-file --format=json --output-file=THIRD_PARTY_LICENSES.json | ||
|
|
||
| # --- Case 2: pyproject.toml ---------------------------------------- | ||
| - name: Licenses from pyproject.toml | ||
| if: inputs.pyproject != '' | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| python -m venv .license-venv | ||
| source .license-venv/bin/activate | ||
| pip install --upgrade pip pip-licenses --quiet | ||
| project_dir=$(dirname "${{ inputs.pyproject }}") | ||
| pip install "$project_dir" | ||
| pip-licenses --from=mixed --with-authors --with-urls --with-license-file --format=json --output-file=THIRD_PARTY_LICENSES.json | ||
|
|
||
| # --- Case 3: Dockerfile --------------------------------------------- | ||
| # Build the image and executes pip-licenses inside the docker container | ||
| - name: Licenses from Dockerfile | ||
| if: inputs.dockerfile != '' | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| docker build \ | ||
| -t license-scan-image \ | ||
| -f "${{ inputs.dockerfile }}" . | ||
| docker run --entrypoint sh --rm -v "$PWD":/output license-scan-image -c " | ||
| python -m pip install --upgrade pip pip-licenses && | ||
| pip-licenses --from=mixed --with-authors --with-urls --with-license-file --format=json --output-file=/output/pip-licenses.json | ||
| " | ||
| - name: Generate SBOM from Docker image | ||
| if: inputs.dockerfile != '' | ||
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | ||
| with: | ||
| image: license-scan-image | ||
| format: syft-json | ||
| output-file: sbom.json | ||
| upload-artifact: false | ||
| # upload-artifact: true | ||
| # artifact-name: sbom.json | ||
| - name: Merge licenses | ||
|
anikaweinmann marked this conversation as resolved.
|
||
| if: inputs.dockerfile != '' | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| wget -q https://raw.githubusercontent.com/mundialis/github-workflows/refs/heads/main/.github/scripts/merge-licenses.py | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a possibility to not have |
||
| python3 merge-licenses.py pip-licenses.json sbom.json THIRD_PARTY_LICENSES.json | ||
|
|
||
| - name: Validate Output | ||
| shell: bash | ||
| run: | | ||
| set -euo pipefail | ||
| if [ ! -s THIRD_PARTY_LICENSES.json ]; then | ||
| echo "::error::THIRD_PARTY_LICENSES.json not created or is empty." | ||
| exit 1 | ||
| fi | ||
| python3 -c "import json; json.load(open('THIRD_PARTY_LICENSES.json'))" | ||
|
|
||
| - name: Upload THIRD_PARTY_LICENSES.json as artifact | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: third-party-license | ||
| path: THIRD_PARTY_LICENSES.json | ||
|
|
||
| - name: Upload as release asset | ||
| uses: actions/upload-release-asset@v1 | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| with: | ||
| upload_url: ${{ github.event.release.upload_url }} | ||
| asset_path: ./THIRD_PARTY_LICENSES.json | ||
| asset_name: THIRD_PARTY_LICENSES.json | ||
| asset_content_type: application/json | ||
|
|
||
| license-scan: | ||
| runs-on: ubuntu-latest | ||
| needs: generate | ||
| continue-on-error: true | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Download THIRD_PARTY_LICENSES.json | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| name: third-party-license | ||
| path: . | ||
|
|
||
| - name: Scan THIRD_PARTY_LICENSES.json for UNKNOWN / GPL/AGPL/LGPL | ||
| run: | | ||
| set -e | ||
|
|
||
| FILE="THIRD_PARTY_LICENSES.json" | ||
|
|
||
| if [ ! -f "$FILE" ]; then | ||
| echo "::error file=$FILE::File $FILE not found." | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Python-Skript: scans JSON, raise warning und creates summary table | ||
| python3 - << 'PY' | ||
|
Comment on lines
+176
to
+177
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If there is a solution to reference the branch/tag, maybe also make a python script out of this? |
||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| file_path = Path("THIRD_PARTY_LICENSES.json") | ||
| data = json.loads(file_path.read_text(encoding="utf-8")) | ||
|
|
||
| # Expected format: List of objects with Name, Author, License, URL | ||
| entries = data if isinstance(data, list) else [] | ||
|
|
||
| problem_entries = [] | ||
|
|
||
| for e in entries: | ||
| category = None | ||
| if any(["UNKNOWN" in val.upper() for val in e.values()]): | ||
| category = "UNKNOWN" | ||
| license_val = e.get("License", "") | ||
| if license_val: | ||
| lic = str(license_val).strip() | ||
| is_gpl_like = any( | ||
| x in lic.upper() | ||
| for x in ["GPL", "AGPL", "LGPL"] | ||
| ) | ||
| category = "GPL/AGPL/LGPL" if is_gpl_like else category | ||
| if category is not None: | ||
| name = e.get("Name", "") | ||
| author = e.get("Author", "") | ||
| url = e.get("URL", "") | ||
| license_file = e.get("LicenseFile", "") | ||
| license_text = e.get("LicenseText", "") | ||
|
|
||
| problem_entries.append({ | ||
| "Name": name, | ||
| "Author": author, | ||
| "License": lic, | ||
| "URL": url, | ||
| "Category": category, | ||
| }) | ||
|
|
||
| # Warn-Annotationen for each entry | ||
| for e in problem_entries: | ||
| msg = f"{e['Category']}: {e['Name']} {e['Author']} ({e['License']})" | ||
| print(f"::warning title=License-Scan::{msg}") | ||
| if problem_entries: | ||
| sys.exit(1) | ||
| PY | ||
Uh oh!
There was an error while loading. Please reload this page.