Packaging and releases
Kangentic uses electron-builder to create platform-specific installers. Configuration lives in electron-builder.yml at the project root.
Platform targets
Section titled “Platform targets”| Platform | Format | Installer Type |
|---|---|---|
| Windows (x64) | .exe | NSIS (Nullsoft Scriptable Install System) |
| macOS (arm64 and x64) | .dmg, .zip | DMG 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, .rpm | Native 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.
Native module handling
Section titled “Native module handling”Several native modules require special handling during packaging:
| Module | Strategy |
|---|---|
| better-sqlite3 | Since 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 |
| koffi | New 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-pty | Ships 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-node | Ships prebuilt native binaries for local voice Dictation; shipped and unpacked from ASAR (native addons can’t be dlopen’d from inside the archive) |
| font-list | Enumerates 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-vec | The 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/transformers | The 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 scripts | event-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 |
ASAR packaging
Section titled “ASAR packaging”The app is packaged as an ASAR archive for faster loading and to prevent casual modification. Selective unpacking is configured for:
- Native
.nodemodules 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, andplugins/** - The dictation runtime (
sherpa-onnx-node) - The Conversation Memory runtime: the
sqlite-vecloadable extension,onnxruntime-node(with its Windows DLLs), and@huggingface/transformers, along with theembed-worker.jsthat 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.
Security fuses
Section titled “Security fuses”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:
| Fuse | Setting | Purpose |
|---|---|---|
| RunAsNode | Disabled | Prevents ELECTRON_RUN_AS_NODE environment variable |
| NodeOptions | Disabled | Prevents NODE_OPTIONS injection |
| Inspection | Disabled | Prevents --inspect debugging in production |
| Cookie Encryption | Enabled | Encrypts cookies at rest |
| ASAR Integrity | Enabled | Validates ASAR archive integrity on load |
| Only Load App From ASAR | Enabled | Refuses to run app code from anywhere but the ASAR archive |
| Load Browser Process Specific V8 Snapshot | Disabled | Electron’s default; no browser-process snapshot is built |
| Grant File Protocol Extra Privileges | Enabled | Electron’s default, and required: every window loads the renderer over file://, its lazy chunks and the diff editor’s workers included |
| Wasm Trap Handlers | Enabled | Electron’s default |
Windows install-folder grant
Section titled “Windows install-folder grant”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.
Code signing
Section titled “Code signing”Windows
Section titled “Windows”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
.appbundle is submitted to Apple’s notary service withnotarytooland stapled in theafterSignhook, before the DMG and ZIP are built around it - A spawn-helper gate on both sides of signing -
afterPackproves the compiledspawn-helperleaves a child with no inherited crash port and still sets its working directory, andafterSignruns the same check on the signed binary before notarization, since hardened runtime is the one thing signing adds. Either failure fails the build
Release workflow
Section titled “Release workflow”Releases follow a structured process:
- Trigger: Run
/release patch|minor|majorin 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 - Preflight: A
preflight-symbolsjob fails the run in seconds if theKANGENTIC_SENTRY_TOKENsecret is missing, before any draft or build exists, and since v0.46.0 apreflight-signingjob does the same for the Apple signing and notarization secrets, becausebuild/afterSign.jsonly 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 - Draft Release: A dedicated
create-draft-releasejob 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) - CI Build: The tag push then builds all five targets, each job attaching its artifacts to that one draft. Every platform verifies
app-update.ymlis packaged; each Linux job additionally fresh-installs the built.rpmon Fedora and openSUSE containers and the.debon Debian and Ubuntu, then runs a second, narrower gate that upgrades from the previous published release - the.rpmon Fedora and the.debon 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 - Publish: Once every build succeeds, a
publish-releasejob first rebuildslatest-mac.ymlto 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 namesarm64on 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.ymlandlatest-linux-arm64.yml). Thenscripts/verify-release-assets.jsruns 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 fromRELEASE_NOTES.mdin 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.jswrites the release’s minimum macOS version intolatest-mac.yml(since v0.44.0), because electron-builder puts it inInfo.plistonly 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 - 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 kangenticdownloads the new version. The@kangentic/protocolworkspace package is published separately by its own workflow, on its own version line andprotocol-vX.Y.Ztag namespace, so protocol releases do not require a desktop release - Web demo: A
deploy-demojob, also gated onpublish-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 - Poster set: A
demo-postersjob, also gated onpublish-release, shoots one still per demo scene in both themes at 3200 by 2000, packs them with amanifest.json, and attaches the result asdemo-posters-X.Y.Z.zip. That is the nineteenth asset, and it sits outsidescripts/release-assets.json purpose: the manifest gate in step 5 runs before this job does. The job refuses to attach a set whosepackage.jsonversion does not match the tag, and it uploads with--clobberso a re-run replaces the asset in place. The website installs this zip before each of its own builds, into its gitignoredsrc/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.
Environment protection
Section titled “Environment protection”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.
CI build matrix
Section titled “CI build matrix”| Runner | Platform | Artifact |
|---|---|---|
ubuntu-24.04 | Linux x64 | _amd64.deb, .x86_64.rpm, latest-linux.yml |
ubuntu-24.04-arm | Linux arm64 | _arm64.deb, .aarch64.rpm, latest-linux-arm64.yml |
windows-latest | Windows x64 | NSIS .exe, its .blockmap, latest.yml |
macos-latest | macOS arm64 | -arm64.dmg, -arm64-mac.zip, a .blockmap for each |
macos-26-intel | macOS x64 | -x64.dmg, -x64-mac.zip, a .blockmap for each |
Local testing
Section titled “Local testing”# Build the app without creating an installernpm run package
# Create distributable installers locally - the same artifacts the# publish pipeline builds, just without uploading them anywherenpm run makeSince 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.
See also
Section titled “See also”- 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