Murasaki
Guides

Auto-update

Signed, self-verifying app updates from GitHub Releases or your own server — no IPC, no third-party update service.

Murasaki apps can check for, download, and install updates themselves. There's no update service to run — the manifest is a JSON file next to your release artifacts (GitHub Releases by default, or any static host you control), and the whole trust model is a single Ed25519 keypair you generate once.

macOS, Windows, and Linux AppImage builds. A .deb install or a bare (manually-extracted) AppDir has no self-contained file to swap — check() reports a structured "updates are managed by the system package manager" result instead of checking a manifest, never an error. See Distribution for the Linux packaging story.

Quick start

  1. Generate a signing key, once, from your project root:

    murasaki release --keygen

    This writes .murasaki/update-key.pub (commit it) and .murasaki/update-key (mode 0600, gitignored automatically). The private key is not printed. Copy it to a MURASAKI_UPDATE_KEY GitHub secret through stdin, as the command output shows:

    gh secret set MURASAKI_UPDATE_KEY < .murasaki/update-key
  2. Turn the updater on:

    murasaki.config.ts
    export default defineConfig({
      // ...
      updater: true,
    })

    true is a complete config: the GitHub repo is inferred from package.json's repository field, and the public key from .murasaki/update-key.pub. Enabling the updater also grants the primary window its internal app:quit permission so the verified install can complete the graceful restart. If the update UI lives in a secondary window, grant app:quit to that window explicitly.

  3. Drop in the button:

    import { UpdateButton } from 'murasaki'
    
    export default function Settings() {
      return <UpdateButton />
    }

That covers the app side. Publishing signed releases is below.

Playground

Explore every state returned by useUpdate() and the update flow presented by <UpdateButton />. This playground never connects to an update server, downloads a file, quits the app, or restarts it.

Murasaki Updater
A signed update is ready to download.
available
Current
0.55.6
Latest
0.56.0
Release notes

Faster startup, improved Windows packaging, and updater reliability fixes.

Hook state
useUpdate()
{
  "status": "available",
  "current": "0.55.6",
  "latest": "0.56.0",
  "notes": "Faster startup, improved Windows packaging, and updater reliability fixes.",
  "mandatory": false
}

How it works

useUpdate()'s check/download/verify logic runs in Node, reached from the page over the same local HTTP server that already serves your app (the same mechanism Server Actions and API routes use) — not the native IPC bridge the context menu and app menu use. Only the final "quit and apply" step touches the native launcher, since it has to keep running after your app process exits.

import { useUpdate } from 'murasaki'

const { status, latest, notes, progress, check, download, install, dismiss } = useUpdate()

status moves through idle → checking → available → downloading → ready (or not-available / error):

  • check() fetches the manifest, verifies its signature, and compares versions.
  • download() streams the platform-matching asset to disk and verifies its SHA-256.
  • install() hands the verified payload to the native launcher and quits the app; the launcher applies the update and relaunches.

useUpdate() is headless — no rendering, no styling opinions. <UpdateButton /> (also from murasaki, styled with @murasakijs/ui) is the ready-made presentation for it:

  • renders nothing while idle / checking / not-available / error,
  • "Update to vX" once available — click to download,
  • a progress bar while downloading,
  • "Restart to update" once ready — click to install and relaunch,
  • checks once, itself, when it mounts.

<UpdateButton /> renders nothing on error — read update.error from useUpdate() yourself if you want to surface failures in your own UI. Likewise, a manifest's mandatory flag is carried on useUpdate()'s state, but <UpdateButton /> doesn't special-case it (no forced, non-dismissable flow) — build your own UI around update.mandatory if you need one.

channel changes which manifest URL gets resolved (see Self-hosting the manifest). checkOnStart and checkInterval drive the updater engine's scheduler. The engine checks once at startup by default and then every six hours; overlapping checks are coalesced. <UpdateButton /> also checks when it mounts, and that call joins an in-flight scheduled check instead of starting a duplicate. Set checkOnStart: false and/or checkInterval: false for fully manual checks.

