From cdf734021238803acb16a8345fb4a839bf84ee37 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 17 Aug 2026 18:49:45 +0200 Subject: [PATCH 1/3] build(release): pin the tool that freezes the executable Python 3.14 on Windows ships Tcl/Tk 9.0, which keeps its library scripts in a zip archive embedded in tcl90.dll instead of a directory on disk. PyInstaller 6.21.0 reads that path as a filesystem directory, collects ZERO Tcl/Tk data files, and so never creates _internal/_tcl_data - while its own pyi_rth__tkinter run-time hook requires that directory. The frozen exe raised FileNotFoundError before any project code ran, on every launch, CLI and GUI alike. PyInstaller 6.22.0 fixed both halves upstream: support for Tcl/Tk 9 builds with embedded data archives, and the missing-data-directory error moved from run time to build time. Both workflows installed a bare `pyinstaller`, so the version that built an artefact was whatever PyPI served that day - CI resolved 6.22.1 while a developer machine had 6.21.0, and the same commit produced a working exe there and a crashing one here. - requirements-build.txt pins pyinstaller==6.22.1, with the reasoning kept next to the number - the ci.yml build job and release.yml install it with -r, not by name - new test_both_workflows_install_the_same_pinned_builder: the pin must use ==, both workflows must install from the file, and a bare pyinstaller on any pip install line is rejected - two MUTATIONS entries, both confirmed red before being written down - corrected the weekly-cron comment in ci.yml, which still described pyinstaller as unpinned Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 19 +++++++++------ .github/workflows/release.yml | 2 +- requirements-build.txt | 32 +++++++++++++++++++++++++ tests/test_mutation_registry.py | 21 ++++++++++++++++ tests/test_version_and_release.py | 40 +++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 requirements-build.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a25f77..ec8d691 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,18 @@ on: # them between pushes: # * `requirements-dev.txt` is deliberately unpinned - pytest, hypothesis and # pytest-cov track latest, and a new pytest can redden a green suite; - # * **pyinstaller is installed unpinned** (`pip install -r requirements.txt - # pyinstaller`) in the build job here AND in release.yml, so an upstream - # release changes the executable we ship without a commit of ours; # * the runner image and the 3.14.x patch `setup-python` resolves both move - # under us. - # In other words the cron catches less than it used to on the runtime side and - # exactly as much on the side that builds the binary. + # under us - and that is not theoretical: a 3.14 patch brought Tcl/Tk 9.0, + # whose DLL-embedded library archive broke the frozen exe on an older + # PyInstaller and added `libtommath` to the bundle (2026-08-17). + # `pyinstaller` USED to be unpinned here and in release.yml, which is how the + # same commit could build a working exe on CI and a crashing one on a developer + # machine. It is pinned in requirements-build.txt now, so what this cron watches + # on the build side is the runner and the interpreter, not the freezer. + # + # 🔴 A red cron is only worth the minutes if somebody reads it. The owner + # decided against opening an issue automatically (2026-08-17), so this run is + # checked by hand - `gh run list --workflow CI --branch master`. schedule: - cron: "0 6 * * 1" # Allow running the pipeline by hand from the Actions tab. @@ -221,7 +226,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt pyinstaller + pip install -r requirements.txt -r requirements-build.txt # onedir + console subsystem + asInvoker - see BeanNetworkTester.spec - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f40c7b..d47c70c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt pyinstaller + pip install -r requirements.txt -r requirements-build.txt # The version is single-sourced in VERSION.txt. The tag is v for a # final release, or v-rc.N (also -beta.N / -alpha.N) for a pre-release. diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..a7f4f20 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,32 @@ +# Build-time dependency: the tool that freezes the shipped executable. +# +# 🔴 PINNED, and the pin is the point. This file exists because of a REAL failure +# on 2026-08-17, not as tidiness. `pyinstaller` was installed unpinned by both +# `ci.yml` and `release.yml` (`pip install -r requirements.txt pyinstaller`), so +# CI and a developer machine could build with different versions of the tool that +# produces the binary - and they did: +# +# * Python 3.14 on Windows ships Tcl/Tk 9.0, which keeps its library scripts in +# a zip archive embedded in `tcl90.dll` instead of a directory on disk. +# * PyInstaller 6.21.0 reads that path as a filesystem directory, fails, and +# collects ZERO Tcl/Tk data files, so `_internal/_tcl_data` never exists. Its +# own run-time hook then requires that directory and raises FileNotFoundError +# before any of our code runs. The exe crashes on launch, every launch. +# * PyInstaller 6.22.0 added support for Tcl/Tk 9 builds with embedded data +# archives, and moved the "data directory missing" error from RUN time to +# BUILD time. A machine on 6.22.x cannot ship that crash. +# +# So the version of this one package decides whether the artefact starts at all, +# and nothing recorded which version built a given release. Same reasoning as the +# pin in requirements.txt: a requirement without a version is not a component, it +# is a wish. +# +# The cost is honest: this number needs raising by hand. Do it deliberately - +# PyInstaller decides the bundle's contents, so a bump can add or drop files that +# `beantester/legal.py` and THIRD-PARTY-NOTICES.md have to account for (the same +# Tcl 9 move added `libtommath` to the bundle). After bumping: build the exe and +# LAUNCH it, CLI and GUI - see "definition of done" in PROJECT_NOTES. +# +# Kept out of requirements-dev.txt on purpose: that file is deliberately unpinned +# so pytest and hypothesis track latest, and this one must not. +pyinstaller==6.22.1 diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index f13c5f7..1524d91 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -854,6 +854,27 @@ " return f\"{name}: stopped (removal was already scheduled)\"", "test": "test_a_removal_windivert_already_scheduled_is_not_reported_as_a_failure", }, + { + # The 2026-08-17 failure in one line: an unpinned builder in a workflow. + # CI resolved PyInstaller 6.22.1, this machine had 6.21.0, and only the + # older one mis-handles Python 3.14's DLL-embedded Tcl/Tk 9 archive - so + # the same commit built a working exe there and a crashing one here. + "label": "release: a workflow installs the freezer unpinned again", + "file": ".github/workflows/ci.yml", + "old": " pip install -r requirements.txt -r requirements-build.txt", + "new": " pip install -r requirements.txt pyinstaller", + "test": "test_both_workflows_install_the_same_pinned_builder", + }, + { + # The other half: the file is wired in, but stops actually pinning. Kept + # version-agnostic on purpose - `pyinstaller==` survives every bump, while + # spelling the number here would make this entry go stale on each one. + "label": "release: the builder pin loosens into a range", + "file": "requirements-build.txt", + "old": "pyinstaller==", + "new": "pyinstaller>=", + "test": "test_both_workflows_install_the_same_pinned_builder", + }, ] # The runner's own check: a patch that cannot compile must be reported as BROKEN, not diff --git a/tests/test_version_and_release.py b/tests/test_version_and_release.py index 0db6a14..e138d5e 100644 --- a/tests/test_version_and_release.py +++ b/tests/test_version_and_release.py @@ -405,3 +405,43 @@ def python_versions(path, inside_job=None): check("release.yml freezes exactly one Python", len(release) == 1, f"({release})") check("ci.yml's build job and release.yml freeze the SAME Python", release == build, f"(release={release} ci-build={build})") + + +def test_both_workflows_install_the_same_pinned_builder(): + """PyInstaller decides whether the shipped exe starts, so its version is part + of the artefact - and both workflows must take it from ONE pinned file. + + Paid for on 2026-08-17: `pyinstaller` was installed unpinned in both + workflows, CI resolved 6.22.1 and this machine had 6.21.0, and only the older + one mis-handles the DLL-embedded Tcl/Tk 9 library archive that Python 3.14 + ships. Result: the same commit produced a working exe on CI and one that died + in PyInstaller's own tkinter run-time hook locally. The interpreter had a + parity guard (above); the freezer had none. + + Checks the shape of the failure, not just the presence of a string: an + unpinned install is rejected wherever it appears, so re-adding a bare + `pyinstaller` to either workflow reddens this. + """ + pin_file = "requirements-build.txt" + with open(os.path.join(ROOT, pin_file), encoding="utf-8") as f: + pins = [ln.strip() for ln in f + if ln.strip() and not ln.lstrip().startswith("#")] + check(f"{pin_file} pins exactly one package", len(pins) == 1, f"({pins})") + check(f"{pin_file} pins it with == ", bool(re.match(r"^pyinstaller==\d", pins[0])), + f"({pins[0]!r} - a range or a bare name is not a pin)") + + for path in ("ci.yml", "release.yml"): + with open(os.path.join(ROOT, ".github", "workflows", path), + encoding="utf-8") as f: + body = f.read() + installs = re.findall(r"^\s*pip install .*$", body, re.MULTILINE) + check(f"{path}: still has a pip install line", bool(installs), + "(none found - did the step move or change shape?)") + builder = [ln for ln in installs if f"-r {pin_file}" in ln] + check(f"{path}: installs the builder from {pin_file}", bool(builder), + f"(install lines: {installs})") + loose = [ln for ln in installs + if re.search(r"(? Date: Mon, 17 Aug 2026 19:12:15 +0200 Subject: [PATCH 2/3] docs(build): the Tcl trim comment says what it actually does The comment above _TCL_CRUFT claimed the filter drops "~750 files" of tzdata and msgcat catalogs. On a Tcl 9 build it drops nothing at all. Measured 2026-08-17 (Python 3.14.7, Tcl 9.0.4, PyInstaller 6.22.1): Tcl 9 keeps its library scripts in a zip archive embedded in tcl90.dll, so tcltk_info.data_files is 0, the bundle has no _tcl_data/ or _tk_data/ at all, and _internal/ carries exactly tcl90.dll and tcl9tk90.dll. The filter itself stays, because requires-python = ">=3.10" still permits an interpreter shipping Tcl 8.6 where it does bite. That saving is now marked as not re-measured rather than stated as fact, and the comment names the number to re-read if a future Python moves the Tcl data again. No behaviour change - the comprehension is untouched. Co-Authored-By: Claude Opus 5 --- BeanNetworkTester.spec | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/BeanNetworkTester.spec b/BeanNetworkTester.spec index 032a24c..266d4d7 100644 --- a/BeanNetworkTester.spec +++ b/BeanNetworkTester.spec @@ -102,11 +102,23 @@ a = Analysis( # Performance > size (PROJECT_NOTES): a trim may only shrink the release footprint, # never touch startup or runtime. onedir does NOT unpack at launch, so dropping # files does not speed startup - it only makes the folder next to the exe smaller. -# Tcl bundles the full IANA timezone database (_tcl_data/tzdata, ~600 files) and its +# A Tcl 8.6 build bundles the full IANA timezone database (_tcl_data/tzdata) and its # own msgcat message catalogs (_tcl_data/msgs, _tk_data/msgs). This tool uses -# Python's time (never Tcl's [clock]) and its own i18n (lang/*.json), so those ~750 -# files are dead weight. Encodings are KEPT (Tk needs them). OpenSSL (libcrypto/libssl) +# Python's time (never Tcl's [clock]) and its own i18n (lang/*.json), so those files +# are dead weight. Encodings are KEPT (Tk needs them). OpenSSL (libcrypto/libssl) # is dropped via the Analysis `excludes` above - see the note there. +# +# 🔴 On Tcl 9 this filter is a NO-OP, and the sentence above used to claim it saved +# "~750 files" unconditionally. MEASURED 2026-08-17 (Python 3.14.7, Tcl 9.0.4, +# PyInstaller 6.22.1): Tcl 9 keeps its library scripts in a zip archive embedded in +# tcl90.dll, so `tcltk_info.data_files` is 0, there is no _tcl_data/ or _tk_data/ in +# the bundle at all, and this comprehension drops NOTHING. The built _internal/ +# carries exactly two Tcl files, tcl90.dll and tcl9tk90.dll. +# The filter stays because `requires-python = ">=3.10"` (see the ADR on the tested vs +# supported split) still permits an interpreter that ships Tcl 8.6, and there it does +# bite - NOT re-measured here, so treat the size saving as true for 8.6 and zero for 9. +# Nothing guards this comment. If a future Python moves the Tcl data again, the number +# to re-read is `len(tcltk_info.data_files)`, not this paragraph. _TCL_CRUFT = ("_tcl_data/tzdata/", "_tcl_data/msgs/", "_tk_data/msgs/") a.datas = [d for d in a.datas if not any(part in d[0].replace("\\", "/") for part in _TCL_CRUFT)] From 43a06a15d7d45f92ca0ff714a3018f56982b1333 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 17 Aug 2026 19:12:30 +0200 Subject: [PATCH 3/3] fix(license): register libtommath, which Tcl 9 brings into the bundle The weekly cron went red on master: the build and the exe smoke both passed, and the job failed at `sbom.py --audit-bundle` naming `\_internal\libtommath`. Python 3.14 ships Tcl/Tk 9.0, and Tcl 9 links libtommath for its arbitrary-precision integer arithmetic, so libtommath.dll arrives from the CPython DLLs directory with everything else Tk needs. No requirements file names it - the third component found by scanning the built bundle rather than by reading a manifest, after zlib and libffi. The guard worked exactly as intended. Licence read at the source: the Unlicense, a public-domain dedication, so GPLv3-compatible. The full text is bundled verbatim. - legal.COMPONENTS gains the entry, so --license and the About window name it - THIRD-PARTY-NOTICES.md gains its section, licenses/ its text - sbom.py maps it with a substring pattern, because the scan reports the name with a path prefix - "Unlicense" added to the curated SPDX allowlist in test_sbom.py, after checking it against the official SPDX list Verified locally in both directions with a synthetic syft-json, since syft is not installed here: a scan naming the component now exits 0, and a control scan naming an unknown one still exits 1 and names it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++++ THIRD-PARTY-NOTICES.md | 13 +++++++++++++ beantester/legal.py | 12 ++++++++++++ licenses/libtommath-LICENSE.txt | 26 ++++++++++++++++++++++++++ tests/test_sbom.py | 6 ++++++ tools/sbom.py | 4 ++++ 6 files changed, 68 insertions(+) create mode 100644 licenses/libtommath-LICENSE.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d23e9d0..69a86f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ## [Unreleased] +### Docs +- **One more component named in the licence list.** `libtommath` now appears in the About window, + in `--license` and in the third-party notices, with its licence and where its source lives. It + ships with the program because the graphical interface needs Tk, and Tk brings it along - it is + public-domain software and nothing here calls it directly. Nothing about how the program works + has changed. It was missing from the list, and a list that claims to be complete has to be. + ## [0.5.0] - 2026-08-12 **The short version.** This release is about not losing your files when the program is diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index f2d9ba1..519923e 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -160,6 +160,19 @@ version is linked above. Contact: https://donislawdev.com/ --- +## libtommath + +* Copyright: none claimed - the authors dedicate the work to the public domain +* Used under: **the Unlicense** (a public-domain dedication) +* Licence text: `licenses/libtommath-LICENSE.txt` +* Source code: https://github.com/libtom/libtommath +* Ships as `libtommath.dll`, as part of the CPython Windows runtime. Python 3.14 + ships Tcl/Tk 9.0, and Tcl 9 links libtommath for its arbitrary-precision + integer arithmetic. Nothing in this project calls it directly - it arrives + because the graphical interface needs Tk. + +--- + ## Microsoft C Runtime (`ucrtbase.dll`, `VCRUNTIME140*.dll`, `api-ms-win-*.dll`) * Copyright (c) Microsoft Corporation diff --git a/beantester/legal.py b/beantester/legal.py index 4df6d9e..0d15e0b 100644 --- a/beantester/legal.py +++ b/beantester/legal.py @@ -65,6 +65,18 @@ ("libffi", None, "MIT-style (text inside Python-LICENSE.txt)", "https://github.com/libffi/libffi", "MIT"), + # 🔴 Found the same way, six days later (bundle scan, 2026-08-17), and it + # arrived without a commit of ours: Python 3.14 ships Tcl/Tk 9.0, and Tcl 9 + # links libtommath for its bignum support. `libtommath.dll` sits in the + # CPython DLLs directory, so it lands in the bundle with everything else Tk + # needs. Nothing in any requirements file names it - which is the whole reason + # the scan guards this list instead of the other way round. + # Licence read at the source (libtom/libtommath LICENSE): "This is free and + # unencumbered software released into the public domain", pointing at + # unlicense.org - so SPDX `Unlicense`, and compatible with GPLv3. + ("libtommath", None, "public domain (the Unlicense)", + "https://github.com/libtom/libtommath", + "Unlicense"), # 42 files, over half the bundle by count: ucrtbase, VCRUNTIME140(_1) and 39 # `api-ms-win-*` ApiSet stubs. The stubs were nearly missed - they are easy to # read as Windows itself rather than as something we redistribute. diff --git a/licenses/libtommath-LICENSE.txt b/licenses/libtommath-LICENSE.txt new file mode 100644 index 0000000..b23b3c8 --- /dev/null +++ b/licenses/libtommath-LICENSE.txt @@ -0,0 +1,26 @@ + The LibTom license + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/tests/test_sbom.py b/tests/test_sbom.py index 65370f6..e7bc167 100644 --- a/tests/test_sbom.py +++ b/tests/test_sbom.py @@ -72,9 +72,15 @@ def test_the_declared_licences_are_real_spdx_and_match_the_registry(): it looks authoritative to a tool that cannot check it.""" doc = sbom.build() by_name = {p["name"]: p for p in doc["packages"]} + # Hand-curated on purpose: the point is to catch an INVENTED identifier, and a + # list fetched at test time would need the network. Each entry was read off the + # official SPDX list before being added here - "Unlicense" (full name "The + # Unlicense", active, OSI-approved) checked at spdx.org/licenses on 2026-08-17 + # when Tcl 9 brought libtommath into the bundle. known_ids = { "GPL-3.0-only", "LGPL-3.0-only", "GPL-2.0-only", "LGPL-3.0-or-later", "GPL-2.0-or-later", "BSD-3-Clause", "PSF-2.0", "TCL", "Zlib", "MIT", + "Unlicense", } known_exceptions = {"Bootloader-exception"} diff --git a/tools/sbom.py b/tools/sbom.py index 26ed44a..cf09406 100644 --- a/tools/sbom.py +++ b/tools/sbom.py @@ -159,6 +159,10 @@ def build(namespace_seed=None): (r"windivert", "WinDivert"), (r"^zlib", "zlib"), (r"libffi", "libffi"), + # Arrives with Tcl 9 (bignum support), not with any requirement of ours. The + # scan named it `\_internal\libtommath`, hence a substring match rather than + # an anchored one - `re.search`, so the path prefix does not matter. + (r"libtommath", "libtommath"), (r"^pydivert", "PyDivert"), (r"^psutil", "psutil"), (r"pyinstaller", "PyInstaller (bootloader)"),