GitHub Actions + Fastlane for building, testing, and distributing ReCursor to iOS and Android.
PR opened/updated:
[flutter analyze] -> [flutter test] -> (pass/fail)
Push to main:
[flutter analyze] -> [flutter test] -> [build Android APK/AAB] -> [build iOS IPA]
| |
Play Store TestFlight
(internal track)
Three jobs:
test— runs onubuntu-latest: analyze, unit tests, widget tests, golden testsbuild-android— runs onubuntu-latest: build AAB, upload to Play Store internal trackbuild-ios— runs onmacos-latest(required for Xcode): build IPA, upload to TestFlight
# .github/workflows/test.yml
name: Test
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
working-directory: apps/mobile
- name: Analyze
run: flutter analyze
working-directory: apps/mobile
- name: Run tests
run: flutter test
working-directory: apps/mobile# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
# Same as test.yml
build-android:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
working-directory: apps/mobile
- name: Build AAB
run: fastlane android deploy
working-directory: apps/mobile
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
build-ios:
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.0'
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Build IPA
run: fastlane ios deploy
working-directory: apps/mobile
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}- Store encrypted certificates and provisioning profiles in a private Git repo.
- Required GitHub Secrets:
MATCH_GIT_BASIC_AUTHORIZATION— base64-encodedusername:PATMATCH_PASSWORD— encryption passphrase- App Store Connect API key (preferred over
FASTLANE_PASSWORDto avoid 2FA issues)
- Use
setup_ciin the Fastlane lane to create a temporary keychain on the CI runner.
# fastlane/Fastfile
platform :ios do
desc "Deploy iOS app to TestFlight"
lane :deploy do
setup_ci
match(
type: "appstore",
readonly: is_ci,
)
build_app(
scheme: "Runner",
workspace: "ios/Runner.xcworkspace",
)
upload_to_testflight(
skip_waiting_for_build_processing: true,
)
end
end- Store the keystore as a base64-encoded GitHub Secret (
KEYSTORE_BASE64). - Decode in workflow:
echo $KEYSTORE_BASE64 | base64 --decode > android/app/keystore.jks - Reference key alias and passwords from secrets in
key.properties. - Upload to Play Store internal track via Fastlane's
supplyaction. - Note: First release must be uploaded manually via Play Console.
# fastlane/Fastfile
platform :android do
desc "Deploy Android app to Play Store"
lane :deploy do
build_android_app(
task: "bundle",
build_type: "release",
)
upload_to_play_store(
track: "internal",
release_status: "draft",
)
end
end- Never echo secret values in logs.
- PR builds run tests only, never deploy.
- Use branch-based triggers: PRs -> test;
mainpush -> test + deploy. - Pin Flutter version in CI to match local development (
subosito/flutter-action). - Cache pub dependencies and build artifacts between runs.
- Purpose-built for Flutter with macOS build machines included.
- Built-in code signing management (no Fastlane config needed).
- Costs money but saves significant setup time, especially for iOS.
- Consider if GitHub Actions macOS runner costs or complexity become prohibitive.
- Testing Strategy — Testing approach
- Architecture Overview — System architecture
Last updated: 2026-03-17