CI/CD & App Store Deployment

Automating Builds with GitHub Actions

16 min Lesson 6 of 12

Automating Builds with GitHub Actions

Manually building, signing, and uploading a Flutter app before every release is error-prone and time-consuming. GitHub Actions solves this by running your entire build pipeline automatically whenever you push to a branch or create a tag. In this lesson you will write a production-ready workflow YAML that installs Flutter, executes your test suite, builds a signed Android AAB and an iOS IPA, and uploads both as downloadable artifacts — all without storing sensitive credentials in your repository.

How GitHub Actions Works

A workflow is a YAML file stored under .github/workflows/. GitHub reads it and runs each job on a cloud-hosted runner (Ubuntu, macOS, or Windows). Jobs contain steps: shell commands or reusable actions pulled from the Marketplace. For Flutter CI/CD you typically need two separate jobs — one on ubuntu-latest for Android and one on macos-latest for iOS — because the iOS toolchain only runs on macOS.

Note: Each job starts from a clean runner. Nothing from a previous run persists unless you explicitly upload an artifact or use a cache action. Always restore your pub cache and Gradle caches to avoid re-downloading hundreds of megabytes on every run.

Storing Secrets Securely

Your Android keystore and iOS signing certificates must never be committed to the repository. GitHub provides an encrypted Secrets store (Settings → Secrets and variables → Actions). Binary files like keystores are base-64 encoded before storage and decoded inside the workflow:

  • KEYSTORE_BASE64 — base-64 encoded .jks / .keystore file
  • KEY_ALIAS — alias used when you created the keystore
  • KEY_PASSWORD — key password
  • STORE_PASSWORD — keystore password
  • IOS_P12_BASE64 — base-64 encoded Distribution certificate (.p12)
  • IOS_P12_PASSWORD — password for the .p12 file
  • PROVISIONING_PROFILE_BASE64 — base-64 encoded .mobileprovision file
Warning: Never print secrets to the log. GitHub redacts known secret values, but if you base-64 decode and echo a secret you may expose fragments. Always write decoded files to disk immediately and remove them at the end of the job.

Complete Workflow: Android Signed AAB

The following workflow triggers on every push to main and on version tags (v*). The Android job installs Flutter, caches pub packages, runs tests, decodes the keystore, builds a release AAB, and uploads it as an artifact.

.github/workflows/flutter_ci.yml — Android job

name: Flutter CI/CD

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

jobs:
  # ── Android ─────────────────────────────────────────────
  build_android:
    name: Build Android AAB
    runs-on: ubuntu-latest

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

      - name: Set up Java 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'

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

      - name: Install Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Run tests
        run: flutter test --coverage

      - name: Decode keystore
        run: |
          echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode \
            > android/app/keystore.jks

      - name: Build release AAB
        run: flutter build appbundle --release
        env:
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}

      - name: Upload AAB artifact
        uses: actions/upload-artifact@v4
        with:
          name: android-release-aab
          path: build/app/outputs/bundle/release/app-release.aab
          retention-days: 14

Complete Workflow: iOS Signed IPA

The iOS job runs on macos-latest. It imports the distribution certificate into a temporary keychain, installs the provisioning profile, builds the app with flutter build ipa, and uploads the resulting IPA.

.github/workflows/flutter_ci.yml — iOS job (append to same file)

  # ── iOS ─────────────────────────────────────────────────
  build_ios:
    name: Build iOS IPA
    runs-on: macos-latest

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

      - name: Install Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Import signing certificate
        env:
          P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
          P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
        run: |
          KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
          security create-keychain -p "" "$KEYCHAIN_PATH"
          security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
          security unlock-keychain -p "" "$KEYCHAIN_PATH"
          echo "$P12_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
          security import "$RUNNER_TEMP/cert.p12" \
            -k "$KEYCHAIN_PATH" -P "$P12_PASSWORD" \
            -A -t cert -f pkcs12
          security list-keychain -d user -s "$KEYCHAIN_PATH"

      - name: Install provisioning profile
        env:
          PP_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
        run: |
          PP_PATH=$RUNNER_TEMP/profile.mobileprovision
          echo "$PP_BASE64" | base64 --decode > "$PP_PATH"
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp "$PP_PATH" \
            ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS IPA
        run: flutter build ipa --release --no-codesign

      - name: Upload IPA artifact
        uses: actions/upload-artifact@v4
        with:
          name: ios-release-ipa
          path: build/ios/ipa/*.ipa
          retention-days: 14

Configuring android/app/build.gradle for CI Signing

The workflow injects signing credentials via environment variables. Your android/app/build.gradle must read them so Gradle picks up the right keystore at build time:

android/app/build.gradle — signingConfigs block

android {
    signingConfigs {
        release {
            keyAlias     System.getenv("KEY_ALIAS")     ?: "mykey"
            keyPassword  System.getenv("KEY_PASSWORD")  ?: ""
            storeFile    file("keystore.jks")
            storePassword System.getenv("STORE_PASSWORD") ?: ""
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true
            proguardFiles getDefaultProguardFile(
                'proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

Caching Strategies for Faster Runs

Cold builds can take 8–15 minutes. Effective caching cuts this to 2–4 minutes on subsequent runs:

  • Pub cache — keyed on pubspec.lock; saves ~30 seconds per run
  • Flutter SDK cache — the subosito/flutter-action action has a built-in cache: true option
  • Gradle cache — add a separate actions/cache step targeting ~/.gradle/caches and ~/.gradle/wrapper
  • CocoaPods cache — on macOS, cache ios/Pods keyed on ios/Podfile.lock
Tip: Pin your Flutter version explicitly (e.g. flutter-version: '3.22.0') rather than using channel: stable alone. This makes builds reproducible: a new Flutter release will not silently break your pipeline until you deliberately bump the version.

Summary

A well-structured GitHub Actions workflow gives you automated, reproducible Flutter builds on every push. The key points are: store all credentials as repository secrets and decode them at runtime; run tests before building so failures are caught early; use caching to keep runtimes under five minutes; and upload artifacts so QA and stakeholders can download the binaries directly from the Actions tab without needing a local environment.

Key Takeaway: Your workflow file is code — version-control it, review it in PRs, and treat signing secrets as first-class infrastructure. A single .github/workflows/flutter_ci.yml can replace hours of manual release work with a fully automated, auditable pipeline.