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
-
Generate a signing key, once, from your project root:
murasaki release --keygenThis writes
.murasaki/update-key.pub(commit it) and.murasaki/update-key(mode0600, gitignored automatically). The private key is not printed. Copy it to aMURASAKI_UPDATE_KEYGitHub secret through stdin, as the command output shows:gh secret set MURASAKI_UPDATE_KEY < .murasaki/update-key -
Turn the updater on:
murasaki.config.ts export default defineConfig({ // ... updater: true, })trueis a complete config: the GitHub repo is inferred frompackage.json'srepositoryfield, and the public key from.murasaki/update-key.pub. Enabling the updater also grants the primary window its internalapp:quitpermission so the verified install can complete the graceful restart. If the update UI lives in a secondary window, grantapp:quitto that window explicitly. -
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.
Simulation only
Faster startup, improved Windows packaging, and updater reliability fixes.
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:
{
"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:
generatedAt— an anti-freeze/replay guard. See Manifest freshness below.rollout— a 0-100 staged-rollout percentage. See Staged rollout below.keyId— a key-rotation hint. See Key rotation below.
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— scansdist/for this version's payloads (the macOS.app.zipfilesmurasaki bundleproduces, the Windows-setup-<arch>.exefilesmurasaki installerproduces — the legacy un-suffixed-setup.exename is still recognized too, for win32-x64 assets published before arch-suffixed naming), hashes whichever exist, and writesdist/latest.jsonwith ageneratedAttimestamp. 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— signsdist/latest.jsonintodist/latest.json.sig. Reads the private key from$MURASAKI_UPDATE_KEY, falling back to.murasaki/update-key. When.murasaki/update-key.pubis present, also writes akeyIdhint 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:
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.sigAdd 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
- 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
generatedAtfails closed. A pre-generatedAtdeployment can temporarily opt in withallowLegacyManifestsWithoutGeneratedAt: 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 authenticatedgeneratedAtand 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.
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.
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:
- 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. - 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. - 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
| Platform | Update payload | Status |
|---|---|---|
| macOS (arm64) | <productName>-darwin-arm64.app.zip | Supported |
| macOS (x64) | <productName>-darwin-x64.app.zip | Supported |
| Windows (x64) | <productName>-<version>-setup-x64.exe | Supported |
| Windows (arm64) | <productName>-<version>-setup-arm64.exe | Supported |
| Linux AppImage (x64 / arm64) | <productName>-<version>-linux-{x64,arm64}.AppImage | Supported |
Linux .deb / bare AppDir | — | Not 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:
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).