Flutter Build and Release Management for iOS and Android
Master Flutter build and release pipelines: version mapping, multi-flavor configurations, headless CI signing with Fastlane match, R8 shrinking, and phased store rollouts.

Flutter Build and Release Management for iOS and Android
Cross-platform frameworks offer an enticing architectural promise: write a unified UI application once in Dart, and compile it across operating systems. Yet when an engineering team transitions from local development to production delivery, the illusion of cross-platform uniformity quickly dissolves.
At the release boundary, mobile applications are governed by two fundamentally distinct, non-negotiable platform ecosystems:
- Apple's Darwin Ecosystem: Managed by Xcode, CocoaPods or Swift Package Manager, cryptographic code signing identities, mobile provisioning profiles, and App Store Connect API governance.
- Google's Android Ecosystem: Managed by Gradle, Java/Kotlin compilation toolchains, Java Keystores (
.jks), R8 code shrinking, Android App Bundles (.aab), and Google Play Developer Console APIs.
┌─────────────────────────────────┐
│ Unified Dart Codebase │
│ (pubspec.yaml, lib/, assets) │
└────────────────┬────────────────┘
│
┌────────────────▼────────────────┐
│ Continuous Integration (CI) │
│ Quality Gates & Unit Tests │
└────────┬───────────────┬────────┘
│ │
┌───────────────────────▼──┐ ┌──▼────────────────────────┐
│ Android Pipeline │ │ iOS Pipeline │
│ (Linux / macOS Runner) │ │ (macOS Runner) │
├──────────────────────────┤ ├───────────────────────────┤
│ Gradle Toolchain │ │ Xcode Toolchain │
│ Product Flavors │ │ Schemes & xcconfigs │
│ Java Keystore (.jks) │ │ Fastlane match & .p8 Key │
│ R8 Shrinking & Rules │ │ Mach-O Binary Compilation │
│ Android App Bundle (.aab)│ │ Signed App Archive (.ipa) │
│ ProGuard Mapping Capture │ │ dSYM Symbol Capture │
└───────────┬──────────────┘ └───────────┬───────────────┘
│ │
┌───────────▼──────────────┐ ┌───────────▼───────────────┐
│ Google Play Console │ │ Apple App Store Connect │
│ Internal -> Staged (1-100)│ │ TestFlight -> Phased (7-Day│
└──────────────────────────┘ └───────────────────────────┘
Treating mobile release engineering as an afterthought leads to common production failures: interactive keychain prompts freezing headless CI/CD runners, unreadable production crash logs caused by discarded symbol tables, misaligned version codes that block store uploads, and accidental regressions deployed to 100% of an active user base without a rollback mechanism.
Establishing a dependable release pipeline requires mastering versioning translations, multi-flavor isolation, headless credential management, binary optimization, and phased rollout governance.
1. Semantic Versioning Across Native Ecosystems
Flutter unifies application versioning within pubspec.yaml using a composite string format:
version: 2.4.1+158
This composite format combines two distinct values separated by a plus (+) symbol:
- Version Name (
2.4.1): A standard Semantic Versioning (major.minor.patch) string representing customer-facing application iterations. - Build Number (
158): A positive integer identifying the specific binary build iteration.
Native Platform Translation Mechanics
When the Flutter build tool compiles an application, it automatically parses pubspec.yaml and maps these values into platform-specific configuration manifests:
| Metric / Attribute | Flutter Source | Android Native Mapping | iOS Native Mapping |
| :--- | :--- | :--- | :--- |
| User-Facing Version | version prefix (2.4.1) | versionName in build.gradle | CFBundleShortVersionString in Info.plist |
| Store Build Counter | version suffix (158) | versionCode in build.gradle | CFBundleVersion in Info.plist |
| Data Type | String + Integer | Positive Integer (32-bit signed) | String (conventionally numeric) |
| Store Invariant | Must be parseable | Strictly monotonically increasing | Unique and increasing per version string |
On Android, versionCode must be an integer that strictly increments with each submission to the Google Play Console. Google Play will reject any upload whose versionCode is less than or equal to a previously submitted build on any track.
On iOS, CFBundleVersion must be unique and monotonically increasing for submissions within the same CFBundleShortVersionString. While Apple permits resetting CFBundleVersion when incrementing the major/minor marketing version (e.g., going from 2.4.1 (300) to 2.5.0 (1)), adopting a single, globally increasing integer across both platforms avoids cross-store synchronization errors.
Overriding Versions at Build Time
Hardcoding versions directly in source control introduces merge conflicts and requires manual commits for every release candidate. Instead, release pipelines should pass dynamic versioning parameters via the Flutter CLI:
flutter build appbundle \
--release \
--build-name="2.4.1" \
--build-number="158"
flutter build ipa \
--release \
--build-name="2.4.1" \
--build-number="158"
The CLI flags --build-name and --build-number override any values defined in pubspec.yaml without mutating the tracked repository file on disk.
Generating Monotonic Build Numbers in Headless CI
Because modern CI/CD runners (such as GitHub Actions or GitLab CI) operate in ephemeral virtual machines, they cannot rely on local file counters. Three primary strategies exist for generating monotonic build numbers:
Strategy A: Git Commit Depth
[Commit 1] ──> [Commit 2] ──> ... ──> [Commit 158] ──> Build Number = 158
(Fully deterministic, zero network dependency, requires linear git history)
Strategy B: CI Runner Pipeline Counter
Pipeline Execution #482 ──> Build Number = 482
(Monotonic per CI platform, loses continuity if switching CI vendors)
Strategy C: Store Query via Fastlane
Query App Store Connect / Play Console API ──> Highest Build = 157 ──> Build Number = 158
(Resilient across CI migrations, requires network call and store credentials before build)
- Git Commit Counter:
Trade-off: Purely deterministic and requires no external network calls or cloud infrastructure. However, it requires a disciplined, linear git history on the release branch; squashed merges or shallow git clones (BUILD_NUMBER=$(git rev-list --count HEAD)fetch-depth: 0is required) can skew the calculated count. - CI Pipeline Execution ID:
Using environment variables such as
$GITHUB_RUN_NUMBERor$CI_PIPELINE_IID. Trade-off: Guaranteed to increase monotonically on that platform. However, if the engineering organization migrates from GitHub Actions to GitLab or CircleCI, the pipeline counter resets to 1, causing immediate store rejection unless manually offset. - Live Store API Query:
Using Fastlane actions (
latest_testflight_build_numberorgoogle_play_track_version_codes) to fetch the current highest number in the store and add 1. Trade-off: Survives CI platform migrations and git history rewrites, but introduces an external API dependency prior to compilation.
2. Multi-Flavor and Multi-Environment Architecture
A production application must operate against isolated backend environments: development, staging, and production. Installing a staging build on a test device must never overwrite a production build or share its local SQLite database, secure keychain items, or cached preferences.
Achieving complete environment isolation requires two complementary mechanisms:
- Native Flavors: Native Android Product Flavors and iOS Xcode Schemes that alter the underlying OS bundle identifier (
applicationIdandCFBundleIdentifier), application icon, and platform display name. - Dart Compile-Time Defines: Injecting compile-time environment variables into the Dart runtime using
--dart-defineor--dart-define-from-file.
Android Gradle Configuration
In android/app/build.gradle, declare explicit flavorDimensions and define corresponding productFlavors:
android {
...
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
resValue "string", "app_name", "App (Dev)"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
resValue "string", "app_name", "App (Staging)"
}
prod {
dimension "environment"
resValue "string", "app_name", "App"
}
}
}
By specifying applicationIdSuffix, the resulting Android application identifiers become com.example.app.dev, com.example.app.staging, and com.example.app. The operating system treats each as an entirely independent application package, allowing all three to coexist on the same physical device simultaneously.
iOS Schemes and Configuration Layering
In iOS, Flutter relies on Xcode Schemes and Build Configurations. To mirror Android flavors, configure custom Build Configurations in Xcode matching the pattern [Mode]-[Flavor]:
Debug-dev,Profile-dev,Release-devDebug-staging,Profile-staging,Release-stagingDebug-prod,Profile-prod,Release-prod
Manage bundle identifiers cleanly by binding them to .xcconfig files rather than hardcoding values inside project.pbxproj:
// ios/Flutter/Release-dev.xcconfig
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release-dev.xcconfig"
#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.example.app.dev
APP_DISPLAY_NAME = App (Dev)
// ios/Flutter/Release-prod.xcconfig
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release-prod.xcconfig"
#include "Generated.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.example.app
APP_DISPLAY_NAME = App
In Xcode's Info.plist, set CFBundleDisplayName to $(APP_DISPLAY_NAME) and ensure CFBundleIdentifier uses $(PRODUCT_BUNDLE_IDENTIFIER).
Decoupled Runtime Configuration via --dart-define-from-file
Rather than passing dozens of command-line flags or bundling sensitive base URLs inside tracked Dart files, use --dart-define-from-file (supported in Flutter 3.7+) to pass JSON-formatted configuration payloads:
// config/prod.json
{
"ENVIRONMENT": "production",
"API_BASE_URL": "https://api.example.com/v1",
"ENABLE_VERBOSE_LOGGING": false,
"SENTRY_DSN": "https://pubkey@sentry.io/12345"
}
In the Flutter application, access these variables via compile-time constants:
class AppConfig {
static const String environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://dev-api.example.com/v1',
);
static const bool enableVerboseLogging = bool.fromEnvironment(
'ENABLE_VERBOSE_LOGGING',
defaultValue: false,
);
}
The unified CLI build invocation binds both native flavors and Dart configuration together:
flutter build appbundle \
--flavor prod \
--target lib/main_prod.dart \
--dart-define-from-file=config/prod.json \
--release
3. Automated Code Signing on Headless CI/CD
Code signing guarantees binary authenticity and integrity. On local developer workstations, Xcode and Android Studio rely on interactive GUI prompts to unlock system keychains or select cached development certificates. On headless CI/CD runners, interactive dialogues freeze the build pipeline indefinitely. Headless automation demands fully automated, non-interactive credential management.
Android Code Signing Automation
Android binaries are signed using a Java Keystore (.jks or PKCS12 format) containing a private RSA or ECDSA key and its corresponding public certificate.
To prevent security vulnerabilities:
- Never commit
.jksfiles or passwords to version control. - Store the keystore as a base64-encoded secret within CI infrastructure (
ANDROID_KEYSTORE_BASE64). - Inject signing credentials dynamically during CI execution.
Configure android/app/build.gradle to decouple file storage from credential consumption:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
...
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias'] ?: System.getenv('ANDROID_KEY_ALIAS')
keyPassword keystoreProperties['keyPassword'] ?: System.getenv('ANDROID_KEY_PASSWORD')
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : (System.getenv('ANDROID_KEYSTORE_PATH') ? file(System.getenv('ANDROID_KEYSTORE_PATH')) : null)
storePassword keystoreProperties['storePassword'] ?: System.getenv('ANDROID_STORE_PASSWORD')
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
Google Play App Signing Architecture
Modern Android deployment distinguishes between the Upload Key and the App Signing Key:
[ Developer CI Runner ]
│
│ Signs .aab with Developer Upload Key (.jks)
▼
[ Google Play Ingestion Gateway ]
│
├─► Verifies signature against registered Upload Certificate
├─► Strips Upload Signature
├─► Re-signs optimized APKs with Google-Managed App Signing Key
▼
[ End-User Device ]
With Google Play App Signing, Google securely manages the master signing key that end-user devices verify. The developer signs the generated .aab using an Upload Key. If an upload key is compromised or lost, the project administrator can reset it via the Google Play Console without permanently breaking update continuity for existing users.
iOS Code Signing with Fastlane match
iOS signing requires coordinating four distinct entities:
- Distribution Certificate (
.p12/.cer): The cryptographic identity issued by Apple. - Private Signing Key: The matching private key installed in a macOS Keychain.
- App ID: The explicit bundle identifier registered in the Apple Developer Portal.
- Provisioning Profile (
.mobileprovision): The cryptographically signed manifest linking the App ID, distribution certificate, entitlements, and target device UDIDs.
When multiple engineers and CI runners attempt to generate certificates individually, Apple's 2-distribution-certificate limit is quickly exceeded, revoking active developer profiles.
The industry standard solution is Fastlane match. match implements the GitOps model for iOS code signing: it stores encrypted certificates and provisioning profiles in a private Git repository or cloud storage bucket (AWS S3, Google Cloud Storage), decrypting them on demand using a shared symmetric passphrase (MATCH_PASSWORD).
┌────────────────────────────────┐
│ Private Encrypted Git / S3 │
│ (Certificates & Profiles) │
└──────────────┬─────────────────┘
│ Fastlane match (Decrypted via MATCH_PASSWORD)
▼
┌────────────────────────────────┐
│ Ephemeral macOS CI Runner │
│ 1. Create temporary keychain │
│ 2. Import private key (.p12) │
│ 3. Install .mobileprovision │
│ 4. Codesign Mach-O binary │
│ 5. Destroy temporary keychain │
└────────────────────────────────┘
App Store Connect API Authentication
Headless pipelines must never authenticate with Apple ID usernames and passwords, which require interactive Two-Factor Authentication (2FA) SMS or push confirmations. Instead, use official App Store Connect API Keys:
- Issuer ID: UUID identifying the organization.
- Key ID: 10-character alphanumeric identifier for the key.
- Private Key (
.p8): Elliptic Curve DSA private key downloaded from App Store Connect.
App Store Connect API keys generate short-lived JSON Web Tokens (JWTs) that authenticate all profile downloads, binary uploads, and metadata operations without human interaction.
4. Compilation Pipelines, Shrinking, and Symbolication
Generating a release artifact is not simply a matter of stripping debug flags. In production, binaries must be compiled ahead-of-time (AOT), minimized to reduce over-the-network download size, obfuscated to resist decompilation, and linked to external symbol tables to ensure crashes remain diagnosable.
Android: App Bundles, R8, and ProGuard
Standard APKs package universal assets and native libraries across all CPU architectures (armeabi-v7a, arm64-v8a, x86_64), leading to unnecessarily large downloads. Android App Bundles (.aab) solve this by deferring final APK packaging to Google Play's Dynamic Delivery system:
flutter build appbundle --flavor prod --release
Google Play parses the App Bundle and serves customized "split APKs" containing only the exact CPU architecture, screen density, and language pack required by the user's specific hardware.
R8 Code and Resource Shrinking
R8 runs during the Gradle release compilation step, performing three critical functions:
- Tree Shaking / Dead Code Elimination: Inspects the Java/Kotlin bytecode and discards unreachable classes, methods, and fields.
- Resource Shrinking: Removes unused drawables, layouts, and assets from the merged native package when
shrinkResources trueis enabled. - Name Minification: Renames classes and attributes to shortened, non-descriptive identifiers (e.g.,
com.example.NetworkManagerbecomesa.b.c).
Because native reflection can hide method usage from static analysis, configure android/app/proguard-rules.pro to keep Flutter framework internals and data models serialized across platform channels:
# Keep Flutter Engine and Plugins
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Preserve Line Numbers and Source Attributes for Crashlytics/Sentry
-keepattributes SourceFile,LineNumberTable
-keepattributes *Annotation*
iOS: IPA Generation and the Deprecation of Bitcode
On iOS, compile production releases using the ipa subcommand:
flutter build ipa --flavor prod --release
This compiles Dart code into native AOT Mach-O machine code for arm64, invokes Xcode's xcodebuild -exportArchive pipeline, and packages the verified payload into a distributable .ipa file inside build/ios/ipa/.
[!NOTE] Bitcode Deprecation: Apple officially deprecated Bitcode in Xcode 14 and completely removed Bitcode support in Xcode 15+. App Store submissions containing Bitcode are now rejected. Modern Flutter release pipelines compile directly to final machine binaries without intermediary LLVM bitcode representation.
Dart Code Obfuscation and Symbol Management
While native R8 obfuscates Java/Kotlin platform code, it does not touch the compiled Dart AOT machine code (libapp.so on Android or the App.framework Mach-O binary on iOS). By default, compiled Dart binaries preserve class names, function names, and file path strings.
To protect intellectual property and minimize binary footprint, compile with Dart obfuscation enabled:
flutter build appbundle \
--release \
--obfuscate \
--split-debug-info=build/app/outputs/symbols
flutter build ipa \
--release \
--obfuscate \
--split-debug-info=build/ios/outputs/symbols
The Symbolication Requirement
When --obfuscate is applied, Dart replaces all human-readable identifiers with obfuscated offsets. If an unhandled exception occurs in production, the resulting crash trace is unintelligible:
# Unsymbolicated Crash Trace (Unusable in Production)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
pid: 14821, tid: 14890, name: 1.ui
backtrace:
#00 pc 00000000004a2bc4 libapp.so
#01 pc 00000000004a3120 libapp.so
#02 pc 00000000002b1f88 libapp.so
To resolve these offsets into meaningful stack traces, the crash reporting infrastructure (such as Sentry or Firebase Crashlytics) requires the debug symbol files generated by --split-debug-info:
- Android:
app.android-arm64.symbolsand R8'smapping.txt(located atbuild/app/outputs/mapping/prodRelease/mapping.txt). - iOS:
app.ios-arm64.symbolsand Xcode'sdSYMdirectory bundles (located inside the.xcarchive).
┌─────────────────────────────────────────────────────────────┐
│ Obfuscated Crash: libapp.so (0x004a2bc4) │
└──────────────────────────────┬──────────────────────────────┘
│
▼ Uploaded during CI Build
┌─────────────────────────────────────────────────────────────┐
│ Sentry / Crashlytics Symbol Engine │
│ Match against: app.android-arm64.symbols + mapping.txt │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Symbolicated Stack Trace: │
│ PaymentRepository.submitOrder (payment_repo.dart:142) │
└─────────────────────────────────────────────────────────────┘
A CI release pipeline must automatically archive these symbol artifacts and upload them to the telemetry provider as part of the build step. Once a release pipeline tears down its runner, discarded symbol tables can never be reconstructed.
5. Distribution Pipelines and Phased Rollouts
Deploying a mobile update differs fundamentally from deploying a backend web service. You cannot instantly roll back a broken client binary. Once a user downloads an update, that code runs on their device until they choose to update again.
Consequently, release distribution must be phased, instrumented, and backed by runtime emergency controls.
Google Play Console Track Progression
Google Play structures binary promotion through an escalating hierarchy of testing tracks:
[ Internal App Sharing ] ──► Zero review delay; fast sharing for QA engineers.
│
▼
[ Internal Testing Track ] ──► Up to 100 testers; instantaneous rollout without full review.
│
▼
[ Closed Testing (Alpha) ] ──► Controlled organizational groups; formal store review.
│
▼
[ Open Testing (Beta) ] ──► Public opting-in user base; telemetry under real-world usage.
│
▼
[ Production Staged Rollout ]
Day 1: 1% ──► Monitor Crash Rate & ANR (Android Vitals)
Day 2: 5% ──► Check backend API load & connection pools
Day 3: 20% ──► Review support tickets and error trends
Day 4: 50% ──► Verify payment gateway metrics
Day 5: 100% (Complete Rollout)
Google Play permits halting a staged rollout at any percentage. If a critical regression spikes crash rates or corrupts user databases, halting the rollout immediately freezes distribution: existing users who downloaded the update remain on it, but no new users receive the faulty build while the engineering team prepares a hotfix.
Apple App Store TestFlight and Phased Releases
Apple enforces a two-tier beta model followed by an automatic 7-day rollout schedule:
- TestFlight Internal Testing: Up to 100 internal team members. Builds become available immediately upon processing without requiring Beta App Review.
- TestFlight External Testing: Up to 10,000 external testers via email or public link. The first build of a new minor/major version requires Beta App Review.
- App Store Phased Release for Automatic Updates:
When releasing to production, enabling Phased Release rolls out the update automatically over seven consecutive days:
- Day 1: 1%
- Day 2: 2%
- Day 3: 5%
- Day 4: 10%
- Day 5: 20%
- Day 6: 50%
- Day 7: 100%
Users who manually trigger an update in the App Store receive the build immediately, but automatic background app updates respect the phased percentage. The phased release can be paused for up to 30 days total if an issue arises.
Emergency Governance: Roll Forward vs Kill Switches
Because app store binary updates require re-signing and store review, the mean time to remediate (MTTR) a mobile failure ranges from hours to days. Production architectures must incorporate two defensive mechanisms:
┌────────────────────────────┐
│ App Launch Event │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ GET /v1/app/version-gate │
│ Client Version: 2.4.1 │
└─────────────┬──────────────┘
│
┌───────────────────────┴───────────────────────┐
│ │
▼ ▼
[ Version >= MinSupported ] [ Version < MinSupported ]
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Continue Normal Execution │ │ Present Modal Block Dialog │
│ Check Feature Flags │ │ "Update Required to Proceed"│
│ (Kill Switch Inactive) │ │ [ Redirect to App Store ] │
└─────────────────────────────┘ └─────────────────────────────┘
- Server-Driven Minimum Supported Version:
During the initial handshake or startup sequence, the application queries a lightweight endpoint (or Firebase Remote Config) returning the
minimum_supported_version. If the client falls below this floor, it displays a non-dismissible modal directing the user to the store listing. When an API-breaking change or severe data corruption bug enters the field, this gate halts old clients from corrupting backend state. - Remote Feature Flags / Kill Switches: New features should be wrapped in remote boolean flags rather than hardcoded directly to active execution paths. If a new checkout flow crashes on specific device models, flipping the remote toggle immediately restores the legacy execution path without requiring an emergency store submission.
6. End-to-End Automation with Fastlane and GitHub Actions
Fastlane bridges Flutter CLI tools and native store APIs, packaging complex build and distribution steps into reproducible, declarative lanes.
Production Fastfile Architecture
Create ios/fastlane/Fastfile and android/fastlane/Fastfile (or a unified root Fastfile):
# ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build and deploy release candidate to TestFlight"
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
key_content: ENV["APP_STORE_CONNECT_KEY_CONTENT"],
is_key_content_base64: true
)
# Prepare temporary keychain on headless CI
setup_ci if is_ci
# Synchronize code signing identities via Fastlane match
match(
type: "appstore",
readonly: is_ci,
api_key: api_key
)
# Determine build number from Git
build_num = sh("git rev-list --count HEAD").strip
version_name = "2.4.1"
# Compile obfuscated IPA via Flutter CLI
sh(
"flutter build ipa " \
"--flavor prod " \
"--target lib/main_prod.dart " \
"--build-name=#{version_name} " \
"--build-number=#{build_num} " \
"--obfuscate " \
"--split-debug-info=../../build/ios/outputs/symbols " \
"--release"
)
# Upload binary to TestFlight
upload_to_testflight(
api_key: api_key,
ipa: "../build/ios/ipa/Runner.ipa",
skip_waiting_for_build_processing: true
)
# Upload dSYM debug symbols to Sentry
sentry_upload_dif(
path: "../build/ios/archive/Runner.xcarchive/dSYMs",
auth_token: ENV["SENTRY_AUTH_TOKEN"],
org_slug: "example-org",
project_slug: "mobile-ios"
)
end
end
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Build and deploy release candidate to Google Play Internal Track"
lane :internal do
build_num = sh("git rev-list --count HEAD").strip
version_name = "2.4.1"
# Compile obfuscated App Bundle via Flutter CLI
sh(
"flutter build appbundle " \
"--flavor prod " \
"--target lib/main_prod.dart " \
"--build-name=#{version_name} " \
"--build-number=#{build_num} " \
"--obfuscate " \
"--split-debug-info=../../build/app/outputs/symbols " \
"--release"
)
# Distribute to Google Play Internal Testing
upload_to_play_store(
track: "internal",
package_name: "com.example.app",
aab: "../build/app/outputs/bundle/prodRelease/app-prod-release.aab",
json_key_data: ENV["PLAY_STORE_JSON_KEY"],
mapping_paths: ["../build/app/outputs/mapping/prodRelease/mapping.txt"]
)
end
end
GitHub Actions CI/CD Pipeline
The following workflow coordinates testing, environment decodes, parallel platform compilation, and store publishing:
name: Mobile Build & Release Pipeline
on:
push:
tags:
- 'v*.*.*'
jobs:
test:
name: Shared Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
- run: flutter pub get
- run: flutter test
build-android:
name: Build & Deploy Android
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history required for git rev-list build counter
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
- name: Decode Android Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Run Fastlane Android
working-directory: android
env:
ANDROID_KEYSTORE_PATH: "upload-keystore.jks"
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_JSON_KEY }}
run: bundle exec fastlane internal
build-ios:
name: Build & Deploy iOS
needs: test
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
- name: Run Fastlane iOS
working-directory: ios
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_GIT_PRIVATE_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
bundle install
bundle exec fastlane beta
7. Common Release Failures and Root-Cause Mitigations
Mobile build pipelines encounter failure modes unique to compiled, signed client binaries. The table below catalogs real-world release breakdowns and their technical mitigations:
| Failure Mode | Observable Symptom | Root Cause | Engineering Mitigation |
| :--- | :--- | :--- | :--- |
| Keystore Password Mismatch | Keystore was tampered with, or password was incorrect | Environment variable encoding corrupted special characters during CI injection. | Base64-encode credentials in CI secrets, decode directly to disk, and verify keystore via keytool -list -v in a staging step. |
| macOS Keychain Freeze | CI runner exceeds execution timeout (60+ min) on codesign. | The system keychain presented an interactive GUI permission prompt on a headless runner. | Invoke Fastlane setup_ci or manually execute security set-key-partition-list -S apple-tool:,apple: -s -k "$PWD" on the temporary keychain. |
| Play Store versionCode Collision | Version code 142 has already been used for package com.example.app. | Two branches were cut from the same commit depth, generating identical git rev-list counts. | Enforce release tags or query the store API dynamically (google_play_track_version_codes) before triggering the build. |
| Unusable Crash Logs (Raw Hex) | Crashlytics reports libapp.so (0x002f1a) without method names or line numbers. | Binary was compiled with --obfuscate, but debug symbol artifacts were not uploaded to telemetry servers. | Automate symbol extraction (sentry-cli or Firebase Crashlytics CLI) inside the build lane and verify upload before runner teardown. |
| Runtime ClassNotFound Crash | App crashes immediately on launch only in --release mode. | R8 code shrinking stripped classes parsed dynamically via native reflection or JNI. | Add explicit -keep class com.example.model.** { *; } annotations to android/app/proguard-rules.pro. |
| Missing Privacy Manifest (iOS) | Apple rejects upload with ITMS-91053: Missing API declaration. | Third-party CocoaPods or Flutter plugins query standard APIs (UserDefaults, disk space) without a privacy manifest. | Audit dependencies using flutter pub outdated, update to versions containing PrivacyInfo.xcprivacy, and inspect the compiled app bundle. |
8. Production Release Verification Checklist
Before promoting a release candidate from development to production store tracks, execute this systematic verification checklist:
Pre-Build Metadata & Versioning
- [ ]
pubspec.yamlsemantic version (versionName/CFBundleShortVersionString) adheres to standard SemVer. - [ ] Monotonic build number (
versionCode/CFBundleVersion) is verified to exceed the current live store versions on all active tracks. - [ ]
--dart-define-from-filepoints to verified, non-development configuration JSON files. - [ ] Release branch git working tree is clean and tagged with the exact release semver (
vX.Y.Z).
Signing & Credential Hygiene
- [ ] Android upload keystore is decoded and loaded non-interactively without committing secrets to version control.
- [ ] Google Play App Signing is configured; upload key matches the public certificate registered in Play Console.
- [ ] App Store Connect API keys (
.p8) authenticate without interactive 2FA prompts. - [ ] Fastlane
matchsuccessfully provisions active App Store distribution profiles in read-only mode (readonly: true).
Optimization & Binary Shrinking
- [ ] Android builds output an Android App Bundle (
.aab) rather than a monolithic APK. - [ ] R8
minifyEnabledandshrinkResourcesare enabled;proguard-rules.proretains all necessary reflection classes and plugin interfaces. - [ ] Dart obfuscation is active (
--obfuscate) with--split-debug-infooutputting symbols to an isolated directory. - [ ] Bitcode is completely omitted from iOS export configurations (mandated by Xcode 15+).
Crash Telemetry & De-obfuscation
- [ ] Android ProGuard
mapping.txtis verified and uploaded to Crashlytics or Sentry. - [ ] iOS
.dSYMarchive is located and uploaded to the telemetry provider. - [ ] Dart symbol maps (
app.android-arm64.symbolsandapp.ios-arm64.symbols) are archived in release storage. - [ ] A synthetic staging crash verifies that real-time stack traces de-obfuscate to exact source file lines.
Store Governance & Rollout Controls
- [ ] Google Play production rollout is initiated as a Staged Rollout starting at 1%–5%, with Android Vitals crash thresholds monitored.
- [ ] Apple App Store is configured for Phased Release for Automatic Updates (7-day schedule).
- [ ] Minimum supported version check (
/v1/app/version-gate) is verified against the backend to ensure force-upgrade controls are active if required. - [ ] Remote feature flags and kill switches are confirmed accessible and verified in production staging.
Conclusion
Mobile release engineering is the architectural bridge between clean application source code and a reliable production application. A shared Flutter codebase eliminates redundant UI logic across platforms, but it cannot abstract away the operating system contracts governing iOS and Android execution.
By standardizing semantic version mappings, isolating environments with native flavors and compile-time defines, securing headless CI signing via Fastlane match and App Store Connect API keys, shrinking binaries with R8 and Dart obfuscation, and deploying through instrumented phased rollouts, engineering teams transform mobile releases from an unpredictable manual bottleneck into a deterministic, reproducible, and resilient software delivery pipeline.


