Hyperbolic Gateway

Building installers & auto-updates โ€” scope

Status: scoping (2026-10-21) ยท Related: DECISION_MODELS.md, ../CHANGELOG.md, ../electron-builder.json

This document covers three things that are easy to conflate: producing installers on each platform, publishing them to GitHub Releases, and updating an installed copy from those releases. The build workflow produces Windows (x64), Linux (x64) and macOS (Apple Silicon) artefacts; publishing is automated from those jobs.


1. Where we are

piece state
win targets (nsis, portable, zip, x64) configured, built and used
mac targets (dmg, zip, arm64) configured, built in CI; Intel Macs unsupported (see ยง1 blockers)
linux targets (AppImage, deb, tar.gz, x64) configured, built in CI
icon public/assets/mark-eleven.png, 1024ร—1024 โ€” usable for .icns/.ico conversion
runtime platform branches host-fingerprint.ts handles darwin/linux; index.ts has macOS window-all-closed
CI .github/workflows/build.yml (guard โ†’ platform matrix โ†’ publish)
signing none
auto-update Windows + Linux configured; macOS deferred until signing

Packaging invariants

  1. The pnpm linker is hoisted. pnpm-workspace.yaml sets nodeLinker: hoisted; electron-builder does not follow pnpm's isolated peer symlinks. apache-arrow is also a direct dependency: it is a peer of LanceDB, and a package that is only a peer is not collected into the production tree. The same applies to @opentelemetry/api and reflect-metadata (transitive through LanceDB); the afterPack check verifies all three.
  2. Native deps are per-target, not host-only. CI installs on each runner, then scripts/prune-pack.cjs keeps only the target OS/arch. scripts/verify-pack.cjs runs after packing and fails the build if any critical JS module, LanceDB/ONNX/sharp native binary, or the unpacked MCP server is missing.
  3. macOS dmg cannot be built on Windows, and AppImage/deb realistically need Linux (fpm). build:dist:all on one machine will not produce three platforms โ€” use the CI matrix.
  4. Intel Macs are not supported. LanceDB 0.40 publishes lancedb-darwin-arm64 but not lancedb-darwin-x64; an x64 build would install and then crash in Cortex. The macOS targets and runner are arm64 only.
  5. Runtime is untested off Windows. Specifically: Linux tray needs libappindicator; macOS tray icons want a template image for dark mode; CDP typing sends windowsVirtualKeyCode (Chromium also honours key/text, so it should work, but it is unverified); HIL "bring window to front" is Windows-centric.
  6. Runtime state lives in userData, not the install directory. src/main/data-paths.ts resolves downloads, Cortex, logs, recipes, sessions and settings to Electron's userData/data in packaged builds (repo data/ in development). This matters because Windows may start the installed executable with a read-only working directory; writing relative to process.cwd() crashes the main process before the window exists. HB_DATA_DIR (legacy KUANG_DATA_DIR) overrides the root for tests.
  7. No signing. Unsigned macOS builds are refused by Gatekeeper; Windows shows a SmartScreen warning.

2. Artefact matrix

platform artefacts notes
Windows *-Setup-<ver>.exe (NSIS, per-user, choose dir), *-Portable-<ver>.exe, *.zip NSIS supports auto-update; portable cannot self-update (document it)
macOS *.dmg (distribution), *.zip (required for updates) arm64 only; universal/x64 is not buildable with LanceDB 0.40
Linux *.AppImage (auto-updatable), *.deb, *.tar.gz only AppImage supports in-place updates

Naming: keep artifactName explicit so the updater and humans agree โ€” e.g. ${productName}-${version}-${os}-${arch}.${ext}.


3. CI: build matrix

One workflow, three runners, artefacts only (no publishing from the build job):

name: build
on:
  workflow_dispatch:
  push:
    tags: ['v*']

jobs:
  guard:
    runs-on: ubuntu-latest
    outputs: { build: "${{ steps.check.outputs.build }}" }
    steps:
      - uses: actions/checkout@v4
      - id: check
        run: node scripts/check-release-tag.mjs "${{ github.ref_name }}"

  build:
    needs: guard
    # Four-part tags emit build=false โ†’ the matrix is skipped, not failed. Manual runs build.
    if: needs.guard.outputs.build == 'true'
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: windows-latest
            platform: win
          - os: ubuntu-latest
            platform: linux
          - os: macos-14
            platform: mac
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - name: Prune native deps for target
        shell: bash
        run: |
          case "${{ matrix.platform }}" in
            win) PLATFORM=win32; ARCH=x64 ;;
            linux) PLATFORM=linux; ARCH=x64 ;;
            mac) PLATFORM=darwin; ARCH=arm64 ;;
          esac
          TARGET_PLATFORM=$PLATFORM TARGET_ARCH=$ARCH node scripts/prune-pack.cjs
      - name: Package
        run: pnpm exec electron-builder --${{ matrix.platform }} --publish never
        env:
          CSC_IDENTITY_AUTO_DISCOVERY: false      # unsigned by default; see ยง5
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.platform }}-artifacts
          path: |
            release/*.exe
            release/*.dmg
            release/*.zip
            release/*.AppImage
            release/*.deb
            release/*.tar.gz
            release/latest*.yml

Notes:


4. Publishing to GitHub Releases

Publishing is automated by the publish job in .github/workflows/build.yml, which runs after all three platform builds succeed:

  1. downloads the win-, linux- and mac-artifacts bundles,
  2. stages the installers, zips, blockmaps and latest*.yml feeds,
  3. creates a draft release (if it does not exist) with notes extracted from CHANGELOG.md,
  4. uploads every asset with --clobber (re-runs are idempotent), then
  5. publishes only for a patch tag.
Tag Result
v1.4.6 (patch) Published automatically (gh release edit --draft=false --latest).
v1.5.0 (minor) Left as a fully-populated draft; publish with gh release edit v1.5.0 --draft=false --latest.
v2.0.0 (major) Same: draft, human publishes.
v1.4.6.1 (four-part) No build at all โ€” the guard skips the matrix.

The job summary links the one-line publish command for drafts. --latest is required: the auto-updater finds the feed through GitHub's "latest release", not by scanning tags.

Manual fallback / re-publish: run the workflow from a tag with the publish input, or locally:

gh run download <run-id> -D release-artifacts
# flatten artifacts/release/* into one folder, then:
gh release create v1.4.6 --draft --notes-file notes.md
gh release upload v1.4.6 assets/* --clobber
gh release edit v1.4.6 --draft=false --latest

Why not electron-builder --publish always? Three OS jobs would race to create and update the same release, occasionally leaving a half-uploaded page. A single publish job also guarantees a release is only considered when every platform built.


5. Signing & notarisation

platform what is needed without it
Windows Authenticode cert (OV/EV), WIN_CSC_LINK + WIN_CSC_KEY_PASSWORD (or Azure Trusted Signing) SmartScreen warns; updates still work
macOS Developer ID Application certificate (.p12 + password โ†’ CSC_LINK/CSC_KEY_PASSWORD), plus notarisation credentials: APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID (or App Store Connect API key) Gatekeeper blocks the app; Squirrel.Mac refuses to apply updates to an unsigned app
Linux none nothing โ€” AppImage is self-contained

Decision: macOS ships unsigned first

The borrowed Developer ID is not available yet, so macOS starts unsigned (mac.identity: null, CSC_IDENTITY_AUTO_DISCOVERY: false). Consequences, which must be stated wherever the DMG is offered:

When the cert arrives: phase 4b below, no code changes beyond the credentials.


6. Auto-update from GitHub builds

Use electron-updater (6.x). It reads the latest*.yml feed that electron-builder writes and verifies each download against the sha512 in that file.

"publish": [{ "provider": "github", "owner": "hyperbolictonic", "repo": "hyperbolic-gateway" }]
// main: after app.whenReady()
import { autoUpdater } from 'electron-updater';
autoUpdater.autoDownload = true;          // Windows/Linux: download in background
autoUpdater.autoInstallOnAppQuit = false; // install explicitly, or from before-quit below
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.allowPrerelease = false;      // stable channel
autoUpdater.on('update-available', (info) => hud.send('app:update-available', info.version));
autoUpdater.on('download-progress', (p) => hud.send('app:update-progress', Math.round(p.percent)));
autoUpdater.on('update-downloaded', () => hud.send('app:update-ready'));   // HUD offers "Restart"
app.on('before-quit', (event) => { if (ready && !installing) { event.preventDefault(); quitAndInstall(false, true); } });
app.whenReady().then(() => setTimeout(() => autoUpdater.checkForUpdates().catch(() => {}), 30_000));
setInterval(() => autoUpdater.checkForUpdates().catch(() => {}), 6 * 60 * 60 * 1000);

Platform behaviour:

Operational notes:


7. Versioning vs updates โ€” the important interaction

Our current policy allows a fourth number in the tag (v1.4.2.1) while package.json/APP_VERSION stay three-part (1.4.2). That is fine for docs, and it is incompatible with auto-update:

Rule going forward: four-part tags are docs-only, never artefact releases. Anything that ships an installer must bump the three-part version (1.4.3, 1.4.4, โ€ฆ). Tags that publish binaries must be v<package.json version> exactly โ€” add a CI check that fails if v* โ‰  package.json#version for releases with artefacts.


8. Phased plan

phase work effort risk
1 Remove/scope electronDist; add release-tag check; .github/workflows/build.yml (matrix, unsigned, artefacts only) ~ยฝ day low โ€” Windows path unchanged
2 Run the Linux + macOS jobs, fix runtime issues found (tray, native deps, key dispatch); publish Linux AppImage/deb unsigned 1โ€“2 days medium โ€” untested UI paths
3 Wire electron-updater + HUD prompts โ€” done in 1.4.4 (Windows + Linux; macOS deferred with signing). Feed metadata (latest*.yml) ships with each release; the remaining check is one real upgrade cycle between consecutive versions ~1 day medium โ€” needs two real releases
4a macOS unsigned: build dmg/zip, publish with the Gatekeeper workaround in the release notes; mac auto-update stays disabled ~ยฝ day low โ€” but users must right-click โ†’ Open
4b macOS signing + notarisation once the cert is borrowed; publish latest-mac.yml, enable mac updates, verify Squirrel.Mac applies one ~1 day after the cert external dependency, low code risk
5 Windows signing (OV cert or Azure Trusted Signing); staged rollout; optional beta channel later procurement

9. What could bite