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
| Piece | Job |
|---|---|
flutter build | Makes the signed .aab (Android) or .ipa (iOS) |
| Fastlane lane | A named Ruby function that runs the build and the upload |
| Match | Keeps iOS certificates and profiles in a private Git repo, so CI can sign |
| Store API keys | Let CI upload without your personal login or two-factor prompt |
| GitHub Actions | Starts 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:
my_app/
├── Gemfile
├── fastlane/
│ ├── Appfile
│ ├── Fastfile
│ └── Matchfile
├── android/
├── ios/
└── pubspec.yaml# 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
# 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
endjson_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:
bundle exec fastlane android internal build_number:42iOS
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.
# 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
# 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
endWhat the pieces do:
setup_cimakes 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_keyloads the key for the actions that follow, soupload_to_testflightdoes not ask for a login.File.expand_path(...)turns the path into an absolute one. TheFastfileruns infastlane/, 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 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:
bundle exec fastlane ios beta build_number:42The GitHub Actions workflow
Two jobs, one per platform. iOS needs a macOS runner. Android runs on Linux.
# .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:
git tag v1.2.0
git push origin v1.2.0flutter 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.
| Secret | What it is |
|---|---|
ANDROID_KEYSTORE_BASE64, ANDROID_STORE_PASSWORD, ANDROID_KEY_PASSWORD | Your upload keystore (base64 -i upload-keystore.jks) and its passwords |
PLAY_JSON_KEY | The full text of the Play service account JSON |
MATCH_GIT_URL, MATCH_PASSWORD | The certificates repo and the password that encrypts it |
MATCH_GIT_BASIC_AUTHORIZATION | Base64 of username:token, so CI can clone the private certificates repo |
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT | The 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_numberor the commit count. - Match prompts for a password or the keychain hangs. Call
setup_ciand setMATCH_PASSWORD. file not foundfor the bundle or ipa. Fastfile code runs infastlane/, 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,MatchfileandPluginfile. The lanes take aflavor:option, so one command buildsdevelopment,stagingorproduction: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/hasrelease-mobile.yml, which starts on amobile/v*tag and calls reusablebuild-android.ymlandbuild-ios.ymlworkflows that run those lanes. Pick GitLab CI instead with thegitProvideroption and you get a.gitlab-ci.ymlwith the same stages.- Every lane runs
build-preparebefore 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.
Related guides
- Flutter flavors
- Remove debug tools from a Flutter release build
- Flutter monorepo CI: only test what changed
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_appThe Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.