This document outlines the process for creating releases for the web, desktop, and mobile applications.
All platforms share a single version. Running pnpm release bumps package.json, tauri.conf.json, and Cargo.toml together via the version lifecycle hook.
The desktop release workflow is release-desktop.yml. It creates or updates a draft GitHub Release and uploads the Tauri desktop artifacts for the full desktop matrix.
| Platform | Architecture | Artifacts |
|---|---|---|
| macOS | arm64 | .dmg, updater archive + signature |
| macOS | x64 | .dmg, updater archive + signature |
| Windows | x64 | .msi, -setup.exe, updater archive + signature |
| Windows | arm64 | -setup.exe, updater archive + signature |
| Linux | x64 | .AppImage, .deb, .rpm, updater archive + signature |
| Linux | arm64 | .deb, .rpm, updater archive + signature |
The workflow uses native GitHub-hosted runners for macos-15, macos-15-intel, ubuntu-22.04, ubuntu-22.04-arm, windows-latest, and windows-11-arm. Linux arm64 intentionally publishes Debian and RPM bundles only, because the broader Linux bundle set is not reliable on that ARM runner. Windows arm64 currently publishes the NSIS setup executable rather than MSI so the arm64 path stays aligned with the documented Windows installer support in Tauri.
pnpm release:desktop patchEvery push to main and every PR triggers ci.yml:
- Lint + format checks
- Unit tests (Vitest)
- Build
- E2E tests (Playwright — desktop Chromium, mobile Android, mobile iOS viewports)
We use np for version management. It handles version bumping, git tagging, and pushing.
pnpm releasenp prompts for patch/minor/major, creates the v* tag, and pushes to trigger the full release flow.
The version lifecycle hook in package.json runs scripts/sync-version.cjs, which updates:
| File | Field | Example |
|---|---|---|
src-tauri/tauri.conf.json |
version |
0.8.2 |
src-tauri/Cargo.toml |
version |
0.8.2 |
src-tauri/gen/android/app/tauri.properties |
versionName + versionCode |
0.8.2 / 802 |
The Android versionCode is derived as major * 10000 + minor * 100 + patch (e.g., 0.8.2 = 802, 1.0.0 = 10000). This ensures it always increments with each semver bump, as required by the Play Store.
Trigger the desktop workflow manually via GitHub Actions:
- Open Actions → Release Desktop (Tauri)
- Choose Run workflow
- Enter a tag such as
desktop-v0.7.0
| Platform | Architecture | Files |
|---|---|---|
| macOS | Apple Silicon (arm64) | .dmg, .app.tar.gz, .app.tar.gz.sig |
| macOS | Intel (x64) | .dmg, .app.tar.gz, .app.tar.gz.sig |
| Windows | x64 | .msi, .msi.sig, -setup.exe, -setup.exe.sig |
| Windows | arm64 | -setup.exe, -setup.exe.sig |
| Linux | x64 | .AppImage, .AppImage.sig, .deb, .deb.sig, .rpm, .rpm.sig |
| Linux | arm64 | .deb, .deb.sig, .rpm, .rpm.sig |
| File | Description |
|---|---|
forwardemail-mail_<version>_android.apk |
Signed universal APK for sideloading |
forwardemail-mail_<version>_android.aab |
Signed Android App Bundle for Play Store upload |
Built by release-mobile.yml. Requires Android signing secrets in the release environment (see SECRETS.md).
| File | Description |
|---|---|
forwardemail-mail_<version>_ios.ipa |
Signed IPA (archival — primary distribution is TestFlight below) |
The same IPA is uploaded to App Store Connect → TestFlight via xcrun altool using the App Store Connect API key. Testers install through the TestFlight app rather than downloading from the GitHub Release. See ios-setup.md for the post-release flow (processing wait, inviting testers, beta review).
Signing is automatic when secrets are configured. Without secrets, builds are unsigned but still functional.
| Platform | Signing | Notes |
|---|---|---|
| macOS | Apple Developer ID + notarization | Users won't see Gatekeeper warnings |
| Windows | Authenticode certificate | Improves Microsoft Defender and SmartScreen trust, but reputation still builds over time |
| Linux | None needed | .deb and .rpm work unsigned; trust is handled by the host package flow |
| Android | Self-managed keystore (.jks) |
Required for Play Store; optional for APK |
| iOS | Apple Distribution + ASC API key | Required for TestFlight — job skips gracefully when secrets aren't set |
| Auto-updater | Ed25519 key | Required for .sig files |
See SECRETS.md for the full list of required secrets, desktop-ci-secrets.md for desktop signing setup, and ios-setup.md for the iOS signing and TestFlight flow.
Tauri v2 has a handful of platform-specific bugs that are easy to ship past in
tauri dev and only show up in signed/notarized production builds. Run through
this list before promoting a draft GitHub release.
The macOS bundle has com.apple.security.app-sandbox = true with
com.apple.security.network.client = true granted (src-tauri/Entitlements.plist).
Without that network entitlement, the App Sandbox blocks every outbound
request in production builds — including the updater check — even though
everything works in tauri dev (tauri-apps/tauri#13878).
Smoke test, on a notarized signed build (not tauri dev):
- Install the signed
.dmgfrom the draft release. - Open Console.app → filter for "Forward Email".
- Launch the app. Confirm:
- The updater check fires and either reports "up to date" or surfaces a new version prompt — not a network error.
- The login flow reaches
api.forwardemail.net(sign in with a test account).
- If outbound traffic is silently failing, re-check
Entitlements.plistships in the bundle (codesign -d --entitlements - /Applications/Forward\ Email.app).
Windows ships both NSIS (-setup.exe) and MSI installers. The deep-link
plugin's compile-time scheme registration only works with MSI on Windows
(tauri-apps/plugins-workspace#10095) — so we register mailto: at runtime via
direct registry mutation in set_default_mailto_handler (src-tauri/src/lib.rs).
Smoke test, on a fresh Windows 11 VM (don't reuse a dev machine — stale registry entries from prior installs hide the bug):
- Install via
-setup.exe(NSIS). - Launch the app, sign in.
- Settings → make Forward Email the default mail handler. Accept the Windows Settings prompt that pops.
- Open
cmdand runstart mailto:test@example.com. Confirm:- Forward Email comes to the foreground.
- The compose window opens pre-populated with
test@example.comin the To field.
- Repeat with a single-instance test: with the app already open, run the
start mailto:command again. Confirm a new compose window opens in the existing instance, not a second app process.
If step 4 silently does nothing, the runtime registry write isn't taking — fall
back to MSI (.msi) and reproduce there before shipping.
The updater pulls its manifest from a single GitHub URL
(tauri.conf.json → plugins.updater.endpoints):
https://github.com/forwardemail/mail.forwardemail.net/releases/latest/download/latest.json
GitHub's redirect chain has historically returned 401s/403s for some
clients (tauri-apps/tauri#2579 lineage). To add resilience, host a mirror of
latest.json at a stable URL on forwardemail.net infra (e.g. a Cloudflare
Worker or static R2/S3 object) and add it as a second entry in endpoints:
"endpoints": [
"https://releases.forwardemail.net/latest.json",
"https://github.com/forwardemail/mail.forwardemail.net/releases/latest/download/latest.json"
]Tauri tries each endpoint in order and falls through on network or non-200 errors — so the self-hosted mirror becomes primary, the GitHub URL is the backstop.
The mirror needs to publish the same manifest JSON the desktop release
workflow already produces; the simplest path is a CI job that re-uploads
latest.json to the mirror after the GitHub release is published.
Authenticode signing alone doesn't suppress every Windows Defender / third-party
AV false-positive against WebView2 binaries (tauri-apps/wry#2486). On each
release, proactively submit the new -setup.exe and .msi to:
- Microsoft Defender — https://www.microsoft.com/wdsi/filesubmission (mark as "incorrectly detected as malware").
- Major third-party AV vendors that have one-shot false-positive forms (Avast/AVG, Bitdefender, Kaspersky, ESET).
Reputation builds over weeks, so submitting on each release is more useful than batching after user reports.
tauri.conf.json declares libwebkit2gtk-4.1-0 as the deb dependency. Don't
drop back to 4.0 — it's no longer in Ubuntu 24 / Debian 13 repos
(tauri-apps/wry#9662). When testing the AppImage, use a fresh Ubuntu 24 VM
(not Ubuntu 22) so we catch any 4.1 incompatibilities before users do.
- ChromeOS Android intent filter (tauri-apps/plugins-workspace#3207): deep links don't fire for ChromeOS users running our Android build. Niche; no upstream fix yet.
- Notification plugin on Android (tauri-apps/plugins-workspace#2341): the
cancelAll,pending,active, andchannelsAPIs are broken. Don't add call sites for any of them —notification-bridge.jsalready avoids them and logs explicitly when channel creation fails on Android. - macOS WKWebView pinned to OS version: users on old macOS get old WebKit.
We currently set
bundle.macOS.minimumSystemVersion = "10.15". Bumping to11.0would drop Catalina users in exchange for fewer JS-feature edge cases — defer until telemetry shows Catalina usage is negligible.
- SECRETS.md — Required secrets for CI/CD and release signing
- SECURITY.md — Code signing verification and supply chain protections
- DEVELOPMENT.md — Building for production locally
- Desktop CI Secrets — Detailed desktop signing setup
- iOS Setup — Local iOS setup, CI signing, and TestFlight workflow