Skip to content

Guides

Flutter CI/CD with Fastlane and GitHub Actions ​

Short answer. Put the build and upload steps in Fastlane lanes, so you can run them on your laptop. Then make GitHub Actions run the same lanes when you push a tag. Fastlane knows how to sign and upload. GitHub Actions knows when and where to run it. If a release fails in CI, you can reproduce it locally with the same command.

This guide builds a small pipeline for a plain Flutter app: Android to the Play Store internal track, iOS to TestFlight, both started by a version tag. The last section shows what Archipelago generates for the same job.

The pieces ​

PieceJob
flutter buildMakes the signed .aab (Android) or .ipa (iOS)
Fastlane laneA named Ruby function that runs the build and the upload
MatchKeeps iOS certificates and profiles in a private Git repo, so CI can sign
Store API keysLet CI upload without your personal login or two-factor prompt
GitHub ActionsStarts the lanes on a tag and holds the secrets

Project layout ​

Put Gemfile and fastlane/ next to your pubspec.yaml, and use one Fastfile for both platforms:

text
my_app/
├── Gemfile
├── fastlane/
│   ├── Appfile
│   ├── Fastfile
│   └── Matchfile
├── android/
├── ios/
└── pubspec.yaml
ruby
# Gemfile
source "https://rubygems.org"

gem "fastlane"

Run bundle install once. Always call bundle exec fastlane, so your laptop and CI use the same Fastlane version.

One rule that trips people up: code written directly in the Fastfile (such as sh) runs inside the fastlane/ folder, but Fastlane actions (such as upload_to_play_store) run from the project root. This is described in the Fastlane docs. That is why the examples below use cd .. before flutter build and give action paths relative to the root.

A more robust habit is to build absolute paths once, for example APP_ROOT = File.expand_path("..", FastlaneCore::FastlaneFolder.path), and use it for every File.read, File.exist? and sh. Then no line depends on where Fastlane happens to run.

Android ​

1. Sign release builds ​

Flutter's Android deployment guide shows how to read a key.properties file in android/app/build.gradle.kts. That file must not be committed. In CI you will write it from secrets in a later step. Follow the Flutter guide until flutter build appbundle --release gives you a signed bundle on your machine.

2. Create a Play Console service account ​

Create a service account in Google Cloud, grant it access to your app in Play Console, and download its JSON key. Fastlane's supply action reads that key. Treat the file like a password.

3. Write the lane ​

ruby
# fastlane/Fastfile
default_platform(:android)

platform :android do
  desc "Build the production bundle and upload it to the internal track"
  lane :internal do |options|
    build_number = options[:build_number] || ENV["BUILD_NUMBER"] || "1"

    sh("cd .. && flutter build appbundle --release --flavor production --build-number=#{build_number}")

    upload_to_play_store(
      track: "internal",
      aab: "build/app/outputs/bundle/productionRelease/app-production-release.aab",
      json_key_data: ENV["PLAY_JSON_KEY"],
      package_name: "com.example.myapp",
    )
  end
end

json_key_data takes the raw JSON text, so CI can pass a secret straight in. Locally, set PLAY_JSON_KEY="$(cat path/to/key.json)". The --flavor production flag assumes you set up flavors. See Flutter flavors. Drop the flag and use release in the path if you have no flavors.

Google Play needs the version code to go up with every upload. Flutter maps --build-number to it, so pass a number that always grows.

Run it:

bash
bundle exec fastlane android internal build_number:42

iOS ​

1. Use an App Store Connect API key ​

Create an API key in App Store Connect (Users and Access > Integrations). A key avoids the Apple ID password and the two-factor prompt that block CI. Save the key ID, the issuer ID and the .p8 file.

2. Store certificates with Match ​

Match keeps your signing certificate and provisioning profiles encrypted in a private Git repo. Every machine and CI run downloads the same ones.

ruby
# fastlane/Matchfile
git_url(ENV["MATCH_GIT_URL"])
storage_mode("git")
type("appstore")
app_identifier("com.example.myapp")

Create the certificates once from your laptop with bundle exec fastlane match appstore. CI then only reads them.

3. Write the lane ​

ruby
# fastlane/Fastfile
platform :ios do
  desc "Build the production app and upload it to TestFlight"
  lane :beta do |options|
    build_number = options[:build_number] || ENV["BUILD_NUMBER"] || "1"

    setup_ci if ENV["CI"]

    app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key_content: ENV["ASC_KEY_CONTENT"],
      is_key_content_base64: true,
    )

    match(type: "appstore", readonly: true)

    sh("cd .. && flutter build ipa --release --flavor production --build-number=#{build_number} --export-options-plist=ios/ExportOptions.plist")

    upload_to_testflight(
      ipa: File.expand_path(Dir.glob("../build/ios/ipa/*.ipa").first),
      skip_waiting_for_build_processing: true,
    )
  end
end

What the pieces do:

  • setup_ci makes a temporary keychain on the CI machine and switches Match to read-only mode. Without it, signing can hang on a keychain prompt.
  • app_store_connect_api_key loads the key for the actions that follow, so upload_to_testflight does not ask for a login.
  • File.expand_path(...) turns the path into an absolute one. The Fastfile runs in fastlane/, but the upload action runs from the root.

flutter build ipa signs with whatever the Xcode project says, so the Release-production configuration must use manual signing with the Match profile. Match names profiles match AppStore <bundle id> by default. Set that profile in Xcode, or with Fastlane's update_code_signing_settings action. Then describe it in ios/ExportOptions.plist:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>method</key>
  <string>app-store</string>
  <key>signingStyle</key>
  <string>manual</string>
  <key>provisioningProfiles</key>
  <dict>
    <key>com.example.myapp</key>
    <string>match AppStore com.example.myapp</string>
  </dict>
</dict>
</plist>

Run it locally to check the lane before CI does:

bash
bundle exec fastlane ios beta build_number:42

The GitHub Actions workflow ​

Two jobs, one per platform. iOS needs a macOS runner. Android runs on Linux.

yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*"

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

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.2"
          bundler-cache: true

      - name: Write the signing files
        run: |
          echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
          cat > android/key.properties <<EOF
          storePassword=$ANDROID_STORE_PASSWORD
          keyPassword=$ANDROID_KEY_PASSWORD
          keyAlias=upload
          storeFile=upload-keystore.jks
          EOF
        env:
          ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
          ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}

      - run: flutter pub get

      - name: Upload to the internal track
        run: bundle exec fastlane android internal build_number:${{ github.run_number }}
        env:
          PLAY_JSON_KEY: ${{ secrets.PLAY_JSON_KEY }}

  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.2"
          bundler-cache: true

      - run: flutter pub get

      - name: Upload to TestFlight
        run: bundle exec fastlane ios beta build_number:${{ github.run_number }}
        env:
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}

Release with a tag:

bash
git tag v1.2.0
git push origin v1.2.0

flutter build reads the version name from pubspec.yaml (the part before +), so bump it before you tag. github.run_number gives the build number. It grows with every run of this workflow, which is what both stores need.

Secrets ​

Add these in Settings > Secrets and variables > Actions.

SecretWhat it is
ANDROID_KEYSTORE_BASE64, ANDROID_STORE_PASSWORD, ANDROID_KEY_PASSWORDYour upload keystore (base64 -i upload-keystore.jks) and its passwords
PLAY_JSON_KEYThe full text of the Play service account JSON
MATCH_GIT_URL, MATCH_PASSWORDThe certificates repo and the password that encrypts it
MATCH_GIT_BASIC_AUTHORIZATIONBase64 of username:token, so CI can clone the private certificates repo
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENTThe App Store Connect API key. Base64-encode the .p8 file for ASC_KEY_CONTENT

Common problems ​

  • The build number is not higher than the last upload. Both stores reject it. Use a number that only goes up, such as github.run_number or the commit count.
  • Match prompts for a password or the keychain hangs. Call setup_ci and set MATCH_PASSWORD.
  • file not found for the bundle or ipa. Fastfile code runs in fastlane/, actions run in the root. Check which one reads the path.
  • Everything works locally but the iOS job fails on signing. Locally, Xcode may sign automatically with your own account. On CI, only the Match profile exists, so manual signing must be set for the release configuration.
  • iOS jobs cost more. GitHub bills macOS runners at a higher rate than Linux. Run the iOS job on tags and on demand, not on every push.

How Archipelago does this ​

archipelago create generates the Fastlane setup and the workflows. There is no extra command.

  • The app package gets Gemfile, fastlane/Appfile, Fastfile, Matchfile and Pluginfile. The lanes take a flavor: option, so one command builds development, staging or production: bundle exec fastlane android deploy_play_store flavor:production track:internal.
  • Lanes also distribute to Firebase App Distribution, upload to TestFlight, and submit to the App Store.
  • .github/workflows/ has release-mobile.yml, which starts on a mobile/v* tag and calls reusable build-android.yml and build-ios.yml workflows that run those lanes. Pick GitLab CI instead with the gitProvider option and you get a .gitlab-ci.yml with the same stages.
  • Every lane runs build-prepare before the build and a leak check after it, so debug tools do not reach a production build. See Build Prepare.
  • Some pull request checks in the generated workflows (size guard, permission guard) need Pro devtool bricks. Fastlane and the release workflow do not.

References: Fastlane Setup, GitHub Actions Workflows, and the flutter_modular_monorepo brick.

FAQ ​

Do I need Fastlane, or can GitHub Actions upload to the stores directly? ​

You can call flutter build and a store upload action from a workflow without Fastlane. Fastlane adds Match for iOS signing and lanes you can run on your laptop. For a single Android app, a workflow alone can be enough.

Why use a lane instead of putting the commands in the workflow? ​

A lane runs the same way on your laptop and in CI. When a release fails, you reproduce it with one command instead of editing the workflow and pushing again.

Can I build iOS on a Linux runner? ​

No. iOS builds need macOS and Xcode.

Does Match work without a private Git repo? ​

Yes. Match can also store files in Google Cloud Storage or Amazon S3. Git is the simplest start.

How do I deploy to Firebase App Distribution instead of the stores? ​

Install the fastlane-plugin-firebase_app_distribution plugin and call firebase_app_distribution in a lane. Archipelago's generated lanes do this for the distribute_firebase lane.

Try Archipelago

Make a free account, install the CLI, and generate a project:

dart pub global activate archipelago_cli
archipelago auth login --interactive
archipelago create --output ./my_app

The Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.