Flutter CI/CD with Fastlane & GitHub Actions — Part 2: Android + match
Series recap: In Part 1 we got Fastlane shipping iOS builds to TestFlight from a local machine. This one does the same for Android and then fixes the signing mess with match. Part 3 hands everything to GitHub Actions.Welcome back 👋
If you followed Part 1, you now have a fastlane beta command that pushes iOS builds to TestFlight. Great. Now let's give Android the same treatment and then solve the one problem I quietly kicked down the road last time: iOS signing certificates.
Why we're doing Android AND match in the same post
Because both are prerequisites for GitHub Actions in Part 3. If we skip either one, the CI job will fail — Android because there's nothing to build, iOS because the runner has no idea what your signing certificate is.
Alright, let's dig in 😃
Android — Play Console prerequisites
Head to Google Play Console and make sure you have:
- An app created with the same
applicationIdas your Flutter Android project (android/app/build.gradle) - Access as a user with Admin or Release Manager rights
- Your app has been uploaded to Play Console AT LEAST ONCE manually (Google's a bit stubborn about this — the first upload has to happen through the web UI before Fastlane can push updates)
Generate your upload keystore
If your project doesn't have one yet:
keytool -genkey -v \
-keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
It'll ask you for a keystore password, key password, and some identity info. Write these down somewhere safe (a password manager, ideally — NOT a sticky note like I did in 2021 🥲).
IMPORTANT: if you lose this keystore, you can never update your app on Play Store again. Google now offers Play App Signing which softens this a bit, but the upload key is still yours to guard.
Now wire it into your Flutter project. Create android/key.properties:
storePassword=your-keystore-password
keyPassword=your-key-password
keyAlias=upload
storeFile=/absolute/path/to/upload-keystore.jks
Add it to .gitignore right away:
android/key.properties
**/upload-keystore.jks
Then in android/app/build.gradle (or build.gradle.kts), load the properties and wire them into the release signing config. If you scaffolded your Flutter project recently this is already half-set-up — you just need to point it at your keystore.
Generate a Play Console service account
This is Android's equivalent of the App Store Connect API key. It's what lets Fastlane authenticate with Google Play without you typing a password.
- Go to Google Cloud Console → your project (or create one)
- IAM & Admin → Service Accounts → Create Service Account
- Give it a name like
fastlane-play-uploader - Skip role granting for now (we'll do it in Play Console next)
- Once created, open the service account → Keys tab → Add Key → JSON. Download the
.jsonfile - Head to Play Console → Users and permissions → Invite new user → paste the service account email
- Grant it: Release manager for the app, plus Admin (all permissions) if you want to keep life simple
Drop the JSON file somewhere safe. I put mine at android/fastlane/play-store-credentials.json and add it to .gitignore immediately.
android/fastlane/play-store-credentials.json
Fastlane Setup for Android:
Go to your Android path:
cd android
And init Fastlane:
fastlane init
It'll ask you for a package name (your applicationId), the path to your JSON key file, and whether to download existing metadata. I usually skip metadata download the first time to keep the folder clean.
You should now have android/fastlane/Appfile and android/fastlane/Fastfile.
Configure the Android Appfile
Open android/fastlane/Appfile:
json_key_file("./play-store-credentials.json")
package_name("com.your.package")
Write the Android Fastfile
Open android/fastlane/Fastfile and paste:
default_platform(:android)
platform :android do
desc "Push a new beta build to Play Store Internal Testing"
lane :beta do
# Bump build number based on the latest one in Play Console
latest_code = google_play_track_version_codes(
track: "internal"
).max || 0
increment_version_code(
gradle_file_path: "app/build.gradle",
version_code: latest_code + 1
)
# Build the AAB (Android App Bundle — Play Store's preferred format)
sh("cd ../.. && flutter build appbundle --release")
# Push to Internal Testing track
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
release_status: "draft"
)
end
end
Two things worth calling out here (like Part 1):
- We're pushing to the internal testing track, not production. This is the safe default — internal releases don't need review and are visible to your test group instantly. Once you're happy, you can promote to
alpha→beta→productionfrom Play Console (or add more lanes for each). - We upload the AAB, not the APK. Google Play stopped accepting APKs for new apps in 2021. If you're on an older project still shipping APKs, use
apk:instead ofaab:and point toapp-release.apk.
Give it a spin
From your android/ directory:
fastlane beta
Grab another coffee ☕. First run takes a while because Gradle has to warm up its cache. If everything is happy, you'll see Successfully finished at the end and your build will appear in Play Console → Testing → Internal testing within a minute.
Congratulations — you can now push both platforms with one command each 🎉
But there's a shadow hanging over iOS. Let's fix it.
The iOS signing problem
Right now, your Part 1 iOS Fastfile works on YOUR machine because YOUR machine has your Apple developer certificate in its keychain. The moment we hand this over to GitHub Actions in Part 3, the runner has NO certificate, NO provisioning profile, and no way to sign the build.
You could:
- Manually export your certificate as a
.p12, upload it as a GitHub secret, and import it in every CI run - OR you could let
matchhandle it
match is by far the saner option. Let me explain the trick.
What match actually does
Fastlane's match solves iOS signing by pushing your certificates and provisioning profiles into a private Git repository, encrypted with a passphrase. When any machine needs to sign — your Mac, your teammate's Mac, a GitHub runner — it clones the repo, decrypts, and imports the assets into a temporary keychain.
The killer feature: it's the SAME certificate everywhere, so all your builds are identical from Apple's perspective. No more "works on my machine but the CI archive is rejected" surprises.
Set up your match repo
Create a private Git repo somewhere — GitHub, GitLab, whichever. Call it something like flutter-app-certs. Leave it empty.
Then from your ios/ directory:
fastlane match init
Pick option 1: git as the storage mode, then paste the HTTPS URL of your private repo.
This creates a Matchfile in ios/fastlane/:
git_url("https://github.com/your-org/flutter-app-certs")
storage_mode("git")
type("appstore")
app_identifier(["com.your.package"])
username("your.email@example.com")
Generate the certificates via match
Still in ios/:
fastlane match appstore
The first time you run this on a fresh Apple developer account, match will:
- Ask you to set a passphrase — this encrypts everything in the repo. WRITE IT DOWN somewhere safe. You'll need it in Part 3 as a GitHub secret.
- Ask if it can generate a new distribution certificate — say yes
- Generate a matching provisioning profile
- Encrypt everything and push it to your private repo
Head over to your private certs repo now and you'll see files like certs/distribution/XXXXXXXX.cer and profiles/appstore/AppStore_com.your.package.mobileprovision. All encrypted. Beautiful.
Update your iOS Fastfile to use match
Open ios/fastlane/Fastfile and update your beta lane:
default_platform(:ios)
APP_STORE_CONNECT_API_KEY_ID = "YOUR_KEY_ID"
APP_STORE_CONNECT_API_ISSUER_ID = "YOUR_ISSUER_ID"
APP_STORE_CONNECT_API_KEY_PATH = "./AuthKey_YOUR_KEY_ID.p8"
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
api_key = app_store_connect_api_key(
key_id: APP_STORE_CONNECT_API_KEY_ID,
issuer_id: APP_STORE_CONNECT_API_ISSUER_ID,
key_filepath: APP_STORE_CONNECT_API_KEY_PATH,
duration: 1200,
in_house: false
)
# NEW: pull certs and provisioning profiles from the match repo
match(
type: "appstore",
readonly: true,
api_key: api_key
)
increment_build_number(
build_number: latest_testflight_build_number(api_key: api_key) + 1,
xcodeproj: "Runner.xcodeproj"
)
sh("cd ../.. && flutter build ios --release --no-codesign")
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
end
end
Two things worth calling out here:
readonly: truemeans match will NEVER try to create new certificates. It only pulls what already exists in the repo. This is the correct setting for CI — you never want a runner regenerating your production cert by accident.- The
match(...)call has to come BEFOREbuild_app, so gym can find the imported certs in the keychain when it needs them.
Verify it still works locally
fastlane beta
If everything is wired correctly, you'll see match log lines showing it pulled the profiles, then gym signing with them, then the TestFlight upload. Same result as Part 1 — but now that same lane will work on ANY machine that has the match repo access + the passphrase.
What's next
You now have:
- ✅ iOS shipping to TestFlight, signed via match
- ✅ Android shipping to Play Internal Testing
- ✅ All secrets containerized in files (
.p8, keystore, service account JSON) OR in the match repo
In Part 3, we'll:
- Base64-encode the sensitive files and put them in GitHub Secrets
- Write two workflow YAMLs (one for iOS, one for Android)
- Trigger deploys on tag pushes
- Add a Slack notification so your team knows when a new build is out
PS: match has a nuke command that will delete all your certs and start fresh. Do NOT run it unless you truly mean it. If you nuke your appstore certs, every teammate on the project will have to re-download the new ones. Been there, still apologizing 🥲
Hope this helps you keep your CI dreams alive 🚀
Enjoy!
References:
Oussama Aniba Newsletter
Join the newsletter to receive the latest updates in your inbox.