Flutter CI/CD: Automated Build, Test, and Release Pipeline
# Flutter CI/CD: Automated Build, Test, and Release Pipeline
Shipping a Flutter app manually is error-prone, slow, and stressful. A well-designed CI/CD pipeline removes the human bottleneck from your release process and lets you focus on writing code instead of babysitting builds. In this article I walk through the full pipeline I use in production — from linting to store deployment — with real configuration examples you can adapt today.
Why CI/CD Matters for Flutter
Flutter targets multiple platforms from a single codebase. That means every release potentially involves an Android APK/AAB, an iOS IPA, a web build, and desktop artifacts. Doing this by hand is not just tedious — it is a reliability risk. A CI/CD pipeline gives you:
Pipeline Essentials
A solid Flutter CI/CD pipeline has four stages:
Each stage acts as a gate. If any step fails, the pipeline stops and the team is notified immediately.
GitHub Actions Workflow
GitHub Actions is my go-to for Flutter CI/CD. Here is a production-ready workflow that covers analysis, testing, and building for both platforms:
name: Flutter CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
FLUTTER_VERSION: "3.24.0"
JAVA_VERSION: "17"
jobs:
analyze-and-test:
name: Analyze & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Check formatting
run: dart format --set-exit-if-changed .
- name: Run analyzer
run: flutter analyze --fatal-infos
- name: Run tests with coverage
run: flutter test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage/lcov.info
build-android:
name: Build Android
needs: analyze-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Decode keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$KEYSTORE_BASE64" | base64 --decode > android/app/release.keystore
- name: Create key.properties
env:
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
run: |
cat > android/key.properties <<EOF
storePassword=$STORE_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=release.keystore
EOF
- name: Build App Bundle
run: flutter build appbundle --release
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: android-release
path: build/app/outputs/bundle/release/*.aab
build-ios:
name: Build iOS
needs: analyze-and-test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install CocoaPods
run: cd ios && pod install
- name: Import signing certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
run: |
echo "$P12_BASE64" | base64 --decode > certificate.p12
security create-keychain -p "" build.keychain
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
security list-keychains -d user -s build.keychain
- name: Install provisioning profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
echo "$PROFILE_BASE64" | base64 --decode > profile.mobileprovision
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
- name: Build IPA
run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ios-release
path: build/ios/ipa/*.ipaIn my release pipelines, I always pin the Flutter version explicitly. Relying on "latest" is a recipe for surprise breakages on a Monday morning.
Codemagic Configuration
Codemagic is a Flutter-first CI/CD service that handles code signing and store deployment with minimal setup. Here is a `codemagic.yaml` configuration:
workflows:
production-release:
name: Production Release
max_build_duration: 60
instance_type: mac_mini_m2
environment:
flutter: 3.24.0
xcode: latest
groups:
- google_play_credentials
- app_store_credentials
- code_signing_ios
vars:
APP_ID: com.example.myflutterapp
triggering:
events:
- tag
tag_patterns:
- pattern: "v*"
include: true
scripts:
- name: Install dependencies
script: flutter pub get
- name: Run analysis
script: flutter analyze --fatal-infos
- name: Run tests
script: flutter test
- name: Set up code signing (iOS)
script: |
keychain initialize
app-store-connect fetch-signing-files "$APP_ID" \
--type IOS_APP_STORE \
--create
keychain add-certificates
- name: Build iOS
script: |
flutter build ipa --release \
--build-number=$PROJECT_BUILD_NUMBER \
--export-options-plist=/Users/builder/export_options.plist
- name: Build Android
script: |
flutter build appbundle --release \
--build-number=$PROJECT_BUILD_NUMBER
artifacts:
- build/ios/ipa/*.ipa
- build/app/outputs/bundle/release/*.aab
publishing:
app_store_connect:
auth: integration
submit_to_testflight: true
google_play:
credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS
track: internalThe tag-based triggering pattern is something I strongly recommend. Pushing a `v1.2.0` tag should be the only manual step in your release process.
Fastlane Integration
Fastlane handles the "last mile" of CI/CD — code signing, screenshots, metadata, and store uploads. Even if you use GitHub Actions or Codemagic for building, Fastlane is invaluable for managing store submissions.
Android Fastfile
default_platform(:android)
platform :android do
desc "Deploy to Google Play internal track"
lane :internal do
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["GOOGLE_PLAY_JSON_KEY"],
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
desc "Promote internal to production"
lane :promote_to_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
json_key_data: ENV["GOOGLE_PLAY_JSON_KEY"],
skip_upload_changelogs: false
)
end
endiOS Fastfile
default_platform(:ios)
platform :ios do
desc "Push a new release to TestFlight"
lane :beta do
setup_ci if ENV["CI"]
match(
type: "appstore",
app_identifier: "com.example.myflutterapp",
readonly: is_ci
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_testflight(
skip_waiting_for_build_processing: true
)
end
desc "Push to App Store"
lane :release do
deliver(
submit_for_review: true,
automatic_release: false,
force: true,
skip_metadata: false,
skip_screenshots: true
)
end
endIn my release pipelines, I always use `match` for iOS code signing because it keeps certificates in a private Git repository that the whole team can access consistently.
Code Signing for iOS and Android
Code signing is where most CI/CD setups break. Getting it right from the start saves weeks of debugging later.
iOS Code Signing
There are two main approaches:
Manual approach: Export your `.p12` certificate and provisioning profile, base64-encode them, store them as CI secrets, and decode them during the build. This is what the GitHub Actions workflow above demonstrates.
Fastlane Match: Match stores your certificates and profiles in an encrypted Git repository. Every team member and CI runner pulls from the same source of truth. No more "works on my machine" signing issues.
# Initialize match for the first time
fastlane match init
# Generate new certificates
fastlane match appstore
fastlane match developmentKey things to remember:
Android Code Signing
Android signing is simpler but still requires care:
"code-comment">// android/app/build.gradle
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}In my release pipelines, I always store a backup of the Android keystore in a separate, encrypted location. Losing a keystore is permanent — Google does not let you recover from it.
Release Strategy
Shipping code is only half the story. You need a strategy that gives you control, visibility, and a rollback plan.
Semantic Versioning
Adopt semantic versioning (`MAJOR.MINOR.PATCH`) and make it part of your workflow:
In Flutter, the version string lives in `pubspec.yaml`:
version: 2.4.1+42
# ^ semver ^ build numberThe build number after `+` must increment with every store submission. I automate this in CI by using the pipeline run number:
flutter build appbundle --build-number=$GITHUB_RUN_NUMBERAutomated Changelogs
Generate changelogs from your commit messages. This requires discipline in your commit conventions:
# Install conventional-changelog
npm install -g conventional-changelog-cli
# Generate CHANGELOG.md
conventional-changelog -p angular -i CHANGELOG.md -sIf your team uses conventional commits (`feat:`, `fix:`, `chore:`), this becomes fully automatic.
Beta Distribution
Never ship directly to production. Use a staged rollout:
For iOS, TestFlight serves as your beta channel. For Android, use Google Play's internal and closed testing tracks. Firebase App Distribution is an excellent alternative for both platforms when you want faster feedback loops without store review delays.
Rollback Plan
Every release should have an escape hatch. In practice this means:
Common CI/CD Mistakes
I have seen these mistakes repeatedly — in my own projects and in teams I have worked with.
1. Not Caching Dependencies
Flutter downloads Dart packages, Gradle dependencies, CocoaPods, and sometimes even the Flutter SDK itself. Without caching, every build wastes 5-15 minutes on downloads.
# GitHub Actions caching example
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
cache: true # Caches the Flutter SDK
- uses: actions/cache@v4
with:
path: |
~/.pub-cache
~/.gradle/caches
ios/Pods
key: ${{ runner.os }}-deps-${{ hashFiles('**/pubspec.lock', '**/Podfile.lock') }}2. Running iOS Builds on Linux Runners
iOS builds require macOS. This sounds obvious, but I have seen pipelines that try to run `flutter build ipa` on an Ubuntu runner and then fail silently. Always use `runs-on: macos-latest` for iOS jobs.
3. Hardcoding Secrets in the Repository
Keystores, API keys, service account JSON files — none of these belong in your Git repository, not even in a `.gitignore`-d directory. Use your CI provider's encrypted secrets feature or a dedicated secrets manager like HashiCorp Vault.
4. Skipping Tests to "Speed Up" the Pipeline
Disabling tests because "they take too long" defeats the purpose of CI. If your tests are slow, optimize them — use mocks, run tests in parallel, split integration tests into a separate stage. Do not skip them.
5. No Build Number Strategy
If your build number does not increment consistently, store uploads will be rejected. Derive the build number from something guaranteed to increase: the CI run number, a timestamp, or a counter in your version control.
6. Ignoring Flaky Tests
A flaky test that is ignored is worse than no test at all. It trains the team to dismiss CI failures. Fix or delete flaky tests immediately.
7. Not Separating Build and Deploy Stages
Building and deploying should be distinct pipeline stages. If your deploy step fails, you should be able to re-deploy the same artifact without rebuilding. Upload your artifact in the build step and download it in the deploy step.
Putting It All Together
A mature Flutter CI/CD pipeline looks like this:
The only manual steps are merging the PR, creating the tag, and approving the promotion. Everything else is automated.
Conclusion
CI/CD is not a nice-to-have for Flutter projects — it is table stakes. The upfront investment in pipeline configuration pays for itself within the first few releases. You ship faster, break less, and sleep better knowing that every release went through the same rigorous, automated process.
Reach out if you want a CI/CD pipeline tailored to your Flutter project and team workflow.
Related Articles
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Flutter Testing Guide: Unit, Widget, and Integration Tests
Build a practical Flutter testing strategy using unit, widget, and integration tests with clear responsibilities.
Flutter Firebase Integration: Auth, Firestore, and Push Notifications
Set up Firebase in Flutter with production-focused auth flows, Firestore usage, and security basics.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch