All Articles
CategoryFlutter
Reading Time
13 min read
Published
2025-02-18
Word Count
3,531words

Grab a coffee — this one is a deep dive!

Flutter CI/CD: Automated Releases with GitHub Actions and Fastlane

Summary

How to set up a Flutter CI/CD pipeline with GitHub Actions and Fastlane: analyze-test-build-deploy stages, match certificates, and TestFlight/Play Internal Testing uploads.

  • Split the pipeline into separate jobs: analyze/test (cheap ubuntu-latest) and build/deploy (macos-14).
  • Share certificates and provisioning profiles with fastlane match on git/Google Cloud/S3, use readonly: true in CI.
  • Automatically upload to TestFlight with pilot and to Play Internal Testing with upload_to_play_store (track: internal).
  • Automate the build number with github.run_number, manage every secret through GitHub repository secrets.
Flutter CI/CD: Automated Releases with GitHub Actions and Fastlane

Running flutter build ipa from the terminal on every push and uploading it to App Store Connect by hand doesn't cause problems while you're working alone; but the moment the team grows and the number of platforms increases, it stops being sustainable. A Flutter CI/CD pipeline built with the GitHub Actions and Fastlane duo automatically runs the analyze-test-build-deploy steps every time code is pushed and removes human error from the process. In this guide we set this pipeline up from scratch, keeping an eye on macOS runner cost.

💡 Pro Tip: Trigger the deploy job only on pushes to the main branch; uploading to TestFlight on every PR wastes both your minute budget and floods testers' notification inboxes unnecessarily.

Table of Contents

The Pipeline's 4 Stages: Analyze, Test, Build, Deploy

Splitting a Flutter CI/CD pipeline into four separate steps, instead of cramming it into a single dev job, catches errors earlier and avoids wasting expensive macOS runner minutes. Flutter's official continuous delivery guide also recommends setting up deployment through fastlane in separate stages.

Stage
Command / Tool
Purpose
Analyze
flutter analyze
Static analysis, lint errors — takes seconds, can run on a Linux runner
Test
flutter test
Unit + widget tests — runs on a Linux runner, doesn't need macOS
Build
flutter build ipa / flutter build appbundle
Produces platform binaries — a macOS runner is required for iOS
Deploy
fastlane pilot / supply
Upload to TestFlight and Play Internal Testing

The critical point: only the build and deploy steps need a macOS runner. Running the analyze and test steps on a cheap ubuntu-latest runner directly lowers your minute bill.

GitHub Actions Workflow Skeleton

A workflow on GitHub Actions is a YAML file under .github/workflows/; it consists of name, on (trigger) and jobs fields, and each job in turn contains runs-on (the runner it runs on) and steps (the list of steps). The skeleton below splits the 4 stages into separate jobs; the test job runs on Linux, and the build/deploy jobs run on macOS:

yaml
1name: Flutter CI/CD
2 
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8 
9jobs:
10 analyze_test:
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v4
14 - uses: subosito/flutter-action@v2
15 with:
16 flutter-version: "3.27.0"
17 - run: flutter pub get
18 - run: flutter analyze
19 - run: flutter test
20 
21 build_deploy:
22 needs: analyze_test
23 if: github.ref == 'refs/heads/main'
24 runs-on: macos-14
25 steps:
26 - uses: actions/checkout@v4
27 - uses: subosito/flutter-action@v2
28 with:
29 flutter-version: "3.27.0"
30 - run: flutter pub get
31 - name: Install signing via fastlane match
32 run: cd ios && bundle exec fastlane signing
33 env:
34 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
35 - run: flutter build ipa --release
36 - name: Deploy via fastlane
37 run: cd ios && bundle exec fastlane release
38 env:
39 APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}

The subosito/flutter-action community action installs a specific Flutter SDK version on the runner; the official GitHub Actions documentation describes a workflow as consisting of name, on, jobs, runs-on and steps fields, and describes uses: actions/checkout@v4 as checking out the repo's code onto the runner.

Triggers (on)

The on block determines when the workflow runs. The example above has both a push and a pull_request trigger; but the if: github.ref == 'refs/heads/main' condition on the build_deploy job ensures that only the analyze_test job runs on PRs, while the build and deploy steps are only triggered when an actual push lands on main. This separation prevents every opened PR from burning unnecessary macOS runner minutes.

