← BlogEngineering

Shipping updates to a Mac app without the App Store

GHZ Code and GHZ Voice are Mac apps built with Tauri, sold on our site and not on the App Store. For their first months, every fix meant asking every user to download a new DMG and reinstall. After the memory leak in GHZ Voice, we decided the next manual install would be the last one.

This is the full chain we run today, from a commit to an app that updates itself in about nine seconds.

Two artifacts per release

Every release produces two things, for two different audiences:

  • A DMG, notarized by Apple, for new users who download the app from their account page.
  • A .app.tar.gz plus a signature file, for apps already installed. This is what the updater downloads.

The second signature has nothing to do with Apple. Tauri’s updater checks it against a public key compiled into the app, so a compromised download server cannot push a fake update.

Signing and notarizing

The build signs the app with our Developer ID automatically. It does not notarize, so we do that step ourselves with Apple’s tool and a stored keychain profile, then staple the ticket:

Release build, condensed
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/<updater-key>)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
npx tauri build            # DMG + .app.tar.gz + .sig

xcrun notarytool submit "GHZ Code.dmg" --keychain-profile <profile> --wait
xcrun stapler staple "GHZ Code.dmg"
shasum -a 256 "GHZ Code.dmg"

Two details cost us time. The updater key had no password, and the build still waited for one on a terminal prompt until we set the password variable to an empty string explicitly. And we rebuild the archive from the stapled app, then re-sign it, so the notarization ticket travels with the update.

Our own update server

The files live on our server, named by their SHA-256 hash. A releases table in Postgres says which version is current for each app and platform, with the download URL, the updater URL and the signature.

The app asks one endpoint at startup, with its current version in the path:

GET /updates/tauri/ghz_code/darwin-aarch64/0.3.13

200 → { "version": "0.3.14", "notes": "...", "pub_date": "...",
        "platforms": { "darwin-aarch64": { "url": "...", "signature": "..." } } }
204 → you are up to date

Publishing a release is one database row. Rolling back is flipping is_latest back on the previous row. The website reads the same table to show download links, so nothing else changes.

The first real update

We published GHZ Voice 0.1.18 only to test the chain. The installed 0.1.17 found it at launch, downloaded it, verified the signature, installed it and relaunched:

~9 s
from launch to updated app
3 s
fastest staged update we timed (Voice 0.1.21 → 0.1.24)
1 row
to publish or roll back a release
Keep reading

More build logs.

All posts →
  1. Engineering2 November 2026Why “Update Codex” failed inside GHZ Code and worked in the Terminal4 min
  2. Story2 November 2026FundingView: 11 days, Claude in a browser tab, and a CSV on GitHub4 min
  3. Engineering2 November 2026Our dictation app grew to 35 GB of RAM. The first fix was wrong.5 min