Ch 5 shipped the web half, where a bad release is undone with wrangler rollback in thirty seconds. This chapter ships the app half — iOS, Android, Mac, and Windows — and the single most important thing to understand up front is what changes: you cannot instantly un-ship a native app. Once a build is in the App Store or installed on a user's machine, "rollback" is no longer a command; it's a strategy. Every best practice in this chapter exists because of that asymmetry.
The good news: all four targets are the same five-act play and the same six-stage spine from Ch 1. Learn the shape once and each platform is "the same thing, different store." And the Ch 4 signing identity — the whole reason we hardened the Mac mini — is what powers all of it.
The Same Spine, Four Endings
Every native release runs the same six stages; only the nouns change. This is the Ch 1 table again, now with the release detail filled in:
| Stage | 🍎 iOS | 🤖 Android | 💻 Mac (direct) | 🪟 Windows |
|---|---|---|---|---|
| Build | xcodebuild archive | gradle bundleRelease | xcodebuild archive | msbuild / dotnet publish |
| Sign | Dist cert + provisioning profile | Upload key → Play App Signing | Developer ID + notarize | Authenticode (signtool) |
| Package | .ipa | .aab | .dmg / .pkg | MSIX / MSI |
| Distribute | TestFlight → App Store | Play Console tracks | R2 / your website | winget / MS Store / site |
| Update | App Store auto-update | Play auto-update | Sparkle-style appcast | MSIX / winget upgrade |
| "Rollback" | Halt phased release; expedite a fix | Halt staged rollout % | Re-publish previous DMG + appcast | Re-publish previous + winget |
The first four stages are mechanical and well-trodden. The interesting rows are the last two — update and rollback — because that's where native diverges hardest from web, and where the rest of this chapter lives.
Figure 1 — One signed artifact, four store-shaped endings. The build and signing converge on the Ch 4 identity; the divergence is entirely in how each store distributes and updates. Signing (red) is the shared hard part — see Ship iOS Ch 1.
iOS → TestFlight → App Store
The iOS pipeline runs on the Mac mini (Apple-only). fastlane is the glue that wraps every step — the same Fastfile runs on a laptop or the runner (Ship iOS Ch 1):
# fastlane/Fastfile
lane :beta do
match(type: "appstore") # sync signing identity (Ch 4) — no certs on disk
build_app(scheme: "MyApp") # xcodebuild archive + export .ipa
upload_to_testflight # → beta testers, the progressive-delivery slice
endThe release shape is TestFlight first, then a phased App Store release. TestFlight is your "preview URL" — a real signed build in front of a limited audience before everyone. Then App Store Phased Release rolls an approved update out to existing users over seven days (1% → 2% → 5% → 10% → 20% → 50% → 100%), and you can pause it at any step if reports go bad. That pause is the closest thing iOS has to a rollback — it stops the bleed, but it doesn't un-install what already shipped.
Android → Play Console Tracks
Android builds on cheap Linux (Ch 3) — no Apple tax. The artifact is an Android App Bundle (.aab), and signing is usually delegated to Play App Signing (Google holds the app signing key; you hold an upload key). Distribution is through ascending tracks:
Figure 2 — Play tracks are environments for apps. The same artifact is promoted up the tracks (internal → closed → open → production), exactly the Ch 5 promotion idea — and production itself supports a staged rollout percentage you can halt, the Android twin of Cloudflare's traffic split.
Mac (Direct) → Notarize → DMG
This is the pipeline this project actually ships, so it's the most concrete. A direct-distribution Mac app (not on the Mac App Store) must be Developer ID-signed and notarized, or Gatekeeper blocks it on launch:
xcodebuild -scheme "Simple App Shipper" -configuration Release \
-archivePath build/App.xcarchive archive
xcodebuild -exportArchive -archivePath build/App.xcarchive \
-exportPath build/export -exportOptionsPlist scripts/exportOptions.plist # Developer ID signing
xcrun notarytool submit "build/export/Simple App Shipper.app" \
--keychain-profile "AC_PASSWORD" --wait # Apple notarizes
xcrun stapler staple "build/export/Simple App Shipper.app" # attach the ticket
hdiutil create -volname "Simple App Shipper" -srcfolder build/export -ov -format UDZO build/App.dmg
xcrun stapler staple build/App.dmg
wrangler r2 object put simpleappshipper-releases/SimpleAppShipper.dmg --file build/App.dmg --remoteDistribution is a DMG on R2 behind releases.simpleappshipper.com; auto-update is a Sparkle-style appcast (an XML feed the app checks for new versions). "Rollback" here is genuinely simple compared to the stores: re-upload the previous DMG and point the appcast back at it — you control the whole channel, so you can revert the download in seconds (though already-updated users still need the next build).
Windows → Authenticode → MSIX
The Windows pipeline runs on a windows-latest runner (Ch 3). The signing concept mirrors macOS but with Authenticode:
dotnet publish -c Release # or msbuild
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 MyApp.exe # Authenticode
makeappx pack /d .\publish /p MyApp.msix # package
signtool sign /fd SHA256 MyApp.msix # sign the package tooTwo Windows-specific facts worth knowing:
- SmartScreen reputation. An unsigned (or freshly OV-signed) app triggers a scary "Windows protected your PC" warning until it earns reputation. An EV (Extended Validation) certificate grants instant SmartScreen reputation — the Windows analogue of notarization removing Gatekeeper friction. It's the single highest-impact thing for a Windows app's first-run experience.
- Distribution + update.
winget(the built-in package manager) is the modern default — publish a manifest and userswinget upgrade. MSIX supports auto-update; the Microsoft Store is optional. As with the others, "rollback" means publishing the previous version forward, not un-installing.
The Hard Truth: You Can't wrangler rollback an App
Here's the asymmetry stated plainly, because it's the whole reason app releases need more discipline than web ones:
Figure 3 — Web reverts; apps can only stop-and-fix-forward. A shipped app lives on users' devices; you can stop new installs (halt the rollout) but can't reach back and un-install. This is why the app half leans so hard on catching problems before wide release.
Because there's no true rollback, your real safety nets are all about limiting and recovering rather than reverting:
| Safety net | What it does |
|---|---|
| Beta channel first | TestFlight / Play closed track / a beta appcast — catch it before the public ever sees it. The single most effective net. |
| Phased / staged rollout | Expose the new version to 1–10% of users; a bad build only reaches the slice before you halt. |
| Halt the rollout | Stop new users getting the bad version — the "pause" button on iOS phased release / Android staged rollout. |
| Fix-forward + expedite | Ship a corrected build fast; request expedited App Store review for genuine emergencies. |
| Server-side kill switch | Gate risky features behind a remote flag your backend controls — so you can disable the bad behaviour without shipping a new build at all. |
Signing Is Still the Hard Part
Across all four, the build is easy and the signing is where the pain lives (Ship iOS Ch 1 made this case for iOS; it generalises). Four platforms, four identities the Ch 4 cluster has to manage safely:
- iOS/Mac: Apple distribution / Developer ID certs + provisioning profiles. Keep them in the mini's isolated keychain, or sync via
fastlane match. - Android: the upload key (and Google holds the app signing key). Never commit the keystore; inject it per-job.
- Windows: the Authenticode cert (ideally EV, often on a hardware token or cloud HSM).
The Ch 4 rule holds for all of them: the identity lives on the hardened machine, is used per-job, and never sits in git or on a laptop.
What This Project Actually Does
Honest status: of the four, this project ships the Mac (direct) column for real, and the others are the general pattern.
- Live: the Mac pipeline above is this repo's actual release flow (archive → Developer ID export →
notarytool→stapler→ DMG → R2), documented inCLAUDE.md. The app also contains shipping helpers — a Binary Upload feature wrappingxcrun altool --upload-app, and a CI Builds panel that ingests webhooks from Bitrise/Codemagic/GitHub/Xcode Cloud if hosted CI is ever added. - Pattern, not yet shipped here: iOS/Android/Windows columns — taught so the architecture is complete, flagged honestly as targets this repo doesn't currently produce.
- The gap to the target: moving the notarize-and-publish steps onto the mini as a signed Ch 3 job (today they're run by hand), and adding a beta appcast channel as the pre-public slice.
The summary: the hardest native column — a notarized, directly-distributed Mac app with a rollback you actually control — is real here; the other three are the same spine pointed at a different store.
Mental Model — Three Sentences
- All four native targets are the same build/sign/package/distribute/update spine from Ch 1; only the store-shaped ending differs, and the Ch 4 signing identity is what powers every one of them.
- You cannot instantly un-ship a native app, so each store's phased/staged rollout is your blast-radius control and "halt the rollout" is your stop button — but a true revert doesn't exist, which is why beta channels and catching problems pre-release matter so much more than on the web.
- The app world's real rollback is a server-side kill switch: make risky features remotely toggleable so you can disable a broken one instantly without shipping a new build — web-speed recovery on a native app.
Try It Yourself (15 Minutes)
- Map your spine. For an app you ship (or want to), fill in the six-stage row for its platform. The "sign" and "rollback" cells are where the real work is.
- Find your beta channel. TestFlight, a Play closed track, or a beta appcast — which one stands between your next build and all your users? If the answer is "nothing," that's the net to build first.
- Locate a kill switch. Pick the riskiest feature in your app. Could you disable it from your backend without shipping an update? If not, that's the highest-leverage resilience change you can make.
- Check your signing hygiene. Where does your signing key live right now? If the answer is "committed in the repo" or "on my laptop," revisit Ch 4 — that's the one to fix before automating anything.
Where This Lands in the Series
Both halves now ship: the web reverts in seconds, the apps roll out in slices behind beta channels and kill switches. You can take a change from a keystroke on a MacBook Pro all the way to five production targets, safely.
One act of the Ch 1 play remains: Operate. Ch 7 is the safety net under everything — environments and secrets done right across all five targets, the observability that tells you a release is bad (web and app), and the rollback/kill-switch playbook that turns "it broke in production" into a non-event. It's the chapter that makes shipping fast and calm, and it closes the series.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper