Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
28 changes: 28 additions & 0 deletions beantester/legal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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))
Expand Down
19 changes: 19 additions & 0 deletions tests/test_mutation_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]})")
Loading