Mobile Engineering10 min readPublished 2026-10-20 · Updated 2026-10-20

Production Flutter CI/CD Pipelines with GitHub Actions

How to build a reproducible, secret-safe release pipeline for Flutter apps covering Android AAB signing, iOS code signing, build flavors, quality gates, and store delivery.

SW

Author: Syed Waleed Nawaz

Senior Flutter Developer & Mobile Backend Engineer

1. Beyond YAML Snippets: What a Production Flutter Pipeline Actually Requires

Most Flutter CI/CD tutorials end where the real problems begin. They show how to run flutter build appbundle inside a GitHub Actions workflow, celebrate the green checkmark, and call the job done. What they consistently omit is everything a shipped product actually needs: reproducible toolchain pinning, secret-safe credential injection, environment-specific builds, iOS signing automation, quality gates that block bad code before it reaches reviewers, and a staged delivery model that separates tester builds from production releases.

The cost of a fragile release pipeline compounds quickly. A team that releases monthly accumulates manual signing steps, environment variable files committed by accident, build numbers re-used across environments, and iOS certificates that expire without warning because nobody automated renewal reminders. Each release becomes a high-stakes, slow, undocumented ritual instead of a repeatable, auditable process.

This guide builds a complete production pipeline from the ground up using GitHub Actions as the CI/CD backbone. The same principles apply when using Codemagic or Bitrise, which offer Flutter-native build environments and their own signing configuration systems. The focus here is on patterns that matter regardless of provider: secret hygiene, reproducible builds, staged delivery, and a clear separation between dev, staging, and production environments.

One architectural decision deserves emphasis before touching YAML: Android and iOS release builds are fundamentally different CI problems. Android AABs can be signed on a Linux runner. iOS IPAs require a macOS runner with Xcode installed. Plan your runner budget and workflow structure accordingly — collapsing both platforms into a single job wastes macOS runner minutes on work that is cheaper on Ubuntu.

Key Takeaway: Split your pipeline into at least three layers: PR validation (fast, cheap, on every push), internal distribution (staging builds for testers), and production promotion (gated, auditable store delivery). Never run all three as one monolithic job.

2. Reproducible Builds: Toolchain Pinning and Quality Gates

A build is only reproducible if the toolchain is explicitly pinned. Floating Flutter SDK channels (stable, beta, main) mean that a workflow passing today may fail next week when the channel advances. Pin to an exact Flutter version and upgrade deliberately, with a tested PR.

The subosito/flutter-action GitHub Action is the standard for Flutter setup in GitHub Actions. Use flutter-version to specify the exact SDK version and always pair it with actions/checkout@v4. Cache the pub package download directory (~/.pub-cache) to cut job duration significantly on repeated runs.

Quality gates belong before the build step, not after. Running flutter analyze and flutter test only when you are about to push to the store means defects travel far. Configure flutter analyze as a required status check on every pull request: it exits with a non-zero code on any error-level lint violation, blocking the merge. Pair it with flutter test to enforce unit and widget test coverage on every PR.

yamlPR quality gate running on every pull request: formatting enforcement, static analysis, and full test suite before any code reaches main.
# .github/workflows/pr-check.yml
name: PR Quality Gate

on:
  pull_request:
    branches: [main, develop]

jobs:
  analyze-and-test:
    name: Analyze & Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: 3.24.5   # Pin to an exact version; upgrade via PR
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Enforce formatting
        run: dart format --output=none --set-exit-if-changed .

      - name: Static analysis (blocks merge on errors)
        run: flutter analyze --fatal-infos

      - name: Run unit and widget tests
        run: flutter test --coverage

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

Key Takeaway: flutter analyze --fatal-infos treats info-level lint violations as errors, giving you the strictest possible gate. Start with --fatal-warnings if your codebase has accumulated info-level noise, then tighten to --fatal-infos once resolved.

3. Android AAB Signing: Keystores, Secrets, and Gradle Integration

Android release builds require a signed artifact. Google Play uses Play App Signing, where you generate an upload key, use it to sign your AAB for upload, and Google re-signs it for distribution to devices. Never commit your keystore (.jks or .keystore file) to version control. Not in a subfolder, not gitignored, not anywhere in the repository.

The correct CI approach is to store the base64-encoded keystore as a GitHub Actions secret (KEYSTORE_BASE64) and decode it into the runner's temporary directory at build time. Pair it with secrets for KEYSTORE_PASSWORD, KEY_ALIAS, and KEY_PASSWORD. Configure Gradle's release signing config to read from a key.properties file, which you generate ephemerally in the workflow and never persist.

Version codes must be unique and monotonically increasing within Google Play. Using github.run_number as the build number is a clean, zero-configuration approach: it increments automatically with each workflow run and requires no external counter. Override pubspec.yaml's build number using flutter build's --build-number flag — never manually edit pubspec.yaml in CI.

