feat(release): generate reproducible CycloneDX SBOM
This commit is contained in:
112
tools/release/generate-release-sbom.py
Normal file
112
tools/release/generate-release-sbom.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user