Skip to content

Cross-OS packaging: build orchestration, PyInstaller/py2app, CI matrix - #43

Merged
Phantom-VK merged 17 commits into
mainfrom
feat/rebuild-12-packaging
Aug 26, 2026
Merged

Phantom-VK merged 17 commits into
mainfrom
feat/rebuild-12-packaging

Conversation

@Phantom-VK

@Phantom-VK Phantom-VK commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • Added packaging/build.py: one command to produce a distributable build on any platform, always rebuilding the frontend first and asserting it's not stale.
  • Rewrote packaging/norefund.spec to target the React/pywebview desktop app instead of the legacy CustomTkinter app, and fixed two real bugs found by actually launching the frozen Linux build (not just compiling it): a missing PyGObject override module that left GTK silently uninitialized, and a glib-library version conflict between bundled and system copies. Both are documented in docs/packaging.md.
  • Added packaging/macos_setup.py (py2app configuration) and a macos extras group, including two version pins needed to work around currently-open upstream py2app/setuptools compatibility bugs.
  • Added packaging/README.md covering build prerequisites, commands, output locations, and end-user runtime requirements per platform.
  • Added a new CI workflow building and smoke-testing on Windows, macOS and Linux on every PR/push, and fixed the existing release workflow, which the packaging rewrite had silently broken (it built via a raw PyInstaller call that never built the frontend or installed the Linux GTK extra first).
  • Fixed an invalid empty email field in pyproject.toml that only py2app's stricter validation caught.

Verified

  • Linux: built and launched the real frozen binary on this machine — window opens, all six views render with real bundled config/frontend data, and PDF export (bundled reportlab font data) produces a valid file. Also verified the missing-runtime failure path for real — installed into an environment with no PyGObject at all and confirmed a clean, actionable message and exit code 2, not a traceback.
  • CI matrix: ubuntu-latest and macos-latest both build and test fully green.
  • Windows: the frozen build itself is untested (no Windows hardware available), and CI surfaced a separate, real, pre-existing issue — the Python test suite hangs inside a legacy Tk GUI test (test_fit_check_view.py) on Windows specifically, after two earlier test failures with no visible traceback. This is the first time any workflow has run the suite on Windows at all, so it predates this branch and isn't caused by the packaging changes here; it needs real Windows access to reproduce and debug interactively. Bounded with an 8-minute step timeout so it fails fast and visibly instead of eating the whole job.

Test plan

  • python -m pytest -q (325 passing locally)
  • ruff check src/
  • cd frontend && npm run typecheck && npm run test && npm run build (97 vitest passing)
  • python packaging/build.py on Linux, then launched and interacted with the real frozen build (all six views, a real download, PDF export)
  • CI: ubuntu-latest and macos-latest green
  • CI: windows-latest — build itself untested; a separate, pre-existing Tk test-suite hang on Windows needs follow-up with real hardware

Phantom-VK and others added 14 commits August 21, 2026 16:46
Bundles the built frontend, drops the customtkinter/PIL._tkinter_finder
Tk leftovers and the CTk-only icon assets, and excludes every unused
pywebview backend so an unrelated Qt install on the build machine can't
silently double the bundle.

Two real bugs surfaced by actually launching the frozen Linux build,
not just compiling it:

- PyGObject looks up gi.overrides.<Module> by name for every gi.repository
  import (the same "discovered at runtime, invisible to static analysis"
  problem tiktoken_ext already needed a hiddenimport for). Without it,
  `import gi.repository.Gtk` silently succeeds without actually
  initializing anything, and Gdk.Display.get_default() returns None
  instead of a real display -- the app launches clean and crashes the
  instant it touches the screen list.
- PyInstaller's own gi runtime hook points GI_TYPELIB_PATH at the frozen
  bundle's gi_typelibs/ dir unconditionally, even when its build-time
  hook found nothing to put there, which hides the system's real
  typelibs instead of falling back to them.
