Shipping a Tauri app to Linux, macOS and Windows as a solo developer
Bumeter is built by one person and ships to Linux, macOS and Windows. There is no build machine under a desk and no release engineer: a release is a git tag, and everything after that runs on GitHub Actions. This post is the whole pipeline, including the parts that are less pretty, like shipping without code signing certificates.
One tag, four runners
git tag v1.0.7 && git push origin main v1.0.7
Pushing a v* tag starts the release workflow. Pushing to main (or running it by hand) runs the same builds and keeps the installers as workflow artifacts, so every merge is a release candidate you can download and try.
The workflow builds on four runners in parallel:
| Runner | Produces |
|---|---|
| Ubuntu 22.04 | .deb and .AppImage for x64 |
| macOS (Apple Silicon) | .dmg for aarch64 |
| macOS (Intel target) | .dmg for x64 |
| Windows | NSIS -setup.exe and .msi for x64 |
Linux builds on Ubuntu 22.04 on purpose: a binary linked against an older glibc runs on newer systems, not the other way round, so the oldest supported release is the right place to build.
Each job builds the bum command line tool first, then the TypeScript UI, then hands both to tauri-action, which builds the app, bundles the installers and attaches them to the GitHub Release.
Shipping a CLI inside a desktop app
bum is a separate Rust binary, and it has to arrive with the app on every platform. Tauri calls this a sidecar: the binary is listed under externalBin and copied into each bundle. Getting it onto the user's PATH is different on each system:
- Linux: the
.debinstalls it as/usr/bin/bum. - macOS: the Homebrew cask links
Bumeter.app/Contents/MacOS/buminto Homebrew'sbin. - Windows: an NSIS hook adds the install folder to the user's
PATH, and the uninstaller removes it.
The Windows hook was the fiddly one. NSIS's own string functions cut values at 1,024 characters, which would silently truncate a long PATH, so the hook uses PowerShell to edit the registry value. It reads the value without expanding it and writes it back as an expandable string, so entries like %USERPROFILE%\bin survive. It then broadcasts the change so new terminals see it without signing out, and it passes the install folder through an environment variable rather than pasting it into the script, because the folder contains the user name and a user name can contain a quote.
Because nothing else exercises an installer, CI runs the real one: it installs silently on the Windows runner, checks that bum.exe is on the user PATH and runs, uninstalls, and checks the entry is gone.
Updating without a server
Bumeter updates itself, and there is no update server. The release workflow also produces a signed latest.json and a signature for each installer, attached to the same GitHub Release. The app uses Tauri's updater plugin to read that file, so GitHub Releases is the whole distribution infrastructure.
- The updates are signed with a key that only CI holds; the app carries the public key and rejects anything else. Local builds do not need the key.
- The app checks once, about 20 seconds after it starts, and only notifies you when a newer version exists. "Check for updates" in the tray or Settings checks on demand.
- The check runs in Rust, not in the web view, so the window keeps the smallest set of permissions and the tray can notify even when no window is open.
Package managers
A download page works, but brew install and winget install are nicer. Both are generated from the release itself: a script reads the published assets and their SHA-256 digests from GitHub and writes the Homebrew cask and the WinGet manifests, so the versions and hashes cannot drift apart. A --check mode fails when the committed files no longer match the release, which is the sanity check before the next tag.
brew install --cask chieund/tap/bumeter
WinGet packages live as manifests in Microsoft's community repository, so publishing a version means a pull request there, which another script prepares and validates against the official schemas. That pull request is the step still in progress: Bumeter is not in WinGet yet, so on Windows you download the installer from the release for now.
Without code signing
An Apple Developer ID and a Windows code signing certificate cost money every year, and Bumeter is free. Shipping unsigned has consequences, and the honest approach is to say what they are:
- macOS quarantines the download, including the
bumbinary inside it. The first launch needs right-click → Open, or onexattr -dr com.apple.quarantinecommand, which also unblocksbumin a terminal. The Homebrew cask prints exactly that after installing. - Homebrew's main cask repository only accepts signed and notarised apps, so the cask lives in a personal tap instead.
- Windows SmartScreen shows "Windows protected your PC" on the first run of the installer: More info → Run anyway. Installing through
wingetwill skip that dialog once the package is accepted.
None of this is hidden in a FAQ; the README and the install instructions say it where you need it.
The checklist
- Bump the version in
Cargo.tomlandtauri.conf.json, commit, tag, push the tag. - Wait for the four jobs to attach their installers.
- Regenerate the cask and WinGet manifests from the release and commit them.
- Push the cask to the tap and open the WinGet pull request.
After that, the app's own updater carries everyone who already has it to the new version.
Bumeter is free and open source (GPL-3.0), and everything stays on your machine. The source is at github.com/chieund/bumeter. The workflow is .github/workflows/release.yml; packaging is in scripts/packaging.py and docs/packaging.md.