Skip to content

Instantly share code, notes, and snippets.

@thimslugga
Forked from ravidsrk/flutter-cicd-guide.md
Created June 30, 2026 14:08
Show Gist options
  • Select an option

  • Save thimslugga/ecd91017b556ce61a1829dd87f351f65 to your computer and use it in GitHub Desktop.

Select an option

Save thimslugga/ecd91017b556ce61a1829dd87f351f65 to your computer and use it in GitHub Desktop.
Flutter CI/CD & Code Signing — Complete Reference Guide (Fastlane, Codemagic, Shorebird, GitHub Actions)

Flutter CI/CD & Code Signing — Complete Reference Guide

Purpose: Reusable reference for setting up automated builds, code signing, and deployment for Flutter apps across iOS and Android.

Last Updated: July 2025

Audience: Solo developers & small teams


Table of Contents


Overview & Tool Comparison

Approach Best For Cost Complexity iOS Signing
Codemagic Flutter-focused small teams Free tier + paid 🟢 Low Automatic
Fastlane + GitHub Actions Full control, complex flows Free (GH Actions minutes) 🔴 High Manual (Match)
Fastlane + Codemagic Best of both worlds Codemagic pricing 🟡 Medium Automatic
GitHub Actions only Cost-conscious, simple apps Free (GH Actions minutes) 🟡 Medium Manual
Xcode Cloud Apple-only teams Apple pricing 🟡 Medium Automatic
Bitrise / Codemagic Teams wanting managed CI Paid 🟢 Low Managed

Recommendation:

  • Want it working today → Codemagic
  • Want full control + free → Fastlane + GitHub Actions
  • Need OTA updates → Add Shorebird on top of either

Option 1: Codemagic (Recommended for Small Teams)

Why Codemagic over Fastlane for solo/small teams:

  • Built by the Flutter community, Flutter-first
  • Handles iOS code signing automatically (integrates with Apple Developer Portal)
  • No Ruby/Fastlane dependency headaches
  • 150,000+ developers trust it
  • Free tier: 500 build minutes/month on macOS

Setup

1. Sign Up

  • Go to codemagic.io
  • Connect your GitHub/GitLab/Bitbucket repo

2. Create codemagic.yaml in your project root

workflows:
  ios-release:
    name: iOS Release
    instance_type: mac_mini_m2
    max_build_duration: 60
    
    integrations:
      app_store_connect: <YOUR_ASC_KEY_NAME>
    
    environment:
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.yourcompany.yourapp
      flutter: stable
      xcode: latest
      cocoapods: default
    
    scripts:
      - name: Get Flutter packages
        script: flutter packages pub get
        
      - name: Build IPA
        script: |
          flutter build ipa --release \
            --build-name=1.0.$PROJECT_BUILD_NUMBER \
            --build-number=$PROJECT_BUILD_NUMBER \
            --export-options-plist=/Users/builder/export_options.plist
    
    artifacts:
      - build/ios/ipa/*.ipa
    
    publishing:
      app_store_connect:
        auth: integration
        submit_to_testflight: true
        # submit_to_app_store: true  # uncomment for production

  android-release:
    name: Android Release
    instance_type: mac_mini_m2
    max_build_duration: 60
    
    environment:
      android_signing:
        - <YOUR_KEYSTORE_REFERENCE>
      groups:
        - google_play_credentials
      flutter: stable
    
    scripts:
      - name: Get Flutter packages
        script: flutter packages pub get
        
      - name: Build AAB
        script: |
          flutter build appbundle --release \
            --build-name=1.0.$PROJECT_BUILD_NUMBER \
            --build-number=$PROJECT_BUILD_NUMBER
    
    artifacts:
      - build/app/outputs/bundle/**/*.aab
    
    publishing:
      google_play:
        credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS
        track: internal

3. Configure in Codemagic Dashboard

  • iOS: Add App Store Connect API key → Codemagic auto-fetches/creates certificates & profiles
  • Android: Upload keystore + Google Play service account JSON
  • Trigger: Set to run on push to main or tag creation

