How boldcurator desktop gets turned into something a curator double-clicks.
.github/workflows/python-release.yml runs exactly this on a matrix of
Windows, macOS and Linux runners; this file documents it for a local build
and records what has and hasn't actually been verified to work.
cd python
pip install -e ".[desktop]"
pip install pyinstaller
pyinstaller --onedir --name boldcurator --paths src \
--collect-all duckdb \
--collect-all shiny \
--collect-all htmltools \
--collect-all shinychat \
--collect-all pywebview \
--collect-all pythonnet \
--collect-all clr_loader \
--collect-all Bio \
--collect-all boldcurator \
--copy-metadata boldcurator \
packaging/entrypoint.pyThe output is dist/boldcurator/ (an executable plus its dependencies,
--onedir rather than --onefile for faster startup -- nothing here
self-extracts on every launch). Zip that directory for distribution; the
snapshot file itself is never bundled -- the first-run setup screen
(ui/setup.py) gets it separately, which is what keeps this small
regardless of snapshot size.
macOS is built differently -- as a .app bundle, with its minimum
macOS enforced, zipped with ditto on the Mac itself. See "macOS: why a
.app bundle" below for why every one of those matters:
python packaging/macos_deployment_target.py repin --target 11.0
pyinstaller --onedir --paths src --windowed --name BOLDcurator \
--icon packaging/icon.icns \
--osx-bundle-identifier io.github.bge-barcoding.boldcurator \
--collect-all ... # the same --collect-all/--copy-metadata list as above
packaging/entrypoint.py
python packaging/macos_deployment_target.py check --target 11.0 dist/BOLDcurator.app
codesign --verify --deep --strict dist/BOLDcurator.app
ditto -c -k --keepParent dist/BOLDcurator.app boldcurator-macos-arm64.zipPyInstaller's static import analysis finds Python code fine; it does not
find a package's own data files (JSON, HTML, JS, CSS bundled alongside the
code) unless told to. --collect-all <package> bundles a package's code,
data and binaries unconditionally, which is blunt but reliable -- the
alternative (a hand-maintained list of hook exceptions) is exactly the kind
of thing that silently rots the next time a dependency updates.
- duckdb: a compiled extension; without this,
import duckdbinside the frozen build fails to find its native library. - shiny, htmltools: Shiny's own static assets (the JS/CSS the browser side needs) live inside the package, not as Python source PyInstaller's import graph would otherwise find.
- shinychat: the one that actually broke a real build. Shiny pulls
in
shinychat(used for its chat UI components) as a dependency even though this app never uses chat;shinychatreads a JSON file (www/attachment-types.json) at import time, not lazily, so its absence crashes the app before a single line ofboldcuratorcode runs, with a traceback that gives no hint the fix is--collect-all shinychatrather than anything in this codebase.--collect-all shinyalone does not bundleshinychat's own data files -- they are two separate packages. - pywebview: the native-window wrapper (
desktop.py, plan 4.1a). - pythonnet, clr_loader: on Windows, every one of pywebview's
backends (winforms, edgechromium, mshtml) bridges to .NET through these
two, and PyInstaller has a long history of not bundling their runtime
config/data files correctly (see "The Windows native-window failure"
below) --
--collect-all shinynot coveringshinychat's data files was the same shape of problem, one layer down in a different dependency. - Bio: the other one that actually broke a real build, on a real
Windows machine, in the Phylogeny tab (
core/phylogeny.py) -- and it took two tries. The first attempt used--collect-all biopython, which builds clean and still breaks identically:pip install biopythoninstalls a distribution namedbiopython, but the only importable top-level packages it provides areBioandBioSQL(python -c "import importlib.metadata as md; print(md.distribution('biopython').read_text('top_level.txt'))"prints exactly that).--collect-alltakes an import name, not a PyPI distribution name, so--collect-all biopythondoesn't error -- it silently collects nothing at all.biopython/Biois one of the classic examples of this mismatch, alongsidebeautifulsoup4/bs4andpyyaml/yaml.--collect-all Biois the actual fix. ImportingBio.Phylo.TreeConstruction(forDistanceMatrix/DistanceTreeConstructor) transitively importsBio.Align, whoseDistanceCalculatorclass body -- executed the instant the module is imported, whether or not that class is ever used -- callssubstitution_matrices.load(), which doesos.listdir()on adata/directory shipped as non-.pypackage data. Exact same failure shape asshinychatabove: the code that references the directory bundles fine, the directory itself doesn't, and the traceback ([WinError 3] The system cannot find the path specified: ...\Bio\Align\substitution_matrices\data) gives no hint the fix lives in the PyInstaller command rather than incore/phylogeny.py. Runboldcurator selftest(seecli.py) against a build to check this without going through the GUI. - boldcurator: the app's own package, not a third-party dependency --
everything above bundles a dependency's data files; this one bundles
this project's own, specifically
ui/static/phylo/(the Phylogeny tab's JS/CSS, served at runtime via Shiny'sstatic_assetsfrom aPath()string inui/app.py, never through animportPyInstaller's own analysis could trace). Diagnosed on a real build's exact symptom: the tab's server-rendered HTML (representative count, monophyly badges) showed up fine -- the Python side ran end to end -- but the tree canvas itself was blank, becausephylo-init.js404'd andwindow.bcRenderPhylotreewas never defined. A<script>calling an undefined function fails silently in the console; nothing in the app itself would have told a curator why.--collect-all boldcuratorworks the same way it does for every dependency above --boldcuratoris already importable via--paths src(the same thing that letspackaging/entrypoint.pyfind it at all), so this walks its own package tree for data files exactly like any other entry in this list. --copy-metadata boldcurator: the app's version.boldcurator.__version__(the app header,--version, the Zenodo User-Agent) is read from the package'sboldcurator-<version>.dist-info, which--collect-all boldcuratordoes not bundle from an editable install: importlib can't map theboldcuratorpackage back to its distribution there, so the metadata copy is skipped without any error. Every desktop release up to this flag reported0.0.0+unknown. The release workflow's--versionsmoke test caught it, and now fails any build that doesn't report the version stamped from the tag (see "Releases").
A curator running a real built zip on Windows got this, straight out of the box:
RuntimeError: Failed to resolve Python.Runtime.Loader.Initialize from
C:\Users\...\_internal\pythonnet\runtime\Python.Runtime.dll
This is a known, still-open issue in the pywebview/pythonnet/PyInstaller
ecosystem (r0x0r/pywebview#1215, #1292, #1638; pythonnet/clr-loader#74) --
environment-specific, sometimes inconsistent between machines or even
between runs on the same machine, and not reliably fixed by any single
--collect-all/--hidden-import combination people have tried. Rather
than chase it indefinitely, this project treats a native window as a
best effort, not a requirement, and now has three window strategies
(desktop.WINDOW_MODES, and boldcurator desktop --window <mode>):
native-- pywebview, embedded via pythonnet/.NET. The failure above is this strategy's failure mode.browser-app-- a Chromium-based browser (Edge, bundled with every current Windows install, or Chrome) launched with--app=<url>: no tabs, no address bar, its own taskbar entry. Visually just as native as pywebview, but launched as a plain subprocess -- it never touches pythonnet at all, so it cannot hit this failure. This is the practical fix: not a better way to make pythonnet work, but a way to stop needing it to.tab-- a plain browser tab. Always works, looks like a browser.
--window auto (the default) tries them in that order, falling back
silently -- so a curator whose machine hits the pythonnet failure now gets
a browser-app window instead of a crash, with no configuration needed.
Forcing one directly (--window native, --window browser-app) is for
comparing them on a given machine: a forced strategy that fails raises
rather than silently recovering, so the comparison is never masked.
--collect-all pythonnet --collect-all clr_loader are also in the build
(the same fix shape as shinychat below) as a first line of defense for
the native path, on the chance they help in some environments even
though reports suggest they don't for everyone.
If browser-app mode itself turns out to need more polish (a custom
taskbar icon, for instance -- Edge/Chrome's own app-mode window uses a
generic or favicon-derived icon, not this project's), the next thing worth
trying is pywebview's separate win32 GUI backend (pywin32 + comtypes,
no pythonnet/.NET at all) -- less actively maintained and missing some
features, but it would keep the window embedded rather than a subprocess.
Not attempted here; launching a real browser was the more robust fix for
less effort.
Up to v3.2 the macOS release was the same bare --onedir folder as Linux:
a Unix executable next to _internal/, holding ~150 separate Mach-O
binaries (every .so extension module in numpy, pandas, duckdb, Bio,
pyobjc..., plus Tcl/Tk and OpenSSL dylibs), all only ad-hoc signed.
Apple Silicon curators reported an admin password prompt for "each script
within the app". What was actually happening:
- A browser-downloaded zip is quarantined, and Archive Utility copies the quarantine flag onto every extracted file.
- A bare folder isn't a bundle, so Gatekeeper had no single app to
approve -- it assessed each quarantined binary on its own, the moment
the app
dlopened it. - Each one failed ("“_multiarray_umath.cpython-311-darwin.so” Not Opened"), and the only way past it on current macOS is System Settings → Privacy & Security → Open Anyway, which asks for an admin password. Once per binary. (macOS 15 also removed the old right-click → Open bypass the website FAQ used to suggest.)
Windows never showed this because SmartScreen only checks the .exe you
launch, never the DLLs it loads afterwards.
The fix has four parts, all in python-release.yml:
--windowedmakes PyInstaller emitdist/BOLDcurator.app. Gatekeeper assesses a bundle as one unit, so an unsigned build should need a single "Open Anyway" instead of one per binary -- and it's double-clickable, where the bare executable opened in Terminal. (Only a Developer ID signature plus notarisation removes that one remaining prompt; see "Code signing" below.)- Zipped with
dittoon the Mac, and the release job never re-zips it. The other platforms go throughupload-artifact→ a Linux runner →zip, which drops every symlink. v3.2's zip had zero symlinks andPython.frameworkstored four times over -- harmless for a bare folder, but a.appdepends on symlinks for its structure and its signature. CI unzips thedittozip the way Finder does and runscodesign --verify --deep --strictplusselfteston that. - The minimum macOS is enforced (
macos_deployment_target.py). pip on a macOS 15 runner picks macOS-14/15-only wheels whenever a package publishes one: v3.2's arm64 build needed macOS 15 (orjson) and its Intel build macOS 14 (numpy), so even a curator who got past Gatekeeper on an older Mac would have hit "built for macOS 15.0 which is newer than running OS".repinreinstalls those packages'macosx_11_0wheels before PyInstaller runs;checkfails the build if any binary in the.appstill needs anything newer. If a future dependency has no old-enough wheel at all,checkis where you'll find out -- pin an older version of it. - A log file for Finder launches. A
.appdouble-clicked from Finder has no terminal, which is exactly why--windowedwas originally left off (an early exception would vanish).entrypoint.pynow sends output to~/.boldcurator/boldcurator.logfor that one kind of launch -- frozen, no arguments, no terminal -- so a failure always leaves something to ask a curator for. RunningBOLDcurator.app/Contents/MacOS/BOLDcuratorfrom Terminal (with or without arguments) prints normally, as before.
For a curator stuck on an old build, clearing the quarantine flag in one
go skips all the per-file prompts (on a new-enough macOS -- see above):
xattr -dr com.apple.quarantine <the unzipped folder>.
A frozen app carries its own OpenSSL, and plain urllib verifies HTTPS
against the CA file whose path was compiled into it. On macOS that path
belongs to the build machine, so on a curator's Mac there is no CA file at
all, and every Zenodo request failed with CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate (V3.3, Intel Mac). Windows escaped
this only because Python on Windows also reads the Windows certificate
store.
build/fetch_snapshot.py's ssl_context() verifies through
truststore instead: the macOS
Keychain, the Windows certificate store, or the usual distro CA bundles on
Linux. That's also what pip does. Unlike bundling certifi, it trusts an
institution's own root certificate when its network inspects HTTPS.
The runner never showed the bug, since the compiled-in path exists there.
So the release workflow runs
SSL_CERT_FILE=/nonexistent SSL_CERT_DIR=/nonexistent boldcurator selftest --network
on every platform. That leaves no OpenSSL CA file to fall back on, so the
step passes only if HTTPS really goes through the OS. A curator can run
selftest --network too (on a Mac,
BOLDcurator.app/Contents/MacOS/BOLDcurator selftest --network in
Terminal) to tell a certificate problem from a network that blocks Zenodo.
Publishing a GitHub release is all it takes -- any tag name (v3.4,
V3.4, v4), created from the Releases page as usual. Within a few
minutes .github/workflows/python-release.yml attaches the five files the
website's download buttons link to (website/README.md, "Download
links"). Your release notes are left alone; only the assets are added.
Every release is built fresh (about 5 minutes, smoke tests included).
pyproject.toml keeps a placeholder, version = "0.0.0.dev0", in git. Both
release workflows rewrite their own checkout's copy from the release tag
with packaging/stamp_version.py before building (V3.4 -> 3.4.0, v4
-> 4.0.0). So every install route reports the same version -- in the app
header, boldcurator --version, and the User-Agent sent to Zenodo:
- the desktop zips, and the app inside the Windows installer
(
python-release.yml, which also passes it to Inno Setup as the installer's AppVersion); - PyPI, i.e.
uv tool install/pip installand the website'sinstall.sh/install.ps1(python-pypi.yml, with--strict: a tag that isn'tvX,vX.YorvX.Y.Zis refused there rather than guessed at, since a PyPI version can never be reused).
The desktop build fails its smoke test if the built app reports anything
other than the stamped version. A source checkout (pip install -e .)
reports 0.0.0.dev0 -- plainly not a release.
The Windows installer shows the repository's LICENSE (MIT, "as is,
without warranty") as an accept-to-continue page (LicenseFile= in
windows-installer.iss, relative to the script, so it needs the full
checkout the release job already makes).
The Windows installer deletes the previous install's _internal\ before
copying in the new one ([InstallDelete] in windows-installer.iss).
Without that, an upgrade kept the old build's boldcurator-*.dist-info
next to the new one, and importlib.metadata reads whichever Windows lists
first. That's how v3.5.1 installed over a pipeline-check build showed
v0.0.0.dev0 in the app, even though the wizard said 3.5.1 and the CI
--version check passed on the clean build. The same applies to
unzipping a desktop zip over an older one: extract into an empty folder.
Before this, the desktop builds never carried a version at all -- every
one reported 0.0.0+unknown, whatever its tag (see --copy-metadata boldcurator above) -- and a release with no changes
under python/ re-attached the previous release's executables instead of
rebuilding. Once builds carry their version, reused files would report the
wrong one, so that shortcut is gone.
To attach executables to a release that doesn't have them (as V3.3 was
backfilled -- see below), or to rebuild one: Actions → Build
desktop executables → Run workflow, pick main, and enter the release's
tag. Leave the tag blank to build on a branch just to test the pipeline
-- nothing is published then, which is how every packaging fix here was
checked before merging.
Only tags that contain python/uv.lock can be built: the build installs
from it with --require-hashes and makes the SBOM from it. Every tag up to
v3.5.2 predates the lock, so the workflow's plan job refuses them. To
give users a build with an SBOM, checksums and attestations, publish a new
release from main instead.
Up to V3.3 the workflow fired on a pushed tag matching v* instead.
GitHub's tag filters are case-sensitive, so V3.3's capital V never
matched: it got no executables, and every download button on the website
404'd, since releases/latest/download/... always points at the newest
release. The installer's version also came out as 0.0.0-dev for any tag
that wasn't exactly vX.Y.Z (v3.2 included); tags are now padded to
x.y.z (V3.3 -> 3.3.0).
The app tells a curator when a newer release is out
(src/boldcurator/app_update.py). It asks Zenodo, the one host the
app already talks to for snapshots, so it needs no new firewall exception
or privacy note. What Zenodo sees is one GET with the same
BOLDcurator/<version> User-Agent as a snapshot download.
It depends on the repository's GitHub-Zenodo link. Zenodo archives every
published GitHub release under one concept DOI ("cite all versions"),
which always resolves to the newest release. That record's
metadata.version is the release tag, padded to x.y.z exactly as
stamp_version.py pads it. /.zenodo.json supplies the archive's title,
authors, licence and links. It must not set version, or every
archive would report that instead of its tag.
One-off setup, in this order:
- On zenodo.org, go to GitHub, then switch on
bge-barcoding/BOLDcuratoR. - Publish a release. Zenodo archives it and mints the concept DOI, which can't be known before then. It's shown on the record page under "Cite all versions?".
- Put that DOI into
APP_ZENODO_CONCEPT_DOI(config/constants.py). Done: it is10.5281/zenodo.23039646(https://doi.org/10.5281/zenodo.23039646). Releases built before it was set (v3.5.1 and earlier) never check, so their users need telling once, by hand.
How it behaves:
-
Automatic, when the app opens: at most once a day, a 5-second timeout, no retries, and silent on any failure. Offline is normal for this app. A newer release shows a banner at the top of the window with how to update this install:
- installer: run the new installer;
- portable zip: extract into an empty folder, never over the old copy (see the stale dist-info problem above);
- macOS: replace the
.app; - pip/uv:
uv tool upgrade boldcurator.
"Don't remind me about this version" hides it until the next release.
-
On request: the Data tab's "Check for app update" button, or
boldcurator check-update. It always asks afresh and reports failures too. -
Off:
"check_for_updates": falsein~/.boldcurator/config.json, or the environment variableBOLDCURATOR_NO_UPDATE_CHECK=1. A check the curator asks for still runs. -
Development builds (
0.0.0.dev0) never check automatically. -
Notify only: nothing is ever downloaded or installed.
selftest --network, which every desktop build runs in CI, reads the
concept record too once the constant is set. So a wrong DOI, or a record
whose version isn't a vX.Y.Z tag, fails the build rather than silently
never notifying anyone.
Curators can also install without any of the files above:
uv tool install --python 3.11 "boldcurator[desktop]"
boldcurator install-shortcutThe website wraps this in website/install.sh / install.ps1 (one pasted
line, which also installs uv if needed). It exists mainly for macOS. The
.app that install-shortcut writes (src/boldcurator/shortcuts.py) is
created on the curator's own Mac, not downloaded, so it has no quarantine
flag and Gatekeeper never asks about it. That removes the "unidentified
developer" prompt without a Developer ID.
It is built not to touch anything in this folder or in
python-release.yml:
- Separate entry point. The installers still start through
entrypoint.py. The wheel's clickable entry point is a[project.gui-scripts]launcher,boldcurator.launcher:main, which duplicatesentrypoint.py's log-file redirect instead of sharing it, so the installer build is unchanged. - Icons stay here. The wheel copies
icon.ico/icon.icnsfrom this folder intoboldcurator/assets/at build time (hatchforce-include), so--iconand Inno Setup'sSetupIconFilestill find them here. - No new dependencies.
shortcuts.pyuses only the standard library (PowerShell'sWScript.Shellon Windows, notpywin32). A frozen build that bundles it through--collect-all boldcuratorgains nothing, and in a frozen buildinstall-shortcut/remove-shortcutrefuse to act. - Separate publishing workflow. PyPI publishing is
.github/workflows/python-pypi.yml, which runs on the samerelease: publishedevent. The tag becomes the package version with the same x.y.z padding as the installer (V3.3 -> 3.3.0). - Both installs share one data folder. Both use
~/.boldcurator/(config, snapshot, saved sessions,boldcurator.log), so a curator with both never downloads the snapshot twice. - Shortcuts don't collide. The shortcut is named "BOLDcurator (Python)"
and the macOS bundle id is
io.github.bge-barcoding.boldcurator.python, so it never replaces the installer's "BOLDcurator" shortcut or confuses LaunchServices.
python-tests.yml's wheel-install job tests this route end to end on all
three OSes. It builds the wheel, runs the website's own install script
against it, and smoke-tests the result the same way the frozen build is
tested. It also launches the app through the shortcut's own target and
removes the shortcut.
Verified, on Linux, in the sandbox this was built in:
- The CLI (
boldcurator info,boldcurator search) runs correctly from a frozen--onedirbuild, including a real DuckDB query and an xlsx export (openpyxl) -- this is the build's biggest real risk (a compiled extension plus binary data files) and it works. boldcurator guiruns as a frozen build and serves a real, working app: loaded in an actual browser (Playwright), a real search returns real results. This is what caught theshinychatissue below.
Verified on a real Windows machine:
- The frozen zip runs, and
boldcurator desktophits thePython.Runtime.Loader.Initializefailure above -- confirming the problem is real, not sandbox-specific. - The Inno Setup installer (
packaging/windows-installer.iss) builds in CI (after a real failure on its first run -- a manually-triggeredworkflow_dispatchsendsgithub.ref_nameas the branch name, not a version, and that branch name's/madeOutputBaseFilenameinvalid; fixed by only trusting a release tag as a version -- see "Releases" above), installs, and launches the app inbrowser-appmode -- "looks like a regular app," per the project owner's own test. Start Menu entry, desktop shortcut and uninstall have not been individually confirmed beyond that.
Not verified anywhere yet:
pywebview's happy path (a native window that actually opens). It could not even be installed in the sandbox this work was done in -- apip install pywebviewthere fails while building one of its own dependencies (proxy_tools), due to asetuptools/distutilsincompatibility specific to that environment's Debian-patched Python, unrelated to this project's code. The real Windows test above landed onbrowser-appmode without anyone confirming whethernativeitself now works or still hits the pythonnet failure -- both are consistent with what was observed.- The
tabfallback path specifically, on a real machine (covered bytests/test_desktop.py's fault-injection tests, which prove the cascade logic runs correctly and doesn't deadlock or double-read theresolvedqueue, but not that a real browser tab actually opens on a real Windows box --browser-appsucceeding first meanstabwas never reached). - The setup screen's native file "Browse…" button (
ui/setup.py, round 4 item 1): verified in this sandbox only as far as "no display at all" gracefully falls through to "no file chosen" with no hang or crash -- not yet run on a machine with an actual screen to confirm the real Tk file picker opens and returns a usable path. - The placeholder icon (
packaging/icon.ico, a plain "BC" monogram) is exactly that -- a placeholder, swapped in purely so the installer and the browser-app window have some icon rather than a missing one. Replace it with real branding before this ships to curators generally. - The macOS
.app(see "macOS: why a.appbundle" above) on a real, quarantined download: CI proves the bundle builds, its signature verifies after adittoround trip, every binary targets macOS 11, and the frozen CLI and GUI server run from inside it -- but a CI runner never quarantines anything, so the actual "one Open Anyway, not 150" outcome still needs confirming by a curator on an Apple Silicon Mac, ideally one running macOS 11-14.--windowedstays macOS-only: on Windows it would hide the console with no log-file equivalent yet. - A
.dmgor other installer wrapping for macOS -- only Windows has an installer (Inno Setup) so far; macOS ships as a zipped.appand Linux as a plain zip of the--onediroutput. - Code signing (plan 4.4) -- deliberately not done yet, a budget decision
the project owner made explicitly. An unsigned build triggers a Gatekeeper/SmartScreen warning with a manual
override, which is fine for curator testing. This also means the Inno
Setup installer itself is unsigned, so installing it hits the same
SmartScreen warning the plain
.exedoes. On macOS, the.appis ad-hoc signed only (PyInstaller's default), which is what leaves the one remaining "Open Anyway" prompt -- a Developer ID certificate plusxcrun notarytoolandstapleris the step that would remove it.
The shinychat issue is the second time in this project a dependency's own
data files needed an explicit hook (Shiny's own www/ assets were already
covered by --collect-all shiny, until shinychat turned out to be a
separate package with the same problem). If a frozen build crashes with a
FileNotFoundError pointing at a path under a site-packages-style
directory that doesn't exist in the frozen _internal/ folder, the fix is
almost always the same shape: --collect-all <the package the traceback names>, not a change to this project's own code.