Week 19: App Signing, Build Flavors & CI/CD

Every build so far has run in debug mode on a development machine. This week configures real signed release builds, separates dev/staging/production into build flavors, and wires up CI/CD so a release is one command (or one merge) away.

Phase 8 of 8 Week 19 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Sign an Android release build with a real upload keystore
  • Configure an iOS release build's signing through Xcode
  • Set up build flavors for development, staging, and production
  • Automate a build with Codemagic or GitHub Actions

1. Signing an Android Release Build

terminal — generating an upload keystore
keytool -genkey -v -keystore upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
android/key.properties — never committed to version control
storePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=/absolute/path/to/upload-keystore.jks
android/app/build.gradle — reading it into the release config
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))

android {
    signingConfigs {
        release {
            keyAlias keystoreProperties['keyAlias']
            keyPassword keystoreProperties['keyPassword']
            storeFile file(keystoreProperties['storeFile'])
            storePassword keystoreProperties['storePassword']
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

key.properties holding real secrets must never be committed — .gitignore it, and use CI secrets (this week's last section) to supply the same values in an automated build instead.

2. iOS Signing Through Xcode

iOS signing works differently — through a distribution certificate and a provisioning profile managed via Xcode and an Apple Developer account, rather than a standalone keystore file.

terminal — opening the iOS project in Xcode
open ios/Runner.xcworkspace

From Xcode: select the Runner target → Signing & Capabilities → enable "Automatically manage signing" and select your Apple Developer team. For CI (where Xcode's UI isn't available), fastlane match is the standard way to manage and sync certificates and profiles across machines and CI runners without manual Xcode steps each time.

3. Build Flavors

A build flavor lets one codebase produce distinct dev/staging/production builds — different API base URLs, app names, and even icons — installable side-by-side on the same device.

terminal — running a specific flavor
flutter run --flavor development -t lib/main_development.dart
flutter build apk --flavor production -t lib/main_production.dart
lib/main_development.dart — a flavor-specific entry point
void main() {
  AppConfig.apiBaseUrl = 'https://dev-api.example.com';
  runApp(const MyApp());
}

Android and iOS each need their own native-side flavor configuration (productFlavors in Gradle; separate schemes in Xcode) alongside the Dart entry points above — worth budgeting real setup time for the first time a project adds flavors, since it touches both platforms' native build config directly.

4. CI/CD with Codemagic & GitHub Actions

codemagic.yaml — a straightforward pipeline
workflows:
  release:
    name: Release build
    environment:
      flutter: stable
    scripts:
      - flutter pub get
      - flutter test
      - flutter build appbundle --flavor production -t lib/main_production.dart
    artifacts:
      - build/**/outputs/**/*.aab

Codemagic is a Flutter-specific CI service that handles both Android and iOS signing through its own UI-managed secrets — often the path of least friction for a mobile team's first real pipeline. A GitHub Actions equivalent works too, and follows the same shape as this site's Go and React Native courses' CI setups: check out, install the toolchain, run tests, then build — with secrets (the keystore, iOS certificates) injected from the CI provider's secret store, never committed to the repository.

5. Hands-on Exercise

Hands-on

Set up signed builds and flavors for the capstone app

Configure real Android signing, at least one build flavor, and an automated build for the app you'll finish in Week 20.

Requirements:

  1. A generated Android upload keystore, referenced through key.properties (gitignored) and wired into build.gradle's release signing config.
  2. Two build flavors — development and production — each with its own entry point setting a different apiBaseUrl.
  3. A successful flutter build appbundle --flavor production, confirmed signed (not just built) by inspecting the resulting bundle.
  4. A Codemagic or GitHub Actions workflow that runs flutter test and then builds the production flavor on every push to your main branch.
  5. Confirm secrets (the keystore file/passwords) are never committed to the repository — verify with git log --all -- android/key.properties or equivalent returning nothing.
Hint

If a release build fails signing verification even though key.properties looks correct, double-check the storeFile path — it needs to resolve correctly from wherever Gradle actually runs (which can differ between a local build and a CI runner), so a relative path that works locally can easily break in CI; an absolute path, or one resolved explicitly relative to a known CI working directory, avoids that mismatch.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why must key.properties never be committed to version control, even in a private repository?

It holds the real secrets (keystore password, key password) needed to produce a signed release build recognized as coming from your app's identity — anyone with access to the repository history would have everything needed to sign a malicious build that Google Play would treat as a legitimate update to your app, which is a serious supply-chain risk regardless of the repository's visibility.

Q2

Why does iOS signing work through Xcode and an Apple Developer account rather than a standalone keystore file the way Android's does?

Apple's signing model is built around a centralized certificate and provisioning profile system tied to a registered Apple Developer account, managed through Xcode (or tooling like fastlane match) rather than a self-generated file an app owns independently — it's a different trust model than Android's self-managed keystore approach, reflecting each platform's own distribution requirements.

Q3

What real problem do build flavors solve that a single build target with an environment variable read at runtime does not fully address?

Flavors can differ at the native build level too — different app names, icons, and even bundle/package identifiers — letting a dev and production build install side-by-side on the same device as genuinely separate apps for testing. A runtime environment variable alone only changes in-app behavior (like the API URL); it doesn't produce two independently-installable app identities.

Q4

Why does a CI pipeline need to inject signing secrets from a secure secret store rather than reading them from a committed file the way a local build might?

The whole point of keeping secrets out of the repository is that CI runs against that same repository — if the secrets were committed, the exact problem the first quiz question describes would apply to CI just as much as local development. Injecting them from the CI provider's own secret store keeps the actual values out of the codebase entirely while still making them available at build time.