From 3f750d9063f959b9da3fd1be2a5b318ce0c6719b Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:34:28 -0500 Subject: [PATCH 1/7] build: replace cyclonedx CLI wrapper with library --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a377899..894494c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ [project.optional-dependencies] dev = [ "build>=1.2", - "cyclonedx-bom>=7", + "cyclonedx-python-lib>=11", "hypothesis>=6", "mypy>=1.10", "pip-audit>=2.7", From 34c8bf0cb49359c655065aa5ec64e0be563d7783 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:34:43 -0500 Subject: [PATCH 2/7] build: drop obsolete chardet constraint --- constraints/ci.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/constraints/ci.txt b/constraints/ci.txt index 6299f2c..70675a5 100644 --- a/constraints/ci.txt +++ b/constraints/ci.txt @@ -10,7 +10,6 @@ build==1.5.0 CacheControl==0.14.4 certifi==2026.6.17 cffi==2.1.0 -chardet==5.2.0 charset-normalizer==3.4.9 colorama==0.4.6 coverage==7.15.2 From 3041c1c9460f9dfa4582426a619857e40c66bb31 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:35:06 -0500 Subject: [PATCH 3/7] build: generate SBOM directly with CycloneDX library --- scripts/build_sbom.py | 131 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 15 deletions(-) diff --git a/scripts/build_sbom.py b/scripts/build_sbom.py index a60ca1f..a514f67 100644 --- a/scripts/build_sbom.py +++ b/scripts/build_sbom.py @@ -1,13 +1,25 @@ -"""Generate a CycloneDX environment SBOM and deterministic artifact checksums.""" +"""Generate a reproducible CycloneDX SBOM and deterministic artifact checksums.""" from __future__ import annotations import argparse import hashlib -import subprocess -import sys +import json +import tomllib +from importlib.metadata import version from pathlib import Path -from typing import Iterable +from typing import Any, Iterable + +from cyclonedx.model import Property +from cyclonedx.model.bom import Bom, BomMetaData +from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model.dependency import Dependency +from cyclonedx.model.license import DisjunctiveLicense, LicenseAcknowledgement +from cyclonedx.model.tool import ToolRepository +from cyclonedx.output.json import JsonV1Dot6 +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packageurl import PackageURL def sha256_file(path: Path) -> str: @@ -19,31 +31,120 @@ def sha256_file(path: Path) -> str: def write_checksums(paths: Iterable[Path], destination: Path) -> None: - lines = [f"{sha256_file(path)} {path.name}" for path in sorted(paths, key=lambda item: item.name)] + lines = [ + f"{sha256_file(path)} {path.name}" + for path in sorted(paths, key=lambda item: item.name) + ] destination.write_text("\n".join(lines) + "\n", encoding="utf-8") +def project_metadata(pyproject: Path) -> dict[str, Any]: + document = tomllib.loads(pyproject.read_text(encoding="utf-8")) + project = document.get("project") + if not isinstance(project, dict): + raise ValueError(f"missing [project] table in {pyproject}") + return project + + +def declared_licenses(project: dict[str, Any]) -> list[DisjunctiveLicense]: + declared = project.get("license") + if not isinstance(declared, str) or not declared: + return [] + return [ + DisjunctiveLicense( + id=declared, + acknowledgement=LicenseAcknowledgement.DECLARED, + ) + ] + + +def direct_dependency_components(project: dict[str, Any]) -> list[Component]: + raw_dependencies = project.get("dependencies", []) + if not isinstance(raw_dependencies, list): + raise ValueError("project.dependencies must be a list") + + components: list[Component] = [] + for index, raw in enumerate(raw_dependencies, start=1): + if not isinstance(raw, str): + raise ValueError("project.dependencies entries must be strings") + requirement = Requirement(raw) + components.append( + Component( + name=requirement.name, + type=ComponentType.LIBRARY, + bom_ref=f"requirements-L{index}", + description=f"project dependency: {raw}", + purl=PackageURL( + type="pypi", + name=canonicalize_name(requirement.name), + ), + ) + ) + return components + + +def build_bom(project: dict[str, Any]) -> Bom: + root = Component( + name=str(project["name"]), + version=str(project["version"]), + type=ComponentType.LIBRARY, + bom_ref="root-component", + description=str(project.get("description", "")) or None, + licenses=declared_licenses(project), + ) + components = direct_dependency_components(project) + dependency_nodes = [Dependency(component.bom_ref) for component in components] + tool = Component( + name="cyclonedx-python-lib", + group="CycloneDX", + version=version("cyclonedx-python-lib"), + type=ComponentType.LIBRARY, + bom_ref="tool-cyclonedx-python-lib", + purl=PackageURL(type="pypi", name="cyclonedx-python-lib"), + ) + metadata = BomMetaData( + component=root, + tools=ToolRepository(components=[tool]), + properties=[Property(name="cdx:reproducible", value="true")], + ) + return Bom( + components=components, + metadata=metadata, + dependencies=[ + *dependency_nodes, + Dependency(root.bom_ref, dependencies=dependency_nodes), + ], + ) + + +def write_reproducible_sbom(bom: Bom, destination: Path) -> None: + document = json.loads(JsonV1Dot6(bom).output_as_string()) + document.pop("serialNumber", None) + metadata = document.get("metadata") + if isinstance(metadata, dict): + metadata.pop("timestamp", None) + destination.write_text( + json.dumps(document, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("directory", nargs="?", default="dist", type=Path) args = parser.parse_args() directory = args.directory.resolve() artifacts = [ - path for path in directory.iterdir() + path + for path in directory.iterdir() if path.is_file() and (path.suffix == ".whl" or path.name.endswith(".tar.gz")) ] if not artifacts: parser.error(f"no wheel or sdist artifacts found in {directory}") + sbom = directory / "hardproof.cdx.json" - subprocess.run( - [ - sys.executable, "-m", "cyclonedx_py", "requirements", "-", - "--pyproject", str(Path("pyproject.toml").resolve()), - "--mc-type", "library", "--output-reproducible", - "--output-format", "JSON", "--output-file", str(sbom), - ], - input="PyYAML>=6,<7\n", text=True, check=True, - ) + project = project_metadata(Path("pyproject.toml").resolve()) + write_reproducible_sbom(build_bom(project), sbom) write_checksums([*artifacts, sbom], directory / "SHA256SUMS") print(f"SBOM: {sbom}") print(f"Checksums: {directory / 'SHA256SUMS'}") From 522a64309886e5e38388144e9f5114cf4ddd9504 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:35:25 -0500 Subject: [PATCH 4/7] build: remove unused CycloneDX CLI constraint --- constraints/ci.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/constraints/ci.txt b/constraints/ci.txt index 70675a5..161ec75 100644 --- a/constraints/ci.txt +++ b/constraints/ci.txt @@ -14,7 +14,6 @@ charset-normalizer==3.4.9 colorama==0.4.6 coverage==7.15.2 cryptography==49.0.0 -cyclonedx-bom==7.3.0 cyclonedx-python-lib==11.11.0 defusedxml==0.7.1 docutils==0.23 From 6144af334eb745646c7edc758db393d1550083fc Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:36:10 -0500 Subject: [PATCH 5/7] test: lock reproducible SBOM compatibility contract --- tests/unit/test_build_sbom.py | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/unit/test_build_sbom.py b/tests/unit/test_build_sbom.py index 13ca60e..74a356d 100644 --- a/tests/unit/test_build_sbom.py +++ b/tests/unit/test_build_sbom.py @@ -1,8 +1,28 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tomllib from pathlib import Path from scripts.build_sbom import sha256_file, write_checksums +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "build_sbom.py" + + +def run_generator(directory: Path) -> None: + subprocess.run( + [sys.executable, str(SCRIPT), str(directory)], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + + def test_checksum_manifest_is_sorted_and_reproducible(tmp_path: Path) -> None: (tmp_path / "b.whl").write_bytes(b"b") (tmp_path / "a.tar.gz").write_bytes(b"a") @@ -13,3 +33,52 @@ def test_checksum_manifest_is_sorted_and_reproducible(tmp_path: Path) -> None: assert destination.read_bytes() == first assert destination.read_text(encoding="utf-8").splitlines()[0].endswith(" a.tar.gz") assert sha256_file(tmp_path / "a.tar.gz") in destination.read_text(encoding="utf-8") + + +def test_sbom_generation_is_reproducible_and_preserves_project_contract( + tmp_path: Path, +) -> None: + directory = tmp_path / "dist" + directory.mkdir() + (directory / "hardproof-0.3.1-py3-none-any.whl").write_bytes(b"wheel") + (directory / "hardproof-0.3.1.tar.gz").write_bytes(b"sdist") + + run_generator(directory) + first_sbom = (directory / "hardproof.cdx.json").read_bytes() + first_checksums = (directory / "SHA256SUMS").read_bytes() + run_generator(directory) + + assert (directory / "hardproof.cdx.json").read_bytes() == first_sbom + assert (directory / "SHA256SUMS").read_bytes() == first_checksums + + document = json.loads(first_sbom) + assert document["bomFormat"] == "CycloneDX" + assert document["specVersion"] == "1.6" + assert "serialNumber" not in document + assert "timestamp" not in document["metadata"] + assert {item["name"] for item in document["metadata"]["properties"]} == { + "cdx:reproducible" + } + + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + root = document["metadata"]["component"] + assert root["name"] == project["name"] + assert root["version"] == project["version"] + assert {component["name"] for component in document["components"]} == {"PyYAML"} + + checksum_text = first_checksums.decode("utf-8") + assert "hardproof.cdx.json" in checksum_text + assert "hardproof-0.3.1-py3-none-any.whl" in checksum_text + assert "hardproof-0.3.1.tar.gz" in checksum_text + + +def test_sbom_toolchain_does_not_reintroduce_cyclonedx_cli_chardet_cap() -> None: + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + constraints = (ROOT / "constraints" / "ci.txt").read_text(encoding="utf-8") + + assert "cyclonedx-python-lib>=11" in pyproject + assert "cyclonedx-bom" not in pyproject + assert "cyclonedx-bom==" not in constraints + assert "chardet==" not in constraints From c42f4e0f01b35dc804861d7c0b7327bfc10dc415 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:38:54 -0500 Subject: [PATCH 6/7] ci: upload pytest failure reports --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 100c592..cb7ceb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,7 +100,13 @@ jobs: - name: Build (wheel contract tests need dist/) run: python -m build - name: Unit, integration, contract, and e2e tests - run: python -m pytest + run: python -m pytest --junitxml=pytest-results.xml + - name: Upload pytest failure report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: pytest-${{ matrix.os }}-py${{ matrix.python }} + path: pytest-results.xml package: name: Build, package-data, license, smoke, and pip-audit From 1520c1a98cd475d551f7eba587fb45fa42310c00 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Mon, 20 Jul 2026 15:40:01 -0500 Subject: [PATCH 7/7] test: declare jsonschema contract dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 894494c..24065b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "build>=1.2", "cyclonedx-python-lib>=11", "hypothesis>=6", + "jsonschema>=4", "mypy>=1.10", "pip-audit>=2.7", "pytest>=8",