CI/CD Pipeline Setup

Medium PriorityAsked in ~55% of senior interviews

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

CapabilityTool / 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 uploadflutter build produces debug-info/; upload it to your service
Version bumpingcider or Fastlane set_info_plist_value / Gradle hooks
Release notes from commitsFastlane changelog_from_git_commits
Integration tests on real devicesFirebase Test Lab, BrowserStack, AWS Device Farm
Visual regression (goldens)Run on a fixed Docker image so renders are consistent
Internal distributionFirebase App Distribution / TestFlight / Play internal track

Strategy — branches and environments

BranchTriggersArtifact target
mainTests + iOS/Android build + internal distributionTestFlight, Play internal
developTests + lint only (no builds)n/a
release/*Tests + production-config buildBeta or staged rollout
Tag v*.*.*Production build + store submissionProduction release track
PRsTests + analyzer + coverage gate; no buildStatus 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

  1. 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).

  2. 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.

  3. 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.

  4. How do you symbolicate crash reports for obfuscated builds? --split-debug-info=./debug-info produces 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.