From 8f284289929e5602f81c19754040a4b738eb4186 Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Mon, 20 Jul 2026 16:36:29 -0400 Subject: [PATCH 1/5] ci: add release pipeline that publishes GitHub Release with CycloneDX SBOM On every pushed v* tag, install the project into an isolated venv, generate a CycloneDX SBOM of its Python runtime environment with cyclonedx-py, and create a GitHub Release with the bom.json attached. --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5b56c8a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +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 + +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. + run: | + pipx run --spec cyclonedx-bom cyclonedx-py environment .venv/bin/python \ + --output-format JSON \ + --output-file bom.json + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes \ + bom.json From 9241171372c1a948a311c23a2c62d1162f63a929 Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Mon, 20 Jul 2026 16:40:41 -0400 Subject: [PATCH 2/5] ci: name SBOM asset aibom-generator-.json per release --- .github/workflows/release.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b56c8a..f2c7c40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ on: 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 @@ -35,13 +38,13 @@ jobs: run: | pipx run --spec cyclonedx-bom cyclonedx-py environment .venv/bin/python \ --output-format JSON \ - --output-file bom.json + --output-file "aibom-generator-${RELEASE}.json" - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" \ - --title "${GITHUB_REF_NAME}" \ + gh release create "${RELEASE}" \ + --title "${RELEASE}" \ --generate-notes \ - bom.json + "aibom-generator-${RELEASE}.json" From 19c7720c8300e6a6472d3564ab271a42592f835e Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Mon, 20 Jul 2026 16:41:29 -0400 Subject: [PATCH 3/5] ci: use .cdx.json extension for CycloneDX SBOM asset --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2c7c40..a5deee4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: run: | pipx run --spec cyclonedx-bom cyclonedx-py environment .venv/bin/python \ --output-format JSON \ - --output-file "aibom-generator-${RELEASE}.json" + --output-file "aibom-generator-${RELEASE}.cdx.json" - name: Create GitHub Release env: @@ -47,4 +47,4 @@ jobs: gh release create "${RELEASE}" \ --title "${RELEASE}" \ --generate-notes \ - "aibom-generator-${RELEASE}.json" + "aibom-generator-${RELEASE}.cdx.json" From 73431738f78fd61161b08b640d25b655bda5af27 Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Mon, 20 Jul 2026 16:45:02 -0400 Subject: [PATCH 4/5] ci: root SBOM dependency graph at project via --pyproject --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5deee4..6bccc21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,8 +35,15 @@ jobs: - 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" From 04ffe39f498faa125d33d4569ac0c7debabe662b Mon Sep 17 00:00:00 2001 From: Dmitry R Date: Mon, 20 Jul 2026 16:58:10 -0400 Subject: [PATCH 5/5] ci: set root component group, purl, supplier, manufacturer and validate SBOM --- .github/workflows/release.yml | 20 ++++++++++ scripts/finalize_sbom.py | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 scripts/finalize_sbom.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bccc21..6b05c2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,26 @@ jobs: --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 }} 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()