Releasing Tetravox
How a version becomes downloadable artefacts. The contract is docs/ARCHITECTURE.md §12; this is the operator's manual for it, the way /developers/testing is the operator's manual for §11.
| Bump versions, changelog, commit, tag | scripts/release.sh <version> — never pushes |
| Build every artefact, publish the Release | .github/workflows/release.yml, on a v* tag |
Downloadable builds of main | ci.yml's package job, every push to main |
| macOS artefacts locally | pnpm package |
| Linux artefacts locally | scripts/package-linux.sh (Docker) |
| Prove a packaged artefact works | node scripts/smoke-artefact.mjs |
| Build the SDK a module repository pins | node scripts/emit-module-sdk.mjs (§9.3) |
1. What gets built
packages/app/electron-builder.yml. Artefact names are Tetravox-<version>-<os>-<arch>.<ext> on every platform and every target — set by an artifactName inside each os block, because the top-level default is not inherited into the dmg / nsis / appImage blocks the way per-os ones are, and a .deb otherwise arrives as tetravox_0.2.0_amd64.deb.
| Platform | Targets | Arch | Built on | Required |
|---|---|---|---|---|
| macOS | dmg, zip | arm64 and x64 | macos-latest — one runner builds and smoke-tests both slices (§7); also pnpm package locally | yes |
| Linux | AppImage, deb, tar.gz | x64 | ubuntu-24.04; locally via scripts/package-linux.sh | yes |
| Windows | nsis, zip | x64 | windows-latest; also builds from macOS/Linux — see §5 | no (§5) |
macOS and Linux are the priority platforms. Both workflows mark the Windows leg continue-on-error, and release.yml's verify job treats missing Windows .exe/.zip assets as absences to report rather than a failure. A Windows problem never blocks a macOS/Linux release.
The exact asset names at version <v>:
Tetravox-<v>-mac-arm64.dmg Tetravox-<v>-mac-arm64.zip
Tetravox-<v>-mac-x64.dmg Tetravox-<v>-mac-x64.zip
Tetravox-<v>-linux-x86_64.AppImage
Tetravox-<v>-linux-amd64.deb
Tetravox-<v>-linux-x64.tar.gz
Tetravox-<v>-win-x64.exe Tetravox-<v>-win-x64.zip (optional)
latest-mac.yml latest-linux.yml
latest.yml (optional, with the Windows leg)
tetravox-module-sdk-<hostApi>-<v>.tgzThe module SDK is not an installer; an extension repository builds against it (§9). It is required by verify.
The three latest*.yml are the in-app update feeds (ARCHITECTURE §12.4): a running install asks releases/latest/download/latest-<os>.yml what the newest version is, so a release without them is one no installed copy ever notices. electron-builder writes them into release/ beside the installers (the publish: block in electron-builder.yml configures the feed without publishing — --publish never still holds); the workflow's per-leg globs attach them, and verify requires the mac and linux ones. Publishing is what makes the release visible to running apps — the feed cannot see a draft, so nothing updates until verify flips it to published.
${arch} resolves per target to that ecosystem's spelling, which matters if you are writing a download link by hand: macOS and .tar.gz get arm64 / x64, the .AppImage gets x86_64, and the .deb gets amd64. The pattern is uniform; the arch token inside it is native. Hard-coding x64 would look tidier and would silently misname the first arm64 Linux build.
linux.executableName: tetravox is required, not cosmetic. electron-builder derives the Linux binary name from package.json's name — here the scoped @tetravox/app — sanitises it to @tetravoxapp, and then refuses it: executableName contains characters that cannot be safely used in file paths. macOS and Windows name the binary after productName and never reach that code, so the whole failure is invisible until the first Linux build. This is exactly the class of bug §12.1 means by Linux artefacts are never built on macOS.
zip alongside dmg on macOS is not redundant: the .dmg is what a human downloads, the .zip is what a script downloads, and the .zip is the only mac artefact that unpacks without a mount. tar.gz on Linux is the escape hatch for every distro that is neither Debian nor FUSE-capable.
Not universal on macOS. It doubles the download for every user to save them a choice, and the two slices are built on separate runners in CI anyway.
File associations (§8) are declared once and registered on all three platforms: .nii, .nii.gz, .msh, .gii, .geo, .pos, .tetravox.json. .nii.gz and .tetravox.json are compound extensions — macOS treats each as its own UTI and Linux as its own MIME type, which is why they are separate entries rather than a gz entry with a qualifier. .geo is rank: Default, not Owner: the extension is shared with Gmsh's geometry-script language, which this app does not open, so it must not claim to be the machine-wide handler for every .geo.
Two Linux association gaps, measured (both are electron-builder warnings during the .AppImage build, and neither is fatal):
file extension contains unexpected characters and will be skipped — extension=nii.gz, and the same fortetravox.json. Compound extensions get no Linux MIME glob. macOS registers each as its own UTI and Windows as its own progid, but electron-builder's Linux path writes one shared-mime-info entry per simple extension and refuses a dotted one..nii,.msh,.gii,.geoand.posassociate normally; a.nii.gzor a*.tetravox.jsonopens fine from the command line and from Open With, but double-clicking it does not reach Tetravox by default. Closing it means shipping a hand-written*.xmlwith<glob pattern="*.nii.gz"/>in the.deband registering it withxdg-mime.desktopName is not set in package.json— without it a desktop environment may not link the running window to the launcher icon. The config setsStartupWMClass: Tetravox, which covers the common case; the upstream fix isdesktopNamepluslinux.syncDesktopName: true.
Icons live in packages/app/build/ — icon.png (1024²), icon.icns, icon.ico, icons/<n>x<n>.png — and are named explicitly in the config rather than left to the buildResources convention, because electron-builder falls back to the stock Electron logo in silence when they are absent. scripts/ensure-icons.sh mirrors a repo-root build/ into that directory and otherwise fails loudly; both CI packaging workflows run it before electron-builder.
2. Before you cut
pnpm install && pnpm wasm
pnpm lint && pnpm typecheck && pnpm test && pnpm e2eThen the two things a source-only test run cannot tell you:
pnpm package # macOS: 2 dmgs + 2 zips, ~2 min
node scripts/smoke-artefact.mjs # launches the packaged binary with --job
TETRAVOX_REQUIRE_PACKAGED=1 pnpm --filter @tetravox/app run e2e:packagedTETRAVOX_REQUIRE_PACKAGED=1 turns the packaged Playwright project's self-skip into a failure. Its default is to skip when nothing has been packaged, which is right for pnpm e2e and is a silent hole here.
Optionally, the Linux half. On an Apple-silicon Mac budget 20–30 minutes and expect WARNING: The requested image's platform (linux/amd64) does not match the detected host platform: electronuserland/builder publishes no arm64 image, so the whole thing runs under emulation, and the .deb target's single-threaded xz over a ~250 MB tree is most of the wall clock. The .AppImage and the .tar.gz land in about four minutes. On the ubuntu-24.04 CI runner it is all native and the leg is a few minutes end to end.
scripts/package-linux.sh3. Cutting the release
git switch main && git pull
scripts/release.sh 0.2.0 --dry-run # prints what would change
scripts/release.sh 0.2.0That single command rewrites the version in six places — the root package.json, the four workspace package.jsons, and [workspace.package] version in Cargo.toml, which all five crates inherit — regenerates Cargo.lock, dates the CHANGELOG section, runs prettier over what it touched, commits chore(release): 0.2.0 and creates the annotated tag v0.2.0.
A partial bump is the failure this exists to prevent: packages/app/package.json is the file electron-builder reads for ${version}, so a tree that is 0.2.0 everywhere except there ships Tetravox-0.1.0-mac-arm64.dmg out of a 0.2.0 release.
release.sh does not push, and neither should an agent. Pushing the tag is what starts the release workflow and therefore what creates a Release, so it is a deliberate act:
git push origin main
git push origin v0.2.0 # <- this builds the matrix and publishes the ReleaseWhat the tag push does
release.yml runs three stages, in the shape of the maintainer's release-build.yml:
create-release— makes the GitHub Release for the tag immediately, as a draft, with the body taken fromCHANGELOG.md's section for this version (scripts/changelog-section.mjs) plus the unsigned-build instructions, and GitHub's generated commit/PR summary appended underneath.build— four parallel jobs (macOS arm64, macOS x64, Linux x64, Windows x64). Each installs the pinned toolchains, builds, runs its artefact smoke test, uploads a workflow artefact, and then attaches its own files to that Release. Creating the Release first is what lets each platform publish as soon as it is ready instead of waiting for the slowest.verify— reads the assets actually attached and fails if any required one is missing. This is the check that a green matrix does not give you: a leg can succeed and still upload nothing. When it passes, its last step publishes the Release and marks it Latest. There is no manual step: a greenverifyis the same statement a maintainer used to make by pressing Publish.
The draft therefore lasts only as long as the build. A missing required asset leaves verify red and the Release a draft, so an incomplete release is never public — and a release nobody remembers to publish, which is its own failure (the repo page and the update feeds both read the published latest, so a forgotten draft silently strands every installed copy on the previous version).
After it lands, the checks worth doing are the ones a machine cannot do:
- Download the
.dmgfor your own machine and open it. The smoke test proves the renderer starts; it does not prove the installer produced something a human can double-click. - Read the notes on the published release.
If verify fails, the Release stays a draft. Re-run the failed build leg — verify then publishes it — or delete the draft and the tag (git push origin :refs/tags/v0.2.0) and cut it again.
To rehearse without a tag: run release.yml from the Actions tab. workflow_dispatch builds the same matrix and uploads workflow artefacts, while create-release, the attach step and verify are all if:-gated on refs/tags/v and do not run. Every push to main also runs ci.yml's package job, so main always has downloadable builds without any tag at all.
4. Signing and notarisation (macOS)
packages/app/electron-builder.yml describes the signed build — hardenedRuntime: true, gatekeeperAssess: false, entitlements / entitlementsInherit: build/entitlements.mac.plist, notarize: true, and deliberately no identity: key (with one, electron-builder never looks at CSC_LINK). electron-builder 26 signs from CSC_LINK and notarises through the APPLE_* variables by itself; @electron/notarize is its own dependency, not one of ours.
Whether that config is used is decided in one place: scripts/electron-builder.sh. Every packaging path goes through it — pnpm package, ci.yml's package legs, release.yml's build step:
CSC_LINK | What happens |
|---|---|
| set | signed with the Developer ID, hardened, notarised, stapled |
| empty | CSC_IDENTITY_AUTO_DISCOVERY=false, plus --config.mac.hardenedRuntime=false --config.mac.notarize=false — an ordinary unsigned build, no error |
The script unsets the five variables on that path rather than leaving them empty. A workflow writes CSC_LINK: ${{ secrets.CSC_LINK }} unconditionally, so on a runner without the secret the variable exists and is "" — and electron-builder tests it for defined, not for non-empty. It then resolves "" as a certificate path and dies with ⨯ /…/packages/app not a file, an error that names neither signing nor the empty variable (release run 33220659986).
The unsigned fallback is not politeness towards forks, it is a correctness rule: electron-builder ad-hoc-signs the arm64 slice whether or not you have a certificate, and an ad-hoc signature plus hardenedRuntime: true is an app the kernel kills at launch. Turning auto-discovery off also keeps pnpm package deterministic between two developers, one of whom happens to have a Developer ID in their login keychain.
The .dmg is stapled by the workflow, not by electron-builder. electron-builder notarises and staples the .app; it never submits the disk image, so the dmg itself carries no ticket (release run 33222399227: both apps Notarization Ticket=stapled, both dmgs "does not have a ticket stapled to it"). release.yml's "Notarise and staple the dmg" step runs notarytool submit --wait and stapler staple on each dmg after the build, and the "Signature, Gatekeeper and staple" gate then holds. A local pnpm package skips that step: its dmgs open fine (Gatekeeper reads the app's stapled ticket once the image is mounted) but stapler validate on them reports no ticket.
The four secrets
release.yml's build step passes these on every leg and tolerates all of them being empty. Only the first four are secrets; APPLE_TEAM_ID is public and is written in the workflow (3BMY24SA43).
| Secret | What it is | Where it comes from |
|---|---|---|
CSC_LINK | the Developer ID Application certificate, as a base64 .p12 | Xcode → Settings → Accounts → Manage Certificates, or developer.apple.com; export from Keychain Access as .p12, then base64 -i cert.p12 | pbcopy |
CSC_KEY_PASSWORD | the password set when exporting that .p12 | you chose it during the export |
APPLE_ID | the Apple ID of the developer-programme account | — |
APPLE_APP_SPECIFIC_PASSWORD | an app-specific password, never the account password | appleid.apple.com → Sign-In and Security → App-Specific Passwords |
The maintainer adds them once:
gh secret set CSC_LINK --repo idossha/tetravox < cert.p12.base64
gh secret set CSC_KEY_PASSWORD --repo idossha/tetravox
gh secret set APPLE_ID --repo idossha/tetravox
gh secret set APPLE_APP_SPECIFIC_PASSWORD --repo idossha/tetravoxUntil they exist the mac artefacts are unsigned, the release still builds, and the release-run log says so: the Signature, Gatekeeper and staple step runs codesign -dv, spctl -a -t install and xcrun stapler validate on the built .app and prints what they say without gating on it. Once the secrets are in place, stapler validate reporting The validate action worked! is what proves the notarisation ticket is stapled — and the smoke test still runs against the signed, stapled app, so a signature that breaks the launch is caught in the same job that made it.
The mac leg checks these credentials with xcrun notarytool history before it builds. If that step fails, fix the secret first and only then re-run — do not just press re-run.
4.1 "Your Apple ID has been locked" (HTTP 401)
Every failed notarytool sign-in counts as a failed login on the Apple ID; a few in a row (a stale or mistyped app-specific password, re-run three times) lock the account, and after that every run fails with HTTP status code: 401. Your Apple ID has been locked no matter what the secret holds. This happened on the v0.2.0 runs of 2026-08-29. Recovery is entirely outside CI:
- Unlock the account at <https://iforgot.apple.com> (or appleid.apple.com → Sign-In and Security).
- Generate a new app-specific password — the old one is invalidated by the lock.
gh secret set APPLE_APP_SPECIFIC_PASSWORD --repo idossha/tetravox- Re-run only the failed mac leg of the release run (
gh run rerun <id> --failed), then theVerify the Release assetsjob runs again on its own.
Notarisation is slow (minutes, occasionally tens of minutes on a first submission); the mac leg's timeout-minutes: 60 covers it. When signing is live, drop the unsigned-build paragraph from scripts/changelog-section.mjs and the xattr -dr com.apple.quarantine walkthrough from docs/USER_GUIDE.md. In-app updates shipped 2026-08-31 (ARCHITECTURE §12.4): signed builds update in place. The unsigned-build escape hatches above matter only to local/fork builds — those still check, but a macOS install ends at Squirrel's signature check with an honest error.
Windows signing is not planned. It needs a paid certificate whose reputation SmartScreen builds up over downloads, which an unpopular installer never accumulates, so the warning would remain.
5. Windows, and what it actually is
electron-builder produces the nsis installer from macOS and Linux, with no wine. This was tested, not assumed: electron-builder --win --x64 on macOS arm64 downloads nsis-3.0.4.1.7z and a darwin-arm64 7-zip, runs a native makensis, and writes a 107 MB Tetravox-0.1.0-win-x64.exe. Wine is needed for signing a Windows binary from a non-Windows host, and this project signs nothing.
The matrix builds it on windows-latest anyway, for one reason: that is the only runner where the result can be launched. An installer nobody has ever run is not evidence.
The Windows smoke test is weaker than the other two, deliberately. macOS and Linux run scripts/smoke-artefact.mjs, which launches the packaged binary with --job on a committed synthetic fixture and asserts job-result.json is ok with a real PNG on disk — a frame off the GPU. Windows runs --version-only: launch, print a version, exit 0. A hosted windows-latest runner has no GPU and no compositor, and rather than let the leg go green on a vacuously offscreen render, it claims only what it can prove — the installer produced a runnable exe.
So: Windows is built and launch-verified on every release, and rendering on Windows is not covered by CI. That is a real gap and it is stated here rather than papered over. Closing it needs a self-hosted Windows runner with a GPU, or a user report.
6. Linux locally, and why package-linux.sh looks the way it does
§12.1: Linux artefacts are never built on macOS. The .deb's strip, the AppImage runtime and the desktop-file validation are Linux-native steps that fail or silently no-op on darwin. So the local path is Docker:
scripts/package-linux.sh # build + smoke-test the AppImage under Xvfb
scripts/package-linux.sh --no-smokeThree arrangements were tried; the script's header records all three, and the one it uses is: the wasm is built on the host, and only electron-builder runs in the container, on the plain electronuserland/builder image. packages/wasm/pkg is wasm32-unknown-unknown output — the same bytes everywhere — so carrying it in is correct rather than a shortcut, and it saves a rustup install and a full release build per run. The :wine image is not used, for the §5 reason.
Three things that bite and are handled:
--publish neveron every electron-builder invocation. electron-builder readsCI=trueas consent to publish; without the flag it builds every artefact and then dies with⨯ GitHub Personal Access Token is not set, at the very end of a twenty-minute run. Uploading isrelease.yml's business —actions/upload-artifactand a draft Release — never electron-builder's. The flag is on thepnpm packagescript and on both workflows too.The
.debtarget hard-fails withouthomepageinpackages/app/package.json—⨯ Please specify project homepage— and it does so after the.AppImageand the.tar.gzhave already been written, so the run looks like it nearly worked.linux.executableName(§1) is the other metadata field only Linux reads. Both are now set; this is here so the next one is recognised.No apostrophes in the in-container script, comments included. The whole container body is one single-quoted argument to
bash -c, so a lone'closes it. This is not a syntax error — it fails silently: the commands after the apostrophe never run,dockerexits 0, and the script lists the previous run's artefacts as if it had just built them. A green run over stale files is the worst outcome a packaging script can have, sopackage-linux.shnow also stamps a marker file before the container starts and fails if any artefact is older than it. The comment block in the script says so in capitals; keep it that way.The container must not write root-owned files into your checkout. The
electronuserlandimages run as root; the scriptchowns back to the invoking uid, and gives the container its own pnpm store andnode_modulesso a darwinesbuildand a linux one never overwrite each other.The AppImage cannot self-mount in a container. It needs libfuse2, which neither the builder image nor Ubuntu 24.04 ships, and the failure reads as
AppImages require FUSE to run— an environment artefact, not a build defect. The smoke test uses--appimage-extractand runs the extractedtetravox.
--no-sandbox on every Linux launch, packaged or not: chrome-sandbox inside the AppImage is not root-owned setuid, and Chromium aborts rather than drop the sandbox silently — even for --version (§12.2).
7. The smoke test
scripts/smoke-artefact.mjs is what §12.1 means by "launch the packaged binary with a CLI arg pointing at a fixture and assert it exits 0 after rendering one frame".
node scripts/smoke-artefact.mjs # discover the artefact in packages/app/release
node scripts/smoke-artefact.mjs --exe <path> # an explicit binary
node scripts/smoke-artefact.mjs --all # every slice this platform built (both mac slices)
node scripts/smoke-artefact.mjs --software-gl # force ANGLE/SwiftShader (a runner with no GPU)
node scripts/smoke-artefact.mjs --version-only # launch-and-exit (Windows)What each leg passes, and why it is not the same everywhere. A hosted runner is not a desktop: three of the four have no GPU, and the v0.2.0 release run failed on exactly that (run 33217830015).
| Leg | Flags | Renderer it proves |
|---|---|---|
macOS macos-latest | --all | Apple Metal, both slices — arm64 natively, x64 under Rosetta 2 |
Linux ubuntu-24.04 | --software-gl | ANGLE/SwiftShader under Xvfb, asserted to be software |
Windows windows-latest | --version-only | launch-and-exit only (§5) |
--software-gl adds --use-gl=angle --use-angle=swiftshader --disable-gpu-compositing to the launch (packages/app/src/main/index.ts §2), on top of the --enable-unsafe-swiftshader every launch carries and the --no-sandbox Linux always needs. The distinction matters: enable-unsafe-swiftshader only permits a fallback, and on a runner where the GPU process cannot bring up a display at all there is nothing to fall back from — the Linux leg died with vertex shader failed to compile: (no log) from a context that was already gone. The job then logs which renderer answered (gl: <renderer> (software|hardware)) and the smoke test asserts on it, so a leg cannot quietly make a different claim than its name.
There is no software-GL path on macOS, and that is measured, not assumed. macOS ANGLE allows only metal and swiftshader; SwiftShader's Vulkan backend fails to initialise (Internal Vulkan error (-3) … Exiting GPU process) — reproduced identically on an M2 Mac against eight flag combinations (--disable-gpu-sandbox, --in-process-gpu, --ignore-gpu-blocklist, --use-vulkan=swiftshader, VK_ICD_FILENAMES, …) and on the macos-26-intel runner, which has no Metal device either. That is why the Intel leg is gone rather than fixed: electron-builder.yml declares arch: [arm64, x64], so the arm64 runner already emits all six mac assets — during the failed v0.2.0 run it filled the draft Release on its own — and --all smoke-tests the x64 slice there, on a real GPU under Rosetta 2. Measured on an M2 Max: arm64 2.6 s, x64 under Rosetta 39 s.
It writes a two-file job (testdata/vol_asym.nii + testdata/mesh_v2_ascii.msh, plain preset, one screenshot) into a temp directory, runs the packaged binary against it, and asserts ok: true, one output, and a smoke.png larger than a header. It does not compare a golden — that is §11's job and hundreds of tests already do it on the dev build. What this asks is whether the packaged thing can start a renderer at all, which is the failure mode packaging introduces and testing cannot see.
--job is what makes it CI-safe: src/main/window.ts forces offscreen for any argv carrying it, and a --job run is exempt from the single-instance lock. It never takes a developer's focus and never needs a display manager (AGENTS.md rule 9).
On Apple silicon after a two-arch build, discovery prefers release/mac-arm64 over release/mac. A plain readdir finds mac first, which smoke-tests the x64 slice under Rosetta and never touches the arm64 one — a leg that passes while proving the wrong thing.
8. Measured, on this hardware
Every artefact below was built and verified locally at 0.1.0 — macOS with pnpm package on an M-series Mac, Linux with scripts/package-linux.sh (Docker), Windows with electron-builder --win --x64 from macOS.
| Artefact | Size | How it was verified |
|---|---|---|
Tetravox-<v>-mac-arm64.dmg | 123 MB | built; the .app inside is what the two checks below run |
Tetravox-<v>-mac-arm64.zip | 123 MB | built |
Tetravox-<v>-mac-x64.dmg | 127 MB | built; smoke test passes under Rosetta |
Tetravox-<v>-mac-x64.zip | 127 MB | built |
Tetravox-<v>-linux-x86_64.AppImage | 120 MB | built in Docker; smoke test runs the unpacked payload under Xvfb (see §6) |
Tetravox-<v>-linux-amd64.deb | 95 MB | built in Docker |
Tetravox-<v>-linux-x64.tar.gz | 114 MB | built in Docker |
Tetravox-<v>-win-x64.exe | 107 MB | built from macOS with no wine; not launched — that is windows-latest's job |
The two macOS checks, both on the arm64 slice:
node scripts/smoke-artefact.mjs→ok=true,smoke.png5,015 B, load 354 ms, 7.2 s wall.TETRAVOX_REQUIRE_PACKAGED=1 pnpm --filter @tetravox/app run e2e:packaged→ 130 passed, 70 skipped, 26.1 s.
The Linux check, inside the container under Xvfb: ok=true, smoke.png 3,427 B, load 1,373 ms.
The Rosetta number is the reason §7's discovery order exists. The same packaged e2e against the x64 slice on this arm64 Mac took 4.9 minutes instead of 26.1 seconds, and the same smoke test took 3,879 ms to load instead of 354 ms. Before the fix, both ran against release/mac — the x64 build — and passed, so the leg was green and the arm64 artefact had never been launched.
9. The extension SDK
ARCHITECTURE.md §13.8. An extension lives in its own repository, is built there, and ships as release assets; users download it through File ▸ Extensions… — nothing is bundled into the application (the bundled tier, modules.lock and scripts/fetch-locked-modules.mjs were removed 2026-08-31). The one extension artefact this repository's release carries is the SDK an extension repository is built against, driven by MODULE_HOST_VERSION — a value a reviewer reads as a diff — and never by a step that reaches for whatever is newest.
9.1 The SDK artefact
node scripts/emit-module-sdk.mjs writes dist/module-sdk/tetravox-module-sdk-<hostApi>-<version>.tgz — at 0.2.0 and host API 1 that is tetravox-module-sdk-1-0.2.0.tgz. An extension repository pins it by URL:
{ "devDependencies": {
"@tetravox/module-sdk":
"https://github.com/idossha/tetravox/releases/download/v0.2.0/tetravox-module-sdk-1-0.2.0.tgz" } }There is no npm publishing, deliberately: an SDK belongs to exactly one core release, and a release URL says which one.
Everything in it is generated from the sources it mirrors — host.ts (frozen, §12.3 item 6), the manifest contract, the shared/contacts kit, and a type-only subset of @tetravox/engine. The one hand-written file is scripts/module-sdk/sdk-runtime.ts, which declares nothing: it reads the host's React, ModuleHostError, stemOf and the contacts kit off globalThis.__tetravoxModuleSdk. Every import in it is import type, so the emitted index.js has no imports at all — which is what lets an extension build inline it and produce the single-file, zero-import bundle a tetravox://module/ load requires.
When the core tree carries src/modules/manifest-schema.ts — it does since the extension host landed — the emission also ships the validator as plain ESM, manifest-schema.mjs and the manifest-types.mjs it imports, so an extension repository can check its own manifest.json with node and no install. It also ships contacts.mjs — the shared/contacts kit as runnable ESM — so an extension repository's own vitest imports the kit's runtime from the SDK rather than pinning shared/contacts to a core commit sha (a pin raw.githubusercontent stops serving once a squash-merge garbage-collects that sha). Inside the app an extension reads contacts off the host global — one instance — so contacts.mjs is for tests, not the production bundle. All of these are named in the package's exports: a package with an exports map serves nothing that is not listed there, so an entry point that is merely in the tarball is not importable.
The script is five gates, and a failure in any of them is a failure to produce a usable SDK rather than a missing file:
- every import in every staged source resolves inside the SDK;
index.jscontains noimportand noexport … from;- the emitted package typechecks against a probe that imports it the way an extension does;
manifest-schema.mjs, when it ships, is imported and made to validate a manifest — the.mjsfiles aretscoutput with their specifiers rewritten, and a rewrite that does not resolve would otherwise be green here and red in somebody else's CI;contacts.mjsis imported and made to build a set, proving the same specifier rewrite resolves for the kit as well as for the validator.
release.yml's sdk job emits it and attaches it to the same draft Release, and verify requires it by the name that job reported. A release cannot be published with a module host and no SDK to build a module against.
9.2 What CI checks, and where
| Check | Job | Cost |
|---|---|---|
| The SDK emitter's rules | docs-guard | node only, no install |
| The SDK emits, all five gates | test | one tsc over a small staging tree |
| The SDK tarball is attached | sdk → verify (release.yml) | — |
9.3 Refresh the shipped catalogue — a release step, not a CI step
packages/app/src/shared/extensions-index.json is the floor the app merges the live registry onto (ARCHITECTURE.md §13.8): a build with no network offers exactly what this file lists, and the fetched index can only add to it. So refresh it from the registry before cutting a release, while the diff is still readable:
node scripts/refresh-extensions-index.mjs # rewrite it from idossha/tetravox-extensions
node scripts/refresh-extensions-index.mjs --check # is it current? (exit 1 if behind)
node scripts/refresh-extensions-index.mjs --from index.json # offline, from a local copyIt merges rather than overwrites, so a version this build already offers is never dropped. CI does not run --check: it is a network call to raw.githubusercontent.com, and a GitHub outage would redden pull requests that never touched extensions. Only the script's own rules run in docs-guard. Skipping this step does not break a release — the app still merges the live registry at runtime — it only leaves an offline user an older floor.
10. Managed native installations
A host application may unpack the platform ZIP (macOS) or tar.gz (Linux) into its per-user directory and invoke Tetravox.app/Contents/MacOS/Tetravox <scene>.tetravox.json or tetravox <scene>.tetravox.json. Windows provides a ZIP containing Tetravox.exe for managed extraction; NSIS remains the regular end-user installer. Do not use NSIS to install a second managed copy: its existing-install registry lookup may uninstall a standalone installation even when a different destination is supplied. The ZIP is a new build target and is absent from the existing 0.4.0 release; managed Windows support requires a verified official ZIP and pinned checksum before a host enables it. Scene dataset paths must resolve on the host filesystem.
Give managed launches a dedicated --user-data-dir so an existing standalone instance cannot receive their scenes through its single-instance lock. Set TETRAVOX_MANAGED_BY to the installation manager name when launching. This disables the native updater for that process and displays the manager in Updates; the manager must own pinned-version upgrades. Existing releases predating this behavior do not honor this environment variable.
The browser embed is retired; releases no longer build or require its tarball, schemas or checksums.