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]