Flutter

Flutter CI/CD: Automated Build, Test, and Release Pipeline

13 min readFebruary 9, 2026Updated Mar 9, 2026
Flutter CI/CDFlutter GitHub ActionsFlutter CodemagicFlutter FastlaneFlutter release automationFlutter build pipelineFlutter deploymentmobile CI/CD

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:

  • Consistency: Every build runs the same steps in the same order.
  • Speed: Parallel jobs cut your feedback loop from hours to minutes.
  • Confidence: Automated tests catch regressions before they reach users.
  • Traceability: Every artifact is tied to a specific commit.

Pipeline Essentials

A solid Flutter CI/CD pipeline has four stages:

  1. Code quality checks — lint, format, static analysis
  2. Automated testing — unit, widget, and integration tests
  3. Build & artifact generation — platform-specific binaries
  4. Release & distribution — store uploads, beta channels, changelogs

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:

yaml
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/*.ipa

In 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:

yaml
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: internal

The 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

ruby
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
end

iOS Fastfile

ruby
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
end

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

bash
# Initialize match for the first time
fastlane match init

# Generate new certificates
fastlane match appstore
fastlane match development

Key things to remember:

  • Apple limits you to a small number of distribution certificates. Do not create a new one for every developer.
  • Provisioning profiles expire annually. Build a renewal reminder into your process.
  • Never store signing credentials in your source repository. Always use encrypted secrets or a dedicated secrets manager.

Android Code Signing

Android signing is simpler but still requires care:

  1. Generate a keystore once and keep it safe. Losing it means you cannot update your app.
  2. Store the keystore as a base64-encoded CI secret.
  3. Reference it from android/app/build.gradle through a key.properties file.
groovy
// 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:

  • MAJOR: Breaking changes, major redesigns
  • MINOR: New features, backward-compatible
  • PATCH: Bug fixes, minor tweaks

In Flutter, the version string lives in pubspec.yaml:

yaml
version: 2.4.1+42
#        ^ semver  ^ build number

The build number after + must increment with every store submission. I automate this in CI by using the pipeline run number:

bash
flutter build appbundle --build-number=$GITHUB_RUN_NUMBER

Automated Changelogs

Generate changelogs from your commit messages. This requires discipline in your commit conventions:

bash
# Install conventional-changelog
npm install -g conventional-changelog-cli

# Generate CHANGELOG.md
conventional-changelog -p angular -i CHANGELOG.md -s

If your team uses conventional commits (feat:, fix:, chore:), this becomes fully automatic.

Beta Distribution

Never ship directly to production. Use a staged rollout:

  1. Internal track — Team and QA test the build.
  2. Closed beta — Selected external users provide feedback.
  3. Open beta / Staged rollout — Gradual rollout to a percentage of users.
  4. Full production — Release to everyone.

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:

  • Keep the previous version's artifact available for re-deployment.
  • Use feature flags to disable new functionality without shipping a new binary.
  • On Android, staged rollouts can be halted and reversed. On iOS, you can "remove from sale" but cannot easily roll back, which makes thorough TestFlight testing critical.

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.

yaml
# 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:

  1. Developer pushes to a feature branch.
  2. CI runs format checks, static analysis, and tests on every push.
  3. PR is merged to develop — CI builds and deploys to internal testing.
  4. Release branch is created and tagged (e.g., v2.4.1).
  5. CI builds both platforms, signs artifacts, generates a changelog.
  6. Artifacts are uploaded to TestFlight and Google Play internal track.
  7. After QA approval, a promotion job moves the build to production.

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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch