Once you've got the basic patch flow working and you understand when to patch vs ship, the next step is wiring it into a real workflow so you're not running CLI commands from your laptop at midnight when production is on fire.
This is the production setup I run on my own apps. Three pieces: CI for releases, staged rollouts for safety, and a few small habits that catch problems before they hit users.
Step 1: GitHub Actions for releases
The biggest mistake I see is forgetting which builds were created with shorebird release versus plain flutter build. Only the former are patchable. The fix is to never run flutter build for store builds — always go through Shorebird, and automate it from CI so you can't forget.
Here's the workflow I use:
name: Release
on:
push:
tags:
- 'v*'
jobs:
release-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- uses: shorebirdtech/setup-shorebird@v1
- name: Authenticate Shorebird
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
run: echo "$SHOREBIRD_TOKEN" | shorebird login:ci
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks
- name: Create release
run: shorebird release android --no-confirm
- name: Upload AAB
uses: actions/upload-artifact@v4
with:
name: app-release.aab
path: build/app/outputs/bundle/release/*.aabThe flow: tag a commit with v1.2.3, push the tag, GitHub Actions runs shorebird release android, the resulting AAB is uploaded as a build artifact for you to drop into Google Play Console. Same idea for iOS, with the added joy of code signing on a macOS runner.
Step 2: Patches from CI too
Once releases are automated, do the same for patches. I gate this on a separate tag prefix so I never accidentally patch when I meant to release:
name: Patch
on:
push:
tags:
- 'patch-*'
jobs:
patch-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- uses: shorebirdtech/setup-shorebird@v1
- name: Authenticate
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
run: echo "$SHOREBIRD_TOKEN" | shorebird login:ci
- name: Patch latest release
run: shorebird patch android --no-confirmNow my emergency flow looks like:
- Fix the bug locally
- Open a PR, get a review (yes, even for hotfixes)
- Merge, then
git tag patch-2026-04-10-fix-crash && git push --tags - CI patches Android and iOS
- Watch the dashboard
The PR review step is the biggest safety upgrade. It costs 5 minutes and catches the "fix that breaks more than it fixes" bug at least once a year for me.
Step 3: Staged rollouts
Shorebird supports staged rollouts: roll a patch out to a percentage of users, watch for issues, then roll it to 100%. This is the most important production feature and the one most people skip until they get burned.
# Stage to 5% of users on the latest release
shorebird patch android --staging --percentage 5
# After confirming things look good, promote to 100%
shorebird patches promote --patch-number 3My rule: any patch that touches a code path with more than ~10% of usage gets staged at 5% first. Trivial copy fixes can go straight to 100%. Anything that touches auth, payments, sync, or data persistence rolls to 5%, sits for an hour, then promotes.
Step 4: Tracking patch adoption
Shorebird's dashboard shows you real-time install counts per patch. The metrics I check:
- Adoption rate — what percentage of active users on the release have downloaded this patch? Climbs over the first 24 hours, plateaus around 70-80%, then keeps creeping up as users open the app.
- Crash rate per patch — your crash reporter (Sentry, Crashlytics, whatever you use) should let you tag events with the patch number. Compare patch N's crash rate against patch N-1.
- Revert rate — how many times have you had to delete this patch? If it's nonzero, you're not reviewing patches carefully enough.
I check the dashboard about an hour after pushing a patch, and again the next morning. That's it.
A safety net I always add
Wrap your Shorebird init in a try/catch so a Shorebird outage can never break your app launch:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
// Shorebird auto-initializes on launch — but if its servers are
// unreachable or the device is offline, we don't want that to
// block the app from starting.
await Future.any([
// Whatever your Shorebird init looks like
Future.delayed(Duration.zero),
Future.delayed(const Duration(seconds: 2)),
]);
} catch (_) {
// Swallow — the app will start without the patch.
// The patch will apply on the next launch.
}
runApp(const MyApp());
}In practice the Shorebird SDK already handles this gracefully — but I sleep better knowing I have my own timeout in front of it.
What I skip
A few things people set up that I don't bother with:
- Per-environment Shorebird projects — I have one Shorebird app ID per Flutter app, and I rely on tags + manual environment selection. Multiple flavors complicate the patch logic enough that I find it cleaner to just run the CLI manually for non-production environments.
- Patch tests in CI — Shorebird patches don't really lend themselves to automated testing because they only apply to installed builds. Manual smoke test on a spare device is faster.
- Custom rollout percentages beyond 5/100 — I tried 10/50/100 once and it was just more work. 5% staged → 100% is the entire ladder I need.
The full production checklist
Before any app I ship goes live with Shorebird:
shorebird initcommitted to reporelease.ymlandpatch.ymlworkflows in.github/workflows/SHOREBIRD_TOKENin GitHub secrets- Crash reporter (Sentry or similar) tagged with the current patch number
- A test release pushed to internal track to verify the CI flow
- Documented in the team README: "tag with
v*to release, tag withpatch-*to patch, always stage at 5% first"
Six steps. After that, patching is muscle memory and you can sleep at night.