| status | in-force |
|---|---|
| summary | The packaging and update scheme in one page -- buckets and manifests, Pkgversion as the governed version, derived dependencies, and the traps already paid for. |
| since | 2026-08-13 (consolidated from three sessions) |
| skill | fns-packaging |
Graduated 2026-08-22 from
briefs/2026-08-13-packaging-handover.md, which was gitignored despite being scheme-of-record ("nothing above section 6 is historical --- it is current"). One claim was corrected on the way in; the rest is as written. The step-by-step runbook is packaging/RELEASING.md; the distribution model and its history are in ConfiguratorDistribution.md.
Consolidated 2026-08-13 after three sessions. This describes the scheme as it now stands. Earlier revisions of this file described two update models that were built and then reversed; §6 keeps the closed paths so nobody re-treads them, but nothing above §6 is historical — it is current.
Distribution is buckets and manifests. The one-drop FNSTools.tox
bootstrapper is the install rail, and there is no GitHub-based update flow.
(This section originally named native .exe/.dmg installers as the
bootstrap; that was reversed on 2026-08-21 --- see
NativeInstallerDecision.md. Nothing else in the
scheme depended on it.) The bucket is the
single source of truth for what exists; base_url says where to fetch.
Four questions, four different answers — keeping them separate is the whole design:
| Question | Answered by |
|---|---|
| What packages exist, and what do they need? | manifest.json, derived from the live project |
| Is a newer build available? | Pkgversion — a custom par we govern, on every package |
| Where do I fetch it? | the manifest's pinned per-release url |
| Did the download arrive intact? | sha256 — integrity, and nothing else |
Pkgversion is read LIVE off the installed component, not from a
record of what was installed. That is the only thing that works for a
package embedded in a .toe: there is no file to hash and no artifact to
consult, but the component still declares what it is. It also means no
side table can drift out of truth — the component is the truth.
Dependencies are DERIVED, never declared. Registry masters live in
core and tools ship stamped hosts, so a package's requires is exactly
the core packages owning the registries it hosts. Every tool needs
FNS_Config; a toolbar button also needs FNS_Toolbar (pre-3.0 names; since
v3.0.0 the owners are FNS_ConfigRegistry and FNS_ToolbarRegistry). Nothing
hand-maintains it, so it cannot drift. Package identity = a depth-1 COMP
that is a tracked pi_suspect with its own tox.
The one hand-maintained field is Pkgversion. Bump it whenever you
change a package. Forgetting is silent — no install learns anything is
new — so publish.py refuses to stage a new release that bumps nothing
and reports what did move. It cannot catch forgetting one package among
several; nothing can, without a content fingerprint (see §6).
| Pulse | Cost | Does |
|---|---|---|
| Refresh Store | whole store | fetch manifest + every artifact whose bytes differ → <palette>/FNSTools/store/. Machine-wide; touches no project |
| Check for Updates | one small JSON | fetch the manifest only, then compare. Asking "anything new?" must not cost 6 MB |
| Update This Project | only what differs | fetch just the packages this project needs, then apply them |
Compare() is the single decision point and reports:
update · current · unversioned (declares no version — shown, never
touched) · locked (newer, but this copy must not be written) · missing
(recorded installed, component gone).
An update pass is not an install pass — a package the user never chose stays uninstalled.
Applying runs one package per frame (_drain): each replacement
reinitialises extensions, so batching them into one frame is both a long
main-thread block and the crash-prone case.
Chosen on the installer (Package Files). The updater tracks no mode — it follows whatever binding each package actually has, so there is nothing to drift:
| Mode | Files | Update path |
|---|---|---|
embedded (default) |
inside the .toe |
replaceOp from the store artifact |
shared |
bound to the palette store | rewrite + reload (often just a reload) |
project |
<project>/FNStools/ or Package Folder |
rewrite + reload |
A bound package updates by rewriting its file and reloading — no copy/destroy of an extension-bearing COMP, no docked-op juggling, and the change is a file the user can version-control.
project is what delivers isolated local component state: each project
owns its copies, so one can hold a modified package without touching any
other install. shared knowingly reintroduces machine-wide coupling — a
store refresh reaches every project sharing it — because some users want
exactly that. It is not the default.
Settings are safe by construction, not by care: they live in
<palette>/FNSTools/config/FNStools_config.json, never in a .tox,
and RegisterTool(autoload=True) re-applies each tool's section when its
host re-registers after a reload. SaveAll() still runs before any pass.
Verified live this session:
- Store refresh, local/file path — 39 artifacts staged, all hashes verified.
- Store refresh, real HTTP (
python -m http.serveroverpackaging/publish/) — 39/39 fetched and verified, 0 failed, 41 GETs in the server log. - Full project pull on a cooking-disabled
/sys/quiet/trial— installed embedded (nothing on disk), component declared1.0.0; bumped the source to1.1.0, re-exported, re-staged, refreshed;Comparereported exactly that oneupdate; the pass applied it; the component then declared1.1.0at 123 ops, no errors; re-check reported none. - Rewrite-in-place — wrote deliberately DIFFERENT bytes over a bound
.toxand confirmed the live COMP reloaded to match (123 → 96 ops, the donor's children), then a full pass restored it. - Version ordering — unit-checked on 10 cases including
1.9vs1.10, which string comparison gets wrong. - Guards — live authored packages refuse; the scratch copy is allowed.
- Shipped
dist/UPDATER.toxloads clean: extensions ready, no errors,externaltoxempty,updatesDAT present. - Live canary after all of it: Toolbar 37 / Navbar 13 / MainMenu 12 / Config 44, unchanged. (OpMenu/PaneType use accessors I did not find — unverified this session, previously 3 / 2.)
NOT verified — self-update. UPDATER updating its own package destroys
the DAT running the loop, so it is ordered LAST and executed from a
detached run() string (literals only, owned by TDResources' queue). It
cannot be exercised here: on a source checkout UPDATER is refused by the
authored-here guard, and a scratch target can never be the package that is
actually running. Structurally correct, failure bounded — every other
package has landed by then, worst case is re-dropping the tox by hand —
but treat it as unproven until a real bucket install hits it.
The vendored TDFileDownloader (three separate ways to fail silently):
- A request issued from inside the Web Client DAT's own callback is silently dropped — the file lands, the next GET never goes out. Its own
queueNext()re-issues from exactly there, so the internal queue is unusable too: every post-download stage is deferred a frame (_later) and the queue is driven by_pump. - A stale
stateDictentry poisons every later request for that file (keyed url+location; an entry left inGET/WAITmakesDownload()return the stale state instead of fetching). Every job starts withAbortAll(). - A connection that never opens produces NO callback at all — no success, no abort, just a request sitting in
GETforever. Hence the 45 s stall watchdog.
Artifacts and identity:
.toxexport is NOT reproducible. One untouched component exported three times: 66198 / 66190 / 66150 bytes, three hashes, diverging at byte 9 — the container header, before any content.pi_suspectsurvives into the shipped artifacts — it is NOT a dev marker. Neither isexternaltoxa sufficient one. What identifies an authored component is: this project is the source checkout (the packaging generator sits beside it) AND the component lives in the container that generator exports from.- Artifact URLs in the manifest are pinned to the real bucket, so fetching them verbatim ignores
Baseurland dials a host that does not exist yet. Artifacts resolve relative to the configured base. - The portable export inlines externalized ext DATs and clears the ROOT comp's
externaltox, butfilepars and NESTEDexternaltoxsurvive.
TouchDesigner mechanics:
- Install/update tests MUST target a cooking-disabled container (
allowCooking = FalseBEFORE loading). A live copy of a registry master otherwise promotes itself to the/sysglobal and destroys the running one./sys/quietis the right staging home. COMP.loadTox()loads the component INTO the given COMP — it creates the child itself. Pre-creating a container named after the package nests it a level too deep (AutoRes/AutoRes).- A pulse's effect reads STALE in the same
execute_pythonthat fired it, and a COMP reloaded by pulsing its external-tox reload does not report its new state in that call either — results settle on the next tick. - A fresh
tableDATalready holds one empty row, sonumRows == 0is the wrong test for "needs a header". - Copy+destroy of extension-bearing COMPs is crash-prone: one tool per
execute_python, ending withcomp.save(externaltox)in the SAME call. - Before renaming/destroying a COMP containing stamped registry hosts, grep
externalizations.tsvfor rows under its path — Embody's move detection has re-matched orphaned rows to a different clone and deleted the master.py~20 clones sync from. parameterexecuteDAT's pulse toggle isonpulse, notpulse.- Forcing a bound par to CONSTANT in a
pre_releasehook ships it UNBOUND — the value clears, the wiring does not come back, and the artifact then looks healthy from every angle while doing nothing. FNS_TimelineTools shipped v3.1.4 with all twelve of its binds cut this way (FNS_Waveform's Audio File following the package's, the Background'sMediafilefollowingMoviefile, and every status/readout reporting back up), so the released waveform could not be pointed at a file at all. A hook that must clear a bound par has to force CONSTANT (or the assignment travels UP the bind to the master), so it must RECORD what it unbound and restore it after the sweep — never as it goes.bindExprsurvives the switch to CONSTANT, and assigning it back is itself enough to return the par to BIND (measured, 2025.33070). - A
pre_releasehook CANNOT fail an export by raising. Measured 2026-09-09 with a deliberate failure armed in the hook: Embody catches the exception and writes the .tox regardless,ExportPortableToxreturnsTrue(build_manifest's "aborted pre_release hook" comment describes some other failure and is misleading). Worse, a raise mid-hook skips every sweep BELOW it — the author's media paths, storage andpi_suspecttags — to report a smaller problem. Hooks report throughop.Embody.Log(msg, 'ERROR'), which reaches the ring buffer, the log files, and rides back on the MCP response. inspect.getsource()on a TD builtin WEDGES the main thread. A responsive Envoy is not proof the main thread is alive —result = 1+1is the cheap disambiguator.
Paid for the hard way: an artifact was written over the live AutoRes,
silently stripping its Embody bindings. Restored from
modules/suspects/.../AutoRes.tox, enableexternaltox/externaltox
re-set by hand, nested kindergaertner_mymod reloaded to get back to 123
ops. The authored-here guard exists because of this.
Do not re-propose these without new information:
- Artifact hashes as the update signal — built, then reversed.
.toxexport is not reproducible, so every release marks every package updated. vc_data/ theVc*pars as the version source — rejected on ownership: it is Private Investigator's table, written by tooling outside this repo. Also thin: 1 of 39 had a real version, 1 had no table, andbuilddid not move across two project saves.- A TDN content fingerprint — rejected on ownership: TDN is an external package. Worth noting it IS technically sound (two exports of one component differ by exactly one line,
exported_at, out of 2280), so if that constraint ever changes, this is the mechanism. - Refusing every
externaltox-bound package — too blunt; a binding is the better update path, not a reason to refuse. - Per-package palette toxes as the only model — now available as
shared, but not the default: it mutates every project on the machine. - In-project
replaceOponly — nowembedded, the default; it was never wrong, just insufficient alone. - GitHub releases /
Gittagpolling — dead under buckets+manifests.github_remote,TDAsyncIO,PollLatestTag()andpar.Filenameare deleted.
Three holes found 2026-08-26 by comparison against a shipping rail (DistributionComparison.md); the work plan is RailHardening.md. All present in the code today:
publish.pycomputesbumped/addedbut neverremoved, so a package can silently vanish from a release and the bump guard still passes;upload.pynever re-fetches after upload, so a truncated object is discovered by a user; andBaseurlis a single value with no fallback and no way to reach the field, so a moved bucket strands every install. The last one cannot be repaired after the fact.Correction 2026-09-08. All three closed on 2026-08-27 (RailHardening.md §3):
publish.pycomputesremoved,upload.pyreads back what it uploaded, and a signed discovery document withminimum_updaterandnoticessits abovebase_url(ExtUpdater.DISCOVERY_PINS). TheBASE_URLswap and theCache-Controlon the rolling manifest in the list below are done as well (upload.py). The paragraph above is kept as the record of what was found.
- Swap
BASE_URLinbuild_manifest.pyand theBaseurlpar default when the bucket is real. Nothing else changes — both rails were built and verified against a local tree precisely so this is a one-constant swap. Assume public-read; a token must never ship inside a distributed tox. - Set
Cache-Controlon the rollingmanifest.jsonat upload time. A CDN-cached root manifest would silently pin users to an old release; deliberately not worked around client-side. - Exercise self-update once against a real bucket install (§4).
OpTemplatesdoes not ship self-contained — itsOPTemplates1child is an external tox in the user palette, so a fresh install gets an empty template library. Embed it or have the installer fetch it.catalog.jsondescriptions were seeded by inspection and need an owner pass — they drive the picker.QuickCollapse/popDialog/PopDialogExt.pystill carries therecreateall+ init-mutation lines fixed inf616b09for CustomParTools. Same latent class, not reported broken.- Six
OpTemplatesrender templates pin TD's ownSamples/Geoby version.
packaging/build_manifest.py derives manifest.json from the LIVE project
packaging/catalog.json curated: category + description only
packaging/release.json release label + channel (NOT from git)
packaging/manifest.json 39 packages, 7 core, versions + hashes + urls
packaging/InstallerExt.py the single install implementation
packaging/install.py thin script wrapper over InstallerExt
packaging/build_installer.py builds dist/FNS_Installer.tox from source
packaging/publish.py stages the bucket tree, re-hashes, guards bumps
packaging/configurator/ picker + single-file standalone build
packaging/dist/ 39 .tox + FNS_Installer.tox (gitignored)
packaging/publish/ staged bucket tree (gitignored)
The updater is modules/suspects/FNSTools/FNS_Updater/ExtUpdater.py
(corrected 2026-09-08: the package is FNS_Updater since v3.0.0, and
scripts/UpdaterExt.py is Embody's own GitHub self-updater, unrelated). The
tree above is the 2026-08-13 snapshot; at v3.1.4 the catalog has 58 packages
and 10 core, and build_installer.py builds the rails into dist/
(FNSTools.tox bootstrap, FNS_Installer.tox).
Branch note, historical (2026-08-13): the update commits were
079515e (store refresh + project pull) → c78e8fa (bound packages
rewrite in place) → d746cd4 (governed versions replace hashes); all merged
since.
docs/ConfiguratorDistribution.md is the design record and is current —
§4.2 carries the update model including the reversal and its evidence.
docs/UvPackagingResearch.md is research only, not the plan.
Superseded 2026-09-08 as a runbook: releases go through Guided Release in packaging/RELEASING.md. The steps below still name the underlying rails; the upload is
packaging/upload.py(wrangler, with read-back verification), which replaced theaws s3 syncline.
# 1. bump Pkgversion on every package you changed (Package page)
# 2. rebuild + re-export those artifacts
exec(open('packaging/build_manifest.py').read()); Build(export=['ChangedPkg'])
# 3. set the release label in packaging/release.json, then stage
exec(open('packaging/publish.py').read()); result = Stage()
# -> refuses a new release that bumps nothing; reports `bumped` / `added`aws s3 sync packaging/publish/ s3://<bucket>/fnstools/ --delete