Files
govoplan/tools/release/generate-release-sbom.py

113 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""Generate a CycloneDX SBOM from the resolved GovOPlaN release environment."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import tomllib
META_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
from govoplan_release.sbom import ( # noqa: E402
build_release_sbom,
parse_sbom_timestamp,
render_sbom,
sbom_sha256,
timestamp_from_source_date_epoch,
validate_sbom,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--python", default=sys.executable, help="Python executable for pip inspect.")
parser.add_argument("--core-root", type=Path, default=META_ROOT.parent / "govoplan-core")
parser.add_argument(
"--output",
type=Path,
default=META_ROOT / "runtime" / "release-artifacts" / "govoplan-sbom.cdx.json",
)
parser.add_argument("--pip-inspect", type=Path, help="Use an existing pip inspect report instead of executing pip.")
parser.add_argument(
"--timestamp",
help="ISO-8601 build timestamp. Defaults to SOURCE_DATE_EPOCH, then the current time.",
)
args = parser.parse_args()
core_root = args.core_root.expanduser().resolve()
product_version = _project_version(core_root / "pyproject.toml")
npm_lock_path = core_root / "webui" / "package-lock.release.json"
if not npm_lock_path.exists():
parser.error(f"release npm lockfile does not exist: {npm_lock_path}")
pip_inspect = _read_json(args.pip_inspect) if args.pip_inspect else _run_pip_inspect(args.python)
npm_lock = _read_json(npm_lock_path)
try:
timestamp = _resolved_timestamp(args.timestamp)
payload = build_release_sbom(
product_version=product_version,
pip_inspect=pip_inspect,
npm_lock=npm_lock,
timestamp=timestamp,
)
except ValueError as exc:
parser.error(str(exc))
validate_sbom(payload)
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_sbom(payload), encoding="utf-8")
print(f"Wrote {len(payload['components'])} components to {output}")
print(f"SHA-256: {sbom_sha256(payload)}")
return 0
def _run_pip_inspect(python: str) -> dict:
result = subprocess.run(
[python, "-m", "pip", "inspect", "--local"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=120,
)
if result.returncode != 0:
raise SystemExit(f"pip inspect failed: {result.stderr.strip()}")
return json.loads(result.stdout)
def _read_json(path: Path) -> dict:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"expected a JSON object in {path}")
return payload
def _project_version(path: Path) -> str:
with path.open("rb") as handle:
payload = tomllib.load(handle)
project = payload.get("project")
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
raise ValueError(f"project.version is missing from {path}")
return project["version"]
def _resolved_timestamp(explicit: str | None):
if explicit is not None:
return parse_sbom_timestamp(explicit)
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
if source_date_epoch is not None:
return timestamp_from_source_date_epoch(source_date_epoch)
return None
if __name__ == "__main__":
raise SystemExit(main())