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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,22 @@ jobs:
pip install --no-deps --no-build-isolation -e .
pytest -q

# An SBOM of the wheel, generated the same way the release generates it, so a change that
# would ship a wrong one goes red on the pull request rather than at the tag. The
# assertions live in `tests/test_sbom.py`; this is the half that proves the script runs in
# a clean checkout with only its own pinned requirements installed.
- name: The SBOM of the wheel
run: |
pip install --require-hashes -r requirements/sbom.txt
./scripts/sbom.sh dist/*.whl /tmp/ctrlrun.cdx.json
python -c "
import json, sys
bom = json.load(open('/tmp/ctrlrun.cdx.json'))
names = sorted(c['name'] for c in bom.get('components', []))
assert names == ['PyYAML', 'click'], names
print('sbom:', bom['metadata']['component']['name'], bom['specVersion'], names)
"

- name: The quick start works from the wheel
run: |
python -m venv /tmp/qs
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ jobs:
python -m build --no-isolation
python scripts/normalize_sdist.py dist/*.tar.gz

# The bill of materials for the wheel this release carries, written into `dist/` so the
# `gh release create dist/*` below attaches it. Generated from the built wheel in an empty
# environment rather than from `pyproject.toml`: a manifest-derived SBOM is the project's
# opinion of its own dependencies, and this one is a measurement of the bytes being
# released. It is attested along with everything else in `dist/`, because an unsigned
# inventory of a signed artifact is the weakest link in the pair.
- name: The bill of materials
if: steps.existing.outputs.exists == 'false'
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
pip install --require-hashes -r requirements/sbom.txt
./scripts/sbom.sh dist/*.whl "dist/ctrlrun-$VERSION.cdx.json"

# Signed provenance for the artifacts this release is about to carry. The action signs
# against the workflow's OIDC identity, so what it attests is *this* workflow, at *this*
# commit, having built *these* bytes -- there is no key anywhere, and nothing to leak or
Expand Down
4 changes: 4 additions & 0 deletions requirements/in/sbom.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Generating the CycloneDX SBOM for a built wheel: CI's `package` job and publish.yml.
# Its own lock rather than a line in `build.in`, because the SBOM is generated in a clean
# environment and nothing else in that step should be resolvable from it.
cyclonedx-bom
441 changes: 441 additions & 0 deletions requirements/sbom.txt

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ compile requirements/adapters.txt pyproject.toml $extras requirements/in/adapte
compile requirements/docs.txt pyproject.toml $extras --extra postgres requirements/in/docs.in requirements/in/backend.in
compile requirements/fuzz.txt pyproject.toml requirements/in/backend.in
compile requirements/build.txt requirements/in/build.in requirements/in/backend.in
compile requirements/sbom.txt requirements/in/sbom.in requirements/in/backend.in
compile requirements/atheris.txt requirements/in/atheris.in requirements/in/backend.in
47 changes: 47 additions & 0 deletions scripts/sbom.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/sh
# SPDX-FileCopyrightText: 2026 The CTRLRun contributors
# SPDX-License-Identifier: Apache-2.0
# An SBOM of the distribution this repository ships, measured from the built wheel.
#
# Usage: scripts/sbom.sh <wheel> <output.cdx.json>
#
# **From the artifact, not from the manifest.** `pyproject.toml` says what the package
# *declares*; this installs the wheel into an empty environment and records what actually
# resolves, which is the same discipline release verification follows for everything else here:
# verify the thing you ship. A manifest-derived SBOM would be this repository's opinion of its
# own dependencies, and the whole point of the document is to be checkable against reality.
#
# **The seed packages are uninstalled before the scan, and that is not cosmetic.**
# `python -m venv` puts pip in the environment, and on some versions setuptools and wheel too. A
# scanner reading that environment cannot tell the difference between "ctrlrun needs this" and
# "the venv came with this". An SBOM listing them as dependencies of ctrlrun is wrong in the
# direction that matters: it overstates what a consumer is taking on, and nothing about a padded
# SBOM looks broken.
#
# The list is three because a measurement said so, not because three felt safe. The first
# version removed only pip, which is all Python 3.12 seeds, and it passed locally and went red in
# CI on 3.11 with `['PyYAML', 'click', 'setuptools']`. That is the guard working: the assertion
# in `test_sbom.py` and in the `package` job compares by **equality**, so a future interpreter
# that seeds something else fails loudly rather than shipping a document nobody can trust.
set -eu

wheel="$1"
out="$2"
root="$(cd "$(dirname "$0")/.." && pwd)"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT

"${PYTHON:-python3}" -m venv "$scratch/venv"
"$scratch/venv/bin/pip" install --quiet --no-cache-dir "$wheel"
# `pip` last: it cannot uninstall the others once it has removed itself.
"$scratch/venv/bin/pip" uninstall --yes --quiet setuptools wheel 2>/dev/null || true
"$scratch/venv/bin/pip" uninstall --yes --quiet pip

cyclonedx-py environment "$scratch/venv" \
--of JSON \
--output-reproducible \
--pyproject "$root/pyproject.toml" \
--mc-type library \
-o "$out"

printf 'sbom: %s\n' "$out"
118 changes: 118 additions & 0 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# SPDX-FileCopyrightText: 2026 The CTRLRun contributors
# SPDX-License-Identifier: Apache-2.0
"""The SBOM says what the wheel actually carries, and stays wrong-proof.

An SBOM is not a claim, which is why it is here at all: `ROADMAP.md`'s standards rule is
*integrate first, map second, never claim compliance*, and a bill of materials asserts no
conformance with anything. It is a measurement of one artifact.

**Which is exactly why a wrong one is worse than none.** A consumer reads it to decide what they
are taking on, and the failure mode is silent: nothing about a stale or padded SBOM looks broken.
`scripts/sbom.sh` measures the built wheel in an empty environment rather than reading
`pyproject.toml`, so the document cannot drift from the manifest without drifting from reality
first, and this file pins the result.

The environment is the subtle part. `python -m venv` seeds pip, and a scanner reading that
environment cannot tell "ctrlrun needs this" from "the venv came with this". The script
uninstalls pip before scanning; the assertion below is what stops that from rotting quietly if a
future Python seeds something else, because the failure would otherwise be an SBOM that
overstates the dependency surface and a suite that stays green.
"""

from __future__ import annotations

import json
import shutil
import subprocess
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts" / "sbom.sh"

#: What `pyproject.toml` declares, and `test_core_declares_only_pyyaml_and_click` pins from the
#: other side. Names as the installed distributions spell them, which is not always how the
#: requirement does: PyPI serves `pyyaml` and the distribution calls itself `PyYAML`.
EXPECTED = {"PyYAML", "click"}


@pytest.fixture(scope="module")
def sbom(tmp_path_factory) -> dict:
"""One wheel, built once, scanned once. The slow part is the build, not the scan."""
if not SCRIPT.exists(): # pragma: no cover - sdist prunes scripts/
pytest.skip("scripts/ is not in this distribution")
if shutil.which("cyclonedx-py") is None:
pytest.skip("cyclonedx-py is not installed; it is in requirements/sbom.txt")

workspace = tmp_path_factory.mktemp("sbom")
build = subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--outdir", str(workspace)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
if build.returncode != 0: # pragma: no cover - `build` is in the dev extra
pytest.skip(f"could not build a wheel: {build.stderr[-400:]}")

wheel = next(workspace.glob("*.whl"))
out = workspace / "sbom.cdx.json"
generated = subprocess.run(
[str(SCRIPT), str(wheel), str(out)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert generated.returncode == 0, f"{generated.stdout}\n{generated.stderr}"
return json.loads(out.read_text(encoding="utf-8"))


def test_the_sbom_lists_the_two_runtime_dependencies_and_nothing_else(sbom) -> None:
"""The measurement, and the reason the script uninstalls pip.

Asserting equality rather than containment is deliberate. `>=` would pass an SBOM that had
quietly grown `pip`, `setuptools` or a transitive dependency nobody chose, which is the
failure this document exists to make visible.
"""
found = {component["name"] for component in sbom.get("components", [])}

assert found == EXPECTED, (
f"the SBOM lists {sorted(found)}; the wheel declares {sorted(EXPECTED)}. "
"Extra names usually mean the scanned environment was not empty."
)


def test_the_sbom_names_this_distribution_as_its_root(sbom) -> None:
"""A bill of materials for nothing in particular is not one. The root carries the name, the
version and the licence a consumer is actually taking on."""
root = sbom["metadata"]["component"]

assert root["name"] == "ctrlrun"
assert root["type"] == "library"
assert root["version"], "the root component carries no version"
declared = {
entry["license"].get("id") for entry in root.get("licenses", []) if "license" in entry
}
assert "Apache-2.0" in declared, f"root licences are {declared}"


def test_the_sbom_is_cyclonedx_and_says_which_version(sbom) -> None:
"""A consumer's scanner dispatches on these two fields. Without them the file is JSON that
happens to look like an SBOM."""
assert sbom["bomFormat"] == "CycloneDX"
assert sbom["specVersion"].startswith("1."), sbom["specVersion"]


def test_every_component_carries_a_version_and_a_purl(sbom) -> None:
"""The two fields that make a component resolvable. A name alone does not identify what was
installed, and a vulnerability scanner matching on names alone is guessing."""
thin = [
component["name"]
for component in sbom.get("components", [])
if not component.get("version") or not component.get("purl")
]

assert thin == [], f"components with no version or no purl: {thin}"