Skip to content

Packaging and releases

Kangentic uses electron-builder to create platform-specific installers. Configuration lives in electron-builder.yml at the project root.

PlatformFormatInstaller Type
Windows (x64).exeNSIS (Nullsoft Scriptable Install System)
macOS (arm64 and x64).dmg, .zipDMG disk image (drag-to-install); the .zip feeds the macOS auto-updater and is what npx kangentic downloads and unpacks into ~/Applications
Linux (x64 and arm64).deb, .rpmNative package manager formats

Since v0.46.0 a release builds five targets: Windows x64, macOS on Apple Silicon and Intel, and Linux x64 and arm64. Each one builds on a runner of its own architecture, never cross-built, because npm installs only the host’s native packages and build/afterPack.js loads them with the packaged Electron binary. Windows stays x64 only, and Windows on ARM runs it under emulation: a native arm64 build would lose dictation (sherpa-onnx ships no win-arm64 package) and vector search (sqlite-vec ships windows-x64 only). The Intel macOS build has no Knowledge Graph, because onnxruntime-node ships no Intel macOS binding, so afterPack skips the embed worker probe on that leg.

Several native modules require special handling during packaging:

ModuleStrategy
better-sqlite3Since v0.44.0 (better-sqlite3 13) a Node-API module that ships every platform’s addon as prebuilds/<platform>-<arch>.node, so nothing is rebuilt and scripts/rebuild-native.js is gone. The same binary loads under Electron and plain Node. build/afterPack.js deletes every prebuild but the target’s, and the package excludes the C++ and SQLite sources and the build-time headers. Unpacked from ASAR, because the retrieval worker opens the project databases from the unpacked tree
koffiNew in v0.44.0. A prebuilt Node-API foreign-function library in a per-platform package, loaded by the pty host worker on Windows and macOS to find what a finished task left running. Unpacked from ASAR, and the afterPack gate makes one native call through it
node-ptyShips NAPI prebuilt binaries; no rebuild needed. Pinned exactly to 1.2.0-beta.15 since v0.46.0, for its ConPTY handle lock: before it, two terminals exiting at the same moment on Windows could crash the terminal host and close every terminal with it. The whole package is unpacked from ASAR, since the pty host worker loads it from there. The one exception is its macOS spawn-helper. build/afterPack.js compiles Kangentic’s own from build/spawn-helper/spawn-helper.c over the prebuilt one. It clears the crash-handler ports a child would otherwise inherit before it starts a terminal’s program, so a crash in something an agent ran is not written into Kangentic’s crash database. A macOS package build therefore needs Xcode or the Command Line Tools
sherpa-onnx-nodeShips prebuilt native binaries for local voice Dictation; shipped and unpacked from ASAR (native addons can’t be dlopen’d from inside the archive)
font-listEnumerates installed fonts for the terminal font picker (shells out to fc-list on Linux and a PowerShell script on Windows). Shipped and unpacked from ASAR because its bundled macOS binary is spawned via child_process, which cannot exec a path inside the archive
sqlite-vecThe loadable SQLite vector extension for Conversation memory and the Knowledge Graph, loaded only by the retrieval worker. Ships per-platform binary packages, unpacked from ASAR because SQLite’s dlopen can’t read a loadable extension from inside the archive. The afterPack gate loads it under the packaged Electron binary
onnxruntime-node + @huggingface/transformersThe local embedding runtime for semantic search (plus onnxruntime.dll / DirectML.dll on Windows). Shipped and unpacked so the embed worker resolves them via plain node_modules resolution
Bridge and worker scriptsevent-bridge.js, status-bridge.js, the five worker bundles (embed, line count, dictation, retrieval, and pty host), and plugins/** are selectively unpacked from ASAR so they can execute from real file paths

The app is packaged as an ASAR archive for faster loading and to prevent casual modification. Selective unpacking is configured for:

  • Native .node modules that require filesystem access
  • The bridge and worker scripts used for process spawning: event-bridge.js, status-bridge.js, line-count-worker.js, embed-worker.js, dictation-worker.js, and plugins/**
  • The dictation runtime (sherpa-onnx-node)
  • The Conversation Memory runtime: the sqlite-vec loadable extension, onnxruntime-node (with its Windows DLLs), and @huggingface/transformers, along with the embed-worker.js that runs embedding inference in an isolated process

Unpacking cuts both ways. The embed worker is forked from app.asar.unpacked, and Node resolving from a real directory never looks inside the archive, so a package the worker reaches only transitively has to be unpacked too or the worker exits at module load on every fork. That is what onnxruntime-common, sharp, @img/*, detect-libc, and semver are doing in the unpack list: @huggingface/transformers requires them at module scope. v0.38.0 and v0.39.0 shipped without them, so semantic search never started in an installed build. build/afterPack.js now gates the package on it, loading the worker’s externals from the unpacked tree in a child process whose resolution is fenced to that tree, and failing the build when the closure drifts. npm run package runs the same gate, so it holds locally and not only on the release matrix.

Production builds set Electron security fuses to harden the application. Since v0.44.0 build/afterPack.js sets every fuse explicitly and fails the build when an Electron upgrade adds one the config does not set, rather than shipping it at whatever default it came with:

FuseSettingPurpose
RunAsNodeDisabledPrevents ELECTRON_RUN_AS_NODE environment variable
NodeOptionsDisabledPrevents NODE_OPTIONS injection
InspectionDisabledPrevents --inspect debugging in production
Cookie EncryptionEnabledEncrypts cookies at rest
ASAR IntegrityEnabledValidates ASAR archive integrity on load
Only Load App From ASAREnabledRefuses to run app code from anywhere but the ASAR archive
Load Browser Process Specific V8 SnapshotDisabledElectron’s default; no browser-process snapshot is built
Grant File Protocol Extra PrivilegesEnabledElectron’s default, and required: every window loads the renderer over file://, its lazy chunks and the diff editor’s workers included
Wasm Trap HandlersEnabledElectron’s default

The NSIS installer includes build/installer.nsh since v0.44.0. On a per-user install it grants ALL APPLICATION PACKAGES read access to the install folder (icacls "$INSTDIR" /grant *S-1-15-2-1:(OI)(CI)(RX)) on every install and update. Kangentic checks at startup that its sandboxed processes can read that folder, and on a few machines an access entry left by a packaged app, with no read grant beside it, made the app exit before its window opened. See Installation.

Windows builds are signed using Azure Trusted Signing. The CI pipeline authenticates with Azure credentials and signs the NSIS installer during the build step.

macOS builds use:

  • Hardened runtime - required for notarization
  • Apple notarization - the signed .app bundle is submitted to Apple’s notary service with notarytool and stapled in the afterSign hook, before the DMG and ZIP are built around it
  • A spawn-helper gate on both sides of signing - afterPack proves the compiled spawn-helper leaves a child with no inherited crash port and still sets its working directory, and afterSign runs the same check on the signed binary before notarization, since hardened runtime is the one thing signing adds. Either failure fails the build

Releases follow a structured process:

  1. Trigger: Run /release patch|minor|major in the repo - this bumps the version and pushes a git tag. The command checks that the Sentry symbol-upload secret exists before it tags, since on a tag push the tag already exists by the time the workflow starts
  2. Preflight: A preflight-symbols job fails the run in seconds if the KANGENTIC_SENTRY_TOKEN secret is missing, before any draft or build exists, and since v0.46.0 a preflight-signing job does the same for the Apple signing and notarization secrets, because build/afterSign.js only logs a skip and a missing one would ship an un-notarized Mac build. Without it the build produces no source maps and no native debug files, which is how v0.37.0 and v0.38.0 shipped with unreadable crash stacks while every job reported success. Each platform build then uploads its source maps to Sentry (the Windows build adds node-pty’s debug symbols), and with a token present an upload failure fails the build rather than warning past it
  3. Draft Release: A dedicated create-draft-release job pre-creates the single draft GitHub Release before any platform build starts, and the build matrix waits on it. (Letting each platform job race to create the draft once produced three separate drafts and a partial release, so creation is now serialized up front)
  4. CI Build: The tag push then builds all five targets, each job attaching its artifacts to that one draft. Every platform verifies app-update.yml is packaged; each Linux job additionally fresh-installs the built .rpm on Fedora and openSUSE containers and the .deb on Debian and Ubuntu, then runs a second, narrower gate that upgrades from the previous published release - the .rpm on Fedora and the .deb on Debian. Upgrading is the path auto-update actually takes, and it can fail where a fresh install succeeds (file conflicts between versions, scriptlet ordering, a dependency only the old package pulled in). That gate downloads the real previous release asset, so a renamed artifact fails it rather than silently skipping. The arm64 leg runs the same gates in arm64 containers; its upgrade gates skip on the first release that ships arm64, since no older release has an arm64 package to upgrade from, and never again after it
  5. Publish: Once every build succeeds, a publish-release job first rebuilds latest-mac.yml to list both macOS zips (scripts/merge-mac-update-feeds.js). Each macOS leg writes a feed naming only its own files, and the updater picks the zip whose URL names arm64 on Apple Silicon and the other on Intel, so one feed has to list both: left alone, whichever leg finished last would decide every Mac’s updates. Linux needs none of this, because each arch has its own feed (latest-linux.yml and latest-linux-arm64.yml). Then scripts/verify-release-assets.js runs as a hard gate: the tag must resolve to exactly one release, carrying all eighteen expected build assets (scripts/release-assets.js), all fully uploaded. A finished release carries nineteen, because step 8 attaches the docs poster set after this gate has run. Only then does it clear the draft flag and set the release body from RELEASE_NOTES.md in one call, making the release visible to the auto-updater. Build success on its own proves nothing about a release’s contents, which is what the gate exists for. A draft left unpublished means the gate failed; publishing it by hand would bypass the one check standing between a partial release and every user’s auto-updater. Before that gate, scripts/patch-mac-update-info.js writes the release’s minimum macOS version into latest-mac.yml (since v0.44.0), because electron-builder puts it in Info.plist only and the updater reads it from the feed: without it, a Mac on macOS 12 would download a build that will not open. It re-downloads the feed to prove the field landed, and a failure leaves the release a draft
  6. npm Publish: The launcher package is published to npm (with provenance, via OIDC trusted publishing; the step skips itself if the version is already on the registry) so npx kangentic downloads the new version. The @kangentic/protocol workspace package is published separately by its own workflow, on its own version line and protocol-vX.Y.Z tag namespace, so protocol releases do not require a desktop release
  7. Web demo: A deploy-demo job, also gated on publish-release, builds the static web demo at the release tag and deploys it to GitHub Pages, which is where the website’s live figures come from. A failure here turns the run red after the release is already published: the release stands, and the red run says the demo is stale
  8. Poster set: A demo-posters job, also gated on publish-release, shoots one still per demo scene in both themes at 3200 by 2000, packs them with a manifest.json, and attaches the result as demo-posters-X.Y.Z.zip. That is the nineteenth asset, and it sits outside scripts/release-assets.js on purpose: the manifest gate in step 5 runs before this job does. The job refuses to attach a set whose package.json version does not match the tag, and it uploads with --clobber so a re-run replaces the asset in place. The website installs this zip before each of its own builds, into its gitignored src/assets/figures/, where it becomes what every docs figure renders before its frame boots, under no-JS, and in print. A failure here also lands after the release is live

electron-builder.yml also sets releaseInfo.releaseNotesFile: RELEASE_NOTES.md, which bakes the release notes into the generated latest*.yml update manifests at build time. The in-app release-notes dialog shown before an update restart reads its notes straight from that manifest - no extra network fetch, and no fallback to GitHub’s unsanitized Atom feed content.

Each build leg runs under its own GitHub environment (release-linux, release-linux-arm64, release-windows, release-macos-arm64, release-macos-x64). Any protection rules configured on those environments, such as required reviewers, therefore gate the build itself rather than the publish step. GitHub creates an environment the first time a workflow names it, with no rules, so a newly added leg runs unreviewed until someone adds one. The secrets are repository secrets, which every environment inherits.

RunnerPlatformArtifact
ubuntu-24.04Linux x64_amd64.deb, .x86_64.rpm, latest-linux.yml
ubuntu-24.04-armLinux arm64_arm64.deb, .aarch64.rpm, latest-linux-arm64.yml
windows-latestWindows x64NSIS .exe, its .blockmap, latest.yml
macos-latestmacOS arm64-arm64.dmg, -arm64-mac.zip, a .blockmap for each
macos-26-intelmacOS x64-x64.dmg, -x64-mac.zip, a .blockmap for each
Terminal window
# Build the app without creating an installer
npm run package
# Create distributable installers locally - the same artifacts the
# publish pipeline builds, just without uploading them anywhere
npm run make

Since v0.44.0 node scripts/package-smoke.mjs runs the unpacked app from out/ in isolation (scratch data, user data, and project) and checks a terminal, a Knowledge Graph read, a task cleanup that must stop a detached process the task’s terminal left, and a clean quit, failing on any crash or fallback log line. The package-smoke workflow runs the same script on every release target, each on the runner the release uses, for pull requests that touch packaging, the pty host, or the retrieval worker. A local package never uploads symbols to Sentry, even with a token set; only CI or an explicit KANGENTIC_SENTRY_UPLOAD=1 does.

  • Contributing - prerequisites and project structure
  • Testing - tests that run before each release

Kangentic is free and open source. A star helps other people find it.

Star on GitHub