Advanced · Lesson 6 · 13 min read
Release Builds, Signing and CI/CD
Configure flavours and environments, sign Android and iOS builds properly, shrink your app, and automate the whole release in CI.
Updated July 31, 2026
What you will learn
- Build signed release artifacts for Android and iOS
- Separate dev, staging and production configuration
- Reduce app size and enable obfuscation
- Automate builds and store uploads in CI
Everything up to this point runs on your machine. Shipping introduces a different set of problems: signing keys, environment configuration, store requirements, and making the whole thing reproducible so a release does not depend on one laptop.
Build modes
| Mode | Compilation | Use for |
|---|---|---|
| Debug | JIT, assertions on, hot reload | Development only |
| Profile | AOT with tracing kept | Performance measurement |
| Release | AOT, assertions stripped, minified | Everything you ship |
# Android — App Bundle is required for the Play Store
flutter build appbundle --release
# APK for direct distribution or testing
flutter build apk --release --split-per-abi
# iOS — then archive and upload from Xcode or via CI
flutter build ipa --release
# Web
flutter build web --releaseCompile-time configuration
Use --dart-define rather than checked-in config files. Values are compile-time constants, so tree shaking removes unused branches and nothing environment-specific sits in your repository.
class Env {
static const apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.dev.example.com',
);
static const environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
static const enableAnalytics = bool.fromEnvironment(
'ENABLE_ANALYTICS',
defaultValue: false,
);
static bool get isProduction => environment == 'production';
}flutter build appbundle --release \
--dart-define=API_URL=https://api.example.com \
--dart-define=ENVIRONMENT=production \
--dart-define=ENABLE_ANALYTICS=true
# Or keep them in a file, so CI and developers stay in sync
flutter build appbundle --release --dart-define-from-file=config/prod.jsonAndroid signing
keytool -genkey -v -keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias uploadstorePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=/Users/you/upload-keystore.jksval keystoreProperties = Properties().apply {
val file = rootProject.file("key.properties")
if (file.exists()) load(FileInputStream(file))
}
android {
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
}
}
}Flavours
Flavours let dev, staging and production apps coexist on one device with different names, icons and bundle ids.
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "FlutterLearn Dev")
}
create("staging") {
dimension = "env"
applicationIdSuffix = ".staging"
resValue("string", "app_name", "FlutterLearn Staging")
}
create("prod") {
dimension = "env"
resValue("string", "app_name", "FlutterLearn")
}
}flutter run --flavor dev --dart-define-from-file=config/dev.json
flutter build appbundle --flavor prod --dart-define-from-file=config/prod.jsonOn iOS the equivalent is a scheme plus a build configuration per flavour, with an xcconfig file supplying the bundle id and display name.
Size and obfuscation
# Obfuscate Dart symbols and keep the mapping for crash de-symbolication
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols/prod
# Find out what is actually large
flutter build apk --release --analyze-size- Keep the symbol files. Without them, obfuscated crash reports are unreadable. Archive them per release alongside the artifact.
- Audit assets. Uncompressed PNGs and bundled fonts are usually the largest contributors. Ship WebP, and subset fonts to the glyphs you use.
- Drop unused dependencies. Every package adds Dart code and sometimes native libraries.
- Use `--split-per-abi` for APKs so each device downloads only its architecture. App Bundles handle this automatically.
CI with GitHub Actions
name: Release
on:
push:
tags: ['v*']
jobs:
android:
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: '3.35.0'
cache: true
- run: flutter pub get
- run: flutter analyze
- run: flutter test --coverage
# Reconstruct the keystore from a base64 secret
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/upload.jks
cat > android/key.properties <<EOF
storePassword=${{ secrets.STORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
keyAlias=upload
storeFile=${{ github.workspace }}/android/upload.jks
EOF
- name: Build App Bundle
run: |
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols \
--dart-define-from-file=config/prod.json
- uses: actions/upload-artifact@v4
with:
name: release-bundle
path: |
build/app/outputs/bundle/release/app-release.aab
build/symbols
- name: Upload to Play Store (internal track)
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: dev.flutterlearn.app
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: internalVersioning and crash reporting
# version: <semantic version>+<build number>
# The build number must increase with every store upload
version: 1.4.2+58# Override at build time from CI, e.g. with the run number
flutter build appbundle --build-name=1.4.2 --build-number=${{ github.run_number }}Finally, wire crash reporting before you ship, not after your first bad release. Catch both Flutter framework errors and raw platform errors, and upload the symbol files from your obfuscated build so stack traces are readable.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// Errors from the Flutter framework
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
// Errors from the underlying platform / async zones
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}Key takeaways
- Ship release builds only; use
--dart-definefor environment config, never for secrets. - Back up your Android keystore somewhere you cannot lose it.
- Obfuscate with
--split-debug-infoand archive the symbols with each release. - Automate analyze → test → build → upload in CI so releases never depend on one machine.
Practice
Automate a release end to end
Set up dev/staging/prod flavours with separate bundle ids and API URLs, wire signing from CI secrets, and create a workflow that runs analyze and tests, builds an obfuscated App Bundle on a version tag, and uploads it to an internal track with the symbol files attached to the run.
Show hints
- Test the workflow on a throwaway tag before pointing it at your real listing.
- Store the keystore as a base64 secret and reconstruct it at build time.