yamlAndroid signing workflow using ephemeral key.properties. The cleanup step uses if: always() to remove credentials even when earlier steps fail.
# .github/workflows/deploy-android.yml (relevant job steps)

      - name: Decode keystore from secret
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$RUNNER_TEMP/upload-keystore.jks"

      - name: Create key.properties (ephemeral, never committed)
        run: |
          cat > android/key.properties <<EOF
          storePassword=${{ secrets.KEYSTORE_PASSWORD }}
          keyPassword=${{ secrets.KEY_PASSWORD }}
          keyAlias=${{ secrets.KEY_ALIAS }}
          storeFile=$RUNNER_TEMP/upload-keystore.jks
          EOF

      - name: Build signed AAB
        run: |
          flutter build appbundle \
            --release \
            --build-name=1.5.0 \
            --build-number=${{ github.run_number }} \
            --dart-define=ENV=prod

      - name: Upload AAB artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-release-aab
          path: build/app/outputs/bundle/release/app-release.aab
          retention-days: 30

      - name: Clean up keystore
        if: always()
        run: rm -f "$RUNNER_TEMP/upload-keystore.jks" android/key.properties

Key Takeaway: Configure Gradle's release signing block to read from key.properties in android/app/build.gradle.kts. The file is generated ephemerally in CI and immediately removed after the build — it must never be committed. The if: always() guard on cleanup ensures credentials are wiped even on workflow failure.

4. iOS Code Signing: macOS Runners, Certificates, and Fastlane Match

iOS release builds cannot run on Linux or Windows runners. An IPA requires Xcode for archiving and a valid Apple Distribution certificate with a matching provisioning profile for signing. This means iOS CI jobs must target runs-on: macos-latest (or a pinned macOS version). macOS runners cost more per minute than Ubuntu — factor this into pipeline architecture decisions.

Managing iOS signing manually is brittle at team scale. Apple Distribution certificates have a 1-year validity period. Provisioning profiles expire and must be regenerated when a certificate rotates or a new device is added. Fastlane Match solves this by storing certificates and provisioning profiles in an encrypted Git repository (or S3/Google Cloud Storage). Every CI run decrypts and installs the current signing assets in one command: bundle exec fastlane match appstore --readonly true.

The readonly flag is critical in CI: it prevents match from generating new certificates during a routine build. Certificate generation should be a deliberate action. Store the match repository URL and decryption passphrase (MATCH_PASSWORD) as protected GitHub Secrets scoped to your production environment. For teams without Fastlane, manual certificate and profile installation is possible by base64-encoding the .p12 and .mobileprovision files as secrets and importing them into the macOS Keychain in the workflow — but this approach requires significantly more maintenance as certificates rotate.

After signing assets are installed, the Flutter build command for a release IPA is straightforward. The resulting .ipa can be uploaded directly to TestFlight using Fastlane's pilot command, or via the App Store Connect API through Transporter.