In dev, check() works — so you can validate a manifest/key pair without a full bundle — but download() / install() fail fast with status: 'error' and error: 'Updates only apply to a bundled app. Run \murasaki bundle` first.'`: there's no packaged app or launcher binary to apply an update to yet.

The manifest

murasaki release --manifest writes dist/latest.json, always published alongside a detached signature, dist/latest.json.sig:

latest.json
{
  "version": "1.2.0",
  "publishedAt": "2026-07-12T09:00:00.000Z",
  "generatedAt": "2026-07-12T09:00:00.000Z",
  "notes": "markdown release notes",
  "mandatory": false,
  "rollout": 25,
  "keyId": "a1b2c3d4e5f60718",
  "assets": {
    "darwin-arm64": { "url": "https://.../App-1.2.0-darwin-arm64.app.zip", "sha256": "<hex>" },
    "darwin-x64":   { "url": "https://.../App-1.2.0-darwin-x64.app.zip",   "sha256": "<hex>" },
    "win32-x64":    { "url": "https://.../App-1.2.0-setup-x64.exe",       "sha256": "<hex>" },
    "win32-arm64":  { "url": "https://.../App-1.2.0-setup-arm64.exe",     "sha256": "<hex>" }
  }
}

assets keys are <platform>-<arch>, matching Node's process.platform / process.arch on the running app. A missing key for the running platform means "no update available for you," not an error — an app can ship for fewer platforms than a manifest might otherwise cover.

latest.json.sig is the base64 of a detached Ed25519 signature over latest.json's exact raw bytes. The client verifies those bytes before ever parsing them as JSON, never the other way around — there's no JSON canonicalization ambiguity to worry about.

generatedAt is required by the client; rollout and keyId are optional. All three are covered by that same signature since they live in the signed bytes:

Publishing releases

murasaki release --keygen [--force]
murasaki release --manifest --base-url <url> --version <v> [--notes <md>] [--mandatory] [--rollout <0-100>]
murasaki release --sign
  • --keygen — generates the Ed25519 keypair (see Quick start). Refuses to overwrite an existing key unless you pass --force — rotating the key invalidates trust for any already-shipped app still holding the old public key, unless you pin both keys first (see Key rotation).
  • --manifest — scans dist/ for this version's payloads (the macOS .app.zip files murasaki bundle produces, the Windows -setup-<arch>.exe files murasaki installer produces — the legacy un-suffixed -setup.exe name is still recognized too, for win32-x64 assets published before arch-suffixed naming), hashes whichever exist, and writes dist/latest.json with a generatedAt timestamp. A missing target is skipped, not an error — only zero payloads found is fatal. --rollout <0-100> writes an optional staged-rollout percentage (see Staged rollout).
  • --sign — signs dist/latest.json into dist/latest.json.sig. Reads the private key from $MURASAKI_UPDATE_KEY, falling back to .murasaki/update-key. When .murasaki/update-key.pub is present, also writes a keyId hint into the manifest before signing it (see Key rotation).

(--generate-manifest still works as a deprecated alias of --manifest.)

GitHub Actions

A release workflow needs to build the update payloads for each platform, generate and sign the manifest, and upload everything to the same GitHub Release — so the default updater: true URL (releases/latest/download/latest.json) actually resolves them:

.github/workflows/release.yml
name: Release
on:
  push:
    tags: ['v*']

jobs:
  macos:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: pnpm install
      - run: pnpm exec murasaki bundle            # darwin-arm64 (host arch)
      - run: pnpm exec murasaki bundle --arch x64  # darwin-x64 (cross-arch)
      - uses: actions/upload-artifact@v4
        with:
          name: macos-payloads
          path: dist/bundle/*.app.zip

  windows:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: pnpm install
      - name: Install NSIS
        shell: pwsh
        run: |
          choco install nsis -y
          echo "C:\Program Files (x86)\NSIS" >> $env:GITHUB_PATH
      - run: pnpm exec murasaki installer
      - uses: actions/upload-artifact@v4
        with:
          name: windows-payload
          path: dist/*-setup-*.exe

  publish:
    needs: [macos, windows]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: pnpm install
      - uses: actions/download-artifact@v4
        with: { name: macos-payloads, path: dist/bundle }
      - uses: actions/download-artifact@v4
        with: { name: windows-payload, path: dist }
      - name: Build + sign the manifest
        env:
          MURASAKI_UPDATE_KEY: ${{ secrets.MURASAKI_UPDATE_KEY }}
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          BASE_URL="https://github.com/${{ github.repository }}/releases/download/${GITHUB_REF_NAME}"
          pnpm exec murasaki release --manifest --base-url "$BASE_URL" --version "$VERSION"
          pnpm exec murasaki release --sign
      - uses: softprops/action-gh-release@v2
        with:
          files: |
            dist/bundle/*.app.zip
            dist/*-setup-*.exe
            dist/latest.json
            dist/latest.json.sig

Add MURASAKI_UPDATE_KEY as a repository secret from the private key file; do not paste it into a shell argument. This is independent of code-signing — see Distribution if you also want a Developer ID-signed, notarized .dmg; nothing stops you from adding those steps to the same macos job.

This example only publishes win32-x64, but win32-arm64 is supported too (see Platform support below) — add a second windows job (or a matrix leg) that runs murasaki bundle --target win32-arm64 / murasaki installer --target win32-arm64 and uploads its -setup-arm64.exe alongside the x64 one; the arch-suffixed filenames won't collide in the same dist/.

Apply transaction and first-launch rollback

The native launcher does not delete the current install before hoping the new one works. It writes an owner-only, atomically replaced journal beside the installed app, renames the current install to a same-volume backup, installs the verified payload, and retains that backup across relaunch.

The new version acknowledges health only after the packaged Node server is listening, the primary-instance endpoint is published, and every createOnLaunch: true native window/WebView was created. Dormant templates are validated as metadata but are outside this first-launch checkpoint. If the update helper dies during the swap, or the first new launcher exits before that checkpoint, the next launch copies a recovery helper outside the install target, restores the previous version, and relaunches it. PID ownership, an exclusive journal lock, and app-scoped single-component backup names prevent another process or path traversal from claiming the transaction.

Rollback covers the application files through the first-launch checkpoint. It does not roll back a later crash, and Windows registry/shortcut changes made by NSIS are outside the file transaction. A power loss during the recovery rename itself can still require manual repair. Keep application data migrations backward-compatible until the new version has established its own durable migration checkpoint.

Security model

Signature verification is mandatory — there is no config option to disable it. Every check() fetches latest.json and latest.json.sig, verifies the Ed25519 signature over the manifest's raw bytes against the app's public key, and refuses to trust the manifest if that fails. Every download() separately re-verifies the payload's SHA-256 against the hash inside the (already-verified) manifest.

That combination means an attacker who controls only the file host (a compromised CDN, a MITM'd mirror, a malicious PR to a self-hosted dist/ bucket) can't push a fake update without also holding the private key — which never leaves .murasaki/update-key / your CI secret.

Use murasaki installer --target win32-x64 --sign for Authenticode in addition to this manifest signature — see Distribution. Without --sign, Ed25519 is the update's only authenticity guarantee on Windows.

Keep .murasaki/update-key out of version control (--keygen gitignores it automatically) and treat MURASAKI_UPDATE_KEY like any other release-signing secret.

A self-hosted updater.endpoint must be https:http: is only accepted for loopback hosts (127.0.0.1, localhost, [::1]), for local testing. This is enforced both when your config loads and again at fetch time, so a config that somehow bypasses validation still can't be pointed at a plaintext endpoint. GitHub-hosted manifests are always fetched over https:, so this only matters if you set endpoint (see Self-hosting the manifest).

Manifest freshness

Every manifest must include a generatedAt timestamp (written automatically by murasaki release --manifest) and is checked for freshness before anything else. A manifest older than updater.maxManifestAgeDays (default 90, minimum

  1. is rejected outright, as a possibly frozen or replayed manifest — without this, an attacker who once captured a validly-signed old manifest could keep serving it forever, freezing a fleet on a known-vulnerable version. A manifest dated more than 24 hours in the future is also rejected, as a clock-skew/tampering signal (that tolerance is fixed, not configurable). A manifest with no generatedAt fails closed. A pre-generatedAt deployment can temporarily opt in with allowLegacyManifestsWithoutGeneratedAt: true; this logs a warning and weakens replay protection, so regenerate and re-sign the manifest instead whenever possible. Packaged apps also persist the highest authenticated generatedAt and version per channel in their OS app-data directory. A later manifest that moves either value backwards is rejected even when its signature and age are otherwise valid.
murasaki.config.ts
export default defineConfig({
  // ...
  updater: {
    maxManifestAgeDays: 30, // reject anything older than 30 days
    // allowLegacyManifestsWithoutGeneratedAt: true, // migration only
  },
})

Key rotation

Pin more than one Ed25519 public key with publicKeys (up to 4 total, counting publicKey) so you can rotate keys without breaking already-shipped apps: verification tries every pinned key until one succeeds, so an app holding either the old or the new key still trusts a manifest signed with either one.

murasaki.config.ts
export default defineConfig({
  // ...
  updater: {
    publicKey: 'OLD_KEY_BASE64...',      // .murasaki/update-key.pub before rotation
    publicKeys: ['NEW_KEY_BASE64...'],   // the new key, added ahead of rotating
  },
})

murasaki release --sign also writes a keyId — the first 8 bytes (hex) of sha256 of the raw public key — into the manifest next to the signature, when .murasaki/update-key.pub is available. The client uses keyId only as a hint for which pinned key to try first; it always falls back to trying every pinned key, so a missing or stale hint never causes a false rejection.

Rotation runbook, across three releases:

  1. Version N — ship pinning [old, new] (both keys), still signing with the old key. Every app that installs or updates to N now trusts both keys.
  2. Version N+1 — run murasaki release --keygen --force (or otherwise swap in the new key), then sign with the new key. Apps on N (or later) already trust it; apps still behind N reject it until they update to N first.
  3. Version N+2 — drop the old key from publicKeys/publicKey, pinning only the new one. By now every app that's kept up has moved past the version that only knew the old key.

Staged rollout

murasaki release --manifest --rollout <0-100> writes an optional rollout percentage into the manifest. Absent (or 100) means every client sees the update immediately, same as today.

Below 100, each installed app computes a stable bucket from a random id persisted the first time it checks (update-client-id, stored in Main's OS-standard context.paths.data directory) — sha256(id)'s first byte, mod 100. Mutable rollout state is never written into the signed/read-only app resources; only the one-shot .murasaki-apply.json launcher handoff lives there. A client whose bucket is >= rollout gets not-available for that check, exactly like "no update for your platform": no error, no retry storm, just try again on the next scheduled check, by which point you may have raised the percentage. This is a distribution knob, not a security boundary — it doesn't change what a client is allowed to install, only when it's offered.

Platform support

PlatformUpdate payloadStatus
macOS (arm64)<productName>-darwin-arm64.app.zipSupported
macOS (x64)<productName>-darwin-x64.app.zipSupported
Windows (x64)<productName>-<version>-setup-x64.exeSupported
Windows (arm64)<productName>-<version>-setup-arm64.exeSupported
Linux AppImage (x64 / arm64)<productName>-<version>-linux-{x64,arm64}.AppImageSupported
Linux .deb / bare AppDirNot applicable — check() reports "managed by the system package manager"

Windows self-update is deliberately coupled to a per-user NSIS install. When updater is configured, murasaki installer requires NSIS, skips MSI, and rejects installer.windows.installMode: 'perMachine'. MSI is a separate, system-managed deployment path available only when the built-in updater is disabled; upgrade it with MSI major upgrades or your organization's software management system instead of the Murasaki update engine.

murasaki installer names the NSIS installer with an arch suffix so x64 and arm64 builds never collide in the same dist/; murasaki release --manifest still also recognizes the legacy un-suffixed -setup.exe name, so assets published before this change keep resolving as win32-x64.

Self-hosting the manifest

Not using GitHub Releases? Point endpoint at any URL serving latest.json (with latest.json.sig alongside it) instead of repo:

murasaki.config.ts
export default defineConfig({
  // ...
  updater: {
    endpoint: 'https://updates.example.com/latest.json',
  },
})

repo and endpoint are mutually exclusive — murasaki throws at build/dev time if both are set. endpoint must be https: (see Security model above for the loopback exception used in local testing). Run murasaki release --manifest --base-url https://updates.example.com and upload dist/latest.json + dist/latest.json.sig + the payloads to wherever that URL resolves.

For a non-stable channel on GitHub, Murasaki points at releases/download/<channel>/latest.json instead of releases/latest/download/… — a moving tag you re-push on every release for that channel (e.g. a beta tag for a beta channel).

Next

Improve this page on GitHub

On this page