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

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

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:

  • **Code quality checks** — lint, format, static analysis
  • **Automated testing** — unit, widget, and integration tests
  • **Build & artifact generation** — platform-specific binaries
  • **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:

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

  • **Internal track** — Team and QA test the build.
  • **Closed beta** — Selected external users provide feedback.
  • **Open beta / Staged rollout** — Gradual rollout to a percentage of users.
  • **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:

  • Developer pushes to a feature branch.
  • CI runs format checks, static analysis, and tests on every push.
  • PR is merged to `develop` — CI builds and deploys to internal testing.
  • Release branch is created and tagged (e.g., `v2.4.1`).
  • CI builds both platforms, signs artifacts, generates a changelog.
  • Artifacts are uploaded to TestFlight and Google Play internal track.
  • 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