CI/CD #
In modern software engineering, automation is the main key to maintaining team productivity and product quality. CI/CD (Continuous Integration / Continuous Delivery) replaces all tedious manual app release tasks vulnerable to human error—like code testing, programming language style analysis (linting), API key configuration, digital signature creation, and binary uploads to app stores—into one automated pipeline system.
With a well-configured CI/CD pipeline, every time the development team pushes code to the repository, the CI/CD server triggers automated testing. If all tests pass, the system automatically builds the app and sends it to Quality Assurance (QA) teams or directly publishes it to end users on the App Store and Google Play Store.
CI/CD Basic Concepts and Branching Strategies #
CI/CD automation success rests on applying a disciplined Git branching strategy. Branching determines when a test runs, what environment configuration types are injected, and where app binaries will be distributed.
Here’s the industry-standard branching strategy we recommend for Flutter projects:
feature/*: Used by developers to write new features. Every push to this branch only triggers the Continuous Integration (CI) pipeline (unit tests and linter analysis) to ensure no broken code is uploaded.develop: Serves as the staging integration branch. Every time a merge happens to thedevelopbranch, the pipeline does automatic builds using the Staging configuration and distributes them to internal testers via Firebase App Distribution.main: The main production branch. Every push to themainbranch triggers binary builds using the Production configuration and uploads them to the Google Play Store’s Internal Testing track and Apple TestFlight.- Release Tags (
v*.*.*): When you create an official release version tag (e.g.,v2.4.0), the pipeline automatically processes the final production release build and launches it to the public Play Store and App Store tracks.
Here’s a visual flow diagram of the CI/CD pipeline lifecycle from the code push process to app store distribution:
flowchart TD
DevPush(["Developer Push / PR"]) --> TriggerCI["GitHub Actions Triggered"]
TriggerCI --> JobCI["Job 1: Linter & Unit Test"]
JobCI --> CheckStatus{"Do the Tests Pass?"}
CheckStatus -- No --> FailNotification["Send Failure Notifications (Slack/Email)"]
CheckStatus -- Yes --> BranchBranch{"Detect Branch / Tag"}
BranchBranch -- "develop branch" --> StagingCD["Job 2: Staging Deployment<br/>1. Decode Keystore & API Keys<br/>2. Build APK/AAB<br/>3. Upload to Firebase App Distribution"]
BranchBranch -- "main branch" --> TestFlightCD["Job 3: Release Candidate<br/>1. Setup iOS Certificate/Profile<br/>2. Build the Release IPA<br/>3. Upload to Apple TestFlight"]
BranchBranch -- "Tag v*.*.*" --> ProdCD["Job 4: Production Release<br/>1. Build the Final AAB & IPA<br/>2. Publish to Play Store Production & App Store"]
StagingCD --> EndNotify["Deployment Success Notifications"]
TestFlightCD --> EndNotify
ProdCD --> EndNotifyContinuous Integration (CI): Automated Testing and Analysis #
The first stage of your pipeline aims to validate code cleanliness. You use GitHub Actions because it integrates natively with your GitHub repository and provides reliable server runners.
Here’s the GitHub Actions workflow configuration for linter and automated testing:
# .github/workflows/ci_validation.yml
name: Continuous Integration
on:
push:
branches: [ '**' ] # Runs on all branches to validate every push
pull_request:
branches: [ main, develop ]
jobs:
test_and_analyze:
name: Lint & Unit Testing
runs-on: ubuntu-latest
steps:
# 1. Download our code repository to the runner
- name: Checkout Code
uses: actions/checkout@v4
# 2. Initialize Java (Very important for Gradle & Android modules)
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# 3. Initialize the Flutter SDK with Automatic Caching
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.0'
channel: 'stable'
cache: true # Enables Flutter SDK caching to speed up subsequent builds
# 4. Download Dart package dependencies
- name: Install Dependencies
run: flutter pub get
# 5. Run linter analysis
- name: Run Linter Analyze
run: flutter analyze --no-fatal-infos
# 6. Run all unit and widget tests along with coverage reports
- name: Run Tests
run: flutter test --coverage
# 7. Upload the code coverage report (optional, if using Codecov)
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
Android Continuous Delivery (CD): Automated Build and Distribution #
After the CI validation process passes, you move to CD automation. For Android, the challenge is how to inject the physical keystore and signing property files dynamically without storing them openly in your git repository.
You solve this by encrypting the .jks binary keystore file into Base64 text and storing it in GitHub Secrets.
# .github/workflows/cd_android.yml
name: Android Continuous Delivery
on:
push:
branches: [ develop, main ]
jobs:
build_android:
name: Build & Distribute Android
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
cache: 'gradle' // Automatically caches gradle dependencies
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.0'
channel: 'stable'
cache: true
- name: Install Dependencies
run: flutter pub get
# 1. Write the compile-time JSON configuration file from GitHub Secrets
- name: Inject Dart Environment Defines
run: |
mkdir -p .dart_define
cat > .dart_define/production.json << EOF
{
"APP_ENV": "production",
"API_URL": "${{ secrets.PROD_API_URL }}",
"API_KEY": "${{ secrets.PROD_API_KEY }}",
"ENABLE_LOGS": "false"
}
EOF
# 2. Decode the binary keystore from the Base64 string stored in Secrets
- name: Decode Android Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
# 3. Dynamically write the key.properties file on the runner
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_STORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
# 4. Execute the release Android App Bundle compilation
- name: Build Android App Bundle
run: |
flutter build appbundle --release \
--dart-define-from-file=.dart_define/production.json
# 5. Upload the AAB binary as a GitHub artifact (downloadable by the team)
- name: Upload AAB Artifact
uses: actions/upload-artifact@v4
with:
name: android-release-bundle
path: build/app/outputs/bundle/release/app-release.aab
retention-days: 5
# 6. Send the release to internal testers via Firebase App Distribution (develop branch only)
- name: Upload to Firebase App Distribution
if: github.ref == 'refs/heads/develop'
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
groups: QA-Testers
file: build/app/outputs/bundle/release/app-release.aab
iOS Continuous Delivery (CD): Keychain Configuration & macOS Builds #
Building iOS apps has its own complexity because it must run on macOS operating system servers (runs-on: macos-latest) and requires keychain authorization settings (Keychain) on the macOS runner system to unlock .p12 digital certificates.
# .github/workflows/cd_ios.yml
name: iOS Continuous Delivery
on:
push:
branches: [ main ]
jobs:
build_ios:
name: Build & Distribute iOS
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
flutter-version: '3.27.0'
channel: 'stable'
cache: true
- name: Install CocoaPods
run: |
cd ios
pod install --repo-update
# 1. Configure the Temporary Keychain on the macOS Runner
- name: Initialize macOS Keychain
env:
CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASS: "temporary-keychain-pass-123"
run: |
# Create a new keychain file
security create-keychain -p "$KEYCHAIN_PASS" temporary.keychain
security set-keychain-settings -lut 21600 temporary.keychain
security unlock-keychain -p "$KEYCHAIN_PASS" temporary.keychain
# Decode the .p12 certificate and import it into the keychain
echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security import certificate.p12 -k temporary.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security list-keychain -d user -s temporary.keychain
# 2. Decode and Install the iOS Provisioning Profile
- name: Install iOS Provisioning Profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision
echo "$PROFILE_BASE64" | base64 --decode > $PROFILE_PATH
# Copy the profile to the default Xcode search directory
mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles
cp $PROFILE_PATH ~/Library/MobileDevice/Provisioning\\ Profiles/
# 3. Inject the AppConfig JSON variables
- name: Inject Environment Config
run: |
mkdir -p .dart_define
cat > .dart_define/production.json << EOF
{
"APP_ENV": "production",
"API_URL": "${{ secrets.PROD_API_URL }}",
"API_KEY": "${{ secrets.PROD_API_KEY }}",
"ENABLE_LOGS": "false"
}
EOF
# 4. Build the Release IPA File
- name: Compile iOS IPA
run: |
flutter build ipa --release \
--dart-define-from-file=.dart_define/production.json \
--export-options-plist=ios/ExportOptions.plist
# 5. Upload the binary to Apple TestFlight (using the App Store Connect API Key)
- name: Upload to Apple TestFlight
run: |
xcrun altool --upload-app \
--type ios \
--file build/ios/ipa/*.ipa \
--apiKey ${{ secrets.APP_STORE_API_KEY }} \
--apiIssuer ${{ secrets.APP_STORE_ISSUER_ID }}
# 6. Clean up the keychain (always executed even if the processes above error)
- name: Cleanup macOS Keychain
if: always()
run: |
security delete-keychain temporary.keychain
Credential Security via GitHub Secrets #
To configure secrets in your repository:
- Open your GitHub repository page in a web browser.
- Go to the Settings menu $\rightarrow$ Secrets and variables $\rightarrow$ Actions $\rightarrow$ click New repository secret.
For binary files (like .jks Keystore files or Apple .p12 certificates), you must convert them to Base64 text strings first on your local computer before pasting them into GitHub Secrets:
# For macOS users: Convert the keystore file to a Base64 string and copy it to the clipboard
base64 -i android/app/upload-keystore.jks | pbcopy
# For Linux users:
base64 -w 0 android/app/upload-keystore.jks | xclip -sel clip
Speeding Up Build Times with Caching #
Build execution times on cloud CI/CD servers can take very long (15 to 20 minutes) because the server must re-download thousands of package dependencies and native Gradle/CocoaPods libraries from scratch on every run process.
You can reduce build wait times to below 5 minutes by enabling caching features in your GitHub Actions configuration files:
# 1. Caching Dart Package Dependencies (Pub Cache)
- name: Cache Pub Dependencies
uses: actions/cache@v4
with:
path: |
~/.pub-cache
.dart_tool/
key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
pub-${{ runner.os }}-
# 2. Caching Android Gradle Build Tooling
- name: Cache Gradle Data
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# 3. Caching iOS CocoaPods Installation Files
- name: Cache CocoaPods Pods
uses: actions/cache@v4
with:
path: ios/Pods
key: cocoapods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
cocoapods-${{ runner.os }}-
Advanced Automation Using Fastlane #
Although writing raw GitHub Actions scripts is very effective, the industry standard for large-scale mobile app development usually separates deployment logic using Fastlane. Fastlane is a Ruby-based automation engine running on top of native Android and iOS command lines.
The advantage of using Fastlane is that your deployment scripts (called Fastfile) can run with exactly the same instructions both locally on developer computers and inside any CI/CD server (GitHub Actions, GitLab CI, Bitrise, Jenkins).
Fastlane integration folder structure:
android/
fastlane/
Appfile # Stores Android package ID configurations
Fastfile # Contains Android release lanes
ios/
fastlane/
Appfile # Stores Apple ID configurations
Fastfile # Contains iOS release lanes
Here’s an example of writing release lanes inside the android/fastlane/Fastfile file:
# android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Run linter and unit testing"
lane :test do
gradle(task: "test")
end
desc "Send a trial build to Firebase App Distribution"
lane :distribute_beta do
# Run the release apk compilation via gradle
gradle(task: "clean assembleRelease")
firebase_app_distribution(
app: ENV["FIREBASE_ANDROID_APP_ID"],
groups: "QA-Testers",
apk_path: "../build/app/outputs/apk/release/app-release.apk",
release_notes: "Automatic build via Fastlane"
)
end
desc "Release the final build to the Google Play Store (Internal Track)"
lane :publish_to_playstore do
gradle(task: "clean bundleRelease")
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab"
)
end
end
Now, inside your GitHub Actions workflow, you just call the clean Fastlane command:
- name: Execute Fastlane Beta Lane
run: |
cd android
bundle exec fastlane distribute_beta
Summary #
- Continuous Integration (CI): Focuses on automating code analysis (
flutter analyze) and unit testing (flutter test) processes on every branch push process or Pull Request submission.- Continuous Delivery (CD): Automates the release binary build process. Leverage Linux-based Runners (
ubuntu-latest) for Android, and must use macOS (macos-latest) for iOS.- Base64 Encryption: Secure native binary credentials (Android
.jksKeystores & Apple.p12Certificates) by converting them to Base64 text strings for encrypted storage in GitHub Secrets.- Cache Optimization: Install caching for Pub cache folders, Gradle builds, and iOS CocoaPods installation folders to cut build times by up to $70%$.
- Fastlane Automation: Apply Fastlane as an independent release flow (lanes) manager executable uniformly both on cloud CI/CD servers and locally on developer computers.