Getting every Flutter build to QA automatically with Jenkins and Firebase App Distribution
· Ravi Jagani
Android, on Jenkins
- Pick a branch: Start a build with
BRANCH_NAME(devby default) - Build and sign: Release APK for the development flavor, signed from Jenkins credentials
- App Distribution: Uploaded to the QA tester group
- QA and Slack: Testers install it, and Slack posts the install link
iOS, on GitHub Actions
- Push to dev: Or start the workflow by hand
- Build the IPA:
flutter build ipawith an ad hoc profile - App Distribution: Uploaded with a service account
- Test devices: Devices listed in the profile install it
Before every release of a Flutter app, QA and the rest of the team need the latest build on their phones. Doing that by hand is slow and easy to get wrong: someone builds on their own machine, has to sign it correctly and share the file, and testers end up on different versions.
On the UK fitness app I led, a Jenkins pipeline does this for us. It builds the release version of the app and uploads it to Firebase App Distribution, so the internal team and QA can always install the latest version from one place.
What Firebase App Distribution gives the team
- Testers install new builds from an email invite, or from the App Tester app on Android. No store review is involved.
- Every upload appears as a new version with its release notes, so QA knows exactly which build they are testing.
- It handles Android and iOS builds, and the same tester groups work for both.
The Android pipeline
This is the Jenkins pipeline I used recently for the app’s Android development builds. It runs in stages:
- Check the build Mac’s tools: Flutter, Java, the Firebase CLI, the Android SDK and the keystore.
- Check out the branch picked when the build starts (a
BRANCH_NAMEparameter,devby default). - Write the signing config from Jenkins credentials.
- Clean, then build the signed release APK for the
developmentflavor, and stop if the APK isn’t there. - Upload it to Firebase App Distribution for the QA tester group.
- Post a Slack message with the install link, or an alert if anything failed.
The app has three flavors: dev, staging and production. Each has its own bundle ID and config, which is what lets a development build and a production build sit side by side on the same test phone.
Here’s the Jenkinsfile, with the project’s names, paths, IDs and secrets taken out:
// Posts the build result to Slack (used by both post blocks at the bottom)
def notifySlack(String title, String color, List extraFields = []) {
def user = (
currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause')*.userName
?: ['System/Unknown']
)[0]
def time = new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Kolkata'))
def message = [
text: title,
attachments: [[
color: color,
fields: [
[title: 'Triggered by', value: user, short: true],
[title: 'Branch', value: params.BRANCH_NAME, short: true],
[title: 'Environment', value: 'Development', short: true],
[title: 'Build', value: "#${env.BUILD_NUMBER}", short: true],
[title: 'Time', value: time, short: true]
] + extraFields
]]
]
writeFile(file: 'slack.json', text: groovy.json.JsonOutput.toJson(message))
sh 'curl -sS -X POST -H "Content-type: application/json" --data @slack.json "$SLACK_WEBHOOK_URL"'
}
pipeline {
agent any
parameters {
string(name: 'BRANCH_NAME', defaultValue: 'dev', description: 'Branch to build')
}
environment {
LANG = 'en_US.UTF-8'
LC_ALL = 'en_US.UTF-8'
// Tools on the build Mac (paths depend on the machine)
FLUTTER_HOME = '/Users/jenkins/flutter'
JAVA_HOME = '/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home'
ANDROID_HOME = '/Users/jenkins/Android/Sdk'
PATH = "/opt/homebrew/opt/ruby/bin:$JAVA_HOME/bin:$FLUTTER_HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
GRADLE_OPTS = '-Xmx4g'
// Every secret comes from Jenkins credentials, never from this file
FIREBASE_TOKEN = credentials('firebase-ci-token')
SLACK_WEBHOOK_URL = credentials('slack-webhook-url')
KEYSTORE_PATH = credentials('android-upload-keystore') // "Secret file" credential
KEYSTORE_PASSWORD = credentials('android-keystore-password') // "Secret text" credential
KEY_ALIAS = 'upload'
// From Firebase console > Project settings > Your apps (not a secret)
ANDROID_FIREBASE_APP_ID = '1:000000000000:android:0000000000000000'
APK_PATH = 'build/app/outputs/flutter-apk/app-development-release.apk'
}
stages {
stage('Check environment') {
steps {
sh '''
flutter --version
java -version
firebase --version
echo "Android SDK: $ANDROID_HOME"
test -f "$KEYSTORE_PATH" && echo "Keystore found"
'''
}
}
stage('Checkout') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.BRANCH_NAME}"]],
userRemoteConfigs: [[
url: 'https://github.com/your-org/your-flutter-app.git',
credentialsId: 'github-credentials'
]]
])
}
}
stage('Write signing config') {
steps {
// Written for this build only, and never printed:
// anyone who can read the build log would see the password
sh '''
cat > android/keystore.properties <<EOF
storePassword=$KEYSTORE_PASSWORD
keyPassword=$KEYSTORE_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=$KEYSTORE_PATH
EOF
'''
}
}
stage('Build Android APK') {
steps {
sh '''
set -e
flutter clean
flutter pub get
flutter build apk --release --flavor development -t lib/main_development.dart
if [ ! -f "$APK_PATH" ]; then
echo "APK not found at $APK_PATH"
exit 1
fi
'''
}
}
stage('Distribute to Firebase') {
steps {
script {
// Single quotes: the shell reads the token from the environment,
// so Groovy never writes the secret into the command line
def output = sh(
returnStdout: true,
script: '''
firebase appdistribution:distribute "$APK_PATH" \
--app "$ANDROID_FIREBASE_APP_ID" \
--release-notes "Development build #$BUILD_NUMBER from $BRANCH_NAME" \
--groups "internal-qa" \
--token "$FIREBASE_TOKEN"
'''
).trim()
echo output
// The CLI prints a link testers can open; fall back to the console
env.FIREBASE_RELEASE_LINK =
output.find(/https:\/\/appdistribution\.firebase\.google\.com\/\S+/) ?:
'https://console.firebase.google.com'
}
}
}
}
post {
success {
script {
notifySlack(':rocket: *Development APK uploaded to Firebase*', 'good', [
[title: 'Install', value: "<${env.FIREBASE_RELEASE_LINK}|Open in App Distribution>", short: false]
])
}
}
failure {
script {
notifySlack(':rotating_light: *Development build failed*', 'danger')
}
}
always {
cleanWs()
}
}
}Project names, paths, IDs and secrets replaced with placeholders. The Firebase CLI still accepts --token, but now recommends a service account (GOOGLE_APPLICATION_CREDENTIALS) instead.
The Slack message matters more than it looks. QA doesn’t have to watch Jenkins or wait for someone to say a build is ready: the install link arrives in the channel as soon as the upload finishes.
The same flow for iOS, on GitHub Actions
iOS builds need a Mac with Xcode, and GitHub’s macOS runners are an easy way to get one. The workflow does the same job: install the signing certificate and an ad hoc provisioning profile, build the IPA with Flutter, and upload it to App Distribution.
name: iOS build to Firebase App Distribution
on:
workflow_dispatch: # run it by hand from the Actions tab
push:
branches: [dev]
jobs:
distribute:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Install the signing certificate and profile
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
PROFILE_BASE64: ${{ secrets.IOS_ADHOC_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
echo "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
echo "$PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/profile.mobileprovision"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN"
# Lets codesign use the key without a password prompt (which would hang the build)
security set-key-partition-list -S apple-tool:,apple:,codesign: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN"
# Xcode 16 reads profiles from the second folder, older versions from the first
for dir in "$HOME/Library/MobileDevice/Provisioning Profiles" \
"$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"; do
mkdir -p "$dir"
cp "$RUNNER_TEMP/profile.mobileprovision" "$dir/"
done
- name: Build the IPA
# ExportOptions-adhoc.plist uses method "ad-hoc": App Distribution
# installs only on devices listed in the provisioning profile
run: |
flutter pub get
flutter build ipa --release --export-options-plist=ios/ExportOptions-adhoc.plist
echo "IPA_PATH=$(ls build/ios/ipa/*.ipa | head -1)" >> "$GITHUB_ENV"
- name: Upload to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_IOS_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
groups: internal-qa
releaseNotes: "Development build ${{ github.run_number }} from ${{ github.ref_name }}"
file: ${{ env.IPA_PATH }}Every secret is a GitHub Actions secret. The certificate and profile are stored base64-encoded.
Three details that commonly break this kind of workflow:
- A Flutter project has to be built with
flutter build ipa. Callingxcodebuildstraight away fails, because Flutter hasn’t generated its config files or installed the CocoaPods yet. - Without
set-key-partition-list, macOS can ask for permission to use the signing key, and on a runner nobody is there to click Allow, so the build hangs. - App Distribution installs iOS builds only on devices listed in the ad hoc provisioning profile. Add a new tester’s device to the profile before inviting them.
Signing is the part that breaks
The fiddly part of any Flutter pipeline is signing. On this project I fixed signing and keystore configuration errors and got the release process stable on Android and iOS. What I’d pass on:
- Keep the keystore file and its password in Jenkins credentials, never in the repo or the Jenkinsfile, and write
keystore.propertiesduring the build. - Don’t print
keystore.propertiesto check it worked. Anyone who can read the build log would see the password. - Pass secrets to shell commands in single-quoted strings (
sh '''...'''), so the shell reads them from the environment. With double quotes, Groovy writes the secret into the command itself, and Jenkins warns about it. - Make the build fail loudly when something is missing, like the check for the APK above, instead of quietly carrying on.
- On iOS, certificates and provisioning profiles expire. When a build that used to work suddenly fails with a trust or signing error, check those first.
Why it’s worth setting up
- QA always tests the build the pipeline produced, not one from someone’s laptop.
- Nobody has to remember to share a build.
- I use the same Jenkins and Fastlane approach for Play Store and App Store releases on a monthly cycle.