Flutter CI CD with GitHub Actions: A Step-by-Step Tutorial

Step-by-step Flutter CI CD with GitHub Actions: lint, test, build Android iOS, sign, cache, and release to stores. Ready-to-copy YAML included.

ASOasis
7 min read
Flutter CI CD with GitHub Actions: A Step-by-Step Tutorial

Image used for representation purposes only.

Overview

This tutorial shows how to build a production grade Flutter CI CD pipeline on GitHub Actions. You will get ready to copy YAML that runs static checks, unit tests, builds signed Android bundles and iOS IPAs, and can optionally ship to Google Play and TestFlight. The focus is speed, repeatability, and secure handling of signing keys.

What you will build

  • Pull request CI that runs analyze, format check, and tests with coverage
  • Caching for pub and Gradle to make runs fast
  • Android release build and signing with a secure keystore
  • iOS release build and signing on macOS runners
  • Optional store delivery to Play and TestFlight
  • Artifacts you can download from every run

Prerequisites

  • Flutter stable set in your project
  • GitHub repo with Actions enabled
  • App configured for code signing
    • Android keystore and alias
    • iOS distribution certificate and provisioning profile
  • Basic store accounts if you plan to publish

Repository layout

.
├─ lib/
├─ test/
├─ android/
├─ ios/
└─ .github/workflows/

Secrets you will need

Add these under Repo settings Secrets and variables Actions

Android

  • ANDROID_KEYSTORE_BASE64 base64 of keystore file jks or keystore
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_ALIAS
  • ANDROID_KEY_PASSWORD
  • PLAY_SERVICE_ACCOUNT_JSON optional for publishing via Gradle Play Publisher

iOS

  • IOS_CERT_P12_BASE64 base64 of distribution p12
  • IOS_CERT_PASSWORD
  • IOS_PROVISION_PROFILE_BASE64 base64 of .mobileprovision for the app id
  • APP_STORE_KEY_P8_BASE64 base64 of App Store Connect API key p8
  • APP_STORE_KEY_ID
  • APP_STORE_ISSUER_ID

Misc

  • CODECOV_TOKEN optional if you use Codecov

1. Pull request CI lint, test, coverage

Create .github/workflows/flutter_ci.yml

name: Flutter CI

on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  analyze_test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

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

      - name: Flutter pub get
        run: flutter pub get

      - name: Format check
        run: flutter format --set-exit-if-changed .

      - name: Analyze
        run: flutter analyze

      - name: Run tests with coverage
        run: flutter test --coverage

      - name: Upload coverage artifact
        uses: actions/upload-artifact@v4
        with:
          name: coverage-lcov
          path: coverage/lcov.info

      - name: Upload to Codecov
        if: env.CODECOV_TOKEN != ''
        uses: codecov/codecov-action@v4
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
        with:
          files: coverage/lcov.info

Notes

  • subosito flutter action installs and caches the stable SDK
  • Use Java 17 for modern Android Gradle Plugin versions
  • concurrency avoids duplicate CI work on force pushes

2. Speed up builds with extra caches

For larger apps add Gradle and pub caches. Extend the job with

      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: |
            gradle-${{ runner.os }}-

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

3. Android release build and signing

Create .github/workflows/android_release.yml

name: Android Release

on:
  workflow_dispatch:
    inputs:
      track:
        description: Play track internal alpha beta production
        required: false
        default: internal
  push:
    tags:
      - v*

permissions:
  contents: read

jobs:
  android:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

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

      - name: Install dependencies
        run: flutter pub get

      - name: Decode keystore
        run: |
          mkdir -p android
          echo '${{ secrets.ANDROID_KEYSTORE_BASE64 }}' | base64 -d > android/keystore.jks

      - name: Create key.properties
        run: |
          cat > android/key.properties <<'EOF'
          storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
          keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
          keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
          storeFile=keystore.jks
          EOF

      - name: Build app bundle
        run: flutter build appbundle --release

      - name: Build apk
        run: flutter build apk --release

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: android-release
          path: |
            build/app/outputs/bundle/release/*.aab
            build/app/outputs/flutter-apk/app-release.apk

      - name: Publish to Play with Gradle Play Publisher
        if: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
        env:
          GPP_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
        run: |
          echo "$GPP_JSON" > android/play-service-account.json
          cd android
          ./gradlew publishRelease -Pandroid.injected.signing.store.file=keystore.jks

Project config snippet for Gradle Play Publisher in android app build gradle

plugins {
  id 'com.github.triplet.play' version '3.10.1'
}

play {
  serviceAccountCredentials.set(file('play-service-account.json'))
  defaultToAppBundles.set(true)
}

4. iOS release build and signing

Create .github/workflows/ios_release.yml

name: iOS Release

on:
  workflow_dispatch:
  push:
    tags:
      - v*

jobs:
  ios:
    runs-on: macos-14
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - name: Set up Xcode
        run: sudo xcode-select -switch /Applications/Xcode.app

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

      - name: Install dependencies
        run: flutter pub get

      - name: Set up keychain and signing
        env:
          P12_BASE64: ${{ secrets.IOS_CERT_P12_BASE64 }}
          P12_PASSWORD: ${{ secrets.IOS_CERT_PASSWORD }}
          PROVISION_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
        run: |
          KEYCHAIN=build.keychain
          security create-keychain -p '' $KEYCHAIN
          security set-keychain-settings -lut 21600 $KEYCHAIN
          security unlock-keychain -p '' $KEYCHAIN
          echo "$P12_BASE64" | base64 -d > cert.p12
          security import cert.p12 -k $KEYCHAIN -P "$P12_PASSWORD" -T /usr/bin/codesign
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          echo "$PROVISION_BASE64" | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/app.mobileprovision
          security list-keychain -d user -s $KEYCHAIN login.keychain

      - name: Pod install
        working-directory: ios
        run: |
          sudo gem install cocoapods --no-document
          pod repo update
          pod install

      - name: Build IPA
        run: flutter build ipa --release

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ios-release
          path: build/ios/ipa/*.ipa

      - name: Ship to TestFlight with fastlane pilot
        if: ${{ secrets.APP_STORE_KEY_P8_BASE64 != '' }}
        env:
          APP_STORE_KEY_P8_BASE64: ${{ secrets.APP_STORE_KEY_P8_BASE64 }}
          APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }}
          APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }}
        run: |
          brew install fastlane || true
          mkdir -p fastlane
          echo "$APP_STORE_KEY_P8_BASE64" | base64 -d > fastlane/AuthKey.p8
          cat > fastlane/Fastfile <<'EOF'
          default_platform(:ios)
          platform :ios do
            lane :beta do
              api_key = app_store_connect_api_key(
                key_id: ENV['APP_STORE_KEY_ID'],
                issuer_id: ENV['APP_STORE_ISSUER_ID'],
                key_filepath: 'fastlane/AuthKey.p8'
              )
              pilot(api_key: api_key, ipa: 'build/ios/ipa/*.ipa', skip_waiting_for_build_processing: true)
            end
          end
          EOF
          bundle exec fastlane ios beta || fastlane ios beta

Tips

  • Make sure the iOS Runner target uses Automatic signing with your team id in Xcode project settings
  • The macos runner name advances over time use the latest available in the docs if macos 14 is not present

5. Reusable workflows for mono repos or multiple apps

If you ship flavors or many apps, create a reusable workflow. Example

.github/workflows/flutter_reusable.yml

name: Flutter reusable
on:
  workflow_call:
    inputs:
      flutter_channel:
        required: false
        type: string
        default: stable
      build_target:
        required: true
        type: string
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: ${{ inputs.flutter_channel }}
      - run: flutter pub get
      - run: flutter build ${{ inputs.build_target }} --release

Then call it from a thin wrapper

name: Ship flavor
on: workflow_dispatch
jobs:
  aab:
    uses: .github/workflows/flutter_reusable.yml
    with:
      build_target: appbundle

6. Promotion by tag or manual dispatch

  • Push a tag like v1.2.3 to trigger both Android and iOS release workflows
  • Or run workflows manually from the Actions tab using workflow dispatch inputs

7. Security and secret hygiene

  • Keep secrets only in GitHub encrypted secrets
  • Never echo secrets in plain logs redact with shell patterns or use masked output
  • Rotate store and signing keys on a schedule
  • Limit Actions permissions to the minimum and prefer fine grained repo access
  • Avoid third party actions when a first party action exists

8. Common pitfalls and fixes

  • Gradle build fails with different Java version pin actions setup java to 17
  • iOS code sign no matching profiles team id or app id mismatch fix provisioning and ensure the profile uuid exists on the runner
  • CocoaPods issues run pod repo update and clear cache if needed
  • Large artifacts use aab for Play and keep ipa as the only iOS artifact
  • Slow runs add caches for pub pods and Gradle and avoid running jobs you do not need with paths filters

9. Local parity

Use the same commands locally that CI runs so failures are predictable

flutter clean
flutter pub get
flutter analyze
flutter test --coverage
flutter build appbundle --release
flutter build ipa --release

10. Checklist for going live

  • App ids package names and version codes match store metadata
  • Release notes and changelog included if you publish automatically
  • Screenshots and listing ready for first time submission
  • Crash reporting and analytics keys configured via env or secrets

Wrap up

With these workflows you have a solid baseline CI CD for Flutter on GitHub Actions. Start simple with analyze and tests, then layer in signed builds, caching, and automated store delivery. Iterate by measuring run times, pruning steps, and promoting only from a green main branch tagged with a semantic version.

Related Posts