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..07cc0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,46 @@ 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 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 + 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/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 3b2ff8d..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" @@ -57,8 +62,22 @@ 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 `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. + """ + from artifacts import sbom_names as names + + return set(names(version)) + + 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 +96,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 +120,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..81fd407 --- /dev/null +++ b/build/windows/sbom.py @@ -0,0 +1,389 @@ +"""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. + +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, +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",) + +#: 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", + "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 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, + "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": "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": [{ + "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.", + "", + 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: + 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" + "#\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) + + +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..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. @@ -131,6 +132,71 @@ 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, 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: + +```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. + +### 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 @@ -158,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 7a1b0e3..c0bef7b 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -21,11 +21,25 @@ 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. + +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 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, 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. ## Release target @@ -401,7 +415,9 @@ 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 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 new file mode 100644 index 0000000..38772c5 --- /dev/null +++ b/tests/test_sbom.py @@ -0,0 +1,367 @@ +"""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 + + +# --------------------------------------------------- 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