· 5 min read
Firebase App Distribution with GitHub Actions for Flutter
How to build a signed Flutter Android APK in GitHub Actions and send it to testers with Firebase App Distribution, using a service account and secrets.
Sending a test build to testers by hand is a small job that eats a lot of time. You build the APK, find the right file, upload it, type the release notes and tell people it is there. Then you do it again tomorrow. Firebase App Distribution with GitHub Actions turns this into a push to a branch. This post shows the workflow I use for a Flutter Android app: how to sign the build with secrets, how to log in with a service account, and how to write release notes nobody has to type.
What the pipeline does
The goal is simple. When code lands on the testing branch, CI should:
- Check out the code and set up Java and Flutter.
- Restore the signing keystore from GitHub secrets.
- Build a release APK with a unique build number.
- Write release notes from the latest commits.
- Upload the APK to Firebase App Distribution for a tester group.
Testers get an email or a notification in the App Tester app, and they install the build from there. Nobody on the team touches a file.
I use an APK here, not an app bundle. App Distribution can take an AAB too, but then Firebase has to be linked to your Google Play account first. For internal test builds an APK is the shorter path.
The GitHub Actions workflow for Firebase App Distribution
Here is the full workflow. It lives in .github/workflows/android-test-build.yml.
name: Android test build
on:
push:
branches: [develop]
workflow_dispatch:
jobs:
distribute:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x.y' # pin the version you build with locally
channel: stable
cache: true
- name: Restore keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks
cat > android/key.properties <<PROPS
storePassword=$KEYSTORE_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=$KEY_ALIAS
storeFile=upload-keystore.jks
PROPS
- run: flutter pub get
- run: flutter build apk --release --build-number=${{ github.run_number }}
- name: Release notes
run: git log -10 --pretty=format:'- %s' > release-notes.txt
- name: Upload to App Distribution
env:
SA_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
run: |
export GOOGLE_APPLICATION_CREDENTIALS="$RUNNER_TEMP/firebase-sa.json"
printf '%s' "$SA_JSON" > "$GOOGLE_APPLICATION_CREDENTIALS"
npx --yes firebase-tools appdistribution:distribute \
build/app/outputs/flutter-apk/app-release.apk \
--app "$APP_ID" \
--groups "qa" \
--release-notes-file release-notes.txt
The rest of this post explains the three parts that usually go wrong: signing, login and release notes.
Signing the build with GitHub secrets
A release APK must be signed. On your laptop the keystore is a file next to the project, and android/key.properties tells Gradle where it is. Neither file belongs in git. So CI has to rebuild both from secrets.
First, turn the keystore into one line of text. On macOS this copies it to the clipboard:
base64 -i upload-keystore.jks | pbcopy
On Linux:
base64 -w 0 upload-keystore.jks
Paste the result into a repository secret called ANDROID_KEYSTORE_BASE64. Add the store password, key password and key alias as three more secrets.
The "Restore keystore" step decodes the file and writes key.properties. It expects the Gradle setup from the Flutter Android deployment guide, where the app's Gradle file reads key.properties and passes the values to signingConfigs.release. In that setup storeFile is resolved from the android/app folder, which is why the keystore goes to android/app/upload-keystore.jks and the property is just the file name.
Two mistakes I see often:
- Passing secrets straight into
run:with${{ }}. GitHub pastes the value into the script before the shell sees it. A password with a quote or a$breaks the command. Put secrets inenv:and use them as shell variables, as above. - A wrapped base64 string. On Linux,
base64without-w 0adds line breaks every 76 characters.base64 --decodeusually copes, but a value copied by hand from a terminal often loses a line. One line is safer.
Logging in with a service account
The Firebase CLI needs to know who is uploading. The App Distribution CLI docs mention two ways for CI: a service account, or a refresh token from firebase login:ci. Use the service account. The token is tied to one person's Google account, and the CLI warns that this method is deprecated.
The setup takes a few minutes:
- In the Google Cloud console for your Firebase project, create a service account for CI.
- Give it the Firebase App Distribution Admin role. Nothing more is needed for uploads.
- Create a JSON key and paste the whole file into the
FIREBASE_SERVICE_ACCOUNT_JSONsecret. - Copy the Android App ID from Firebase project settings. It looks like
1:1234567890:android:0a1b2c3d4e5f67890. Store it asFIREBASE_ANDROID_APP_ID.
In the upload step, the JSON goes to a file in $RUNNER_TEMP, and GOOGLE_APPLICATION_CREDENTIALS points at it. The Firebase CLI picks it up from there. $RUNNER_TEMP is cleaned after each job, so the key does not stay on the runner.
npx --yes firebase-tools runs the CLI without a global install. If you want the same CLI version on every run, pin it: npx --yes firebase-tools@<version>.
Build numbers and release notes
Two small details make the builds much easier to use for testers.
A unique build number. If every build has the same version code, testers see a list of identical entries and cannot tell which one is new. --build-number=${{ github.run_number }} gives each CI run its own number. It only goes up, so Android also accepts the new build as an update over the old one.
Release notes from git. Nobody wants to write notes for a test build, so most builds go out with none. Then testers do not know what to check. The step git log -10 --pretty=format:'- %s' takes the last ten commit messages and turns them into a list. That is why the checkout step uses fetch-depth: 20: the default shallow clone has only one commit.
This works only if commit messages are readable. "fix" and "wip" make bad release notes. Once you know testers read them, it is a good reason to write better commit messages.
You can go further and take only the commits since the last tag, or only merge commits. Start with the simple version and change it when testers ask.
Where this fits
This workflow covers Android test builds. For iOS I use Xcode Cloud and TestFlight, which I described in Xcode Cloud for Flutter: the ci_post_clone.sh script. If you also run UI tests on pull requests, Maestro in GitHub Actions shows how I keep that fast. I also put my mobile CI/CD setup into an open-source skill: goodiny777/mobile-cicd-skill.
Takeaways
- Rebuild the keystore and
key.propertiesfrom GitHub secrets. Never commit either file. - Pass secrets through
env:, not straight intorun:scripts. - Log in with a service account that has only the App Distribution Admin role, not a personal token.
- Use
github.run_numberas the build number so every test build is unique. - Generate release notes from commit messages, and fetch enough history for it to work.