This document covers how to create 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.
The unified release.yml runs the webview E2E gate before it
calls the reusable desktop workflow, so it passes skip_e2e_gate: true to avoid running the same
suite twice. GitHub therefore shows the nested build-desktop / E2E webview (gate) as skipped by
design. The six macOS, Windows, and Linux desktop build rows are still created; a skipped nested
gate does not mean desktop CI was removed.
| Platform | Architecture | Installer bundles | Updater/signature assets |
|---|---|---|---|
| macOS | arm64 | Forward.Email_<version>_aarch64.dmg |
Forward.Email_aarch64.app.tar.gz and .sig |
| macOS | x64 | Forward.Email_<version>_x64.dmg |
Forward.Email_x64.app.tar.gz and .sig |
| Windows | x64 | Forward.Email_<version>_x64_en-US.msi, Forward.Email_<version>_x64-setup.exe |
matching .sig sidecars |
| Windows | arm64 | Forward.Email_<version>_arm64-setup.exe |
matching .sig sidecar |
| Linux | x64 | Forward.Email_<version>_amd64.AppImage, Forward.Email_<version>_amd64.deb, Forward.Email-<version>-1.x86_64.rpm, forwardemail-mail_<version>_amd64.snap |
matching .sig sidecars |
| Linux | arm64 | Forward.Email_<version>_arm64.deb, Forward.Email-<version>-1.aarch64.rpm, forwardemail-mail_<version>_arm64.snap |
matching .sig sidecars |
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 limits the native Tauri bundles to Debian and RPM because the broader Linux bundle set is not reliable on that ARM runner; it additionally builds the native arm64 Snap. 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 patchrelease.yml calls release-cli.yml after the release is created. It attaches these assets to the same GitHub Release:
| Asset | What it is |
|---|---|
forwardemail-{linux,darwin}-{x64,arm64}.gz |
Standalone executables (Node.js single executable applications) |
forwardemail-win-{x64,arm64}.exe.gz |
Standalone Windows executables, Authenticode-signed like the desktop installers |
install.sh, install.ps1 |
The one-line installers |
The executables are listed in SHA256SUMS.txt, which the installers and the self-updater verify against, and they carry build provenance attestations. The same version is published to npm as forwardemail on every release, from the bundle CI built and tested; np itself does not publish. The installers and the updater only see a release once it is published, not while it is a draft. The terminal client guide has the details.
Every 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
The exact desktop asset basenames are listed in the matrix above. Tauri also uploads latest.json, and the unified release workflow adds SHA256SUMS.txt. Linux arm64 intentionally has no AppImage; Windows arm64 intentionally has no MSI.
| File | Description |
|---|---|
forwardemail-mail_<version>_android.apk |
Signed sideloadable APK (arm64-v8a + armeabi-v7a) containing both FCM and UnifiedPush; users may select a UnifiedPush distributor |
forwardemail-mail_<version>_android.aab |
The same dual-provider application as an Android App Bundle (all ABIs) for Google Play |
forwardemail-mail_<version>_fdroid.apk |
Signed Google-free UnifiedPush-only APK (arm64-v8a + armeabi-v7a) for the self-hosted F-Droid repository and Obtainium |
Built by release-mobile.yml. The dual-provider Android release job requires Android signing secrets, Firebase client configuration, and the matching VAPID public key before toolchain setup (see SECRETS.md). The separate android-fdroid job uses the same Android release certificate and pnpm tauri:android:build:fdroid, but excludes Firebase and Google Play Services.
The sideloadable APKs are universal APKs, so every ABI they contain is downloaded by every user; they ship only the two ARM ABIs that real devices use. The AAB keeps x86 and x86_64 as well because Play serves each device only its matching split. In all three, the Rust library is stripped by Gradle and its debug symbols are packaged separately (scripts/inject-android-native-symbols.cjs): inside the AAB for Play Console crash symbolication, and as the android-native-debug-symbols workflow artifact for tombstones from sideloaded installs.
The regular v* orchestrator invokes all configured downstream distribution lanes after the GitHub Release artifacts are ready. The table intentionally distinguishes asset production from store publication: each native Linux row attaches its architecture-matched Snap to the GitHub Release, while every store-facing action remains opt-in until its account and credentials are configured.
| Channel | Release behavior | Opt-in control | Operator action after a release |
|---|---|---|---|
| Snap Store | Builds a strict Snap and adds it to the GitHub Release; publishes it to the stable store channel when enabled |
PUBLISH_SNAP_STORE=true |
Verify the stable listing and test snap install |
| Flathub | Flathub rebuilds the submitted source manifest; the source workflow provides only PR verification | Flathub application acceptance | Review the External Data Checker update PR |
| F-Droid repository | Downloads the signed Google-free APK, creates signed indexes, and deploys the repository to GitHub Pages | PUBLISH_FDROID_REPOSITORY=true |
Check Pages, index signature/fingerprint, and client refresh |
| Homebrew tap | Downloads both macOS DMGs, calculates checksums, and opens/updates a cask pull request in the separate tap | PUBLISH_HOMEBREW_TAP=true |
Review and merge the tap pull request |
| Obtainium | Tracks the Google-free GitHub Release APK directly | Always available after the APK exists | No publisher action required |
The full one-time setup, secret generation, installation commands, and failure handling are in distribution-publishing.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).
Local and non-release builds can be unsigned. CI release behavior is stricter: the Android release fails its preflight unless all four keystore values are configured, while the iOS release skips when its signing and App Store Connect values are unavailable. Desktop signing and notarization remain conditional on their platform credentials. See the workflow-specific requirements below before creating a release tag.
| Platform | Signing | Notes |
|---|---|---|
| macOS | Apple Developer ID + notarization | Users won't see Gatekeeper warnings |
| Windows | SSL.com certificate via eSigner | Signed in CI through eSigner cloud signing; the Windows rows fail closed without the ESIGNER_* secrets. SmartScreen reputation still builds over time after signing |
| 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 Developer ID macOS bundle is not sandboxed. src-tauri/Entitlements.plist
carries only com.apple.security.network.client and the two Hardened Runtime
JIT entitlements; com.apple.security.app-sandbox was removed on 2026-06-02
because a sandbox without file-access entitlements crashes the file picker
(see the postmortem).
A separate Entitlements.appstore.plist (sandbox plus user-selected file
access) exists for the future Mac App Store lane and must never be referenced
from tauri.conf.json. Entitlement mistakes only show up in signed builds
(tauri-apps/tauri#13878), so run this on a notarized build.
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). - Open Compose and add an attachment. The native file picker must open; a crash here means the sandbox entitlement crept back in.
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 and 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.
The release stays a draft until every build row, the mobile jobs, and the
checksum file have landed. The publish job then flips it to published with
make_latest=true set explicitly, so Latest does not depend on GitHub's
creation-date default (a draft created before a hotfix and published after it
would otherwise not become Latest). release-summary ends by verifying that the
tag is published and is what releases/latest returns, because the desktop
updater endpoint follows Latest. A green run therefore always means the new
version is what users are offered.
GitHub's release upload endpoint returns transient 5xx responses a few times a month ("Unicorn!", "Error saving asset", "Error creating asset temp dir"). In v0.13.9 (run 35251791779) three rows failed this way after their builds had already succeeded. Two defenses are in place:
tauri-actionruns withretryAttempts: 3, so a failed upload (or build) is retried inside the same job without losing the warm Rust target directory.- Every first-party upload (Snap, APK, AAB, Google-free APK, IPA, checksums)
goes through
.github/actions/upload-release-asset, which deletes any partial asset of the same name, retries with backoff, and verifies the uploaded size.
If a row still fails, use Re-run failed jobs on the run. GitHub re-runs the failed rows and every job that was skipped downstream (checksums, publish, deploy), and a re-uploaded asset replaces the earlier one by name. A re-run executes the workflow files from the tagged commit, so it will not pick up workflow fixes merged since the tag.
- 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
- Distribution Publishing Guide: Snap, Flathub, F-Droid, Homebrew, and Obtainium setup and operations