sortOfRemoteNG

Tauri Updater — Signing & Feed Setup

This document covers the Tauri updater plugin wiring for sortOfRemoteNG: how the Ed25519 signing keypair is generated, where the private key must live, and the JSON schema of the latest.json feed included with every successfully promoted GitHub Release. A signed platform entry uses the cryptographically verified automatic installation path. An unsigned entry from the fixed official public GitHub feed can use the separate unsigned installer only after an explicit warning acknowledgement for that attempt; it is never trusted automatically. Private unsigned entries and artifacts remain non-executable and manual-only unless a future release defines an additional pinned trust policy.

The production updater path is intentionally narrow: the signed tauri-plugin-updater runtime verifies and installs artifacts, and the application exposes it through the backend-owned updater_* commands in sorng-updater. The separate backend-owned updater_install_unsigned command ignores configured private endpoints, re-resolves the version and platform target from the fixed PUBLIC_ENDPOINT_URL, requires an explicit risk acknowledgement for each invocation, and accepts only the exact canonical GitHub release artifact URL for this repository. Frontend code must call those commands through src/hooks/updater/useUpdater.ts; it must not call @tauri-apps/plugin-updater directly.

Unsigned completion is platform-specific. Windows launches the matching NSIS or MSI installer and Linux replaces the running AppImage. macOS cannot replace an installed app merely by opening a DMG: the action downloads and opens the validated DMG, closes sortOfRemoteNG, and tells the user to drag the app to Applications and reopen it. The native status keeps that update available instead of claiming the DMG handoff installed it.

The old custom downloader, copy installer, scheduler, channel, history, and rollback paths are not production update mechanisms. Automatic P1 installs are signed Tauri updater installs only, and only package types compatible with the feed payload may use them: Linux AppImage, Windows NSIS, Windows MSI, and the macOS app bundle. Private feed configuration is managed by Settings > Updater and documented in private updater endpoint guide.

Public OS installers and updater delivery are separate release outputs. Every successful main push may publish Windows, Linux, and macOS installers without OS-signing certificates. The workflow generates latest.json with or without the updater signing key and includes it in every promoted release. When TAURI_SIGNING_PRIVATE_KEY is absent, its platform entries have empty signatures. For the official public feed, supported install modes expose both the manual-download link and a separate unsigned-install action that remains disabled until the user acknowledges the warning for that attempt. The backend re-resolves the fixed public feed, version, and platform target and does not trust a URL supplied by the UI. Configured private endpoints are not consulted by the unsigned command; their unsigned entries remain manual-download metadata. Signed updater archives and signatures are published only when the key is configured.

The feed’s Linux payload is the architecture-matched AppImage. Windows carries two payloads per architecture, selected by how the running app was installed: the NSIS setup.exe under the bare windows-<arch> key, and the .msi under the per-installer windows-<arch>-msi key (see §4.1). A different package type must never install those bytes over itself. Debian, RPM, Flatpak, and the architecture-matched Windows x64 and ARM64 portable ZIP builds therefore use externally managed updates: download and reinstall or replace the newer matching public asset from GitHub Releases. Flatpak builds additionally live under read-only /app; update them manually, for example with flatpak install --user --reinstall ./sortOfRemoteNG_<version>_linux-<arch>.flatpak. Those package types are intentionally absent from latest.json and do not expose in-app update check/install controls.


1. Pubkey (committed) / Privkey (never committed)

Public key — inline in src-tauri/tauri.conf.json under plugins.updater.pubkey. This is the base64-wrapped minisign public key embedded into every build so that the runtime updater can verify the signature of any downloaded update artifact. Rotating it requires a coordinated release (see §5).

The current committed pubkey:

dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMwNDBGMTM2QTFFRUU2RDQKUldUVTV1NmhOdkZBTUpMeUFxYXNzMHdBRi9PemFUOUludnVYUHFpTTJVVXAyRGN4TUdJMmlhYjQK

Private key — must never be committed. It is generated via tauri signer generate -w <path> (minisign-format Ed25519) and lives in exactly two places, with a third (developer workstation) as an opt-in for local signed-build testing.

Location Purpose Access
1Password vault: sortOfRemoteNG / Release signing Canonical master copy. Holds sortofremoteng.key, the password used at generation time, and a dated rotation log entry. Release maintainers only.
GitHub secret TAURI_SIGNING_PRIVATE_KEY (+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD) Consumed by the release CI workflow (tauri-action). Paste the entire *.key file contents including the untrusted comment: header line. Repository admins; rotates by overwrite.
Developer workstation at ~/.tauri/sortofremoteng.key Optional. Only for maintainers running signed local builds. Must be 0600 / ACL-restricted. Not required for normal development — tauri dev and unsigned tauri build both work without it. Individual maintainer.

