ADR-0049 — Desktop auto-update over GitHub Releases (unified v* tag, minisign-verified)¶
- Status: Accepted
- Date: 2026-07-03
- Sprint: v0.6 (M6 — shippable; F15 cross-platform desktop packaging)
- Features: F15 (cross-platform: Tauri v2 desktop bundles + self-update, macOS + Windows) ·
F16 (settings/i18n IT/EN —
desktop.update.*namespace) - Builds on: ADR-0039 (Tauri v2 shell — scaffold, CI, single codebase; no
invoke/window.__TAURI__in React) · ADR-0047 (desktop runtime server URL,base.ts,isTauri()container check, Connect gate) · ADR-0048 (desktop pack:tauri-plugin-notification,capabilities/default.json, Tauri-only UI guarded byisTauri()) - Reference: R13 (Tauri v2 — https://tauri.app;
tauri-plugin-updater,tauri-plugin-process,tauri-action, minisign updater signatures) · CLAUDE.md §3 (invariants I5/I6/I7/I9), §11 (branch/commit conventions), §12 (no hardcoded config in code) - Invariants owned: I5 (Obsidian compat — unaffected; pure client-side update transport) · I6 (pluggable inference — backend-side; not touched by desktop self-update) · I7 (loops bounded — the update check is a single startup check, no polling loop) · I9 (do not reinvent — Tauri's first-party updater over GitHub Releases; no bespoke update server, no third-party update framework). No invariant is traded for convenience.
- Author: solution-architect
- Implementers: devops-engineer (
.github/workflows/desktop-release.yml,src-tauri/tauri.conf.json,src-tauri/Cargo.toml,src-tauri/capabilities/default.json,src-tauri/src/main.rs, version bump +frontend/package.jsonsync) · frontend-engineer (components/**update banner/dialog,store/**update state,en.json/it.jsondesktop.update.*) · tech-writer (D6b DEPLOY: release/tag procedure + key-loss caveat; D6a USER: what the update banner does)
1. Context¶
ADR-0047 and ADR-0048 delivered the packaged Tauri v2 desktop app (macOS-arm64, Windows-x64),
unsigned at the OS level (Gatekeeper/SmartScreen right-click-open accepted per ADR-0047 §4),
with tauri-plugin-notification already wired. A v0.6.0 GitHub release exists (created
manually with gh). There is currently no in-app update path: an installed client stays
frozen at the version it was installed with, and the owner would have to hand-deliver each new
build.
The owner explicitly requested a fully GitHub-hosted desktop auto-update flow — no
self-hosted update server, no third-party service. Tauri v2 ships a first-party updater
(tauri-plugin-updater) that reads a static latest.json manifest, verifies each artifact
against an app-embedded minisign public key, downloads the platform bundle, and installs
it; tauri-action produces the updater artifacts and latest.json and can attach them to a
GitHub release. This is the canonical R13 mechanism (I9: reuse, do not reinvent).
Verified facts (re-confirmed against the tree at this ADR's date):
- CI (
.github/workflows/desktop-release.yml): triggers ondesktop-v*tags +workflow_dispatch; matrixmacos-latest(arm64) +windows-latest(x64); usestauri-apps/tauri-action@v0withtauriScript: node frontend/node_modules/@tauri-apps/cli/tauri.js(invoked from repo root so the CLI finds./src-tauri);tagName/releaseNameset only ondesktop-v*tag pushes (workflow_dispatch= build-only, emptytagName). No signing env vars are passed today; nolatest.json/updater artifacts are produced. src-tauri/tauri.conf.json:version"0.6.0", identifierai.synapse.app,security.csp: null, bundle targets["app","dmg","nsis"],bundle.iconpresent. Nobundle.createUpdaterArtifacts; noplugins.updaterblock.src-tauri/Cargo.toml:tauri = "2",tauri-plugin-notification = "2". Notauri-plugin-updater, notauri-plugin-process.src-tauri/capabilities/default.json(v1, window"main"): core window/app perms +notification:default. Noupdater:default, noprocess:allow-restart.src-tauri/src/main.rs:tauri::Builder::default().plugin(tauri_plugin_notification::init())….setup(...)….run(...). The updater/process plugins are not initialised. (lib.rshas a deadrun()stub that is not the active entry point — main.rs is.)- Version is duplicated in
src-tauri/tauri.conf.jsonandsrc-tauri/Cargo.toml(both0.6.0) and infrontend/package.json. - Updater keypair already generated (minisign): private key + password live in GitHub
Actions secrets
TAURI_SIGNING_PRIVATE_KEY/TAURI_SIGNING_PRIVATE_KEY_PASSWORD; the private key is not in the repo (local copy in~/.tauri/). Public key (to embed):dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY2NTI4RkIwMTRFOUUyNzEKUldSeDR1a1VzSTlTWnJSRGtqa0JyWml5ZEltdlVGM1F4NmErSkUxZHdiQjdGdlRCM1NoTHRlQUQK - All existing desktop-only UI (ADR-0047/0048) is guarded by
isTauri()(__TAURI_INTERNALS__presence check), so a browser build renders every desktop branch inert. The update UI follows the same guard.
The updater's security guarantee is independent of Apple/Microsoft OS signing: Tauri verifies our minisign signature over each artifact before installing. So the unsigned-at-OS posture of ADR-0047 does not weaken update integrity — the private key is the only trust root for the update chain, which makes key custody the load-bearing risk (§4).
2. Decision¶
Ratify the contract U1–U6 verbatim, with two consolidations recorded (not amendments to intent): the version source-of-truth reconciliation (§2.5, because Cargo.toml also carries the version) and the release-notes source for the in-app dialog (§2.4). All six contract points are accepted.
2.1 (U1) Unified release channel — v* tag on GitHub Releases — RATIFIED¶
.github/workflows/desktop-release.yml moves to one tag per product release on the
pattern v* (e.g. v0.7.0). The desktop-v* trigger is removed — there is a single
release channel for the whole product, and a desktop build/release is produced from the same
v* tag.
- Matrix stays
macos-latest(arm64) +windows-latest(x64);tauri-action@v0invoked with the existingtauriScript(node frontend/node_modules/@tauri-apps/cli/tauri.jsfrom repo root — do NOTnpm --prefix frontend run, which would chdir and break./src-tauridetection; unchanged from ADR-0047/0039). tauri-actioncreates/updates the GitHub release for the tag, uploads the OS bundles (.app.tar.gz+.sig, NSIS.exe+.sig) andlatest.json(includeUpdaterJson: true), signing the updater artifacts with the two secrets:TAURI_SIGNING_PRIVATE_KEY/TAURI_SIGNING_PRIVATE_KEY_PASSWORDpassed asenvto the build step.contents: writepermission is retained.tagName/releaseNameare gated onstartsWith(github.ref, 'refs/tags/v');workflow_dispatchremains build-only (emptytagName, no release).releaseDraft: false,prerelease: falseas today.- Two-runner
latest.jsonmerge: because both runners target the same release for one tag, the workflow must produce a singlelatest.jsoncarrying both platform entries (darwin-aarch64,windows-x86_64).tauri-action'sincludeUpdaterJsonmerges platform entries into the release'slatest.jsonacross matrix jobs; the implementer MUST verify the final release asset contains both platforms before the release is considered good (acceptance check in §5.1). The manualv0.6.0release predates the updater and has nolatest.json— the firstv*release cut under this ADR (≥v0.7.0) is the first one installed clients can update to.
2.2 (U2) tauri.conf.json — updater artifacts + endpoint — RATIFIED¶
bundle.createUpdaterArtifacts: true(this is what makestauri buildemit the.app.tar.gz/.sig+ NSIS.sigupdater bundles alongside the installers).plugins.updaterblock with:pubkey: the base64 minisign public key above (embedded in the binary — this is the trust root; it is public by design and safe to commit).endpoints:["https://github.com/Emanuele-Chiummo/llm-wiki-synapse/releases/latest/download/latest.json"]— GitHub'sreleases/latest/download/<asset>redirect always resolves to the most recent non-prerelease release's asset, so the endpoint is static and needs no bump per release (I9: no update server; GitHub is the host).- The public key committed in config MUST match the private key in the GitHub secrets, or every client will reject every update (signature check fails). This pairing is a release-time invariant (§4 risk 1, §5.1 acceptance).
2.3 (U3) Rust plugins + capabilities — RATIFIED¶
src-tauri/Cargo.toml: addtauri-plugin-updater = "2"andtauri-plugin-process = "2"(process plugin provides the post-install relaunch).src-tauri/src/main.rs: initialise both in the builder chain, alongside the existing notification plugin:.plugin(tauri_plugin_updater::Builder::new().build())and.plugin(tauri_plugin_process::init()). No new command handlers are registered (invoke_handlerstays empty) — the frontend calls the plugins' JS APIs, consistent with ADR-0039's "no bespokeinvokecommands in React" posture (these are first-party plugin APIs, not custom IPC commands).src-tauri/capabilities/default.json: add"updater:default"and"process:allow-restart"to thepermissionsarray (window"main"), next to the existingnotification:default.process:allow-restartis the minimal process permission — do NOT addprocess:allow-exitor broader process perms (least privilege; the only process action needed is relaunch after install).
2.4 (U4) Frontend update UX — startup check, single prompt, no polling — RATIFIED¶
All update UI is Tauri-only, every plugin call behind isTauri() with the plugin JS
modules dynamically imported inside the guard (so the web/PWA bundle never imports
@tauri-apps/plugin-updater/plugin-process and every branch is dead code in a browser —
identical pattern to ADR-0048 §2.4c notifications).
- When: on app start, after the shell has rendered, run
check()once, non-blocking (fire-and-forget; a failed/timed-out check MUST NOT block or crash the app — catch and no-op). No periodic polling, no interval, no background loop — one check per process start. This is the I7 bound: the "loop" is a single iteration by construction (§2.7 invariant check). - If an update is available: show a dismissible banner/dialog with the new
{{version}}, the release notes (update.bodyfrom the manifest), and two actions — Update now / Later. i18n namespacedesktop.update.*in bothen.jsonandit.json(keys at least:availablewith{{version}}interpolation,notes/notes-label,updateNow,later,downloading(+progress),installing,error; parity test must pass). - On "Update now": call
downloadAndInstall()and surface progress state (bytes/percent from the plugin's progress callback) in the UI; on completion callrelaunch()(from@tauri-apps/plugin-process) to restart into the new version. - On "Later": dismiss and do not re-prompt until the next app start (no re-nag within
the session; the next startup
check()will surface it again). No persistence of "skip this version" is required for v0.6 — dismissal is session-scoped by design (simplest correct behaviour; a per-version mute can be a future ADR if it proves annoying).
2.5 (U5) Version source of truth — RATIFIED, with a reconciliation¶
- The updater compares the running app version against
latest.json'sversion; the running version is theversionfield ofsrc-tauri/tauri.conf.json(Tauri's authoritative app version). This is the source of truth, bumped per release (0.7.0, …). - Reconciliation (recorded, not a change of intent): the version is currently duplicated in
three places —
src-tauri/tauri.conf.json,src-tauri/Cargo.toml, andfrontend/package.json.tauri.conf.jsonversionis authoritative for the updater; the release procedure MUST bump all three together so they never diverge (a mismatched Cargo.toml version produces a binary whose reported version disagrees with the manifest, breaking the compare). The tag (vX.Y.Z) MUST equal thetauri.conf.jsonversion. This three-way bump is part of the DEPLOY release checklist (D6b) and the §5.1 acceptance.
2.6 (U6) macOS unsigned caveat — RATIFIED (documented, not mitigated here)¶
The updater artifacts are ad-hoc-signed .app tarballs (no Apple Developer ID / no
notarization — ADR-0047 §4 posture). Tauri's updater verifies our minisign signature over
each artifact, which is independent of Apple notarization: update integrity does not depend
on OS-level signing. This is acceptable per the ADR-0047 unsigned posture.
Documented consequence (D6b): the minisign private key
(TAURI_SIGNING_PRIVATE_KEY + password; local copy ~/.tauri/) is the sole trust root for
the update chain. If the private key is lost, all installed clients can no longer receive
updates — a new keypair produces signatures that embedded clients reject, so the only recovery
is a manual re-install of a build carrying the new public key. Therefore the key MUST be backed
up securely off-machine (and the GitHub secret must not be rotated without a coordinated
re-release). This is the primary operational risk (§4 risk 1). Additionally, macOS
first-install (not update) still triggers Gatekeeper (unchanged from ADR-0047 §4) — the
updater path only applies to an already-installed, already-trusted app updating itself.
2.7 Invariant check¶
- I1 (incremental index): N/A — this is a desktop client self-update; no vault scan, no Postgres/Qdrant writes. Unaffected.
- I2 / I3 / I4 (graph / per-token / editor): untouched — no graph, no chat streaming, no editor surface is involved; the update UI is a banner/dialog rendered once on an app-lifecycle event, not per token or per frame. Hold.
- I5 (Obsidian compat): no vault, frontmatter, or wikilink change. Unaffected.
- I6 (pluggable inference): backend-side and unchanged; no provider is referenced, configured, or hardcoded anywhere in the update path. Holds.
- I7 (loops bounded): the update check is a single call on startup — no polling
interval, no retry loop, no unbounded iteration. It is bounded to exactly one iteration per
process start by construction;
downloadAndInstallis a single one-shot download. Nomax_iter/token_budgetneeded because there is no loop. Holds — explicitly. - I8 (docs-as-DoD): D6b (DEPLOY) gains the release/tag procedure + three-way version bump + key-loss caveat; D6a (USER) gains the update-banner description. C4 (D1): topology does not change (no new container/component — GitHub Releases is an existing external, the desktop app is an existing client); no C4 update required, recorded here so the docs gate is not falsely tripped.
- I9 (do not reinvent): uses Tauri's first-party updater over GitHub Releases as the
static host — no bespoke update server, no third-party updater. Reuses the existing
tauri-actionCI and the existing minisign keypair. Holds. - No invariant is traded for convenience. CONFIRMED: none violated.
3. Consequences¶
Positive
- Installed desktop clients self-update from GitHub Releases with zero self-hosted
infrastructure; the owner cuts a v* tag and every client picks it up on next launch.
- One unified release channel (v*) for the whole product replaces the separate desktop-v*
channel — simpler mental model, one tag per release.
- Update integrity is cryptographically guaranteed by our minisign key, independent of the
(absent) Apple/Microsoft OS signing — the ADR-0047 unsigned posture is preserved without
weakening the update chain.
- The startup-only, no-polling design keeps the client simple and bounded (I7) and the update
UI web-inert (guarded by isTauri() + dynamic import), so the PWA/web bundle is unchanged.
Trade-offs / limitations (stated explicitly)
- Single trust root: losing the minisign private key permanently breaks updates for all
installed clients (recovery = manual re-install). This is the cost of client-verified updates;
mitigated only by disciplined key backup, not by design.
- Startup-only check: a long-running desktop session will not learn about a release cut
mid-session until the next launch. Accepted as the simplest bounded behaviour; periodic
polling was deliberately rejected (I7 simplicity, avoids a background loop).
- Three-way version duplication (tauri.conf.json / Cargo.toml / package.json) is a manual
bump hazard; mitigated by the DEPLOY checklist + §5.1 acceptance, not by tooling in this ADR.
- First install still unsigned: the updater does not solve Gatekeeper/SmartScreen on the
initial download (ADR-0047 §4 unchanged) — only the self-update of an already-trusted app.
- releases/latest semantics: the static endpoint always points at the newest
non-prerelease; a broken/incomplete latest.json (e.g. only one platform merged) would offer
a bad update — hence the two-platform acceptance check (§5.1).
4. Risks (surfaced to orchestrator)¶
- Private-key loss / pubkey-privkey mismatch = broken update chain. If
TAURI_SIGNING_PRIVATE_KEYis lost, or the committedplugins.updater.pubkeydoes not pair with the secret used to sign, every client rejects every update, and recovery is a manual re-install. Back up the key off-machine; verify the pubkey/privkey pairing at firstv*release (§5.1). Highest-severity, lowest-reversibility risk. - Incomplete
latest.jsonacross the two-runner matrix. Bothmacos-latestandwindows-latestwrite to the same release for one tag; if the merge misses a platform, half the fleet gets no update (or a 404 on download). Acceptance gate: the release'slatest.jsonMUST list bothdarwin-aarch64andwindows-x86_64with valid signatures before the release is announced. - Version drift between
tauri.conf.jsonandCargo.toml/package.json. A binary whose compiled version disagrees with the manifest either offers a spurious update or never updates. The three-way bump is a manual step; enforce via the DEPLOY checklist and reject any release PR that bumps only one.
5. Contract the implementers follow verbatim¶
5.1 Release / CI (devops-engineer)¶
.github/workflows/desktop-release.yml: replace thedesktop-v*tag trigger withv*; gatetagName/releaseNameonstartsWith(github.ref, 'refs/tags/v'); keepworkflow_dispatchbuild-only. Keep the matrix (macos-latestarm64 +windows-latestx64) and thetauriScript: node frontend/node_modules/@tauri-apps/cli/tauri.jsinvocation.- Pass to the
tauri-actionbuild step (envorwith):includeUpdaterJson: true,TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }},TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}. Retainpermissions: contents: writeandGITHUB_TOKEN. - Acceptance (release gate): the cut
v*release contains, per platform, the OS bundle +.sig, and a singlelatest.jsonlisting bothdarwin-aarch64andwindows-x86_64with signatures that verify against the committed pubkey. Version inlatest.json== the tag ==tauri.conf.json.version==Cargo.toml.version==frontend/package.json.version.
5.2 Tauri config + Rust (devops-engineer)¶
src-tauri/tauri.conf.json: add"bundle": { …, "createUpdaterArtifacts": true }and a"plugins": { "updater": { "pubkey": "<base64 pubkey above>", "endpoints": ["https://github.com/Emanuele-Chiummo/llm-wiki-synapse/releases/latest/download/latest.json"] } }block. Bumpversionper release.src-tauri/Cargo.toml: addtauri-plugin-updater = "2"andtauri-plugin-process = "2"; keepversionin sync with tauri.conf.json.src-tauri/src/main.rs: chain.plugin(tauri_plugin_updater::Builder::new().build())and.plugin(tauri_plugin_process::init())alongside the existing notification plugin; leaveinvoke_handlerempty.src-tauri/capabilities/default.json: add"updater:default"and"process:allow-restart"topermissions(only these two — no broader process perms).
5.3 Frontend (frontend-engineer)¶
- Update-check module: on app start, after render, run
check()once, non-blocking, inside anisTauri()guard, with@tauri-apps/plugin-updater/@tauri-apps/plugin-processdynamically imported inside the guard. Catch and no-op on error/timeout. No interval, no polling. - If update available → dismissible banner/dialog:
{{version}}+update.bodynotes + Update now / Later. Update now →downloadAndInstall()with progress state →relaunch(). Later → dismiss; re-surface only on next app start (session-scoped). desktop.update.*keys in bothen.jsonandit.json(parity test passes): at minimumavailable(with{{version}}), a notes label,updateNow,later,downloading(+ progress),installing,error.
5.4 Docs (tech-writer)¶
- D6b (DEPLOY): the
v*tag release procedure, the three-way version bump,includeUpdaterJson- the two signing secrets, and the key-loss caveat (§2.6 — private key is the sole trust root; back it up; loss = manual re-install for all clients). D6a (USER): the update banner (what it offers, Update now vs Later). No C4 change (topology unchanged).
6. Do NOT (reject any PR that does these)¶
- Do NOT add a polling interval / background update loop — startup check only, exactly one
check()per process start (I7). A timer/interval is a reject-on-review condition. - Do NOT commit or embed the private minisign key or its password anywhere in the repo or
in
tauri.conf.json— only the public key is embedded; signing uses the GitHub secrets. - Do NOT block app startup on the update check, and do NOT crash on a failed/timed-out check — it must be fire-and-forget and caught.
- Do NOT import
@tauri-apps/plugin-updater/plugin-processat module top level or call them outside anisTauri()guard — dynamic import inside the guard, so the web/PWA bundle stays inert (ADR-0039 §9.1 / ADR-0048 §2.4c pattern). - Do NOT keep the
desktop-v*trigger or run two release channels —v*is the single unified channel (U1). - Do NOT bump the version in only one of the three files — tauri.conf.json / Cargo.toml / package.json move together and equal the tag (I7/§5.1 acceptance; version drift breaks the compare).
- Do NOT broaden the process capability beyond
process:allow-restart(least privilege). - Do NOT hardcode any inference provider or touch the inference layer (I6 untouched).