name: CI # Pull requests run only the jobs their changed paths can affect, and a newer # push cancels the older run. Draft PRs do not run. The `ci` job at the end is # the required check: it is green when every job that ran passed. # # Pushes to master run no checks. The PR already tested the same tree, so a # re-run after the merge was a second copy of every job. Two push jobs warm # caches instead. `app_size_base` builds the master APK once so that PRs can # compare against it without a second build, and writes the Flutter, pub and # Gradle caches. `ios_cache_base` writes the iOS pods and Xcode build cache. # PRs read caches in master scope. A cache that a PR writes is visible only to # that PR. on: pull_request: # Drafts are skipped, so the suite has to start when a draft becomes ready. types: [opened, synchronize, reopened, ready_for_review] push: branches: - master # Keep in sync with tool/build_size_apk.sh --key. paths: - 'lib/**' - 'android/**' - 'assets/**' - 'shaders/**' - 'packages/**' - 'pubspec.yaml' - 'pubspec.lock' - '.fvmrc' - 'tool/build_size_apk.sh' - 'tool/write_firebase_options_stub.sh' # ios_cache_base key. - 'ios/Podfile.lock' - '.github/workflows/ci.yml' concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read env: APP_SIZE_MAX_DELTA_BYTES: 1572864 APP_SIZE_MAX_DELTA_PERCENT: 5 APP_SIZE_TARGET_PLATFORM: android-arm64 APP_SIZE_BUILD_MODE: profile GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dkotlin.daemon.jvm.options=-Xmx2g -Dorg.gradle.vfs.watch=false jobs: changes: name: Detect changes if: github.event_name == 'pull_request' && github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 5 permissions: pull-requests: read outputs: flutter: ${{ steps.filter.outputs.flutter }} functions: ${{ steps.filter.outputs.functions }} web: ${{ steps.filter.outputs.web }} rules: ${{ steps.filter.outputs.rules }} android: ${{ steps.filter.outputs.android }} ios: ${{ steps.filter.outputs.ios }} app_size: ${{ steps.filter.outputs.app_size }} workflow: ${{ steps.filter.outputs.workflow }} notification_contract: ${{ steps.filter.outputs.notification_contract }} steps: # Reads the PR file list from the API, so no checkout is needed. - uses: dorny/paths-filter@v4 id: filter with: # A `!` pattern excludes paths only with this quantifier. Under the # default `some`, a negated pattern matches every other path. predicate-quantifier: some-with-excludes filters: | flutter: # Native files stay in. The backend notification-channel input # is handled by the small notification_contract job below. - '**' - '!functions/**' - '!web/**' - '!demo/**' - '!examples/**' - '!docs/**' - '!.github/**' - '!.claude/**' - '!.agents/**' - '!**/*.md' - '!firestore.rules' - '!firestore.indexes.json' functions: - 'functions/**' notification_contract: - 'functions/src/*.ts' - 'lib/core/startup/notification_channels.dart' - 'test/core/startup/notification_channels_test.dart' - 'tool/notification_contract_fixture_test.py' - 'lib/l10n/**' - 'pubspec.yaml' - 'pubspec.lock' - '.fvmrc' web: - 'web/**' rules: - 'firestore.rules' - 'firebase.json' - 'tool/firestore_rules_smoke.mjs' android: - 'android/**' - 'packages/**' - 'pigeons/**' - 'pubspec.yaml' - 'pubspec.lock' - '.fvmrc' - 'Makefile' ios: - 'ios/**' - 'packages/**' - 'pigeons/**' - 'pubspec.yaml' - 'pubspec.lock' - '.fvmrc' - 'Makefile' app_size: # The inputs of tool/build_size_apk.sh --key, plus the compare script. - 'lib/**' - 'android/**' - 'assets/**' - 'shaders/**' - 'packages/**' - 'pubspec.yaml' - 'pubspec.lock' - '.fvmrc' - 'tool/build_size_apk.sh' - 'tool/write_firebase_options_stub.sh' - 'tool/compare_app_size.py' workflow: - '.github/workflows/ci.yml' flutter: name: Flutter verify needs: changes if: needs.changes.outputs.flutter == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v4 - name: Read Flutter version from .fvmrc id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} - name: Generate Firebase options stub for CI run: tool/write_firebase_options_stub.sh # tool/cloudflare-worker-check.sh needs Node 22.6+. - name: Setup Node uses: actions/setup-node@v4 with: node-version: '24' - name: Run local CI gate run: make ci CI=true notification_contract: name: Notification-channel contract needs: changes if: needs.changes.outputs.notification_contract == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Read Flutter version id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} - name: Check notification-channel contract run: flutter test test/core/startup/notification_channels_test.dart - name: Exercise missing-channel regression fixture run: FLUTTER=flutter python3 tool/notification_contract_fixture_test.py android: name: Android native verify needs: changes if: needs.changes.outputs.android == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Read Flutter version id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true # Share master's Flutter caches on a hit. Misses can still save a PR-scoped copy. cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} - uses: gradle/actions/setup-gradle@v4 - run: tool/write_firebase_options_stub.sh - run: make native-android-check CI=true ios: name: iOS native verify needs: changes if: needs.changes.outputs.ios == 'true' || needs.changes.outputs.workflow == 'true' runs-on: macos-26 # Warm (cache from ios_cache_base): about 8 minutes. A PR that changes ios/Podfile.lock, # pubspec.lock or .fvmrc builds cold (about 15 minutes). timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Select Xcode 26.6 run: sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer - name: Read Flutter version id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} # Pods and Xcode's compilation cache (content-keyed, so new file times do not force a # rebuild). Only ios_cache_base saves it. - uses: actions/cache/restore@v4 with: path: | ios/Pods ~/Library/Caches/CocoaPods build/ios/native-tests key: ios-build-v2-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock', 'pubspec.lock', '.fvmrc') }} - run: tool/write_firebase_options_stub.sh - run: make native-ios-check CI=true functions: name: Functions verify needs: changes if: needs.changes.outputs.functions == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 15 defaults: run: working-directory: functions steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: functions/package-lock.json - name: Install, build, lint, and test run: | npm ci npm run build npm run lint node --test lib/__tests__/*.test.js - name: Setup Java for Firestore emulator uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Verify real Firestore transactions working-directory: . timeout-minutes: 5 run: >- timeout 240s npx --yes firebase-tools@15.15.0 emulators:exec --only firestore --project demo-prism-functions "node --test --test-timeout=120000 functions/lib/__tests__/favouriteTransactionsEmulator.test.js functions/lib/__tests__/onUserCreated.test.js functions/lib/__tests__/claimUsername.test.js functions/lib/__tests__/deleteAccount.test.js" web: name: Web verify needs: changes if: needs.changes.outputs.web == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: web steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: web/package-lock.json - name: Install, test, typecheck and build run: | npm ci node --test test/analytics.test.cjs npx playwright install --with-deps chromium npm run test:e2e npx tsc --noEmit npm run build rules: name: Firestore rules verify needs: changes if: needs.changes.outputs.rules == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '24' - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Cache Firebase emulators uses: actions/cache@v4 with: path: ~/.cache/firebase/emulators key: firebase-emulators-${{ runner.os }}-15.15.0 - name: Firestore rules smoke test run: npx --yes firebase-tools@15.15.0 emulators:exec --only firestore --project demo-prism "node tool/firestore_rules_smoke.mjs" repo_assets: name: Repository assets guard needs: changes if: github.event_name == 'pull_request' && github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout code uses: actions/checkout@v4 - name: Check repository assets run: python3 tool/check_repo_assets.py # Not part of the required `ci` check, so the merge does not wait for two # APK builds. A size regression still shows as a red check and a PR comment. app_size: name: Android app size needs: changes if: needs.changes.outputs.app_size == 'true' || needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read pull-requests: write env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 1 # The base commit may predate the script, so run the head copy. - name: Compute the base APK cache key id: base run: | git fetch --no-tags --depth=1 origin "$BASE_SHA" if ! git fetch --no-tags --depth=1 origin "$HEAD_SHA"; then git fetch --no-tags --depth=1 origin "refs/pull/$PR_NUMBER/head" test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" fi git checkout --force --detach "$HEAD_SHA" cp tool/build_size_apk.sh "$RUNNER_TEMP/" key=$("$RUNNER_TEMP/build_size_apk.sh" --key "$BASE_SHA") echo "key=app-size-apk-${{ runner.os }}-$APP_SIZE_BUILD_MODE-$APP_SIZE_TARGET_PLATFORM-$key" >> "$GITHUB_OUTPUT" # Written by app_size_base when the base commit merged. - name: Restore the base APK id: base_apk uses: actions/cache/restore@v4 with: path: build/size/base key: ${{ steps.base.outputs.key }} - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - name: Read Flutter version from .fvmrc id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} # pubspec.lock is in the key because Flutter plugins bring their own # Gradle dependencies. A plain actions/cache entry never changes after # it is written, so the key must move when the dependencies move. - name: Cache Gradle uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties', 'pubspec.lock') }} restore-keys: | gradle-${{ runner.os }}- - name: Build base APK if: steps.base_apk.outputs.cache-hit != 'true' run: | git checkout --force --detach "$BASE_SHA" "$RUNNER_TEMP/build_size_apk.sh" build/size/base ./android/gradlew --stop || true - name: Build head APK run: | git checkout --force --detach "$HEAD_SHA" "$RUNNER_TEMP/build_size_apk.sh" build/size/head - name: Compare app size and enforce thresholds run: | python3 tool/compare_app_size.py \ --base-artifact build/size/base/app-"$APP_SIZE_BUILD_MODE".apk \ --head-artifact build/size/head/app-"$APP_SIZE_BUILD_MODE".apk \ --max-delta-bytes "$APP_SIZE_MAX_DELTA_BYTES" \ --max-delta-percent "$APP_SIZE_MAX_DELTA_PERCENT" \ --json-out build/size/report.json \ --summary-out build/size/report.md cat build/size/report.md >> "$GITHUB_STEP_SUMMARY" - name: Upload app size artifacts if: ${{ always() && !cancelled() }} uses: actions/upload-artifact@v4 with: name: app-size-report path: build/size - name: Upsert app-size PR comment if: ${{ always() && !cancelled() && hashFiles('build/size/report.md') != '' }} continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: header: app-size-report path: build/size/report.md # Replaces an app-size comment from an earlier push, so it does not go stale. app_size_skipped: name: Android app size skipped needs: changes if: needs.changes.outputs.app_size != 'true' && needs.changes.outputs.workflow != 'true' runs-on: ubuntu-latest timeout-minutes: 5 permissions: pull-requests: write steps: - name: Upsert skipped app-size PR comment continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: header: app-size-report message: | ## Android App Size Report Skipped: no app-size relevant files changed in this PR. ios_cache_base: name: iOS build cache (master) if: github.event_name == 'push' runs-on: macos-26 timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Read Flutter version id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - name: Check for a cached build id: cached uses: actions/cache/restore@v4 with: path: | ios/Pods ~/Library/Caches/CocoaPods build/ios/native-tests key: ios-build-v2-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock', 'pubspec.lock', '.fvmrc') }} lookup-only: true - name: Select Xcode 26.6 if: steps.cached.outputs.cache-hit != 'true' run: sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer - if: steps.cached.outputs.cache-hit != 'true' uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} - if: steps.cached.outputs.cache-hit != 'true' run: tool/write_firebase_options_stub.sh - if: steps.cached.outputs.cache-hit != 'true' run: make native-ios-check CI=true - if: steps.cached.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | ios/Pods ~/Library/Caches/CocoaPods build/ios/native-tests key: ${{ steps.cached.outputs.cache-primary-key }} app_size_base: name: Android app size (master baseline) if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@v4 - name: Compute the APK cache key id: apk run: | key=$(tool/build_size_apk.sh --key HEAD) echo "key=app-size-apk-${{ runner.os }}-$APP_SIZE_BUILD_MODE-$APP_SIZE_TARGET_PLATFORM-$key" >> "$GITHUB_OUTPUT" - name: Check for a cached APK id: cached uses: actions/cache/restore@v4 with: path: build/size/base key: ${{ steps.apk.outputs.key }} lookup-only: true - name: Setup Java if: steps.cached.outputs.cache-hit != 'true' uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - name: Read Flutter version from .fvmrc if: steps.cached.outputs.cache-hit != 'true' id: fvm run: echo "version=$(jq -r '.flutter' .fvmrc)" >> "$GITHUB_OUTPUT" - name: Setup Flutter if: steps.cached.outputs.cache-hit != 'true' uses: subosito/flutter-action@v2 with: flutter-version: ${{ steps.fvm.outputs.version }} channel: stable cache: true cache-key: flutter-${{ runner.os }}-${{ steps.fvm.outputs.version }} pub-cache-key: flutter-pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} - name: Cache Gradle if: steps.cached.outputs.cache-hit != 'true' uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties', 'pubspec.lock') }} restore-keys: | gradle-${{ runner.os }}- - name: Build APK if: steps.cached.outputs.cache-hit != 'true' run: tool/build_size_apk.sh build/size/base - name: Save APK if: steps.cached.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: build/size/base key: ${{ steps.apk.outputs.key }} ci: name: ci needs: [changes, flutter, notification_contract, android, ios, functions, web, rules, repo_assets] # `always()` so a cancelled or failed job reports red. A skipped required # check counts as passed, so `ci` must never skip on a non-draft PR. if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.draft == false }} runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Aggregate results env: RESULTS: ${{ needs.changes.result }} ${{ needs.flutter.result }} ${{ needs.notification_contract.result }} ${{ needs.android.result }} ${{ needs.ios.result }} ${{ needs.functions.result }} ${{ needs.web.result }} ${{ needs.rules.result }} ${{ needs.repo_assets.result }} run: | echo "changes flutter notification_contract android ios functions web rules repo_assets: $RESULTS" [ "${{ needs.changes.result }}" = success ] || { echo 'Detect changes failed.'; exit 1; } for r in $RESULTS; do case "$r" in success|skipped) ;; *) echo 'A required job failed.'; exit 1 ;; esac done