CI/CD
Flutter CI/CD — build automation, code signing, Fastlane, flavors, release pipeline, OTA updates
CI/CD
Flutter ships a single Dart codebase to iOS, Android, web, and desktop — but each target has its own signing, packaging, and distribution story. A production CI/CD pipeline must handle all of them without manual intervention.
Build Pipeline Overview
A mature Flutter pipeline flows through six stages. Every stage is automatable; the goal is zero human steps between merge and production.
lint → test → build → sign → distribute → monitor| Stage | What Happens | Key Tools |
|---|---|---|
| Lint | flutter analyze, dart format --set-exit-if-changed . | analysis_options.yaml, custom_lint |
| Test | flutter test, flutter test --coverage, golden tests | lcov, codecov, very_good_cli |
| Build | flutter build ipa, flutter build appbundle | Xcode, Gradle |
| Sign | Certificate + provisioning (iOS), keystore (Android) | Fastlane match, Play App Signing |
| Distribute | TestFlight, Play Console, Firebase App Distribution | Fastlane deliver, supply |
| Monitor | Crash-free rate, ANR, performance | Crashlytics, Sentry, Datadog |
CI Provider Comparison
| Provider | macOS Runners | Flutter Pre-installed | Build Minutes (Free Tier) | Native Strength |
|---|---|---|---|---|
| GitHub Actions | Yes (M1 available) | No (install via action) | 2,000/month | Ecosystem, marketplace |
| Codemagic | Yes (M2 available) | Yes | 500/month | Flutter-first, zero config |
| Bitrise | Yes | Via step | 300 credits | Visual workflow editor |
| GitLab CI | Self-hosted only | No | 400/month (shared) | Self-hosted flexibility |
iOS builds require macOS. There is no way around this — Xcode, codesign, and the iOS toolchain only run on macOS. Budget for macOS runners (GitHub Actions charges 10x Linux rates for macOS minutes) or use a Flutter-first provider like Codemagic that includes macOS by default. Android builds run fine on Ubuntu.
Self-Hosted vs Cloud Runners
Self-hosted runners are worth considering when:
- Build minutes are expensive at scale (large team, many PRs)
- You need persistent caches (CocoaPods, Gradle, pub cache survive between builds)
- You require specific hardware (physical devices for integration tests)
- Security policy forbids cloud builds
The tradeoff is maintenance: you own OS updates, Xcode upgrades, disk cleanup, and runner availability. For most teams, cloud runners with aggressive caching are the better default.
Code Signing
iOS
iOS code signing requires four artifacts working together:
| Artifact | What It Is | Lifetime |
|---|---|---|
| Signing certificate (.p12) | Your identity, issued by Apple | 1 year (dev) / 3 years (distribution) |
| Private key | Paired with the certificate | Same as certificate |
| Provisioning profile (.mobileprovision) | Links certificate + app ID + devices + entitlements | 1 year |
| App ID | Bundle identifier registered in Apple Developer Portal | Permanent |
Distribution certificate is used for App Store and Ad Hoc builds. Development certificate is for debug builds on registered devices. A team gets a maximum of 3 distribution certificates.
Manual vs Automatic Signing
Xcode's automatic signing works for solo developers but breaks on CI: it expects interactive Xcode access, and each machine generates its own certificate, burning through the 3-certificate limit. For teams, manual signing with Fastlane match is the standard.
Fastlane Match
Match stores certificates and profiles in a Git repo (or Google Cloud Storage / S3), encrypted with a passphrase. Every developer and CI machine pulls from the same source of truth.
# fastlane/Matchfile
git_url("https://github.com/your-org/certificates")
storage_mode("git")
type("appstore") # appstore, adhoc, development, enterprise
app_identifier("com.example.myapp")
username("ci@example.com")
# For CI: set MATCH_PASSWORD env var instead of interactive prompt# fastlane/Fastfile — iOS lanes
platform :ios do
desc "Sync certificates and profiles"
lane :sync_certs do
match(type: "appstore", readonly: is_ci)
match(type: "development", readonly: is_ci)
end
desc "Build and upload to TestFlight"
lane :beta do
sync_certs
build_ios_app(
workspace: "ios/Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store",
output_directory: "./build/ios",
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
)
end
endCI Keychain Setup
On CI, you must create and unlock a temporary keychain before match can install certificates:
# Create a temporary keychain for CI
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
# After match installs certs, they live in this keychain
# Clean up after build:
security delete-keychain build.keychainAndroid
Android signing is simpler: a Java keystore file contains the signing key.
Keystore Generation
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias release \
-storepass "$STORE_PASSWORD" \
-keypass "$KEY_PASSWORD" \
-dname "CN=Your Name, OU=Your Org, O=Your Company, L=City, ST=State, C=US"Never commit the keystore or passwords to Git. Store the keystore as a base64-encoded CI secret and decode it at build time. Losing the upload key means you cannot update your app on the Play Store (unless you use Play App Signing).
key.properties
# android/key.properties — gitignored
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=release
storeFile=../release-keystore.jksbuild.gradle Signing Config
// android/app/build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ?
file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}Play App Signing
Google Play App Signing (opt-in but strongly recommended) means Google holds the actual release signing key. You sign uploads with an upload key — if you lose it, Google can reset it. This is a safety net that also enables Google to optimize APK delivery per device.
Flavors / Build Variants
What Flavors Solve
Real apps need at least two environments: one that hits staging APIs and one that hits production. Flavors let you build the same codebase against different configurations without if (kDebugMode) scattered everywhere.
| Flavor | API Base | App Name | Bundle ID Suffix |
|---|---|---|---|
| dev | api-dev.example.com | MyApp Dev | .dev |
| staging | api-staging.example.com | MyApp Staging | .staging |
| production | api.example.com | MyApp | (none) |
Flutter Flavors Setup
Run a specific flavor:
flutter run --flavor dev --dart-define-from-file=config/dev.json
flutter build appbundle --flavor production --dart-define-from-file=config/production.jsonEnvironment Config File
// config/dev.json
{
"API_BASE_URL": "https://api-dev.example.com",
"ENABLE_LOGGING": "true",
"ANALYTICS_ENABLED": "false",
"APP_NAME": "MyApp Dev"
}// config/production.json
{
"API_BASE_URL": "https://api.example.com",
"ENABLE_LOGGING": "false",
"ANALYTICS_ENABLED": "true",
"APP_NAME": "MyApp"
}Reading Config in Dart
class EnvConfig {
static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
static const enableLogging = String.fromEnvironment('ENABLE_LOGGING') == 'true';
static const analyticsEnabled = String.fromEnvironment('ANALYTICS_ENABLED') == 'true';
static const appName = String.fromEnvironment('APP_NAME', defaultValue: 'MyApp');
}
// Usage
final dio = Dio(BaseOptions(baseUrl: EnvConfig.apiBaseUrl));Android productFlavors
// android/app/build.gradle
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
resValue "string", "app_name", "MyApp Dev"
versionNameSuffix "-dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
resValue "string", "app_name", "MyApp Staging"
versionNameSuffix "-staging"
}
production {
dimension "environment"
resValue "string", "app_name", "MyApp"
}
}
}iOS Schemes and Configurations
For iOS, create one Xcode scheme per flavor. Each scheme uses a different build configuration (Debug-dev, Release-dev, Debug-production, Release-production). Set the PRODUCT_BUNDLE_IDENTIFIER and PRODUCT_NAME per configuration in the xcconfig files or via Xcode build settings.
// ios/Flutter/dev.xcconfig
#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER=com.example.myapp.dev
PRODUCT_NAME=MyApp Dev
FLUTTER_FLAVOR=devFastlane Integration
Why Fastlane for Flutter
Fastlane consolidates build, sign, screenshot, and distribute into a single declarative Fastfile. It eliminates the need to chain together flutter build, xcodebuild, jarsigner, and store-specific upload CLIs manually.
Complete Fastfile
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build and push to TestFlight"
lane :beta do
match(type: "appstore", readonly: is_ci)
# Increment build number using CI build number
increment_build_number(
build_number: ENV["CI_BUILD_NUMBER"] || latest_testflight_build_number + 1,
xcodeproj: "ios/Runner.xcodeproj",
)
build_ios_app(
workspace: "ios/Runner.xcworkspace",
scheme: "production",
export_method: "app-store",
output_directory: "./build/ios",
xcargs: "-allowProvisioningUpdates",
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
apple_id: ENV["APPLE_APP_ID"],
)
slack(
message: "iOS beta uploaded to TestFlight",
slack_url: ENV["SLACK_WEBHOOK"],
) if ENV["SLACK_WEBHOOK"]
end
desc "Full App Store release"
lane :release do
beta
deliver(
submit_for_review: true,
automatic_release: false,
force: true,
submission_information: {
add_id_info_uses_idfa: false,
},
)
end
end
platform :android do
desc "Build and push to Play Store internal track"
lane :beta do
# Decode keystore from base64 secret
sh("echo $ANDROID_KEYSTORE_BASE64 | base64 --decode > ../android/release-keystore.jks") if is_ci
gradle(
project_dir: "./android",
task: "bundle",
build_type: "Release",
flavor: "production",
properties: {
"android.injected.signing.store.file" => File.expand_path("../android/release-keystore.jks"),
"android.injected.signing.store.password" => ENV["STORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["KEY_PASSWORD"],
},
)
upload_to_play_store(
track: "internal",
aab: "./build/app/outputs/bundle/productionRelease/app-production-release.aab",
skip_upload_metadata: true,
skip_upload_changelogs: false,
skip_upload_images: true,
skip_upload_screenshots: true,
)
end
desc "Promote internal to production"
lane :release do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: "0.1", # 10% staged rollout
)
end
endCommon Lanes
| Lane | Purpose |
|---|---|
fastlane ios beta | Build + sign + upload to TestFlight |
fastlane ios release | Beta + submit for App Store review |
fastlane android beta | Build + sign + upload to Play internal track |
fastlane android release | Promote internal to production with staged rollout |
fastlane ios screenshots | Capture localized screenshots with snapshot |
fastlane match | Sync certificates and profiles |
GitHub Actions Workflow
A production-ready workflow that lints, tests, and builds for both platforms.
# .github/workflows/flutter-ci.yml
name: Flutter CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
FLUTTER_VERSION: "3.27.4"
jobs:
# ──────────────────────────────────────────────
# Stage 1: Lint + Test (runs on every PR)
# ──────────────────────────────────────────────
analyze-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:"
- name: Install dependencies
run: flutter pub get
- name: Check formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Run tests with coverage
run: flutter test --coverage --test-randomize-ordering-seed=random
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage/lcov.info
fail_ci_if_error: false
# ──────────────────────────────────────────────
# Stage 2: Build Android (on merge to main)
# ──────────────────────────────────────────────
build-android:
needs: analyze-and-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
# Gradle cache
- uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('android/build.gradle', 'android/app/build.gradle') }}
restore-keys: gradle-
- name: Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/release-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties <<EOF
storePassword=${{ secrets.STORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
keyAlias=${{ secrets.KEY_ALIAS }}
storeFile=release-keystore.jks
EOF
- name: Build App Bundle
run: flutter build appbundle --flavor production --dart-define-from-file=config/production.json
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: android-release
path: build/app/outputs/bundle/productionRelease/*.aab
retention-days: 14
# ──────────────────────────────────────────────
# Stage 3: Build iOS (on merge to main)
# ──────────────────────────────────────────────
build-ios:
needs: analyze-and-test
if: github.ref == 'refs/heads/main'
runs-on: macos-14 # M1 runner
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
# CocoaPods cache
- uses: actions/cache@v4
with:
path: ios/Pods
key: pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: pods-
- name: Install CocoaPods
run: cd ios && pod install --repo-update
- name: Setup Fastlane
run: |
cd ios
gem install bundler
bundle install
- name: Build and upload to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
CI_BUILD_NUMBER: ${{ github.run_number }}
run: bundle exec fastlane ios beta
- name: Upload dSYM artifact
uses: actions/upload-artifact@v4
with:
name: ios-dsym
path: build/ios/*.dSYM.zip
retention-days: 90Cache aggressively. A cold Flutter + Gradle + CocoaPods build takes 15-25 minutes. With proper caching of pub packages, Gradle dependencies, and CocoaPods, you can cut this to 5-8 minutes. The subosito/flutter-action cache parameter handles the Flutter SDK itself; add explicit caches for Gradle and Pods.
Version Management
Semantic Versioning for Apps
Flutter uses a combined version string in pubspec.yaml:
# pubspec.yaml
version: 2.4.1+127
# ^^^^^ ^^^
# | |
# | build number (integer, auto-incremented in CI)
# version name (semver: major.minor.patch)| Component | Meaning | Increment When |
|---|---|---|
| Major | Breaking changes, major redesign | New major feature set, migration required |
| Minor | New features, backward-compatible | Feature additions |
| Patch | Bug fixes, minor improvements | Hotfixes |
| Build number | Monotonically increasing integer | Every CI build |
Platform Mapping
| pubspec.yaml | iOS (Info.plist) | Android (build.gradle) |
|---|---|---|
2.4.1 (version name) | CFBundleShortVersionString | versionName |
127 (build number) | CFBundleVersion | versionCode |
Auto-Incrementing Build Numbers in CI
# Option 1: Use CI build number directly
flutter build appbundle --build-number=$GITHUB_RUN_NUMBER
# Option 2: Derive from git commit count (deterministic)
BUILD_NUMBER=$(git rev-list --count HEAD)
flutter build ipa --build-number=$BUILD_NUMBER
# Option 3: Fastlane — fetch latest from store and increment
# In Fastfile:
# increment_build_number(build_number: latest_testflight_build_number + 1)Android versionCode must strictly increase. The Play Store rejects uploads where versionCode is less than or equal to the current published version. Use a monotonically increasing CI variable (run number or git commit count) rather than timestamps or random values.
Changelog Generation
Automate changelogs from conventional commits:
# Using git-cliff (Rust-based, fast)
git-cliff --latest --strip header > CHANGELOG.md
# Or using conventional-changelog (Node.js)
npx conventional-changelog -p angular -i CHANGELOG.md -sPair with a commit lint hook (commitlint) to enforce conventional commit format (feat:, fix:, chore:).
Release Pipeline
Release Stages
A professional release pipeline uses progressive exposure to catch issues before they reach all users:
Internal Testing → Closed Beta → Open Beta → Staged Rollout → Full Production| Stage | Audience | Purpose | Duration |
|---|---|---|---|
| Internal testing | Team only (10-20 people) | Smoke test, basic validation | 1-2 days |
| Closed beta | Invited external testers (100-500) | Real-world usage, edge cases | 3-7 days |
| Open beta | Anyone who opts in | Scale testing, broader feedback | 1-2 weeks |
| Staged rollout | 1% → 5% → 25% → 50% → 100% | Monitor crash rate at each tier | 1-2 weeks |
TestFlight Distribution (iOS)
TestFlight supports up to 10,000 external testers. Internal testers (up to 100, must be App Store Connect users) receive builds instantly without review. External testers require a light Beta App Review (usually < 24 hours).
# Fastlane: upload to TestFlight with external group
upload_to_testflight(
distribute_external: true,
groups: ["Beta Testers"],
changelog: "Bug fixes and performance improvements",
)Google Play Tracks (Android)
| Track | Visibility | Tester Limit |
|---|---|---|
| Internal testing | Invite-only link | 100 |
| Closed testing (alpha) | Invite-only or Google Group | Unlimited |
| Open testing (beta) | Public opt-in on Play Store | Unlimited |
| Production | All users | Everyone |
# Fastlane: promote from internal to production with 10% rollout
upload_to_play_store(
track: "internal",
track_promote_to: "production",
rollout: "0.1",
)
# Later: increase rollout to 50%
upload_to_play_store(
track: "production",
rollout: "0.5",
)
# Full release
upload_to_play_store(
track: "production",
rollout: "1.0",
)Firebase App Distribution
For cross-platform beta distribution without store review:
# Install the Firebase CLI
npm install -g firebase-tools
# Distribute Android
firebase appdistribution:distribute build/app/outputs/apk/production/release/app-production-release.apk \
--app "$FIREBASE_APP_ID_ANDROID" \
--groups "qa-team, beta-testers" \
--release-notes "Build $BUILD_NUMBER: $(git log -1 --pretty=%s)"
# Distribute iOS
firebase appdistribution:distribute build/ios/ipa/MyApp.ipa \
--app "$FIREBASE_APP_ID_IOS" \
--groups "qa-team"Firebase App Distribution is ideal for internal/QA builds that need fast turnaround without waiting for TestFlight review or Play Store processing.
OTA Updates
Why Flutter Does NOT Support Traditional OTA
Flutter compiles Dart to AOT (Ahead-of-Time) native machine code for release builds. The resulting binary is platform-specific ARM code, not interpreted bytecode. Apple and Google both prohibit downloading and executing new native code at runtime — this is fundamentally different from React Native (JavaScript bundle) or web apps.
You cannot hot-patch Dart code in a released Flutter app via standard mechanisms.
Shorebird: Code Push for Flutter
Shorebird provides OTA updates for Flutter by patching the AOT-compiled Dart code at the binary level. It works by diffing the compiled artifacts and distributing minimal patches.
| Aspect | Details |
|---|---|
| What can be updated | Dart code only (logic, UI, state) |
| What cannot be updated | Native code, assets, platform channels, pubspec dependencies |
| Patch size | Typically 50-200 KB (diff-based) |
| App Store compliance | Apple-approved (no new native code execution) |
| Pricing | Free tier available; paid for teams |
# Initialize Shorebird in your project
shorebird init
# Create a release (baseline for future patches)
shorebird release android --flavor production
shorebird release ios --flavor production
# Ship a patch (after fixing a bug in Dart code)
shorebird patch android --flavor production
shorebird patch ios --flavor productionShorebird is not a substitute for proper releases. Use it for critical hotfixes (crash fixes, broken API endpoints) where waiting for store review is unacceptable. Routine feature work should go through the normal release pipeline — patches cannot update native code, assets, or dependencies.
Remote Config for Feature Flags
For runtime behavior changes without any code update, use Firebase Remote Config:
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.setDefaults({
'new_checkout_flow': false,
'max_upload_size_mb': 10,
'maintenance_mode': false,
});
await remoteConfig.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(hours: 1),
));
await remoteConfig.fetchAndActivate();
// Usage
final useNewCheckout = remoteConfig.getBool('new_checkout_flow');
final maxUpload = remoteConfig.getInt('max_upload_size_mb');
if (remoteConfig.getBool('maintenance_mode')) {
// Show maintenance screen
}| Mechanism | Use Case | Requires Store Review |
|---|---|---|
| New binary release | New features, native changes, dependency updates | Yes |
| Shorebird patch | Critical Dart-only hotfixes | No |
| Remote Config | Feature flags, A/B tests, kill switches | No |
Bundle Size Management
Analyze Build Size
# Generate a size breakdown
flutter build apk --analyze-size --target-platform=android-arm64
# iOS equivalent
flutter build ipa --analyze-size
# Output: a JSON file you can load in DevTools → App SizeThe DevTools App Size tool visualizes which packages, assets, and native libraries consume the most space.
Tree Shaking and Obfuscation
# Production build with all optimizations
flutter build appbundle \
--release \
--obfuscate \
--split-debug-info=build/debug-info \
--tree-shake-icons| Flag | Effect | Size Impact |
|---|---|---|
--obfuscate | Renames Dart symbols to short names | 5-15% reduction |
--split-debug-info | Moves debug symbols out of the binary | 10-20% reduction |
--tree-shake-icons | Removes unused Material/Cupertino icons | Up to 1 MB |
Always keep --split-debug-info output. You need these symbol files to deobfuscate crash stack traces. Upload them to your crash reporting service (Sentry, Crashlytics) or archive them alongside each release.
Deferred Components
For large apps, split features into deferred components that download on demand:
// Define a deferred import
import 'package:my_app/features/video_editor/video_editor.dart'
deferred as video_editor;
Future<void> openVideoEditor() async {
// Downloads the component on first access
await video_editor.loadLibrary();
Navigator.push(context, MaterialPageRoute(
builder: (_) => video_editor.VideoEditorPage(),
));
}This requires Android App Bundle (Play Feature Delivery). iOS does not support deferred components natively.
Asset Optimization
| Strategy | Savings | Tradeoff |
|---|---|---|
| WebP instead of PNG | 25-35% per image | Requires Android 4.3+ (effectively universal) |
| SVG for simple icons | Eliminates raster assets | Runtime parsing cost; pre-render complex SVGs |
| Icon fonts instead of PNGs | Single file for all icons | Monochrome only |
--tree-shake-icons | Up to 1 MB | None — pure win |
Compress PNGs with pngquant | 40-70% per image | Lossy (usually imperceptible) |
Size Budget Enforcement in CI
#!/bin/bash
# scripts/check_bundle_size.sh
MAX_SIZE_MB=30
APK_PATH="build/app/outputs/flutter-apk/app-production-release.apk"
SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH")
SIZE_MB=$((SIZE_BYTES / 1048576))
echo "APK size: ${SIZE_MB}MB (budget: ${MAX_SIZE_MB}MB)"
if [ "$SIZE_MB" -gt "$MAX_SIZE_MB" ]; then
echo "FAIL: APK exceeds size budget by $((SIZE_MB - MAX_SIZE_MB))MB"
exit 1
fiMonitoring Post-Release
Crash-Free Rate Targets
| Metric | Target | Action Threshold |
|---|---|---|
| Crash-free users | > 99.5% | Investigate below 99.3% |
| Crash-free sessions | > 99.8% | Investigate below 99.6% |
| ANR rate (Android) | < 0.5% | Critical above 1% |
| Hang rate (iOS) | < 0.1% per session | Investigate any increase |
Google Play Console flags apps with crash rates above 1.09% or ANR rates above 0.47% — exceeding these thresholds reduces Play Store visibility.
Performance Monitoring Setup
// Firebase Performance — automatic HTTP and screen tracing
import 'package:firebase_performance/firebase_performance.dart';
// Custom trace for a critical operation
Future<void> processCheckout(Cart cart) async {
final trace = FirebasePerformance.instance.newTrace('checkout_flow');
trace.putAttribute('item_count', cart.items.length.toString());
await trace.start();
try {
await paymentService.charge(cart);
trace.putMetric('total_cents', cart.totalCents);
} finally {
await trace.stop();
}
}// Sentry — captures crashes + performance
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = const String.fromEnvironment('SENTRY_DSN');
options.tracesSampleRate = 0.2; // 20% of transactions
options.profilesSampleRate = 0.1;
options.environment = const String.fromEnvironment('SENTRY_ENV',
defaultValue: 'production');
},
appRunner: () => runApp(const MyApp()),
);
}Rollback Strategy
When monitoring reveals a critical issue post-release:
- Android: Halt the staged rollout in Play Console immediately. If already at 100%, publish a hotfix or roll back to the previous version by re-uploading the old AAB with an incremented
versionCode. - iOS: Contact Apple to expedite review for a hotfix (use the "Critical Bug Fix" option). There is no self-serve rollback on the App Store.
- Both: If using Shorebird, push a Dart-only patch immediately while preparing a full binary fix.
User Feedback Channels
Instrument in-app feedback for early signal:
- In-app feedback form (custom or via
feedbackpackage) - Shake-to-report (Instabug, Shake SDK)
- App Store / Play Store review monitoring (automated alerts for 1-2 star reviews)
Pipeline Checklist
Run through this before declaring your pipeline production-ready:
| Area | Check | Status |
|---|---|---|
| Source Control | Feature branches, PR reviews, protected main branch | - [ ] |
| Lint | flutter analyze --fatal-infos in CI | - [ ] |
| Format | dart format --set-exit-if-changed . in CI | - [ ] |
| Tests | Unit + widget tests with coverage threshold (>80%) | - [ ] |
| iOS Signing | Fastlane match, certificates in encrypted repo | - [ ] |
| Android Signing | Keystore in CI secrets, Play App Signing enabled | - [ ] |
| Flavors | dev / staging / production with separate bundle IDs | - [ ] |
| Build Numbers | Auto-incremented from CI, monotonically increasing | - [ ] |
| Caching | Pub, Gradle, CocoaPods, Flutter SDK cached | - [ ] |
| Artifacts | APK/AAB/IPA uploaded as CI artifacts | - [ ] |
| Distribution | TestFlight + Play internal track automated | - [ ] |
| Crash Reporting | Sentry or Crashlytics with dSYM/debug-info upload | - [ ] |
| Performance | Firebase Performance or equivalent monitoring | - [ ] |
| Size Budget | Bundle size check in CI with defined threshold | - [ ] |
| Rollback Plan | Documented procedure, team knows the steps | - [ ] |
| Secrets | No credentials in Git, all secrets in CI vault | - [ ] |
| Changelog | Auto-generated from conventional commits | - [ ] |
Start with the critical path. You do not need every item on this checklist on day one. A viable starting pipeline is: lint + test + build + manual upload. Add signing automation, Fastlane, and staged rollouts as the team and product mature. Over-engineering CI before product-market fit is wasted effort.