diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b05c2e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +# Publish a GitHub Release whenever a version tag is pushed, and attach a +# CycloneDX SBOM of the project's Python runtime environment. +# +# git tag v1.0.3 +# git push origin v1.0.3 +on: + push: + tags: + - "v*" + +permissions: + contents: write # required to create a release and upload assets + +env: + RELEASE: ${{ github.ref_name }} # the pushed tag, e.g. v1.0.3 + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install project into an isolated environment + run: | + python -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install . + + - name: Generate Python SBOM (CycloneDX) + # cyclonedx-py runs isolated via pipx so the SBOM reflects only the + # project's runtime dependencies, not the SBOM tool itself. + # + # `environment` introspects the installed venv, so the full transitive + # dependency closure is captured automatically, and the CycloneDX + # `dependencies` graph records the transitive paths between components. + # `--pyproject` roots that graph at the project itself, so its direct + # deps (and everything reachable from them) are traceable. + run: | + pipx run --spec cyclonedx-bom cyclonedx-py environment .venv/bin/python \ + --pyproject pyproject.toml \ + --output-format JSON \ + --output-file "aibom-generator-${RELEASE}.cdx.json" + + - name: Finalize root component metadata + # Set the root component's group, PURL namespace, supplier and + # manufacturer to the owning organization, and align its version with + # the release tag. cyclonedx-py cannot set these from pyproject alone. + run: | + python scripts/finalize_sbom.py "aibom-generator-${RELEASE}.cdx.json" "${RELEASE}" + + - name: Validate SBOM against CycloneDX schema + run: | + .venv/bin/python - "aibom-generator-${RELEASE}.cdx.json" <<'PY' + import sys + from cyclonedx.validation.json import JsonStrictValidator + from cyclonedx.schema import SchemaVersion + data = open(sys.argv[1], encoding="utf-8").read() + error = JsonStrictValidator(SchemaVersion.V1_6).validate_str(data) + if error is not None: + raise SystemExit(f"SBOM failed CycloneDX 1.6 validation: {error}") + print("SBOM is valid against CycloneDX 1.6") + PY + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${RELEASE}" \ + --title "${RELEASE}" \ + --generate-notes \ + "aibom-generator-${RELEASE}.cdx.json" diff --git a/scripts/finalize_sbom.py b/scripts/finalize_sbom.py new file mode 100644 index 0000000..cafc87c --- /dev/null +++ b/scripts/finalize_sbom.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Finalize the release SBOM's root component metadata. + +`cyclonedx-py environment --pyproject` populates the root component's name, +version, type, description and license, but not its group, PURL, supplier or +manufacturer. This script fills those in so the released SBOM clearly +identifies the project and its owning organization (GenAI-Security-Project). + +Usage: + python scripts/finalize_sbom.py [version] + +If ``version`` is given (e.g. the release tag) it overrides the root +component's version and is reflected in the PURL. +""" +from __future__ import annotations + +import json +import sys + +GROUP = "GenAI-Security-Project" +ORG_URL = "https://github.com/GenAI-Security-Project" +REPO_URL = "https://github.com/GenAI-Security-Project/aibom-generator" + + +def finalize(bom: dict, version_override: str | None = None) -> dict: + component = bom.setdefault("metadata", {}).setdefault("component", {}) + + name = component.get("name") or "owasp-aibom-generator" + if version_override: + # Release tags look like "v1.0.3"; store clean semver "1.0.3". + if len(version_override) > 1 and version_override[0] in "vV" and version_override[1].isdigit(): + version_override = version_override[1:] + component["version"] = version_override + version = component.get("version") + + # Root component identity. + component["type"] = "application" + component["group"] = GROUP + + # PURL with the organization as the namespace/group. + purl = f"pkg:pypi/{GROUP}/{name}" + if version: + purl = f"{purl}@{version}" + component["purl"] = purl + + # Who made it (manufacturer) and who distributes it (supplier). + org = {"name": GROUP, "url": [ORG_URL, REPO_URL]} + component["manufacturer"] = dict(org) + component["supplier"] = dict(org) + + return bom + + +def main() -> None: + if len(sys.argv) < 2: + raise SystemExit("usage: finalize_sbom.py [version]") + path = sys.argv[1] + version = sys.argv[2] if len(sys.argv) > 2 else None + + with open(path, encoding="utf-8") as fh: + bom = json.load(fh) + + finalize(bom, version) + + with open(path, "w", encoding="utf-8") as fh: + json.dump(bom, fh, indent=2) + fh.write("\n") + + +if __name__ == "__main__": + main()