From e19b03d2291b9b763fbdf07eeb782688abad31c9 Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Sat, 19 Sep 2026 09:10:38 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(build):=20add=20SBOM=20tim?= =?UTF-8?q?estamp=20and=20generator=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auditwheel and bocpy both hand-roll the same CycloneDX shape virtualenv does, and bocpy is the one precedent that adds metadata.timestamp and metadata.tools rather than leaving them out. Neither precedent puts build-environment details (OS, Python version, toolchain) anywhere in the document: that's already covered separately by the SLSA build provenance attestation the release workflow generates, so duplicating it here would blur the SBOM/attestation split and risk reproducibility if a runner's exact environment string ever varied between otherwise identical builds. metadata.timestamp is derived from hatchling's own get_reproducible_timestamp(), the same SOURCE_DATE_EPOCH-aware helper it uses for the wheel's own zip entries, so a reproducible build keeps producing a byte-identical SBOM. metadata.tools names hatch_build.py as the generator without a version number, since it isn't an independently versioned package and ships in lockstep with virtualenv itself. --- hatch_build.py | 14 ++++++++++++++ tasks/validate_sbom.py | 20 ++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/hatch_build.py b/hatch_build.py index eadf5f87e..873be010b 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -4,10 +4,12 @@ import json import tempfile import uuid +from datetime import datetime, timezone from pathlib import Path from typing import Any, Final from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.builders.utils import get_reproducible_timestamp _ROOT: Final[Path] = Path(__file__).resolve().parent _EMBED_INIT: Final[Path] = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py" @@ -71,6 +73,12 @@ def _cyclonedx_document(version: str, name: str) -> dict[str, Any]: "serialNumber": _serial_number(name, version, wheel_sha256), "version": 1, "metadata": { + "timestamp": _timestamp(), + "tools": { + "components": [ + {"type": "application", "name": "hatch_build.py", "vendor": "pypa"}, + ], + }, "component": { "type": "application", "name": name, @@ -104,3 +112,9 @@ def _serial_number(name: str, version: str, wheel_sha256: dict[str, str]) -> str # of the same commit against the same bundled wheels produce a byte-identical document payload = f"{name}@{version}+{','.join(f'{k}:{v}' for k, v in sorted(wheel_sha256.items()))}" return f"urn:uuid:{uuid.uuid5(_SBOM_NAMESPACE, payload)}" + + +def _timestamp() -> str: + # honors SOURCE_DATE_EPOCH through hatchling's own helper, the same value it uses for the wheel's zip entry + # timestamps, so setting it for a reproducible build keeps the SBOM byte-identical too + return datetime.fromtimestamp(get_reproducible_timestamp(), tz=timezone.utc).isoformat() diff --git a/tasks/validate_sbom.py b/tasks/validate_sbom.py index bf6f4d8f4..95efa5299 100644 --- a/tasks/validate_sbom.py +++ b/tasks/validate_sbom.py @@ -7,6 +7,7 @@ import sys import zipfile from pathlib import Path +from typing import Any _SERIAL_PATTERN = re.compile(r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") @@ -48,10 +49,26 @@ def validate(wheel: Path) -> list[str]: # the document as CycloneDX at all, and silently rejects anything missing it as an unknown format problems.append(f"serialNumber must match {_SERIAL_PATTERN.pattern}, got {serial!r}") - root_ref = document.get("metadata", {}).get("component", {}).get("bom-ref") + metadata_problems, root_ref = _validate_metadata(document.get("metadata", {})) + problems += metadata_problems + problems += _validate_dependency_graph(document, root_ref) + return problems + + +def _validate_metadata(metadata: dict[str, Any]) -> tuple[list[str], str | None]: + problems = [] + if not metadata.get("timestamp"): + problems.append("metadata.timestamp is missing") + if not metadata.get("tools", {}).get("components"): + problems.append("metadata.tools.components is missing or empty") + root_ref = metadata.get("component", {}).get("bom-ref") if not root_ref: problems.append("metadata.component.bom-ref is missing") + return problems, root_ref + +def _validate_dependency_graph(document: dict[str, Any], root_ref: str | None) -> list[str]: + problems = [] component_refs = {component.get("bom-ref") for component in document.get("components", [])} dependency_entries = { entry.get("ref"): set(entry.get("dependsOn", [])) for entry in document.get("dependencies", []) @@ -65,7 +82,6 @@ def validate(wheel: Path) -> list[str]: problems.append(f"no dependencies entry for the root component {root_ref!r}") elif root_depends_on != component_refs: problems.append(f"root dependsOn {sorted(root_depends_on)} does not match components {sorted(component_refs)}") - return problems From c6b09374e34ae20ffec9a90b66a2a9f32186d4c8 Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Sat, 19 Sep 2026 09:16:31 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(changelog):=20add=20fra?= =?UTF-8?q?gment=20for=20#3269?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/3269.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/3269.bugfix.rst diff --git a/docs/changelog/3269.bugfix.rst b/docs/changelog/3269.bugfix.rst new file mode 100644 index 000000000..6dbfc99a8 --- /dev/null +++ b/docs/changelog/3269.bugfix.rst @@ -0,0 +1,2 @@ +Add ``metadata.timestamp`` (derived from ``SOURCE_DATE_EPOCH`` for reproducibility) and ``metadata.tools`` naming the +generator to the embedded SBOM, and validate both fields in the packaging checks that already run on every pull request.