Skip to content

Flutter CI/CD overview ​

A fully automated deployment pipeline for Flutter projects


Contents ​


Overview ​

The projectops Flutter CI/CD system combines wizard tools with GitHub Actions workflows.

Key features:

  • A web UI wizard generates the complicated deployment settings for you
  • PR/issue comments trigger test builds
  • Automatic deployment to iOS TestFlight, Android Play Store and Firebase App Distribution

System architecture ​

Overall flow ​

┌─────────────────────────────────────────────────────────────────┐
│                        Initial setup                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  🧙 TestFlight wizard    🧙 Play Store wizard   🧙 Firebase wizard│
│  ├─ ExportOptions.plist  ├─ Fastfile            ├─ Deploy config │
│  ├─ Fastfile             ├─ Signing config      └─ Tester groups │
│  └─ Gemfile              └─ Signing key guide                    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                  Verification and testing during development     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  develop push/PR → PROJECT-FLUTTER-CI.yaml (analysis + build check)│
│                                                                  │
│  Build command comment on a PR/issue (build app/apk build/ios build)│
│                     ↓                                            │
│  PROJECT-FLUTTER-PROJECTOPS-APP-BUILD-TRIGGER.yaml (trigger)     │
│                     ↓  repository_dispatch                       │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │ PROJECT-FLUTTER-ANDROID-TEST-APK.yaml  → APK artifact   │    │
│  │ PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml → TestFlight   │    │
│  └─────────────────────────────────────────────────────────┘    │
│                     ↓                                            │
│  Build result comment is posted automatically                    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│                        Production deployment                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Push to main branch                                             │
│           ↓                                                      │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │ PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml       → TestFlight  │    │
│  │ PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml → Play Store│    │
│  │ PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml  → Firebase  │    │
│  │ PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml → Own server│   │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

All four production deployment workflows trigger on a main push. Delete or disable the ones your project does not need.

Wizard-to-workflow relationship ​

.github/util/flutter/testflight-wizard/
    → Generates: ExportOptions.plist, Fastfile, Gemfile
    → Used by workflows:
        - PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml (production deployment)
        - PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml (test)

.github/util/flutter/playstore-wizard/
    → Generates: Fastfile, build.gradle.kts signing config
    → Used by workflows:
        - PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml (production deployment)
        - PROJECT-FLUTTER-ANDROID-TEST-APK.yaml (test)

.github/util/flutter/firebase-wizard/
    → Generates: Firebase App Distribution deploy config
    → Used by workflows:
        - PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml (production deployment)
        - PROJECT-FLUTTER-ANDROID-TEST-APK.yaml (Firebase upload option)

Wizard tools ​

WizardPurposeDetailed guide
TestFlight wizardGenerates iOS deployment settingsFLUTTER-TESTFLIGHT-WIZARD.md
Play Store wizardGenerates Android Play Store deployment settingsFLUTTER-PLAYSTORE-WIZARD.md
Firebase wizardGenerates Firebase App Distribution deployment settingsFLUTTER-FIREBASE-WIZARD.md

Workflow list ​

CI (code verification) ​

WorkflowPurposeTrigger
PROJECT-FLUTTER-CI.yamlCode analysis + build verificationdevelop push / PR targeting develop

Production deployment workflows ​

WorkflowPurposeTrigger
PROJECT-FLUTTER-IOS-TESTFLIGHT.yamliOS TestFlight deploymentmain push
PROJECT-FLUTTER-IOS-ASC-STATUS.yamlLooks up App Store Connect version status and the latest build number (read-only, Linux runner)Manual run
PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yamlAndroid Play Store internal testing deploymentmain push
PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yamlFirebase App Distribution deploymentmain push
PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yamlAPK deployment to your own server (SMB)main push

Self-hosted APK signing: by default it uses the debug key and logs a warning. It signs with the release key (RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD) only when you register the repository variable ANDROID_SELFHOSTED_RELEASE_SIGNING=true, and in that case the build fails if any of those secrets is empty. If the signing key changes, the update may not install over an app that is already installed, so be careful. Set the upload subpath with the workflow env.SMB_PATH_ANDROID (default: empty).

How far a deployment goes (DEPLOY_MODE) ​

One value goes to different points on the two platforms. The shared name makes this easy to confuse, so here is a table.

DEPLOY_MODEiOSAndroid
store_only (default)Up to the TestFlight uploadUp to the internal testing track
store_prepareAttaches the build to the App Store version (does not submit)Promotes to a production draft (a person presses "Start rollout" in the console; the console label is Korean in the original, 출시 시작)
store_submitSubmits to App Store reviewRegisters for production review automatically

The old aliases (testflight_only, appstore_prepare, appstore_submit) are still accepted.

⚠️ On a push (automatic deploy), store_prepare and store_submit are lowered to store_only (#816), even if a repository variable or store-deploy.json says otherwise. Google Play folds a new change into the review already in progress and reviews it again (measured), so submitting on every deploy means the review never finishes. Promote to production and submit to App Store review by running the workflow manually (deploy_mode). To submit on every push anyway, set "android": {"auto_submit_on_push": true} (or "ios") in store-deploy.json.

The default is store_only, in the workflow and inside the Fastfile alike. Previously only the Android Fastfile defaulted to store_submit, so calling the lane directly without going through the workflow put the build into production review (fixed in #618).

Store "What's New" text (STORE_WHATS_NEW_OVERRIDE) ​

This is the text that goes into the store "What's New" field (iOS "What's New in This Version", Android "Release notes") on store_prepare/store_submit. Reviewers read it as well as users. Set it in the env of the workflow file (the name is the same for iOS and Android, so change both places together).

ValueBehavior
"" (template default)Uses the CHANGELOG entry for that version, same as before
TextUses that text every time, regardless of version
  • A value with only whitespace is treated as empty. The build log records the source (CHANGELOG / STORE_WHATS_NEW_OVERRIDE 덮어쓰기 (STORE_WHATS_NEW_OVERRIDE override)).
  • If you give the manual run input whats_new_override a value, it takes priority for that run only.
  • The iOS TestFlight "What to Test" text (for internal testers) always uses the CHANGELOG, regardless of this value.
  • The Android release notes are fixed at the internal testing upload and carry through to promotion, so they apply to every track.
  • Why env and not a repository variable: the template cannot pre-create repository variables, and since this value rarely changes, it is better to have it visible in code with a git history. (The deploy mode stays a repository variable because you may need to switch it off right away in an emergency.)

iOS review notes (Notes): if ios/fastlane/review_notes.txt exists, its content is used every time. If it is missing or empty, the existing App Store Connect value is left as is. (It used to try to "reset" the notes with an empty file, but an empty value is not sent, so the existing value stayed. Confirmed by measurement.) Apps that have no shared review account and keep login instructions only in the Notes can manage them alongside the code with this file.

Android tracks: internal testing alone cannot reach production ⚠️ ​

DEPLOY_MODE decides only the production step. Independent switches handle the intermediate tracks.

Switch (repository variable / manual run input)What it doesDefault
ANDROID_PROMOTE_TO_CLOSED_TESTINGAlso uploads to the closed testing track (or promote_closed_testing in store-deploy.json)true (#816)
ANDROID_PROMOTE_TO_OPEN_TESTINGAlso uploads to the open testing trackfalse
ANDROID_CLOSED_TESTING_TRACKClosed track namealpha
ANDROID_OPEN_TESTING_TRACKOpen track namebeta

After a manual production submission, pushes pause closed and open testing promotions (#816). A closed testing promotion is a review submission too, so sending one while the production review runs restarts that review. The Play API does not expose review status, so for review_cooldown_hours (in store-deploy.json, default 48) after the last manual store_submit run, pushes upload to internal testing only and leave a warning. To promote right away, run the workflow manually with promote_to_closed_testing on.

TrackGoogle reviewTakes effectCounts toward production access requirements
internal (internal)NoneA few minutes❌
alpha (closed)YesTens of minutes to several days✅
beta (open)YesSame✅
productionYesSamen/a

A personal developer account created after 2023-11-13 must complete closed testing with at least 12 testers participating for at least 14 days to get production access. Internal testing has no review, which is convenient, but it does not count toward that requirement. If you only run internal testing, the requirement never fills. (Accounts created before that date and organization accounts are not affected.)

Track names can be changed in the console, so they are values. If hard-coded, a repository that uses custom names would silently fail to upload.

Test build workflows ​

WorkflowPurposeTrigger
PROJECT-FLUTTER-PROJECTOPS-APP-BUILD-TRIGGER.yamlDetects the build trigger@projectops build app / apk build / ios build comment
PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yamliOS test buildrepository_dispatch (build-ios-app)
PROJECT-FLUTTER-ANDROID-TEST-APK.yamlAndroid APK test buildrepository_dispatch (build-android-app)

Detailed guide: FLUTTER-TEST-BUILD-TRIGGER.md

iOS build number and version (#643) ​

ItemBehavior
Build numberSeconds elapsed since 2024-01-01 UTC (about 87 million), decided right before the archive. The latest ASC number and the number Apple's rejection message asks for are used only as lower bounds. The rule lives in one place, build_number.py
Test build versionThe version.yml version. If it has already been released and closed, it builds with the next patch and shows the reason in the progress comment
Release versionThe version.yml version. If it is closed, it fails before the build (raise the version.yml version and run again)
Upload rejectionFor a number that is too low, it retries up to 5 times with a new number at or above the required value (the Flutter build is not redone). For a closed-version rejection, only test builds retry with the next patch. A duplicate number means that number is already uploaded, so it fails without re-uploading, and running again uses a new number
Failure reasonA test build shows a 사유 (reason) line in the failure comment; a release shows it in the run summary and ::error::

version_code is not used for iOS or for test build numbers (version-control.md). For the detailed rules, see FLUTTER-TEST-BUILD-TRIGGER.md.


Quick start ​

Step 1: Generate the settings files with a wizard ​

bash
# iOS TestFlight settings
open .github/util/flutter/testflight-wizard/testflight-wizard.html

# Android Play Store settings
open .github/util/flutter/playstore-wizard/playstore-wizard.html

# Firebase App Distribution settings
open .github/util/flutter/firebase-wizard/firebase-wizard.html

Step 2: Set up GitHub Secrets ​

Register them using the full list of GitHub Secrets below.

⚠️ Secret names must match exactly what the workflows reference. If even one name differs, the build fails at the certificate/keystore restore step.

Step 3: Install the workflows ​

bash
# Install the Flutter workflows with the npx wizard
npx projectops --mode workflows --type flutter

Step 4: Run a test build ​

Write a comment on a PR or issue:

@projectops build app    # Build both Android and iOS
@projectops apk build    # Build Android only
@projectops ios build    # Build iOS only

Full list of GitHub Secrets ​

iOS (TestFlight, shared by production deployment and test builds) ​

SecretDescription
APPLE_CERTIFICATE_BASE64Apple Distribution certificate .p12 (base64 encoded)
APPLE_CERTIFICATE_PASSWORD.p12 certificate password
APPLE_PROVISIONING_PROFILE_BASE64.mobileprovision file (base64 encoded)
IOS_PROVISIONING_PROFILE_NAMEProvisioning profile name
APP_STORE_CONNECT_API_KEY_IDApp Store Connect API Key ID (10 characters)
APP_STORE_CONNECT_ISSUER_IDIssuer ID (UUID format)
APP_STORE_CONNECT_API_KEY_BASE64AuthKey_XXXXXX.p8 file (base64 encoded)
IOS_BUNDLE_ID (optional)Bundle ID. Can also be given as a repository variable (vars) instead of a Secret
ENV_FILE (optional).env file contents
SECRETS_XCCONFIG (optional)Contents of ios/Flutter/Secrets.xcconfig

App Store Connect API key role: App Manager or higher is recommended. The prepare step uses this key to read the app's version list and latest build number, and read access for the Developer role has not been confirmed. If the lookup fails, it only logs a warning and continues with the version.yml version, and the automatic recovery when an upload is rejected for a closed version works the same. No new Secret is required.

Android: Play Store deployment ​

SecretDescription
RELEASE_KEYSTORE_BASE64Signing keystore .jks (base64 encoded)
RELEASE_KEYSTORE_PASSWORDKeystore password
RELEASE_KEY_ALIASKey alias
RELEASE_KEY_PASSWORDKey password
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64Play Console service account JSON (base64 encoded)
GOOGLE_SERVICES_JSONFirebase google-services.json contents
ENV_FILE or ENV (optional).env file contents (ENV_FILE takes priority)

Android: Firebase App Distribution deployment ​

It uses the same RELEASE_* signing Secrets as Play Store; only the upload credentials differ.

SecretDescription
RELEASE_KEYSTORE_BASE64 / _PASSWORDSigning keystore and its password
RELEASE_KEY_ALIAS / RELEASE_KEY_PASSWORDKey alias and its password
FIREBASE_SERVICE_ACCOUNT_JSON_BASE64Firebase service account JSON (base64 encoded)
GOOGLE_SERVICES_JSON (optional)Firebase google-services.json contents
ENV_FILE or ENV (optional).env file contents

Android: own server (SMB) deployment ​

SecretDescription
RELEASE_KEYSTORE_BASE64 / _PASSWORDSigning keystore and its password
RELEASE_KEY_ALIAS / RELEASE_KEY_PASSWORDKey alias and its password
SERVER_HOST / SERVER_USER / SERVER_PASSWORDSMB connection info
GOOGLE_SERVICES_JSON (optional)Firebase google-services.json contents
ENV_FILE or ENV (optional).env file contents

The 🔑 필수 GitHub Secrets (Required GitHub Secrets) comment at the top of each workflow file is always the most current reference. If it disagrees with this table, trust the workflow comment.


File location summary ​

.github/
├── util/flutter/
│   ├── testflight-wizard/           # iOS wizard
│   │   ├── testflight-wizard.html
│   │   ├── testflight-wizard.js
│   │   ├── testflight-wizard.py
│   │   └── templates/
│   │       ├── ExportOptions.plist
│   │       ├── Fastfile.ios.template
│   │       └── Gemfile
│   │
│   ├── playstore-wizard/            # Android Play Store wizard
│   │   ├── playstore-wizard.html
│   │   ├── playstore-wizard.js
│   │   ├── playstore-wizard.py
│   │   └── templates/
│   │       ├── Fastfile.playstore.template
│   │       └── build.gradle.kts.signing.template
│   │
│   └── firebase-wizard/             # Firebase App Distribution wizard
│       ├── firebase-wizard.html
│       ├── firebase-wizard.js
│       └── firebase-wizard.py
│
└── workflows/project-types/flutter/
    ├── PROJECT-FLUTTER-CI.yaml
    ├── PROJECT-FLUTTER-IOS-TESTFLIGHT.yaml
    ├── PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml
    ├── PROJECT-FLUTTER-ANDROID-FIREBASE-CICD.yaml
    ├── PROJECT-FLUTTER-ANDROID-SELFHOSTED-CICD.yaml
    ├── PROJECT-FLUTTER-PROJECTOPS-APP-BUILD-TRIGGER.yaml
    ├── PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml
    └── PROJECT-FLUTTER-ANDROID-TEST-APK.yaml