Dependency Between Jobs (needs)

The needs: analyze_test line guarantees that the build_deploy job will not start before the analyze_test job finishes successfully. If analysis or tests come back red, the build is never triggered — this stops a broken piece of code from ever making it to TestFlight in the first place.

macOS Runner Cost and Cache Strategy

macOS runners are noticeably more expensive than Linux runners on GitHub Actions; that's why in the skeleton above we deliberately assigned the analyze/test job to ubuntu-latest, and only build/deploy to macos-14. The second way to cut cost further is caching.

GitHub's official actions/cache documentation states that the action first looks for an exact match with key, and if it can't find one, falls back to a partial match in order with restore-keys. In Flutter projects you can cache the pub cache like this:

yaml
1- uses: actions/cache@v4
2 with:
3 path: |
4 ~/.pub-cache
5 **/.dart_tool
6 key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
7 restore-keys: |
8 ${{ runner.os }}-pub-

As long as pubspec.lock doesn't change, this cache stays the same and flutter pub get finishes in seconds. On the iOS side you can similarly cache the ios/Pods directory keyed on the Podfile.lock hash for CocoaPods — this directly shortens one of the slowest steps on the macOS runner (pod install).

Certificate and Profile Management with Fastlane match

iOS code signing is the most headache-inducing part of CI, because certificates and provisioning profiles are normally tied to a single Mac. fastlane's match tool solves this: according to the official documentation, match "creates all the certificates and provisioning profiles you need and stores them in a separate git repository, Google Cloud, or Amazon S3," so that the team and CI share the same signing identity.

Storage
How it works
When to prefer it
Git repo
Certificates are encrypted with OpenSSL and pushed to a private repo
Small-to-medium team, don't want an extra cloud account
Google Cloud
Encrypted with keys Google manages and stored in GCS
Teams already using GCP
Amazon S3
Stored in a bucket you provide yourself
Teams already using AWS

Creating a Matchfile

Running fastlane match init creates a Matchfile:

ruby
1git_url("https://github.com/<org>/certificates")
2app_identifier("com.example.app")
3username("[email protected]")

Usage in the CI Lane

match must be called before the step that produces the signed build — the documentation states this explicitly: "match should be called before building with gym". In this pipeline the IPA is produced by the workflow's flutter build ipa step rather than fastlane's gym action, so match lives in a separate signing lane that the workflow calls before the build step:

ruby
1lane :signing do
2 match(type: "appstore", readonly: true)
3end

Using readonly: true in CI prevents the pipeline from accidentally generating a new certificate — this distinction is especially critical in a multi-developer + CI combination.

TestFlight and Play Internal Testing Upload

iOS: TestFlight with pilot

Once the build is signed, it's time to distribute it. fastlane's pilot action (also known as upload_to_testflight) uploads the build to TestFlight; the official documentation lists not needing 2FA and better performance over an Apple ID among the advantages of the API key method:

ruby
1lane :release do
2 pilot(
3 ipa: Dir[File.expand_path("../../build/ios/ipa/*.ipa")].first,
4 api_key_path: "./fastlane/api_key.json",
5 skip_waiting_for_build_processing: true
6 )
7end

The ipa parameter gives the path of the file to upload: the lane doesn't build again, it uploads the IPA that the workflow's flutter build ipa step produced under build/ios/ipa/ (Ruby code in the Fastfile runs from the fastlane/ folder, so the path starts two directories up). The skip_waiting_for_build_processing: true parameter controls whether fastlane waits for the build to finish processing on Apple's side; if you don't want the CI job hanging for minutes after the build is uploaded, leaving this value true lets the job trigger the upload and finish right away — but in that case distribute_external will not work and the build is not distributed to testers automatically.

Android: Play Internal Testing with supply

The Android counterpart is the upload_to_play_store action (also known as supply). According to the documentation the default track options are production, beta, alpha, internal — uploading to the internal track rather than directly to production on every push is the right habit from CI:

ruby
1platform :android do
2 lane :release do
3 upload_to_play_store(
4 track: "internal",
5 json_key: ENV["PLAY_STORE_JSON_KEY"]
6 )
7 end
8end

The json_key parameter points to the path of the credentials file for the Google Cloud service account (or its contents coming from an environment variable in CI). Separating these two lanes in the same Fastfile with platform :ios do ... end and platform :android do ... end blocks lets you manage both platforms from a single file.

Version Number and Build Number Automation

In Flutter, version information is kept on a single line inside pubspec.yaml; according to the official documentation the format is {version}+{build-number}:

yaml
1version: 1.0.0+1

Here 1.0.0 is the version visible to the user (CFBundleShortVersionString on iOS), and 1 is the build number (CFBundleVersion). Instead of updating this value by hand on every build in CI, you can generate the build number from GitHub Actions' own run counter and override it from the command line — the documentation explicitly supports this override:

yaml
1- name: Build IPA
2 run: |
3 flutter build ipa \
4 --build-name=1.2.0 \
5 --build-number=${{ github.run_number }}

github.run_number is a context variable automatically provided by GitHub that tells you how many times that workflow has run in that repo; it increases on every push, giving you a build number that's guaranteed to be unique. You still typically set the --build-name value by hand, the way it's kept in pubspec.yaml, or read it from a git tag — meaningful version numbering (semver) still requires a human decision; only the build number part is automated.

Secret Management and Security

MATCH_PASSWORD, the App Store Connect API key, the Play Store service account JSON — none of these should ever be committed to the repo. GitHub's official guide describes adding a secret via Settings → Secrets and variables → Actions → New repository secret or with the CLI:

bash
1gh secret set MATCH_PASSWORD

In the workflow YAML, the secret is accessed through the secrets context:

yaml
1env:
2 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}

A critical security detail: GitHub's documentation explicitly states that secrets other than GITHUB_TOKEN are never passed to the runner on pull requests coming from a fork. This means that even if an outsider opens a PR and modifies the workflow file to try to print secrets to the console, the attempt will fail — but this protection only applies to fork PRs; PRs opened from branches within the same repo can access secrets. Flutter's own CI guide also stresses that you shouldn't "re-echo" secret values to the console in test scripts; leaving debug modes like set -x turned on in fastlane lanes is the most common reason secrets leak into logs.

Multiple Builds by Flavor

The Flavor Parameter in the Fastfile

Each of the dev/staging/prod flavors needs a different bundle ID, different signing and a different App Store Connect/Play Console registration. The way to manage this from a single Fastfile is to pass the flavor name as a parameter. The --flavor flag you pass to flutter build ipa and the entry-point file you specify with -t (like lib/main_dev.dart, lib/main_prod.dart) are the standard structure Flutter uses to produce flavor-specific builds; each flavor is wired to its own Info.plist/bundle ID setting through an Xcode scheme:

ruby
1lane :release do |options|
2 flavor = options[:flavor] || "prod"
3 match(type: "appstore", app_identifier: "com.example.app.#{flavor}", readonly: true)
4 sh("flutter build ipa --flavor #{flavor} --release " \
5 "-t lib/main_#{flavor}.dart")
6 pilot(
7 ipa: Dir[File.expand_path("../../build/ios/ipa/*.ipa")].first,
8 api_key_path: "./fastlane/api_key.json"
9 )
10end

Parallel Flavors with a Matrix Build

On the GitHub Actions side you can trigger this with a matrix build, running a separate job for each flavor:

yaml
1strategy:
2 matrix:
3 flavor: [dev, staging, prod]
4steps:
5 - run: bundle exec fastlane release flavor:${{ matrix.flavor }}

This structure runs all three flavors as parallel jobs in a single workflow file — each pulls its own certificate with its own match app_identifier, without mixing with the others. As the number of flavors grows, adding a new line to the matrix carries far less maintenance burden than duplicating the Fastfile.

5 Common CI Mistakes

  • Running match without readonly: if you don't pass readonly: true in CI, every build can try to generate a new certificate and you'll hit Apple's certificate limit; generate certificates only locally, by hand, and leave CI read-only.
  • Writing secrets in plaintext in the workflow file: a line like env: MATCH_PASSWORD: "real-password" gets permanently baked into the repo's history; always use the secrets context.
  • Combining test and build into a single job: when one widget test turns out flaky, your entire macOS runner minutes (build included) go to waste; keep tests on a separate, cheap runner.
  • Forgetting to update the build number by hand: if flutter build ipa ships with the same build number every time, App Store Connect will reject the upload; automating this in CI (see the section above) permanently eliminates this mistake.
  • Setting up the cache key wrong: if you make key a static string (e.g. just pub-cache), stale dependencies get served even after pubspec.lock changes; always tie the key to a content-sensitive value like hashFiles('**/pubspec.lock').

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

If you've applied this guide from start to finish, you should now have a Flutter CI/CD pipeline set up in your own project. The checklist below summarizes, in the order the article covered them, the steps you should review before integrating it into your project — check off each item to see whether you've left a step incomplete.

FAQ

How do you build a Flutter app with GitHub Actions?

You add a YAML file to your repo's .github/workflows/ folder; inside it you install the Flutter SDK with a community action like subosito/flutter-action, then run flutter pub get and, depending on the platform, flutter build appbundle (Android) or flutter build ipa (iOS, requires a macOS runner). The GitHub Actions Workflow Skeleton section in this article has a complete example.

How do you automate uploading to TestFlight from Flutter?

fastlane's pilot action (upload_to_testflight) does this. After signing and building the IPA (match + flutter build ipa), calling pilot(ipa: "...", api_key_path: "...") in your Fastfile is enough; using an App Store Connect API key removes the 2FA requirement.

How are iOS code-signing certificates managed in CI?

With fastlane match. Certificates and provisioning profiles are encrypted and stored in a git repository, Google Cloud, or S3; CI pulls these certificates read-only with a match(readonly: true) call, without generating a new certificate.

What are the free CI options for Flutter?

GitHub Actions is free with unlimited minutes for public repos and a set monthly minute quota for private repos; however, macOS runner minutes are deducted from the quota at a multiplier several times higher than Linux runners, which is why moving build/deploy steps to macOS only when needed, as in this article, protects the budget.

How is uploading to internal testing automated on the Android side?

With fastlane's upload_to_play_store (supply) action; the track: "internal" parameter uploads the build directly to the Internal Testing track in Play Console instead of production, and the json_key parameter passes the Google Cloud service account credentials.

Update (September 2026)

This guide was written in February 2025; since then, two changes to Apple's App Store Connect upload requirements directly affect this pipeline. According to Apple's official "Upcoming requirements" page, as of April 28, 2026, apps uploaded to App Store Connect must be built with Xcode 26 or later, using the iOS 26/iPadOS 26/tvOS 26/visionOS 26/watchOS 26 SDK. This means workflows using an older runner image like macos-14 now need to move to a newer macOS runner (for example macos-15 or later) that hosts a compatible Xcode version; tracking image updates in GitHub's runner-images repo should be your reference point for this transition.

The same page also clarified that, as of September 9, 2026, iOS/iPadOS apps uploaded to App Store Connect must target at least iOS 13 — meaning you can't keep your deployment target below that. You need to keep the iOS Deployment Target field in your project's Xcode settings, and the minimum version Flutter itself supports (which you can check on the official Supported deployment platforms page), aligned with these two requirements. No other change is needed in the Fastfile or CI workflow; what's actually affected is the runner image the build job runs on and the Xcode version.

Related posts published later:

Conclusion

A pipeline built with the Flutter CI/CD GitHub Actions and Fastlane duo rests on four simple principles: separate the stages (analyze/test on a cheap runner, build/deploy on macOS), share certificates with match, never write secrets into the repo, and automate the build number. Once you apply these four principles, the entire manual build-and-upload process disappears, and every push turns into a release candidate that tests and deploys itself.

If you want to see how the same duo is set up on the native iOS side, you can check out the iOS CI/CD Pipeline: GitHub Actions and Fastlane guide — it covers the same match+gym+pilot flow specifically for a Swift/Xcode project; this article instead focuses on managing Flutter's two platforms (iOS+Android) from a single Fastfile.

If you're considering migrating from React Native, the React Native vs Flutter Comparison can help you decide.

Sources

Tags

#Flutter#CI/CD#GitHub Actions#Fastlane#DevOps#TestFlight#Google Play
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share