I Automated My Flutter App Releases. Here’s Everything I Wish I’d Known First.
A practical guide to shipping Flutter apps with GitHub Actions, Fastlane, and Firebase — written for people who have never touched CI/CD.
I Automated My Flutter App Releases. Here’s Everything I Wish I’d Known First.
A practical guide to shipping Flutter apps with GitHub Actions, Fastlane, and Firebase — written for people who have never touched CI/CD.
The first time I shipped a Flutter app update, it took me forty minutes and I still got it wrong.
I built the APK. I uploaded it. The Play Store rejected it — version code already exists. I had forgotten to bump the number in pubspec.yaml. So I bumped it, rebuilt, waited another five minutes, uploaded again. Then I realized I'd built from the wrong branch.
Nothing about this was hard. It was just a sequence of small steps that I had to remember perfectly, every single time, forever.
That’s the actual problem CI/CD solves. Not “advanced DevOps.” Just: stop asking a human to do the same twelve things from memory.
Here’s how the whole thing works, built up from nothing.
The goal
By the end of this, merging a pull request will do the work:
- Merge into
develop→ the app is built, signed, and pushed to your beta testers - Merge into
main→ the app is built, signed, and submitted to the Play Store and App Store
No terminal. No clicking through Play Console. No forgetting the version number.
Three ideas you need before any of this makes sense
1. A “runner” is a fresh computer that gets destroyed
When your pipeline runs, GitHub spins up a brand new virtual machine. It has nothing on it — not your code, not Flutter, not your signing keys. Every single thing your build needs must be installed or created during the run. Then the machine is deleted.
This explains almost every weird thing you’ll see in a workflow file. Why does it check out the code again in the second job? Because that’s a different machine. Why does it recreate your key.properties file from secrets? Because the real one is gitignored and doesn't exist on a clean checkout.
2. The build number must always go up
Your version looks like this:
version: 1.4.2+87
The 1.4.2 is what users see. The 87 is the build number, and here's the rule that catches everyone:
The build number must strictly increase with every single upload. Not every release — every upload.
Upload build 87, then try to upload 87 again with a bug fix? Rejected. This is exactly the mistake I made on day one, and it’s exactly the kind of thing a computer should be tracking instead of me.
3. Every release build must be signed
Both Apple and Google require a cryptographic signature proving the app came from you and hasn’t been tampered with. On Android that’s a keystore file. On iOS it’s a certificate plus a provisioning profile.
These files are secret. They can’t live in your repo. So the pipeline reconstructs them at build time from encrypted secrets — which is what all that base64 decoding in the workflow is doing.
The shape of a workflow file
GitHub Actions workflows live in .github/workflows/ and look like this:
name: Deploy Beta
on: # WHEN
push:
branches: [develop]
jobs: # WHAT
build_android:
runs-on: ubuntu-latest # WHERE
steps: # HOW
- uses: actions/checkout@v4
- run: flutter build apk --release
Four questions: when, what, where, how. That’s the entire mental model.
One detail worth internalizing: a pull request merge is a push. When you click “Merge pull request,” git creates a commit on the target branch. That commit landing is a push event. So on: push: branches: [develop] fires exactly when a PR is merged into develop — which is what we want.
Solving the version number problem
This is the first genuinely useful thing to automate:
jobs:
version:
runs-on: ubuntu-latest
outputs:
build_number: ${{ steps.ver.outputs.build_number }}
version_name: ${{ steps.ver.outputs.version_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version
id: ver
run: |
VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d '+' -f1)
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT"
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
The clever part is git rev-list --count HEAD — it counts every commit in your history. Commits only ever get added, so this number only ever goes up. The strictly-increasing rule is satisfied automatically, forever, with zero human involvement.
The line that will bite you: fetch-depth: 0. By default, GitHub's checkout does a shallow clone with only the latest commit — for speed. With a shallow clone, git rev-list --count HEAD returns 1. Every time. Your builds all get build number 1 and you spend an hour confused.
Meanwhile 1.4.2 stays manual. Only a human knows whether a change is a feature or a bug fix, so semantic versioning shouldn't be automated.
Building and signing
build_android:
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- run: flutter pub get
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore.jks
cat > android/key.properties <<EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=keystore.jks
EOF
- name: Build APK
run: |
flutter build apk --release \
--build-name=${{ needs.version.outputs.version_name }} \
--build-number=${{ needs.version.outputs.build_number }}
Two things to notice.
**needs: version** makes this job wait for the version job. Jobs run in parallel by default, so without this it would start before the version exists. And needs.version.outputs.version_name only works because that job explicitly declared the value under outputs: — nothing is shared between jobs otherwise.
Base64 exists because GitHub secrets are text fields. A keystore is binary. Base64 represents binary data using only safe text characters so it survives the round trip. You encode it once on your machine (base64 -w0 upload-keystore.jks), paste the result as a secret, and the pipeline decodes it back.
Pin your Flutter version. If you don’t, your builds silently change whenever Flutter releases an update, and a build that worked yesterday fails today for reasons unrelated to your code.
Getting it to testers
For beta, Firebase App Distribution is hard to beat — it’s free, instant, and handles both platforms:
- uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
groups: beta-testers
file: build/app/outputs/flutter-apk/app-release.apk
releaseNotes: ${{ github.event.head_commit.message }}
That last line is a small thing that makes testers much happier: instead of “Build 342,” they see the actual merge commit message describing what changed.
Everything you need here comes from one place — the Firebase Console. Register your app to get the App ID, enable App Distribution, create a tester group, and generate a service account key under Project Settings → Service Accounts.
Production is a different animal
Beta is instant. Production involves review, stores, and a tool called Fastlane.
What Fastlane actually is
Fastlane is a Ruby command-line tool that automates the tedious parts of store releases — signing, uploading, submitting for review. Apple and Google both have APIs for this, but those APIs are fiddly. Fastlane wraps them in a few readable lines.
Is it industry standard? Functionally, yes. It’s what most mobile teams reach for, and managed platforms like Codemagic and Bitrise often use it under the hood anyway.
You define lanes in a file called a Fastfile:
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
lane :deploy_production do
upload_to_play_store(
track: "production",
json_key_data: ENV["PLAY_STORE_JSON_KEY"],
aab: "../build/app/outputs/bundle/release/app-release.aab",
release_status: "inProgress",
rollout: "0.1",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
A few notes on this:
Production needs an .aab, not an .apk. The Play Store requires App Bundles for new apps — Google uses them to generate device-optimized APKs so users download smaller files. You can't install an .aab directly, which is why beta still uses APKs.
**rollout: "0.1" is the line I'd argue hardest for.** Instead of going to 100% of users instantly, this releases to 10%. You watch crash reports for a day, then expand — or halt. A bug that reaches 10% of users is dramatically cheaper than one that reaches all of them.
*Those `skipuploadflags aren't optional.** By default Fastlane expects afastlane/metadata/` folder with your store listing and screenshots, and errors out if it's missing. These flags say "just upload the binary, leave the listing alone."
How the workflow actually calls Fastlane
This confused me for longer than I’d like to admit. The entire connection is two steps:
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: android
- name: Deploy via Fastlane
working-directory: android
env:
PLAY_STORE_JSON_KEY: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
run: bundle exec fastlane deploy_production
Three separate mechanisms are doing work here:
**working-directory: android* is what selects which* Fastfile runs. Fastlane looks for afastlane/folder relative to where you invoke it. There's no import, no path config — just the directory you're standing in. Set it toiosand you getios/fastlane/Fastfileinstead.- The
env:block is the only way a GitHub secret can reach Ruby code.${{ secrets.X }}is YAML template syntax; Ruby can't see it. Copying it into an environment variable is what letsENV["PLAY_STORE_JSON_KEY"]work inside the lane. **bundle exec** runs the exact Fastlane version locked in yourGemfile.lock, rather than whatever happens to be installed. Without it, a Fastlane release could silently change your pipeline's behavior.
iOS, briefly
Same pattern, three differences: you need a macos runner (Xcode only runs on macOS), you authenticate with an App Store Connect API key rather than your Apple ID (2FA can't work unattended), and you almost certainly want automatic_release: false:
upload_to_app_store(
api_key: api_key,
submit_for_review: true,
automatic_release: false,
phased_release: true,
force: true,
skip_screenshots: true,
skip_metadata: true
)
Apple review takes hours to days and you don’t control when it finishes. With automatic_release: false, the pipeline submits everything, but a human presses the final button once approval lands. That's a checkpoint worth keeping.
One more: Mac runners cost roughly 10x Linux runners. Keep those jobs lean, and set skip_waiting_for_build_processing: true so you're not paying for a machine to sit and poll Apple's servers for twenty minutes.
Mistakes I made, so you don’t have to
Trying to set it all up at once. Android beta, iOS beta, Android production, iOS production, all in one sitting. Everything failed simultaneously and I couldn’t tell which failure caused which.
Do it in this order instead: get local signed builds working → upload manually once → automate Android beta → add iOS beta → automate production with track: "internal" first → switch to production.
Not testing Fastlane locally. You can run it from your own machine:
cd android
bundle install
export PLAY_STORE_JSON_KEY=$(cat ~/service-account.json)
bundle exec fastlane deploy_internal
If that works locally, wiring it into CI is a small step. If it doesn’t, you’ve isolated the problem to Fastlane instead of debugging two systems at once.
Not knowing the Play Store requires a manual first upload. The API refuses automated uploads until at least one build has been uploaded by hand through the console. This is not documented anywhere obvious.
Forgetting permissions: contents: write on any job that pushes git tags. It fails with a 403 and the error doesn't explain why.
Writing deployment automation before test automation. This is the one I’d change first. A separate workflow that runs on pull requests is arguably more valuable than everything above:
name: CI
on:
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- run: flutter pub get
- run: flutter analyze
- run: flutter test
Catching a bug before merge is cheaper than catching it after it reached your testers.
One thing that isn’t a CI/CD problem
While setting this up, you’ll be tempted to put API keys in your app via --dart-define or a .env file. Both work, and --dart-define is the better of the two for CI since values get compiled in as constants rather than shipped as a readable asset file.
But be clear-eyed about what that buys you:
Nothing embedded in a mobile binary is truly secret. Anyone can decompile an app and extract strings.
--dart-define raises the effort required. It does not make a value safe. Real secrets — database credentials, payment provider keys — belong on your backend. If your app currently ships one, moving it server-side matters more than any pipeline improvement.
Start smaller than you think
If all of this feels like a lot, here’s a legitimate first version. It doesn’t distribute anything — it just builds a signed APK and saves it for download from the Actions tab:
name: Beta
on:
push:
branches: [develop]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
cache: true
- run: flutter pub get
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore.jks
cat > android/key.properties <<EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=keystore.jks
EOF
- run: flutter build apk --release --build-number=$(git rev-list --count HEAD)
- uses: actions/upload-artifact@v4
with:
name: apk
path: build/app/outputs/flutter-apk/app-release.apk
That alone eliminates “it builds on my machine.” Add distribution next. Then iOS. Then production.
Incremental beats complete-but-broken — which, for what it’s worth, is also true of most things that aren’t CI/CD pipelines.
Add workflow_dispatch: to your triggers early. It gives you a manual "Run workflow" button, which means you can iterate without pushing empty commits to fake a trigger. You'll use it more than you expect.
메타데이터
- post_id
- 8fa7cd8dbdd5
- slug
- i-automated-my-flutter-app-releases-heres-everything-i-wish-i-d-known-first-8fa7cd8dbdd5
- url
- https://medium.com/@bibekpaneru01/i-automated-my-flutter-app-releases-heres-everything-i-wish-i-d-known-first-8fa7cd8dbdd5
- canonical_url
- https://medium.com/@bibekpaneru01/i-automated-my-flutter-app-releases-heres-everything-i-wish-i-d-known-first-8fa7cd8dbdd5
- author_url
- https://medium.com/@bibekpaneru01
- status
- ok
- fetched_at
- 2026-08-19 19:37:03