CI/CD Pipeline Setup
4 min read
Operations
Push / PR
↓
1. Format check (seconds)
2. Static analysis (seconds)
3. Unit + widget tests + coverage gate (minutes)
4. Build artifacts (Android + iOS in parallel) — only on main
5. Upload to internal track / TestFlight — only on main
6. Optional: integration tests on Firebase Test Lab
The order matters: cheap checks early so a missing semicolon fails in 30s, not after a 15-minute iOS build.
Code in action — GitHub Actions
name: Flutter CI/CD
on:
push: { branches: [main, develop] }
pull_request: { branches: [main] }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.27.x', cache: true }
- run: flutter pub get
- run: dart format --set-exit-if-changed .
- run: flutter analyze --fatal-infos
- run: flutter test --coverage
- name: Coverage gate
run: |
C=$(lcov --summary coverage/lcov.info 2>&1 | grep lines | awk '{print $2}' | sed 's/%//')
echo "Coverage: $C%"
[ $(echo "$C < 80" | bc -l) -eq 1 ] && exit 1 || exit 0
android:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.27.x', cache: true }
- run: flutter build apk --release --obfuscate --split-debug-info=./debug-info
- uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_JSON }}
packageName: com.example.app
releaseFiles: build/app/outputs/flutter-apk/app-release.apk
track: internal
ios:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-latest # iOS REQUIRES macOS
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.27.x', cache: true }
- run: flutter build ipa --release --obfuscate --split-debug-info=./debug-info
- uses: apple-actions/upload-testflight-build@v1
with:
app-path: build/ios/ipa/*.ipa
issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
api-key-id: ${{ secrets.APPLE_API_KEY_ID }}
api-private-key: ${{ secrets.APPLE_API_PRIVATE_KEY }}
What to wire up beyond the basics
| Capability | Tool / approach |
|---|---|
| Code signing (Android) | keystore.jks stored as a base64 secret, decoded in CI |
| Code signing (iOS) | Fastlane match (preferred) or App Store Connect API key |
| Crashlytics / Sentry symbol upload | flutter build produces debug-info/; upload it to your service |
| Version bumping | cider or Fastlane set_info_plist_value / Gradle hooks |
| Release notes from commits | Fastlane changelog_from_git_commits |
| Integration tests on real devices | Firebase Test Lab, BrowserStack, AWS Device Farm |
| Visual regression (goldens) | Run on a fixed Docker image so renders are consistent |
| Internal distribution | Firebase App Distribution / TestFlight / Play internal track |
Strategy — branches and environments
| Branch | Triggers | Artifact target |
|---|---|---|
main | Tests + iOS/Android build + internal distribution | TestFlight, Play internal |
develop | Tests + lint only (no builds) | n/a |
release/* | Tests + production-config build | Beta or staged rollout |
Tag v*.*.* | Production build + store submission | Production release track |
| PRs | Tests + analyzer + coverage gate; no build | Status checks on the PR |
Common mistakes to avoid
❌ Running iOS builds on Linux
They will fail. Xcode requires macOS. Use macos-latest runners.
❌ Building APK / IPA on every PR
Burns minutes; PR feedback is slow. Build only on main.
❌ Not caching Flutter / pub deps
Each run downloads the SDK + every package — minutes of wasted CI time.
✅ subosito/flutter-action's cache: true.
❌ Storing signing keys in the repo
Even private — leaks happen. Use CI secrets / vault.
❌ Skipping --obfuscate + --split-debug-info on release
Larger binaries; reverse-engineering trivial; crash reports unsymbolicated.
❌ Coverage gate without an explicit threshold
"Tests pass" but coverage silently drops → quality regresses.
❌ One monolithic pipeline file with branching
Hard to maintain. Split into reusable workflows / composite actions.
❌ Manual store uploads
Slow, error-prone, locks the release to one developer's laptop.
✅ Automate uploads via Fastlane or platform actions.
Interview follow-ups
-
Why use Fastlane for store uploads instead of GitHub Actions plugins? Fastlane is the de facto standard for mobile release automation — handles signing (
match), screenshots (snapshot), metadata, store submission, dSYM upload, all in one tool. Platform-specific GH actions work for simple flows; Fastlane scales to complex release engineering (multiple flavors, regional submissions, gradual rollouts). -
How do you handle code signing in CI? Android: keystore as a base64-encoded secret, decoded into a file in CI. iOS: Fastlane match stores certificates and provisioning profiles in a private git repo, decrypted with a passphrase from secrets. Both approaches keep secrets out of the repo while making them available at build time.
-
What's the trade-off of running integration tests in CI? They catch end-to-end regressions but are slow (minutes), flaky on emulators, and expensive on real-device cloud services. Strategy: run a small smoke suite on every PR; full integration suite nightly or pre-release.
-
How do you symbolicate crash reports for obfuscated builds?
--split-debug-info=./debug-infoproduces mapping files alongside your APK/IPA. Upload them as a CI artifact AND to Crashlytics/Sentry. When a crash arrives, the service auto-symbolicates using the matching build's mappings. Don't lose those files — they're the only way to read production stack traces.
How helpful was this content?
Please sign in to rate this article.