yamliOS CI job using Fastlane Match for reproducible certificate management. Match's --readonly flag prevents accidental certificate regeneration during routine builds.
# .github/workflows/deploy-ios.yml (relevant job excerpt)
jobs:
  build-ios:
    name: Build & Sign iOS IPA
    runs-on: macos-latest    # iOS builds REQUIRE macOS

    steps:
      - uses: actions/checkout@v4

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: 3.24.5
          cache: true

      - name: Install Ruby dependencies (Fastlane)
        run: bundle install

      - name: Install signing assets via Fastlane Match
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }}
        run: bundle exec fastlane match appstore --readonly true

      - name: Install Flutter packages
        run: flutter pub get

      - name: Build Flutter IPA
        run: |
          flutter build ipa \
            --release \
            --build-name=1.5.0 \
            --build-number=${{ github.run_number }} \
            --dart-define=ENV=prod

      - name: Upload to TestFlight
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_API_KEY_CONTENT }}
        run: bundle exec fastlane pilot upload --ipa build/ios/ipa/*.ipa --skip_waiting_for_build_processing true

Key Takeaway: Never store Apple certificates or provisioning profiles as repository files. Fastlane Match centralizes signing assets in an encrypted repository with a single access point. If Match is not an option, base64-encode the .p12 and .mobileprovision files and inject them through CI secrets — but document the rotation procedure carefully before any certificate expires.

5. Build Flavors and Dart-Define: Engineering Dev, Staging, and Production Environments

A Flutter app that ships to real users needs at least two runtime environments: one that developers test against without fear of corrupting production data, and one that is the genuine article. In practice, most production-grade apps need three: dev (local/CI experimentation), staging (QA signoff against a production-like backend), and prod (the release build submitted to app stores).

Flutter's --flavor flag maps to Android product flavors and iOS schemes. This requires native-side configuration: define a dev, staging, and prod product flavor in android/app/build.gradle.kts with distinct applicationId values (com.example.app.dev, com.example.app.staging, com.example.app), and create matching Xcode schemes in the iOS project. Each flavor can have its own app icon, display name, and Firebase configuration file (google-services.json / GoogleService-Info.plist), placed in the correct flavor-specific source set.

Compile-time environment values travel through --dart-define or --dart-define-from-file. The file-based approach is cleaner when there are more than two or three values: store non-sensitive configuration (API base URLs, feature flags, app identifiers) in versioned JSON files per environment. Read them in Dart with const String.fromEnvironment(). An important caveat: do not put genuine secrets (API keys with server-side privileges, signing credentials) in dart-define. Values compiled into the app binary can be extracted from the artifact with standard tooling.

yamlEnvironment configuration files for dart-define-from-file paired with --flavor. Only non-sensitive compile-time values belong in these JSON files — no credentials, no signing material.
# env/staging.json (committed to source control — no secrets here)
{
  "APP_ENV": "staging",
  "API_BASE_URL": "https://api-staging.example.com",
  "SENTRY_DSN": "https://staging-dsn@sentry.example.com/1",
  "ENABLE_DEBUG_BANNER": "true"
}

# env/production.json
{
  "APP_ENV": "production",
  "API_BASE_URL": "https://api.example.com",
  "SENTRY_DSN": "https://prod-dsn@sentry.example.com/1",
  "ENABLE_DEBUG_BANNER": "false"
}

# GitHub Actions build step using dart-define-from-file
      - name: Build staging AAB
        run: |
          flutter build appbundle \
            --release \
            --flavor staging \
            --dart-define-from-file=env/staging.json \
            --build-number=${{ github.run_number }}

Key Takeaway: Use --flavor to select the native build variant (different app IDs, icons, Firebase configs). Use --dart-define-from-file to inject non-secret compile-time configuration. These are complementary, not interchangeable. A staging build uses both: flutter build appbundle --flavor staging --dart-define-from-file=env/staging.json

6. Staged Delivery: Firebase App Distribution, TestFlight, and Store Promotion

A production release pipeline distinguishes between internal distribution and store delivery. Internal distribution (Firebase App Distribution for Android and TestFlight for iOS) gets staging builds to QA testers without going through the app store review queue. Store delivery is a separate, gated workflow that only runs after explicit sign-off — never automatically on every push to main.

Firebase App Distribution is the practical choice for distributing development and staging Android builds to tester groups. The wzieba/Firebase-Distribution-Github-Action action handles upload in a single step. Store your Firebase App ID (FIREBASE_APP_ID) and a service account JSON key (FIREBASE_SERVICE_ACCOUNT_JSON) as repository secrets. The service account needs the Firebase App Distribution Admin role in your GCP project.

For production Play Store delivery, a common approach is using a service account JSON key to upload the signed AAB via the Google Play Developer API. Configure the target track (internal, alpha, beta, or production) and release status explicitly. Never automate a direct rollout to the production track without a staging gate and explicit human approval. GitHub Actions environments with required reviewers provide an auditable approval gate without external tooling: define a production environment that blocks the deploy job until an authorized team member approves.

yamlTwo-stage Android delivery: Firebase App Distribution for tester builds, then a gated Play Store upload using a GitHub Actions environment with required reviewer approval.
# Staging delivery to Firebase App Distribution
      - name: Distribute staging build to Firebase
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_APP_ID_ANDROID_STAGING }}
          serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
          groups: qa-testers
          releaseNotes: "Staging build ${{ github.run_number }} from ${{ github.sha }}"
          file: build/app/outputs/bundle/stagingRelease/app-staging-release.aab

---

# Production Play Store delivery (requires GitHub environment approval)
# .github/workflows/release-production.yml
jobs:
  release-play-store:
    name: Release to Play Store
    runs-on: ubuntu-latest
    environment: production    # Blocks until a required reviewer approves

    steps:
      - uses: actions/checkout@v4

      - name: Download signed AAB from staging workflow
        uses: actions/download-artifact@v4
        with:
          name: android-release-aab

      - name: Upload to Play Store internal track
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.app
          releaseFiles: app-release.aab
          track: internal
          status: completed

Key Takeaway: The artifact that passes QA in staging should be the exact same artifact promoted to the Play Store. Rebuilding from source for the production workflow risks toolchain drift. Download and re-use the tested AAB artifact rather than rebuilding it. GitHub Actions environments with required reviewers give you an auditable approval gate without external tooling.

Topics:#Flutter#CI/CD#GitHub Actions#Android#iOS#Build Automation#Mobile Engineering#DevOps

Ready to build your next production application?

Share your product scope, platform requirements, and target timeline. I'll provide a concrete architecture and deployment blueprint.