Back to Blog
2026-03-12
10 min read

How I Automated Flutter APK Releases with GitHub Actions — And Why It Failed 4 Times Before Working

#Flutter#GitHub Actions#CI/CD#Android#DevOps#Firebase

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:

  1. Run flutter build apk --release
  2. Copy the APK from build/app/outputs/flutter-apk/
  3. Manually upload to GitHub Releases
  4. 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 main or on a version tag like v*
  • 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


Four failures, one working pipeline. CI/CD is mostly just reading error messages carefully.