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
13 changes: 13 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog/3265.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Every published wheel now carries a `CycloneDX <https://cyclonedx.org/>`_ 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.
5 changes: 5 additions & 0 deletions docs/development.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://cyclonedx.org/>`_ 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 <file> -R
pypa/virtualenv --predicate-type https://cyclonedx.org/bom``.

Automated testing
=================
Expand Down
95 changes: 95 additions & 0 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -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,
}
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down