An APK is a package a device installs directly; an AAB (Android App Bundle) is a publishing format from which Google Play builds a slimmer APK per device, and Play requires it for new apps. Every store build is signed: a keystore on Android, a certificate plus provisioning profile on iOS.
Android build: flutter build appbundle --release writes build/app/outputs/bundle/release/app-release.aab; signingConfigs.release in android/app/build.gradle.kts reads the keystore from a gitignored key.properties
Play App Signing: you sign with an upload key and Google re-signs with its own key, so a lost upload key can be reset
APK still has uses: sideloading, Firebase App Distribution, other stores; flutter build apk --split-per-abi keeps each file small
iOS build: a paid Apple Developer account, an App ID, a distribution certificate and an App Store provisioning profile (Xcode's automatic signing manages them on your Mac); flutter build ipa archives and exports the .ipa, which you upload with Transporter or fastlane
Before every upload: raise the build number; if you obfuscate (--obfuscate --split-debug-info), keep the symbol files so crash reports stay readable
FlutterSenior
CI/CD & DevOps
GitHub Actions, Fastlane, Firebase Distribution, flavors
Questions closed
0 / 19
- 0Review
- 5Asked often
Questions
version: 1.4.2+87 in pubspec.yaml holds two numbers: 1.4.2 is the version users see, and 87 is the build number, and the stores check it differently: Google Play requires versionCode higher than every previous upload; App Store Connect requires a higher build number for the same version.
Flutter maps them: to versionName/versionCode on Android and CFBundleShortVersionString/CFBundleVersion on iOS
The build number only goes up: take it from one source of truth, never from a person: one release workflow's github.run_number (it is per workflow and a re-run keeps it, so use run_number*10+run_attempt if re-runs upload), or the latest number read from the store with fastlane (latest_testflight_build_number) plus one
The version name follows semver intent: patch for fixes, minor for new features, major for changes users notice as breaking
Override at build time: flutter build appbundle --build-name=1.4.2 --build-number=$RUN_NUMBER instead of hand-editing pubspec.yaml before every release; a git tag such as v1.4.2 can drive the name
Classic mistake: a hotfix branch or a second workflow reuses an old build number and the upload is rejected; take the next number from the single counter
Trunk-based development with a release branch per store version: the hotfix branches from the tag of the build users have, gets the fix cherry-picked from main, and ships as a new patch version, while main keeps moving untouched.
Why not full GitFlow: develop, release and hotfix branches suit a few big releases a year; a team shipping every week or two pays for that ceremony in merges, and a long-lived develop drifts away from what is in the store
Tag every store build: v1.4.2 marks the exact commit of the binary that is live or in review; the hotfix starts there, or on the existing release/1.4 branch, never on main
**Fix on main first, then cherry-pick: the fix cannot go missing from the next release, the classic GitFlow slip when a hotfix is merged back late or not at all
Versions**: the hotfix becomes 1.4.3 and takes the next number from the same build counter as every other build
Unfinished work stays harmless: on main it sits behind flags; a const bool.fromEnvironment flag compiles the code out of release builds, and a Remote Config flag switches a shipped feature off without a release
Dart-only fix: if the release was built with Shorebird, a patch reaches users without review, but it is still built from the release branch, not from main
Flavors are build variants of one codebase: dev, staging and production compiled as separate apps that can sit side by side on one device, each with its own app id, name, icon, signing and backend.
Android: productFlavors inside a flavor dimension in android/app/build.gradle.kts; applicationIdSuffix gives each flavor its own id, and android/app/src/dev/google-services.json its own Firebase project
iOS: one Xcode scheme per flavor plus build configurations named Debug-dev, Release-dev and so on; bundle id, display name and GoogleService-Info.plist are chosen per configuration
Building: flutter run --flavor dev -t lib/main_dev.dart; the flavor name must exist on both platforms, or the build fails on the one that lacks it
The Dart side: appFlavor from package:flutter/services.dart tells you which flavor is running; endpoints and keys come from --dart-define-from-file, not from the flavor itself
Less hand work: flutter_flavorizr generates the Gradle and Xcode setup, and an asset entry in pubspec.yaml can list flavors: so it is bundled only into those builds
--flavor selects the native variant (app id, signing, Firebase files), while --dart-define and --dart-define-from-file compile constants into the Dart code. They are separate flags, so a command that sets one and forgets the other builds without a single error.
What Dart learns from the flavor: only its name, through appFlavor; no endpoint, no key
A missing define is silent: String.fromEnvironment('API_URL', defaultValue: ...) returns the default, usually someone's dev value, and the production build talks to staging
**Read it as const**: fromEnvironment is only guaranteed to work in a constant expression; a non-const call in an AOT release build may just return the default
Fail loudly: no defaultValue for required values, check bool.hasEnvironment at startup, and keep each flavor paired with its config file in one build script or CI matrix entry
Not a vault: defines are compiled into the binary and visible to anyone who unpacks it; use them for configuration, never for secrets
fastlane is a Ruby tool that wraps the long sequence of signing, building and uploading commands into named lanes in a Fastfile; fastlane beta then does the same thing on a laptop and on any CI machine.
The actions you will use: match for certificates and profiles, build_app (gym) for the .ipa, upload_to_testflight (pilot), upload_to_play_store (supply) for Play tracks, deliver for App Store metadata and review submission
Reproducible: a clean machine with the right secrets produces the same artifact, and the lanes live in the repo, reviewed like other code
Safer credentials: an App Store Connect API key instead of a person's Apple ID and 2FA, match in read-only mode on CI, setup_ci for a temporary keychain
Portable: the release logic is in the Fastfile, not in one vendor's YAML, so moving from Bitrise to GitHub Actions means changing a few lines
When to skip it: a small app shipping monthly is fine with flutter build plus a couple of actions; Codemagic and Xcode Cloud already cover signing and upload
Choose by who maintains the pipeline and where the iOS build runs: Codemagic and Bitrise give ready Mac machines and mobile steps, GitHub Actions gives full control next to the code, and Xcode Cloud covers only the Apple side.
GitHub Actions: workflows live in the repo, a huge marketplace, free minutes for public repos; you assemble signing, caching and fastlane yourself, and hosted macOS minutes cost several times more than Linux ones
Codemagic: built around Flutter: SDK versions preinstalled, automatic iOS signing through an App Store Connect API key, publishing to both stores from codemagic.yaml; less suited to non-mobile jobs
Bitrise: mobile-focused, with a step library and a visual editor; a good fit for teams shipping native and Flutter apps side by side
Xcode Cloud: Apple's own, tied into App Store Connect and TestFlight; Flutter works through a ci_scripts/ci_post_clone.sh that installs Flutter and runs flutter build ios --config-only, and Android still needs a second system
What decides: macOS cost and queue time, where secrets may live, what the rest of the company already uses, and how easy it is to leave; keeping release logic in fastlane or scripts makes the vendor replaceable
Signing material lives only in the CI secret store, is decoded into a temporary file for the build and deleted when the job ends; nothing signing-related is ever committed.
Android: keep the upload keystore Base64-encoded in a secret, decode it at build time, write key.properties from secrets and let signingConfigs.release read it
Play App Signing: CI holds only the upload key; Google keeps the app signing key, so a leaked or lost upload key can be reset instead of ending your ability to publish
iOS: you need a distribution certificate (.p12) and a provisioning profile; fastlane match keeps both encrypted in a private repo or bucket, and CI runs it read-only into a temporary keychain created by setup_ci
Store access: an App Store Connect API key and a Play service-account JSON, each with the narrowest role that can upload
Hygiene: secrets masked in logs, protected environments with reviewers for release jobs, never exposed to pull requests from forks, and rotated when someone with access leaves
On iOS the beta channel is TestFlight; on Android you choose between Firebase App Distribution for fast engineering builds and the Play Console's internal or closed testing tracks, which deliver through the store itself.
TestFlight: up to 100 internal testers with no review; up to 10,000 external testers after Apple's beta review; every build expires after 90 days
Firebase App Distribution: invite by email or group, testers install through the App Tester app or a link; the quickest path for ad-hoc builds from any branch
Play internal testing: the build comes from Google Play with the real signing key, so Play Integrity, in-app purchases and in-app updates behave as they will in production
Many teams use both: Firebase for every main build to engineers, Play internal and TestFlight for QA and product
Hygiene: release notes with the commit and branch, crashes tagged with the build number, and old builds expired so nobody tests last month's code
Both stores expose upload APIs, and CI reaches them with a machine credential: a Google Cloud service account for Play and an App Store Connect API key for Apple, never a person's login.
Google Play: a service account invited in the Play Console with release permissions; its JSON key is a CI secret, and upload_to_play_store or the Gradle Play Publisher plugin uploads the .aab
Tracks stage the release: internal, then closed or open testing, then production with a percentage rollout
App Store: an API key (.p8 plus key id and issuer id) replaces Apple ID, password and 2FA; upload_to_testflight sends the build, deliver handles metadata and review submission
Keep metadata in git: fastlane's metadata folders make release notes and screenshots reviewable and versioned with the code
Guardrails: CI uploads to internal or TestFlight automatically, the production promotion waits for a manual approval, and the artifact is kept so you promote the tested build instead of rebuilding it
Most rejections come from what the reviewer sees, not from Dart: a sign-in they cannot pass, permissions or data a plugin brought in undeclared, iOS metadata showing Android, or an app too thin to count. Checking the release build before each submission catches nearly all of them.
Why reviewers reject
No way in (Apple 2.1): a demo account in the review notes with its backend up; for SMS sign-in, a test phone number with a fixed code, which Firebase Auth supports
Plugin permissions: a plugin can add READ_MEDIA_IMAGES or QUERY_ALL_PACKAGES to the merged manifest, and Play wants a declaration and a core use for each; remove a stray one with tools:node="remove"
Purpose strings: an API a plugin links makes App Store Connect demand its NS…UsageDescription (ITMS-90683) even if you never call it, and 5.1.1(ii) wants a reason: "Scan receipts to fill in expenses", not "Camera access"
Data declarations: Play's Data safety form and Apple's privacy labels must cover what Firebase or an ads SDK collects, not only your code; sign-up also requires account deletion inside the app
The other platform: Android in iOS screenshots or text breaks 2.3.10; Google sign-in on iOS needs a privacy-friendly equivalent such as Sign in with Apple (4.8)
Too thin or too many: a webview_flutter wrapper around the website fails 4.2, and near-identical white-label flavors fail 4.3
Catching it first
iPad: the Flutter template targets iPad too (TARGETED_DEVICE_FAMILY = "1,2"), so the reviewer may test there; make the layout hold or drop iPad before the first release
Read what ships: the release build's merged manifest, and Generate Privacy Report on the archive in Xcode's Organizer
Early eyes: Play's pre-launch report on a testing track, TestFlight's lighter beta review for external testers, and a checklist in git beside the fastlane metadata
A release build is a different binary: AOT-compiled, asserts stripped, shrunk by R8, signed with another key and built from the release manifest. Each of those can break something debug never exercised, and flutter test never builds that binary.
**No INTERNET permission**: Flutter adds it to the debug and profile manifests only; without it in src/main/AndroidManifest.xml the release APK has no network
R8 shrinking: a class that a plugin reaches by reflection is removed unless proguard-rules.pro keeps it; the symptom is a release-only ClassNotFoundException or missing method
A different signing key: Google Sign-In, Firebase Auth and Maps check the SHA-1; register the upload key and the Play app signing key, not only the debug one
Debug-only code: side effects inside assert or behind kDebugMode disappear, and with --obfuscate logic built on runtimeType.toString() breaks
How to debug: reproduce with flutter run --release (or --profile to keep DevTools), read adb logcat or the Xcode console, and decode obfuscated traces with flutter symbolize and the saved --split-debug-info files
In CI: build the release artifact on every PR, install it on an emulator, check it launches and survives the main flow, and run integration tests in profile mode
The files that flutter pub get generates: ios/Flutter/Generated.xcconfig and .flutter-plugins-dependencies are gitignored, and the Podfile reads that xcconfig to find the Flutter SDK and the plugin pods.
The order: flutter pub get, then flutter build ios --config-only with the same mode, flavor and defines as the real build, and only then xcodebuild or fastlane's build_app
**What --config-only does**: rewrites the xcconfig for this build mode, flavor and set of defines, and runs pod install when the plugin set changed, without compiling anything
Skipping it: gives a build that succeeds but carries the wrong mode, the wrong flavor or last week's defines
**Cache ios/Pods by Podfile.lock and the Flutter version**: plugin pods point into the pub cache, and a stale Pods folder fails with "The sandbox is not in sync with the Podfile.lock"
Pin Xcode explicitly: hosted macOS images carry several versions and change the default between image updates
**Commit Podfile.lock**: never Pods/, Generated.xcconfig or flutter_export_environment.sh
Split it in two: a fast CI lane on every pull request that proves the code is correct, and a CD lane on main or a version tag that builds signed artifacts and ships them to testers and then the stores.
On every pull request (Linux)
flutter pub get --enforce-lockfile, dart format --set-exit-if-changed ., flutter analyze --fatal-infos, flutter test --coverage
Rules from packages built on custom_lint are not run by flutter analyze; if the project uses one, add dart run custom_lint as its own step
If generated files are not committed, run build_runner first; cancel superseded runs with a concurrency group
On main or a tag
Build the .aab and .ipa with the right flavor, --dart-define-from-file, a CI build number and --obfuscate --split-debug-info; upload symbols to the crash reporter
Ship to Firebase App Distribution or TestFlight and the Play internal track automatically; promotion to production waits for a manual approval
Runners and reproducibility
Linux for everything that does not need Xcode; macOS only for iOS builds, because it is slower and costs several times more per minute
Pin the Flutter SDK with flutter-version-file: pubspec.yaml (an exact flutter: version under environment) and pin Xcode, so CI never changes underneath you
Signing keys and store credentials only in protected environments, never visible to pull requests from forks
A release-ready app can be built and signed reproducibly from CI, tells you when it breaks in the field, can be forced to update, has risky features that can be switched off remotely, and meets each store's current technical and privacy rules.
Build and identity
A signed release from CI with a build number that only goes up; flavors keep staging and production apart
--obfuscate --split-debug-info, with the Dart symbols and iOS dSYMs uploaded to Crashlytics or Sentry, or release stack traces are unreadable
Icon, splash screen, display name and permission texts (NSCameraUsageDescription and the like) checked for the production flavor
Visibility in the field
Crash reporting wired to both FlutterError.onError and PlatformDispatcher.instance.onError
Analytics on the key funnel and performance monitoring for startup time and slow frames
Logs free of tokens and personal data
Control after launch
Remote feature flags or kill switches for risky features
A minimum supported version checked at startup, so you can force an update
A rollout plan: Play percentage rollout, App Store phased release, and who watches the crash-free rate
Store rules
Google Play: the target API level Play currently requires (raised every year), support for 16 KB memory pages for apps targeting Android 15+, the Data safety form
App Store: the minimum Xcode and SDK Apple accepts for uploads, a PrivacyInfo.xcprivacy privacy manifest (plugins ship their own), App Privacy labels, in-app account deletion if users can create accounts
Both: a privacy policy URL, and review notes with a demo account
Read the per-step timings before changing anything. In almost every slow Flutter pipeline the answer is cold caches plus a macOS runner doing work a Linux runner could do.
Caching first: the Flutter SDK, the pub cache keyed on pubspec.lock, Gradle on the Gradle files, CocoaPods on Podfile.lock; a key that contains the branch name misses on every new branch, so check the hit rate
Separate the lanes: format, analyze and unit tests on Linux for every PR; platform builds are slow and macOS minutes cost several times more, so run them on main, tags or a label
Parallelise: a job matrix instead of one long script, the unit suite sharded with --total-shards and --shard-index, golden and integration tests in their own jobs
Fail fast: run format and analyze once instead of in every shard, and cancel superseded runs with a concurrency group
Cut the waste: never flutter clean in CI, it throws away what you just restored; run code generation only when its inputs changed or commit its output
A staged rollout limits how many users a bad build can reach, and a prepared response plan lets you stop the damage without waiting for store review.
Rolling out
Google Play: the production track ramps by percentage (for example 1%, 5%, 20%, 50%, 100%), with a day or more at each step; you can halt and resume at any point
App Store: phased release spreads automatic updates over 7 days and can be paused; users can still update manually, so it limits reach rather than blocking it
Watch the signals: crash-free users against the previous version, the critical funnels (sign-in, checkout), backend error rates and new reviews; decide in advance who watches and what number stops the rollout
When it is broken, cheapest move first
Turn it off remotely: a feature flag or kill switch disables the broken path in seconds, with no build and no review; this is why risky features ship behind a flag
Fix it on the server: often smaller than the client fix, and it reaches every installed version at once
Halt the rollout: stops new users from getting the build, but does nothing for those who already have it
Ship a fix: a Dart-only patch through code push if the release was built for it, otherwise a new build with a higher version number (stores do not let you roll back to an older binary); ask Apple for an expedited review for genuine breakage
Force an update: a minimum supported version on the server as the last resort for a build that corrupts data or breaks the API
Shorebird ships new Dart code to an app already in the store: shorebird release builds the store version, shorebird patch fixes it, applied on the next launch. Native code, plugins and Flutter upgrades need a store release; there a minimum version checked on the server forces an update.
How it works
The store build must come from Shorebird, which ships a modified Flutter engine; a build made with plain flutter build cannot be patched
A patch targets one release version, so each release you support gets its own patch
On iOS, changed functions run in an interpreter to satisfy App Store rules, while unchanged code stays AOT-compiled; heavily patched hot paths can get slower
By default patches apply on the next restart; the shorebird_code_push package lets the app check for and download a patch itself
Limits
Store rules allow bug fixes, not changing what the app is; anything big still goes through review
It is a third-party service in your release path: plan for its pricing, its availability and how you roll a bad patch back
Forcing an update
Keep a minimum supported version on the server (Remote Config or your own API) and check it at startup; below it, show a blocking screen with a store link
On Android, Play's in-app updates have an immediate mode (the in_app_update package); iOS has no equivalent, so the server check is the mechanism
Keep the API backward compatible for every version above the minimum, so a force is the exception
Filter by the dependency graph, not by the folder someone touched: Melos can select the packages changed since a git ref plus every package that depends on them, and CI analyzes and tests only that set.
The filter: diff: origin/main with includeDependents: true; without dependents, a breaking change in core is tested by nobody, because core itself still passes
The workspace: since Dart 3.6 a pub workspace (workspace: at the root, resolution: workspace in each package) gives one root pubspec.lock, which is also your cache key; Melos 7 reads its config from the root pubspec.yaml under melos:
**fetch-depth: 0**: actions/checkout clones one commit by default, so the diff has no base and either fails or degrades into running everything
Changes outside any package: the root lockfile, the root pubspec.yaml and the workflow file affect everything, so detect them and fall back to the full run
The graph must be honest: a package imported without being declared in pubspec.yaml is invisible to the filter and will be skipped