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
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> for a
# final release, or v<version>-rc.N (also -beta.N / -alpha.N) for a pre-release.
Expand Down
18 changes: 15 additions & 3 deletions BeanNetworkTester.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions beantester/legal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions licenses/libtommath-LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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 <http://unlicense.org/>
32 changes: 32 additions & 0 deletions requirements-build.txt
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions tests/test_mutation_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/test_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
40 changes: 40 additions & 0 deletions tests/test_version_and_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!-r )\bpyinstaller\b(?!==)", ln)
and pin_file not in ln]
check(f"{path}: never installs pyinstaller unpinned", not loose,
f"({loose} - put the version in {pin_file}, not on the command line)")
4 changes: 4 additions & 0 deletions tools/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"),
Expand Down
Loading