4. Push and watch it build

git add codemagic.yaml
git commit -m "Add Codemagic CI/CD"
git push

That's it. No Match, no keychain, no provisioning profile hell.


Option 2: Fastlane + GitHub Actions (Full Control)

Prerequisites

On your Mac (development machine):

# Install Fastlane
brew install fastlane
# OR
gem install fastlane

# Verify
fastlane --version

Required accounts:

  • Apple Developer Account ($99/year)
  • App Store Connect access
  • Google Play Developer Account ($25 one-time)
  • GitHub account

Project Structure

your_flutter_app/
├── android/
│   └── fastlane/
│       ├── Appfile                # Android app metadata
│       ├── Fastfile               # Android lanes
│       └── metadata/
│           └── versionCode        # Track version code
├── ios/
│   └── fastlane/
│       ├── Appfile                # iOS app metadata
│       ├── Fastfile               # iOS lanes
│       └── Matchfile              # Match configuration
├── .github/
│   └── workflows/
│       ├── build-ios.yml          # iOS CI/CD workflow
│       ├── build-android.yml      # Android CI/CD workflow
│       └── release.yml            # Combined release workflow
├── .gitignore
├── Gemfile                        # Ruby dependencies
└── pubspec.yaml

Gemfile (project root)

source "https://rubygems.org"

gem "fastlane"
gem "cocoapods"     # if using CocoaPods

