0049 — M9 packaging: constructor installer architecture (offline base env + isolated sidecar)
- Status: accepted
- Date: 2026-07-13
- Deciders: bioedca
- PRD anchor: §4.1 (technology stack / installers), §9 M9 (packaging & docs), §12.7 (release pipeline)
- Milestone: M9
Context and problem statement
M9 ships Tether as signed, self-contained desktop installers for Windows, macOS and Linux:
"installers install clean on Windows + Mac + Linux" (§9 M9), built with constructor (PRD §4.1
names it). The installer must bundle two of the repo's three isolated conda stacks and honour every
load-bearing invariant:
- Two conda-lock stacks stay isolated (PLAN §1.3, [[ADR-0004]]): the base GUI/compute stack
(
conda-lock.yml— PySide6/napari on current numpy) and the trimmed tMAVEN sidecar (sidecar/conda-lock.yml—numpy<2/PyQt5,biasdomitted, numba upper-bounded). They must land as two separate environments inside one installer, never merged into one interpreter. - Pin-and-hold (PRD §4.1, [[ADR-0004]]): the installer must bundle the exact locked packages, never re-solve fresh at build time.
- No install-time git/network (§9 M9): everything the two envs need is bundled at build time; the installer resolves fully offline.
- Two components are not conda packages and cannot come from a channel:
tetheritself is a hatchling/hatch-vcswheel, not a conda package.- tMAVEN is a
pip/git project (not on conda-forge), the source of the sidecar'svbFRET/ consensus VB-HMM / ebFRET drivers, withbiasddeliberately omitted (sidecar env header). - The optional
deep/GPU stack ([[ADR-0047]]) is a terminal optional add-on ("CPU base app unaffected", §9 M8) — a heavy torch/CUDA world that must not bloat the base installer. - The final installers must be code-signed (Authenticode on Windows,
productsign+ notarization on macOS) — which requires signing secrets/certificates that only the maintainer holds.
Constructor's runtime model makes (2)–(4) tractable: it fetches every package at build time and
embeds them, so the produced installer is offline by construction; a secondary env is declared with
extra_envs; and non-conda payloads ride along via extra_files + a post_install script.
Decision drivers
- Honour pin-and-hold: bundle the frozen locks byte-for-byte, no re-solve ([[ADR-0004]]).
- Keep the two stacks isolated: the sidecar is its own environment, never in the base interpreter.
- Offline by construction: no git/network at install time (§9 M9).
- Keep the base app / base lock untouched and the required 3-OS matrix green — packaging is
heavy, network-bound and cannot run in the required
testmatrix (mirrors [[ADR-0047]]'s reasoning for the deep leg). - Ship the GPL license texts beside/inside the installer (REUSE/GPL compliance).
- Never fabricate: bundle real, locked, buildable artifacts — no stub env, no placeholder wheel.
Considered options
- A.
constructorwith rendered explicit locks + offline wheels + anextra_envssidecar. Base env from the rendered per-platform explicit lock (conda-lock render→@EXPLICIT), so constructor installs the exact pinned URLs and never re-solves (see the base-env restructure correction below: constructor requirescondainbaseforextra_envs, so the GUI stack moved to anextra_envsandbasebecame a thin python+conda bootstrap — the pinned locks are unchanged); thetetherwheel bundled viaextra_filesandpip install --no-index --no-depsin apost_installscript; the trimmed sidecar declared as a constructorextra_envsfrom the renderedsidecar/conda-lock.yml, with the tMAVEN wheel bundled and offline-installed into it andTETHER_SIDECAR_PYTHONwired to the bundled sidecar interpreter. Thedeep/stack is not bundled. Signing is layered on later. - B. PyInstaller / Briefcase one-file bundle. Rejected: napari/PySide6 + Qt/OpenGL plus a second
numpy<2/PyQt5 world is precisely what conda environments isolate cleanly; a freezer fights the dual-numpy isolation and the GL stack, and constructor is the PRD-named tool. - C.
conda-packof pre-built envs. Rejected: produces a tarball, not a user installer — no guided install, menu/uninstall, or per-OS packaging; doesn't meet "installers install clean". - D. Also bundle the
deep/GPU stack. Rejected: bloats every install with a heavy DL framework and breaks "optional / CPU base unaffected" (§9 M8); torch-CUDA stays the documented separate install ([[ADR-0047]]).
Decision outcome
Chosen: Option A. It is the only option that honours both pin-and-hold (rendered explicit
locks, no re-solve) and the two-isolated-stacks invariant (the sidecar as a constructor
extra_envs), while producing an offline, GPL-compliant, per-OS installer.
This PR (PR-1a) is the recipe + advisory build leg only — UNSIGNED. Concretely it lands:
packaging/construct.yaml— the recipe: base env from the per-platform rendered explicit lock (platform-selected),extra_envs.sidecarfrom the rendered sidecar lock,extra_filesfor the two wheels + the GPLlicense_file,post_installscripts,installer_typeper OS (exe/pkg/sh), and the signing keys present but documented-and-unset (constructor leaves installers unsigned by default) so PR-2 turns them on with secrets.packaging/scripts/post_install.{sh,bat}— offlinepip --no-index --no-depsof thetetherwheel into the base env and the tMAVEN wheel intoenvs/sidecar, then persistTETHER_SIDECAR_PYTHONpointing at the bundled sidecar interpreter..github/workflows/packaging.yml— a non-required,workflow_dispatch-only 3-OS leg (render locks → build the two wheels →constructor→ networking-blocked install-smoke:tether --version, then the bundled sidecar interpreter importstmaven/PyQt5 offline). It is advisory by construction (nopull_request/push/merge_grouptrigger), so it can never become a required merge check — the same posture asdeep-gpu.yml([[ADR-0047]]).tests/test_marker_contract.py— a new clause lockingpackaging.yml's advisory shape (dispatch-only; runs the offline install-smoke), so the leg cannot silently become gating.
Signing / notarization is deferred to PR-2 (release.yml, §12.7) where the Authenticode
certificate and Apple notarization credentials are wired as repository secrets — constructor's
windows_signing_tool / signing_identity_name / notarization_identity_name are simply unset in
this slice. This is the standard constructor pattern (build unsigned; sign in the release pipeline),
not a stub: the recipe and the artifacts it bundles are real, locked and buildable on the advisory leg.
Consequences
- Good: pin-and-hold is honoured (rendered
@EXPLICITlocks — no re-solve); the two stacks stay isolated (base interpreter +envs/sidecar); the installer is offline by construction; the base app, the three locks and the required 3-OS matrix are untouched (schema-guard + conda-lock-verify green — this PR adds only packaging config, a docs page and a workflow); the advisory leg exercises the real 3-OS build without ever gating a merge; the contract test keeps the leg honestly advisory. - Bad / trade-off: the full 3-OS build is validated only on the non-required leg, not the
required matrix (identical posture to
deep-gpu.yml); the tMAVEN wheel must be built from its pinned source at build time and offline-installed (apost_installstep, not a channel package); the GUI desktop menu shortcut is deferred — there is currently no GUI console-script entry point (only thetetherCLI), so the install-smoke targets the headlesstether --version+ the sidecar-resolves-offline clause (the roadmap's actual PR-1 acceptance), and amenuinstshortcut for the PySide6 shell is a follow-up; signing is deferred to PR-2. - Follow-up: PR-2 (
release.yml) adds Authenticode + notarization signing, SBOM (CycloneDX/Syft over both stacks), checksums, provenance/SBOM attestations and the changelog/publish pipeline, driven by a signedv*tag; a later slice adds the GUI menu shortcut; PR-6 cutsv1.0.0.
More information
- No new §11.2 tunable — this is packaging infrastructure, not a scientific parameter.
- New files:
packaging/construct.yaml,packaging/scripts/post_install.sh,packaging/scripts/post_install.bat,packaging/README.md,.github/workflows/packaging.yml,docs/packaging.md; contract clause intests/test_marker_contract.py. - Locks consumed (unchanged): root
conda-lock.yml(the frozen Tether GUI/runtime installed asextra_envs.tether) andsidecar/conda-lock.yml(installed asextra_envs.sidecar), rendered per platform at build time. Constructor's ownbaseis a live-solved Python + conda bootstrap and is not part of the reproducibility bill of materials.deep/conda-lock.ymlis intentionally not consumed or bundled by constructor;release.ymlpublishes it only as the standalonedeep-conda-lock.ymlreproducibility asset for the optional deep environment, so no deep packages enter the desktop installers. - Related: ADR-0004 (pin-and-hold + dual-lock isolation), ADR-0047 (optional stack + the non-required advisory CI-leg precedent), ADR-0010 (the standalone-tMAVEN GUI hand-off deferred to M9 — the sidecar's PyQt5/GUI role the bundled sidecar env preserves).
Correction — base-env restructure (PR-1b, 2026-07-13)
The first real 3-OS constructor build (the advisory packaging.yml leg, dispatched for the first
time in PR-1b) surfaced a hard constructor constraint that PR-1a's --render/schema validation
could not catch, because it is enforced only during the solve, not at parse time:
RuntimeError: conda needs to be present in 'base' environment for 'extra_envs' to work(constructor/fcp.py: raised unconditionally whenextra_envsare declared andcondais not among the base env's package records — the installer uses the base env'scondato create each extra env offline at install time).
PR-1a's recipe made the PySide6/napari GUI stack the constructor base (from its explicit lock)
and the sidecar the only extra_envs. That base has no conda, so the build failed. The only ways to
add conda to that base would be to put conda into the frozen GUI conda-lock.yml — forcing a
re-solve of the pinned runtime stack and bloating every install with the conda machinery — which
violates pin-and-hold and this ADR's own "base lock untouched" consequence. Rejected.
Corrected architecture (implemented in PR-1b): base is a thin python + conda bootstrap,
solved fresh from conda-forge at build time (installer plumbing, version-inert — it holds no Tether
runtime code). Both pinned runtime stacks are now extra_envs, consumed byte-for-byte from their
committed explicit locks: the GUI/compute stack at envs/tether and the isolated tMAVEN sidecar at
envs/sidecar. Consequences of the correction:
- Pin-and-hold is strengthened, not weakened: both frozen locks are consumed verbatim as
extra_envs(no re-solve); only the throwaway bootstrapbaseis solved live, and it carries nothing that affects the app. - Isolation is unchanged:
envs/tether(PySide6 / current numpy) andenvs/sidecar(PyQt5 / numpy<2) are still two separate interpreters that never share a process ([[ADR-0004]]). - The
tetherwheel is now offline-installed intoenvs/tether(was:base); the app launches fromenvs/tether(envs/tether/bin/tether, orpython -m tetheron Windows).TETHER_SIDECAR_PYTHONis wired viaenvs/tether'sactivate.d, resolving the siblingenvs/sidecarinterpreter. initialize_conda: falseis set so the bootstrapcondanever offers to modify the user's shell — this stays a self-contained GUI app, not a conda distribution.