CI/CD, flavors and store deployment

Separate environments with build flavors, keep signing material out of the repository, automate releases with Fastlane and Codemagic, and submit to both stores.

Flavors and environments

# Android: product flavors in android/app/build.gradle.kts
flutter build apk --flavor staging -t lib/main_staging.dart
flutter build appbundle --flavor production -t lib/main_production.dart

# iOS: schemes and configurations created in Xcode
flutter build ipa --flavor production -t lib/main_production.dart
// lib/config.dart: one compile-time source of truth
class Env {
  static const apiBase = String.fromEnvironment(
    'API_BASE',
    defaultValue: 'https://staging.api.example.com',
  );
  static const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'staging');
  static bool get isProduction => flavor == 'production';
}

// build with: flutter build apk --dart-define=FLAVOR=production \
//   --dart-define=API_BASE=https://api.example.com
  • Use --dart-define plus String.fromEnvironment so configuration is baked at compile time and cannot be changed at runtime.
  • Give each flavor a distinct application id suffix on Android and bundle id on iOS so both can be installed side by side.
  • Keep keystores and provisioning profiles in the CI secret store, never in the repository, and rotate them when someone leaves the team.

A release pipeline

# .github/workflows/flutter.yml
name: flutter
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: 3.24.x
          cache: true
      - run: flutter pub get
      - run: flutter analyze --fatal-infos
      - run: flutter test --coverage
      - run: flutter build appbundle --flavor production
      - uses: actions/upload-artifact@v4
        with:
          name: appbundle
          path: build/app/outputs/bundle/productionRelease/app-production-release.aab
# fastlane/Fastfile
default_platform(:android)

platform :android do
  lane :beta do
    gradle(task: "bundle", build_type: "Release", flavor: "production")
    upload_to_play_store(
      track: "internal",
      aab: "build/app/outputs/bundle/productionRelease/app-production-release.aab"
    )
  end
end
⚠️
Never let a pipeline publish straight to production on a merge. Upload to an internal track first, promote deliberately after a smoke test, and keep the previous build so a rollback is one command rather than a re-release.

Submitting to both stores

ConcernGoogle PlayApp Store
SigningPlay App Signing holds the keyCertificate plus provisioning profile
Staged rolloutPercentage rollout, easy to haltPhased release over seven days
Review timeHours to a few daysUsually a day or more
Binary formatAABIPA
Data disclosureData safety formPrivacy manifest and nutrition label

Automate versioning from the CI run number for the build identifier and bump the user-facing version manually when you intend a release. Tying both to the same counter produces meaningless store versions.

FAQ

Do I need a Mac to release an iOS build from CI?
Yes for the toolchain. Either run the iOS job on a macOS runner, which is slower and billed differently, or use a hosted service such as Codemagic that provides Mac machines for Flutter specifically.
How do I keep staging and production data apart?
Give each flavor its own API base URL and its own application id. Two installs on one device, two sets of preferences, no chance of a staging write reaching production data.

Testing Flutter apps Next steps: Firebase, web and desktop targets

Last refreshed 2026-09-18.