The repository’s .gitignore contains the patterns .keys/, *.key, *.key.pub to stop accidental commits of either half of the keypair. The public *.key.pub file is also gitignored — we commit the pubkey inline in tauri.conf.json, never as a loose file, so that a single well-reviewed location is the source of truth.

Regenerating the keypair (bootstrap / one-time)

npx tauri signer generate -w ~/.tauri/sortofremoteng.key
# Follow prompts; choose a strong password; record it in 1Password.

Then:

  1. Copy ~/.tauri/sortofremoteng.key.pub contents (single base64 blob).
  2. Paste it into src-tauri/tauri.conf.json → plugins.updater.pubkey.
  3. Paste ~/.tauri/sortofremoteng.key contents into the GitHub secret TAURI_SIGNING_PRIVATE_KEY. If you set a password, also set TAURI_SIGNING_PRIVATE_KEY_PASSWORD.
  4. File a 1Password vault entry and delete the workstation file once the GitHub secret is confirmed working (or keep it for local signed-build testing — see table above).

2. CI signing env vars

The GitHub release workflow (.github/workflows/release.yml) consumes these environment variables when invoking tauri-apps/tauri-action:

Env var Required Notes
TAURI_SIGNING_PRIVATE_KEY for signed updater artifacts and automatic installation Full contents of sortofremoteng.key, including the untrusted comment: header.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD when that private key is password-protected Plaintext password; store it as a protected secret.

No other updater-specific secrets are required — the pubkey is embedded at build time from tauri.conf.json. Apple Developer ID/notarization credentials and Windows Authenticode credentials are optional OS-signing inputs described in the linked platform runbooks. Their absence must not block publication of public installers.

Release identity has two deliberate representations:

  • Bare Git tags and GitHub Release names use rolling YY.N (26.1) with no prefix.
  • YY is the two-digit UTC year and N is that year’s monotonically increasing counter. The counter starts at 1 after each UTC year rollover; 26.1 is the first-release example for 2026.
  • Package manifests, bundle filenames, and updater feeds use the machine-only SemVer projection YY.N.0 (26.1.0).
  • version.json and the other projections are synchronized in an immutable, detached release snapshot. Its Release-Source-SHA commit trailer records the successful main source commit.

Every successful push to main queues this workflow only after the CI-internal jobs and the exact-source Audit, Backend Coverage, Frontend Build, and Docker e2e (nightly) workflows pass. The workflow receives that commit as source_sha, atomically allocates or recovers its bare tag, derives YY.N.0, and runs the version checks against the prepared snapshot. A rerun for the same source SHA reuses its tag and GitHub Release rather than incrementing N.

The eight updater platform keys are windows-x86_64, windows-aarch64, windows-x86_64-msi, windows-aarch64-msi, linux-x86_64, linux-aarch64, darwin-x86_64, and darwin-aarch64. They are published from six build targets: the two Windows targets each contribute both an NSIS and an MSI platform key, so the per-installer keys have no build target and no *.provenance.json record of their own. If the Tauri private key is absent, the workflow omits updater signatures and signed macOS app archives but still publishes the corresponding public OS installers and an unsigned latest.json. Compatible installations may use its official public artifacts only after explicit per-use warning acknowledgement; the app does not automatically trust them. The workflow records that intentional optional-off state in the job summary rather than a warning annotation. A password secret configured without the private key fails closed as an incomplete updater configuration. If the key is present, missing or invalid updater artifacts fail publication of the feed. Public .rpm and .flatpak files do not add updater platform keys or signature entries.


3. Feed URL and runtime endpoint policy

The committed public endpoint in tauri.conf.json is:

"endpoints": [
  "https://github.com/supermarsx/sortOfRemoteNG/releases/latest/download/latest.json"
]

GitHub serves latest/download/<asset> as a 302 redirect to the most recent release’s asset with that filename, so publishing latest.json is sufficient to expose the newest version — no edits to the committed endpoint list are required per release. The feed’s updater_signing marker and selected platform signature distinguish the verified automatic path from the warning-gated official-public unsigned path. Private unsigned entries remain manual-only.

At runtime, sorng-updater may prepend a Settings-managed private endpoint by constructing a plugin updater with updater_builder().endpoints(..). The same embedded pubkey verifies both private and public artifacts. The legacy UPDATER_PRIVATE_ENDPOINT_URL build-time env var is ignored and should not be used for production configuration.


4. latest.json schema

The updater expects a JSON document of the following shape (this is the standard Tauri v2 schema; see the Tauri updater docs for the upstream reference). The file must be a release asset named exactly latest.json and must sign the installer artifacts, not itself. The feed’s version is always the machine projection (YY.N.0), while its release URL and human-facing notes retain the bare public tag/version (YY.N). updater_signing is a sortOfRemoteNG release-contract marker used by CI and release auditing. Tauri ignores the extra field, and the runtime/manual-install UX derives the signed path from the selected platform entry’s signature and applies the separate official-public policy before offering an unsigned install action. The native command then rechecks the fixed public feed rather than the configured endpoint list.

