Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
71 changes: 71 additions & 0 deletions scripts/finalize_sbom.py
Original file line number Diff line number Diff line change
@@ -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 <sbom.json> [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 <sbom.json> [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()
Loading