The Order I Had to Fix Before Tauri 2 Apps Were Safe to Sell — Signing, Notarization, Updates, Licensing
The mistakes I shipped before getting paid Tauri 2 apps right — signing in the wrong order, a DMG that carried an unsigned app through notarization, a rollout gate that leaked at 0 percent, and license verification that trusted the clock.
The Order I Had to Fix Before Tauri 2 Apps Were Safe to Sell
Most developers who finish a Tauri 2 app discover the painful truth on release day: the hard part wasn't writing the code. It was everything that comes after tauri build returns successfully. Notarization rejections, SmartScreen warnings, broken updaters, license keys that don't survive offline use, Stripe webhooks firing twice — these are the topics that the official quickstart politely sidesteps, and they're exactly where every paid desktop app gets stuck.
I've shipped iPhone and Android apps as an indie developer for years, but desktop is a different animal. The store no longer handles distribution for you. You build the pipeline yourself, or you don't ship. This article walks through the full production deployment pattern I use for paid Tauri 2 apps, with Antigravity's agents doing the heavy lifting on the boilerplate so I can focus on the design decisions.
Why "Production Tauri 2" is Genuinely Hard
Tauri's edge over Electron isn't just smaller binaries and faster startup. It's distribution freedom. Tauri 2 generates native installers directly and lets you bring your own update infrastructure. That freedom is powerful — and it means everything is your responsibility.
In the Electron world, electron-updater and electron-builder automate most of this. Tauri 2 ships with tauri-plugin-updater, but key management, distribution endpoints, and staged rollout strategy are yours to build. The reason Antigravity matters here is that you can split this work across agents naturally: the Manager Surface lets one agent write the Rust signature-verification code while another writes the TypeScript license UI, and a third writes your Cloudflare Worker. That separation of concerns is the design philosophy that runs through this entire article.
A second reality: the failure modes of desktop distribution are completely different from web. A bad deploy on the web means rolling back in seconds; a bad release of a desktop app sits on customer machines until they relaunch. That changes how you design every layer. Your update server has to be conservative by default. Your license format has to survive across versions. Your installer has to be re-runnable without breaking existing data. None of these are obvious until your first hot-fix that has to ship without breaking anyone, and you discover the file you needed to update is owned by an installer that overwrites it.
The third thing that catches people: Tauri 2's smaller surface area cuts both ways. Yes, the binary is small and starts fast. But the ecosystem is younger than Electron's, which means many problems you'll hit don't have a Stack Overflow answer. This is exactly where Antigravity earns its keep — its agents can read the Tauri 2 source directly, propose fixes that match upstream's intent, and explain why a particular API exists rather than guessing at usage from forum posts. I've spent more than one evening watching the Manager Surface track down an issue across tauri-plugin-updater's Rust code, the Tauri JS bindings, and the underlying WebView crate — work that would have taken me a full day alone.
Code signing alone isn't enough on macOS. A signed .dmg or .app will still display "cannot verify the developer" until you submit it to Apple for notarization, then staple the result to the binary. Skip notarization and your users see a scary warning — and Gatekeeper will block the app entirely on certain configurations.
The first version of my script produced a confusing outcome: notarization passed, and the shipped app still warned users. Three things were wrong, and none of them were about syntax. All three were about order.
First, I leaned on codesign --deep. Apple discourages it for distribution, and it will happily walk past sidecars and frameworks without applying the entitlements you intended. Second, I omitted --timestamp, which notarization rejects as a signature without a secure timestamp. Third, and worst: the DMG that tauri build produced already contained the unsigned.app, and that was the DMG I was notarizing and stapling.
The outside was notarized. The inside was not. Here is the version with the order rebuilt:
#!/usr/bin/env bash# notarize.sh - Tauri 2 macOS pipeline (sign -> rebuild DMG -> notarize -> staple)# Required env: APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD, SIGNING_IDENTITY# Assumes tauri build ran with --bundles app; this script owns the DMGset -euo pipefailAPP_NAME="MyApp"APP_PATH="src-tauri/target/universal-apple-darwin/release/bundle/macos/${APP_NAME}.app"DMG_PATH="dist/${APP_NAME}_universal.dmg"mkdir -p dist# 1. Sign inside-out. Do not use --deep for distribution builds:# nested code does not inherit your entitlements, and the failure is hard to trace later.while IFS= read -r -d '' NESTED; do codesign --force --options runtime --timestamp \ --sign "$SIGNING_IDENTITY" "$NESTED"done < <(find "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Frameworks" \ -type f -perm -u+x -print0 2>/dev/null)# 2. Sign the .app itself last (--timestamp is required or notarization fails# with "the signature does not include a secure timestamp")codesign --force --options runtime --timestamp \ --sign "$SIGNING_IDENTITY" \ --entitlements src-tauri/entitlements.plist \ "$APP_PATH"codesign --verify --strict --verbose=2 "$APP_PATH"# 3. Rebuild the DMG from the signed .app. Skip this and you ship an unsigned app inside.STAGE="$(mktemp -d)"cp -R "$APP_PATH" "$STAGE/"ln -s /Applications "$STAGE/Applications"rm -f "$DMG_PATH"hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"# 4. Notarize the DMG. Read the status field; do not trust the exit code alone.xcrun notarytool submit "$DMG_PATH" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" \ --wait \ --output-format json > notarize_result.jsonSTATUS=$(jq -r '.status' notarize_result.json)SUBMISSION_ID=$(jq -r '.id' notarize_result.json)if [ "$STATUS" != "Accepted" ]; then echo "Notarization did not pass: status=${STATUS} id=${SUBMISSION_ID}" xcrun notarytool log "$SUBMISSION_ID" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" exit 1fi# 5. Staple and verify (a DMG is assessed against its primary signature)xcrun stapler staple "$DMG_PATH"spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG_PATH"echo "Notarized: $DMG_PATH"
The difference between the old and new script is not the number of commands. It is where verification can catch you. The old one assessed the .app, so it could never notice an unsigned binary sitting inside the DMG. The new one assesses the DMG itself with --context context:primary-signature, which is the artifact your users actually download.
Trusting the exit code of notarytool submit --wait was the other quiet risk. I had a run come back Invalid while CI stayed green, and that build went out. Reading the status field explicitly is four lines of shell and removes the entire class of mistake.
Expected output is Accepted from notarytool, then spctl reporting source=Notarized Developer ID after stapling. If you still see Unnotarized Developer ID, either stapling failed or the artifact you notarized is not the artifact you are shipping.
I hand the log reading to an Antigravity Sub-Agent: "take notarytool log JSON and bucket issues[].message into missing entitlements, missing timestamp, or unsigned nested binary — then print only the offending paths." Notarization errors are worded abstractly, so one layer of classification meaningfully shortens recovery time.
A subtle thing about notarytool's behavior worth knowing: the --wait flag will sit and poll for up to about 15 minutes per submission. If your CI runner has a tight timeout, you may need to either increase the runner timeout or split into a non-blocking submit followed by a separate poll job. I went with the simpler --wait approach because every minute of CI complexity I added paid for itself only in marginal speed improvements, while the simpler version stayed reliable across hundreds of releases. Pick the option that minimizes the moving parts in your pipeline.
There's also a question of what to do when notarization is slow but ultimately succeeds. Apple's median turnaround is under three minutes, but I've seen 12-minute submissions during product launches and right after macOS dot releases ship. If you're publishing on a schedule (e.g. weekly Friday releases), staggering your notarization submissions away from common rush hours helps. The asymmetry: notarization has no SLA, so when Apple is slow, you wait. Build that variance into your release schedule rather than fighting it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will avoid the ordering trap where the DMG produced by tauri build carries an unsigned app straight through notarization, using a script that rebuilds it at the right moment
✦You will close two real rollout bugs — a gate that ships to some machines at 0 percent, and a bucket that reshuffles on every launch — with a corrected Worker you can paste in
✦You will get a single Worker that handles Stripe signature verification, idempotency, and Ed25519 license issuance, ready to port into your own product
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Windows Code Signing — The EV Certificate and SmartScreen Wall
On Windows, Microsoft Defender SmartScreen will loudly warn users about any unsigned installer. Even with a regular OV certificate, you'll see the warning until SmartScreen builds reputation — which can take weeks. An EV (Extended Validation) certificate establishes reputation immediately, but they cost ¥40,000–100,000 per year. My approach is to ship initially with an OV cert, build reputation, and upgrade to EV only if user warnings are actually hurting conversion.
EV certificates live on a USB token or HSM, which makes CI signing tricky. The cleanest solution is Azure Key Vault's Code Signing service — your CI calls a signing API instead of needing physical access to a token. The PowerShell script looks like this:
When Antigravity drafts this script for you, insist on parameterizing the timestamp server URL and configuring two fallback servers. DigiCert's timestamp server has gone down once or twice a month in my experience. Add http://timestamp.sectigo.com and http://timestamp.globalsign.com/tsa/r6advanced1 as fallbacks, and your build failures from this cause drop to nearly zero across a year.
A Self-Hosted Update Server on Cloudflare Workers + R2
Tauri 2's tauri-plugin-updater reads a JSON manifest, compares versions, and downloads the new binary. You could host the manifest on GitHub Releases — many tutorials suggest this — but I push back. Cloudflare Workers + R2 wins on three points:
First, staged rollout (10% → 50% → 100%) is trivial Worker logic. Second, future requirements like region-specific distribution or A/B-tested updates slot in cleanly. Third, R2 has zero egress fees, so your binary distribution costs are essentially the storage bill.
Here's the minimal Worker. When delegating this to Antigravity, I always specify "rollout percentage lives in KV, SHA256 hashes come from R2 metadata" — that constraint produces code that's resilient to operational changes:
// src/index.ts - Tauri 2 update manifest endpointinterface Env { UPDATES_KV: KVNamespace; UPDATES_R2: R2Bucket; UPDATE_SIGNATURE_PUBLIC_KEY: string;}interface UpdateMeta { version: string; notes: string; pub_date: string; rollout: number; // 0-100 platforms: Record<string, { url: string; signature: string }>;}export default { async fetch(req: Request, env: Env): Promise<Response> { const url = new URL(req.url); const match = url.pathname.match(/^\/api\/update\/([^/]+)\/([^/]+)$/); if (!match) return new Response("Not Found", { status: 404 }); const [, platform, currentVersion] = match; // Require a stable per-install id. Falling back to randomUUID() here means the // bucket changes on every launch, and staged rollout becomes a fresh lottery each time. const clientId = req.headers.get("x-client-id"); if (!clientId) return new Response("Missing client id", { status: 400 }); const userBucket = hashToBucket(clientId); try { const metaJson = await env.UPDATES_KV.get("latest_meta", "json") as UpdateMeta | null; if (!metaJson) return new Response("No update available", { status: 204 }); // Staged rollout gate. Buckets are 0..99, so `>` leaks bucket 0 when rollout is 0. if (userBucket >= metaJson.rollout) { return new Response("Not eligible for rollout", { status: 204 }); } // Skip if client is already up to date if (compareSemver(currentVersion, metaJson.version) >= 0) { return new Response("Up to date", { status: 204 }); } const platformData = metaJson.platforms[platform]; if (!platformData) { return new Response(`Unsupported platform: ${platform}`, { status: 400 }); } // Tauri Updater's expected response shape return Response.json({ version: metaJson.version, notes: metaJson.notes, pub_date: metaJson.pub_date, platforms: { [platform]: platformData }, }); } catch (err) { console.error("Update endpoint error:", err); // Return 204 on error so the client treats it as "no update" return new Response("Internal error (silenced)", { status: 204 }); } },};// FNV-1a: even distribution, and the same id always lands in the same bucketfunction hashToBucket(id: string): number { let h = 2166136261; for (let i = 0; i < id.length; i++) { h ^= id.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) % 100;}function compareSemver(a: string, b: string): number { // Never propagate NaN when handed "1.2" or "1.2.0-beta.1" const parse = (v: string) => v.replace(/^v/, "").split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0); const [a1 = 0, a2 = 0, a3 = 0] = parse(a); const [b1 = 0, b2 = 0, b3 = 0] = parse(b); return a1 - b1 || a2 - b2 || a3 - b3;}
Two things in this endpoint were wrong for longer than I'd like to admit, and both concern the rollout gate.
Buckets run 0 through 99. Writing userBucket > rollout means that a rollout of 0 still ships to bucket 0. My release process is "publish at 0%, then open it deliberately," so a build I had not opened was reaching roughly one percent of users. The fix is a single character. It took me two releases to see it.
The second was falling back to crypto.randomUUID() when x-client-id was absent. That makes the same machine land in a different bucket on every launch, so at 10% rollout anyone who checks ten times will eventually win. The header is now required and a missing one returns 400. Being able to not ship is the entire point of staged rollout.
The 204 semantics are worth stating precisely. tauri-plugin-updater treats 204 No Content as "no update available" and other non-2xx responses as errors. So 204 is the sanctioned way to say nothing happened, which is why I also return it when the Worker itself fails — users get no pointless dialog.
That choice has a cost. If internal errors collapse into 204, nobody notices when distribution is broken. I return 204 and log every internal error, with an alert threshold on the error rate. Staying quiet toward users and staying blind yourself are different things.
Expected behavior: when a new version exists, Tauri Updater receives the JSON, downloads the binary with signature verification, and installs. Promoting from 10% to 50% to 100% is just a KV write — no redeploys, no manifest re-uploads.
Don't forget to design rollback. I keep the previous version's manifest under a previous_meta KV key, so I can roll back by swapping two key values. R2 binaries are never deleted and URLs never change. That immutability is what makes rollback safe.
A pragmatic operational tip about KV updates: I always update the manifest by writing to a versioned key first (meta_v42), then atomically updating the latest_meta pointer to that version. That gives me a write-ahead log of every release I've ever shipped, and rollback becomes "point latest_meta back to meta_v41" — no rebuilding, no rebundling, just a KV write. With Cloudflare's eventual-consistency guarantees, the change propagates globally within a minute, which is fast enough for emergency rollbacks while being safer than directly mutating the active key.
The other thing worth designing carefully is the previous_meta semantics. I keep the prior release accessible at the dedicated key for at least 30 days, separately from the versioned history. The versioned history exists for archival and rollback; previous_meta exists for graceful degradation if the latest manifest gets corrupted, allowing the Worker to fall back transparently. These look like the same data but they answer different operational questions, and conflating them has bitten me before.
Offline-Capable License Verification
Paid desktop apps face a real tension: users expect to launch the app on planes and trains without internet, but you also want some defense against casual piracy. The pattern I've settled on is server-signed payload + client-side verification. No constant phoning home, no "you're offline, please connect" friction.
The flow: Stripe checkout completes → server signs a license payload (user ID + expiry + device fingerprint) with a private key → client embeds the matching public key → app verifies the signature on launch. Offline-friendly, stateless, no online dependency.
// src-tauri/src/license.rs - Ed25519 offline license verification// URL-safe, unpadded: survives email line wrapping and pasting into a URL field,// which removes a surprising amount of support emailuse base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine as _};use ed25519_dalek::{Signature, Verifier, VerifyingKey};use serde::{Deserialize, Serialize};use std::time::{SystemTime, UNIX_EPOCH};const LICENSE_PUBLIC_KEY: &[u8] = include_bytes!("../keys/license_pub.bin");#[derive(Debug, Serialize, Deserialize)]pub struct LicensePayload { pub user_id: String, pub email: String, pub expires_at: u64, pub device_fingerprint: String, pub plan: String, // "lifetime" | "annual"}#[derive(Debug)]pub enum LicenseError { Malformed, InvalidSignature, Expired, DeviceMismatch,}pub fn verify_license( license_str: &str, current_fingerprint: &str,) -> Result<LicensePayload, LicenseError> { // Format: <base64(payload)>.<base64(signature)> let (payload_b64, sig_b64) = license_str .split_once('.') .ok_or(LicenseError::Malformed)?; let payload_bytes = B64.decode(payload_b64).map_err(|_| LicenseError::Malformed)?; let sig_bytes = B64.decode(sig_b64).map_err(|_| LicenseError::Malformed)?; let payload: LicensePayload = serde_json::from_slice(&payload_bytes).map_err(|_| LicenseError::Malformed)?; let key_array: [u8; 32] = LICENSE_PUBLIC_KEY .try_into() .map_err(|_| LicenseError::InvalidSignature)?; let verifying_key = VerifyingKey::from_bytes(&key_array).map_err(|_| LicenseError::InvalidSignature)?; let signature = Signature::from_slice(&sig_bytes) .map_err(|_| LicenseError::InvalidSignature)?; verifying_key .verify(&payload_bytes, &signature) .map_err(|_| LicenseError::InvalidSignature)?; // Skip expiry for lifetime plans (encoded as expires_at = 0) if payload.plan != "lifetime" { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); if now > payload.expires_at { return Err(LicenseError::Expired); } } if payload.device_fingerprint != current_fingerprint { return Err(LicenseError::DeviceMismatch); } Ok(payload)}#[tauri::command]pub fn check_license(license: String, fingerprint: String) -> Result<LicensePayload, String> { verify_license(&license, &fingerprint).map_err(|e| format!("{:?}", e))}
The ordering inside verify_license matters too. Signature verification runs first; expiry and fingerprint checks come after. A payload only becomes trustworthy once the signature has vouched for it, so it must never feed a branch before that. Swapping the order still "works," which is exactly why it slips through review.
One honest limitation: expires_at trusts the device clock. Roll the clock back and an expired annual license verifies fine. I now store the highest timestamp ever observed in the OS keychain and treat any earlier reading as expired. If you only sell lifetime licenses you can skip this, but if annual plans are in the mix it is cheaper to add now than later.
The non-obvious decision here is how to compute the device fingerprint. MAC addresses are trivially spoofed by virtual NICs. Hard drive serial numbers break in dual-boot setups. I use the OS-level install UUID — IOPlatformUUID on macOS, MachineGuid on Windows — combined and SHA256-hashed. It's not bulletproof, but it stops casual sharing among friends and family. Going further than that gets into pixel-counting paranoia that costs more in user friction than it gains in piracy prevention.
You'll notice I don't maintain a server-side license revocation list. That's a deliberate trade-off. Revocation lists demand an online check, which destroys the "works offline" property. They also complicate refund automation. For genuinely abusive cases, I bake a blacklist of revoked license IDs into the next app release — slow, but doesn't require running an authoritative server.
Stripe Checkout to License Issuance in One Worker
The other critical piece is wiring Stripe webhooks to license issuance. It takes two paragraphs to describe and considerably longer to get right, because signature verification and idempotency both have sharp edges. Here is the Worker I actually run.
// worker/webhook.ts - receive a Stripe webhook, issue an Ed25519 licenseinterface Env { LICENSES_KV: KVNamespace; STRIPE_WEBHOOK_SECRET: string; LICENSE_SIGNING_KEY: string; // PKCS#8 Ed25519 private key, base64}interface Claims { user_id: string; email: string; plan: "lifetime" | "annual"; device_fingerprint: string;}export default { async fetch(req: Request, env: Env): Promise<Response> { // Verify against the raw body. Parsing to JSON first will never match. const raw = await req.text(); const ok = await verifyStripeSignature(raw, req.headers.get("stripe-signature") ?? "", env.STRIPE_WEBHOOK_SECRET); if (!ok) return new Response("Invalid signature", { status: 400 }); const event = JSON.parse(raw); if (event.type !== "checkout.session.completed") { return new Response("Ignored", { status: 200 }); } // Idempotency: never process the same event.id twice const guard = `evt:${event.id}`; if (await env.LICENSES_KV.get(guard)) { return new Response("Duplicate", { status: 200 }); } await env.LICENSES_KV.put(guard, "1", { expirationTtl: 60 * 60 * 24 * 30 }); const session = event.data.object; const email = session.customer_details?.email ?? ""; const license = await issueLicense(env, { user_id: session.client_reference_id ?? session.id, email, plan: session.metadata?.plan === "annual" ? "annual" : "lifetime", device_fingerprint: session.metadata?.device_fingerprint ?? "", }); // Always persist so the license can be re-sent later await env.LICENSES_KV.put(`license:${email}`, license); return new Response("OK", { status: 200 }); },};async function issueLicense(env: Env, claims: Claims): Promise<string> { const expires_at = claims.plan === "annual" ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 : 0; const payload = new TextEncoder().encode(JSON.stringify({ ...claims, expires_at })); const key = await crypto.subtle.importKey( "pkcs8", b64ToBytes(env.LICENSE_SIGNING_KEY), { name: "Ed25519" }, false, ["sign"], ); const sig = new Uint8Array(await crypto.subtle.sign("Ed25519", key, payload)); return `${b64url(payload)}.${b64url(sig)}`;}async function verifyStripeSignature(raw: string, header: string, secret: string): Promise<boolean> { const parts = Object.fromEntries( header.split(",").map((p) => p.split("=") as [string, string]), ); const t = parts.t; const v1 = parts.v1; if (!t || !v1) return false; // Discard anything older than five minutes as a replay if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${raw}`)); const expected = [...new Uint8Array(mac)] .map((b) => b.toString(16).padStart(2, "0")) .join(""); // Compare without returning early if (expected.length !== v1.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i); return diff === 0;}function b64url(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, "");}function b64ToBytes(b64: string): Uint8Array { return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));}
The key detail is that b64url pairs with URL_SAFE_NO_PAD on the Rust side. When the issuer and the verifier disagree about a base64 dialect, you get a valid signature that fails verification, and the trail is cold. That one cost me half a day.
Ed25519 is available in the Workers WebCrypto implementation. Older compatibility_date values exposed it as NODE-ED25519, so if importKey throws NotSupportedError, check your wrangler configuration date before debugging the key material.
On idempotency, the honest caveat: KV is eventually consistent, so two near-simultaneous deliveries of the same event can both read an empty guard. If your price point is high, serialize through a Durable Object. At my volume Stripe's retry spacing makes KV sufficient — but that is a judgment about my revenue scale, not a claim that the design is correct in general.
Licenses are re-sendable by design, which is what license:{email} is for. Most support mail is some form of "I lost the email," and whether that path is automated changes how much of your week belongs to support.
One more design decision worth highlighting: I generate licenses with a 30-day "grace period" for annual plans. The expires_at timestamp is set 30 days past the actual subscription end date. The reasoning is empathy — when a user's payment method expires, you don't want to brick their app the same day. Send them a reminder, give them time to update billing, and only enforce the cutoff if they ignore three weeks of email. This single decision dropped my refund rate noticeably; people who were going to renew anyway just needed a few days, and people who genuinely wanted to cancel weren't surprised by anything.
For monthly subscriptions, I take a different approach: instead of issuing a license with a 30-day expiry every month, I issue a license that expires 75 days out and have the app silently re-fetch a fresh one every 30 days when online. This means a user who goes offline for 2 months still has a working app, and only after that do they need to reconnect to refresh the license. The trade-off is more server hits per month, but R2 + Workers handle that scale for pennies.
Everything above only holds if the steps run in the right order, so I treat the CI definition itself as the design document.
# .github/workflows/release.yml (macOS job only — the order is the point)jobs: macos: runs-on: macos-14 steps: - uses: actions/checkout@v4 # 1. Do not let tauri build produce a DMG containing an unsigned .app - name: Build (.app only) run: | npm ci npm run tauri build -- --target universal-apple-darwin --bundles app # 2. Sign -> rebuild DMG -> notarize -> staple - name: Sign and notarize env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }} run: ./scripts/notarize.sh # 3. Sign the update artifact only after notarization - name: Sign update artifact env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_KEY_PASSWORD }} run: npx @tauri-apps/cli signer sign dist/MyApp_universal.dmg # 4. Upload to R2 and register at 0% — opening it is a human decision - name: Publish at 0% run: ./scripts/publish.sh --rollout 0
Two things carry the weight here.
--bundles app is the first. If tauri build is allowed to produce the DMG, a DMG containing the unsigned app exists before you ever sign anything — and the accident of shipping the wrong one becomes possible. Not producing it removes the accident.
Publishing at 0% is the second. I used to start at 10%, and moved to 0% because I want the first thirty minutes after a release to be spent confirming the download works from a clean machine. Since rollout is only a KV write, opening it afterwards costs nothing.
The update-artifact signature has to come after notarization, not before. Notarization and stapling modify the bytes of the artifact, so a signature taken earlier no longer matches. That is the one step in this pipeline whose position is not negotiable.
Pitfalls You Will Hit (Field-Tested)
Five pitfalls I learned the hard way. None are well documented in the official Tauri docs.
Updater public key changes. If you regenerate tauri signer generate keys in CI, every existing user's app will reject your updates as "invalid signature." Generate the key once, back it up obsessively, and when you need to rotate, ship a transitional release that's signed with both the old and new keys.
Universal Binary RPATH issues on macOS. Building Intel and Apple Silicon separately, then merging with lipo, can embed mismatched RPATHs that crash on one architecture. Use cargo-zigbuild or Tauri's --target universal-apple-darwin to keep both architectures in the same toolchain.
SmartScreen reputation reset on Windows. This one cost me three weeks. If you distribute your signed .exe inside a .zip, Windows applies "Mark of the Web" to the inner executable and treats it as untrusted again, even though it's signed. Distribute the signed installer directly, never zipped.
License keys stored in plaintext config. Always use the OS keychain — Keychain on macOS, Credential Manager on Windows, Secret Service on Linux. Tauri 2 has tauri-plugin-stronghold, which gives you encrypted local storage with a clean API.
Untestable updaters. Don't believe anyone who says you can only test the updater by actually shipping a release. Ask Antigravity to "spin up a local Worker with a stub manifest endpoint," and you'll have a development loop that exercises the updater from your dev machine. Before shipping, walk through five cases: same-version (no update), minor update, major update, key mismatch, and network disconnected.
One additional pitfall worth flagging because it bites every team eventually: treating "release" as a single atomic event. In practice, building, signing, notarizing, uploading to R2, and publishing the manifest are five separate operations across two operating systems. Any one can fail independently. I structure my GitHub Actions workflow so each stage produces an artifact that subsequent stages consume. If notarization fails, I haven't already announced the release on the manifest. If R2 upload fails, the manifest update never runs. This pipeline shape — not a single big script — is what lets you sleep through a botched release.
A related operational mindset: I never push manifest updates manually. Even when I'm about to deploy a hotfix at 2am because something is broken, I run the same pipeline. Manual ops are fine when nothing's wrong; under stress, they're how you ship the wrong binary or forget to staple a notarization ticket. Antigravity helped me lock this in by writing the entire workflow in a way that the local dev loop produces the exact same artifacts as CI — that symmetry is what keeps the release process trustworthy.
Real-World Application
This is the architecture I run for several apps in production: an AI-assisted writing tool, a lightweight SQL client, and a local-first image generation GUI. They share three properties: they have to run well offline, they have a "buy once" or "annual" pricing model, and they ship to both Mac and Windows.
One thing I cannot recommend enough: document this entire production deployment design in your project's AGENTS.md. Six months from now, when you're modifying the updater and start wondering "why didn't I add a server-side revocation list," your past self will have written down the answer. My AGENTS.md includes the pitfall list above almost verbatim, so future-me — and Antigravity's agents reading the file — don't fall into the same traps.
A practical observation about pricing: I've found that buy-once licenses outperform subscriptions for desktop apps in the indie space. Web apps train users to expect monthly billing; desktop apps still feel like "products you buy." My own data: the same app sold at $39 lifetime converts at roughly 2.3x the rate of $5/month, even though long-term revenue is comparable. Your mileage will vary, but worth A/B testing rather than assuming subscriptions are universally better.
I'll add one more architectural note. The license verification code lives in src-tauri/src/license.rs for a reason — it's Rust, not JavaScript. A determined attacker could patch a JavaScript verifier inside the WebView, but patching Rust requires modifying the binary itself, which invalidates the code signature. The Rust path forces them to also re-sign the binary, which means re-distributing the modified app. That additional friction stops most casual cracking attempts at the boundary, and it's a free benefit of how Tauri is structured.
If I had to compress this whole article into one line: shipping a Tauri 2 app to production takes more time designing the system than writing the app code. Signing, notarization, updates, licensing, payments — each is its own small project. The reason Antigravity makes this tractable for solo developers is that the agent-oriented workflow lets you parallelize the boilerplate across all of these systems at once.
If you do one thing today, add the macOS notarization script to your existing project. The moment that script lands in your CI, your app crosses the line from "side project" to "product I can sell." Hook it into GitHub Actions so cutting a release tag produces a notarized build automatically — that single piece of infrastructure changes how you think about your project. From there, work outward to the other pieces.
Every mistake described here is one I shipped before I fixed it. If it saves someone else the same half day, writing it down was worth it. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.