arrow_back Back

One Button, Two Stores: Shipping Your Flutter App to Play Store and App Store with GitHub Actions

Oct 30, 2026

One Button, Two Stores: Shipping Your Flutter App to Play Store and App Store with GitHub Actions and Fastlane

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.

The problem

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:

  • The upload keystore lived on one or two laptops. If that person was on leave, nothing shipped.
  • Someone had to bump the build number in pubspec.yaml by hand. Sometimes two people uploaded the same number and Play rejected the second one.
  • Every so often a “quick test build” went out pointing at the wrong API.

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.

What we ended up with

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:

  • Android: a signed App Bundle is released to Google Play’s Internal testing track, and testers can install it straight away.
  • iOS: a signed build is processing in TestFlight, and internal testers get it automatically.

Why GitHub Actions and Fastlane?

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.

iOS without a Mac and without Xcode

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:

  • The app’s Info.plist declares that it only uses standard encryption (HTTPS), so TestFlight doesn’t stop each upload to ask about export compliance.
  • The workflow doesn’t wait for Apple to finish processing the build, which takes 10 to 30 minutes. There’s no reason to pay for a macOS machine to sit and watch that.

Every release follows the same four stages

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.

Key decisions

The store decides the build number

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.

Type the version once, see it everywhere

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.

Release to testers directly, keep production human

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.

Secrets you need

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.

One-time store setup

The workflow is the easy part. Most of the time goes into store-side setup, and you only do it once.

Google Play

  1. Upload the first build by hand. The Play API can only upload to an app that already has at least one release.
  2. Enable the Google Play Android Developer API in any Google Cloud project.
  3. Create a service account and download a JSON key.
  4. Invite the service account’s email in Play Console under Users and permissions, and allow it to release to testing tracks.
  5. Wait about five minutes for the permissions to take effect.

Don’t generate a new keystore: Play only accepts builds signed with the upload key it already knows.

Apple

  1. Join the Apple Developer Program for your organisation.
  2. Create the app in App Store Connect with the right bundle ID.
  3. Create an App Store Connect API key with the Admin role, so it can create certificates and profiles. Download the .p8 file straight away, because Apple only lets you download it once.
  4. Create an internal TestFlight group and add your testers.

The workflow file

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

How the workflow works, in short

  • Inputs: when you click Run workflow, you choose the platform (Android, iOS or both), the API (dev or prod) and, optionally, the version number.
  • permissions: the workflow can only read the repo. It never commits anything back.
  • test job: checks the version format, then runs 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.
  • android job: runs on Linux. It decodes the keystore and Play key from secrets into a temporary folder, then Fastlane builds, signs and releases the app to Play Internal testing. The last step deletes the keystore and key, even if the build failed.
  • ios job: runs on GitHub’s macOS machine with the latest stable Xcode. Fastlane builds the app, signs it using the App Store Connect API key and uploads it to TestFlight. This is the job that replaces your own Mac.
  • environment: builds using the prod API run in the production environment, which waits for an approved reviewer. Dev builds run straight away.
  • concurrency: only one upload per store runs at a time. A second run waits its turn, so two releases can never pick the same build number.
  • upload-artifact: keeps a copy of the .aab and .ipa for 14 days, named with the API, version and run number.

Troubleshooting

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.

What we got out of it

  • Anyone on the team can ship, from Windows, Mac or Linux, with nothing installed locally.
  • No Mac and no Xcode needed for iOS releases, and no certificate files to renew.
  • No secrets on laptops. Keys exist only as encrypted GitHub secrets and on a build machine for the length of a job.
  • No more build number clashes, because the store decides and uploads run one at a time.
  • Tests always gate a release. A red test means nothing is built.
  • People still make the go-live decision for production.

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).

Jeet Patel

Author

Jeet Patel

Jeet Patel is a software professional at MagnusMinds IT Solution.