How I Automated Flutter APK Releases with GitHub Actions — And Why It Failed 4 Times Before Working
How I Automated Flutter APK Releases with GitHub Actions — And Why It Failed 4 Times Before Working
Why I Needed This
I was building the Emergency 108 mobile app — a Flutter app that works alongside a Spring Boot backend to dispatch ambulances in real time. Every time I fixed a bug or added a feature, the release process was manual:
- Run
flutter build apk --release - Copy the APK from
build/app/outputs/flutter-apk/ - Manually upload to GitHub Releases
- Share the link
For a project meant to simulate emergency response infrastructure, this was embarrassingly slow. I wanted a pipeline where every push to main (or a tag like v1.0.3) would automatically build and publish the APK to GitHub Releases — without me touching it.
This is the story of setting that up. Including the 4 times it broke before working.
The Goal
A GitHub Actions workflow that:
- Triggers on push to
mainor on a version tag likev* - Sets up Flutter
- Runs
flutter build apk --release - Uploads the resulting APK as a GitHub Release artifact
Simple in theory. Here's how it actually went.
Failure #1 — Wrong Working Directory
The workflow
name: Flutter APK Release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
- name: Build APK
run: flutter build apk --release
The error
Error: No pubspec.yaml file found.
This command should be run from the root of your Flutter project.
Why it failed
My repo structure looked like this:
Emergency108/
backend/ ← Spring Boot
mobile/ ← Flutter app (pubspec.yaml lives HERE)
.github/
workflows/
release.yml
GitHub Actions checked out the repo root. But pubspec.yaml was inside mobile/. Flutter had no idea where the project was.
Fix
- name: Build APK
run: flutter build apk --release
working-directory: mobile
Or alternatively, set it at the job level:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile
Failure #2 — Dart SDK Version Mismatch
The error
Because emergency108 requires sdk >=3.3.0 <4.0.0, version solving failed.
The current Dart SDK version is 3.1.5.
Failed to run flutter pub get.
Why it failed
I had specified flutter-version: '3.19.0' — but I was running Flutter 3.24.x locally. The version I pinned in the workflow was too old and came with Dart 3.1.5, which didn't satisfy the sdk: '>=3.3.0' constraint in pubspec.yaml.
Fix
Match the Flutter version to what you're actually using locally:
# Check your local Flutter version
flutter --version
Output:
Flutter 3.24.3 • channel stable • Dart 3.5.3
Update the workflow:
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.3'
channel: 'stable'
Or use channel: stable without pinning a version to always get the latest stable:
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
Failure #3 — Missing google-services.json
The error
ERROR: File google-services.json is missing from module root folder.
The Google Services Gradle plugin was not applied correctly.
Failed task: :app:processDebugGoogleServices
Why it failed
Emergency 108 uses Firebase for push notifications. That requires google-services.json in mobile/android/app/. I had it locally but it was in .gitignore — because it contains real Firebase project credentials and you should never commit it to a public repo.
GitHub Actions doesn't have it, so the build fails.
Fix — Use GitHub Secrets
Step 1: Encode the file as base64:
# On Linux/macOS
base64 -i google-services.json
# On Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("google-services.json"))
Step 2: Add it to GitHub → Settings → Secrets and variables → Actions → New repository secret:
- Name:
GOOGLE_SERVICES_JSON - Value: (the base64 string)
Step 3: Decode it in the workflow before the build:
- name: Decode google-services.json
run: |
echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > android/app/google-services.json
working-directory: mobile
Now the file exists at build time without ever being committed to source control.
Failure #4 — 403 on GitHub Release Upload
The error
Error: HttpError: Resource not accessible by integration
403: must have write permissions for releases
Why it failed
I was using softprops/action-gh-release to create the GitHub Release and upload the APK. But I hadn't given the workflow permission to write releases.
By default, GitHub Actions tokens have read permission for contents. Creating a Release needs write.
Fix — Add permissions block
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # Required to create GitHub Releases
Alternatively, go to Settings → Actions → General → Workflow permissions → select Read and write permissions.
I prefer the explicit permissions block in the YAML — it's scoped, auditable, and doesn't affect other workflows.
The Workflow That Finally Worked
name: Flutter APK Release
on:
push:
tags:
- 'v*' # Triggers on tags like v1.0.0, v1.2.3
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
defaults:
run:
working-directory: mobile
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- name: Decode google-services.json
run: |
echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > android/app/google-services.json
- name: Install dependencies
run: flutter pub get
- name: Build release APK
run: flutter build apk --release
- name: Upload APK to GitHub Releases
uses: softprops/action-gh-release@v2
with:
files: mobile/build/app/outputs/flutter-apk/app-release.apk
name: "Emergency 108 ${{ github.ref_name }}"
body: "Automated release for ${{ github.ref_name }}"
How to trigger it
git tag v1.0.0
git push origin v1.0.0
GitHub Actions picks up the tag, builds the APK, and creates a release — no manual steps.
Key Lessons
| Failure | Root Cause | Fix |
|---|---|---|
| No pubspec.yaml found | Wrong working directory | Add working-directory: mobile |
| Dart SDK mismatch | Pinned old Flutter version | Match local Flutter version or use channel: stable |
| google-services.json missing | Secret file not committed | Base64 encode → GitHub Secret → decode in workflow |
| 403 on release upload | Missing write permissions | Add permissions: contents: write |
What This Changed in My Workflow
Before: build → copy APK → upload manually → share link. That's 3 manual steps after every change.
After: git tag v1.x.x && git push origin v1.x.x. The rest is automated.
For Emergency 108 specifically, this was meaningful — the app is designed to respond to emergencies. A deployment pipeline that doesn't require me to be at my laptop to push a release actually aligns with the project's purpose.
References
- subosito/flutter-action
- softprops/action-gh-release
- GitHub Actions: Workflow permissions
- Flutter build docs
Four failures, one working pipeline. CI/CD is mostly just reading error messages carefully.