plugins_path = File.join(File.dirname(__FILE__), 'ios', 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

plugins_path = File.join(File.dirname(__FILE__), 'android', 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

Run bundle install to generate Gemfile.lock. Commit both files.


iOS Setup

1. App Store Connect API Key

Why: API keys avoid 2FA/session issues in CI. This is the recommended auth method.

Steps:

  1. Go to App Store Connect → Users and Access → Keys
  2. Click + to generate a new key
  3. Name: Fastlane CI (or whatever you prefer)
  4. Access: App Manager (minimum for uploads)
  5. Download the .p8 file — you can only download it once
  6. Note the Key ID and Issuer ID

Store these values:

  • APPLE_KEY_ID — the Key ID (e.g., ABC1234DEF)
  • APPLE_ISSUER_ID — the Issuer ID (e.g., 12345678-abcd-efgh-ijkl-123456789012)
  • APPLE_KEY_CONTENT — Base64-encoded .p8 file content
# Encode your .p8 file to base64
base64 -i AuthKey_ABC1234DEF.p8 | pbcopy
# Paste this as APPLE_KEY_CONTENT

2. Fastlane Match (Certificate Management)

Match stores your certificates and provisioning profiles in a private Git repo (or Google Cloud Storage / S3). This is the single best way to manage code signing across machines and CI.

Initial setup (run once, from ios/ directory):

cd ios
fastlane match init

Choose git storage. Create a private repo first (e.g., github.com/yourname/certificates).

This creates ios/fastlane/Matchfile:

# ios/fastlane/Matchfile

git_url("https://github.com/yourname/certificates.git")

storage_mode("git")

type("appstore")  # default type

app_identifier("com.yourcompany.yourapp")

# Optional: For multiple apps
# app_identifier(["com.yourcompany.app1", "com.yourcompany.app2"])

# Optional: specific Apple ID
# username("your@email.com")

Generate certificates and profiles:

# Development (for running on devices)
fastlane match development

# App Store (for TestFlight + App Store)
fastlane match appstore

# Ad Hoc (for direct distribution)
fastlane match adhoc

Match will:

  1. Create certificates in Apple Developer Portal (if they don't exist)
  2. Create provisioning profiles
  3. Encrypt and store them in your Git repo
  4. Install them on your machine

Match passphrase: Match encrypts everything with a passphrase. Store this as MATCH_PASSWORD in your CI secrets.

⚠️ Critical Rules:

  • Never run match without readonly: true on CI
  • Only generate new certs from your local machine
  • If certs expire, run fastlane match nuke locally, then regenerate

3. iOS Fastfile

# ios/fastlane/Fastfile

default_platform(:ios)

APPLE_KEY_ID = ENV["APPLE_KEY_ID"]
APPLE_ISSUER_ID = ENV["APPLE_ISSUER_ID"]
APPLE_KEY_CONTENT = ENV["APPLE_KEY_CONTENT"]
DEVELOPER_APP_ID = ENV["DEVELOPER_APP_ID"]

platform :ios do
  # ─────────────────────────────────────────────
  # Helper: Get App Store Connect API key
  # ─────────────────────────────────────────────
  desc "Load App Store Connect API key"
  lane :load_api_key do
    app_store_connect_api_key(
      key_id: APPLE_KEY_ID,
      issuer_id: APPLE_ISSUER_ID,
      key_content: APPLE_KEY_CONTENT,
      is_key_content_base64: true,
      duration: 1200,
      in_house: false
    )
  end

  # ─────────────────────────────────────────────
  # Certificates & Profiles
  # ─────────────────────────────────────────────
  desc "Sync certificates and profiles"
  lane :sync_certs do |options|
    type = options[:type] || "appstore"
    api_key = load_api_key

    match(
      type: type,
      api_key: api_key,
      readonly: is_ci,            # CRITICAL: readonly on CI
      force_for_new_devices: !is_ci
    )
  end

  # ─────────────────────────────────────────────
  # Build
  # ─────────────────────────────────────────────
  desc "Build iOS app"
  lane :build do |options|
    sync_certs(type: "appstore")

    # Flutter build (run from project root before this lane, or use shell)
    # sh("cd ../.. && flutter build ipa --release --no-codesign")

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      output_directory: "../build/ios",
      output_name: "Runner.ipa",
      export_method: "app-store",
      export_options: {
        provisioningProfiles: {
          ENV["APP_IDENTIFIER"] => "match AppStore #{ENV["APP_IDENTIFIER"]}"
        }
      }
    )
  end

  # ─────────────────────────────────────────────
  # TestFlight
  # ─────────────────────────────────────────────
  desc "Upload to TestFlight"
  lane :beta do
    setup_ci if ENV['CI']   # Creates temporary keychain on CI
    api_key = load_api_key

    sync_certs(type: "appstore")

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "app-store"
    )

    upload_to_testflight(
      api_key: api_key,
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )
  end

  # ─────────────────────────────────────────────
  # App Store Release
  # ─────────────────────────────────────────────
  desc "Deploy to App Store"
  lane :release do
    setup_ci if ENV['CI']
    api_key = load_api_key

    sync_certs(type: "appstore")

    build_app(
      workspace: "Runner.xcworkspace",
      scheme: "Runner",
      export_method: "app-store"
    )

    upload_to_app_store(
      api_key: api_key,
      submit_for_review: false,       # Set true to auto-submit
      automatic_release: false,        # Set true for auto-release after approval
      force: true,                     # Skip HTML preview verification
      skip_metadata: true,             # Skip metadata upload (do it separately)
      skip_screenshots: true           # Skip screenshot upload
    )
  end

  # ─────────────────────────────────────────────
  # Utilities
  # ─────────────────────────────────────────────
  desc "Register new device"
  lane :register_device_lane do
    device_name = prompt(text: "Device name: ")
    device_udid = prompt(text: "Device UDID: ")
    api_key = load_api_key

    register_devices(
      devices: { device_name => device_udid },
      api_key: api_key
    )

    # Regenerate profiles to include new device
    match(type: "development", api_key: api_key, force_for_new_devices: true)
    match(type: "adhoc", api_key: api_key, force_for_new_devices: true)
  end

  desc "Nuke and regenerate all certificates (USE WITH CAUTION)"
  lane :nuke_certs do
    api_key = load_api_key
    match_nuke(type: "development", api_key: api_key)
    match_nuke(type: "appstore", api_key: api_key)
    match(type: "development", api_key: api_key)
    match(type: "appstore", api_key: api_key)
  end
end

4. iOS Appfile

# ios/fastlane/Appfile

app_identifier(ENV["APP_IDENTIFIER"] || "com.yourcompany.yourapp")
apple_id(ENV["APPLE_ID"] || "your@email.com")
team_id(ENV["TEAM_ID"] || "YOUR_TEAM_ID")
itc_team_id(ENV["ITC_TEAM_ID"] || "YOUR_ITC_TEAM_ID")

Android Setup

1. Google Play Service Account

Steps:

  1. Go to Google Play Console → Setup → API access
  2. Link to a Google Cloud Project (or create one)
  3. Click Create new service account
  4. In Google Cloud Console:
    • Create service account with name fastlane-deploy
    • Skip granting roles (Play Console handles permissions)
    • Create JSON key → download it
  5. Back in Play Console: Grant the service account Release Manager access
  6. Base64-encode the JSON:
    base64 -i play-store-service-account.json | pbcopy
  7. Store as PLAY_STORE_CONFIG_JSON (base64) in GitHub Secrets

2. Keystore Setup

Generate a keystore (one-time):

keytool -genkey -v \
  -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 \
  -validity 10000 \
  -alias upload

Configure signing in 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'] ?: System.getenv('KEY_ALIAS')
            keyPassword keystoreProperties['keyPassword'] ?: System.getenv('KEY_PASSWORD')
            storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : (System.getenv('KEYSTORE_PATH') ? file(System.getenv('KEYSTORE_PATH')) : null)
            storePassword keystoreProperties['storePassword'] ?: System.getenv('STORE_PASSWORD')
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

Local android/key.properties (DO NOT COMMIT):

storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=/Users/yourname/upload-keystore.jks

Add to .gitignore:

android/key.properties
*.jks
*.keystore

For CI: Base64-encode the keystore and store as a secret:

base64 -i ~/upload-keystore.jks | pbcopy
# Store as ANDROID_KEYSTORE_BASE64

3. Android Fastfile

# android/fastlane/Fastfile

default_platform(:android)

platform :android do
  # ─────────────────────────────────────────────
  # Version Management
  # ─────────────────────────────────────────────
  desc "Bump version code"
  lane :bump_version_code do
    version_code_file = "fastlane/metadata/versionCode"

    # Create file if it doesn't exist
    unless File.exist?(version_code_file)
      FileUtils.mkdir_p(File.dirname(version_code_file))
      File.write(version_code_file, "0")
    end

    version_code = File.read(version_code_file).to_i + 1
    File.write(version_code_file, version_code.to_s)

    UI.message("Version code bumped to #{version_code}")
    version_code
  end

  # ─────────────────────────────────────────────
  # Build
  # ─────────────────────────────────────────────
  desc "Build Android App Bundle"
  lane :build do |options|
    # Flutter build (run from project root before, or use shell)
    # sh("cd ../.. && flutter build appbundle --release")

    UI.message("AAB built at: ../build/app/outputs/bundle/release/app-release.aab")
  end

  # ─────────────────────────────────────────────
  # Internal Testing
  # ─────────────────────────────────────────────
  desc "Deploy to Google Play Internal Testing"
  lane :internal do
    bump_version_code

    upload_to_play_store(
      track: "internal",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
      skip_upload_metadata: true,
      skip_upload_changelogs: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end

  # ─────────────────────────────────────────────
  # Beta (Closed Testing)
  # ─────────────────────────────────────────────
  desc "Deploy to Google Play Closed Testing (Beta)"
  lane :beta do
    bump_version_code

    upload_to_play_store(
      track: "beta",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
      skip_upload_metadata: true,
      skip_upload_changelogs: false  # Include changelogs for beta testers
    )
  end

  # ─────────────────────────────────────────────
  # Production
  # ─────────────────────────────────────────────
  desc "Deploy to Google Play Production"
  lane :release do
    bump_version_code

    upload_to_play_store(
      track: "production",
      aab: "../build/app/outputs/bundle/release/app-release.aab",
      json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
      rollout: "0.1",              # 10% staged rollout — change to "1.0" for full
      skip_upload_metadata: true,
      skip_upload_changelogs: false
    )
  end

  # ─────────────────────────────────────────────
  # Promote
  # ─────────────────────────────────────────────
  desc "Promote internal build to production"
  lane :promote_to_production do
    upload_to_play_store(
      track: "internal",
      track_promote_to: "production",
      json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
      rollout: "1.0",
      skip_upload_changelogs: true
    )
  end

  # ─────────────────────────────────────────────
  # Firebase App Distribution (Alternative Beta)
  # ─────────────────────────────────────────────
  desc "Deploy to Firebase App Distribution"
  lane :firebase_beta do
    # Requires: fastlane add_plugin firebase_app_distribution
    firebase_app_distribution(
      app: ENV["FIREBASE_APP_ID_ANDROID"],
      groups: "internal-testers",
      android_artifact_type: "AAB",
      android_artifact_path: "../build/app/outputs/bundle/release/app-release.aab",
      release_notes: "Bug fixes and improvements"
    )
  end
end

4. Android Appfile

# android/fastlane/Appfile

json_key_file(ENV["GOOGLE_PLAY_JSON_KEY_PATH"] || "")
package_name(ENV["APP_IDENTIFIER"] || "com.yourcompany.yourapp")

GitHub Actions Workflows

GitHub Secrets Required

Add these in your repo → Settings → Secrets and variables → Actions:

iOS Secrets:

Secret Description
APPLE_KEY_ID App Store Connect API Key ID
APPLE_ISSUER_ID App Store Connect Issuer ID
APPLE_KEY_CONTENT Base64-encoded .p8 file content
MATCH_PASSWORD Passphrase for Match encryption
MATCH_GIT_BASIC_AUTHORIZATION Base64 of username:personal_access_token for cert repo
APP_IDENTIFIER e.g., com.yourcompany.yourapp
TEAM_ID Apple Developer Team ID
DEVELOPER_APP_ID App Store Connect App ID (numeric)

Android Secrets:

Secret Description
PLAY_STORE_CONFIG_JSON Base64-encoded Google Play service account JSON
ANDROID_KEYSTORE_BASE64 Base64-encoded .jks keystore file
KEY_ALIAS Keystore key alias
KEY_PASSWORD Key password
STORE_PASSWORD Store password

Generate MATCH_GIT_BASIC_AUTHORIZATION:

echo -n "your_github_username:ghp_your_personal_access_token" | base64

iOS Workflow (.github/actions/build-ios.yml)

name: iOS Build & Deploy

on:
  push:
    branches: [main]
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      lane:
        description: 'Fastlane lane to run'
        required: true
        default: 'beta'
        type: choice
        options:
          - beta
          - release

jobs:
  build-ios:
    runs-on: macos-14          # M1 runner (faster, ARM)
    timeout-minutes: 45

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.x'    # Pin your version
          channel: 'stable'
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true         # Caches gems automatically

      - name: Flutter dependencies
        run: flutter pub get

      - name: Flutter build (no codesign)
        run: flutter build ios --release --no-codesign

      - name: Install Fastlane dependencies
        run: |
          cd ios
          bundle install

      - name: Run Fastlane
        env:
          APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
          APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
          APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          APP_IDENTIFIER: ${{ secrets.APP_IDENTIFIER }}
          TEAM_ID: ${{ secrets.TEAM_ID }}
          DEVELOPER_APP_ID: ${{ secrets.DEVELOPER_APP_ID }}
        run: |
          cd ios
          bundle exec fastlane ${{ github.event.inputs.lane || 'beta' }}

      - name: Upload IPA artifact
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: ios-build
          path: build/ios/**/*.ipa
          retention-days: 14

Android Workflow (.github/actions/build-android.yml)

name: Android Build & Deploy

on:
  push:
    branches: [main]
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      lane:
        description: 'Fastlane lane to run'
        required: true
        default: 'internal'
        type: choice
        options:
          - internal
          - beta
          - release

jobs:
  build-android:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'gradle'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.x'
          channel: 'stable'
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Flutter dependencies
        run: flutter pub get

      - name: Decode keystore
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks

      - name: Create key.properties
        run: |
          cat > android/key.properties <<EOF
          storePassword=${{ secrets.STORE_PASSWORD }}
          keyPassword=${{ secrets.KEY_PASSWORD }}
          keyAlias=${{ secrets.KEY_ALIAS }}
          storeFile=upload-keystore.jks
          EOF

      - name: Flutter build AAB
        run: flutter build appbundle --release

      - name: Decode Play Store credentials
        run: |
          echo "${{ secrets.PLAY_STORE_CONFIG_JSON }}" | base64 --decode > android/fastlane/play-store-key.json

      - name: Run Fastlane
        env:
          PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
        run: |
          cd android
          bundle install
          bundle exec fastlane ${{ github.event.inputs.lane || 'internal' }}

      - name: Upload AAB artifact
        if: success()
        uses: actions/upload-artifact@v4
        with:
          name: android-build
          path: build/app/outputs/bundle/release/*.aab
          retention-days: 14

      - name: Cleanup secrets
        if: always()
        run: |
          rm -f android/app/upload-keystore.jks
          rm -f android/key.properties
          rm -f android/fastlane/play-store-key.json

Combined Workflow (.github/actions/release.yml)

name: Release (iOS + Android)

on:
  push:
    tags:
      - 'v*'     # Trigger on version tags: v1.0.0, v1.2.3, etc.

jobs:
  ios:
    uses: ./.github/workflows/build-ios.yml
    secrets: inherit
    with:
      lane: release

  android:
    uses: ./.github/workflows/build-android.yml
    secrets: inherit
    with:
      lane: release

  notify:
    needs: [ios, android]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Summary
        run: |
          echo "## Release Summary" >> $GITHUB_STEP_SUMMARY
          echo "- iOS: ${{ needs.ios.result }}" >> $GITHUB_STEP_SUMMARY
          echo "- Android: ${{ needs.android.result }}" >> $GITHUB_STEP_SUMMARY

Zero-Config Alternative: flutter-fastlane-action

If you want minimal setup, use the MTtankkeo/flutter-fastlane-action GitHub Action:

name: Deploy with flutter-fastlane-action

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      
      - uses: MTtankkeo/flutter-fastlane-action@v1
        with:
          platform: 'ios'               # or 'android'
          lane: 'beta'
          apple-key-id: ${{ secrets.APPLE_KEY_ID }}
          apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
          apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
          match-password: ${{ secrets.MATCH_PASSWORD }}
          match-git-url: 'https://github.com/yourname/certificates.git'
          match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}

This action auto-configures Fastlane if your project doesn't have it. Only 6 secrets needed for iOS.


Version Management

Automated Version Bumping

Add this to your root project or as a shared script:

# In either Fastfile — or a shared Fastfile
desc "Bump version from pubspec.yaml"
lane :bump_version do |options|
  type = options[:type] || "patch"  # major, minor, patch
  
  pubspec = File.read("../../pubspec.yaml")
  current_version = pubspec.match(/version:\s*(\d+\.\d+\.\d+)\+(\d+)/)
  
  major, minor, patch = current_version[1].split('.').map(&:to_i)
  build = current_version[2].to_i + 1
  
  case type
  when "major"
    major += 1; minor = 0; patch = 0
  when "minor"
    minor += 1; patch = 0
  when "patch"
    patch += 1
  end
  
  new_version = "#{major}.#{minor}.#{patch}+#{build}"
  new_pubspec = pubspec.gsub(/version:\s*\d+\.\d+\.\d+\+\d+/, "version: #{new_version}")
  File.write("../../pubspec.yaml", new_pubspec)
  
  UI.success("Version bumped to #{new_version}")
end

Git Tag-Based Releases

# Bump version, commit, tag, push
flutter pub version patch   # or manually edit pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to 1.0.1"
git tag v1.0.1
git push && git push --tags  # Triggers release workflow

Environment / Flavor Support

For apps with multiple environments (dev, staging, production):

Flutter Flavors

# Define flavors in android/app/build.gradle and ios scheme
flutter build appbundle --flavor production --target lib/main_production.dart
flutter build ipa --flavor production --target lib/main_production.dart

Fastfile with Flavors

# ios/fastlane/Fastfile
platform :ios do
  lane :deploy do |options|
    env = options[:env] || "production"
    
    scheme = case env
             when "dev" then "Runner-Dev"
             when "staging" then "Runner-Staging"
             else "Runner"
             end
    
    identifier = case env
                 when "dev" then "com.yourcompany.yourapp.dev"
                 when "staging" then "com.yourcompany.yourapp.staging"
                 else "com.yourcompany.yourapp"
                 end
    
    sync_certs(type: "appstore", app_identifier: identifier)
    
    build_app(
      workspace: "Runner.xcworkspace",
      scheme: scheme,
      export_method: "app-store"
    )
    
    upload_to_testflight(api_key: load_api_key)
  end
end

Usage:

fastlane deploy env:staging
fastlane deploy env:production

Option 3: Shorebird (OTA Updates)

Shorebird lets you push code updates to Flutter apps without going through App Store review. It's complementary to Fastlane/Codemagic — not a replacement.

When to Use Shorebird

  • Hot-fixing bugs without waiting for App Store review (1–7 days)
  • A/B testing features
  • Updating business logic, UI tweaks
  • Cannot change native code, assets, or platform channels

Setup

# Install Shorebird CLI
curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash

# Initialize in your Flutter project
shorebird init

# Create a release (instead of flutter build)
shorebird release android
shorebird release ios

# Push a patch (OTA update)
shorebird patch android
shorebird patch ios

Integrate with Fastlane

# In your Fastfile
lane :patch do
  sh("shorebird patch ios")
  sh("shorebird patch android")
  UI.success("OTA patch pushed!")
end

Integrate with GitHub Actions

- name: Setup Shorebird
  uses: shorebirdtech/setup-shorebird@v1
  with:
    cache: true

- name: Push Shorebird Patch
  env:
    SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
  run: shorebird patch android --force

Troubleshooting

iOS Code Signing Issues

🔴 "No code signing identities found"

# Nuke and regenerate
fastlane match nuke development
fastlane match nuke appstore
fastlane match development
fastlane match appstore

🔴 "Provisioning profile doesn't include signing certificate"

# Force profile regeneration
fastlane match appstore --force

🔴 Match encryption error: "couldn't set additional authenticated data"

  • Your MATCH_PASSWORD is wrong or changed
  • Re-run fastlane match locally with the correct passphrase
  • If lost, nuke and start over: fastlane match nuke

🔴 "No provisioning profile provided" on CI

  • Ensure setup_ci is called (creates temporary keychain)
  • Check that APP_IDENTIFIER matches exactly (including case)
  • Verify Match repo has profiles for the right bundle ID

🔴 flutter build ipa fails with manual signing

  • Use --no-codesign flag: flutter build ios --release --no-codesign
  • Let Fastlane build_app (gym) handle signing separately
  • Or provide custom ExportOptions.plist

🟡 CI build hangs on "codesign" step

  • Keychain is locked. Ensure setup_ci runs first
  • If using custom keychain:
    create_keychain(
      name: "ci_keychain",
      password: "ci_password",
      default_keychain: true,
      unlock: true,
      timeout: 3600
    )

🟡 "Certificate has been revoked"

  • Someone manually revoked it in Apple Developer Portal
  • Run fastlane match nuke then regenerate

Android Build Issues

🔴 "Keystore was tampered with, or password was incorrect"

  • Base64 encoding/decoding corrupted the file
  • Re-encode: base64 -i keystore.jks | tr -d '\n' (remove newlines)
  • Verify: base64 --decode < encoded.txt > test.jks && keytool -list -keystore test.jks

🔴 "Upload failed: APK specifies a version code that has already been used"

  • Bump versionCode in pubspec.yaml (1.0.0+2 → the +2 is the version code)
  • Or use the bump_version_code lane

🟡 "Google Play API access not configured"

  • Ensure service account has Release Manager permissions in Play Console
  • Wait ~24 hours after creating the service account (propagation delay)
  • First upload must be done manually via Play Console

Fastlane General Issues

🔴 "Could not find 'fastlane' gem"

bundle install
bundle exec fastlane [lane]  # Always use bundle exec

🔴 Ruby version conflicts

# Use rbenv or asdf for Ruby version management
rbenv install 3.2.0
rbenv local 3.2.0

🟡 Slow Fastlane execution

  • Add skip_docs to your Fastfile: skip_docs
  • Use --skip_waiting_for_build_processing for TestFlight uploads
  • Cache gems in CI with bundler-cache: true

🟡 "Lane not found"

  • Ensure you're in the right directory (ios/ or android/)
  • Run bundle exec fastlane lanes to list available lanes

Reference Repos & Resources

GitHub Repos

Repo Stars Description
fastlane/fastlane 41K+ Official Fastlane
microservicer/flutter-pipelines 88 Complete Flutter CI/CD pipeline
MTtankkeo/flutter-fastlane-action 32 Zero-config GitHub Action
dotdoom/fastlane-plugin-flutter 40 Official Flutter plugin
jtmuller5/Fastfile gist Best combined Fastfile example
lukepighetti/nightly-testflight Nightly TestFlight workflow

Documentation

Resource Link
Flutter CD Guide (Official) flutter.dev/docs/deployment/cd
Fastlane Docs docs.fastlane.tools
Fastlane Match Guide docs.fastlane.tools/actions/match
Codemagic Docs docs.codemagic.io
Shorebird Docs docs.shorebird.dev
App Store Connect API developer.apple.com
Google Play API developers.google.com

Articles

Article Source
Automate Flutter Deployments to Stores Constant Solutions (2024)
CI/CD in Flutter with Match + GH Actions Marvel Apps (2025)
Production Flutter CI/CD Pipeline freeCodeCamp

Quick Start Checklist

If Using Codemagic (Fastest Path)

  • Sign up at codemagic.io
  • Connect your Git repository
  • Add App Store Connect API key in dashboard
  • Upload Android keystore in dashboard
  • Add Google Play service account JSON
  • Create codemagic.yaml in project root
  • Push and build

If Using Fastlane + GitHub Actions (Full Control)

  • Install Fastlane (brew install fastlane)
  • Create Gemfile in project root
  • iOS: App Store Connect
    • Generate API key (.p8 file)
    • Note Key ID and Issuer ID
    • Base64-encode the .p8 file
  • iOS: Match
    • Create private Git repo for certificates
    • Run fastlane match init in ios/
    • Run fastlane match development
    • Run fastlane match appstore
    • Note the Match passphrase
  • iOS: Fastlane
    • Create ios/fastlane/Fastfile
    • Create ios/fastlane/Appfile
    • Test locally: cd ios && bundle exec fastlane beta
  • Android: Google Play
    • Create service account + JSON key
    • Grant Release Manager access in Play Console
    • Upload first build manually to Play Console
  • Android: Keystore
    • Generate keystore (keytool -genkey ...)
    • Configure build.gradle signing
    • Create key.properties (local only)
    • Base64-encode keystore for CI
  • Android: Fastlane
    • Create android/fastlane/Fastfile
    • Create android/fastlane/Appfile
    • Test locally: cd android && bundle exec fastlane internal
  • GitHub Actions
    • Add all secrets to repo settings
    • Create workflow YAML files
    • Push and verify builds
  • Optional: Shorebird
    • Install CLI
    • Run shorebird init
    • Add SHOREBIRD_TOKEN to secrets

💡 Pro Tip: Start with Codemagic to ship fast. Migrate to Fastlane + GitHub Actions when you need custom lanes, multi-flavor builds, or fine-grained control. They're not mutually exclusive — you can use Codemagic as the CI runner and still use Fastlane for custom automation.


This document is a living reference. Update it as tools evolve and your workflow matures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment