diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3218fb8..0f40c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,16 @@ permissions: contents: write # For the SBOM attestation. `id-token` mints the short-lived OIDC token that # signs it, `attestations` writes the result to the repository's attestation - # store. Both are required by actions/attest-sbom and neither grants anything - # else; a release that skipped them would still publish an SBOM, just an - # unsigned one that anybody could swap. + # store. Both are required by actions/attest and neither grants anything else; + # a release that skipped them would still publish an SBOM, just an unsigned one + # that anybody could swap. + # + # NOT here on purpose: `artifact-metadata: write`, which that action's README + # lists as a third permission. It is needed to create the artifact STORAGE + # RECORD, and `create-storage-record` requires `push-to-registry`, which + # defaults to false and is not used here (read in the action's own action.yml, + # 2026-08-12). Granting a permission we do not need would contradict the line + # above it. id-token: write attestations: write @@ -139,10 +146,17 @@ jobs: # anybody could replace, which is the difference between a bill of materials # and a note claiming to be one. - name: Attest the SBOM against the release archive + # Was actions/attest-sbom until 2026-08-12. Its own README now says it is + # deprecated in favour of actions/attest and runs as a wrapper over it, and + # that "all of the existing action inputs are compatible" - checked against + # actions/attest's action.yml rather than taken on trust: `sbom-path` is + # there, with the same meaning, and providing it is what makes this an SBOM + # attestation rather than build provenance. So both inputs stay as they are. + # # Pinned by SHA, not by tag. This is the first third-party-shaped action in # the workflow that publishes the release, and a tag can be moved; the SHA - # below is what `v4.1.0` pointed at on 2026-08-11, read from the API. - uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + # below is what `v4.2.2` pointed at on 2026-08-12, read from the API. + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: ${{ env.ASSET }} sbom-path: ${{ env.SBOM }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b70af85..d23e9d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,11 @@ either yet. administrator uses that administrator's folder instead of yours. Nothing used to say so, which made saved profiles look lost. Both READMEs explain it, and an administrator can set `BEAN_DATA_DIR` system-wide to give every account one shared folder. +- **The SBOM published with each release now names the version of the tool that built it.** That + component ships a piece of itself inside the executable, under its own licence, and the file + could not say which version you were given. The two entries that still say "no assertion" say + it because the files they describe carry no version at all, which is the honest answer rather + than a guess. ### Docs - **Four more guides on the website: no internet, timed scenarios, game lag and chaos testing.** diff --git a/beantester/legal.py b/beantester/legal.py index 5215e49..4df6d9e 100644 --- a/beantester/legal.py +++ b/beantester/legal.py @@ -112,6 +112,32 @@ def _zlib_version(): return "-" +def _pyinstaller_version(): + """The PyInstaller that froze this bundle, when anything here can still say. + + Read from the installed distribution's METADATA rather than by importing the + package: the answer is the same, and importing PyInstaller to ask its version + would drag a large build-time tool into `--license` on any machine that has + it. `importlib.metadata` does not import the package at all. + + Two environments, two honest answers. Where the release is BUILT, PyInstaller + is installed, and `tools/sbom.py` runs in the same job minutes after the build + - so the version it reports is the one that actually froze the archive. + + Inside the shipped executable the distribution is absent (a build tool is not + bundled with what it builds) and the answer falls back to "bundled", which is + what this row said before and is still true: the BOOTLOADER is in there, we + just cannot name its version from inside. Not "-", which is this module's word + for "not present here" and would read as though the component were absent. + `tools/sbom.py` maps "bundled" to NOASSERTION, so the SBOM is unchanged too. + """ + try: + import importlib.metadata + return str(importlib.metadata.version("pyinstaller")) + except Exception: # noqa: BLE001 - absence is an answer, not a failure + return "bundled" + + def component_rows(): """``(name, version, licence, source_url)`` for every third-party component.""" rows = [] @@ -126,6 +152,8 @@ def component_rows(): version = _zlib_version() elif name == "WinDivert": version = WINDIVERT_VERSION + elif name.startswith("PyInstaller"): + version = _pyinstaller_version() else: version = "bundled" # libffi and the MS runtime carry no version rows.append((name, version, licence, url)) diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 4f632fb..f13c5f7 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -71,6 +71,25 @@ "new": ' return bool(key == "duration" and getattr(self.app, "running", False))', "test": "test_start_only_fields_are_locked_while_a_session_runs", }, + { + # The SBOM could not name the tool that froze the binary, whose bootloader + # ships inside it under its own licence. + "label": "sbom: the registry stops asking for the PyInstaller version", + "file": "beantester/legal.py", + "old": ' elif name.startswith("PyInstaller"):\n' + " version = _pyinstaller_version()\n", + "new": "", + "test": "test_the_sbom_names_the_pyinstaller_that_froze_the_build", + }, + { + # The other direction, which matters more: inside the shipped exe there is + # no PyInstaller to ask, and the answer there must stay "no assertion". + "label": "sbom: an absent build tool gets an invented version", + "file": "beantester/legal.py", + "old": ' return "bundled"\n\n\ndef component_rows():', + "new": ' return "0.0.0"\n\n\ndef component_rows():', + "test": "test_a_build_tool_that_is_not_installed_is_not_invented", + }, { # The one defect in this work that CI found and this machine could not: # relpath raises across drives, and the Windows runner keeps the repo and diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 685f01d..65370f6 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -138,3 +138,55 @@ def test_the_bundle_guard_notices_a_component_the_registry_lacks(tmp_path): found = sbom.audit_bundle(str(dirty)) check("an unknown component is reported", "libcurl" in found and "OpenSSL" in found, f"({found})") + + +def test_the_sbom_names_the_pyinstaller_that_froze_the_build(monkeypatch): + """The version of a build tool is knowable exactly where the build happens. + + `tools/sbom.py` runs in the release job minutes after PyInstaller froze the + archive, so the installed distribution IS the one that made it. Until + 2026-08-12 this row said NOASSERTION, which was honest - nothing asked - and + left the SBOM unable to name a component whose bootloader ships inside the + binary under its own licence. + + Read through `importlib.metadata`, so asking the question never imports the + build tool into `--license` on a developer's machine. + """ + import importlib.metadata + + monkeypatch.setattr(importlib.metadata, "version", lambda name: "9.9.9") + rows = {name: version for name, version, _lic, _url in legal.component_rows()} + named = [n for n in rows if n.startswith("PyInstaller")] + check("the registry still has exactly one PyInstaller row", len(named) == 1, f"({named})") + check("it reports the installed version", rows[named[0]] == "9.9.9", f"({rows[named[0]]})") + + packages = {p["name"]: p["versionInfo"] for p in sbom.build()["packages"]} + check("and the SBOM carries it", packages[named[0]] == "9.9.9", f"({packages[named[0]]})") + + +def test_a_build_tool_that_is_not_installed_is_not_invented(monkeypatch): + """Inside the shipped executable there is no PyInstaller to ask. + + A build tool is not bundled with what it builds, so the SBOM's honest answer + there is "no assertion" - the same answer this row gave before it could be + resolved at all. + + The report the user of the binary reads keeps saying "bundled", which is the + other half of the truth and easy to lose: the BOOTLOADER really is inside the + executable, so "-" (this module's word for "not present here") would read as + though the component were missing. + """ + import importlib.metadata + + def missing(name): + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", missing) + rows = {name: version for name, version, _lic, _url in legal.component_rows()} + named = [n for n in rows if n.startswith("PyInstaller")][0] + check("the report still says the component is in there", + rows[named] == "bundled", f"({rows[named]})") + + packages = {p["name"]: p["versionInfo"] for p in sbom.build()["packages"]} + check("and the SBOM says NOASSERTION, not a made-up version", + packages[named] == "NOASSERTION", f"({packages[named]})")