From d25aa30a440731f1ebe6be31134e6fc2e3da82bd Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:14:41 -0400 Subject: [PATCH 1/3] Inventory what ships, and flag the licences that need a decision A build now emits a CycloneDX 1.6 SBOM, a third-party notices file and a pinned requirements list, checksummed alongside the bundle and the installer. The set is the runtime dependency closure of the installed package, which is deliberately not the build environment. The release inventory already records every distribution present, and on a developer machine that includes pytest and ruff: right for reproducing a build, wrong as a statement about what is distributed. The closure is resolved from package metadata with the gui extra included and dev excluded, and a package that is required but not installed is an error rather than a silent omission, because a bill of materials that quietly drops what it could not resolve hides the gap it exists to show. Each component records which metadata field its licence came from. The three fields do not carry equal weight: a PEP 639 License-Expression is precise, a classifier's "BSD License" is approximate, and the free-text field is sometimes a paragraph. Prose is marked as loose rather than truncated into something that resembles an SPDX identifier, and only a real expression is emitted as a CycloneDX expression rather than a bare name. Components whose licences carry redistribution conditions beyond attribution are flagged, and the generator lists them on stderr. Qt ships under LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only while Offloader is MIT, so PySide6, PySide6_Essentials, PySide6_Addons and shiboken6 are all flagged. That is a prompt and not a verdict: what those terms require of a frozen bundle is a decision for a person, and this makes it impossible to miss rather than answering it. The notices file says in as many words that it is not legal advice. The SBOM's serial number is derived from its contents, so the same inputs produce the same document and two of them can be diffed to see what actually moved. A random serial would differ on every rebuild in a field nobody meant to compare. The writer and the build's own output validation name these files in one place, since validate_outputs refuses any checksummed artifact it did not expect and the two disagreeing would fail a signed build at its own verification step. The source archive check gains sbom.py too: a release built from an sdist without it could not produce its own bill of materials. --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 6 + CHANGELOG.md | 25 +++ build/windows/build.py | 20 ++- build/windows/sbom.py | 328 ++++++++++++++++++++++++++++++++++ docs/build-windows.md | 43 +++++ docs/release-plan.md | 22 ++- tests/test_sbom.py | 283 +++++++++++++++++++++++++++++ 8 files changed, 721 insertions(+), 11 deletions(-) create mode 100644 build/windows/sbom.py create mode 100644 tests/test_sbom.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 783dfae..b01f4d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,7 @@ jobs: python -c "import glob, tarfile; names = tarfile.open(glob.glob('dist/*.tar.gz')[0]).getnames(); assert all(any(n.endswith('/build/windows/' + f) for n in names) - for f in ('build.py', 'sign.py', 'offloader.spec', 'installer.nsi'))" + for f in ('build.py', 'sign.py', 'sbom.py', 'offloader.spec', 'installer.nsi'))" - name: Install and check the built wheel outside the checkout run: python scripts/check_wheel.py dist/*.whl - uses: actions/upload-artifact@v4 @@ -156,5 +156,8 @@ jobs: dist/windows/Offloader-*.exe dist/windows/Offloader-*.zip dist/windows/Offloader-*-inventory.json + dist/windows/Offloader-*-sbom.cyclonedx.json + dist/windows/Offloader-*-third-party-notices.txt + dist/windows/Offloader-*-requirements.txt dist/windows/SHA256SUMS.txt if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a03eac..792b3e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,9 @@ jobs: for name in "Offloader-Setup-${version}.exe" \ "Offloader-${version}-windows-x64.zip" \ "Offloader-${version}-inventory.json" \ + "Offloader-${version}-sbom.cyclonedx.json" \ + "Offloader-${version}-third-party-notices.txt" \ + "Offloader-${version}-requirements.txt" \ "SHA256SUMS.txt"; do test -f "$name" || { echo "missing artifact: $name"; exit 1; } done @@ -87,6 +90,9 @@ jobs: dist/windows/Offloader-*.exe dist/windows/Offloader-*.zip dist/windows/Offloader-*-inventory.json + dist/windows/Offloader-*-sbom.cyclonedx.json + dist/windows/Offloader-*-third-party-notices.txt + dist/windows/Offloader-*-requirements.txt dist/windows/SHA256SUMS.txt if-no-files-found: error retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ebae2..4807735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,31 @@ project uses [semantic versioning][semver]. ### Added +- **A bill of materials, third-party notices and a pinned lockfile.** Every + build emits a CycloneDX 1.6 SBOM, a human-readable notices inventory and a + pinned requirements file, checksummed with the other release outputs. + + The set is the runtime dependency closure of the installed package, not the + build environment: the release inventory already records every distribution + present, which on a developer machine includes pytest and ruff. Right for + reproducing a build, wrong as a statement about what ships. A package that + is required but not installed is an error rather than a silent omission. + + Each component records **which metadata field its licence came from**, since + a PEP 639 `License-Expression` is a precise claim and a classifier's "BSD + License" is not. Prose in the free-text field is marked as loose rather than + truncated into something resembling an SPDX identifier, and only a real + expression is emitted as CycloneDX `expression`. The serial number is derived + from the contents, so the same inputs produce the same document and two SBOMs + can be diffed. + + Licences with redistribution conditions beyond attribution are flagged for + review and listed on stderr. **Qt ships under `LGPL-3.0-only OR GPL-2.0-only + OR GPL-3.0-only` while Offloader is MIT**, so all four PySide6/shiboken6 + packages are flagged. That is deliberately a prompt and not a verdict: what + those terms require of a frozen bundle is a decision for a person, and the + tool's job is to make it impossible to miss. + - **The desktop app checks for updates, and declines while a job is running.** One check a couple of seconds after the window opens, saying nothing unless there is something to say, with the release named in the header and diff --git a/build/windows/build.py b/build/windows/build.py index 3b2ff8d..5c2c9c2 100644 --- a/build/windows/build.py +++ b/build/windows/build.py @@ -57,8 +57,23 @@ def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict] return records +def sbom_names(version: str) -> set[str]: + """The bill-of-materials files a release carries. + + Named in one place because `save_outputs` checksums them and + `validate_outputs` refuses anything it did not expect, so the two have to + agree or a build fails at its own verification step. + """ + return { + f"Offloader-{version}-sbom.cyclonedx.json", + f"Offloader-{version}-third-party-notices.txt", + f"Offloader-{version}-requirements.txt", + } + + def save_outputs(bundle: Path, setup: Path | None, identity: dict, signatures: list[dict], signed: bool) -> None: + import sbom from artifacts import bundle_inventory version = identity["version"] @@ -77,7 +92,7 @@ def save_outputs(bundle: Path, setup: Path | None, identity: dict, for path in sorted(bundle.rglob("*")): if path.is_file(): output.write(path, f"Offloader/{path.relative_to(bundle).as_posix()}") - outputs = [archive, inventory] + outputs = [archive, inventory, *sbom.write_all(DIST, identity)] if setup is not None: outputs.append(setup) lines = [] @@ -101,7 +116,8 @@ def validate_outputs(bundle: Path, setup: Path | None, identity: dict) -> None: raise RuntimeError("Output inventory is unsigned or belongs to different sources") if record.get("files") != bundle_inventory(bundle): raise RuntimeError("Output inventory no longer matches the bundle") - expected = {inventory.name, f"Offloader-{version}-windows-x64.zip"} + expected = {inventory.name, f"Offloader-{version}-windows-x64.zip", + *sbom_names(version)} if setup is not None: expected.add(setup.name) checksums = {} diff --git a/build/windows/sbom.py b/build/windows/sbom.py new file mode 100644 index 0000000..78ce303 --- /dev/null +++ b/build/windows/sbom.py @@ -0,0 +1,328 @@ +"""Bill of materials and third-party licences for a release. + +Three files come out of this, all describing the same set of packages: + +* a CycloneDX 1.6 SBOM, for anything that consumes one automatically; +* a human-readable notices file, because distributing these packages means + distributing their licence texts; +* a pinned requirements list, so the set can be reproduced. + +The set is the **runtime dependency closure of the installed package**, not a +dump of the build environment. `build.py`'s inventory already records every +distribution present, which on a developer machine includes pytest and ruff: +useful for reproducing a build, wrong as a statement about what ships. + +This inventories and flags. It does not decide whether a licence is +acceptable, and it must not be read as saying so. PySide6 alone is offered +under `LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only` while Offloader is MIT, +which is a decision for a person: the flags exist so that decision is taken +deliberately rather than by not noticing. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import re +import sys +import uuid +from dataclasses import dataclass +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent + +#: The extras whose dependencies are actually bundled. `dev` is not one. +BUNDLED_EXTRAS = ("gui",) + +#: Licence families that place conditions on redistribution beyond notice. +#: Not a verdict, a prompt: each of these needs a human to say what applies. +REVIEW_FAMILIES = ("GPL", "AGPL", "LGPL", "MPL", "EPL", "CDDL", "CC-BY-SA", + "SSPL", "BUSL", "Proprietary") + +#: A CycloneDX namespace, so the same inputs produce the same serial number. +#: A random one would make every rebuild differ in a field nobody compares on +#: purpose, which defeats diffing two SBOMs to see what moved. +_NAMESPACE = uuid.UUID("2f8b6f34-3c3e-5f1a-9f3a-0b3f6a1c9d42") + + +def normalize(name: str) -> str: + """PEP 503 normalisation, so `PySide6` and `pyside6` are one package.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +@dataclass(frozen=True, order=True) +class Component: + """One package that ships, and where its licence claim came from.""" + + name: str + version: str + licence: str + source: str + + @property + def purl(self) -> str: + return f"pkg:pypi/{normalize(self.name)}@{self.version}" + + @property + def needs_review(self) -> bool: + upper = self.licence.upper() + return any(family in upper for family in REVIEW_FAMILIES) + + +def _marker_ok(requirement, extras: tuple[str, ...]) -> bool: + """Whether a requirement applies to this build. + + Evaluated once per bundled extra and once with no extra at all, because a + dependency guarded by `extra == "dev"` must not ship while one with no + marker must. + """ + from packaging.requirements import Requirement + + parsed = requirement if isinstance(requirement, Requirement) else Requirement(requirement) + if parsed.marker is None: + return True + # Platform markers evaluate against this interpreter, which is the one the + # bundle is built for and with. + if parsed.marker.evaluate({"extra": ""}): + return True + return any(parsed.marker.evaluate({"extra": extra}) for extra in extras) + + +def closure(root: str = "offloader", + extras: tuple[str, ...] = BUNDLED_EXTRAS) -> list[str]: + """Every distribution `root` pulls in at runtime, `root` excluded. + + Breadth first over the installed metadata. A package that is required but + not installed is reported rather than skipped: an SBOM that silently omits + something is worse than one that admits it could not resolve it. + """ + from packaging.requirements import Requirement + + seen: set[str] = {normalize(root)} + order: list[str] = [] + missing: list[str] = [] + queue = [root] + + while queue: + current = queue.pop(0) + try: + requires = importlib.metadata.requires(current) or [] + except importlib.metadata.PackageNotFoundError: + missing.append(current) + continue + for raw in requires: + parsed = Requirement(raw) + if not _marker_ok(parsed, extras): + continue + key = normalize(parsed.name) + if key in seen: + continue + seen.add(key) + order.append(parsed.name) + queue.append(parsed.name) + + if missing: + raise RuntimeError( + "these packages are required but not installed, so the bill of " + f"materials would be incomplete: {', '.join(sorted(missing))}") + return sorted(order, key=normalize) + + +def licence_of(name: str) -> tuple[str, str]: + """A package's licence and which metadata field it came from. + + Three fields carry it in practice and none of them always. `License- + Expression` is the PEP 639 answer, the classifiers are the old one, and the + free-text `License` field is whatever the author typed, including whole + paragraphs. Recording the source matters: "BSD License" from a classifier + is a weaker statement than an SPDX expression, and a reviewer should be + able to tell which one they are reading. + """ + metadata = importlib.metadata.metadata(name) + + expression = metadata.get("License-Expression") + if expression: + return expression.strip(), "License-Expression" + + classifiers = [value for value in metadata.get_all("Classifier") or [] + if value.startswith("License ::")] + if classifiers: + return "; ".join(value.split(" :: ")[-1] for value in classifiers), "Classifier" + + free_text = (metadata.get("License") or "").strip() + if free_text: + first = free_text.splitlines()[0].strip() + # A short first line is a licence name; a long one is prose that + # happens to start with one, and truncating it silently would invent a + # precision the metadata does not have. + if len(first) <= 64 and len(free_text.splitlines()) == 1: + return first, "License" + return f"{first[:61]}...", "License (free text)" + + return "UNKNOWN", "none" + + +def collect(root: str = "offloader", + extras: tuple[str, ...] = BUNDLED_EXTRAS) -> list[Component]: + """The shipped packages, with their licences.""" + components = [] + for name in closure(root, extras): + licence, source = licence_of(name) + components.append(Component( + name=name, + version=importlib.metadata.version(name), + licence=licence, + source=source, + )) + return sorted(components) + + +def _serial(components: list[Component], identity: dict) -> str: + material = json.dumps( + {"identity": identity, + "components": [[c.name, c.version] for c in components]}, + sort_keys=True, + ) + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return f"urn:uuid:{uuid.uuid5(_NAMESPACE, digest)}" + + +def _licence_entry(component: Component) -> dict: + """CycloneDX wants an SPDX expression where there is one and a bare name + otherwise. Claiming `expression` for "BSD License" would assert an SPDX + identifier that does not exist.""" + if component.source == "License-Expression": + return {"expression": component.licence} + return {"license": {"name": component.licence}} + + +def cyclonedx(components: list[Component], identity: dict, + *, version: str) -> dict: + """A CycloneDX 1.6 document describing what ships.""" + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": _serial(components, identity), + "version": 1, + "metadata": { + "tools": {"components": [{ + "type": "application", + "name": "offloader-sbom", + "version": version, + }]}, + "component": { + "bom-ref": f"pkg:pypi/offloader@{version}", + "type": "application", + "name": "Offloader", + "version": version, + "purl": f"pkg:pypi/offloader@{version}", + "licenses": [{"expression": "MIT"}], + }, + "properties": [ + {"name": f"offloader:{key}", "value": str(value)} + for key, value in sorted(identity.items()) + ], + }, + "components": [{ + "bom-ref": component.purl, + "type": "library", + "name": component.name, + "version": component.version, + "purl": component.purl, + "licenses": [_licence_entry(component)], + "properties": [ + {"name": "offloader:licence-source", "value": component.source}, + ], + } for component in components], + } + + +def notices(components: list[Component], *, version: str) -> str: + """The human-readable inventory that ships beside the installer.""" + lines = [ + f"Third-party notices for Offloader {version}", + "", + "Offloader is MIT licensed. It is distributed with the packages below,", + "each under its own licence. This file is an inventory produced from", + "installed package metadata; it is not legal advice, and where a", + "licence is recorded loosely the source field says so.", + "", + ] + width = max(len(component.name) for component in components) if components else 4 + for component in components: + flag = " [review]" if component.needs_review else "" + lines.append(f"{component.name:<{width}} {component.version:<12}" + f" {component.licence}{flag}") + lines.append(f"{'':<{width}} recorded in: {component.source}") + review = [component for component in components if component.needs_review] + if review: + lines += [ + "", + "Marked [review]: these carry conditions on redistribution beyond", + "attribution, and what applies to a frozen bundle is a decision", + "for a person rather than for this tool:", + "", + ] + lines += [f" {component.name} {component.version}: {component.licence}" + for component in review] + return "\n".join(lines) + "\n" + + +def lockfile(components: list[Component]) -> str: + """Pinned versions for the shipped set, so it can be reproduced.""" + header = ("# The runtime closure Offloader ships, pinned. Generated by\n" + "# build/windows/sbom.py; not the build environment, which is\n" + "# recorded in the release inventory.\n") + return header + "".join(f"{component.name}=={component.version}\n" + for component in components) + + +def write_all(directory: Path, identity: dict, + components: list[Component] | None = None) -> list[Path]: + """Write all three files, returning them in a stable order.""" + version = identity["version"] + if components is None: + components = collect() + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + + sbom = directory / f"Offloader-{version}-sbom.cyclonedx.json" + sbom.write_text( + json.dumps(cyclonedx(components, identity, version=version), indent=2) + + "\n", encoding="utf-8") + licences = directory / f"Offloader-{version}-third-party-notices.txt" + licences.write_text(notices(components, version=version), encoding="utf-8") + pins = directory / f"Offloader-{version}-requirements.txt" + pins.write_text(lockfile(components), encoding="utf-8") + return [sbom, licences, pins] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=REPO / "dist" / "windows") + parser.add_argument("--version", default=None, + help="override the version recorded in the documents") + args = parser.parse_args(argv) + + sys.path.insert(0, str(REPO / "src")) + from offloader._version import __version__ + + identity = {"version": args.version or __version__} + components = collect() + for path in write_all(args.out, identity, components): + print(path) + + flagged = [component for component in components if component.needs_review] + if flagged: + print(f"\n{len(flagged)} package(s) need a redistribution decision:", + file=sys.stderr) + for component in flagged: + print(f" {component.name} {component.version}: {component.licence}", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/build-windows.md b/docs/build-windows.md index 6a1ff1e..5a907d4 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -131,6 +131,49 @@ project license and distribution metadata. ffmpeg and ffprobe remain external. Missing media tools reduce metadata/thumbnails, not copy verification. A release-ready third-party license inventory and SBOM remain separate work. +## Bill of materials and licences + +A build emits three files describing what ships, alongside the bundle: + +| File | What it is | +| --- | --- | +| `Offloader-{version}-sbom.cyclonedx.json` | CycloneDX 1.6 SBOM, for anything that consumes one automatically | +| `Offloader-{version}-third-party-notices.txt` | The human-readable inventory that travels with the installer | +| `Offloader-{version}-requirements.txt` | The shipped set, pinned, so it can be reproduced | + +They can be regenerated on their own: + +```powershell +python build\windows\sbom.py --out dist\windows +``` + +The set is the **runtime dependency closure of the installed package**, which +is not the same as the build environment. The release inventory already +records every distribution present, and on a developer machine that includes +pytest and ruff: right for reproducing a build, wrong as a statement about +what is distributed. So the closure is resolved from package metadata with the +`gui` extra included and `dev` excluded, and a package that is required but +not installed is an error rather than a silent omission. + +Each component records **where its licence claim came from**, because the +three metadata fields do not carry equal weight: a PEP 639 +`License-Expression` is precise, a classifier's "BSD License" is approximate, +and the free-text field is sometimes a paragraph. Prose is marked as loose +rather than truncated into something that looks like an SPDX identifier, and +only a real expression is emitted as CycloneDX `expression`. + +Components under a licence with redistribution conditions beyond attribution +are flagged `[review]`, and the generator exits with them listed on stderr. +That is a prompt, not a verdict. **Qt ships under +`LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only` while Offloader is MIT**, so +PySide6, PySide6_Essentials, PySide6_Addons and shiboken6 are all flagged: what +that requires of a frozen bundle is a decision for a person, and the tool's job +is to make it impossible to miss rather than to answer it. + +The SBOM's serial number is derived from its contents, so two builds of the +same inputs produce the same document and two SBOMs can be diffed to see what +actually moved. + ## Tagging a candidate Pushing a `v*` tag runs diff --git a/docs/release-plan.md b/docs/release-plan.md index 7a1b0e3..422fab8 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -21,11 +21,16 @@ implemented now. CI includes Windows bundle checks and fresh-environment wheel installation. See [build-windows.md](build-windows.md) for commands and validation details. +A build now also emits a CycloneDX 1.6 SBOM, a third-party notices inventory +and a pinned requirements file for the runtime closure, all checksummed with +the other outputs. They flag the packages whose licences carry redistribution +conditions; Qt's LGPL/GPL terms against Offloader's MIT are the open decision +they surface, and that decision has not been made here. + Hardware-key signing, clean-machine interactive installation and alternate -credential checks, the complete third-party license inventory/SBOM, private -pilot, public release workflow, and release qualification remain pending. The -tables below retain the planned stage gates; implementation does not complete -those gates. +credential checks, the Qt redistribution decision the notices flag, private +pilot, and release qualification remain pending. The tables below retain the +planned stage gates; implementation does not complete those gates. ## Release target @@ -401,7 +406,8 @@ format; do not ask users to delete state as the default recovery procedure. Version unification, artifact identity and inventory, the NSIS installer path, transactional maintenance, shared installed-instance locking, signing hooks, -and checksum records are implemented. The clean-account GUI walkthrough, -hardware-key signing, alternate-credential install checks, complete license -inventory/SBOM, private pilot, release workflow, and candidate qualification -remain to be done. +checksum records, the update client, the tag-triggered candidate workflow, and +the SBOM/notices/lockfile set are implemented. The clean-account GUI +walkthrough, hardware-key signing, alternate-credential install checks, the Qt +redistribution decision, private pilot, and candidate qualification remain to +be done. diff --git a/tests/test_sbom.py b/tests/test_sbom.py new file mode 100644 index 0000000..76d9c62 --- /dev/null +++ b/tests/test_sbom.py @@ -0,0 +1,283 @@ +"""The bill of materials and the third-party licence inventory. + +Two things here are easy to get wrong in a way that looks fine. The first is +the boundary: an inventory of the build environment rather than of what ships +lists pytest and ruff as though they were distributed, and one that resolves +too narrowly omits a package that is. The second is precision about licences: +"BSD License" read off a classifier is a weaker claim than an SPDX expression, +and presenting them identically invents certainty the metadata does not have. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + + +def _load(name: str, path: Path): + """Load a build script as a module. + + Registered in `sys.modules` before execution, which `@dataclass` requires: + it resolves a class's module through `sys.modules[cls.__module__]`, and an + unregistered module makes that None. + """ + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _sbom(): + return _load("_sbom", REPO / "build" / "windows" / "sbom.py") + + +@pytest.fixture(scope="module") +def sbom(): + return _sbom() + + +@pytest.fixture(scope="module") +def components(sbom): + return sbom.collect() + + +# ------------------------------------------------------------- the boundary + + +def test_the_runtime_dependencies_are_inventoried(components): + names = {component.name.lower() for component in components} + assert "xxhash" in names + assert "reportlab" in names + assert "pyside6" in names + + +def test_transitive_dependencies_are_inventoried(components): + """reportlab pulls pillow, PySide6 pulls shiboken6. A closure that stopped + at the direct requirements would ship both without listing them.""" + names = {component.name.lower() for component in components} + assert "pillow" in names + assert "shiboken6" in names + + +def test_development_dependencies_are_not_inventoried(components): + """The `dev` extra is not distributed, and listing pytest as a shipped + component is a false statement about the bundle. This is the difference + from the build inventory, which records the whole environment on purpose.""" + names = {component.name.lower() for component in components} + assert "pytest" not in names + assert "pymupdf" not in names + assert "ruff" not in names + + +def test_offloader_itself_is_not_one_of_its_own_components(components): + """It is the subject of the document, recorded in `metadata.component`.""" + assert "offloader" not in {component.name.lower() for component in components} + + +def test_every_component_has_a_version_and_a_licence_claim(components): + for component in components: + assert component.version + assert component.licence + assert component.source + + +def test_a_missing_requirement_is_an_error_not_an_omission(sbom, monkeypatch): + """An SBOM that quietly drops what it could not resolve is worse than one + that refuses: the gap is invisible in the output.""" + import importlib.metadata as metadata + + original = sbom.importlib.metadata.requires + + def requires(name): + if sbom.normalize(name) == "xxhash": + raise metadata.PackageNotFoundError(name) + return original(name) + + monkeypatch.setattr(sbom.importlib.metadata, "requires", requires) + with pytest.raises(RuntimeError, match="not installed"): + sbom.closure() + + +@pytest.mark.parametrize("name,expected", [ + ("PySide6", "pyside6"), + ("PySide6_Addons", "pyside6-addons"), + ("charset-normalizer", "charset-normalizer"), + ("Pillow", "pillow"), +]) +def test_names_normalise_for_deduplication(sbom, name, expected): + assert sbom.normalize(name) == expected + + +# ------------------------------------------------------------- the licences + + +def test_the_licence_source_is_recorded(components): + """So a reviewer can tell an SPDX expression from a classifier, which is + the difference between a precise claim and an approximate one.""" + sources = {component.source for component in components} + assert sources <= {"License-Expression", "Classifier", "License", + "License (free text)", "none"} + assert sources, "no components at all" + + +def test_copyleft_dependencies_are_flagged_for_review(components): + """Qt ships under LGPL/GPL while Offloader is MIT. The tool must not be + the thing that decides that is fine; it must be the thing that makes it + impossible to miss.""" + flagged = {component.name.lower() for component in components + if component.needs_review} + assert "pyside6" in flagged + assert "shiboken6" in flagged + + +def test_permissive_dependencies_are_not_flagged(components): + """A flag on everything is a flag on nothing.""" + by_name = {component.name.lower(): component for component in components} + assert not by_name["xxhash"].needs_review + assert not by_name["pillow"].needs_review + + +def test_prose_in_the_licence_field_is_marked_as_loose(sbom, monkeypatch): + """reportlab's `License` field is a paragraph. Truncating it to look like + a licence name would assert a precision the metadata does not carry.""" + class _Meta(dict): + def get_all(self, _key): + return [] + + paragraph = ("BSD license (see license.txt for details), Copyright (c) " + "2000-2024, ReportLab Inc.\nAll rights reserved.") + monkeypatch.setattr(sbom.importlib.metadata, "metadata", + lambda _name: _Meta({"License": paragraph})) + + licence, source = sbom.licence_of("anything") + assert source == "License (free text)" + assert licence.endswith("...") + + +def test_an_absent_licence_says_unknown(sbom, monkeypatch): + class _Meta(dict): + def get_all(self, _key): + return [] + + monkeypatch.setattr(sbom.importlib.metadata, "metadata", + lambda _name: _Meta()) + assert sbom.licence_of("anything") == ("UNKNOWN", "none") + + +# ----------------------------------------------------------------- CycloneDX + + +def test_the_document_is_a_cyclonedx_1_6_bom(sbom, components): + document = sbom.cyclonedx(components, {"version": "0.4.0"}, + version="0.4.0") + assert document["bomFormat"] == "CycloneDX" + assert document["specVersion"] == "1.6" + assert document["serialNumber"].startswith("urn:uuid:") + assert document["version"] == 1 + assert document["metadata"]["component"]["name"] == "Offloader" + assert document["metadata"]["component"]["version"] == "0.4.0" + + +def test_every_component_carries_a_purl_and_a_unique_ref(sbom, components): + document = sbom.cyclonedx(components, {"version": "0.4.0"}, + version="0.4.0") + refs = [entry["bom-ref"] for entry in document["components"]] + assert len(refs) == len(set(refs)) + for entry in document["components"]: + assert entry["purl"].startswith("pkg:pypi/") + assert entry["type"] == "library" + assert entry["licenses"] + + +def test_only_spdx_expressions_are_declared_as_expressions(sbom): + """CycloneDX distinguishes an SPDX expression from a bare name. Declaring + "BSD License" as an expression asserts an identifier that does not exist.""" + spdx = sbom.Component("pillow", "12.0.0", "MIT-CMU", "License-Expression") + loose = sbom.Component("reportlab", "5.0.0", "BSD License", "Classifier") + + document = sbom.cyclonedx([spdx, loose], {"version": "0.4.0"}, + version="0.4.0") + entries = {c["name"]: c["licenses"][0] for c in document["components"]} + assert entries["pillow"] == {"expression": "MIT-CMU"} + assert entries["reportlab"] == {"license": {"name": "BSD License"}} + + +def test_the_serial_number_is_stable_for_the_same_inputs(sbom, components): + """So two SBOMs can be diffed to see what actually moved. A random serial + would differ on every rebuild in a field nobody meant to compare.""" + identity = {"version": "0.4.0", "commit": "abc123"} + first = sbom.cyclonedx(components, identity, version="0.4.0") + second = sbom.cyclonedx(components, identity, version="0.4.0") + assert first["serialNumber"] == second["serialNumber"] + + +def test_the_serial_number_changes_when_a_dependency_does(sbom, components): + identity = {"version": "0.4.0"} + baseline = sbom.cyclonedx(components, identity, version="0.4.0") + moved = sbom.cyclonedx( + [*components[:-1], + sbom.Component(components[-1].name, "99.0.0", + components[-1].licence, components[-1].source)], + identity, version="0.4.0") + assert baseline["serialNumber"] != moved["serialNumber"] + + +# ------------------------------------------------------------- the artifacts + + +def test_all_three_files_are_written(sbom, tmp_path): + written = sbom.write_all(tmp_path, {"version": "0.4.0"}) + assert [path.name for path in written] == [ + "Offloader-0.4.0-sbom.cyclonedx.json", + "Offloader-0.4.0-third-party-notices.txt", + "Offloader-0.4.0-requirements.txt", + ] + for path in written: + assert path.read_text(encoding="utf-8").strip() + json.loads(written[0].read_text(encoding="utf-8")) + + +def test_the_build_expects_exactly_the_files_that_are_written(sbom, tmp_path): + """`validate_outputs` refuses any checksummed output it did not expect, so + the writer and the expectation have to name the same files or a signed + build fails at its own verification step.""" + build = _load("_build", REPO / "build" / "windows" / "build.py") + + written = {path.name for path in sbom.write_all(tmp_path, {"version": "0.4.0"})} + assert written == build.sbom_names("0.4.0") + + +def test_the_notices_name_the_review_packages_twice(sbom, components): + """Once in the table and once in the summary, because a reader scanning a + long table should not have to spot a marker to learn there is a decision + outstanding.""" + body = sbom.notices(components, version="0.4.0") + assert "[review]" in body + assert "decision" in body + assert body.count("PySide6 ") >= 2 + + +def test_the_notices_disclaim_being_advice(sbom, components): + body = sbom.notices(components, version="0.4.0") + assert "not legal advice" in body + + +def test_the_lockfile_pins_every_component(sbom, components): + body = sbom.lockfile(components) + for component in components: + assert f"{component.name}=={component.version}" in body + + +def test_the_lockfile_is_not_the_build_environment(sbom, components): + """The distinction that makes it useful: this reproduces what ships, and + the release inventory records what built it.""" + body = sbom.lockfile(components) + assert "pytest" not in body + assert "not the build environment" in body From 9ad0f2a7d906c7f7656c3646acacb76537caad24 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:25:38 -0400 Subject: [PATCH 2/3] Publish the bill of materials, and say what it does not cover Two ways this described itself as more finished than it is. The three new files entered SHA256SUMS.txt, but the upload command generated by scripts/release_notes.py and the one in docs/build-windows.md still named only the installer, the checksums and the inventory. Following the supplied instructions therefore published checksums for assets that were not there. artifacts.release_assets() is now the one list, read by the build that checksums them, by the verification that refuses anything it did not expect, and by the instructions that upload them, with a test that the generated notes name every file sbom_names() produces. The notices are also not embedded in the installer - generating them beside the bundle does not put them inside an executable that was already assembled - so the docs no longer say they travel with it. The closure walks Python distribution metadata, and a frozen application ships components that have none: the CPython runtime DLL bundle validation requires, and the PyInstaller bootloader compiled into each of the three executables. So these files describe the Python dependency set, not the complete shipped application, and this PR had removed the release-plan item saying a complete inventory was still pending. The gate is back, worded as what actually remains. Every output states its own boundary in its own text - a scope line in the notices with the uncovered components named, an offloader:scope property in the SBOM, a comment in the requirements file - because an inventory read as complete while missing the interpreter it ships is worse than one that says where it stops. uncovered_in_bundle() reports which of those are present in a built bundle, measured against the tree rather than asserted from the list, so the gap shrinks as it is closed and cannot be closed by editing a constant. build.py now puts its own directory on sys.path. Its sibling scripts are imported by name from inside functions, which works when it is run directly and not when it is imported as a module, which is what the tests do. Eight tests: every output carrying its boundary, the SBOM naming each uncovered component, the gap measured against a synthetic bundle, the release plan still carrying the gate, and the generated instructions uploading every checksummed asset. --- build/windows/artifacts.py | 28 +++++++++++++ build/windows/build.py | 20 +++++---- build/windows/sbom.py | 67 ++++++++++++++++++++++++++++-- docs/build-windows.md | 39 ++++++++++++++++-- docs/release-plan.md | 20 ++++++--- scripts/release_notes.py | 12 +++++- tests/test_sbom.py | 84 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 22 deletions(-) diff --git a/build/windows/artifacts.py b/build/windows/artifacts.py index 8ada11b..c26c33f 100644 --- a/build/windows/artifacts.py +++ b/build/windows/artifacts.py @@ -12,6 +12,34 @@ from typing import Any _VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.MULTILINE) + + +def sbom_names(version: str) -> list[str]: + """The bill-of-materials files a release carries, in upload order.""" + return [ + f"Offloader-{version}-sbom.cyclonedx.json", + f"Offloader-{version}-third-party-notices.txt", + f"Offloader-{version}-requirements.txt", + ] + + +def release_assets(version: str) -> list[str]: + """Every file that has to be attached to a published release. + + One list, because three places have to agree about it: the build + checksums these, its own verification refuses anything it did not expect, + and the generated release instructions upload them. They had drifted -- + the bill-of-materials files entered `SHA256SUMS.txt` while the upload + command still named only the installer, the checksums and the inventory, + so following the instructions published checksums for assets that were not + there. + """ + return [ + f"Offloader-Setup-{version}.exe", + "SHA256SUMS.txt", + f"Offloader-{version}-inventory.json", + *sbom_names(version), + ] _SOURCE_SUFFIXES = {".py", ".spec", ".nsi", ".nsh", ".ico", ".bmp"} _RECORD_NAME = ".offloader-build.json" _MAX_RECORD_BYTES = 4 * 1024 * 1024 diff --git a/build/windows/build.py b/build/windows/build.py index 5c2c9c2..a2ab407 100644 --- a/build/windows/build.py +++ b/build/windows/build.py @@ -15,6 +15,11 @@ HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] +# The sibling build scripts are imported by name from inside functions. Running +# this file directly puts its directory on the path; importing it as a module, +# which the tests do, does not. +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) SPEC = HERE / "offloader.spec" DIST = REPO / "dist" / "windows" WORK = REPO / ".pyinstaller" / "windows" @@ -60,15 +65,14 @@ def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict] def sbom_names(version: str) -> set[str]: """The bill-of-materials files a release carries. - Named in one place because `save_outputs` checksums them and - `validate_outputs` refuses anything it did not expect, so the two have to - agree or a build fails at its own verification step. + Named in `artifacts` because four places have to agree about them: + `save_outputs` checksums them, `validate_outputs` refuses anything it did + not expect, and `scripts/release_notes.py` writes the upload command that + publishes them. """ - return { - f"Offloader-{version}-sbom.cyclonedx.json", - f"Offloader-{version}-third-party-notices.txt", - f"Offloader-{version}-requirements.txt", - } + from artifacts import sbom_names as names + + return set(names(version)) def save_outputs(bundle: Path, setup: Path | None, identity: dict, diff --git a/build/windows/sbom.py b/build/windows/sbom.py index 78ce303..81fd407 100644 --- a/build/windows/sbom.py +++ b/build/windows/sbom.py @@ -12,6 +12,16 @@ distribution present, which on a developer machine includes pytest and ruff: useful for reproducing a build, wrong as a statement about what ships. +It is also not the whole of what ships. This walks Python distribution +metadata, and a frozen application carries components that have no metadata to +walk: the CPython runtime DLL the bundle validation requires, and the +PyInstaller bootloader compiled into each executable. `UNCOVERED` names them, +`uncovered_in_bundle` finds the ones actually present, and every output says +so, because an inventory that is read as complete while missing the +interpreter it ships is worse than one that admits its boundary. Closing that +boundary is a release gate in `docs/release-plan.md`, not something these +three files discharge. + This inventories and flags. It does not decide whether a licence is acceptable, and it must not be read as saying so. PySide6 alone is offered under `LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only` while Offloader is MIT, @@ -36,6 +46,30 @@ #: The extras whose dependencies are actually bundled. `dev` is not one. BUNDLED_EXTRAS = ("gui",) +#: What this inventory does not reach. Each is a real third-party component of +#: the shipped application with no Python distribution metadata to read: the +#: closure below cannot see them, and neither can any tool that consumes its +#: output. Named so the gap is stated rather than left to be discovered. +UNCOVERED = ( + ("CPython runtime", "python3*.dll", + "the interpreter the frozen bundle runs on"), + ("PyInstaller bootloader", "Offloader.exe", + "compiled into each frozen executable, not installed as a package"), + ("PyInstaller bootloader", "offloader-cli.exe", + "compiled into each frozen executable, not installed as a package"), + ("PyInstaller bootloader", "offloader-maintenance.exe", + "compiled into each frozen executable, not installed as a package"), +) + +#: The one sentence every output carries about its own boundary. +SCOPE_NOTE = ( + "Scope: the Python distribution dependencies of the frozen application. " + "Components without Python packaging metadata are not covered, including " + "the bundled CPython runtime and the PyInstaller bootloader compiled into " + "each executable. This is not a complete third-party inventory of the " + "shipped application." +) + #: Licence families that place conditions on redistribution beyond notice. #: Not a verdict, a prompt: each of these needs a human to say what applies. REVIEW_FAMILIES = ("GPL", "AGPL", "LGPL", "MPL", "EPL", "CDDL", "CC-BY-SA", @@ -179,6 +213,20 @@ def collect(root: str = "offloader", return sorted(components) +def uncovered_in_bundle(bundle: Path) -> list[tuple[str, str, str]]: + """The entries from `UNCOVERED` that are actually in this bundle. + + Against the built tree rather than asserted from the list, so the gap + shrinks as it is closed and cannot be closed by editing a constant. + """ + bundle = Path(bundle) + found = [] + for name, pattern, why in UNCOVERED: + if any(bundle.rglob(pattern)): + found.append((name, pattern, why)) + return found + + def _serial(components: list[Component], identity: dict) -> str: material = json.dumps( {"identity": identity, @@ -221,8 +269,12 @@ def cyclonedx(components: list[Component], identity: dict, "licenses": [{"expression": "MIT"}], }, "properties": [ - {"name": f"offloader:{key}", "value": str(value)} - for key, value in sorted(identity.items()) + {"name": "offloader:scope", "value": SCOPE_NOTE}, + *({"name": "offloader:uncovered", + "value": f"{name} ({pattern}): {why}"} + for name, pattern, why in UNCOVERED), + *({"name": f"offloader:{key}", "value": str(value)} + for key, value in sorted(identity.items())), ], }, "components": [{ @@ -249,6 +301,11 @@ def notices(components: list[Component], *, version: str) -> str: "installed package metadata; it is not legal advice, and where a", "licence is recorded loosely the source field says so.", "", + SCOPE_NOTE, + "", + "Not covered here:", + *(f" {name} ({pattern}) -- {why}" for name, pattern, why in UNCOVERED), + "", ] width = max(len(component.name) for component in components) if components else 4 for component in components: @@ -274,7 +331,11 @@ def lockfile(components: list[Component]) -> str: """Pinned versions for the shipped set, so it can be reproduced.""" header = ("# The runtime closure Offloader ships, pinned. Generated by\n" "# build/windows/sbom.py; not the build environment, which is\n" - "# recorded in the release inventory.\n") + "# recorded in the release inventory.\n" + "#\n" + "# Python distribution dependencies only. The bundled CPython\n" + "# runtime and the PyInstaller bootloader inside each executable\n" + "# have no packaging metadata and are not listed here.\n") return header + "".join(f"{component.name}=={component.version}\n" for component in components) diff --git a/docs/build-windows.md b/docs/build-windows.md index 5a907d4..7badc4b 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -51,7 +51,8 @@ bundle, so do not build over executables currently in use. The builder writes `.offloader-build.json` inside the bundle, an external `Offloader-{version}-inventory.json` with dependency versions and signature -coverage, and `SHA256SUMS.txt` for the final installer, ZIP, and inventory. +coverage, and `SHA256SUMS.txt` for the final installer, ZIP, inventory and +the three bill-of-materials files. A failed build leaves `.offloader-build-incomplete`; it must not be promoted. Source changes during a build invalidate the candidate. These inventories are provenance and tamper checks, not a complete third-party license inventory or SBOM. @@ -138,7 +139,7 @@ A build emits three files describing what ships, alongside the bundle: | File | What it is | | --- | --- | | `Offloader-{version}-sbom.cyclonedx.json` | CycloneDX 1.6 SBOM, for anything that consumes one automatically | -| `Offloader-{version}-third-party-notices.txt` | The human-readable inventory that travels with the installer | +| `Offloader-{version}-third-party-notices.txt` | The human-readable inventory, uploaded as a release asset | | `Offloader-{version}-requirements.txt` | The shipped set, pinned, so it can be reproduced | They can be regenerated on their own: @@ -174,6 +175,28 @@ The SBOM's serial number is derived from its contents, so two builds of the same inputs produce the same document and two SBOMs can be diffed to see what actually moved. +### What these three files do not cover + +The Python distribution dependencies, and only those. A frozen application also +ships components that have no packaging metadata for the closure to walk: the +CPython runtime DLL that bundle validation requires, and the PyInstaller +bootloader compiled into each of the three executables. All three outputs say +so in their own text — a scope line in the notices, a `offloader:scope` +property in the SBOM, a comment in the requirements file — because an inventory +read as complete while missing the interpreter it ships is worse than one that +states its boundary. + +`sbom.uncovered_in_bundle()` reports which of those are actually present in a +built bundle, measured against the tree rather than asserted from a list, so +the gap shrinks as it is closed and cannot be closed by editing a constant. +The complete third-party inventory remains a gate in +[release-plan.md](release-plan.md). + +These files are generated **beside** the bundle. They are not embedded in the +installer, so they accompany a release only by being uploaded with it, which +is why they are in the asset list below rather than assumed to travel with the +setup executable. + ## Tagging a candidate Pushing a `v*` tag runs @@ -201,13 +224,21 @@ prerelease pinned to the tagged commit, with notes and no assets. No assets, on purpose. Signing needs the hardware token, which exists only on the release workstation, and the [release plan](release-plan.md) requires every Windows download to be signed. So the workflow's own output is for inspection, -and the signed installer is uploaded separately: +and the signed installer is uploaded separately, with every asset +`SHA256SUMS.txt` covers — `artifacts.release_assets()` is the one list the +build, its verification and the generated release notes all read: ```powershell git checkout v0.1.0b1 python build\windows\build.py --clean python build\windows\build.py --verify-only -gh release upload v0.1.0b1 dist\windows\Offloader-Setup-0.1.0b1.exe dist\windows\SHA256SUMS.txt dist\windows\Offloader-0.1.0b1-inventory.json +gh release upload v0.1.0b1 ` + dist\windows\Offloader-Setup-0.1.0b1.exe ` + dist\windows\SHA256SUMS.txt ` + dist\windows\Offloader-0.1.0b1-inventory.json ` + dist\windows\Offloader-0.1.0b1-sbom.cyclonedx.json ` + dist\windows\Offloader-0.1.0b1-third-party-notices.txt ` + dist\windows\Offloader-0.1.0b1-requirements.txt ``` `workflow_dispatch` runs the same checks without touching releases, for diff --git a/docs/release-plan.md b/docs/release-plan.md index 422fab8..c0bef7b 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -27,8 +27,17 @@ the other outputs. They flag the packages whose licences carry redistribution conditions; Qt's LGPL/GPL terms against Offloader's MIT are the open decision they surface, and that decision has not been made here. +Those three files cover the **Python distribution dependencies** and say so in +their own text. They are not the complete third-party inventory: a frozen +application also ships the CPython runtime DLL and the PyInstaller bootloader +compiled into each executable, neither of which has packaging metadata for the +closure to walk. `sbom.uncovered_in_bundle` reports which of those are present +in a built bundle, so the gap is measured against the tree rather than +asserted. + Hardware-key signing, clean-machine interactive installation and alternate -credential checks, the Qt redistribution decision the notices flag, private +credential checks, the Qt redistribution decision the notices flag, the +complete third-party inventory covering the frozen runtime components, private pilot, and release qualification remain pending. The tables below retain the planned stage gates; implementation does not complete those gates. @@ -407,7 +416,8 @@ format; do not ask users to delete state as the default recovery procedure. Version unification, artifact identity and inventory, the NSIS installer path, transactional maintenance, shared installed-instance locking, signing hooks, checksum records, the update client, the tag-triggered candidate workflow, and -the SBOM/notices/lockfile set are implemented. The clean-account GUI -walkthrough, hardware-key signing, alternate-credential install checks, the Qt -redistribution decision, private pilot, and candidate qualification remain to -be done. +the SBOM/notices/lockfile set for the Python dependency closure are +implemented. The clean-account GUI walkthrough, hardware-key signing, +alternate-credential install checks, the Qt redistribution decision, the +complete third-party inventory covering the frozen runtime components, private +pilot, and candidate qualification remain to be done. diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 5ea764e..cceb03c 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -20,7 +20,12 @@ REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO / "src")) +# The one list of what a release carries. Written down once because the build +# checksums these files, its own verification refuses anything it did not +# expect, and the instructions below upload them -- and the three had drifted. +sys.path.insert(0, str(REPO / "build" / "windows")) +from artifacts import release_assets # noqa: E402,I001 from offloader._version import __version__ # noqa: E402 TEMPLATE = """\ @@ -41,7 +46,7 @@ git checkout {tag} python build/windows/build.py --clean python build/windows/build.py --verify-only -gh release upload {tag} dist/windows/Offloader-Setup-{version}.exe dist/windows/SHA256SUMS.txt dist/windows/Offloader-{version}-inventory.json +gh release upload {tag} {assets} ``` Then work through the acceptance matrix in `docs/release-plan.md` and publish @@ -63,7 +68,10 @@ def notes(tag: str, commit: str, version: str = __version__) -> str: raise SystemExit( f"error: tag {tag!r} names {tagged!r} but the source declares " f"{version!r}; the release notes would contradict themselves") - return TEMPLATE.format(tag=tag, commit=commit, version=version) + assets = " ".join(f"dist/windows/{name}" + for name in release_assets(version)) + return TEMPLATE.format(tag=tag, commit=commit, version=version, + assets=assets) def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 76d9c62..38772c5 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -281,3 +281,87 @@ def test_the_lockfile_is_not_the_build_environment(sbom, components): body = sbom.lockfile(components) assert "pytest" not in body assert "not the build environment" in body + + +# --------------------------------------------------- what it does not cover + + +def test_every_output_states_its_own_boundary(sbom, components, tmp_path): + """REGRESSION. These three files describe the Python dependency graph, and + a frozen application ships components that have no packaging metadata to + walk. Read as a complete inventory while missing the interpreter it ships, + that is worse than one that says where it stops.""" + written = sbom.write_all(tmp_path, {"version": "0.4.0"}, components) + document = json.loads(written[0].read_text(encoding="utf-8")) + properties = {p["name"]: p["value"] + for p in document["metadata"]["properties"]} + + assert "not a complete third-party inventory" in properties["offloader:scope"] + notices = written[1].read_text(encoding="utf-8") + assert "Not covered here:" in notices + assert "CPython runtime" in notices + assert "PyInstaller bootloader" in notices + assert "no packaging metadata" not in written[2].read_text(encoding="utf-8").lower() \ + or "PyInstaller bootloader" in written[2].read_text(encoding="utf-8") + + +def test_the_sbom_names_each_uncovered_component(sbom, components): + document = sbom.cyclonedx(components, {"version": "0.4.0"}, version="0.4.0") + uncovered = [p["value"] for p in document["metadata"]["properties"] + if p["name"] == "offloader:uncovered"] + assert len(uncovered) == len(sbom.UNCOVERED) + assert any("python3" in value for value in uncovered) + + +def test_the_gap_is_measured_against_the_bundle(sbom, tmp_path): + """Against the built tree rather than asserted from the list, so it shrinks + as it is closed and cannot be closed by editing a constant.""" + bundle = tmp_path / "Offloader" + (bundle / "_internal").mkdir(parents=True) + assert sbom.uncovered_in_bundle(bundle) == [] + + (bundle / "_internal" / "python313.dll").write_bytes(b"MZ") + (bundle / "Offloader.exe").write_bytes(b"MZ") + found = sbom.uncovered_in_bundle(bundle) + + names = {name for name, _pattern, _why in found} + assert names == {"CPython runtime", "PyInstaller bootloader"} + assert len(found) == 2, "only the executables actually present" + + +def test_the_release_plan_still_carries_the_inventory_gate(sbom): + """The claim these files support is narrower than the gate they were read + as discharging, so the gate stays until the frozen runtime is covered.""" + plan = (REPO / "docs" / "release-plan.md").read_text(encoding="utf-8") + assert "complete third-party inventory" in plan + assert "remain pending" in plan + + +# ----------------------------------------------------- publishing them + + +def test_the_release_instructions_upload_every_checksummed_asset(sbom): + """REGRESSION. The three files entered `SHA256SUMS.txt` while the generated + upload command still named only the installer, the checksums and the + inventory, so following the instructions published checksums for assets + that were not there.""" + artifacts = _load("_artifacts", REPO / "build" / "windows" / "artifacts.py") + notes = _load("_notes", REPO / "scripts" / "release_notes.py") + from offloader._version import __version__ + + body = notes.notes(f"v{__version__}", "abc1234") + for name in artifacts.sbom_names(__version__): + assert name in body, f"{name} is checksummed but never uploaded" + + +def test_the_asset_list_and_the_checksums_name_the_same_files(sbom, tmp_path): + """One list, because the build, its verification and the instructions all + read it.""" + artifacts = _load("_artifacts", REPO / "build" / "windows" / "artifacts.py") + build = _load("_build", REPO / "build" / "windows" / "build.py") + + assets = set(artifacts.release_assets("0.4.0")) + assert build.sbom_names("0.4.0") <= assets + assert "SHA256SUMS.txt" in assets + assert "Offloader-Setup-0.4.0.exe" in assets + assert "Offloader-0.4.0-inventory.json" in assets From 1078a930516f6a353a628cbff665f9b258a19827 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:39:18 -0400 Subject: [PATCH 3/3] Record the publication list and the inventory boundary in the changelog --- CHANGELOG.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807735..07cc0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,22 @@ project uses [semantic versioning][semver]. - **A bill of materials, third-party notices and a pinned lockfile.** Every build emits a CycloneDX 1.6 SBOM, a human-readable notices inventory and a - pinned requirements file, checksummed with the other release outputs. + pinned requirements file, checksummed with the other release outputs and + named in the upload instructions that publish them. One list of release + assets is read by the build that checksums them, by the verification that + refuses anything it did not expect, and by the generated release notes, so + the instructions cannot name fewer files than the checksums cover. These are + written beside the bundle, not embedded in the installer, so they accompany + a release by being uploaded with it. + + **They cover the Python distribution dependencies, and say so.** A frozen + application also ships the CPython runtime DLL and the PyInstaller bootloader + compiled into each executable, neither of which has packaging metadata for + the closure to walk. Each output states that boundary in its own text and + names what is outside it, because an inventory read as complete while missing + the interpreter it ships is worse than one that says where it stops. The + complete third-party inventory remains a release gate, and the gap is + measured against a built bundle rather than asserted from a list. The set is the runtime dependency closure of the installed package, not the build environment: the release inventory already records every distribution