diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 07befb34d..02de64dd5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,6 +12,7 @@ jobs: contents: read id-token: write attestations: write + artifact-metadata: write steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,6 +29,13 @@ jobs: run: uv python install 3.14 - name: Build sdist and wheel run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist + - name: Extract the SBOM the wheel build embedded + run: python -m zipfile -e dist/*.whl sbom-extract/ && cp sbom-extract/*.dist-info/sboms/virtualenv.cdx.json . + - name: Attest the SBOM against the sdist and wheel + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/* + sbom-path: virtualenv.cdx.json - name: Build zipapp run: uv tool run --with tox-uv tox r -e zipapp - name: Attest provenance for the zipapp @@ -44,6 +52,11 @@ jobs: with: name: ${{ env.dists-artifact-name }} path: dist/* + - name: Store the SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: virtualenv-sbom + path: virtualenv.cdx.json - name: Store the zipapp uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/docs/changelog/3265.feature.rst b/docs/changelog/3265.feature.rst new file mode 100644 index 000000000..d2efc04bf --- /dev/null +++ b/docs/changelog/3265.feature.rst @@ -0,0 +1,3 @@ +Every published wheel now carries a `CycloneDX `_ SBOM at +``.dist-info/sboms/virtualenv.cdx.json`` (:PEP:`770`), declaring the bundled ``pip`` and ``setuptools`` wheels that +generic SBOM tools cannot see on their own. GitHub attests it against the released sdist and wheel. diff --git a/docs/development.rst b/docs/development.rst index 115fbea9e..b5abefe7b 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -235,6 +235,11 @@ virtualenv is distributed under the MIT License, and everything in the repositor and are redistributed unchanged. - Adding a runtime dependency or bumping an embedded wheel is a maintainer decision; checking the license of the new version is part of that review. +- Every wheel virtualenv publishes carries a `CycloneDX `_ SBOM at + ``.dist-info/sboms/virtualenv.cdx.json``, generated at build time by ``hatch_build.py`` from the same + ``BUNDLE_SUPPORT``/``BUNDLE_SHA256`` tables that back the embedded wheels above, so a wheel bump keeps it current + automatically. GitHub attests it against the release's sdist and wheel; verify with ``gh attestation verify -R + pypa/virtualenv --predicate-type https://cyclonedx.org/bom``. Automated testing ================= diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..9a5a690e8 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import ast +import json +import tempfile +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +_ROOT = Path(__file__).parent +_EMBED_INIT = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py" + + +class SbomBuildHook(BuildHookInterface): + """Write a PEP 770 CycloneDX SBOM into the wheel's ``.dist-info/sboms/`` directory. + + General-purpose SBOM scanners (syft, cyclonedx-py, GitHub's dependency graph) read declared dependency metadata or + an installed environment, and virtualenv's bundled pip and setuptools wheels are neither: they are data files + embedded under ``src/virtualenv/seed/wheels/embed/``, invisible to every one of those tools. Declaring them here, + read straight out of the ``BUNDLE_SUPPORT``/``BUNDLE_SHA256`` tables that ``tasks/upgrade_wheels.py`` already + maintains, means the SBOM tracks a wheel bump automatically instead of needing its own update step. + + """ + + PLUGIN_NAME = "sbom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: # ruff:ignore[unused-method-argument] + # `version` here is hatchling's build variant name (e.g. "standard"), not the package version + if self.target_name != "wheel" or "sbom_files" not in build_data: + # sbom_files only exists on hatchling>=1.28, which build-system.requires excludes for the + # Python versions old enough to still need hatchling<1.28 for building at all + return + document = _cyclonedx_document(self.metadata.version, self.metadata.core.name) + # written outside self.directory, which hatchling also uses as the final wheel output location, so a + # stray copy here would sit next to the built artifacts and trip `twine check dist/*` + out = Path(tempfile.mkdtemp(prefix="virtualenv-sbom-")) / "virtualenv.cdx.json" + out.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + build_data["sbom_files"].append(str(out)) + + +def _bundled_wheels() -> dict[str, str]: + tree = ast.parse(_EMBED_INIT.read_text(encoding="utf-8")) + sha256_by_name = {} + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "BUNDLE_SHA256" for target in node.targets + ): + sha256_by_name = ast.literal_eval(node.value) + break + if not sha256_by_name: + msg = f"BUNDLE_SHA256 not found in {_EMBED_INIT}" + raise RuntimeError(msg) + return sha256_by_name + + +def _cyclonedx_document(version: str, name: str) -> dict[str, Any]: + components = [] + dependencies = [{"ref": f"pkg:pypi/{name}@{version}", "dependsOn": []}] + for filename, sha256 in sorted(_bundled_wheels().items()): + distribution, wheel_version = filename.split("-")[:2] + purl = f"pkg:pypi/{distribution}@{wheel_version}" + components.append({ + "type": "library", + "name": distribution, + "version": wheel_version, + "purl": purl, + "bom-ref": purl, + "licenses": [{"license": {"id": "MIT"}}], + "hashes": [{"alg": "SHA-256", "content": sha256}], + "externalReferences": [ + {"type": "distribution", "url": f"https://pypi.org/project/{distribution}/{wheel_version}/"}, + ], + "properties": [ + {"name": "virtualenv:bundled-wheel", "value": f"src/virtualenv/seed/wheels/embed/{filename}"}, + ], + }) + dependencies[0]["dependsOn"].append(purl) + dependencies.append({"ref": purl, "dependsOn": []}) + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": name, + "version": version, + "purl": f"pkg:pypi/{name}@{version}", + "bom-ref": f"pkg:pypi/{name}@{version}", + }, + }, + "components": components, + "dependencies": dependencies, + } diff --git a/pyproject.toml b/pyproject.toml index ff34d9e67..b729a9d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,8 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.4", - "hatchling>=1.27", + "hatchling>=1.27,<1.28; python_version<'3.10'", + "hatchling>=1.28; python_version>='3.10'", ] [project] @@ -159,11 +160,13 @@ property = [ [tool.hatch] version.source = "vcs" build.hooks.vcs.version-file = "src/virtualenv/version.py" +build.hooks.custom.path = "hatch_build.py" build.targets.sdist.include = [ "/src", "/tasks", "/tests", "/tox.toml", + "/hatch_build.py", ] [tool.ruff]