{
  "version": "26.1.0", // machine SemVer; must be > the running app's version
  "notes": "Release 26.1",
  "pub_date": "2026-04-20T00:00:00Z", // RFC 3339 / ISO 8601
  "updater_signing": true,
  "platforms": {
    "windows-x86_64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_windows-x86_64-setup.exe>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_windows-x86_64-setup.exe",
    },
    "windows-aarch64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_windows-aarch64-setup.exe>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_windows-aarch64-setup.exe",
    },
    "windows-x86_64-msi": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_windows-x86_64.msi>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_windows-x86_64.msi",
    },
    "windows-aarch64-msi": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_windows-aarch64.msi>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_windows-aarch64.msi",
    },
    "darwin-x86_64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_darwin-x86_64.app.tar.gz>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_darwin-x86_64.app.tar.gz",
    },
    "darwin-aarch64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_darwin-aarch64.app.tar.gz>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_darwin-aarch64.app.tar.gz",
    },
    "linux-x86_64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_linux-x86_64.AppImage>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_linux-x86_64.AppImage",
    },
    "linux-aarch64": {
      "signature": "<base64 minisign signature of sortOfRemoteNG_26.1.0_linux-aarch64.AppImage>",
      "url": "https://github.com/supermarsx/sortOfRemoteNG/releases/download/26.1/sortOfRemoteNG_26.1.0_linux-aarch64.AppImage",
    },
  },
}

Platform identifier rules (per Tauri v2):

  • <os>-<arch> where os ∈ {windows, darwin, linux} and arch ∈ {x86_64, aarch64, i686, armv7}, optionally suffixed with -<installer> to address one installer format specifically.
  • The signature field is the base64 contents of the .sig file that tauri build emits next to the bundle (NOT the bundle hash).
  • The url MUST resolve over HTTPS to the same artifact that was signed.
  • pub_date is optional for the runtime but required by our release CI for audit purposes.

The release workflow owns the generator that emits this file from the tauri build output. scripts/ci/validate-updater-feed.mjs enforces the schema, platform entries, artifact URLs, and signatures before a feed can be published. It also requires valid SemVer transport metadata and the exact expected machine projection supplied by the release workflow.

Unsigned releases use the same eight platform keys and set "updater_signing": false plus "signature": "" on every entry. Linux entries reference the published AppImages, Windows entries retain their NSIS and MSI mapping, and macOS entries reference the published DMGs instead of the signed-only app archives. Tauri requires the signature field to be a string, so it cannot be omitted or set to null. Empty signatures are a metadata marker, not a verification bypass. The signed Tauri path and its embedded-key verification remain unchanged. The backend can instead re-resolve the fixed official public feed and launch its exact matching canonical GitHub artifact after the user explicitly acknowledges the unsigned warning for that one attempt. The artifact is not automatically trusted, and the UI cannot choose or substitute its URL. Configured private endpoints are never consulted by this command; their unsigned entries and artifacts remain non-executable and manual-only unless a future release introduces another pinned trust policy.

Clients released before the explicit official-public unsigned path may display an unsigned feed as installable and download its artifact before Tauri rejects the empty signature. They still fail closed before installation. A signed bridge release avoids that one-time UX and bandwidth limitation.

Signing-key continuity is required after publishing a signed updater release. If the newest public release has a signed feed and a later build lacks the updater key, promotion fails instead of replacing the usable signed /releases/latest/download/latest.json with an unsigned feed. Restore the signing secret (or perform an explicitly reviewed channel transition) before retrying that release.

4.1 Per-installer Windows platform keys

tauri-plugin-updater resolves {os}-{arch}-{installer} before falling back to {os}-{arch}, so one latest.json carries both Windows payloads:

Install mode Platform key resolved Payload
NSIS setup.exe windows-<arch> ..._windows-<arch>-setup.exe
MSI windows-<arch>-msi ..._windows-<arch>.msi
Portable ZIP none externally managed

The bare windows-<arch> keys keep their existing meaning — the NSIS setup.exe — so already-installed NSIS clients and clients built before the MSI keys existed are unaffected. There is deliberately no windows-<arch>-nsis key: adding one would change nothing for current clients and risks regressing the fallback they rely on.

MSI installs do not rely on that fallback. sorng-updater passes an explicit .target("windows-<arch>-msi") for installMode = msi, which disables fallback entirely. A feed without the -msi key therefore produces a legible UpdaterTargetMissing error telling the user to install the newer .msi from GitHub Releases, rather than silently downloading the NSIS setup.exe and installing a second, parallel copy of the app beside the MSI one. Operators of a private updater endpoint must publish the -msi keys for MSI clients to update. Note that the explicit target also changes `` expansion in a templated endpoint URL for MSI installs — it expands to windows-x86_64-msi, not windows-x86_64. No endpoint in this repository uses templating.

