diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..823e027 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Release + +# Publish a GitHub Release whenever a version tag is pushed. The release +# includes the built Python distribution artifacts (wheel + sdist) and a +# CycloneDX SBOM of the project's Python runtime environment, with the +# artifacts' hashes recorded in the SBOM. +# +# 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: Verify tag matches pyproject.toml version + # Fail fast if the release tag and the packaged version disagree, so + # the built artifacts can never be named for a different version than + # the release they ship in. + run: | + python - <<'PY' + import os, sys, tomllib + release = os.environ["RELEASE"] + version = release[1:] if release[:1] in "vV" and release[1:2].isdigit() else release + with open("pyproject.toml", "rb") as fh: + project_version = tomllib.load(fh)["project"]["version"] + if version != project_version: + print(f"::error::Release tag '{release}' (version '{version}') does not match " + f"pyproject.toml version '{project_version}'. Bump pyproject.toml before tagging.") + sys.exit(1) + print(f"Tag '{release}' matches pyproject.toml version '{project_version}'.") + PY + + - name: Install project into an isolated environment + run: | + python -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install . + + - name: Build release artifacts (wheel + sdist) + run: | + python -m pip install --upgrade build + python -m build --outdir dist + ls -l dist + + - 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 and record artifact hashes + # Set the root component's group, PURL namespace, supplier and + # manufacturer to the owning organization, align its version with the + # release tag, and record each built artifact (with SHA-256/SHA-512 + # hashes) as a `distribution` external reference on the root component. + run: | + python scripts/finalize_sbom.py "aibom-generator-${RELEASE}.cdx.json" \ + --version "${RELEASE}" \ + --download-base-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${RELEASE}" \ + $(for f in dist/*; do echo --artifact "$f"; done) + + - 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" \ + dist/* diff --git a/scripts/finalize_sbom.py b/scripts/finalize_sbom.py new file mode 100644 index 0000000..c3f92bf --- /dev/null +++ b/scripts/finalize_sbom.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Finalize the release SBOM's root component metadata and record artifact hashes. + +`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). + +It also records the produced release artifacts (wheel / sdist) on the root +component as `distribution` external references, each carrying the artifact's +SHA-256 and SHA-512 hashes, so the SBOM captures exactly what was shipped. + +Usage: + python scripts/finalize_sbom.py \ + [--version ] \ + [--download-base-url ] \ + [--artifact ...] +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os + +GROUP = "GenAI-Security-Project" +ORG_URL = "https://github.com/GenAI-Security-Project" +REPO_URL = "https://github.com/GenAI-Security-Project/aibom-generator" + +# (CycloneDX hash alg name, hashlib constructor name) +HASH_ALGS = (("SHA-256", "sha256"), ("SHA-512", "sha512")) + + +def _normalize_version(version: str) -> str: + # Release tags look like "v1.0.3"; store clean semver "1.0.3". + if len(version) > 1 and version[0] in "vV" and version[1].isdigit(): + return version[1:] + return version + + +def _file_hashes(path: str) -> list[dict]: + hashers = {alg: hashlib.new(constructor) for alg, constructor in HASH_ALGS} + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + for h in hashers.values(): + h.update(chunk) + return [{"alg": alg, "content": hashers[alg].hexdigest()} for alg, _ in HASH_ALGS] + + +def finalize( + bom: dict, + version_override: str | None = None, + artifacts: list[str] | None = None, + download_base_url: str | None = None, +) -> dict: + component = bom.setdefault("metadata", {}).setdefault("component", {}) + + name = component.get("name") or "owasp-aibom-generator" + if version_override: + component["version"] = _normalize_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) + + # Record each produced release artifact with its hashes. + for artifact in artifacts or []: + filename = os.path.basename(artifact) + if download_base_url: + url = f"{download_base_url.rstrip('/')}/{filename}" + else: + url = filename + ref = { + "type": "distribution", + "url": url, + "comment": f"Release artifact: {filename}", + "hashes": _file_hashes(artifact), + } + component.setdefault("externalReferences", []).append(ref) + + return bom + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sbom", help="Path to the CycloneDX JSON SBOM to finalize.") + parser.add_argument("--version", help="Release version/tag for the root component.") + parser.add_argument( + "--artifact", + action="append", + default=[], + help="Path to a produced release artifact (repeatable).", + ) + parser.add_argument( + "--download-base-url", + help="Base URL the artifacts will be downloadable from (e.g. the release assets URL).", + ) + args = parser.parse_args() + + with open(args.sbom, encoding="utf-8") as fh: + bom = json.load(fh) + + finalize(bom, args.version, args.artifact, args.download_base_url) + + with open(args.sbom, "w", encoding="utf-8") as fh: + json.dump(bom, fh, indent=2) + fh.write("\n") + + +if __name__ == "__main__": + main()