· 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:

  1. Check out the code and set up Java and Flutter.
  2. Restore the signing keystore from GitHub secrets.
  3. Build a release APK with a unique build number.
  4. Write release notes from the latest commits.
  5. 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:

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:

  1. In the Google Cloud console for your Firebase project, create a service account for CI.
  2. Give it the Firebase App Distribution Admin role. Nothing more is needed for uploads.
  3. Create a JSON key and paste the whole file into the FIREBASE_SERVICE_ACCOUNT_JSON secret.
  4. Copy the Android App ID from Firebase project settings. It looks like 1:1234567890:android:0a1b2c3d4e5f67890. Store it as FIREBASE_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