4.2 What an MSI in-app update actually does

Tauri’s WiX template is InstallScope="perMachine", so:

  1. The plugin verifies the downloaded .msi against the embedded pubkey before anything is executed. A bad signature aborts here; msiexec is never launched.
  2. It then runs msiexec /i <temp>.msi /passive /promptrestart AUTOLAUNCHAPP=True via ShellExecuteW, which always raises a UAC administrator consent prompt.
  3. The app process exits immediately and unconditionally — before the outcome of that prompt is known. Declining UAC therefore cancels the update and leaves the app closed at its current version; reopening it is all that is needed. This is why Settings > Updater warns about it before the install starts.
  4. msiexec performs an in-place major upgrade and AUTOLAUNCHAPP=True relaunches the app.

The in-place upgrade depends on the new MSI carrying the same UpgradeCode as the installed one. bundle.windows.wix.upgradeCode in src-tauri/tauri.conf.json is pinned to exactly the value Tauri derives today from productName, and tests/release/wix-upgrade-code.test.mjs fails if the two ever diverge. Without that pin, renaming productName would silently start installing every future MSI beside the installed one instead of upgrading it. Upstream Tauri derives that string with a hardcoded x64, so the x64 and ARM64 MSIs share an UpgradeCode; that is pre-existing behaviour and is not changed here.

bundle.windows.wix.enableElevatedUpdateTask is deliberately not enabled. It would suppress the UAC prompt by installing a permanently elevated Scheduled Task, which is a standing local privilege-escalation surface; a per-update consent prompt is the correct trade.

CI proves the MSI artifact, its .sig, and the manifest entry are correct and cryptographically verifiable, but no CI job installs an MSI. On the first release carrying this change, smoke-test manually: install release N’s .msi, update in-app to N+1, and confirm a single entry in Apps & Features and a single install directory.


5. Key rotation

Rotating the keypair is a breaking change for already-installed clients: an installed build with pubkey A cannot verify an update signed by private key B, so a rotation requires at minimum one “bridge” release signed by the old key that ships the new pubkey, followed by releases signed with the new key.

Procedure:

  1. Generate a new keypair (tauri signer generate) and store in 1Password under a new entry (sortOfRemoteNG / Release signing (YYYY-MM)).
  2. Ship release N signed with the old key; it only changes plugins.updater.pubkey in tauri.conf.json to the new pubkey. Clients on version ≤ N-1 verify N with the old pubkey, install it, and thereafter hold the new pubkey.
  3. Ship release N+1 signed with the new key; update the GitHub secret TAURI_SIGNING_PRIVATE_KEY to the new private key.
  4. Retire the old entry in 1Password (keep archived, do not delete — may be needed to forensically verify historical releases).

If the old private key is compromised, skip step 2 and publish an out-of-band advisory instructing affected users to reinstall from a trusted channel; there is no way to forcibly re-key an installed client whose embedded pubkey you can no longer sign for.


6. Local verification

To confirm the plugin is wired correctly after any change to this setup:

cargo check -p sorng-updater
cargo check -p sorng-commands-core -p sorng-commands-tools
cargo check -p app                # embeds pubkey at compile time
npm run lint && npm run test      # covers the Settings updater UI and command facade
node scripts/ci/validate-updater-feed.mjs <latest.json> --expected-version 26.1.0 --updater-signing signed
npm run tauri:build               # produces signed bundle if env vars set

A bundle built without TAURI_SIGNING_PRIVATE_KEY in the environment will still compile but will not emit updater .sig files — that is intentional so day-to-day local builds and public OS-installer releases do not require the secret. Local builds do not create a release feed; the release workflow constructs and validates its unsigned public feed from the public artifacts instead. The explicit acknowledgement path always rechecks that fixed official feed; configured private endpoints and their unsigned artifacts remain manual-only.


7. Rerun, recovery, and bad-release policy

Normal releases are driven by successful main CI. If a run stops before reserving its identity, rerun the failed CI job or manually dispatch with the same source_sha, mode: rolling, and release_tier. If its tag already exists, dispatch with mode: existing, the same source_sha and release_tier, and that exact bare tag. The workflow must reuse and complete that release idempotently.

Rolling tags are immutable evidence. Never force-move, delete, or reuse one for another source commit. If a release is bad, do not repoint its tag or edit its generated feed to impersonate another version. Remove it from updater promotion where practical and publish the next YY.N as a forward fix signed with the same updater key. Clients that already installed the bad build cannot be safely downgraded through the normal version-monotonic updater.

Find your next step

Search documentation

Search page titles and the full documentation text.

    ↑ ↓ Browse Enter Open Esc Close Search stays in this tab.