Automate Your Flutter Builds: CI/CD Pipeline for Dev/Staging/Prod in 30 Minutes (GitHub Actions +β¦ (Part 1)
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!π
Complete guide to setting up CI/CD for Flutter apps with GitHub Actions, Fastlane, and multiple environments.
Flutter DevOps Made Easy
Stop building manually! Set up automated CI/CD pipelines for your Flutter app with multiple environments β deploy to TestFlight and Play Store with a single commit
Hey There! π
Remember how we set up multiple environments (dev, staging, production) in our Flutter app? That was cool, right? But you know what's even cooler? Never having to manually build and deploy your app again! π
Today, I'm gonna walk you through setting up a complete CI/CD pipeline that will:
- Automatically build your app when you push code
- Run tests before building
- Deploy dev builds to internal testers automatically
- Send staging builds to beta testers
- Push production builds to app stores with approval
- Do all of this for BOTH iOS and Android
And the best part? Once it's set up, you just push your code and grab a coffee while the robots do the work! βοΈπ€
π€ What Exactly is CI/CD?
Let me break it down in simple terms:
CI (Continuous Integration): Every time you push code, it automatically builds and tests to make sure nothing broke.
CD (Continuous Deployment): If everything passes, it automatically deploys your app to testers or stores.
Think of it like having a super-efficient assistant who builds, tests, and ships your app while you focus on writing code!
π― What You'll Need
Before we start, make sure you have:
- A Flutter app with multiple environments set up (check my previous article if you haven't!)
- A GitHub account (we'll use GitHub Actions, but the concepts work for GitLab/Bitbucket too)
- Apple Developer account ($99/year β for iOS)
- Google Play Console account ($25 one-time β for Android)
- About 30β45 minutes of your time
ποΈ Architecture Overview
Here's what we're building:
Push to branch β GitHub Actions triggered β
Run tests β Build for specific environment β
Deploy to distribution platform β Notify team
```
**Branches and Environments:**
- `develop` branch β Auto-deploy DEV builds
- `staging` branch β Auto-deploy STAGING builds
- `main` branch β Deploy PRODUCTION builds (with approval)
---
### π¦ Step 1: Project Structure Setup
First, let's organize our project for CI/CD. Create these folders in your project root:
```
your_flutter_app/
βββ .github/
β βββ workflows/
β βββ dev-build.yml
β βββ staging-build.yml
β βββ production-build.yml
βββ android/
β βββ fastlane/
β β βββ Fastfile
β β βββ Appfile
β βββ key.properties
βββ ios/
β βββ fastlane/
β βββ Fastfile
β βββ Appfile
βββ lib/
βββ scripts/
βββ setup_android_signing.sh
βββ setup_ios_signing.sh
π Step 2: Android Signing Setup
This is SUPER IMPORTANT β without proper signing, your builds won't work!
Step 2.1: Generate a Keystore
Open your terminal and run:
keytool -genkey -v -keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
# You'll be asked for passwords - REMEMBER THEM!
# Store password: your_store_password
# Key password: your_key_password
Step 2.2: Create key.properties
Create android/key.properties (add this to .gitignore!):
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=../upload-keystore.jks
Step 2.3: Update build.gradle
Edit android/app/build.gradle:
// Add this at the top
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
// ... existing config
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
// ... other settings
}
}
}
Step 2.4: Prepare for CI/CD
Encode your keystore to base64 (we'll store this as a secret):
base64 -i ~/upload-keystore.jks -o ~/upload-keystore.jks.base64
Keep this file safe β we'll need it later!
π Step 3: iOS Signing Setup (The Match Approach)
iOS signing can be tricky, but we'll use Fastlane Match β the best way to handle it!
Step 3.1: Install Fastlane
# On Mac
brew install fastlane
# Or using RubyGems
sudo gem install fastlane
Step 3.2: Initialize Fastlane for iOS
cd ios
fastlane init
Select option 4 (Manual setup). This creates the fastlane folder.
Step 3.3: Set Up Match
Match stores your certificates in a private Git repository. Create a private repo on GitHub (e.g., your-company/certificates).
cd ios
fastlane match init
Select git and enter your certificates repo URL.
Step 3.4: Generate Certificates
# Development certificates
fastlane match development
# App Store certificates
fastlane match appstore
# You'll need your Apple ID and password
# Use app-specific password for 2FA accounts
Step 3.5: Update Xcode Project
- Open
ios/Runner.xcworkspacein Xcode - Select Runner target
- Go to Signing & Capabilities
- Uncheck "Automatically manage signing"
- Select provisioning profiles created by Match
π Step 4: Fastlane Configuration
Now let's configure Fastlane to build and deploy our apps!
File: android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc "Build Development APK"
lane :build_dev do
flutter_build(
build: "apk",
target: "lib/main_dev.dart",
dart_define: {
ENVIRONMENT: "dev"
}
)
end
desc "Build Staging APK"
lane :build_staging do
flutter_build(
build: "apk",
target: "lib/main_staging.dart",
dart_define: {
ENVIRONMENT: "staging"
}
)
end
desc "Build and Deploy Production to Internal Testing"
lane :deploy_internal do
flutter_build(
build: "appbundle",
target: "lib/main_production.dart",
dart_define: {
ENVIRONMENT: "production"
}
)
upload_to_play_store(
track: 'internal',
aab: '../build/app/outputs/bundle/release/app-release.aab',
skip_upload_screenshots: true,
skip_upload_images: true,
skip_upload_metadata: true
)
end
desc "Deploy Production to Beta"
lane :deploy_beta do
flutter_build(
build: "appbundle",
target: "lib/main_production.dart",
dart_define: {
ENVIRONMENT: "production"
}
)
upload_to_play_store(
track: 'beta',
aab: '../build/app/outputs/bundle/release/app-release.aab'
)
end
desc "Deploy to Production"
lane :deploy_production do
flutter_build(
build: "appbundle",
target: "lib/main_production.dart",
dart_define: {
ENVIRONMENT: "production"
}
)
upload_to_play_store(
track: 'production',
aab: '../build/app/outputs/bundle/release/app-release.aab'
)
end
# Helper lane for Flutter builds
private_lane :flutter_build do |options|
sh("flutter", "clean")
sh("flutter", "pub", "get")
build_type = options[:build]
target_file = options[:target]
dart_defines = options[:dart_define].map { |k, v| "--dart-define=#{k}=#{v}" }.join(" ")
sh("flutter", "build", build_type, "-t", target_file, *dart_defines.split(" "))
end
end
File: android/fastlane/Appfile
json_key_file("") # Path to service account JSON (we'll set in CI)
package_name("com.yourcompany.myapp") # Your production package nameFile: ios/fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build Development IPA"
lane :build_dev do
setup_ci if ENV['CI']
match(
type: "development",
readonly: true
)
flutter_build(
target: "lib/main_dev.dart",
export_method: "development"
)
end
desc "Build Staging IPA"
lane :build_staging do
setup_ci if ENV['CI']
match(
type: "adhoc",
readonly: true
)
flutter_build(
target: "lib/main_staging.dart",
export_method: "ad-hoc"
)
end
desc "Build and Deploy to TestFlight"
lane :deploy_testflight do
setup_ci if ENV['CI']
match(
type: "appstore",
readonly: true
)
flutter_build(
target: "lib/main_production.dart",
export_method: "app-store"
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
skip_submission: true,
distribute_external: false
)
end
desc "Deploy to App Store"
lane :deploy_appstore do
setup_ci if ENV['CI']
match(
type: "appstore",
readonly: true
)
flutter_build(
target: "lib/main_production.dart",
export_method: "app-store"
)
upload_to_app_store(
submit_for_review: false,
automatic_release: false,
skip_screenshots: true,
skip_metadata: true
)
end
# Helper lane
private_lane :flutter_build do |options|
target_file = options[:target]
export_method = options[:export_method]
# Determine environment from target file
environment = "production"
if target_file.include?("dev")
environment = "dev"
elsif target_file.include?("staging")
environment = "staging"
end
sh("flutter", "clean")
sh("flutter", "pub", "get")
sh("flutter", "build", "ios",
"-t", target_file,
"--dart-define=ENVIRONMENT=#{environment}",
"--release",
"--no-codesign"
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: export_method,
output_directory: "../build/ios",
output_name: "app.ipa"
)
end
end
File: ios/fastlane/Appfile
app_identifier("com.yourcompany.myapp") # Production bundle ID
apple_id("your-apple-id@email.com")
team_id("YOUR_TEAM_ID") # Find this in Apple Developer portal
itc_team_id("YOUR_ITC_TEAM_ID") # App Store Connect Team IDπ Step 5: Set Up Google Play Service Account
For Android automated uploads, you need a service account!
Step 5.1: Create Service Account
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google Play Android Developer API
- Go to IAM & Admin β Service Accounts
- Click Create Service Account
- Name it something like "GitHub Actions"
- Click Create and Continue
- Skip permissions for now, click Done
Step 5.2: Create JSON Key
- Click on your service account
- Go to Keys tab
- Click Add Key β Create new key
- Choose JSON format
- Download and save securely
Step 5.3: Grant Play Console Access
- Go to Google Play Console
- Go to Setup β API access
- Link your Cloud project if not linked
- Find your service account and grant access
- Go to Users and permissions
- Find service account and grant these permissions: β’ View app information β’ Manage production releases β’ Manage testing track releases
π Step 6: GitHub Secrets Setup
Now we'll store all sensitive data securely!
Go to your GitHub repo β Settings β Secrets and variables β Actions β New repository secret
Add these secrets:
For Android:
ANDROID_KEYSTORE_BASE64- Your base64 encoded keystoreANDROID_KEYSTORE_PASSWORD- Store passwordANDROID_KEY_PASSWORD- Key passwordANDROID_KEY_ALIAS- Key alias (usually "upload")PLAY_STORE_CONFIG_JSON- Service account JSON content (entire file content)
For iOS:
MATCH_PASSWORD- Password you used when setting up matchMATCH_GIT_BASIC_AUTHORIZATION- Base64 ofusername:personal_access_tokenAPP_STORE_CONNECT_API_KEY_ID- Get from App Store ConnectAPP_STORE_CONNECT_API_ISSUER_ID- Get from App Store ConnectAPP_STORE_CONNECT_API_KEY_CONTENT- Base64 of .p8 key file
For Both:
SLACK_WEBHOOK_URL- (Optional) For notifications
Pro tip: To create GitHub personal access token for Match:
- GitHub β Settings β Developer settings β Personal access tokens
- Generate new token (classic)
- Select
reposcope - Copy token
- Encode to base64:
echo -n "username:token" | base64
βοΈ Step 7: GitHub Actions Workflows
Now the exciting part β automating everything!
File: .github/workflows/dev-build.yml
name: π§ Development Build
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Run analyzer
run: flutter analyze
- name: Run tests
run: flutter test
build-android:
name: Build Android Dev APK
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Get dependencies
run: flutter pub get
- name: Build DEV APK
run: |
flutter build apk \
-t lib/main_dev.dart \
--dart-define=ENVIRONMENT=dev \
--release
- name: Upload APK
uses: actions/upload-artifact@v3
with:
name: dev-apk
path: build/app/outputs/flutter-apk/app-release.apk
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "β DEV Android build successful!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*DEV Android Build Completed*\n\n*Commit:* ${{ github.sha }}\n*Branch:* ${{ github.ref_name }}\n*Author:* ${{ github.actor }}"
}
}
]
}
build-ios:
name: Build iOS Dev IPA
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Install Fastlane
run: |
cd ios
bundle install
- name: Setup Match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
cd ios
bundle exec fastlane match development --readonly
- name: Get dependencies
run: flutter pub get
- name: Build DEV IPA
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
cd ios
bundle exec fastlane build_dev
- name: Upload IPA
uses: actions/upload-artifact@v3
with:
name: dev-ipa
path: ios/build/ios/app.ipa
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "β DEV iOS build successful!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*DEV iOS Build Completed*\n\n*Commit:* ${{ github.sha }}\n*Branch:* ${{ github.ref_name }}\n*Author:* ${{ github.actor }}"
}
}
]
}
File: .github/workflows/staging-build.yml
name: π§ͺ Staging Build & Deploy
on:
push:
branches:
- staging
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Run analyzer
run: flutter analyze
- name: Run tests
run: flutter test
deploy-android:
name: Deploy Android to Internal Testing
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
working-directory: android
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Create Play Store JSON
run: |
echo "${{ secrets.PLAY_STORE_CONFIG_JSON }}" > android/play-store-credentials.json
- name: Get dependencies
run: flutter pub get
- name: Deploy to Internal Testing
run: |
cd android
bundle exec fastlane deploy_internal
env:
SUPPLY_JSON_KEY: play-store-credentials.json
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "π STAGING Android deployed to Internal Testing!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*STAGING Android Deployed*\n\n*Track:* Internal Testing\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}"
}
}
]
}
deploy-ios:
name: Deploy iOS to TestFlight
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
working-directory: ios
- name: Setup Match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
cd ios
bundle exec fastlane match appstore --readonly
- name: Get dependencies
run: flutter pub get
- name: Deploy to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
run: |
cd ios
bundle exec fastlane deploy_testflight
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "π STAGING iOS deployed to TestFlight!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*STAGING iOS Deployed*\n\n*Platform:* TestFlight\n*Commit:* ${{ github.sha }}\n*Author:* ${{ github.actor }}"
}
}
]
}
File: .github/workflows/production-build.yml
name: π Production Build & Deploy
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Get dependencies
run: flutter pub get
- name: Run analyzer
run: flutter analyze
- name: Run tests
run: flutter test
- name: Check test coverage
run: |
flutter test --coverage
# You can add coverage checks here
deploy-android:
name: Deploy Android to Beta
needs: test
runs-on: ubuntu-latest
environment:
name: production-android
url: https://play.google.com/console
steps:
- uses: actions/checkout@v3
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
working-directory: android
- name: Decode Keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Create Play Store JSON
run: |
echo "${{ secrets.PLAY_STORE_CONFIG_JSON }}" > android/play-store-credentials.json
- name: Get dependencies
run: flutter pub get
- name: Deploy to Beta
run: |
cd android
bundle exec fastlane deploy_beta
env:
SUPPLY_JSON_KEY: play-store-credentials.json
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "π PRODUCTION Android deployed to Beta!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*PRODUCTION Android Deployed*\n\n*Track:* Beta\n*Commit:* ${{ github.sha }}\n*Tag:* ${{ github.ref_name }}\n*Author:* ${{ github.actor }}"
}
}
]
}
deploy-ios:
name: Deploy iOS to TestFlight
needs: test
runs-on: macos-latest
environment:
name: production-ios
url: https://appstoreconnect.apple.com
steps:
- uses: actions/checkout@v3
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
working-directory: ios
- name: Setup Match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: |
cd ios
bundle exec fastlane match appstore --readonly
- name: Get dependencies
run: flutter pub get
- name: Deploy to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
run: |
cd ios
bundle exec fastlane deploy_testflight
- name: Send Slack Notification
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "π PRODUCTION iOS deployed to TestFlight!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*PRODUCTION iOS Deployed*\n\n*Platform:* TestFlight\n*Commit:* ${{ github.sha }}\n*Tag:* ${{ github.ref_name }}\n*Author:* ${{ github.actor }}"
}
}
]
}
π¨ Step 8: Additional Configurations
File: android/Gemfile
source "https://rubygems.org"
gem "fastlane"
gem "fastlane-plugin-flutter"
Run bundle install in the android directory.
File: ios/Gemfile
source "https://rubygems.org"
gem "fastlane"
gem "fastlane-plugin-flutter"
Run bundle install in the ios directory.
π Step 9: Set Up Slack Notifications (Optional but Awesome!)
Step 9.1: Create Slack Webhook
- Go to your Slack workspace
- Go to Apps β Incoming Webhooks
- Click Add to Slack
- Choose a channel for notifications
- Copy the webhook URL
Step 9.2: Add to GitHub Secrets
Add SLACK_WEBHOOK_URL as a secret in your GitHub repo.
Now you'll get notifications for every build! π
π― Step 10: Branch Protection Rules
Protect your branches to enforce quality!
For main and staging branches:
- Go to GitHub repo β Settings β Branches
- Click Add rule
- Branch name pattern:
main(orstaging) - Enable: β’ Require pull request reviews before merging β’ Require status checks to pass before merging β’ Select the "Run Tests" check β’ Require branches to be up to date before merging β’ Do not allow bypassing the above settings
This ensures no one can push directly to production without tests passing!
π¦ Step 11: Versioning Strategy
Let's add automatic version bumping!
File: scripts/bump_version.sh
#!/bin/bash
# Usage: ./scripts/bump_version.sh [major|minor|patch]
TYPE=${1:-patch}
# Read current version from pubspec.yaml
CURRENT_VERSION=$(grep '^version: ' pubspec.yaml | sed 's/version: //' | sed 's/+.*//')
CURRENT_BUILD=$(grep '^version: ' pubspec.yaml | sed 's/.*+//')
# Split version into parts
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR=${VERSION_PARTS[0]}
MINOR=${VERSION_PARTS[1]}
PATCH=${VERSION_PARTS[2]}
# Bump version based on type
case $TYPE in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
# Increment build number
NEW_BUILD=$((CURRENT_BUILD + 1))
# New version string
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}+${NEW_BUILD}"
# Update pubspec.yaml
sed -i.bak "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
rm pubspec.yaml.bak
echo "Version bumped from ${CURRENT_VERSION}+${CURRENT_BUILD} to ${NEW_VERSION}"
Make it executable:
chmod +x scripts/bump_version.sh
Usage:
# Bump patch version (1.0.0 -> 1.0.1)
./scripts/bump_version.sh patch
# Bump minor version (1.0.0 -> 1.1.0)
./scripts/bump_version.sh minor
# Bump major version (1.0.0 -> 2.0.0)
./scripts/bump_version.sh major
π Step 12: Adding Test Coverage Reports
Let's add code coverage to our workflow!
Update .github/workflows/dev-build.yml (add to test job):
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
# ... existing steps ...
- name: Run tests with coverage
run: flutter test --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
name: flutter-coverage
fail_ci_if_error: false
- name: Check coverage threshold
run: |
# Install lcov
sudo apt-get install -y lcov
# Generate coverage report
lcov --summary coverage/lcov.info
# Extract coverage percentage
COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep "lines" | awk '{print $2}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
# Fail if coverage is below 80%
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "β Coverage is below 80%!"
exit 1
fi
echo "β Coverage is above 80%!"
π Step 13: Environment-Specific Release Notes
Create a system for automatic release notes!
File: scripts/generate_changelog.sh
#!/bin/bash
# Get the last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
if [ -z "$LAST_TAG" ]; then
echo "No previous tags found. Showing all commits."
COMMITS=$(git log --pretty=format:"- %s (%an)" --no-merges)
else
echo "Changes since $LAST_TAG:"
COMMITS=$(git log $LAST_TAG..HEAD --pretty=format:"- %s (%an)" --no-merges)
fi
# Categorize commits
FEATURES=$(echo "$COMMITS" | grep -i "^- feat" || echo "")
FIXES=$(echo "$COMMITS" | grep -i "^- fix" || echo "")
OTHERS=$(echo "$COMMITS" | grep -iv "^- feat\|^- fix" || echo "")
# Generate changelog
cat << EOF
# What's New
## π New Features
$FEATURES
## π Bug Fixes
$FIXES
## π Other Changes
$OTHERS
EOF
Make it executable:
chmod +x scripts/generate_changelog.sh
Add to your workflows for automatic release notes!
π Step 14: Testing Your Pipeline
Time to test everything!
Step 14.1: Test Development Build
git checkout -b develop
git add .
git commit -m "feat: add CI/CD pipeline"
git push origin develop
Watch the GitHub Actions tab! You should see:
- Tests running β
- Android APK building β
- iOS IPA building β
- Artifacts uploaded β
- Slack notification (if configured) β
Step 14.2: Test Staging Deployment
git checkout -b staging
git push origin staging
This should:
- Run tests β
- Build and deploy to Play Store Internal Testing β
- Build and deploy to TestFlight β
Step 14.3: Test Production (with approval)
git checkout -b main
git tag v1.0.0
git push origin main --tags
You'll need to approve the deployment in GitHub:
- Go to Actions tab
- Click on the workflow run
- Click "Review deployments"
- Select environments to deploy
- Click "Approve and deploy"
π§ Troubleshooting Common Issues
Issue 1: "Build failed β Unable to find keystore"
Solution: Make sure you've correctly encoded and saved your keystore as a secret. Double-check the base64 encoding:
# Verify your base64 encoding
base64 -i upload-keystore.jks -o test.txt
base64 --decode -i test.txt -o test.jks
# If test.jks is identical to original, encoding is correct!
Issue 2: "iOS signing failed β Certificate not found"
Solution: Make sure Match is set up correctly:
# Run locally first to test
cd ios
fastlane match development
fastlane match appstore
# Verify certificates exist in your certificates repo
Issue 3: "Flutter version mismatch"