Steven's Knowledge

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
StageWhat HappensKey Tools
Lintflutter analyze, dart format --set-exit-if-changed .analysis_options.yaml, custom_lint
Testflutter test, flutter test --coverage, golden testslcov, codecov, very_good_cli
Buildflutter build ipa, flutter build appbundleXcode, Gradle
SignCertificate + provisioning (iOS), keystore (Android)Fastlane match, Play App Signing
DistributeTestFlight, Play Console, Firebase App DistributionFastlane deliver, supply
MonitorCrash-free rate, ANR, performanceCrashlytics, Sentry, Datadog

CI Provider Comparison

ProvidermacOS RunnersFlutter Pre-installedBuild Minutes (Free Tier)Native Strength
GitHub ActionsYes (M1 available)No (install via action)2,000/monthEcosystem, marketplace
CodemagicYes (M2 available)Yes500/monthFlutter-first, zero config
BitriseYesVia step300 creditsVisual workflow editor
GitLab CISelf-hosted onlyNo400/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:

ArtifactWhat It IsLifetime
Signing certificate (.p12)Your identity, issued by Apple1 year (dev) / 3 years (distribution)
Private keyPaired with the certificateSame as certificate
Provisioning profile (.mobileprovision)Links certificate + app ID + devices + entitlements1 year
App IDBundle identifier registered in Apple Developer PortalPermanent

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
end

CI 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.keychain

Android

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.jks

build.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.

FlavorAPI BaseApp NameBundle ID Suffix
devapi-dev.example.comMyApp Dev.dev
stagingapi-staging.example.comMyApp Staging.staging
productionapi.example.comMyApp(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.json

Environment 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=dev

Fastlane 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
end

Common Lanes

LanePurpose
fastlane ios betaBuild + sign + upload to TestFlight
fastlane ios releaseBeta + submit for App Store review
fastlane android betaBuild + sign + upload to Play internal track
fastlane android releasePromote internal to production with staged rollout
fastlane ios screenshotsCapture localized screenshots with snapshot
fastlane matchSync 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: 90

Cache 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)
ComponentMeaningIncrement When
MajorBreaking changes, major redesignNew major feature set, migration required
MinorNew features, backward-compatibleFeature additions
PatchBug fixes, minor improvementsHotfixes
Build numberMonotonically increasing integerEvery CI build

Platform Mapping

pubspec.yamliOS (Info.plist)Android (build.gradle)
2.4.1 (version name)CFBundleShortVersionStringversionName
127 (build number)CFBundleVersionversionCode

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 -s

Pair 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
StageAudiencePurposeDuration
Internal testingTeam only (10-20 people)Smoke test, basic validation1-2 days
Closed betaInvited external testers (100-500)Real-world usage, edge cases3-7 days
Open betaAnyone who opts inScale testing, broader feedback1-2 weeks
Staged rollout1% → 5% → 25% → 50% → 100%Monitor crash rate at each tier1-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)

TrackVisibilityTester Limit
Internal testingInvite-only link100
Closed testing (alpha)Invite-only or Google GroupUnlimited
Open testing (beta)Public opt-in on Play StoreUnlimited
ProductionAll usersEveryone
# 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.

AspectDetails
What can be updatedDart code only (logic, UI, state)
What cannot be updatedNative code, assets, platform channels, pubspec dependencies
Patch sizeTypically 50-200 KB (diff-based)
App Store complianceApple-approved (no new native code execution)
PricingFree 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 production

Shorebird 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
}
MechanismUse CaseRequires Store Review
New binary releaseNew features, native changes, dependency updatesYes
Shorebird patchCritical Dart-only hotfixesNo
Remote ConfigFeature flags, A/B tests, kill switchesNo

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 Size

The 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
FlagEffectSize Impact
--obfuscateRenames Dart symbols to short names5-15% reduction
--split-debug-infoMoves debug symbols out of the binary10-20% reduction
--tree-shake-iconsRemoves unused Material/Cupertino iconsUp 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

StrategySavingsTradeoff
WebP instead of PNG25-35% per imageRequires Android 4.3+ (effectively universal)
SVG for simple iconsEliminates raster assetsRuntime parsing cost; pre-render complex SVGs
Icon fonts instead of PNGsSingle file for all iconsMonochrome only
--tree-shake-iconsUp to 1 MBNone — pure win
Compress PNGs with pngquant40-70% per imageLossy (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
fi

Monitoring Post-Release

Crash-Free Rate Targets

MetricTargetAction 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 sessionInvestigate 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:

  1. 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.
  2. 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.
  3. 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 feedback package)
  • 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:

AreaCheckStatus
Source ControlFeature branches, PR reviews, protected main branch- [ ]
Lintflutter analyze --fatal-infos in CI- [ ]
Formatdart format --set-exit-if-changed . in CI- [ ]
TestsUnit + widget tests with coverage threshold (>80%)- [ ]
iOS SigningFastlane match, certificates in encrypted repo- [ ]
Android SigningKeystore in CI secrets, Play App Signing enabled- [ ]
Flavorsdev / staging / production with separate bundle IDs- [ ]
Build NumbersAuto-incremented from CI, monotonically increasing- [ ]
CachingPub, Gradle, CocoaPods, Flutter SDK cached- [ ]
ArtifactsAPK/AAB/IPA uploaded as CI artifacts- [ ]
DistributionTestFlight + Play internal track automated- [ ]
Crash ReportingSentry or Crashlytics with dSYM/debug-info upload- [ ]
PerformanceFirebase Performance or equivalent monitoring- [ ]
Size BudgetBundle size check in CI with defined threshold- [ ]
Rollback PlanDocumented procedure, team knows the steps- [ ]
SecretsNo credentials in Git, all secrets in CI vault- [ ]
ChangelogAuto-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.

On this page