Skip to content
Merged
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
108 changes: 108 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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/*
123 changes: 123 additions & 0 deletions scripts/finalize_sbom.py
Original file line number Diff line number Diff line change
@@ -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 <sbom.json> \
[--version <tag>] \
[--download-base-url <url>] \
[--artifact <file> ...]
"""
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()
Loading