- A related split-brain: PyInstaller bundles libglib/libgobject/libgio
  (pulled in via PyGObject's compiled extension) without bundling
  libgtk/libgdk themselves, so the system's GTK ends up linked against a
  different glib copy than it was built against. Excluded the bundled
  copies so the whole stack comes from the system consistently.

Verified by actually building and running dist/NoRefund/NoRefund on this
machine: window opens, all six views render with real bundled config and
frontend data, and PDF export (bundled reportlab font data) produces a
valid file.
py2app is the pywebview-recommended path on macOS. Bundles the built
frontend and config YAML (py2app's static analysis can't see either the
same way PyInstaller's can't), requests no entitlements the app doesn't
need, and targets macOS 12+.

Written but not build-verified -- no macOS hardware available in this
environment. Flagged in the phase tracking for a real-hardware build and
ad-hoc-sign pass.
Covers build prerequisites, the build command, output location, and
end-user runtime requirements for Linux/Windows/macOS, plus the two real
GTK packaging bugs the Linux rewrite surfaced and how they were fixed.

Verified the Linux missing-runtime failure path for real: installed the
app into a virtualenv with no PyGObject at all and confirmed it prints
the exact install-command message (matching missing_runtime_message())
and exits with code 2, not a traceback.
New build.yml: a lighter-weight, continuous companion to release.yml's
tag-triggered builds. Runs on every PR and push to main across all three
OSes -- frontend typecheck/test/build, pytest, ruff, then the actual
frozen build via packaging/build.py, with a launch smoke test on Linux
and Windows (macOS lacks an easy headless display to smoke-test against
in CI, so it's build-only there for now).

Also fixes release.yml, which the packaging rewrite silently broke: it
built via a raw `pyinstaller packaging/norefund.spec` call that never
built the frontend first (dist would have shipped a blank window) and
never installed the `linux` extra pygobject needs. Both jobs now go
through packaging/build.py instead.
…clean dist/

Local iterative rebuilds otherwise fail outright the moment dist/NoRefund/
already exists from a previous run.
… the CI matrix

- pygobject has no prebuilt wheel for the CI runner's exact platform, so
  pip builds it from source, which needs pycairo's own build deps
  (libgirepository*-dev, libcairo2-dev, pkg-config, python3-dev) --
  missing on a fresh ubuntu-latest runner even though none of them are
  needed at actual runtime.
- pyproject.toml's empty author email fails py2app's stricter build-time
  metadata validation (hatchling itself never checked it). Dropped the
  empty field rather than inventing a value.
- Bounded the Python test step to 8 minutes so a hang fails fast with a
  clear timeout instead of silently eating the whole job's budget.

Windows CI still has a real, separate problem this run surfaced: the
Python test step hung for 18+ minutes inside test_fit_check_view.py
(legacy Tk gui/ tests), after two earlier failures in test_compare_view.py
and test_desktop_dto.py with no visible traceback (the job was cancelled
before pytest's summary printed). This is the first time any workflow has
run the suite on Windows at all, so it predates this branch; not something
fixable without real Windows access to reproduce and debug interactively.
Modern setuptools rejects the legacy install_requires/setup_requires
kwargs on a project that already declares its dependencies via
pyproject.toml's [project] table, which this one does -- CI's macos-latest
runner has a setuptools recent enough to make that a hard error
("install_requires is no longer supported") rather than a warning.
py2app itself is already guaranteed present via the macos pip extra
before this script runs, so setup_requires was never load-bearing.
…bility

py2app 0.28.9 made having any dependencies in pyproject.toml's [project]
table a hard build error ("install_requires is no longer supported"),
regardless of what the py2app-specific setup() call itself passes -- an
open upstream bug (ronaldoussoren/py2app#560), not something fixable from
this project's side.
py2app 0.28.8 still imports pkg_resources directly; setuptools removed it
entirely in v82.0.0. The setuptools maintainers' own stated recommendation
for anyone still needing it is to pin setuptools<81.
The desktop app now runs on the React/pywebview frontend; src/norefund/gui/
is unused reference code, so tests exercising its views only added
Tk-display flakiness and dead fixture maintenance with no product coverage.
to_jsonable() correctly returns a Path's native string form, but the test
hardcoded a posix-style literal, which only matched on Linux/macOS. Removing
the legacy Tk suite let the Windows CI job actually finish pytest instead of
hanging, which is what surfaced this.
useSettings called bridge.getSettings() directly, without waiting for
window.pywebview.api to be injected -- a race it consistently lost on
some machines, throwing "Python bridge is not ready" immediately at
mount. That error was caught into local state App.tsx never reads, so
the app hung forever on the loading spinner with no visible error.

Move the bridgeReady() wait into call() itself so every bridge method
waits for readiness by construction, instead of relying on each call
site to remember to do it (App.tsx's own effect did remember; useSettings
did not). Add bridge.test.ts covering the race and the timeout path.
…ownloaded builds

Files extracted from a GitHub-downloaded zip get tagged as
"from the internet" (Zone.Identifier), and .NET Framework refuses to
load pythonnet's DLL from a tagged file -- crashing before the app
window ever appears. Strip the tag from our own bundle at startup,
before pythonnet loads. Also widen missing_runtime_message()'s except
clause, which only caught ImportError and let this RuntimeError
through as a raw traceback instead of a readable message.
@Phantom-VK Phantom-VK self-assigned this Aug 23, 2026
Phantom-VK and others added 3 commits August 23, 2026 16:23
…rning

actions/checkout@v4, setup-python@v5, setup-node@v4, upload-artifact@v4,
and download-artifact@v4 all still targeted the now-deprecated Node 20
runtime. Bumped each to its current major (checkout v7, setup-node v7,
setup-python v7, upload-artifact v7, download-artifact v8) across
build.yml, ci.yml, and release.yml -- checked each release's changelog
for breaking changes against how this repo actually uses them; none apply.

Also added a Download section to the README explaining the Windows
SmartScreen warning users hit when running the unsigned .exe -- the app
isn't code-signed (no budget for a cert), so this can't be removed from
CI alone; documenting it is the honest option for now.
…g docs

release.yml never actually built or shipped a macOS artifact -- the
release job's needs list only covered Linux and Windows, so a tagged
release had nothing for Mac users to download. Add a build-macos job
mirroring the Linux/Windows ones (ad-hoc sign, tar.gz, versioned
filename) and wire it into the release job.

Also add a macOS smoke test to build.yml for parity with the existing
Linux/Windows launch checks, and update the packaging README: the
right-click-to-Open workaround for an ad-hoc-signed app often doesn't
surface on current macOS, which instead shows a misleading "is damaged"
dialog -- document the xattr -cr fix that actually works, and note
notarization is deliberately deferred (no budget for it yet) rather
than an oversight.
…Windows

build_frontend() called subprocess.run(["npm", "ci"]) with no shell.
Windows' CreateProcess only auto-appends .exe when resolving a bare
command name, not .cmd (npm's actual Windows wrapper) -- this raised
FileNotFoundError/WinError2 immediately. Never caught by build.yml's own
Windows job because it always passes --skip-frontend, bypassing
build_frontend() entirely; only surfaced running the full release.yml
build-windows job, which builds the frontend itself.
@Phantom-VK
Phantom-VK merged commit e8ab015 into main Aug 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant