Skip to content
ExponaBLOG
FLUTTER · DAY 080 / 100

CI/CD for Flutter: a practical guide for developers

Automated builds and releases.

Expona Admin8 min read

Getting your Flutter app to production shouldn't involve manual clicks and crossed fingers. A well-designed CI/CD pipeline catches bugs early, ensures consistent builds, and gets your app in front of testers and users reliably. In this practical guide, we'll walk through setting up CI/CD for Flutter: a practical guide for developers that automates everything from code quality checks through store uploads, saving your team hours each release cycle.

Understanding the CI/CD Pipeline

A production-ready CI/CD pipeline for Flutter follows a logical sequence of gates, each one blocking broken code from progressing further. Think of it like a quality funnel: code enters at the top, gets filtered by automated checks, and only reaches your users if it passes every stage.

The pipeline we recommend for most teams has five distinct phases:

  1. Analyze and format check: Lint your code, check formatting, and run static analysis to catch style issues and potential bugs before they compile.
  2. Unit and widget tests: Run your test suite to verify business logic and UI behavior. This is where you catch functional regressions.
  3. Build Android and iOS: Compile your app for both platforms. If the build fails, the pipeline stops—no point signing code that doesn't compile.
  4. Sign and upload to stores: Add your release signatures and push to Google Play and App Store, or to internal distribution channels for closed testing.
  5. Distribute to testers: Notify your QA team or release a beta to early adopters via TestFlight or Google Play Internal Testing.

Each stage typically runs only if the previous one succeeds. This prevents wasting compute time on signing a build that failed tests, or notifying testers about a broken app.

Choosing Your Tools

You have three main options for running Flutter CI/CD, each with different trade-offs in terms of cost, customization, and ease of setup.

GitHub Actions

If your repository lives on GitHub, GitHub Actions is the quickest path to a working pipeline. It's free for public repos and includes 2,000 free minutes per month on private repos. You define workflows in YAML files committed to your repository, giving you version control over your pipeline definition.

The downside is that GitHub Actions VMs have limited storage and can be slow for large mobile builds, especially the first time you build without caches. For a small team building once or twice daily, it's usually adequate.

Codemagic

Codemagic is purpose-built for Flutter and mobile development. It handles iOS signing, provisioning profiles, and Android signing certificates more transparently than generic CI systems. Codemagic also has macOS runners built in, which you need for iOS builds—GitHub Actions makes you pay extra for macOS time.

Codemagic's free tier gives you enough builds for a solo developer or small team. The web interface for managing secrets and signing certificates is smoother than YAML configuration alone.

Fastlane for Automation

Fastlane is not a CI system itself, but a Ruby tool that automates signing, building, and uploading to app stores. You'll typically run Fastlane inside your CI system—GitHub Actions or Codemagic triggers a script that calls Fastlane, which handles the complex mobile-specific steps.

Fastlane shines when you need consistent behavior across local and CI environments. A developer can run fastlane release locally to build and sign exactly the same way CI does, reducing "works on my machine" surprises.

Building a Pipeline Step by Step

Let's walk through a concrete GitHub Actions workflow for a Flutter app targeting both Android and iOS.

Analyze and Format

Start early with static analysis. Add this job to your workflow:

dart
jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
      - run: flutter pub get
      - run: flutter analyze
      - run: dart format --set-exit-if-changed .

This catches unused imports, potential null errors, and formatting drift before anything else runs. If the team has agreed on a code style via a linter config (usually in analysis_options.yaml), enforce it here.

Unit and Widget Tests

After analysis passes, run your test suite. Widget tests are slower than unit tests, so run both but separate them if your suite is large:

dart
  test:
    runs-on: ubuntu-latest
    needs: analyze
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
      - run: flutter pub get
      - run: flutter test --coverage
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

The --coverage flag generates coverage reports you can upload to Codecov or similar services to track which parts of your codebase lack tests. This data helps prioritize testing efforts in high-risk areas.

Build for Android and iOS

Compile for both platforms. These jobs can run in parallel since they don't depend on each other:

dart
  build-android:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
      - run: flutter pub get
      - run: flutter build apk --release

  build-ios:
    runs-on: macos-latest
    needs: test
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
      - run: flutter pub get
      - run: flutter build ios --release

Note that iOS builds require a macOS runner, which costs more than Ubuntu. If you're on a budget, consider building iOS only on tagged commits (when releasing) rather than on every push to main.

Signing and Uploading

After successful builds, sign your artifacts and push to stores. This is where Fastlane or direct API calls come in. Here's a simplified example using Fastlane for Android:

dart
  deploy-android:
    runs-on: ubuntu-latest
    needs: build-android
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'
      - run: flutter pub get
      - run: |
          echo "${{ secrets.PLAY_STORE_KEY }}" > key.json
          fastlane android deploy

Store your Google Play signing key securely as a GitHub secret. Fastlane will use it to upload to Google Play. For iOS, you'll need provisioning profiles and certificates stored similarly—this is where Codemagic's built-in certificate management can simplify your life.

Caching Dependencies for Speed

Mobile builds are slow. The first time your pipeline runs, it downloads all Dart packages and Gradle dependencies, which can take 5–10 minutes. Caching slashes this to 1–2 minutes on subsequent runs.

Pub Cache

Cache your Flutter packages like this:

dart
  - uses: actions/cache@v3
    with:
      path: ~/.pub-cache
      key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
      restore-keys: |
        ${{ runner.os }}-pub-

This caches based on your pubspec.lock file. If dependencies change, GitHub Actions automatically creates a new cache entry. Old caches expire after 7 days of non-use, so you won't bloat storage.

Gradle Cache

For Android builds, cache Gradle and its downloaded dependencies:

dart
  - uses: gradle/gradle-build-action@v2
    with:
      cache-read-only: false

The gradle-build-action handles caching automatically. It caches the Gradle wrapper, downloaded dependencies, and build artifacts intelligently.

Pod Cache for iOS

iOS dependencies (CocoaPods) should also be cached:

dart
  - uses: actions/cache@v3
    with:
      path: ios/Pods
      key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
      restore-keys: |
        ${{ runner.os }}-pods-

With all three caches in place, your pipeline's second run will be roughly 60–70% faster than the first, which translates to real time savings when you're pushing fixes on a Friday afternoon.

Automating Versioning

Manual version bumps are error-prone. Most teams don't increment versions by hand anymore—let your CI system do it.

Build Number from CI Run Number

Your pubspec.yaml contains a version line like version: 1.2.0+42. The part after the + is the build number. Increment it automatically using your CI environment variables:

dart
  - run: |
      VERSION=$(grep "version:" pubspec.yaml | cut -d' ' -f2)
      NEW_BUILD_NUMBER=${{ github.run_number }}
      NEW_VERSION="${VERSION%+*}+${NEW_BUILD_NUMBER}"
      sed -i "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
      flutter pub get

Now every build has a unique, incrementing build number tied to your CI run. App stores use this to ensure you never upload a build with a lower version number than the previous release.

Semantic Versioning for Releases

For semantic versioning (1.2.0), trigger a manual version bump when you tag a release in git. Tools like github-action-auto-commit can commit version changes back to your repository after a release, creating a clean audit trail.

Common Mistakes

Storing secrets in code: Never commit API keys, signing certificates, or credentials. Always use your CI system's secret management (GitHub Secrets, Codemagic environment variables, etc.). Even in a private repository, this is a bad habit.

No cache invalidation strategy: If a dependency is corrupted or you need a clean build, your cache might prevent a fix. Include a mechanism to manually flush caches or to re-run with cache disabled when needed.

Slow feedback loops: If your pipeline takes 20 minutes to run, developers won't wait—they'll keep pushing while it builds. Aim for 5–10 minutes for a full Android + iOS build. Parallelize jobs, use incremental builds, and cache aggressively.

Forgetting test coverage on CI: Your CI pipeline should fail if test coverage drops below a threshold, not just if tests fail. This prevents slow degradation of code quality over time.

Manual signing setup: If your CI signing process requires manual steps (uploading keys to a secure server, managing certificates outside your pipeline definition), you'll get signing failures at 11 PM when on-call engineers are asleep. Automate everything.

Key Takeaways

  • Build a CI/CD pipeline with five stages: analyze, test, build (Android and iOS), sign, and distribute. Each stage is a gate that blocks broken code.
  • Choose GitHub Actions for free and simplicity, Codemagic for mobile-specific features, and Fastlane to automate mobile builds and signing consistently.
  • Cache Pub packages, Gradle dependencies, and CocoaPods to cut build times in half after the first run.
  • Automate versioning by using CI run numbers for build numbers, so every artifact is uniquely identifiable.
  • Store all secrets (keys, certificates, credentials) in your CI system's environment, never in code or configuration files.
  • Run analysis and tests before building, and parallelize Android and iOS builds to minimize total pipeline time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles