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-defineplusString.fromEnvironmentso 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
| Concern | Google Play | App Store |
|---|---|---|
| Signing | Play App Signing holds the key | Certificate plus provisioning profile |
| Staged rollout | Percentage rollout, easy to halt | Phased release over seven days |
| Review time | Hours to a few days | Usually a day or more |
| Binary format | AAB | IPA |
| Data disclosure | Data safety form | Privacy 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.
Related
Testing Flutter apps Next steps: Firebase, web and desktop targets
Last refreshed 2026-09-18.