🌙
☀️ Dark

Volume 9: CI/CD & App Store

Advanced ⏱ 18 min read

Volume 9: CI/CD & App Store Deployment

Learning Objectives

Why Does This Exist?

Building the app is only 50% of the work. The other 50% is getting it onto a user's phone. Releasing a mobile app is not like deploying a website where you just upload files to a server. Mobile platforms are highly regulated, cryptographically secure walled gardens.

The Problem Before the Solution

If you build an app manually, you have to run tests on your laptop, increment the version number, build the Android APK, sign it with a Keystore, open Xcode, archive the iOS build, sign it with a certificate, log into two different web portals, upload massive files over a slow internet connection, and manually type out release notes. This takes hours. If you make a mistake, you start over. If a new developer joins the team, they spend three days just setting up signing certificates.

Mental Model: The Automated Factory

CI/CD (Continuous Integration / Continuous Deployment) is your automated factory line. You write code and push it to GitHub (the raw materials). GitHub automatically spins up a virtual Mac in the cloud. It runs all your tests. If the tests pass, it builds the Android and iOS binaries. It securely signs them using keys hidden in your GitHub Secrets. It then automatically uploads the finished binaries to the App Store and Google Play for beta testers.

You push code on Friday, go to sleep, and wake up with your app waiting for App Store review.

Internal Working: Cryptographic Signing

Why do we "sign" apps? When a user downloads your app, how does their phone know that an evil hacker hasn't intercepted the download and injected a virus?

You generate a highly secure mathematical Key (Keystore for Android, Certificate for iOS). You keep this key absolutely secret. When you build the app, you stamp it with this key. When the user downloads the app, the OS checks the stamp against Apple/Google's records. If the stamp matches, the app installs. If you lose your Keystore, you can never update your app again. The system will reject it because the stamp doesn't match the original.

Syntax: The CI/CD Pipeline (GitHub Actions)

yaml
name: Flutter CI/CD
on:
  push:
    branches:
      - main
jobs:
  build_and_deploy:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          channel: 'stable'
      
      # 1. Continuous Integration (Tests)
      - run: flutter pub get
      - run: flutter test

      # 2. Continuous Deployment (Builds)
      - run: flutter build ios --release --no-codesign
      - run: flutter build apk --release

      # (Next steps: Use Fastlane to upload to stores)

App Store & Play Store Realities

You must understand the bureaucracy of the stores:

Break It & Debug It

The Bug: You successfully upload your Android app bundle (AAB) to the Play Console. Google rejects it with an error: "Version code 1 has already been used."

The Reason: Every single time you upload an app to a store, it must have a strictly higher version code (build number) than the last one. If your pubspec.yaml says version: 1.0.0+1, the "+1" is the build number. You cannot upload "+1" twice.

The Fix: Change it to version: 1.0.0+2 (or 1.0.1+2), rebuild, and upload again. Always automate the build number incrementing in your CI/CD pipeline!

🔧 Mini Project ⏱ 20 min

Goal: Your First GitHub Actions Pipeline

Create a .github/workflows/flutter_ci.yml file that: triggers on push to main and on pull requests, sets up Flutter (latest stable), runs flutter analyze, runs flutter test, and on success, comments "✅ All checks passed" on the PR. Show the full YAML file with explanations of each key.

💡 See One Approach (Mini Project)

One valid solution — yours may differ.

yaml
# .github/workflows/flutter_ci.yml
name: Flutter CI

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

jobs:
  test:
    name: Test & Analyze
    runs-on: ubuntu-latest  # Cheaper than macos-latest (10x cost difference)

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

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'  # Pin version for reproducibility
          channel: stable
          cache: true  # Cache Flutter SDK between runs (~2 min saved)

      - name: Get dependencies
        run: flutter pub get

      - name: Verify formatting
        run: dart format --output=none --set-exit-if-changed .

      - name: Analyze code
        run: flutter analyze --fatal-infos

      - name: Run unit & widget tests
        run: flutter test --coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

🏗 Bigger Project ⏱ 1.5 hrs

Goal: Full CI/CD including build

Create a full release pipeline that triggers on version tags, sets up signing, builds the Android App Bundle, and uploads to the Play Store.

💡 See One Approach (Bigger Project)

One valid solution — yours may differ.

yaml
# .github/workflows/flutter_release.yml
name: Flutter Release Pipeline

on:
  push:
    tags: [ 'v*' ]  # Trigger on version tags: v1.0.0, v1.0.1

jobs:
  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with: { flutter-version: '3.22.0', cache: true }

      - name: Setup signing
        env:
          KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: echo $KEYSTORE_BASE64 | base64 --decode > android/app/release.jks

      - name: Build App Bundle
        env:
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
          API_KEY: ${{ secrets.PRODUCTION_API_KEY }}
        run: |
          flutter pub get
          flutter build appbundle \
            --dart-define=API_KEY=$API_KEY \
            --dart-define=PRODUCTION=true \
            --obfuscate \
            --split-debug-info=./debug_symbols

      - name: Upload to Play Store
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
          packageName: com.yourcompany.app
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: internal  # internal → alpha → beta → production

🎯 Interview Questions

Answer these before revealing. These appear in real Flutter/Dart interviews.

🔍 Easy: What is the difference between flutter analyze and flutter test in a CI pipeline?

flutter analyze runs the Dart static analyzer (similar to a linter) — it catches type errors, unused variables, deprecated APIs, and style issues WITHOUT running any code. It's fast (~10 seconds). flutter test actually executes your test files, running all unit and widget tests. It validates that your logic is correct at runtime (in a simulated environment). Both should run in CI: analyze catches bugs before testing, tests catch logical errors in working code. Add --fatal-infos to analyze to fail the build on info-level issues too.

🔍 Medium: Explain the difference between the Play Store's internal, alpha, beta, and production tracks.

Internal: up to 100 pre-approved testers, instant publishing (no review), only for your team. Alpha (Closed Testing): defined group of testers (email list or Google Groups), instant publishing, testers must opt-in via a link. Beta (Open Testing): anyone can join via a public link, instant publishing, subject to basic automated checks. Production: full public release, subject to full Play Store review (automated + sometimes manual). The staged rollout feature in Production lets you release to 1% → 10% → 50% → 100% of users with a kill-switch if crash rates spike. Always ship to Internal first, then Alpha/Beta with real testers, then Production with a staged rollout.

🔍 Hard: Your CI pipeline builds an iOS IPA successfully on every commit. You now want to automate TestFlight uploads. What is the complete setup required?

1. Apple Developer Account: create an App Store Connect API Key (in App Store Connect → Users → API Keys). Download the .p8 file — this is your auth credential. Store it as a GitHub Secret. 2. Code Signing: generate a Distribution certificate and provisioning profile in Apple Developer portal. On CI, use fastlane match (stores certs encrypted in a private Git repo) or the apple-actions/import-codesign-certs action to inject certs. 3. Build: flutter build ipa --export-options-plist=ExportOptions.plist where ExportOptions.plist configures app-store distribution. 4. Upload: use xcrun altool --upload-app or Fastlane's upload_to_testflight action with the API key. 5. Notifications: TestFlight automatically emails testers when a new build is available. The entire pipeline costs ~20-30 minutes on macos-latest runners and ~$0.16 per run on GitHub Actions.

✅ I can set up a CI/CD pipeline that automatically runs tests, builds release artifacts, and deploys to stores on every merge.