Native auto-updater
August 3, 2026 · View on GitHub
Goal: the desktop app updates itself. All hard logic lives in Rust; the web UI
reuses the existing VersionCheckDialog ("A new version is ready") card with
one native-gated edge: on desktop builds its button calls the Rust backend
instead of window.location.reload().
Design summary (settled in design review + critique pass):
- The Rust backend auto-checks, auto-downloads, and auto-verifies updates
in the background, then defers the actual install to the exit path.
AMENDMENT (2026-07-31, found by the live signed-in spike): the original
design installed eagerly on the theory that "nothing re-reads the bundle
path at runtime" — but macOS ITSELF does. Keychain access validates the
calling process's code identity against its binary ON DISK; the moment the
updater swapped the bundle, every Keychain operation failed
(
errSecErrnoBase+ENOENT, surfaced as "keychain unavailable: Platform failure: UNIX[No such file or directory]") — sign-in broke outright and rotated-refresh-token saves would fail silently until restart, risking a forced sign-out. Instead: download → signature + version-pairing verify → stage the archive under<app_root>/updates/→ install ONLY inside the exit path, after local-api has drained (no Keychain writers left). Both the card's restart and a plain quit funnel throughExitRequested, so one apply point still gives install-on-quit, and the Keychain-broken window is zero. Stale staged archives are cleaned at boot (crash = one re-download). - The existing drift mechanism is the notification channel: the proxy's
rewrite_config_version(which today pins/api/config'sconfig.versionto the embedded bundle version so the browser-refresh nag never fires in the shell) gains one branch — once an update is staged, verified and ready, it reports the staged version instead. The untouched dialog polls, sees drift twice, and shows the card. No new wire contract, no new poll, no new component. - The card's button, under
useIsDesktopApp(), POSTs a new local-api route that triggers a graceful restart via Tauri'srequest_restart()— documented to deliverRunEvent::ExitRequestedreliably, so the repo's single shutdown pipeline (child sweeps, drain, instance-lock release) always runs. We do NOT addtauri-plugin-processor any webview updater capability: the trigger stays a Rust-side callback, keeping the webview IPC surface closed (capabilities/default.json gains nothing — worth a CI grep assertion thatupdater:/process:permissions never appear there). - Update source of truth is a rolling
native-updatesprerelease on GitHub holding onelatest.json, clobbered as the last step ofrelease-native.yaml.releases/latest/download/…is unusable in this repo (seven workflows create stable releases across six tag schemes; Studiov*dominates "latest"), and serving the manifest from the app/web dist is broken by construction (the.sigexists only after the native build; self-hosted instances would freeze at their build version). bundle.createUpdaterArtifactsis never committed totauri.conf.json5— it is enabled only via the release workflow's existing--configoverlay (JSON Merge Patch per RFC 7396, so the nestedbundlekey augments rather than clobbers). Committed config with a pubkey + updater artifacts would fail every secretless (fork/PR) build. Conversely the release job asserts the signing secret is present and fails loudly.- The updater never runs outside a real shipped build. Three independent
gates, because
#[cfg(not(debug_assertions))]alone is NOT enough —native.yml'stauri-buildjob andboot-smoke.tsbuild the release profile at the committedversion: "0.1.0", which would see prod as "newer" and download ~80 MB over the CI bundle on every PR run:#[cfg(not(debug_assertions))](mirror of the debug-only mcp-bridge registration) — covers dev loops;- skip spawn when
selftest::is_enabled()or the running version is the committed0.1.0placeholder — covers CI/smoke release builds; DECOCMS_DISABLE_AUTO_UPDATE=1honored at runtime and exported innative.yml's boot-smoke step andboot-smoke.tsas belt-and-braces. The kill-switch logstracing::warn!on every skipped cycle (silent freezes must be greppable in support diagnostics), and support docs must note a Finder-launched app doesn't inherit shell env — it needslaunchctl setenvor a terminal launch.
Explicit non-goals for v1 (recorded so nobody "just adds" them):
- Upstream-served update channel. An upstream-controlled
latest.jsonis unsigned (only the artifact is minisigned): a hostile self-hosted upstream could pair a highversionwith an old validly-signed artifact. v2 needs a signed manifest or a version-pin resolved against GitHub, Rust-side (UpdaterBuilder::endpoints()fromupstream::global().target()). v1 endpoint is the static GitHub URL. Note the same attack against the GitHub channel itself IS closed in v1 — see the version-pairing check in Phase 2a. - Run-aware restart gating. The restart is an explicit user click on a card that only appears when an update is verifiably staged. A "N agent runs will be stopped" confirm can layer on later without structural change.
- Linux/Windows. The staged-state machine lives in one Rust module so the Windows difference (install force-exits the app — install must couple to restart consent there) becomes a branch, not a redesign.
- Migrating pre-updater installs. Brew users converge via the CI cask bump. Users who installed the DMG directly on a pre-updater version are stranded silently (no plugin, drift suppressed) — accepted deliberately; the cask/DMG release notes are the only channel to reach them.
Merge order & shippability
Phase 0 → Phase 1 → Phase 2 (+ Phase 4 in the same PR) → Phase 3, each
independently shippable:
- Phase 0 must precede Phase 1's merge (the new signing-secret assertion would otherwise fail every release).
- Phase 1 before Phase 2 is harmless: manifest published, nothing consumes it.
- Phase 4 (
auto_updates true) must ride the Phase 2 PR: merging the plugin + pubkey means the next release-tagging bump ships a self-updating binary, and from that momentbrew upgradewould downgrade self-updated installs. - Phase 3 before Phase 2 would be safe-but-dead (the card cannot appear in native without a staged version), but the order above avoids shipping a button that 404s.
- Channel-promotion launch gate: code merges freely, but do not run the
first real promotion (and do not merge Phase 4) until the manual test
(below) passes its go/no-go criteria — in particular the Keychain
observation. Unsigned builds change CDHash every update, which breaks
Keychain item ACLs (per
crates/upstream/src/tokens.rs's own docs); at ~daily promotion cadence a forced re-login per update is strictly worse than the status quo. The dormantAPPLE_*Developer ID + notarization scaffolding inrelease-native.yaml:133-158activates on secrets alone — it should land before or with the first promotion.
Phase 0 — signing key (existential; do first)
No minisign material exists anywhere in the repo; the TAURI_SIGNING_*
exports in both workflows are fork-safe dormant scaffolding. Whichever key
signs the first shipped updater release is permanent: the pubkey ships pinned
in every binary, and losing the private key strands the entire installed base.
bunx @tauri-apps/cli@^2 signer generate -w ~/.tauri/decocms-updater.keywith a password. (Notbunx tauri …— that resolves the npm package literally namedtauri, the v1 CLI.-wrequires the path argument.)- Escrow private key + password in the org password manager (offline copy), NOT only in GitHub (secrets are write-only; a GitHub-only copy is a single point of loss).
- Set
TAURI_SIGNING_PRIVATE_KEY/TAURI_SIGNING_PRIVATE_KEY_PASSWORDin a GitHub Environment (e.g.native-release) with a deployment branch policy restricted tomain. Precision on what that buys: Environments cannot restrict by workflow file — any workflow that runs onmainand declaresenvironment: native-releasecan read them (that requires a reviewed merge; optionally add required reviewers). This is still strictly narrower than repo-wide secrets, which every workflow — including the seven npm-publish workflows — can read. Phase 1 MUST add the matchingenvironment:key to thereleasejob or the secret resolves empty and every release fails. - The public key goes into
tauri.conf.json5in Phase 2. - Runbooks (durable home:
release-native.yaml's header comment +apps/native/README.md, not this plan):- Key loss → cask is the break-glass channel: users recover via
brew upgrade --greedy --cask deco-studio; keep the CI cask bump forever. - Key leak → there is no revocation channel; rotation = new key + cask-forced reinstall of the fleet. Audit escrow access; treat the GitHub Environment as the only CI holder.
- Bad release shipped → the updater never downgrades; the fix is
promoting a newer fixed release (use the
force_promotedispatch input below if inside the throttle window). Broken-cannot-launch → cask reinstall.
- Key loss → cask is the break-glass channel: users recover via
Phase 1 — release pipeline (.github/workflows/release-native.yaml)
All steps in the existing single release job, in this order:
-
environment: native-releaseon the job (see Phase 0.3). -
Lockstep assertion (new step, after the existing
Read app versionstep): fail the release loudly ifapps/api/package.jsonandapps/native/package.jsonversions differ.crates/local-api/build.rsbakesSTUDIO_WEB_VERSIONfrom apps/api while the manifest/binary version comes from apps/native — equal only by the release-changes.ts lockstep; a single-sided bump would otherwise ship the permanent-nag state. -
Signing-secret assertion (new step): a step with
if: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY == '' }}thatexit 1s with a pointer to Phase 0. The secret value never enters a shell; never inline${{ secrets.* }}into arun:body (multiline key = parse/injection hazard and only per-line masking). -
Build — pin the CLI to an exact version (no
@latest: the signer/ bundler executing with the key in scope must not change under us; also verify once against the pinned version that env-var-supplied key passwords work — tauri#13485/plugins-workspace#2710 report password- via-env failures) and extend the existing overlay:run: bunx @tauri-apps/cli@2.x.y build --config "{\"version\": \"$VERSION\", \"bundle\": {\"createUpdaterArtifacts\": true}}"Scope
TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)as step-levelenv:on this step only — not$GITHUB_ENV(the current dormant export makes the permanent key visible to every later step, including third-party CLI code, the cask python, andgh). TheAPPLE_*export step is unchanged. -
Collect updater artifacts (extend the
assetsstep): the bundler emitstarget/release/bundle/macos/deco.app.tar.gz(+.sig). Copy to versioned namesdeco-$VERSION-aarch64.app.tar.gz(+.sig) and add both to the existinggh release uploadfor the immutablenative-v$VERSIONrelease (alongside ZIP + DMG). -
Assemble
latest.json+ decide promotion — via a tested script. The throttle/manifest logic is real logic that must not first execute in a production release on a BSD-date macOS runner. Addapps/native/scripts/ci/native-update-channel.mjs(zero-dep Node, thecheck-junit-allowlist.mjsconvention) exporting two pure functions with co-located unit tests, and a thin CLI entry the workflow calls:-
buildLatestJson({version, sigContents, notesUrl, pubDate})— structured JSON construction (never shell/string interpolation; the.sigis base64 today but correctness-by-construction is free). Shape:{ "version": "$VERSION", "pub_date": "<RFC 3339>", "notes": "<native-v$VERSION release URL>", "platforms": { "darwin-aarch64": { "url": "https://github.com/decocms/studio/releases/download/native-v$VERSION/deco-$VERSION-aarch64.app.tar.gz", "signature": "<contents of the .sig file>" } } }Platform keys extend later (
darwin-x86_64,linux-x86_64,windows-x86_64) without shape changes. Note: the updater validates the WHOLE file before readingversion— when platform keys are added, one malformed entry bricks updates for all platforms. -
shouldPromote({currentManifest, candidateVersion, now, force})with fail-open-to-promote semantics, in order:- no current manifest / unparseable JSON → promote (first run:
guard the
gh release download native-updates -p latest.json -O -with|| true— it exits non-zero before the release exists); currentManifest.version >= candidateVersion(semver) → skip (prevents a repair-dispatch on an old ref from regressing the channel);force(aforce_promote: trueworkflow_dispatchinput) → promote — the escape hatch for urgent fixes inside the window and for healing a half-failed promotion;pub_datemissing, unparseable, or in the future → promote (a clobbered far-future date must not freeze the channel forever; clock skew heals instead of wedging);pub_dateage < 20 h → skip (the throttle: releases track the ~12×/day cloud cadence at ~80 MB/artifact; auto-download must cost ~one artifact/day). Else promote.
- no current manifest / unparseable JSON → promote (first run:
guard the
-
-
Promote the channel — LAST:
gh release view native-updates || gh release create native-updates --prerelease --title "deco update channel" --notes "Rolling updater manifest. Do not install from here."—prereleasekeeps it out of GitHub'sreleases/latestforever (REST semantics: latest = most recent non-prerelease, non-draft).gh release upload native-updates latest.json --clobber— idempotent, matching the workflow's re-run-only-heals repair semantics. Ordering guarantee: the manifest can never reference assets that don't exist yet.- Workflow-header caveats to record: GitHub's opt-in "immutable releases" repo setting would break the clobber; clobbered same-name assets can serve stale from the CDN briefly (tolerable at daily cadence).
-
Failure alerting (new step, concrete):
if: failure()step following the fork-safe webhook pattern ofrelease-studio.yaml's docs-agent step (skip-when-unconfigured, warn on non-2xx) — or, with no webhook secret configured, create/update a pinned GitHub issue viagh. Rationale: a failed release today means unbounded, invisible staleness (drift is suppressed by design), and a throttle bug that always skips exits successfully — so also emit a::notice::with the promotion decision on every run to make silent-skip streaks greppable.
native.yml changes: export DECOCMS_DISABLE_AUTO_UPDATE=1 in the
boot-smoke step (see gate 3 above). Nothing else — the committed config never
requests updater artifacts, so PR/fork builds stay green.
Phase 2 — Rust
Interface-change note: this phase touches three contract-governed surfaces —
a router.rs route, an AppState field, and the StartOptions/UpdateHooks
pub API (state.rs carries the same "file an interface request" header as
router.rs). Treat them as ONE interface request and update the
module-ownership contract doc alongside. AppState (not the lib.rs
global-publish pattern used for org_mount::set_credentials) is the right
home because the consuming handlers already extract State<AppState>; a
separate field (not EmbeddedOptions) because embedded debug builds have
no updater either — the axes differ.
2a. apps/native/src-tauri — plugin, config, background task, restart trigger
-
Cargo.tomls: addtauri-plugin-updater = "2"to the workspace-dep table inapps/native/Cargo.tomlandworkspace = trueinsrc-tauri/Cargo.toml(repo pattern). The lockedtauri2.11.5 already hasrequest_restart(added 2.4.0) and the post-update relaunch fix (tauri PR #12313). -
tauri.conf.json5: add (committed — safe:createUpdaterArtifactsdefaults to false and the signing requirement triggers only when artifacts are produced):plugins: { updater: { pubkey: "<public key from Phase 0>", endpoints: [ "https://github.com/decocms/studio/releases/download/native-updates/latest.json", ], }, },Invariant to keep greppable (CI assertion is one line):
dangerousInsecureTransportProtocolmust never appear in this file or any--configoverlay — it is the only thing between "pinned pubkey over TLS" and plaintext manifest fetch. -
src/lib.rs: registertauri_plugin_updater::Builder::new().build()under#[cfg(not(debug_assertions))]. The cfg-gate is load-bearing, not belt-and-suspenders: the plugin never auto-checks, but a debug binary with the plugin registered and config present would self-update if anything calledcheck(). -
New module
src/updater.rs:- A constructor
updater::init() -> (UpdateHooks, impl FnOnce(AppHandle))so the watch channel exists beforeStartOptionsis built atsetup.rs:274and the spawn happens after the server starts (next tospawn_auth_status_bridge/revalidate::spawn, setup.rs:307-312) — ordering enforced by types, not convention. - Pure decision logic lives OUTSIDE any cfg-gated module (release-only
cfg would compile its tests out of
cargo test --workspace, which runs the dev profile — the test would appear to exist while never running). Only the spawn is gated. - Spawn gates (see design summary):
cfg(not(debug_assertions))AND!selftest::is_enabled()AND running version ≠0.1.0ANDDECOCMS_DISABLE_AUTO_UPDATEunset (warn-log every skipped cycle when set). - Loop:
tokio::time::intervalof 30 minutes (MissedTickBehavior::Delay, first tick consumed → the first check runs one interval after boot, no boot contention). Deliberately NOT the 5-min revalidate cadence: the manifest changes ≤ ~1.2×/day and card latency is dominated by the dialog's own 5-min × 2-confirmation poll anyway; 5-min checks would be ~288 no-op manifest fetches/day.DECOCMS_UPDATE_CHECK_INTERVAL_SECS(floored at 15 s) overrides the interval for spikes and the manual runbook — harmless in the threat model (a local process able to set env already has the kill switch). - Per tick:
check()withUpdaterBuilder::timeout(…)set.check()itself gates on remote > running (semver, built-in) — do not re-implement that comparison. Skip if the manifest version==the staged version (plain!=supersede semantics — a strict "newer than staged" would block channel rollforward after a botched release; semver ordering vs staged is not needed and hand-rolled comparison invites bugs).- Skip if this version is memoized as failed and its backoff hasn't
elapsed — the failure memo is required, not polish: without it, a
persistently failing install (TCC block, disk full, bad signature)
re-downloads ~80 MB every tick forever (~worst case tens of GB/day
per affected client). One
(version, attempts, next_retry_at)memo + exponential backoff capped at ~24 h; reset when a new version appears. Optional cheap pre-flight: skip download when free disk < ~3× artifact size. - Set an
installingflag (see 2b — the restart route 409s while it is up), thendownload(…)with a generoustokio::time::timeout(a stalled stream must not wedge the loop forever; downloads are not resumable — record that as a known v2 item), progress via the chunk callback attracing::debug!. - Version-pairing check (v1 downgrade defense): before installing,
verify the downloaded bundle's embedded version
(
Info.plistCFBundleShortVersionStringfrom the tar.gz) equals the manifest'sversion. The minisign signature covers the artifact bytes but NOT the manifest's version field, and nine-plus workflows in this repo holdcontents: writeon the release surface — without this check, any of those tokens compromised = silent fleet-wide downgrade to an old validly-signed build. With it, a lying manifest can no longer pair a high version with an old artifact. install()underspawn_blocking(it is synchronous gunzip+untar+ swap I/O;setup.rs:246documents the same rationale for TLS minting). Note the download buffers ~80-100 MB in RAM transiently — acceptable on desktop; recorded so a future memory report doesn't "fix" it blindly.- On success: clear
installing,staged_tx.send(Some(version)), clear the failure memo. On failure: clearinstalling, record the memo,tracing::warn!— except signature verification failure gets its own distinct, greppable error line (it means channel tampering or a botched key rotation, categorically different from offline/404, which stay silent by design).
- A constructor
-
Restart trigger:
setup.rspassesArc<dyn Fn() + Send + Sync>=move || app_handle.request_restart()intoStartOptions.request_restartdeliversExitRequested(→shutdown::run_blocking→ full pipeline → respawn); the take-once inshutdown.rs:18-31tolerates duplicate delivery. Two recorded caveats:restart_on_exitis sticky, so a user quit racing a just-fired restart becomes a relaunch (narrow, harmless, worth a comment); and macOS has an open not-always-relaunching report (tauri#13923) — "process actually respawns" is an explicit manual-test pass criterion, not an assumption.
2b. apps/native/crates/local-api — state, rewrite branch, restart route
-
lib.rs— extendStartOptions(standalone binary and tests leave itNone; the compile-driven touchpoints areboot_from_env, thelib.rstests, androutes::intercept::test_state+ the per-moduletest_state()fns):/// Desktop-app self-update integration. `None` outside the packaged shell. pub update: Option<UpdateHooks>, pub struct UpdateHooks { /// Version an update task has installed on disk; `None` until staged. pub staged_version: tokio::sync::watch::Receiver<Option<String>>, /// True while `download_and_install` is running (install serialization). pub installing: Arc<AtomicBool>, /// Graceful app restart (Tauri `request_restart`). pub request_restart: Arc<dyn Fn() + Send + Sync>, }Store on
AppState(state.rs) under the interface-request framing above. -
routes/upstream.rs— the staged-version branch. The wiring is slightly more than one line and the plan owns that:rewrite_config_versionand its callerproxy_public_confighave noAppState— thread the staged value (and hooks presence) down fromproxythrough both signatures. Extract the selection as a pure, unit-testable fn:/// Staged updates win; otherwise pin to the embedded bundle's version. fn reported_config_version(staged: Option<&str>, baked: &str) -> Option<String>and fix the interplay with the existing empty-
STUDIO_WEB_VERSIONearly-return at upstream.rs:801: a stagedSomemust be reported even when the baked constant is empty (today's guard would silently suppress it).patch_config_versionstays pure and untouched. Semantics: drift reaches the webview iff an update is installed on disk and ready. Update the module doc (784-799): the banner appears "in the browser — or in the shell when, and only when, a staged update makes its action real." -
router.rs— the route MUST be inside a guard-carrying sub-router; the guard is not inherited at the top level. A bare top-level.route()would get onlyrequire_expected_host— no Origin check on POST, no session cookie — i.e. an unauthenticated restart endpoint (a hostile previewed-sandbox page could then kill the user's runs with ano-corsPOST). Concretely: mount under the existing/_localnamespace as/_local/update/restartinside a guarded nest (pattern: the existing guardedapp_apisub-router that carries.layer(guard))./_localrather than a bare/updatebecause bare paths are upstream-proxyable/SPA-fallback surface — the underscore namespaces are the repo's reserved-local convention (is_reserved_api_path, router.rs:728-746) — and because the Vite native dev proxy already forwards/_local(a bare/updatewould need a Vite change just to be testable in the dev loop). -
New
routes/update.rs—POST /_local/update/restart:409 Conflictwhenstate.updateisNone(standalone), when no version is staged, or whileinstallingis true — restarting during a supersedingdownload_and_installwould re-exec a half-swapped bundle; the updater task sets the flag before download and clears it after install completes (this is the serialization the frontend's "409 race" narrative depends on).- Otherwise
202 Accepted, then invokerequest_restartfrom a spawned task after ~100 ms. The delay is best-effort flush, not a guarantee (the shutdown path drops connections immediately); losing the race costs a cosmetic network error on a dying page.
Phase 3 — frontend (the only UI change)
apps/web/src/components/version-check-dialog.tsx:
-
const isDesktopApp = useIsDesktopApp(); -
Use the repo's mutation idiom (62
useMutationcall sites; shape perrequest-to-join-screen.tsx:24-38) rather than hand-rolled state — and note plainfetchresolves on 409, so an.okcheck is what makes the button recover, exactly the bug four reviewers flagged in the first draft:const restartMutation = useMutation({ mutationFn: async () => { const res = await fetch("/_local/update/restart", { method: "POST" }); if (!res.ok) throw new Error(`restart failed: ${res.status}`); }, });Button:
<Button size="sm" disabled={restartMutation.isPending} onClick={() => isDesktopApp ? restartMutation.mutate() : window.location.reload() } > {isDesktopApp ? t("announcements.version.restart") : t("announcements.version.refresh")} <RefreshCw01 size={14} /> </Button>On 202 the app window closes and relaunches — no further UI state. On 409 (staged update superseded mid-download) or network failure the mutation errors,
isPendingresets, the button re-enables; the next poll re-converges the card. A supersede also flickers the card for one ~5-min poll (drift count resets) — expected, harmless. -
Description swaps under the same gate:
isDesktopApp ? t("announcements.version.descriptionNative") : t("announcements.version.description"). -
Title, eyebrow, aria-label, dismiss, and
currentSessionfooter stay shared. -
Delete the orphaned previous-incarnation keys in the same PR (checklist item 5):
common.versionCheckDialog.available/.outdatedVersion/.refreshinen/common.ts:278-281+pt-br/common.ts:288-291are referenced by no component (verify with grep at implementation time).
i18n — apps/web/src/i18n/en/announcements.ts gains:
"announcements.version.descriptionNative":
"An update is installed and ready. Restart the app to start using it.",
"announcements.version.restart": "Restart to update",
apps/web/src/i18n/pt-br/announcements.ts mirrors (compile-enforced):
"announcements.version.descriptionNative":
"Uma atualização foi instalada e está pronta. Reinicie o aplicativo para começar a usá-la.",
"announcements.version.restart": "Reiniciar para atualizar",
Note: the earlier idea of gating VersionCheckDialog out of native entirely
is superseded — the dialog is deliberately dual-purpose and stays mounted
unconditionally at shell-layout.tsx:368.
Phase 4 — Homebrew cask (Casks/deco-studio.rb)
Add auto_updates true, in the Phase 2 PR (see merge order). Without it,
brew upgrade compares the tap version to its install receipt and reinstalls
(downgrades) over a self-updated app; with it, upgrades skip the cask by
default (--greedy still overwrites — fine, the app self-updates back). Keep
the CI cask auto-bump exactly as is (fresh installs need a recent baseline,
and the cask is the key-loss recovery path).
Testing
Per the repo's two tiers and "test each behavior you touch":
- Rust unit / in-process (
cargo test):reported_config_versionpure fn: staged-Some wins; None → baked; staged-Some with empty baked still reports staged.- In-process proxy tests for the staged branch — the existing precedent in
this exact test module (axum stub upstream on a
TcpListener+test_state, cf. the set-cookie proxy test ~upstream.rs:1540): stagedNone→ embedded version reported; stagedSome→ staged reported; flip mid-life (send after first request; second request reports staged — also covers watch freshness). - Router-level tests for
/_local/update/restartwith stubbedUpdateHooks(request_restart= flag-flip closure): 202 flips the flag; 409 when hooks absent; 409 when hooks present but nothing staged (distinct branch); 409 whileinstallingis set. This also pins that the route landed inside the guard (an unauthenticated request must 401/403, not 409). - Updater decision logic (memoized-failure/backoff/supersede) as pure fns in an always-compiled module (see 2a — cfg-gated tests silently never run).
- Workflow script unit tests:
native-update-channel.mjs'sshouldPromote(all five branches, incl. futurepub_date→ promote,>=version → skip, force → promote) andbuildLatestJson(structure, escaping) — co-located,bun test, per theboot-smoke-paths.tsprecedent. - Native e2e (
apps/native/e2e, standalone binary,update: None):- Add
{ name: "update: restart", path: "/_local/update/restart" }toauth-matrix.e2e.test.ts'sCASES— covers no-bearer/wrong-bearer 401 and correct-bearer-not-401 (409) for free; no one-off auth test. GET /api/configrewrite still pins the embedded version with no update handle: assert the reported version is NOT the stub upstream's sentinel (e.g.9.9.9) and is semver-shaped (the harness can't know the baked constant statically).- Daemon-parity/stub-seam allowlists are unaffected (they replay the
daemon's own test files, which never touch these routes) — recorded so
reviewers don't re-derive it. A
LOCAL_API_STAGED_VERSIONenv seam for black-box staged-path coverage was considered and deliberately NOT added — the in-process tests above cover it without widening the standalone surface.
- Add
- Web: no new unit tests — the
isDesktopAppfork is build-time-gated, response handling isuseMutation(its.okthrow is the tested-idiom path), and the pure drift fns are already covered byversion-check-dialog.test.ts; the pt-br mirror is compile-enforced. Checklist item 4 (grep all tiers for changed strings): browser copy is unchanged; native copy is behind new keys;packages/e2easserts on neither (verified by grep).
Manual test — executable runbook (gate for channel promotion)
The production endpoint is baked into the installed app, so "publish N to a
staging tag" alone tests nothing. Mechanism: a workflow_dispatch staging
mode of release-native.yaml (input staging: true) that (a) uploads to a
native-updates-staging rolling prerelease instead, and (b) builds with the
existing --config overlay extended to override
plugins.updater.endpoints to the staging URL. Same signing keypair. This
makes the test a repeatable runbook instead of archaeology — there is
otherwise NO end-to-end integration path for the updater at all.
Verified by a full local spike (2026-07-30, macOS 26): two --config-versioned
release builds + a python3 -m http.server channel — check → download →
signature verify → version-pairing check → eager install (on-disk .app
swapped while the old process runs) → /api/config reporting the staged
version → POST /_local/update/restart 202 → graceful shutdown → relaunch
into the new version ~2 s later (tauri#13923 did not reproduce), with the
relaunched process's own check correctly finding nothing to do. Two
spike-environment gotchas for whoever reruns it: the updater plugin REFUSES
to construct when current_exe() contains a symlink ("StartingBinary …
symlink on a non-allowed platform") — run the app from a symlink-free path
(/private/tmp/..., not /tmp/...; real /Applications installs are
unaffected) — and use a throwaway identifier override
(com.decocms.studio.spike) in BOTH builds so the spike gets its own app
root/instance lock instead of colliding with a running dev or prod app.
A second, SIGNED-IN spike round (2026-07-31) caught the eager-install
Keychain regression described in the design summary: with the bundle swapped
under the running process, every auth_login completed OAuth and then died
at the Keychain write ("keychain unavailable … UNIX[No such file or
directory]") — three times in a row, live. That finding produced the
deferred-install amendment; the runbook's Keychain pass criterion below is
exactly the regression test for it.
On macOS 14+: install staging-N−1 via the real cask flow (quarantine-cleared), publish staging-N, then verify — pass criteria, go/no-go for first real promotion:
- Background download+install succeeds (App Management TCC does not block
the bundle swap; the
.appon disk is N). - The card appears — worst case ~35-40 min with the 30-min check interval +
two 5-min drift confirmations (do NOT expect "~10 minutes"). For spikes
and this runbook,
DECOCMS_UPDATE_CHECK_INTERVAL_SECS(floored at 15 s; terminal-launched processes only — Finder launches don't inherit shell env) shortens the updater's own interval; the dialog's two 5-min poll confirmations still bound how fast the card itself appears. - "Restart to update" → the process actually respawns into N (explicit criterion — open report tauri#13923 says macOS restart can quit without relaunching; the instance lock must have been released by the graceful pipeline), with no orphan rclone/harness processes.
- Keychain: session survives, or the forced re-login is observed and the promotion gate holds until Developer ID + notarization land.
- Note: a translocated app (direct-DMG download run from quarantine) runs from a read-only path — install fails into the (memoized, backed-off) warn path forever. Cask installs avoid translocation; record the direct-DMG limitation in the release notes.
Follow-ups (recorded, out of v1)
- Developer ID + notarization — may be pulled INTO v1 by the promotion gate.
- Run-aware restart confirm ("N agent runs will be stopped").
- v2 upstream-controlled channel: signed manifest or version-pin design, Rust-side runtime endpoints.
- Linux/Windows platform keys + the Windows install-couples-to-exit branch.
- Persisted monotonic high-water mark (refuse to stage below the highest version ever staged) + freeze/replay telemetry (log when the installed version regresses below the last manifest version seen). The v1 version-pairing check already closes the lying-manifest downgrade; these harden freeze detection.
- Resumable/streaming downloads (plugin buffers in RAM; no resume today).
Critique Decisions
Eight-perspective review (correctness, security, architecture, testing, performance, scope, duplication, documentation) applied to the first draft.
Adopted (the significant ones):
- CI/smoke release-profile exposure: added the selftest/0.1.0/env triple gate (correctness + architecture, independently).
- Route moved to
/_local/update/restartinside an explicitly guarded nest — the draft's snippet would have shipped an unauthenticated restart endpoint (security + architecture, independently). - Install/restart serialization (
installingflag; 409 while installing) — the draft's own 409 narrative had no backend to make it true. - Failure memo + backoff in the updater loop — without it a persistent install failure re-downloads ~80 MB per tick forever.
- Version-pairing check before install as v1 downgrade defense (manifest is unsigned and writable by nine-plus workflow tokens).
useMutation+.okhandling —fetchresolves on 409; the draft's.catchleft the button dead (flagged by four reviewers independently).- Testing rewrite: pure
reported_config_versionfn (the draft's unit test targeted an unchanged function), decision logic out of cfg-gated code (tests would silently never run), in-process staged-path proxy tests, auth-matrix membership, tested promotion script (BSDdatehazard). - Throttle hardening: fail-open-to-promote semantics, semver gate,
force_promotedispatch bypass, first-run guard. - Developer ID reframed as a launch gate on channel promotion (not on code).
- Signer command fixed (
bunx tauriresolves the v1 CLI), pinned build CLI, step-scoped signing env,environment:consistency between Phases 0/1. - Executable staging-mode manual test with explicit relaunch (tauri#13923) and Keychain go/no-go criteria; realistic card-latency bound.
- 30-min check interval (manifest changes ≤ ~1.2×/day; card latency is
dominated by the dialog poll),
download/installsplit withspawn_blocking, check/download timeouts. - Dead
common.versionCheckDialog.*key deletion;jq-free structured manifest assembly; concreteif: failure()alerting; merge-order section; kill-switch logging + Finder-env caveat; runbooks (key loss/leak/bad release) with a durable home; App Translocation and DMG-direct-install acceptances; interface-request framing extended tostate.rs.
Rejected:
LOCAL_API_STAGED_VERSIONstandalone env seam for black-box staged-path e2e — in-process tests cover it; keeping the standalone surface minimal outweighs black-box purity here.- Extracting a shared interval-loop helper — four sites × three configs is a config-heavy wrapper around ~4 lines; inline mirroring is the repo's accepted idiom.
Adapted:
- Persisted high-water mark: deferred to follow-ups — the version-pairing check closes the active downgrade attack; the mark only hardens freeze detection, which is already a recorded follow-up.
- "Newer than running AND staged" comparison: simplified to delegating
running-vs-remote entirely to
check()(built-in semver) and plain!=vs staged — strict ordering would block channel rollforward and duplicate the plugin's comparator. - JS
relaunch()rationale: the plugin has since migrated torequest_restartinternally; the decision (no plugin, no webview capability, Rust-side trigger) stands on IPC-surface grounds alone.