← Flutter Reference

Flutter Build & Release Pipeline: Flavors, CI/CD, Stores

Release Engineering · Advanced

By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.

A build and release pipeline is the difference between shipping confidently every week and dreading every release. At iStoria, we ship to 5M+ users on a weekly cadence with 99.9% crash-free sessions. That is not luck — it is a pipeline that builds, tests, signs, and deploys with zero manual steps.

This guide is a reference (not a tutorial) for the full pipeline: flavor configuration, CI/CD setup, store deployment, and the guardrails that prevent bad releases. For a step-by-step flavors tutorial, see our Flutter build flavors article. For the broader release-engineering story (trunk-based development, feature flags, staged rollout), see our release engineering case study.

The goal of this guide is to serve as the reference document you keep open while setting up or auditing your pipeline.

Flavors: Three Environments

Every production Flutter app needs at least three flavors:

1. Development — local development against a dev backend. No real users. Hot reload. Used by engineers daily. 2. Staging — pre-production environment for QA, beta testing, and release candidates. Mirrors production as closely as possible (same backend, same feature flags, same analytics — just isolated data). 3. Production — the real app served to real users. Different bundle ID, different API keys, different signing credentials.

Each flavor has its own application ID/bundle ID, icon set, and environment configuration. The flavor system is the backbone of the pipeline — it lets you test the exact binary that will ship to users, just pointed at a different backend.

Flavor Configuration

On Android, flavors are defined in build.gradle with productFlavors. On iOS, they are defined via Xcode schemes and build configurations. In Flutter, you select the flavor at build time:

flutter build apk --flavor staging --t lib/main_staging.dart
flutter build ipa --flavor staging --export-options-plist ios/staging/ExportOptions.plist

Each flavor uses a separate entry point (main_dev.dart, main_staging.dart, main_prod.dart) that injects the correct environment configuration before calling runApp().

CI/CD Pipeline

The CI/CD pipeline runs on every pull request and on every merge to main. The stages:

Stage 1: Quality Gates (Every PR)

  • flutter analyze — static analysis. Zero warnings tolerated.
  • dart format --set-exit-if-changed — enforce formatting.
  • Unit + widget tests with coverage report.
  • Import linting — verify Clean Architecture boundaries (no Flutter imports in domain).

If any gate fails, the PR cannot merge. This is enforced by GitHub branch protection rules.

Stage 2: Build (Every PR)

  • Build APK and IPA for the staging flavor.
  • Verify the build succeeds (catches platform-specific issues that analysis misses).
  • Run integration tests on the built binary (in CI, on a simulator/emulator).

Stage 3: Release (On Merge to Main or Tag)

  • Bump version number (semantic versioning or build number).
  • Build production APK and IPA with production signing credentials.
  • Run the full test suite one final time.
  • Upload to Google Play (internal testing track) and TestFlight.
  • Tag the commit with the version number for traceability.

Stage 4: Store Rollout (Manual Trigger)

  • Promote from internal testing to production — staged rollout (1% → 10% → 50% → 100%).
  • Monitor crash-free rate and rollback if it drops below threshold.

Signing and Credentials

Signing is the most painful part of the pipeline, and it must be fully automated:

  • iOS: Use App Store Connect API key + a signed certificate/profile stored as CI secrets. Fastlane match manages certificates in a private repo. Never commit certificates to the app repo.
  • Android: Use a Google Play service account JSON key stored as a CI secret. Sign the app bundle with a keystore stored as an encrypted CI secret. Never commit the keystore.

At iStoria, signing is configured once and never touched. The CI pipeline picks up the credentials from GitHub Actions secrets and signs the binary automatically. No manual Xcode signing, no manual keystore passwords.

Environment Configuration

Each flavor needs different configuration values: API URLs, API keys, feature flag endpoints, analytics tokens. We use --dart-define and --flavor to inject these at build time:

flutter build apk \
  --flavor prod \
  --dart-define=API_URL=https://api.istoria.app \
  --dart-define=ANALYTICS_TOKEN=prod-token

Inside the app, String.fromEnvironment('API_URL') reads the value. This keeps configuration out of the source code and makes each flavor's config explicit in the CI pipeline.

Guardrails

The pipeline must have guardrails that prevent bad releases:

1. Branch protection — no direct pushes to main. Every change goes through a PR with passing CI. 2. Required reviews — at least one approval from a teammate. For release tags, require two. 3. Crash-free threshold — after a staged rollout to 1%, check the crash-free rate. If it drops below 99.5%, halt the rollout automatically. 4. Feature flags — new features ship behind flags, disabled by default. They are enabled server-side after the release is stable, allowing instant rollback without a new build. 5. Database migration safety — any schema migration must be backward-compatible (additive). Breaking migrations require a multi-release strategy (add new column → populate → remove old column across releases).

The Weekly Cadence

At iStoria, we ship every week:

  • Monday–Thursday: development on feature branches. PRs reviewed and merged to main behind feature flags.
  • Thursday: tag the release. CI builds the production binary and uploads to internal testing tracks.
  • Friday morning: QA verification on the release candidate. If good, staged rollout begins (1% → 10% → 50% → 100% over the day).
  • Friday afternoon: monitor crash-free rate. If stable at 100% by end of day, the rollout completes.

This cadence requires trunk-based development (no long-lived branches) and feature flags (ship code without enabling features). The pipeline is what makes it repeatable — no manual steps, no manual signing, no manual store uploads.

Recommended folder structure

lib/
├── main.dart                    # production entry point (default)
├── main_dev.dart                 # development entry point
├── main_staging.dart             # staging entry point
├── main_prod.dart                # production entry point (explicit)
├── core/
│   └── config/
│       └── env_config.dart       # reads String.fromEnvironment values
└── ...

