How we went from “whose laptop has the keystore?” to a single Run workflow button that tests, builds, signs and uploads our Android and iOS apps, without anyone on the team owning a Mac.
We build a Flutter app for Android and iOS. Like most teams, we started out releasing it by hand, and every release was slow and a little risky.
The biggest blocker was iOS. To build an iOS app and upload it to the App Store or TestFlight, you need Xcode, and Xcode only runs on a Mac. Every iOS release meant finding a Mac, installing a distribution certificate and a provisioning profile, and hoping none of them had expired since the last time.
Android was easier to build but had its own problems:
pubspec.yaml by hand. Sometimes two people uploaded the same number and Play rejected the second one.We wanted releases to be boring: repeatable, one click, the same steps every time, with no secrets on anybody’s laptop and no Mac on anybody’s desk.
A single manually-triggered GitHub Actions workflow with three inputs:
| Input | Choices | Meaning |
|---|---|---|
| platform | android, ios, both | Which app(s) to release |
| api | dev, prod | Which backend the build talks to |
| version | Text such as 0.1.5 (optional) | The version name shown in the stores and the app; empty uses pubspec.yaml |
Anyone on the team presses Actions → Mobile release → Run workflow, from any computer. About 15 to 25 minutes later:
We split the work between two tools, each doing what it’s best at.
Fastlane handles the how: talking to Google Play and App Store Connect, working out version numbers, running the Flutter build, signing and uploading. It runs the same way on a laptop as it does on CI, so we can still debug a release locally.
GitHub Actions handles the where and when: which machine runs the build (Linux for Android, macOS for iOS), which secrets are injected, who has to approve a production build, and keeping the built files for later.
That split keeps the workflow file short and easy to read.
This was the part we were most worried about, and it ended up being the cleanest.
GitHub provides hosted macOS machines that already have Xcode installed. Our iOS job simply runs on one of them, so the Mac is GitHub’s problem, not ours.
Signing was the other half. The traditional approach is to export a certificate (.p12) and a provisioning profile, store them as secrets, and install them on every run. That’s a lot of moving parts, and certificates expire every year.
Instead we use automatic signing with an App Store Connect API key. The only iOS secrets are the API key itself and our Apple Team ID. During the build, Xcode uses that key to create or reuse the distribution certificate and App Store profile on the fly. There are no certificate files to store, renew or lose.
Two small settings make iOS releases even smoother:
Info.plist declares that it only uses standard encryption (HTTPS), so TestFlight doesn’t stop each upload to ask about export compliance.Android and iOS run the same shape of pipeline, so once you understand one you understand both:
| Stage | What happens |
|---|---|
| Test | flutter analyze and flutter test. Any failure stops everything; nothing is built or uploaded. |
| Version | Ask the store for the latest build number and add 1. The version name comes from the version input, or pubspec.yaml when it’s empty. |
| Build | A clean release build with the right version and API URL baked in. |
| Upload | Android goes to Play Internal testing. iOS goes to TestFlight. |
Keeping the build number in a file means someone has to remember to bump it, and two branches can bump it to the same value. So we stopped trusting the file. Before each build, Fastlane asks Google Play (or TestFlight) for the highest build number it has ever seen and adds one. Nothing is written back to the repo, so the workflow only ever needs read access.
The optional version input sets the version name in one go. Type 0.1.5 and it shows up in Play Console, App Store Connect, TestFlight and on the app’s own profile screen. Only a strict format like 0.1.5 is accepted, so a typo fails in seconds instead of after a 20-minute build. Leave it empty and the version from pubspec.yaml is used.
Android builds go straight to the Internal testing track instead of waiting as drafts, and iOS builds go straight to TestFlight. Only the app itself is uploaded; the store listing, screenshots and descriptions are never touched.
Going live stays a human decision. Builds that use the production API pause until an approved reviewer clicks Approve in GitHub, and promoting a build to Production or submitting it for App Store review is still done by hand, after someone has tested it.
Everything sensitive lives in Settings → Secrets and variables → Actions. Nothing is committed to the repo and nothing is needed on a developer’s machine.
| Secret | Platform | What it is |
|---|---|---|
| ANDROID_KEYSTORE_BASE64 | Android | The upload keystore, base64-encoded |
| ANDROID_KEYSTORE_PASSWORD | Android | Keystore password |
| ANDROID_KEY_ALIAS | Android | Key alias |
| ANDROID_KEY_PASSWORD | Android | Key password |
| PLAY_STORE_JSON_KEY | Android | Google Cloud service-account JSON key |
| APPLE_TEAM_ID | iOS | 10-character Apple Developer team ID |
| ASC_KEY_ID | iOS | App Store Connect API Key ID |
| ASC_ISSUER_ID | iOS | App Store Connect API Issuer ID |
| ASC_KEY_P8_BASE64 | iOS | The AuthKey_XXXX.p8 file, base64-encoded |
The dev and production API URLs are stored as plain variables, since they aren’t secret.
The workflow is the easy part. Most of the time goes into store-side setup, and you only do it once.
Don’t generate a new keystore: Play only accepts builds signed with the upload key it already knows.
.p8 file straight away, because Apple only lets you download it once.Save this as .github/workflows/mobile-release.yml:
name: Mobile release
on:
workflow_dispatch:
inputs:
platform:
description: App to release
required: true
type: choice
options: [android, ios, both]
default: android
api:
description: API the build talks to (prod waits for approval)
required: true
type: choice
options: [dev, prod]
default: dev
version:
description: Version shown in the stores, e.g. 0.1.5 (empty = pubspec.yaml)
required: false
type: string
default: ''
permissions:
contents: read
env:
FLUTTER_VERSION: 3.47.7
RUBY_VERSION: '3.3'
APP_VERSION_NAME: ${{ inputs.version }}
jobs:
test:
name: Analyze and test
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/mobile
steps:
- uses: actions/checkout@v4
- name: Check the version input
if: inputs.version != ''
run: |
if ! [[ "$APP_VERSION_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must look like 0.1.5, got '$APP_VERSION_NAME'"
exit 1
fi
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- run: flutter pub get
- run: flutter analyze
- run: flutter test
android:
name: Android ? Play Internal testing
needs: test
if: inputs.platform != 'ios'
runs-on: ubuntu-latest
concurrency:
group: mobile-release-android
cancel-in-progress: false
environment: ${{ inputs.api == 'prod' && 'production' || 'development' }}
env:
SKIP_FLUTTER_TESTS: 'true'
APP_PLAY_RELEASE_STATUS: completed
APP_DEV_API_URL: ${{ vars.APP_DEV_API_URL }}
APP_PROD_API_URL: ${{ vars.APP_PROD_API_URL }}
APP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
APP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
APP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
bundler-cache: true
working-directory: apps/mobile
- name: Restore the upload keystore and the Play key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
PLAY_JSON: ${{ secrets.PLAY_STORE_JSON_KEY }}
run: |
echo "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/upload-keystore.jks"
printf '%s' "$PLAY_JSON" > "$RUNNER_TEMP/play-store.json"
echo "APP_KEYSTORE_PATH=$RUNNER_TEMP/upload-keystore.jks" >> "$GITHUB_ENV"
echo "APP_PLAY_STORE_JSON_KEY=$RUNNER_TEMP/play-store.json" >> "$GITHUB_ENV"
- name: Build and release to Internal testing
working-directory: apps/mobile/android
run: bundle exec fastlane ${{ inputs.api == 'prod' && 'internal' || 'dev' }}
- uses: actions/upload-artifact@v4
with:
name: android-${{ inputs.api }}-${{ inputs.version && format('{0}-', inputs.version) || '' }}${{ github.run_number }}
path: apps/mobile/build/app/outputs/bundle/release/app-release.aab
retention-days: 14
- name: Remove the keystore and key
if: always()
run: rm -f "$RUNNER_TEMP/upload-keystore.jks" "$RUNNER_TEMP/play-store.json"
ios:
name: iOS ? TestFlight
needs: test
if: inputs.platform != 'android'
runs-on: macos-latest
concurrency:
group: mobile-release-ios
cancel-in-progress: false
environment: ${{ inputs.api == 'prod' && 'production' || 'development' }}
env:
SKIP_FLUTTER_TESTS: 'true'
APP_DEV_API_URL: ${{ vars.APP_DEV_API_URL }}
APP_PROD_API_URL: ${{ vars.APP_PROD_API_URL }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ env.RUBY_VERSION }}
bundler-cache: true
working-directory: apps/mobile
- name: Build, sign and upload to TestFlight
working-directory: apps/mobile/ios
run: bundle exec fastlane ${{ inputs.api }}
- uses: actions/upload-artifact@v4
with:
name: ios-${{ inputs.api }}-${{ inputs.version && format('{0}-', inputs.version) || '' }}${{ github.run_number }}
path: apps/mobile/build/ios/ipa/App.ipa
retention-days: 14
flutter analyze and flutter test once on Linux. If anything fails, the build jobs never start. The build jobs set SKIP_FLUTTER_TESTS so the tests don’t run again on the more expensive machines.production environment, which waits for an approved reviewer. Dev builds run straight away..aab and .ipa for 14 days, named with the API, version and run number.These are the errors we actually hit, and what fixed them:
| Message | Fix |
|---|---|
| The caller does not have permission | Invite the service account in Play Console and grant app permissions, then wait about 5 minutes. |
| Could not read any versionCode from Play | The service account can’t see the app, or no build was ever uploaded by hand. |
| Version code X has already been used | Two uploads raced. The concurrency group prevents this on CI; just rerun. |
| The Android App Bundle was signed with the wrong key | The keystore secret isn’t the upload key Play expects. |
| iOS export fails after a successful archive | Put the signing settings in the Xcode project instead of passing them as build arguments. |
If you’re running a Flutter app with a small team and still releasing from someone’s laptop, this setup takes about a day, and most of that day is store-console setup you only do once. After that, every release is one button.
Stack: Flutter 3.47, Fastlane 2.228, Ruby 3.3, GitHub Actions (ubuntu-latest and macos-latest).