You've built an amazing Flutter app. Your code is clean, your tests are passing locally, and everything works perfectly on your machine. But then you push to production and — boom — something breaks. Or worse, you're manually building and deploying every release, spending hours on what should be automated. I've been there, and honestly, it's a complete waste of time that could be spent actually building features.
The solution? A rock-solid CI/CD pipeline. And after setting up dozens of these for Flutter projects over the past few years, I can tell you that GitHub Actions is hands down the best choice for most teams. It's free for public repos, integrates seamlessly with your existing GitHub workflow, and has excellent Flutter support.
But here's the thing — most Flutter CI/CD tutorials out there are either too basic (just running tests) or overly complex (enterprise setups that don't apply to 90% of projects). I'm going to show you how to build a production-ready pipeline that actually works in the real world. We'll cover everything from basic test automation to multi-platform builds, app security guide scanning, and deployment strategies that I've battle-tested in production apps.
📚 What You Will Learn
How to set up a complete Flutter CI/CD pipeline with GitHub Actions, including automated testing, code quality checks, multi-platform builds, security scanning, and deployment to app stores. You'll get production-ready workflows that you can copy and customize for your own projects.
🔧 Prerequisites
Basic Flutter development experience, familiarity with Git and GitHub, understanding of YAML syntax, and knowledge of app store deployment processes. Experience with command line tools is helpful but not required.
Understanding Flutter CI/CD Pipeline Fundamentals
Before we jump into GitHub Actions, let's talk about what a proper Flutter CI/CD pipeline actually needs to do. I've seen too many teams rush into automation without thinking through their requirements, and it always comes back to bite them.
A solid Flutter DevOps pipeline handles four main stages: validation, building, testing, and deployment. But here's where it gets tricky — Flutter apps often target multiple platforms (iOS, Android, web), each with different requirements and deployment processes.
The Flutter-Specific Challenges
Flutter brings some unique challenges to the CI/CD world. First, you're dealing with multiple build targets that require different toolchains. Android builds need the Android SDK, iOS builds require Xcode (which means macOS runners), and web builds have their own set of requirements.
Then there's the dependency management. Flutter's pub system works great locally, but in CI environments, you need to handle caching properly or your build times will be absolutely terrible. I've seen pipelines that take 20+ minutes just because they're downloading dependencies from scratch every time.
# Basic Flutter project structure for CI/CD
my_flutter_app/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ ├── release.yml
│ └── deploy.yml
├── android/
├── ios/
├── lib/
├── test/
├── integration_test/
└── pubspec.yaml
Pipeline Architecture Strategy
I recommend a three-pipeline approach: a continuous integration pipeline that runs on every push, a release pipeline that handles version management and builds, and deployment pipelines for each target platform. This separation keeps things manageable and allows for different triggers and permissions.
The CI pipeline should be fast — under 10 minutes ideally. It runs linting, unit tests, and basic integration tests. The release pipeline can take longer since it's building production artifacts, but it should still complete in under 30 minutes. Any longer and developers will start bypassing it.
💡 Pro Tip
Start with a simple CI pipeline that just runs tests. You can always add complexity later, but getting the basics right is crucial. I've seen teams spend weeks on elaborate pipelines that don't even run tests properly.
Setting Up GitHub Actions for Flutter Projects
GitHub Actions is perfect for Flutter projects because it supports multiple operating systems out of the box, has excellent caching capabilities, and integrates seamlessly with the GitHub ecosystem. Plus, the YAML syntax is actually readable — unlike some other CI systems I could mention.
The key to a good Flutter GitHub Actions setup is understanding the runner types and how to use them effectively. Ubuntu runners are cheapest and fastest for most tasks, but you'll need macOS runners for iOS builds. Windows runners are rarely needed unless you're targeting Windows desktop.
Basic Workflow Structure
Every Flutter GitHub Actions workflow follows a similar pattern: checkout code, setup Flutter, cache dependencies, run your tasks. But the devil is in the details — specifically, how you handle caching and which Flutter channel you use.
name: Flutter CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Analyze project source
run: flutter analyze
- name: Run tests
run: flutter test --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: coverage/lcov.info
Advanced Caching Strategies
Caching is where you can really speed up your builds. The Flutter action handles Flutter SDK caching automatically, but you should also cache pub dependencies and build outputs. Here's what I use in production:
- name: Cache pub dependencies
uses: actions/cache@v3
with:
path: ${{ env.PUB_CACHE }}
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: ${{ runner.os }}-pub-
- name: Cache build outputs
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
This caching setup can reduce build times from 15+ minutes down to 3-5 minutes on subsequent runs. The key is using the right cache keys — I use the pubspec.lock hash for pub dependencies and gradle file hashes for Android builds.
⚠️ Common Pitfall
Don't cache everything blindly. I've seen teams cache the entire .dart_tool directory, which can lead to weird build issues. Stick to caching dependencies and build artifacts, not generated files.
Implementing Automated Testing in Your Flutter CI Pipeline
Testing is where your CI/CD pipeline really proves its worth. But here's the thing — most Flutter projects have terrible test coverage, and the ones that do have tests often don't run them properly in CI. I'm going to show you how to set up a Flutter testing strategy guide that actually catches bugs before they reach production.
A comprehensive Flutter testing pipeline includes unit tests, custom widget composition patterns tests, integration tests, and golden tests. Each serves a different purpose, and you need all of them for proper coverage. But you also need to be smart about when and how you run them — integration tests on every commit will kill your development velocity.
Unit and Widget Test Automation
Unit and widget tests should run on every commit. They're fast, reliable, and catch most regressions. The trick is setting them up to run in parallel and with proper reporting.
test-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
flutter-version: ['3.19.0', '3.16.0']
steps:
- uses: actions/checkout@v4
- name: Setup Flutter ${{ matrix.flutter-version }}
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter-version }}
channel: 'stable'
cache: true
- run: flutter pub get
- name: Run tests with coverage
run: |
flutter test \
--coverage \
--reporter=github \
--file-reporter=json:test-results.json
- name: Upload test results
uses: dorny/test-reporter@v1
if: always()
with:
name: Flutter Tests (${{ matrix.flutter-version }})
path: test-results.json
reporter: dart-json
Integration Test Strategy
Integration tests are trickier because they need actual devices or emulators. I run these on a schedule (nightly) and on release branches, not on every commit. Here's how to set up Android emulator testing:
integration-test:
runs-on: macos-latest
if: github.event_name == 'schedule' || contains(github.ref, 'release')
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Run Android integration tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
target: default
arch: x86_64
profile: Nexus 6
script: |
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/app_test.dart \
-d emulator-5554
Golden Test Implementation
Golden tests are incredibly valuable for catching UI regressions, but they're also fragile if not set up correctly. The key is running them in a consistent environment and handling failures gracefully.
golden-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Run golden tests
run: |
flutter test --update-goldens
git diff --exit-code test/goldens/
env:
FLUTTER_TEST_FONT: true
- name: Upload golden failures
if: failure()
uses: actions/upload-artifact@v3
with:
name: golden-failures
path: test/failures/
The FLUTTER_TEST_FONT environment variable ensures consistent font rendering across different CI
environments. Without it, your golden tests will fail randomly due to font differences.
🎯 Testing Best Practice
Don't try to achieve 100% test coverage in CI. Aim for 80-90% on critical paths. I've seen teams spend more time maintaining flaky tests than actually building features. Focus on testing the business logic and critical user flows.
Code Quality and Security Scanning
Code quality checks are non-negotiable in a professional Flutter DevOps pipeline. But here's what most teams get wrong — they treat linting and analysis as an afterthought. These checks should be fast, comprehensive, and actually block bad code from getting merged.
I've integrated tools like flutter_lints, custom lint rules, dependency vulnerability scanning, and
security analysis into my pipelines. The goal isn't to be pedantic — it's to catch real issues before they become
production problems.
Advanced Flutter Analysis
The basic flutter analyze command is a good start, but you can do much better with custom analysis
options and additional tools. Here's my production setup:
code-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for some checks
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Run Flutter analysis
run: flutter analyze --fatal-infos
- name: Check for unused dependencies
run: |
dart pub global activate dependency_validator
dart pub global run dependency_validator
- name: Run custom lint rules
run: |
dart pub global activate custom_lint
dart pub global run custom_lint
- name: Check for TODO/FIXME comments
run: |
if grep -r "TODO\|FIXME" lib/ --exclude-dir=generated; then
echo "Found TODO/FIXME comments in production code"
exit 1
fi
Security Vulnerability Scanning
Security scanning is often overlooked in Flutter projects, but it's crucial. You need to scan both your Dart dependencies and native dependencies. Here's how I handle it:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Get dependencies
run: flutter pub get
- name: Scan Dart dependencies
run: |
dart pub global activate pana
dart pub global run pana --no-warning
- name: Run Semgrep security scan
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/dart
- name: Check for hardcoded secrets
run: |
if grep -r "sk_\|pk_\|AIza\|AKIA" lib/ --exclude-dir=generated; then
echo "Potential hardcoded secrets found"
exit 1
fi
- name: Android security scan
working-directory: android
run: |
./gradlew dependencyCheckAnalyze
if [ -f "build/reports/dependency-check-report.html" ]; then
echo "Security vulnerabilities found in Android dependencies"
cat build/reports/dependency-check-report.html
fi
Performance and Size Analysis
I also include Flutter performance optimization checks in the quality pipeline. Bundle size analysis and basic performance metrics can catch regressions early:
- name: Build and analyze app size
run: |
flutter build apk --analyze-size --target-platform android-arm64
flutter build appbundle --analyze-size
- name: Check bundle size
run: |
SIZE=$(stat -f%z build/app/outputs/bundle/release/app-release.aab)
MAX_SIZE=50000000 # 50MB limit
if [ $SIZE -gt $MAX_SIZE ]; then
echo "App bundle size ($SIZE bytes) exceeds limit ($MAX_SIZE bytes)"
exit 1
fi
echo "App bundle size: $SIZE bytes"
This catches bundle size regressions automatically. I set reasonable limits based on the app's requirements and fail the build if they're exceeded. It's saved me from shipping bloated releases multiple times.
🔒 Security Tip
Never commit API keys or secrets to your repository, even in a private repo. Use GitHub Secrets for sensitive data and environment-specific configuration files. I've seen production databases compromised because someone committed a .env file.
Multi-Platform Build Automation
Building Flutter apps for multiple platforms in CI is where things get really complex. You're dealing with different operating systems, toolchains, signing certificates, and deployment targets. But when done right, it's incredibly powerful — push to main and get production-ready builds for all platforms automatically.
The key is understanding the dependencies and constraints of each platform. Android builds can run anywhere, iOS builds need macOS, web builds are straightforward, and desktop builds have their own quirks. I use a matrix strategy to build multiple platforms in parallel while keeping costs reasonable.
Android Build Pipeline
Android builds are the easiest to automate since they can run on any platform. Here's my production Android build workflow that handles signing, optimization, and multiple build variants:
build-android:
runs-on: ubuntu-latest
strategy:
matrix:
build-type: [debug, release]
target: [apk, appbundle]
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Setup Android signing
if: matrix.build-type == 'release'
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > android/keystore.jks
echo "storeFile=keystore.jks" >> android/key.properties
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
- name: Get dependencies
run: flutter pub get
- name: Build Android ${{ matrix.target }}
run: |
if [ "${{ matrix.build-type }}" == "release" ]; then
flutter build ${{ matrix.target }} --release --verbose
else
flutter build ${{ matrix.target }} --debug --verbose
fi
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: android-${{ matrix.target }}-${{ matrix.build-type }}
path: |
build/app/outputs/flutter-apk/*.apk
build/app/outputs/bundle/release/*.aab
iOS Build Configuration
iOS builds are more complex due to provisioning profiles and code signing requirements. You need a macOS runner and proper certificate management:
build-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
- name: Install iOS certificates
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Import certificate and provisioning profile
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode --output $PP_PATH
# Create keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# Install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
- name: Get dependencies
run: flutter pub get
- name: Build iOS
run: |
flutter build ios --release --no-codesign
cd ios && xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Release archive -archivePath build/Runner.xcarchive
xcodebuild -exportArchive -archivePath build/Runner.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build/
- name: Upload IPA
uses: actions/upload-artifact@v3
with:
name: ios-ipa
path: ios/build/*.ipa
Web and Desktop Builds
Web builds are straightforward but benefit from optimization. Desktop builds are still evolving, but here's what works reliably:
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Enable web
run: flutter config --enable-web
- name: Get dependencies
run: flutter pub get
- name: Build web
run: |
flutter build web --release --web-renderer canvaskit --base-href /
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build/web
I use CanvasKit as the web renderer because it provides the most consistent experience across browsers, even though it has a larger bundle size. For most business apps, the consistency is worth the extra bandwidth.
💰 Cost Optimization
macOS runners are expensive — about 10x the cost of Ubuntu runners. Only use them for iOS builds and run everything else on Ubuntu. I've seen teams burn through their GitHub Actions budget by running Android builds on macOS unnecessarily.
Deployment Automation and Release Management
Deployment automation is where your CI/CD pipeline really pays off. But here's the challenge with Flutter — you're often deploying to multiple app stores with different requirements, review processes, and timelines. The key is building a deployment strategy that's flexible enough to handle these differences while maintaining consistency.
I use a staged deployment approach: development builds go to internal testing, staging builds go to beta testers, and production builds go through automated store submission. Each stage has different triggers and approval processes, which keeps releases controlled but not overly bureaucratic.
App Store Deployment Pipeline
Automating App Store submissions is tricky because of Apple's review process, but you can automate the build upload and metadata management. Here's my production iOS deployment workflow:
deploy-ios:
runs-on: macos-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, build-ios]
environment: production
steps:
- uses: actions/checkout@v4
- name: Download IPA artifact
uses: actions/download-artifact@v3
with:
name: ios-ipa
path: ./
- name: Setup App Store credentials
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: |
echo "$APP_STORE_CONNECT_API_KEY" > AuthKey.p8
- name: Upload to App Store Connect
env:
API_KEY_ID: ${{ secrets.API_KEY_ID }}
API_ISSUER_ID: ${{ secrets.API_ISSUER_ID }}
run: |
xcrun altool --upload-app \
--type ios \
--file *.ipa \
--apiKey $API_KEY_ID \
--apiIssuer $API_ISSUER_ID \
--verbose
- name: Submit for review
if: contains(github.event.head_commit.message, '[auto-submit]')
run: |
# Use App Store Connect API to submit for review
curl -X POST \
"https://api.appstoreconnect.apple.com/v1/appStoreVersionSubmissions" \
-H "Authorization: Bearer $(generate-jwt-token)" \
-H "Content-Type: application/json" \
-d '{"data":{"type":"appStoreVersionSubmissions","relationships":{"appStoreVersion":{"data":{"type":"appStoreVersions","id":"'$APP_STORE_VERSION_ID'"}}}}}'
Google Play Store Automation
Google Play Store deployment is more automation-friendly. You can upload builds, update metadata, and even submit for review programmatically:
deploy-android:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, build-android]
environment: production
steps:
- uses: actions/checkout@v4
- name: Download AAB artifact
uses: actions/download-artifact@v3
with:
name: android-appbundle-release
path: ./
- name: Setup Google Play credentials
env:
GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
run: |
echo "$GOOGLE_PLAY_SERVICE_ACCOUNT" > service-account.json
- name: Deploy to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
packageName: com.example.myapp
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: production
status: completed
whatsNewDirectory: metadata/android/
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
Beta Testing and Staged Rollouts
I always use staged rollouts for production releases. Start with a small percentage of users and gradually increase based on crash metrics and user feedback:
staged-rollout:
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
steps:
- name: Check crash metrics
id: metrics
run: |
# Query Firebase Crashlytics or your crash reporting service
CRASH_RATE=$(curl -s "https://api.crashlytics.com/crash-rate" | jq '.rate')
echo "crash_rate=$CRASH_RATE" >> $GITHUB_OUTPUT
- name: Increase rollout percentage
if: steps.metrics.outputs.crash_rate < 0.01 # Less than 1% crash rate
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
packageName: com.example.myapp
track: production
userFraction: 0.20 # Increase from 10% to 20%
- name: Halt rollout on high crash rate
if: steps.metrics.outputs.crash_rate > 0.05 # More than 5% crash rate
run: |
echo "High crash rate detected, halting rollout"
# Send alert to team
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-d '{"text":"🚨 High crash rate detected, rollout halted automatically"}'
This automated rollout management has saved me from several bad releases. The key is having good crash reporting and metrics in place so the automation can make informed decisions.
🎯 Deployment Strategy
Never deploy to production on Fridays or before holidays. I learned this the hard way when a Friday deployment caused weekend outages. Use feature flags for risky changes and always have a rollback plan ready.
Pipeline Monitoring and Performance Optimization
A CI/CD pipeline that takes forever to run is almost as bad as no pipeline at all. I've seen teams abandon automation because their builds took 45+ minutes. The sweet spot for Flutter CI is under 10 minutes for basic checks and under 30 minutes for full builds including deployment.
Monitoring your pipeline performance is crucial. You need to track build times, success rates, flaky test patterns, and resource usage. GitHub provides some basic metrics, but I supplement with custom monitoring to get the full picture.
Build Time Optimization Strategies
The biggest performance wins come from intelligent caching and parallelization. Here's my optimized workflow structure that runs multiple jobs in parallel:
name: Optimized Flutter Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
flutter: ${{ steps.changes.outputs.flutter }}
android: ${{ steps.changes.outputs.android }}
ios: ${{ steps.changes.outputs.ios }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: changes
with:
filters: |
flutter:
- 'lib/**'
- 'test/**'
- 'pubspec.yaml'
android:
- 'android/**'
- 'lib/**'
ios:
- 'ios/**'
- 'lib/**'
test:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.flutter == 'true'
strategy:
matrix:
test-type: [unit, widget, integration]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
channel: 'stable'
cache: true
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
- name: Get dependencies
run: flutter pub get
- name: Run ${{ matrix.test-type }} tests
run: |
case "${{ matrix.test-type }}" in
unit)
flutter test test/unit/ --coverage
;;
widget)
flutter test test/widget/
;;
integration)
flutter test integration_test/
;;
esac
Resource Usage Monitoring
I track resource usage to identify bottlenecks and optimize runner allocation. This custom monitoring helps me understand where time and money are being spent:
performance-monitoring:
runs-on: ubuntu-latest
if: always()
needs: [test, build-android, build-ios]
steps:
- name: Collect workflow metrics
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get workflow run details
RUN_ID=${{ github.run_id }}
REPO=${{ github.repository }}
# Fetch timing data
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$REPO/actions/runs/$RUN_ID/timing" \
> timing.json
# Calculate total build time
TOTAL_TIME=$(jq '.run_duration_ms' timing.json)
echo "Total build time: ${TOTAL_TIME}ms"
# Send metrics to monitoring service
curl -X POST "${{ secrets.METRICS_ENDPOINT }}" \
-H "Content-Type: application/json" \
-d "{
\"workflow\": \"${{ github.workflow }}\",
\"duration\": $TOTAL_TIME,
\"success\": \"${{ job.status == 'success' }}\",
\"branch\": \"${{ github.ref_name }}\",
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}"
- name: Check for performance regressions
run: |
# Compare with historical averages
AVG_TIME=$(curl -s "${{ secrets.METRICS_ENDPOINT }}/average" | jq '.duration')
CURRENT_TIME=${{ needs.test.outputs.duration }}
if [ $CURRENT_TIME -gt $((AVG_TIME * 150 / 100)) ]; then
echo "⚠️ Build time regression detected: ${CURRENT_TIME}ms vs ${AVG_TIME}ms average"
# Send alert
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-d "{\"text\":\"Build time regression: ${CURRENT_TIME}ms vs ${AVG_TIME}ms average\"}"
fi
Flaky Test Detection
Flaky tests are the enemy of reliable CI/CD. I track test failure patterns and automatically quarantine tests that fail inconsistently:
| Metric | Threshold | Action |
|---|---|---|
| Test failure rate | > 5% over 10 runs | Mark as flaky, create issue |
| Build time increase | > 50% vs average | Send alert, investigate |
| Cache hit rate | < 80% | Review caching strategy |
| Runner queue time | > 5 minutes | Consider runner scaling |
- name: Analyze test results
if: always()
run: |
# Parse test results and track failures
if [ -f "test-results.json" ]; then
FAILED_TESTS=$(jq -r '.tests[] | select(.result == "failed") | .name' test-results.json)
for test in $FAILED_TESTS; do
# Check failure history
FAILURE_COUNT=$(curl -s "${{ secrets.METRICS_ENDPOINT }}/test-failures?test=$test&days=7" | jq '.count')
if [ $FAILURE_COUNT -gt 3 ]; then
echo "🔥 Flaky test detected: $test (failed $FAILURE_COUNT times in 7 days)"
# Create issue for flaky test
gh issue create \
--title "Flaky test: $test" \
--body "Test has failed $FAILURE_COUNT times in the last 7 days. Please investigate and fix or quarantine." \
--label "flaky-test,bug"
fi
done
fi
This automated flaky test detection has helped me maintain pipeline reliability. When tests start failing randomly, they get flagged immediately instead of causing frustration for weeks.
⚡ Performance Tip
Use GitHub's larger runners for iOS builds if you can afford it. The 4-core macOS runners are significantly faster than the standard 3-core ones, especially for Xcode builds. The cost difference is worth it for teams that build frequently.
Environment Management and Secrets Handling
Managing different environments and secrets securely is one of the trickiest parts of Flutter DevOps. You're dealing with API keys, signing certificates, environment-specific configurations, and sensitive data that needs to be available to your CI/CD pipeline but secure from unauthorized access.
I use a layered approach: GitHub Secrets for sensitive data, environment-specific configuration files, and runtime environment detection. The key is making it easy for developers to work with different environments while keeping production secrets locked down tight.
Secure Secrets Management
GitHub Secrets are great, but you need to organize them properly. I use a naming convention that makes it clear which environment each secret belongs to:
setup-environment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup environment variables
env:
# Production secrets
PROD_API_KEY: ${{ secrets.PROD_API_KEY }}
PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
# Staging secrets
STAGING_API_KEY: ${{ secrets.STAGING_API_KEY }}
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
# Signing certificates
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
# Service accounts
GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }}
FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
run: |
# Determine environment based on branch
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
ENVIRONMENT="production"
API_KEY="$PROD_API_KEY"
DATABASE_URL="$PROD_DATABASE_URL"
elif [[ "${{ github.ref }}" == "refs/heads/staging" ]]; then
ENVIRONMENT="staging"
API_KEY="$STAGING_API_KEY"
DATABASE_URL="$STAGING_DATABASE_URL"
else
ENVIRONMENT="development"
API_KEY="dev_key_12345"
DATABASE_URL="http://localhost:8080"
fi
echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_ENV
echo "API_KEY=$API_KEY" >> $GITHUB_ENV
echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV
- name: Generate environment config
run: |
cat > lib/config/environment.dart << EOF
class Environment {
static const String current = '${{ env.ENVIRONMENT }}';
static const String apiKey = '${{ env.API_KEY }}';
static const String databaseUrl = '${{ env.DATABASE_URL }}';
static bool get isProduction => current == 'production';
static bool get isStaging => current == 'staging';
static bool get isDevelopment => current == 'development';
}
EOF
Firebase Configuration Management
Firebase Cloud Functions guide projects are particularly tricky because you need different configurations for different environments, and the config files contain sensitive data:
- name: Setup Firebase configuration
env:
FIREBASE_CONFIG_PROD: ${{ secrets.FIREBASE_CONFIG_PROD }}
FIREBASE_CONFIG_STAGING: ${{ secrets.FIREBASE_CONFIG_STAGING }}
GOOGLE_SERVICES_ANDROID_PROD: ${{ secrets.GOOGLE_SERVICES_ANDROID_PROD }}
GOOGLE_SERVICES_ANDROID_STAGING: ${{ secrets.GOOGLE_SERVICES_ANDROID_STAGING }}
run: |
# Setup web Firebase config
if [[ "${{ env.ENVIRONMENT }}" == "production" ]]; then
echo "$FIREBASE_CONFIG_PROD" > web/firebase-config.js
echo "$GOOGLE_SERVICES_ANDROID_PROD" | base64 -d > android/app/google-services.json
else
echo "$FIREBASE_CONFIG_STAGING" > web/firebase-config.js
echo "$GOOGLE_SERVICES_ANDROID_STAGING" | base64 -d > android/app/google-services.json
fi
# Verify config files
if [ ! -f "android/app/google-services.json" ]; then
echo "❌ Google Services config missing"
exit 1
fi
# Validate JSON
if ! jq empty android/app/google-services.json 2>/dev/null; then
echo "❌ Invalid Google Services JSON"
exit 1
fi
echo "✅ Firebase configuration setup complete"
Dynamic Environment Detection
For runtime environment detection, I use a combination of build-time configuration and runtime checks. This allows the same build to behave differently based on deployment context:
// lib/config/app_config.dart
import 'package:flutter/foundation.dart';
class AppConfig {
static const String _apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.myapp.com',
);
static const String _environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'development',
);
static const bool _enableAnalytics = bool.fromEnvironment(
'ENABLE_ANALYTICS',
defaultValue: false,
);
static String get apiBaseUrl {
if (kDebugMode) return 'http://localhost:8080';
return _apiBaseUrl;
}
static bool get isProduction => _environment == 'production';
static bool get enableAnalytics => _enableAnalytics && isProduction;
static bool get enableLogging => !isProduction || kDebugMode;
static Map get debugInfo => {
'environment': _environment,
'apiBaseUrl': apiBaseUrl,
'enableAnalytics': enableAnalytics,
'enableLogging': enableLogging,
'buildMode': kDebugMode ? 'debug' : 'release',
};
}
Then in your build command, you pass the environment variables:
- name: Build with environment config
run: |
flutter build apk \
--release \
--dart-define=ENVIRONMENT=${{ env.ENVIRONMENT }} \
--dart-define=API_BASE_URL=${{ env.API_BASE_URL }} \
--dart-define=ENABLE_ANALYTICS=true
This approach keeps sensitive data out of your source code while making it easy to configure different environments. The build artifacts contain the right configuration, but the source code remains environment-agnostic.
🔐 Security Best Practice
Never log secret values in your CI/CD pipeline, even for debugging. Use placeholder values or hash the first few characters. I've seen teams accidentally expose API keys in build logs that were publicly accessible.
Troubleshooting Common CI/CD Issues
Every Flutter CI/CD pipeline will have issues. I've debugged hundreds of failed builds, and there are definitely patterns to the most common problems. The key is building good debugging practices into your pipeline and knowing where to look when things go wrong.
The most frustrating issues are the ones that work locally but fail in CI. These usually come down to environment differences, timing issues, or missing dependencies. I'll show you how to diagnose and fix the most common problems I encounter.
Build Environment Issues
Environment differences are the number one cause of "works on my machine" problems. Here's a comprehensive environment debugging workflow I use:
debug-environment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: System information
run: |
echo "=== System Information ==="
uname -a
cat /etc/os-release
echo
echo "=== Available disk space ==="
df -h
echo
echo "=== Memory information ==="
free -h
echo
echo "=== Environment variables =
Frequently Asked Questions
What is Flutter CI/CD and how does it work?
Flutter CI/CD (Continuous Integration/Continuous Deployment) is an automated development practice that streamlines building, testing, and deploying Flutter applications. The CI process automatically runs tests, performs code analysis, and builds your app whenever code changes are pushed to the repository. The CD component then automatically deploys successful builds to various environments like app stores or staging servers. This automation reduces manual errors, speeds up development cycles, and ensures consistent quality across all deployments.
How do I set up Flutter CI/CD with GitHub Actions?
To set up Flutter CI/CD with GitHub Actions, create a .github/workflows/flutter.yml file in your
repository root. Define workflow triggers (like push or pull requests), specify the operating system
(ubuntu-latest, macos-latest, or windows-latest), and add steps to checkout code, install Flutter using
subosito/flutter-action@v2, run flutter pub get, execute tests with
flutter test, and build the app. You can extend this workflow to include deployment steps for app
stores or other distribution platforms.
What are the benefits of implementing Flutter DevOps practices?
Flutter DevOps practices provide automated testing that catches bugs early in development, consistent builds
across different environments, and faster release cycles through automated deployment pipelines. Teams gain
better collaboration through standardized processes, reduced manual deployment errors, and improved code
quality through automated analysis tools like flutter analyze. DevOps also enables easy
rollbacks, parallel development workflows, and comprehensive monitoring of application performance across
multiple platforms including iOS, Android, and web.
How do I create an effective Flutter DevOps pipeline?
An effective Flutter DevOps pipeline starts with source control integration, followed by automated testing
stages including unit tests, widget tests, and integration tests using flutter test. Include code
quality checks with flutter analyze and dart format, then build for multiple
platforms using flutter build apk, flutter build ios, or
flutter build web. Add deployment stages for different environments (staging, production) and
implement monitoring and rollback capabilities. Use tools like Fastlane for app store deployments and consider
adding performance testing and security scanning steps.
Can Flutter GitHub Actions build for both iOS and Android simultaneously?
Yes, Flutter GitHub Actions can build for both iOS and Android simultaneously using matrix builds or separate
jobs within the same workflow. You can configure multiple runners with different operating systems (macOS for
iOS, Ubuntu or macOS for Android) and run parallel build jobs. However, iOS builds require macOS runners and
proper code signing certificates stored as GitHub secrets. The workflow can use conditional steps based on the
target platform and automatically build .apk, .ipa, or other platform-specific
artifacts in parallel to reduce overall build time.
Which tools are essential for Flutter DevOps automation?
Essential Flutter DevOps tools include GitHub Actions or GitLab CI for pipeline automation, Fastlane for app
store deployment automation, and Docker for consistent build environments. Code quality tools like
flutter analyze, dart format, and testing frameworks are crucial for maintaining
standards. For deployment, consider Firebase App Distribution for beta testing, Codemagic or Bitrise for
specialized Flutter CI/CD, and monitoring tools like Firebase Crashlytics or Sentry for production apps.
Version management tools and artifact storage solutions complete the essential DevOps toolkit.
What is the difference between Flutter Azure DevOps pipeline and GitHub Actions?
Flutter Azure DevOps pipeline offers enterprise-focused features like advanced work item tracking, detailed reporting, and integrated project management tools, making it ideal for large organizations with complex workflows. GitHub Actions provides simpler setup with extensive marketplace integrations and is more developer-friendly for open-source projects. Azure DevOps includes built-in artifact management and release management features, while GitHub Actions relies on third-party actions and GitHub Packages. Both support Flutter builds effectively, but Azure DevOps offers more granular permissions and compliance features, while GitHub Actions excels in community-driven automation and ease of use.
How do I troubleshoot common Flutter CI/CD pipeline failures?
Common Flutter CI/CD failures include dependency resolution issues (solved by clearing cache and running
flutter clean and flutter pub get), platform-specific build errors requiring proper
SDK versions, and test failures due to environment differences. Check that Flutter and Dart SDK versions match
your local development environment, ensure all required secrets (like signing certificates) are properly
configured, and verify that your workflow has sufficient permissions. Enable verbose logging with
flutter build --verbose to identify specific error points, and consider using Docker containers
for consistent build environments across different runners.