Picture this: it's 4pm on a Friday, and your team has just merged the last feature for the sprint. But your product manager asks for a new build on TestFlight by the end of the day so the client can review it over the weekend.
You open Xcode, wait for the archive to finish, deal with a code signing error that wasn't there yesterday, fix it, re-archive, wait again, upload, and wait for App Store Connect to process it. Then you do the same for Android, but now through Android Studio. You sign the APK, log into Firebase App Distribution, drag the file in, add the testers, write the release notes, and hit send.
It's now 6:45 PM. You haven't written a line of product code in two hours. This happens every release cycle.
Now picture the alternative: you push your code to the dev branch. GitHub's servers take over. Within minutes, an isolated cloud environment has checked out your code, installed Flutter, decoded your signing credentials from encrypted secrets, built the APK and the IPA, and distributed both to Firebase App Distribution for Android testers and TestFlight for iOS testers simultaneously. You're already home. The notification goes out to testers automatically.
That's the pipeline this handbook builds.
By the time you reach the end of this guide, pushing to dev will automatically distribute builds to Firebase App Distribution and TestFlight. Pushing to prod will distribute to the Google Play Store and the Apple App Store. You'll never manually export an IPA or upload an APK again.
The tools that make this possible are GitHub Actions, which provides the cloud computers that run the automation, and Fastlane, which handles the build, signing, and distribution logic. This handbook treats both as production infrastructure deserving the same care and documentation as the app itself.
Table of Contents
Prerequisites
Before starting, make sure the following are in place. Skipping any of these will cause failures that are difficult to diagnose.
An existing Flutter project with a GitHub repository: The project should already be building locally. If
flutter build apk --releaseandflutter build ios --release --no-codesignboth succeed on your machine, you're ready.An Apple Developer account with Admin or Account Holder role: You need this to create App Store Connect API keys. A Developer role isn't sufficient.
A Google Play Console account with a published app in at least draft state: The Google Play API can't push to an app that has never had any version uploaded. If your app is brand new, you need to do one manual upload to create the app listing before automation can take over.
A Firebase project with Firebase App Distribution enabled for both Android and iOS.
Ruby installed on your development machine: Fastlane is a Ruby gem. Run
ruby -vto check. macOS ships with Ruby but it's often outdated. Install a current version via Homebrew:brew install ruby.Fastlane installed locally: Install it with
gem install fastlane. You'll use it from your terminal during setup before the CI server takes over.Homebrew installed on macOS: Used for installing dependencies locally.
A terminal you're comfortable with: Every step in this guide involves running commands. There's no GUI alternative for most of it.
What is CI/CD and Why Your Flutter App Needs It
The Concept
CI/CD stands for Continuous Integration and Continuous Delivery. At its core, it's the practice of automating the steps between writing code and getting that code to users. Continuous Integration means every code change is automatically built and tested. Continuous Delivery means every successful build is automatically prepared for distribution.
For mobile development specifically, this matters more than in almost any other software domain. Mobile builds are complex: they involve code signing with certificates, provisioning profiles, keystore files, and API keys that must be correctly assembled in exactly the right way for the build to succeed. Doing this manually is error-prone. Automating it makes it reliable and repeatable.
Why Manual Deployment Is a Problem
When deployment is manual, several things happen over time. First, it becomes a specialized skill. Only the one or two people who have done it before know the steps, and when they're unavailable, the team can't ship.
Second, it's inconsistent. The build one person produces on their laptop may have subtly different environment variables or Xcode settings than the build someone else produces on theirs.
Third, it's slow. Builds, archives, and uploads are waiting games that interrupt the flow of real engineering work.
Automation solves all three. The steps are written down in version-controlled files. The environment is identical on every run because it's a fresh cloud machine assembled from those files. And the process runs in the background while you work on the next feature.
The Architecture: How All the Pieces Connect
Before touching any configuration file, understand the full system and how every component fits together. Building without this picture leads to debugging failures without knowing where to look.
GitHub Actions provides cloud-based virtual machines called runners. Every time you push to a configured branch, GitHub spins up a fresh runner (Ubuntu for Android, macOS for iOS), executes the steps in your workflow file, and tears down the machine when done. The machine starts completely clean every time.
Fastlane is an open-source tool for automating mobile build and deployment tasks. It runs inside the GitHub Actions runner and handles the platform-specific steps: building the app bundle, managing iOS code signing, and uploading binaries to distribution platforms. You write Fastlane "lanes" (named sequences of steps) that GitHub Actions calls.
Fastlane Match is a sub-system within Fastlane for iOS code signing. iOS apps require a certificate and a provisioning profile to be installed on the machine that builds them. Match stores these in an encrypted private GitHub repository and downloads them onto the CI runner before the build. This eliminates the nightmare of managing certificates manually across multiple machines.
Firebase App Distribution receives your built APK and IPA files for the dev environment and notifies your testers automatically.
App Store Connect and Google Play Console receive your production builds for the prod environment.
The certificates repository is a separate private GitHub repository that Fastlane Match reads from and writes to. It holds your iOS signing materials encrypted with a password that only you know.
Generating Your Credentials and Keys
This section involves navigating multiple third-party dashboards to collect the credentials that the CI pipeline needs.
Firebase Credentials
Firebase App Distribution needs two pieces of information: your app IDs and a service account that grants the CI server permission to upload builds.
Navigate to the Firebase Console and open your project.
Go to Project Settings (the gear icon next to Project Overview in the left sidebar).
Scroll down to the Your apps section. You'll see your registered Android and iOS apps listed. Find and copy the App ID for each. Android App IDs look like 1:1234567890:android:abc123def456. iOS App IDs look like 1:1234567890:ios:abc123def456.
Stay in Project Settings and click the Service accounts tab.
Click Generate new private key and confirm the dialog. A .json file downloads to your machine. This file is the service account credential. Keep it secure and don't commit it to any repository.
Apple App Store Connect API Key
Apple replaced password-based API access with API keys. You need one to let Fastlane communicate with App Store Connect without requiring your Apple ID credentials.
Go to App Store Connect and navigate to Users and Access in the top navigation.
Click the Integrations tab, then select App Store Connect API in the left sidebar.
Click the + button to generate a new key. Name it something clear like GitHub Actions CI. Set the access level to App Manager.
After creating the key, note down the Issuer ID shown at the top of the page and the Key ID shown in the key row. Click Download API Key to save the .p8 file. You can only download this file once. If you lose it, you must create a new key.
Google Play Store Service Account
The Google Play API uses a service account (a machine identity in Google Cloud) to authenticate uploads.
Open the Google Cloud Console and make sure you're in the project linked to your Play Console.
Navigate to IAM and Admin in the left sidebar, then click Service Accounts.
Click Create Service Account. Give it a clear name like github-actions-play-store. Assign the role Service Account User. Complete the creation.
Click on the newly created service account in the list. Go to the Keys tab. Click Add Key then Create new key. Select JSON format. A .json file downloads.
Now link this service account to your Play Console. Go to Google Play Console, open your app, and navigate to Setup then API access. Grant the service account access with at minimum Release manager permission on your app.
Fastlane Match Certificates Repository
Fastlane Match stores your iOS signing materials in a dedicated private GitHub repository. Create a brand-new, completely empty, private repository now. Name it something like your-app-certificates. Don't initialize it with any files.
Next, create a Personal Access Token so Fastlane can read from and write to this repository from the CI runner. Go to your GitHub account Settings, scroll to the bottom and click Developer settings, then click Personal access tokens and then Tokens (classic).
Generate a new classic token. Give it a descriptive name like fastlane-match-ci. Under Select scopes, check the repo scope (which grants full repository access). Set the expiration to at least one year or to no expiration if your security policy allows it. Generate the token and copy it immediately. GitHub won't show it again.
The newly generated token:
Background Cryptography: Turning Files Into Secrets
GitHub Actions Secrets only accepts plain text strings. Your signing credentials are binary files: the Android .jks keystore, the Apple .p8 key file, and the Firebase .json service account. To store binary files as secrets, you convert them to Base64, which is a way of representing any binary data as a string of printable ASCII characters.
Every command in this section runs in your terminal. After running each command, open the resulting .txt file, copy its entire contents, and save that string somewhere safe (a password manager works well). Once copied, delete the .txt file.
Generating the Android Keystore
The Android keystore is the cryptographic identity of your app on the Play Store. Once you publish an app with a particular keystore, you must use that same keystore for every update forever. Losing it means you can't push updates to your existing app. Generate it and back it up securely.
keytool -genkey -v \
-keystore release-keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias YOUR_KEY_ALIAS \
-dname "CN=Your Name, OU=App, O=Your Company, L=Your City, ST=Your State, C=US" \
-storepass "YOUR_SECURE_PASSWORD" \
-keypass "YOUR_SECURE_PASSWORD"
keytool is part of the Java Development Kit and is the standard tool for managing Java cryptographic keystores. -keystore release-keystore.jks names the output file. -keyalg RSA and -keysize 2048 specify the encryption algorithm and key length, which are the standard choices for Android signing.
-validity 10000 sets the certificate validity to approximately 27 years, which is the commonly recommended value for Play Store keys. -alias YOUR_KEY_ALIAS is the name you will reference this key by inside the keystore. Replace it with something meaningful like your app name. -dname is the Distinguished Name, used to identify the certificate owner. Replace all values with your own information.
-storepass and -keypass are the passwords to protect the keystore file and the key inside it respectively. They can be the same value, which simplifies the GitHub Secrets configuration.
Now convert the keystore file to a Base64 string that GitHub Secrets can store:
base64 -i release-keystore.jks > release-keystore-base64.txt
base64 -i release-keystore.jks reads the binary .jks file and encodes it as a Base64 string. The > operator redirects the output to release-keystore-base64.txt instead of printing it to the terminal. Open this file, copy the entire string (it will be long), save it to your password manager under the label ANDROID_KEYSTORE_BASE64, and then delete the .txt file.
Encoding the Apple API Key
base64 -i AuthKey_YOUR_KEY_ID.p8 > authkey-base64.txt
Replace AuthKey_YOUR_KEY_ID.p8 with the exact filename of the .p8 file you downloaded from App Store Connect. The Key ID is in the filename. The command encodes the binary key file to a Base64 string. Open authkey-base64.txt, copy the contents, save it under APPSTORE_API_PRIVATE_KEY_BASE64, and delete the file.
Encoding GitHub Credentials for Match
Fastlane Match authenticates to your certificates repository using HTTP Basic Authentication, which requires a username and token encoded as Base64. This is the standard format for HTTP Basic auth.
echo -n "YOUR_GITHUB_USERNAME:YOUR_PERSONAL_ACCESS_TOKEN" | base64
echo -n outputs the string without a trailing newline. The -n flag is critical: a trailing newline would be included in the Base64 encoding and would corrupt the credential. | base64 pipes the output directly to the Base64 encoder without writing an intermediate file. The encoded result is printed directly to your terminal. Copy it and save it under MATCH_GIT_BASIC_AUTHORIZATION.
Encoding Your Environment File
If your Flutter app uses a .env file for sensitive configuration like API keys (which should never be committed to Git), you need to encode it so the CI runner can reconstruct it before building:
base64 -i .env > env-base64.txt
The .env file is read from the project root and encoded to Base64. Open env-base64.txt, copy the contents, save it under ENV_FILE_BASE64, and delete the file. If your project doesn't use a .env file, skip this step and remove the corresponding step from the GitHub Actions workflow files later.
Configuring GitHub Actions Secrets
With all your credentials encoded, add them to your GitHub repository's secret vault. Secrets stored here are encrypted at rest, masked in workflow logs (they appear as *** if they would otherwise be printed), and are never accessible to code running outside of GitHub Actions.
In your repository on GitHub, go to Settings in the top navigation bar.
In the left sidebar, click Secrets and variables, then Actions.
Click New repository secret for each secret below. The name must match exactly as written, because the workflow files reference these names directly.
Add the following secrets one by one:
Environment and Configuration:
ENV_FILE_BASE64: The Base64 string from encoding your.envfile.
Firebase and Google Play:
FIREBASE_APP_ID_ANDROID: The Android App ID copied from Firebase Console (format:1:xxx:android:xxx).FIREBASE_APP_ID_IOS: The iOS App ID copied from Firebase Console.FIREBASE_SERVICE_ACCOUNT_JSON: Paste the raw contents of the Firebase service account.jsonfile directly. Don't encode this one: the workflow writes it to a file directly.GOOGLE_PLAY_JSON: Paste the raw contents of the Google Play service account.jsonfile directly.
Android Signing:
ANDROID_KEYSTORE_BASE64: The Base64 string from encoding the.jkskeystore file.ANDROID_KEY_ALIAS: The alias you used when generating the keystore (for example,your-app-key).ANDROID_KEY_PASSWORD: The key password you set when generating the keystore.ANDROID_STORE_PASSWORD: The store password you set when generating the keystore.
Apple App Store:
APPSTORE_ISSUER_ID: The Issuer ID from App Store Connect API keys page.APPSTORE_API_KEY_ID: The Key ID from App Store Connect API keys page.APPSTORE_API_PRIVATE_KEY_BASE64: The Base64 string from encoding the.p8file.
Fastlane Match:
MATCH_GIT_BASIC_AUTHORIZATION: The Base64 string ofusername:token.MATCH_PASSWORD: A strong password you create yourself. This is used to encrypt the certificates in the Match repository. Use a password manager to generate something strong. Keep it safe because it can't be recovered: if you lose it, you must re-create the certificates repository.
Setting Up Fastlane for Android
Fastlane for Android lives inside the android/ directory of your Flutter project. Create the following files.
The Gemfile
# android/Gemfile
source "https://rubygems.org"
gem "fastlane"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
source "https://rubygems.org" tells Bundler (Ruby's package manager) where to fetch gems from. gem "fastlane" declares Fastlane as a dependency.
The plugins_path lines load additional plugin declarations from the Pluginfile if it exists. This structure allows the main Gemfile and the plugin list to be maintained separately, which is the convention Fastlane projects follow.
Always use Bundler (bundle exec fastlane) rather than calling fastlane directly, because Bundler ensures the exact gem versions declared in the Gemfile.lock are used, making builds reproducible across machines.
The Gradle Properties File
# android/gradle.properties
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs configures the Java Virtual Machine arguments for the Gradle build process. -Xmx4G sets the maximum heap memory to 4 gigabytes. -XX:MaxMetaspaceSize=1G limits the metaspace (class metadata) to 1 gigabyte. -XX:ReservedCodeCacheSize=512m reserves 512 megabytes for compiled code caching. -XX:+HeapDumpOnOutOfMemoryError generates a heap dump file if the JVM runs out of memory, which helps with post-mortem debugging.
Without this configuration, GitHub Actions runners frequently fail with Exit Code 137 or 143 during Gradle builds, because the default JVM memory settings exceed the 7 GB RAM limit of standard GitHub-hosted runners.
The Android Appfile
# android/fastlane/Appfile
json_key_file(ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"])
package_name("com.yourcompany.app")
json_key_file(...) tells Fastlane where to find the Google service account JSON file that grants access to Google Play. It reads from the FIREBASE_SERVICE_ACCOUNT_JSON_PATH environment variable, which is set by the GitHub Actions workflow step. package_name(...) declares the app's package identifier. Replace com.yourcompany.app with your actual app package name as defined in your AndroidManifest.xml.
The Android Pluginfile
# android/fastlane/Pluginfile
gem 'fastlane-plugin-firebase_app_distribution'
This declares the Firebase App Distribution plugin as a dependency. Fastlane's core installation doesn't include platform-specific plugins. The fastlane-plugin-firebase_app_distribution gem adds the firebase_app_distribution action that the firebase lane uses to upload builds and notify testers. Without this line, the firebase lane would fail with an "undefined method" error when it tries to call firebase_app_distribution.
The Android Fastfile
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Submit a new Beta Build to Firebase App Distribution"
lane :firebase do
notes = ENV["RELEASE_NOTES"]
if notes.nil? || notes.strip.empty?
file_path = File.join(Dir.pwd, "..", "release_notes.txt")
if File.exist?(file_path) && !File.read(file_path).strip.empty?
notes = File.read(file_path)
else
notes = "New build uploaded by CI"
end
end
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID_ANDROID"],
apk_path: "../build/app/outputs/flutter-apk/app-release.apk",
groups: "testers",
release_notes: notes,
service_credentials_file: ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"]
)
end
desc "Deploy to Google Play Store"
lane :prod do
upload_to_play_store(
track: 'production',
aab: '../build/app/outputs/bundle/release/app-release.aab',
json_key: 'play-store-service-account.json',
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
default_platform(:android) sets the default context so Fastlane knows it's operating on an Android project. lane :firebase do defines a named sequence of steps called firebase.
The notes logic at the top attempts to get release notes from three sources in priority order: first from the RELEASE_NOTES environment variable (set by GitHub Actions when the workflow is manually triggered with a notes input), then from a release_notes.txt file in the project root, and finally a default fallback string. firebase_app_distribution(...) is the action provided by the plugin.
app: ENV["FIREBASE_APP_ID_ANDROID"] identifies which Firebase app to upload to, read from the environment variable set in the workflow. apk_path points to where Flutter outputs the compiled APK. groups: "testers" targets a named tester group in Firebase App Distribution. Replace this with your actual group name. For the prod lane, upload_to_play_store(...) is a built-in Fastlane action. track: 'production' uploads to the production track. skip_upload_metadata: true, skip_upload_images: true, and skip_upload_screenshots: true prevent Fastlane from trying to manage your store listing, which is not part of this pipeline's responsibility.
Setting Up Fastlane for iOS
iOS setup is more involved than Android because of code signing. The ios/ directory needs its own Fastlane configuration.
The iOS Gemfile
# ios/Gemfile
source "https://rubygems.org"
gem "fastlane"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
This is identical in structure to the Android Gemfile. iOS and Android maintain separate Bundler environments because they live in separate directories and may need different gem versions or plugins. Running bundle install inside ios/ installs the gems independently of what is installed inside android/.
The iOS Appfile
# ios/fastlane/Appfile
app_identifier("com.yourcompany.app")
app_identifier(...) declares the iOS bundle identifier. This must exactly match the bundle identifier set in Xcode (visible under the General tab of your Runner target). Replace com.yourcompany.app with your actual bundle ID. Fastlane Match uses this identifier when naming the certificate and provisioning profile files it stores in the certificates repository.
The Matchfile
# ios/fastlane/Matchfile
git_url(ENV["MATCH_GIT_URL"] || "https://github.com/YOUR_GITHUB_USERNAME/your-certificates-repo")
storage_mode("git")
type("appstore")
git_url(...) tells Match where the private certificates repository is. In the GitHub Actions workflow, the MATCH_GIT_URL environment variable is set to include the Personal Access Token embedded in the URL, so Match can authenticate to the private repository. The || "https://github.com/..." fallback is used when running Match locally, where you would be prompted for credentials interactively instead. storage_mode("git") tells Match to use Git as the storage backend, as opposed to S3 or Google Cloud Storage. type("appstore") sets the default certificate type, though each lane can override this.
The iOS Pluginfile
# ios/fastlane/Pluginfile
gem 'fastlane-plugin-firebase_app_distribution'
The same Firebase App Distribution plugin is needed on iOS for the firebase lane that uploads the ad-hoc IPA to Firebase. The iOS and Android Pluginfiles are separate and both need this declaration.
The iOS Fastfile
# ios/fastlane/Fastfile
default_platform(:ios)
before_all do
setup_ci
end
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_filepath: ENV["APP_STORE_CONNECT_API_KEY_KEY_FILEPATH"],
in_house: false
)
match(
type: "appstore",
readonly: false,
app_identifier: "com.YOUR-APP.app",
api_key: api_key
)
update_code_signing_settings(
path: "Runner.xcodeproj",
use_automatic_signing: false,
team_id: "GL369K3W98",
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore com.YOUR-APP.app",
targets: ["Runner"]
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
notes = ENV["RELEASE_NOTES"]
if notes.nil? || notes.strip.empty?
file_path = File.join(Dir.pwd, "..", "release_notes.txt")
if File.exist?(file_path) && !File.read(file_path).strip.empty?
notes = File.read(file_path)
else
notes = "New build uploaded by CI"
end
end
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: notes
)
end
desc "Deploy to Apple App Store"
lane :prod do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_filepath: ENV["APP_STORE_CONNECT_API_KEY_KEY_FILEPATH"],
in_house: false
)
match(
type: "appstore",
readonly: false,
app_identifier: "com.YOUR-APP.app",
api_key: api_key
)
update_code_signing_settings(
path: "Runner.xcodeproj",
use_automatic_signing: false,
team_id: "GL369K3W98",
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore com.YOUR-APP.app",
targets: ["Runner"]
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_app_store(
force: true, # Skip HTML report
submit_for_review: false, # Uploads to App Store Connect without auto-submitting for review
automatic_release: false
)
end
desc "Push a new beta build to Firebase App Distribution"
lane :firebase do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
key_filepath: ENV["APP_STORE_CONNECT_API_KEY_KEY_FILEPATH"],
in_house: false
)
match(
type: "adhoc",
readonly: false,
app_identifier: "com.YOUR-APP.app",
api_key: api_key
)
update_code_signing_settings(
path: "Runner.xcodeproj",
use_automatic_signing: false,
team_id: "GL369K3W98",
code_sign_identity: "Apple Distribution",
profile_name: "match AdHoc com.YOUR-APP.app",
targets: ["Runner"]
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "ad-hoc"
)
notes = ENV["RELEASE_NOTES"]
if notes.nil? || notes.strip.empty?
file_path = File.join(Dir.pwd, "..", "release_notes.txt")
if File.exist?(file_path) && !File.read(file_path).strip.empty?
notes = File.read(file_path)
else
notes = "New build uploaded by CI"
end
end
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID_IOS"],
groups: "testers",
release_notes: notes,
service_credentials_file: ENV["FIREBASE_SERVICE_ACCOUNT_JSON_PATH"]
)
end
end
before_all do setup_ci end runs before every lane. setup_ci is a built-in Fastlane action that configures the environment for CI use: it sets up a temporary keychain (so certificates can be installed without macOS prompting for a password), disables code signing pop-ups, and configures other CI-specific settings. Without this, certificate installation would hang waiting for a user to click an approval dialog that never comes.
app_store_connect_api_key(...) reads the App Store Connect API key and creates an API key object that subsequent actions use for App Store authentication. key_id, issuer_id, and key_filepath all come from environment variables set by the workflow. in_house: false indicates this is a standard developer account (not an Apple Enterprise Program account, which has different distribution rules).
match(type: "appstore", ...) connects to the certificates repository, downloads the AppStore distribution certificate and provisioning profile, and installs them into the macOS keychain.
readonly: false allows Match to create the certificate if it doesn't already exist. The first time this runs for a new project, Match generates the certificate and pushes it to the repository. Subsequent runs simply download the existing certificate. For the firebase lane, type: "adhoc" is used because Firebase App Distribution requires an ad-hoc distribution certificate, not an App Store one.
update_code_signing_settings(...) modifies the Xcode project file to use the specific certificate and profile that Match just downloaded.
use_automatic_signing: false is critical: automatic signing would prompt Xcode to manage certificates itself, which fails in a headless CI environment. team_id: "YOUR_TEAM_ID" is your Apple Developer Team ID, visible in the Membership section of the Apple Developer Portal. profile_name: "match AppStore com.yourcompany.app" matches the naming convention Match uses when it creates profiles.
build_app(workspace: "Runner.xcworkspace", scheme: "Runner", export_method: "app-store") invokes xcodebuild to archive and export the app. Runner.xcworkspace is the Flutter-generated Xcode workspace. Using the workspace rather than the project file is required when CocoaPods dependencies are present. export_method: "app-store" tells Xcode which export options to use for the final IPA. For the Firebase lane, this is "ad-hoc".
upload_to_testflight(skip_waiting_for_build_processing: true) uploads the IPA to App Store Connect. skip_waiting_for_build_processing: true tells Fastlane not to wait for Apple to finish processing the build, which can take 15 to 30 minutes. The upload completes and the workflow finishes. The build appears in TestFlight once Apple completes processing on their side.
upload_to_app_store(force: true, submit_for_review: false, automatic_release: false) uploads to App Store Connect for production distribution. force: true skips Fastlane's HTML summary report, which is not useful in CI. submit_for_review: false uploads the build without automatically submitting it for App Review, giving you a chance to review and submit manually. automatic_release: false prevents automatic release after approval.
Writing the GitHub Actions Workflows
Workflows are YAML files placed in .github/workflows/ at the root of your repository. Each file defines a workflow with a name, the events that trigger it, and the sequence of steps to execute.
The Android Workflow
# .github/workflows/android_distribution.yml
name: Android Firebase App Distribution
on:
push:
branches:
- dev
- prod
workflow_dispatch:
inputs:
release_notes:
description: 'Release Notes'
required: false
default: 'Manual trigger from GitHub Actions'
jobs:
distribute_android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
- run: flutter pub get
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: android
- name: Decode Keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo $ANDROID_KEYSTORE_BASE64 | base64 --decode > android/app/upload-keystore.jks
echo "storeFile=upload-keystore.jks" > android/key.properties
echo "storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}" >> android/key.properties
echo "keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" >> android/key.properties
- name: Create .env file
env:
ENV_FILE_BASE64: ${{ secrets.ENV_FILE_BASE64 }}
run: echo $ENV_FILE_BASE64 | base64 --decode > .env
- name: Build Android Release
run: |
if [ "${{ github.ref_name }}" == "prod" ]; then
flutter build appbundle --release
else
flutter build apk --release
fi
- name: Create Firebase Service Account JSON
if: ${{ github.ref_name == 'dev' }}
env:
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
run: echo $FIREBASE_SERVICE_ACCOUNT_JSON > android/firebase-service-account.json
- name: Distribute to Firebase App Distribution (Dev)
if: ${{ github.ref_name == 'dev' }}
env:
FIREBASE_APP_ID_ANDROID: ${{ secrets.FIREBASE_APP_ID_ANDROID }}
FIREBASE_SERVICE_ACCOUNT_JSON_PATH: "firebase-service-account.json"
RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
run: bundle exec fastlane firebase
working-directory: android
- name: Distribute to Google Play Store (Prod)
if: ${{ github.ref_name == 'prod' }}
env:
GOOGLE_PLAY_JSON: ${{ secrets.GOOGLE_PLAY_JSON }}
run: |
echo $GOOGLE_PLAY_JSON > play-store-service-account.json
bundle exec fastlane prod
working-directory: android
name: Android Firebase App Distribution is the display name visible in the GitHub Actions tab of your repository.
on: push: branches: [dev, prod] configures the trigger. This workflow runs every time a commit is pushed to either the dev or prod branch. It doesn't run for any other branch, including main and develop, which remain untouched staging branches.
workflow_dispatch: inputs: release_notes adds a manual trigger. In the GitHub Actions tab, you can click "Run workflow" and optionally type release notes that will be passed to Fastlane. This is useful for testing and for ad-hoc releases.
runs-on: ubuntu-latest specifies the virtual machine. Ubuntu is used for Android because the Android build toolchain runs on Linux and Ubuntu runners are less expensive than macOS runners.
actions/checkout@v4 clones your repository into the runner's working directory. Without this, no other step can access your code.
actions/setup-java@v3 installs Java 17 using the Zulu distribution. Java 17 is required for Gradle 8 compatibility, which is what current Flutter projects use. Without the correct Java version, Gradle fails immediately.
subosito/flutter-action@v2 installs the Flutter SDK. channel: 'stable' uses the stable release channel, which is correct for production builds. cache: true caches the Flutter SDK download between workflow runs, significantly reducing the setup time on subsequent runs.
ruby/setup-ruby@v1 installs Ruby 3.2 and runs bundle install in the android/ directory automatically when bundler-cache: true is set. The bundler-cache option also caches the installed gems between runs, which saves two to three minutes per workflow execution.
The Decode Keystore step is the core of Android security setup. echo $ANDROID_KEYSTORE_BASE64 | base64 --decode > android/app/upload-keystore.jks reverses the Base64 encoding to recreate the binary .jks file at the expected path. The subsequent echo commands write the key.properties file that the Android Gradle build reads to find the keystore and its passwords. This file is created fresh on every run directly from secrets, so it is never stored anywhere permanently.
if [ "${{ github.ref_name }}" == "prod" ] is a bash conditional. github.ref_name is the name of the branch that triggered the push. If the branch is prod, the workflow builds an App Bundle (.aab, required for Play Store). Otherwise (for dev), it builds an APK (.apk, simpler and faster, appropriate for Firebase App Distribution). The same workflow file handles both branches with this one conditional.
if: ${{ github.ref_name == 'dev' }} is a step-level conditional. Steps with this condition only run when the triggering branch is dev. The Firebase distribution steps are skipped entirely on prod pushes, and the Play Store step is skipped entirely on dev pushes.
The iOS Workflow
# .github/workflows/ios_distribution.yml
name: iOS TestFlight and Firebase Distribution
on:
push:
branches:
- dev
- prod
workflow_dispatch:
inputs:
release_notes:
description: 'Release Notes'
required: false
default: 'Manual trigger from GitHub Actions'
jobs:
distribute_ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
- run: flutter pub get
- name: Create .env file
env:
ENV_FILE_BASE64: ${{ secrets.ENV_FILE_BASE64 }}
run: echo $ENV_FILE_BASE64 | base64 --decode > .env
- name: Build Flutter iOS (No Codesign)
run: flutter build ios --release --no-codesign
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: ios
- name: Configure Fastlane Match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
echo "MATCH_PASSWORD=${MATCH_PASSWORD}" >> $GITHUB_ENV
AUTH=$(echo "$MATCH_GIT_BASIC_AUTHORIZATION" | base64 --decode)
echo "MATCH_GIT_URL=https://$AUTH@github.com/YOUR_GITHUB_USERNAME/your-certificates-repo" >> $GITHUB_ENV
- name: Create Auth Key for App Store Connect
env:
APPSTORE_API_PRIVATE_KEY_BASE64: ${{ secrets.APPSTORE_API_PRIVATE_KEY_BASE64 }}
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
run: |
mkdir -p ~/.appstoreconnect/private_keys/
echo $APPSTORE_API_PRIVATE_KEY_BASE64 | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8
- name: Create Firebase Service Account JSON
if: ${{ github.ref_name == 'dev' }}
env:
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
run: echo $FIREBASE_SERVICE_ACCOUNT_JSON > ios/firebase-service-account.json
- name: Distribute to Firebase App Distribution (Dev)
if: ${{ github.ref_name == 'dev' }}
env:
FIREBASE_APP_ID_IOS: ${{ secrets.FIREBASE_APP_ID_IOS }}
FIREBASE_SERVICE_ACCOUNT_JSON_PATH: "firebase-service-account.json"
RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
run: bundle exec fastlane firebase
working-directory: ios
- name: Distribute to TestFlight (Dev)
if: ${{ github.ref_name == 'dev' }}
env:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
run: bundle exec fastlane beta
working-directory: ios
- name: Distribute to Apple App Store (Prod)
if: ${{ github.ref_name == 'prod' }}
env:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_KEY_FILEPATH: ~/.appstoreconnect/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8
run: bundle exec fastlane prod
working-directory: ios
runs-on: macos-latest is non-negotiable for iOS builds. Xcode only runs on macOS, and xcodebuild (which Fastlane uses under the hood) is only available there. macOS runners are approximately ten times more expensive per minute than Ubuntu runners, which is why Android uses Ubuntu. For iOS, there's no alternative.
flutter build ios --release --no-codesign compiles the Flutter Dart code and the native iOS framework code into a release build without applying any code signing. The --no-codesign flag is critical here: Flutter's build step shouldn't attempt signing because the signing certificate isn't yet installed. Fastlane Match handles the signing in the subsequent Fastlane lane, after it has downloaded and installed the correct certificate.
The Configure Fastlane Match step does something important. AUTH=$(echo "$MATCH_GIT_BASIC_AUTHORIZATION" | base64 --decode) decodes the Base64 username:token string back to plain text. echo "MATCH_GIT_URL=https://$AUTH@github.com/..." >> $GITHUB_ENV writes the complete authenticated URL (with the token embedded) to the $GITHUB_ENV file, which GitHub Actions reads to propagate environment variables to subsequent steps. The authenticated URL format https://username:token@github.com/... is HTTP Basic Authentication, the format that Git uses for credential passing in non-interactive environments.
The Create Auth Key step reconstructs the .p8 file from its Base64 encoding. mkdir -p ~/.appstoreconnect/private_keys/ creates the directory that Fastlane expects to find the key in. echo $APPSTORE_API_PRIVATE_KEY_BASE64 | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8 writes the decoded key to the exact filename pattern that app_store_connect_api_key looks for.
The iOS workflow runs two parallel distribution steps for the dev branch: the firebase lane (which builds an ad-hoc IPA and uploads to Firebase App Distribution) and the beta lane (which builds an App Store IPA and uploads to TestFlight). Both run sequentially after the shared setup steps. This means a single push to dev delivers the build to both distribution channels automatically.
Screenshots:
Android and iOS Workflow running:
Completed Android Workflow:
Completed iOS Workflow:
Android and iOS Completed Workflow:
Firebase App Distribution – Android:
Firebase App Distribution – iOS:
TestFlight iOS Build:
How a Full Deployment Runs End to End
When all configuration is in place, here's the complete sequence of events from a push to dev:
Both runners execute in parallel, so the total wall clock time is approximately equal to whichever platform takes longer, typically iOS due to Xcode compilation time.
For prod pushes, the sequence is identical in structure but the final distribution steps target Google Play Store (Android) and App Store Connect (iOS).
Best Practices
Keep Your Certificates Repository Private and Access-Controlled
The certificates repository holds your iOS signing materials encrypted with the Match password. Even though the files are encrypted, treat access to this repository as you would treat access to a production database. Revoke personal access tokens that are no longer needed. Don't share the Match password in plain text anywhere.
Set a Minimum Build Number Strategy
Automated CI builds need a unique build number per upload. App Store Connect and Google Play both reject uploads with duplicate build numbers. Implement a versioning strategy that doesn't require manual intervention. One reliable approach is using the GitHub Actions GITHUB_RUN_NUMBER, which is an integer that increments with every workflow run:
- name: Set Build Number
run: |
BUILD_NUMBER=${{ github.run_number }}
# For Flutter, update the build number in pubspec.yaml
sed -i '' "s/version: .*/version: 1.0.0+${BUILD_NUMBER}/" pubspec.yaml
github.run_number is a GitHub-provided environment variable that starts at 1 for the first workflow run in a repository and increments by 1 for every subsequent run. This guarantees a unique, monotonically increasing build number across all runs. The sed command replaces the version line in pubspec.yaml with the run number appended as the build number.
Add Branch Protection Rules
With automation in place, protect your branches from accidental direct pushes. In your repository Settings, go to Branches and add protection rules for main, develop, dev, and prod.
For prod specifically, consider requiring at least one pull request approval before merging, which creates a human gate before the production deployment trigger fires.
Monitor Your Workflow Run Times and Costs
GitHub Actions charges based on runner minutes. macOS minutes cost ten times more than Linux minutes. Go to your GitHub organization's Settings, then Billing to see your current usage.
Caching (the cache: true on Flutter and bundler-cache: true on Ruby) is the most impactful optimization. After the first run, subsequent runs that hit the cache skip the download and extraction steps entirely.
Store Release Notes in a File, Not Just as Input
The release_notes.txt fallback in the Fastfile means you can commit release notes as part of your pull request, and they automatically appear in the Firebase and TestFlight distribution notifications. Create this file at the project root and update it with each release branch. This keeps release notes in version history alongside the code they describe.
Common Mistakes
Using the Xcode Project Instead of the Workspace in Fastlane
Flutter iOS projects always use a workspace (Runner.xcworkspace) rather than a project file (Runner.xcodeproj) because CocoaPods dependencies are wired in at the workspace level. Passing Runner.xcodeproj to build_app will fail with missing dependency errors. Always use workspace: "Runner.xcworkspace".
Not Setting setup_ci for iOS
Omitting setup_ci from the before_all block causes the workflow to hang indefinitely while macOS waits for keychain access approval that never comes. This looks like a timeout and the error message points elsewhere. Always include before_all do setup_ci end in any iOS Fastfile used in CI.
Running Match in Readonly Mode for a New Project
The first time Match runs on a new app identifier, it needs to create the certificate and provisioning profile. If readonly: true is set, Match can't create them and fails with a "No certificates found" error. Use readonly: false. In production, some teams switch to readonly: true after the initial setup to prevent inadvertent certificate regeneration, but false is correct for this setup.
Forgetting to Increment the Build Number
Both Apple and Google reject builds with the same version number as a previously uploaded build. If you push twice to dev without incrementing the build number, the second upload fails. The GITHUB_RUN_NUMBER strategy described in Best Practices prevents this automatically.
Encoding Files With a Trailing Newline
Using echo "content" | base64 instead of echo -n "content" | base64 adds a trailing newline to the string before encoding. When decoded on the CI runner, the file contains a trailing newline that wasn't in the original. For the username:token string in MATCH_GIT_BASIC_AUTHORIZATION, a trailing newline corrupts the credential and causes authentication failures that look like permission errors. Always use echo -n when encoding strings that aren't files.
Using the Wrong Distribution Type for Firebase
Firebase App Distribution for iOS requires an ad-hoc distribution certificate, not an App Store one. Uploading an App Store-signed IPA to Firebase fails because ad-hoc builds are specifically designed for direct device distribution outside the App Store. The firebase lane in the iOS Fastfile explicitly uses type: "adhoc" and export_method: "ad-hoc" for this reason. The beta lane uses type: "appstore" because TestFlight requires an App Store certificate.
Granting Insufficient Permissions to the Google Play Service Account
The most common Play Store upload failure is a permissions error from the API. The service account must be linked to your Play Console app with at least Release manager permissions. Creating the service account in Google Cloud is only half the setup: you must also grant it access inside Play Console under API access. Missing the Play Console step results in 403 Forbidden errors from the Fastlane upload action.
Conclusion
What you've built here is infrastructure that pays compounding returns. The first time you push to dev and watch the GitHub Actions tab show both an Android and iOS build completing without your involvement, the value of the setup is immediate and visceral. The fourth time, the tenth time, the fiftieth time: the value compounds silently because you're never aware of the deployment happening. It just happens.
The architecture in this guide covers the common paths, but the underlying tools (GitHub Actions, Fastlane, Match) are flexible enough to accommodate nearly any workflow. Teams add steps for automated testing before the build, Slack notifications when a build completes or fails, version number management driven by Git tags, and multiple target environments beyond just dev and prod. The foundation you have here supports all of those extensions.
The one practice worth emphasizing above all others is this: treat your CI configuration files with the same care as your production code. Review changes to workflow files in pull requests. Add comments to non-obvious steps. Keep secrets out of the workflow files and in the Secrets vault where they belong. The pipeline fails for the same reasons production code fails: unreviewed changes, missing context, and undocumented assumptions.
With this pipeline in place, your team can ship faster and with more confidence, because the process of getting code into testers' hands is no longer a manual, error-prone ritual. It's a side effect of committing code, which is exactly what it should be.
References
GitHub Actions
GitHub Actions Documentation
Complete reference for workflow syntax, contexts, secret management, and runner specifications.actions/checkout
Official action for checking out your repository in a workflow.subosito/flutter-action
Community-maintained action for installing the Flutter SDK in GitHub Actions runners.ruby/setup-ruby
Official Ruby action that installs a specified Ruby version and optionally runs Bundler.GitHub Actions Billing Documentation
Reference for runner minutes, billing, and cost multipliers for macOS and Windows runners.
Fastlane
Fastlane Documentation
Complete reference for all Fastlane actions includingupload_to_testflight,upload_to_play_store,match, andbuild_app.Fastlane Match Documentation
Detailed documentation for the code signing management system, including initial setup and certificate rotation.firebase_app_distribution Fastlane Plugin
Documentation for the plugin that adds thefirebase_app_distributionaction to Fastlane lanes.
Apple
App Store Connect API Documentation
Reference for App Store Connect API keys, required roles, and the.p8file format.Apple Code Signing Guide
Apple's official explanation of certificates and provisioning profiles.TestFlight Documentation
Reference for tester limits, build expiration, and processing time between upload and availability.
Google Play Developer API
Documentation for the API Fastlane uses to upload to the Play Store, including track names and required permissions.Firebase App Distribution Documentation
Complete reference for tester group management, release notes, and CI/CD integration.Google Cloud Service Accounts
Documentation for creating and managing service accounts and IAM role assignment.
Flutter
Flutter Build Documentation
Reference forflutter build apk,flutter build appbundle, andflutter build ioscommands and their flags.Android App Signing Documentation from Flutter
Flutter's official guide for creating keystores and configuring Gradle for release builds.iOS Deployment from Flutter
Flutter's guide to deploying to App Store and TestFlight.