CI/CD with GitHub Actions in Flutter: solved exercise

CI/CD with GitHub Actions in Flutter: solved exercise

Without CI/CD, code quality depends on every developer remembering to run tests and analysis before each push. With GitHub Actions, those checks run automatically on every PR and every merge to main, with no manual intervention.

Problem statement

Create a GitHub Actions pipeline for a Flutter project that:

  1. Analysis and tests: runs on every push and PR to main.
  2. APK build: generates a release APK on every merge to main.
  3. Pub cache: reduces setup time from ~2 min to ~15 s.
  4. Version matrix: tests against Flutter stable and beta.

File structure

1
2
3
4
.github/
└── workflows/
    ├── ci.yml       ← analysis + tests (pull requests)
    └── release.yml  ← APK/IPA build (push to main)

Solution: ci.yml (analysis + tests)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  analyze_and_test:
    name: Analyze & Test (Flutter ${{ matrix.flutter_channel }})
    runs-on: ubuntu-latest

    strategy:
      matrix:
        flutter_channel: [stable, beta]
      fail-fast: false  # does not cancel the other channel if one fails

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '' # uses the latest for the channel
          channel: ${{ matrix.flutter_channel }}
          cache: true         # caches the Flutter SDK

      - name: Cache pub packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.pub-cache
            .dart_tool
          key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            pub-${{ runner.os }}-

      - name: Install dependencies
        run: flutter pub get

      - name: Check formatting
        run: dart format --set-exit-if-changed .

      - name: Static analysis
        run: dart analyze --fatal-warnings

      - name: Unit and widget tests
        run: flutter test --coverage

      - name: Upload coverage to Codecov
        if: matrix.flutter_channel == 'stable'
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: coverage/lcov.info

Solution: release.yml (APK build)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# .github/workflows/release.yml
name: Release Build

on:
  push:
    branches: [main]
  workflow_dispatch:  # allows manual trigger from GitHub UI

jobs:
  build_apk:
    name: Build APK (prod)
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - name: Cache pub
        uses: actions/cache@v4
        with:
          path: ~/.pub-cache
          key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}

      - name: Install dependencies
        run: flutter pub get

      - name: Create production env
        run: |
          echo '{
            "APP_ENV": "prod",
            "APP_NAME": "MyApp",
            "API_BASE_URL": "${{ secrets.PROD_API_URL }}",
            "ENABLE_LOGGING": "false"
          }' > envs/prod.json

      - name: Build release APK
        run: flutter build apk --release --dart-define-from-file=envs/prod.json

      - name: Upload APK as artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-release-${{ github.sha }}
          path: build/app/outputs/flutter-apk/app-release.apk
          retention-days: 14

  build_ios:
    name: Build IPA (prod)
    runs-on: macos-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - run: flutter pub get

      - name: Build iOS (unsigned — for internal distribution)
        run: flutter build ios --release --no-codesign

      - name: Create unsigned IPA
        run: |
          mkdir -p build/ios/ipa
          cd build/ios/iphoneos
          mkdir Payload
          cp -r Runner.app Payload/
          zip -r ../ipa/app-release.ipa Payload

      - uses: actions/upload-artifact@v4
        with:
          name: app-ios-release-${{ github.sha }}
          path: build/ios/ipa/app-release.ipa
          retention-days: 14

Adding badges to README

1
2
3
<!-- README.md -->
[![CI](https://github.com/yourusername/yourrepo/actions/workflows/ci.yml/badge.svg)](https://github.com/yourusername/yourrepo/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/yourusername/yourrepo/branch/main/graph/badge.svg)](https://codecov.io/gh/yourusername/yourrepo)

Required GitHub secrets

In Settings → Secrets and variables → Actions:

SecretUsage
CODECOV_TOKENUpload coverage to Codecov
PROD_API_URLProduction API URL (not in code)
KEYSTORE_BASE64Android keystore encoded in base64 (for signing)
KEYSTORE_PASSWORDKeystore password

Signing the APK in CI (advanced)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Additional steps in build_apk to sign the APK
- name: Decode keystore
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

- name: Build signed APK
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: |
    flutter build apk --release \
      --dart-define-from-file=envs/prod.json

And in android/app/build.gradle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
android {
    signingConfigs {
        release {
            storeFile file("keystore.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias System.getenv("KEY_ALIAS")
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

Approximate execution times

JobTime without cacheTime with cache
flutter pub get~90 s~10 s
dart analyze~20 s~20 s
flutter test~30–120 s~30–120 s
flutter build apk~3–5 min~2–3 min

Common mistakes

  • dart analyze passes locally but fails in CI: different Dart version. Pin the version in flutter-action with flutter-version: '3.32.0' or use the same channel as locally.
  • Tests fail in CI but not locally: usually tests using DateTime.now() or timezone-dependent logic. Use fixed dates in tests.
  • APK too large: add --split-debug-info=build/debug-info and --obfuscate to reduce size and obfuscate code.

Practical use

With this pipeline: every PR goes through automatic analysis and tests, reviewers see the result before approving, and every merge to main produces an APK ready for internal distribution or Play Store upload.

Guided practice and next step

FAQ

How many GitHub Actions minutes does the free plan include?

2,000 minutes/month on Linux for private repositories. For public repositories, Linux runners are free and unlimited. macOS runners consume 10× more minutes than Linux.

Can I deploy automatically to the Play Store?

Yes, using fastlane or the r0adkll/upload-google-play action. You need a Google Play API key and must upload an AAB instead of an APK.

How do I prevent a PR from reaching main without passing tests?

In Settings → Branches → Branch protection rules, enable “Require status checks to pass before merging” and select the CI job as a required check.