android/
├── app/
│   └── build.gradle              # productFlavors: dev, staging, prod
└── ...

ios/
├── Runner.xcodeproj              # schemes: dev, staging, prod
├── flutter/
│   ├── staging/ExportOptions.plist
│   └── prod/ExportOptions.plist
└── ...

.github/workflows/
├── pr-check.yml                  # analyze + test + build (staging) on every PR
├── release.yml                   # build prod + upload to stores on tag
└── nightly.yml                   # integration tests + dependency audit

fastlane/                         # optional, for store metadata management
├── Fastfile                      # lanes for upload to Play Store / TestFlight
└── match/                        # certificate management (private repo)

Code example

// lib/main_dev.dart — development entry point
import 'package:flutter/material.dart';
import 'app.dart';
import 'core/config/env_config.dart';

Future<void> main() async {
  EnvConfig.initialize(
    apiUrl: const String.fromEnvironment(
      'API_URL',
      defaultValue: 'https://dev-api.istoria.app',
    ),
    environment: Environment.dev,
    analyticsEnabled: false,
  );
  runApp(const MyApp());
}

// lib/main_prod.dart — production entry point
Future<void> main() async {
  EnvConfig.initialize(
    apiUrl: const String.fromEnvironment(
      'API_URL',
      defaultValue: 'https://api.istoria.app',
    ),
    environment: Environment.prod,
    analyticsEnabled: true,
  );
  runApp(const MyApp());
}

// core/config/env_config.dart — central config
enum Environment { dev, staging, prod }

class EnvConfig {
  static late String apiUrl;
  static late Environment environment;
  static late bool analyticsEnabled;

  static void initialize({
    required String apiUrl,
    required Environment environment,
    required bool analyticsEnabled,
  }) {
    EnvConfig.apiUrl = apiUrl;
    EnvConfig.environment = environment;
    EnvConfig.analyticsEnabled = analyticsEnabled;
  }

  static bool get isProduction => environment == Environment.prod;
}

# android/app/build.gradle — flavor definitions
android {
    flavorDimensions += "default"
    productFlavors {
        dev {
            dimension "default"
            applicationIdSuffix ".dev"
            versionNameSuffix "-dev"
        }
        staging {
            dimension "default"
            applicationIdSuffix ".staging"
            versionNameSuffix "-staging"
        }
        prod {
            dimension "default"
        }
    }
}

# .github/workflows/release.yml — production release pipeline
name: Release
on:
  push:
    tags: ['v*']

jobs:
  build-and-deploy:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.x'
          channel: stable

      - name: Install dependencies
        run: flutter pub get

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

      - name: Build Android (production)
        run: flutter build appbundle \
            --flavor prod \
            --dart-define=API_URL=https://api.istoria.app

      - name: Build iOS (production)
        run: flutter build ipa \
            --flavor prod \
            --export-options-plist ios/prod/ExportOptions.plist \
            --dart-define=API_URL=https://api.istoria.app

      - name: Upload to Google Play
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
          packageName: app.istoria
          releaseFiles: build/app/outputs/bundle/prodRelease/app-prod-release.aab
          track: internal
          status: completed

      - name: Upload to TestFlight
        uses: apple-actions/upload-testflight@v1
        with:
          app-store-connect-issuer-id: ${{ secrets.ASC_ISSUER_ID }}
          app-store-connect-key-id: ${{ secrets.ASC_KEY_ID }}
          app-store-connect-private-key: ${{ secrets.ASC_PRIVATE_KEY }}
          app-path: build/ios/ipa/istoria.ipa

Implementation checklist

  • Configure at least three flavors (dev, staging, prod) with separate entry points, bundle IDs, and icon sets.
  • Inject environment configuration via --dart-define so API URLs and keys never live in source code.
  • Set up CI quality gates on every PR: flutter analyze, formatting check, unit + widget tests.
  • Store signing credentials (keystore, certificates) as CI secrets — never commit them to the repository.
  • Automate the full release flow: build, sign, and upload to internal testing tracks on tag, with zero manual steps.
  • Use staged rollout (1% → 10% → 50% → 100%) with an automated crash-free rate check that halts on regression.
  • Ship new features behind feature flags so you can disable them server-side without a new app release.

Common mistakes

  • Using only two flavors (dev and prod) with no staging, making pre-production QA unreliable.
  • Committing signing credentials or keystore passwords to the repository instead of using CI secrets.
  • Releasing directly to 100% rollout without a staged phase, risking a bad release reaching all users.
  • Hardcoding API URLs and keys in source code instead of using --dart-define, making flavor switching impossible.
  • Not enforcing branch protection, allowing direct pushes to main that bypass CI quality gates.

Related definitions

Related reading

FAQ

Do I really need three flavors? Can't I just use dev and prod?

You can start with two, but staging becomes essential as you scale. Without staging, you test new features against either a local dev backend (which does not match production) or the real production backend (risky). Staging is a pre-production environment that mirrors production as closely as possible — the same build, same backend, isolated data. It is where you catch integration issues before users do.

How do I manage signing certificates across CI and team members?

Use Fastlane match for iOS (stores certificates in a private repo, team members and CI fetch them via a passphrase) and a Google Play service account JSON key for Android (stored as a CI secret). Never commit certificates to the app repo. Configure once, then the CI pipeline handles signing automatically on every build.

What is staged rollout and why does it matter?

Staged rollout releases your app to a small percentage of users first (1%), then gradually increases (10% → 50% → 100%) over hours or days. If the crash-free rate drops or a critical bug surfaces at 1%, you halt the rollout before it reaches the majority of users. This is the single most important guardrail for a 5M-user app — it turns a bad release from a crisis into a minor incident.


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work