fix(settings): offer a way to confirm a switch to a keyed provider #1235
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Default Workflow | |
| on: | |
| # `push` is intentionally limited to `main` so the workflow doesn't | |
| # double-fire on the `develop → main` release PR (where every push to | |
| # `develop` would otherwise trigger both a `push` run *and* a | |
| # `pull_request` synchronize run). All pre-merge validation happens via | |
| # `pull_request` (against `main` and `develop`). The `push: main` trigger | |
| # only catches post-merge runs on `main`. A direct push to `develop` | |
| # does not run this workflow — prefer PRs into `develop` (see CONTRIBUTING.md). | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| branches: | |
| - main | |
| - develop | |
| # Long-lived integration branches, where a multi-PR feature is staged | |
| # before it reaches `develop` as one merge. Without an entry here a PR | |
| # retargeted onto one of these silently gets *no* checks at all — the | |
| # PR still reports mergeable, so the absence is easy to miss. | |
| # | |
| # This is scoped to `feature/**` rather than `**` on purpose: it should | |
| # cover deliberate integration branches without firing a full CI run on | |
| # every PR between two arbitrary topic branches. | |
| - 'feature/**' | |
| workflow_dispatch: | |
| jobs: | |
| linux-checks: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Flutter + cache packages | |
| uses: ./.github/actions/setup-flutter-cache | |
| - name: Setup Just | |
| uses: taiki-e/install-action@just | |
| # The envied package obfuscates `.env` values into env.g.dart at compile | |
| # time. CI doesn't have access to the real secrets and the test suite | |
| # never hits Sentry / Supabase / FDC at runtime, so we stub them. Real | |
| # values must come from a developer's local .env when building for | |
| # release. | |
| - name: Generate stub .env for CI | |
| uses: ./.github/actions/write-env-file | |
| with: | |
| sentry_dns: https://stub@sentry.io/0 | |
| supabase_project_url: https://stub.supabase.co | |
| supabase_project_anon_key: ci-stub | |
| # `just ci` would also run `dart format --set-exit-if-changed`, but the | |
| # codebase currently has accumulated pre-existing format drift from the | |
| # period when CI was disabled. We run the rest of `just ci` (l10n | |
| # generation, build_runner, analyze, test) and leave the format pass | |
| # for a dedicated follow-up PR. | |
| - name: Install Flutter packages | |
| run: just install | |
| # check_l10n generates, then fails if any locale is missing a key. | |
| # `flutter gen-l10n` alone exits 0 on a missing translation and lets it | |
| # ship as English, and nothing downstream sees the difference. | |
| - name: Generate localizations (fails if a locale is missing keys) | |
| run: just check_l10n | |
| - name: Generate code (env.g.dart, Hive adapters, JSON serializers) | |
| run: just build | |
| - name: Static analysis | |
| run: flutter analyze | |
| - name: Run tests | |
| run: just test | |
| # The iOS pod-install fallback parser is the only piece of the iOS | |
| # CI flow that decides which pods to update when Podfile.lock | |
| # drifts. A regression in its regexes would silently degrade the | |
| # targeted-update path back to a full `pod update`, which is | |
| # exactly the behaviour #369 was filed to avoid. The test runs on | |
| # ubuntu — it stubs out CocoaPods entirely with a shell mock — so | |
| # we don't pay for it on the macOS runners. | |
| - name: Test the iOS pod-install fallback parser | |
| run: .github/scripts/test_pod_install_with_targeted_fallback.sh | |
| # Lightweight guard against the "iOS deps shifted but nobody re-ran | |
| # `pod install`" failure mode. The full `ios-build` job below also | |
| # regenerates Podfile.lock and auto-commits drift back to same-repo | |
| # PRs, but fork PRs run with a read-only token and that auto-commit | |
| # silently no-ops; this job exists to surface a hard failure on the PR | |
| # check so a human notices and refreshes the lockfile locally on a | |
| # Mac. We only run it on `pull_request` events where one of | |
| # pubspec.yaml, pubspec.lock, or ios/Podfile actually changed — | |
| # otherwise the macOS minutes aren't worth burning. | |
| ios-podfile-lock-guard: | |
| runs-on: macos-26 | |
| if: github.event_name == 'pull_request' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| # Need the merge base in scope so the paths-filter step below | |
| # can diff against the PR's target branch. | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| - name: Check for iOS-relevant file changes | |
| id: changes | |
| uses: dorny/paths-filter@v4 | |
| with: | |
| base: ${{ github.event.pull_request.base.ref }} | |
| filters: | | |
| ios: | |
| - 'pubspec.yaml' | |
| - 'pubspec.lock' | |
| - 'ios/Podfile' | |
| - name: Setup Flutter + cache packages | |
| if: steps.changes.outputs.ios == 'true' | |
| uses: ./.github/actions/setup-flutter-cache | |
| # Swift Package Manager is enabled by default on Flutter's stable | |
| # channel (see flutter_tools' features.dart), which flips CocoaPods | |
| # over to installing plugins with a Package.swift (image_picker, | |
| # sentry_flutter, ...) via SPM instead. That works for a normal | |
| # build, but `flutter build ios --no-codesign` in ios-build hits an | |
| # Xcode limitation where SPM package products still demand a | |
| # signing identity even with codesigning disabled — and this app | |
| # has no Development Team configured in CI. Pin CocoaPods as the | |
| # sole iOS dependency manager until that's resolved (or until we | |
| # wire up real signing for CI). | |
| - name: Disable Swift Package Manager | |
| if: steps.changes.outputs.ios == 'true' | |
| run: flutter config --no-enable-swift-package-manager | |
| - name: Generate stub .env for CI | |
| if: steps.changes.outputs.ios == 'true' | |
| uses: ./.github/actions/write-env-file | |
| with: | |
| sentry_dns: https://stub@sentry.io/0 | |
| supabase_project_url: https://stub.supabase.co | |
| supabase_project_anon_key: ci-stub | |
| - name: Install Flutter packages | |
| if: steps.changes.outputs.ios == 'true' | |
| run: flutter pub get | |
| - name: Pod install (with repo update) | |
| if: steps.changes.outputs.ios == 'true' | |
| run: cd ios && pod install --repo-update | |
| # If `pod install` rewrote Podfile.lock, the working tree now has a | |
| # diff that the PR author hasn't committed. Surface it as a hard | |
| # failure with a hint so the contributor knows what to do next, | |
| # rather than letting a stale lockfile silently ship to main. | |
| - name: Fail if Podfile.lock drifted | |
| if: steps.changes.outputs.ios == 'true' | |
| run: | | |
| if ! git diff --exit-code ios/Podfile.lock; then | |
| echo "::error file=ios/Podfile.lock::Podfile.lock is out of date with pubspec.yaml. Run 'cd ios && pod install' on macOS and commit the result." | |
| exit 1 | |
| fi | |
| ios-build: | |
| runs-on: macos-26 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| # Same-repo PRs: check out the head branch so the auto-commit | |
| # step below can push Podfile.lock fixes back to the branch. | |
| # Fork PRs: this job runs with `contents: write` permissions, | |
| # which means GitHub issues a token that's scoped to the base | |
| # repo and can't authenticate against the fork — the auto-commit | |
| # step is already gated to skip for fork PRs, so we just need a | |
| # checkout that works. Use the PR head SHA from the base repo's | |
| # refs/pull/N/head mirror, which the GITHUB_TOKEN can always read. | |
| # Push and workflow_dispatch fall back to github.ref. | |
| ref: >- | |
| ${{ | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| && github.event.pull_request.head.ref | |
| || github.event.pull_request.head.sha | |
| || github.ref | |
| }} | |
| repository: >- | |
| ${{ | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| && github.event.pull_request.head.repo.full_name | |
| || github.repository | |
| }} | |
| - name: Setup Flutter + cache packages | |
| uses: ./.github/actions/setup-flutter-cache | |
| # See the matching step in ios-podfile-lock-guard: SPM is enabled | |
| # by default on stable, but `flutter build ios --no-codesign` | |
| # below can't satisfy the signing identity SPM package products | |
| # need, and this job has no Development Team configured. | |
| - name: Disable Swift Package Manager | |
| run: flutter config --no-enable-swift-package-manager | |
| - name: Cache CocoaPods | |
| uses: actions/cache@v6 | |
| with: | |
| path: ios/Pods | |
| key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pods- | |
| - name: Generate stub .env for CI | |
| uses: ./.github/actions/write-env-file | |
| with: | |
| sentry_dns: https://stub@sentry.io/0 | |
| supabase_project_url: https://stub.supabase.co | |
| supabase_project_anon_key: ci-stub | |
| - name: Install Flutter packages | |
| run: flutter pub get | |
| - name: Generate code | |
| run: dart run build_runner build --delete-conflicting-outputs | |
| # `pod install` honours Podfile.lock as authoritative even with | |
| # --repo-update — CocoaPods will only refresh the spec cache, not | |
| # re-resolve. So if the lockfile pins a transitive subdep version | |
| # that's been bumped in pubspec.yaml (e.g. sentry_flutter 9.19 | |
| # now wants Sentry/HybridSDK 8.58 but the lock still pins 8.46), | |
| # `pod install` fails. The script's fallback re-resolves only the | |
| # pod(s) named in the CocoaPods error output (full `pod update` | |
| # only as a last resort), and the auto-commit step below pushes | |
| # the refreshed lockfile back to the PR branch so future runs hit | |
| # the deterministic `install` path. See #369 for the rationale | |
| # behind targeting specific pods rather than re-resolving all. | |
| # The script self-locates the iOS dir, so the caller doesn't need | |
| # to cd first. | |
| - name: Pod install (regenerate lockfile if constraints have shifted) | |
| run: .github/scripts/pod_install_with_targeted_fallback.sh | |
| # Same-repo PRs only — fork PRs run with a read-only token so this | |
| # step would fail to push. Fork contributors get the lockfile via | |
| # the artifact uploaded below. `pod install` above can also touch | |
| # project.pbxproj (it (re)writes the per-flavor Pods-Runner | |
| # xcconfig file references), so both are covered here. | |
| - name: Auto-commit refreshed iOS project files | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| uses: stefanzweifel/git-auto-commit-action@v7 | |
| with: | |
| file_pattern: 'ios/Podfile.lock ios/Runner.xcodeproj/project.pbxproj' | |
| commit_message: 'chore(ios): refresh Podfile.lock and project.pbxproj via CI' | |
| - name: Upload Podfile.lock artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ios-podfile-lock | |
| path: ios/Podfile.lock | |
| - name: Build iOS (no codesign) | |
| # --flavor full maps to the `full` Xcode scheme (matches the | |
| # production bundle identifier and display name). The `develop` | |
| # scheme exists for local sideloading alongside a TestFlight / | |
| # App Store install and isn't what CI should validate. | |
| run: flutter build ios --no-codesign --release --flavor full | |
| # Two calls on two runners, not two attempts on one. See the header of | |
| # .github/workflows/ios-integration-attempt.yml for why: the hang this suite | |
| # suffers is runner-sticky, so an in-place retry never recovers from it while | |
| # a fresh runner has every time. Neither call fails on its own — each reports | |
| # a verdict, and ios-integration-tests-result below turns the pair into the | |
| # single required check. | |
| ios-integration-tests: | |
| uses: ./.github/workflows/ios-integration-attempt.yml | |
| # Only when the first runner did not pass. An empty output counts as "did not | |
| # pass": that is what a job killed by its own timeout-minutes leaves behind. | |
| ios-integration-tests-retry: | |
| needs: ios-integration-tests | |
| if: ${{ !cancelled() && needs.ios-integration-tests.outputs.passed != 'true' }} | |
| uses: ./.github/workflows/ios-integration-attempt.yml | |
| # The check to require in branch protection — the two jobs above are green | |
| # even when their tests fail, by design, so requiring either of them directly | |
| # would require nothing at all. | |
| ios-integration-tests-result: | |
| needs: | |
| - ios-integration-tests | |
| - ios-integration-tests-retry | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Require one of the two runners to have passed | |
| run: | | |
| first='${{ needs.ios-integration-tests.outputs.passed }}' | |
| second='${{ needs.ios-integration-tests-retry.outputs.passed }}' | |
| echo "first runner: ${first:-<no verdict — the job was killed>}" | |
| echo "second runner: ${second:-<not run>}" | |
| if [ "$first" = 'true' ] || [ "$second" = 'true' ]; then | |
| exit 0 | |
| fi | |
| echo "::error::iOS integration tests failed on two independent runners — treat this as a real break, not a flake." | |
| exit 1 | |
| android-build: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Java (Zulu) | |
| id: setup_java_zulu | |
| uses: actions/setup-java@v5.6.0 | |
| continue-on-error: true | |
| with: | |
| distribution: 'zulu' | |
| java-version: '17' | |
| # Azul's download CDN intermittently returns HTTP 520, which fails the | |
| # whole job at the JDK-fetch step. Fall back to Temurin only when the | |
| # Zulu setup failed, so a single vendor's CDN blip no longer reds the | |
| # build. It now takes both vendors being down at once. | |
| - name: Setup Java (Temurin fallback) | |
| if: steps.setup_java_zulu.outcome == 'failure' | |
| uses: actions/setup-java@v5.6.0 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '17' | |
| - name: Setup Flutter + cache packages | |
| uses: ./.github/actions/setup-flutter-cache | |
| - name: Cache Gradle | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.gradle/caches | |
| ~/.gradle/wrapper | |
| key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} | |
| restore-keys: | | |
| ${{ runner.os }}-gradle- | |
| - name: Generate stub .env for CI | |
| uses: ./.github/actions/write-env-file | |
| with: | |
| sentry_dns: https://stub@sentry.io/0 | |
| supabase_project_url: https://stub.supabase.co | |
| supabase_project_anon_key: ci-stub | |
| - name: Install Flutter packages | |
| run: flutter pub get | |
| - name: Generate code | |
| run: dart run build_runner build --delete-conflicting-outputs | |
| - name: Build APK (debug) | |
| # --release would need a keystore which CI doesn't have; --debug | |
| # exercises the same Gradle / desugaring / R8 paths sufficiently | |
| # for a "compiles cleanly" gate. | |
| # --flavor full builds the production-equivalent applicationId | |
| # (no .develop suffix); the develop flavor exists for local | |
| # sideloading alongside the Play Store install and isn't what CI | |
| # should validate. | |
| run: flutter build apk --flavor full --debug | |
| android-integration-tests: | |
| # Same as iOS: runs in parallel from the start, gated on neither | |
| # android-build (it rebuilds the app itself) nor a file-discovery job. | |
| runs-on: ubuntu-latest | |
| # Same backstop as iOS, and like iOS the per-attempt bounds below are what | |
| # actually catch a hang. On a cold cache this job pays an AVD seed boot | |
| # plus a test boot, and the "retry once" step adds a third boot + rebuild, | |
| # so 30 was too tight and got cancelled mid-retry (which also skips the | |
| # AVD cache save, keeping the next run cold). 50 covers the retry path. | |
| timeout-minutes: 50 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| # The hosted ubuntu runner has ~14 GB free after its preinstalled | |
| # toolchains, and pulling the android-34 system image plus booting the | |
| # emulator can tip it into "No space left on device". That kills the | |
| # emulator before it boots, surfacing downstream as the misleading | |
| # "could not connect to TCP port 5554". Reclaim ~15 GB by dropping the | |
| # large toolchains this job never uses. Deliberately leaves the Flutter | |
| # and Java toolcache and the Android SDK dir untouched. | |
| - name: Free up disk space for the emulator | |
| run: | | |
| df -h / | |
| sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ | |
| /usr/local/share/boost /usr/share/swift /opt/hostedtoolcache/CodeQL | |
| sudo docker image prune --all --force || true | |
| df -h / | |
| - name: Setup Java (Zulu) | |
| id: setup_java_zulu | |
| uses: actions/setup-java@v5.6.0 | |
| continue-on-error: true | |
| with: | |
| distribution: 'zulu' | |
| java-version: '17' | |
| # Azul's download CDN intermittently returns HTTP 520, which fails the | |
| # whole job at the JDK-fetch step. Fall back to Temurin only when the | |
| # Zulu setup failed, so a single vendor's CDN blip no longer reds the | |
| # build. It now takes both vendors being down at once. | |
| - name: Setup Java (Temurin fallback) | |
| if: steps.setup_java_zulu.outcome == 'failure' | |
| uses: actions/setup-java@v5.6.0 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '17' | |
| - name: Setup Flutter + cache packages | |
| uses: ./.github/actions/setup-flutter-cache | |
| - name: Cache Gradle | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.gradle/caches | |
| ~/.gradle/wrapper | |
| key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} | |
| restore-keys: | | |
| ${{ runner.os }}-gradle- | |
| # Cache the AVD snapshot so future runs skip the slow first-boot. | |
| # Key includes the API level so a bump invalidates the cache. | |
| - name: Cache AVD | |
| uses: actions/cache@v6 | |
| id: avd-cache | |
| with: | |
| path: | | |
| ~/.android/avd/* | |
| ~/.android/adb* | |
| key: avd-android-34 | |
| - name: Enable KVM (so the Android emulator runs at native speed) | |
| run: | | |
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| sudo udevadm control --reload-rules | |
| sudo udevadm trigger --name-match=kvm | |
| - name: Generate stub .env for CI | |
| uses: ./.github/actions/write-env-file | |
| with: | |
| sentry_dns: https://stub@sentry.io/0 | |
| supabase_project_url: https://stub.supabase.co | |
| supabase_project_anon_key: ci-stub | |
| - name: Install Flutter packages | |
| run: flutter pub get | |
| - name: Generate code | |
| run: dart run build_runner build --delete-conflicting-outputs | |
| # First emulator-runner pass — only runs when the AVD cache is empty. | |
| # It boots the emulator once to seed the snapshot, then exits. This | |
| # makes the actual test pass below much faster. Only the first | |
| # shard to land on a fresh cache pays this cost; later shards in the | |
| # same run reuse the warmed AVD via the actions/cache restore key. | |
| - name: Generate AVD snapshot (cache miss only) | |
| if: steps.avd-cache.outputs.cache-hit != 'true' | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: 34 | |
| target: google_apis | |
| arch: x86_64 | |
| force-avd-creation: false | |
| emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none | |
| disable-animations: false | |
| script: echo "AVD snapshot generated for cache" | |
| # Two steps rather than a script-level loop on purpose: the flake we see | |
| # is the emulator-runner occasionally failing to bring the AVD up before | |
| # the test runs (it died on "could not connect to TCP port 5554"), which | |
| # happens outside the `script`. Retrying the whole step gives the retry a | |
| # fresh emulator boot, not just a re-run of flutter test. Attempt 1 uses | |
| # continue-on-error so a flake doesn't fail the job; attempt 2 runs only | |
| # if attempt 1 failed, and a real break fails both. | |
| - name: Run integration tests (attempt 1) | |
| id: android-integration | |
| continue-on-error: true | |
| # Same bound as iOS, for the same reason. No Android run in the last | |
| # twenty hung, and the emulator-runner already bounds the boot it is | |
| # known to flake on, but `script:` itself is unbounded — so the one | |
| # failure mode that skips the retry is available here too, and this | |
| # closes it for the price of a line. | |
| # | |
| # 20 minutes: the slowest healthy attempt in that sample was 12m4s. | |
| timeout-minutes: 20 | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: 34 | |
| target: google_apis | |
| arch: x86_64 | |
| force-avd-creation: false | |
| emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none | |
| disable-animations: true | |
| # --flavor full so the integration-test APK matches the | |
| # production-equivalent applicationId rather than the dev sideload | |
| # variant (see android/app/build.gradle). The whole | |
| # integration_test/ directory runs from a single build. | |
| script: flutter test integration_test/ --flavor full --reporter expanded | |
| - name: Run integration tests (retry once) | |
| if: steps.android-integration.outcome == 'failure' | |
| timeout-minutes: 20 | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: 34 | |
| target: google_apis | |
| arch: x86_64 | |
| force-avd-creation: false | |
| emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none | |
| disable-animations: true | |
| script: flutter test integration_test/ --flavor full --reporter expanded | |
| ios-package: | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| github.event_name == 'workflow_dispatch' | |
| needs: | |
| - linux-checks | |
| - ios-build | |
| - ios-integration-tests-result | |
| - android-build | |
| - android-integration-tests | |
| runs-on: macos-26 | |
| permissions: | |
| contents: read | |
| env: | |
| LANG: en_US.UTF-8 | |
| LC_ALL: en_US.UTF-8 | |
| BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile | |
| MATCH_READONLY: 'true' | |
| MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} | |
| MATCH_GIT_SSH_PRIVATE_KEY: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }} | |
| MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} | |
| APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Ruby + Bundler | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.3' | |
| bundler-cache: true | |
| - name: Load SSH key for match repository | |
| if: startsWith(env.MATCH_GIT_URL, 'git@') && env.MATCH_GIT_SSH_PRIVATE_KEY != '' | |
| uses: webfactory/ssh-agent@v0.10.0 | |
| with: | |
| ssh-private-key: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }} | |
| - name: Install GitHub SSH host keys | |
| if: startsWith(env.MATCH_GIT_URL, 'git@') && env.MATCH_GIT_SSH_PRIVATE_KEY != '' | |
| run: | | |
| mkdir -p ~/.ssh | |
| # Fetch github.com's host keys with ssh-keyscan rather than the | |
| # api.github.com/meta endpoint. That API is unauthenticated and | |
| # rate-limited to 60 requests/hour per IP, and the shared macOS | |
| # runner IP exhausts it — the step fails intermittently with | |
| # "403 rate limit exceeded". ssh-keyscan talks to github.com:22 | |
| # directly and has no such limit. | |
| ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> ~/.ssh/known_hosts | |
| chmod 600 ~/.ssh/known_hosts | |
| - name: Cache CocoaPods | |
| uses: actions/cache@v6 | |
| with: | |
| path: ios/Pods | |
| key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pods- | |
| - name: Install FVM | |
| run: | | |
| curl -fsSL https://fvm.app/install.sh | bash | |
| echo "$HOME/fvm/bin" >> "$GITHUB_PATH" | |
| - name: Install Flutter version for FVM | |
| run: fvm install 3.44.6 | |
| - name: Generate .env from GitHub Secrets | |
| uses: ./.github/actions/write-env-file | |
| env: | |
| SENTRY_DNS: ${{ secrets.SENTRY_DNS }} | |
| SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }} | |
| SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }} | |
| with: | |
| sentry_dns: ${{ env.SENTRY_DNS }} | |
| supabase_project_url: ${{ env.SUPABASE_PROJECT_URL }} | |
| supabase_project_anon_key: ${{ env.SUPABASE_PROJECT_ANON_KEY }} | |
| - name: Install Flutter packages | |
| run: fvm flutter pub get | |
| - name: Generate code | |
| run: fvm dart run build_runner build --delete-conflicting-outputs | |
| # The Podfile's Flutter post-install hook needs the iOS engine | |
| # artifacts (Flutter.xcframework). The setup-flutter-cache action used | |
| # by ios-build precaches them, but this job installs Flutter via FVM | |
| # (for parity with local dev) which does not, so `pod install` fails | |
| # with "Flutter.xcframework must exist ... run flutter precache --ios | |
| # first". Precache the iOS engine before pods. | |
| - name: Precache iOS Flutter engine | |
| run: fvm flutter precache --ios | |
| - name: Install CocoaPods dependencies | |
| working-directory: ios | |
| run: bundle exec pod install | |
| - name: Build signed iOS IPA via Fastlane | |
| working-directory: ios | |
| env: | |
| APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} | |
| run: bundle exec fastlane ios build | |
| - name: Upload IPA artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ios-ipa | |
| path: build/ios/ipa/*.ipa | |
| if-no-files-found: error | |
| ios-deploy: | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| github.event_name == 'workflow_dispatch' | |
| # Deploy only when BOTH platforms packaged successfully (see android-deploy) | |
| # so a one-sided failure never publishes half a release. | |
| needs: | |
| - ios-package | |
| - android-package | |
| # Must run on macOS. The TestFlight upload goes through Apple's iTunes | |
| # Transporter, which needs the macOS toolchain (xcrun/altool); on | |
| # ubuntu it failed with "No such file or directory @ dir_chdir0" as the | |
| # transporter couldn't resolve its working directory. The IPA is built | |
| # in ios-package and handed off as an artifact, so this job only uploads. | |
| runs-on: macos-26 | |
| permissions: | |
| contents: read | |
| env: | |
| LANG: en_US.UTF-8 | |
| LC_ALL: en_US.UTF-8 | |
| BUNDLE_GEMFILE: ${{ github.workspace }}/ios/Gemfile | |
| MATCH_READONLY: 'true' | |
| MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} | |
| MATCH_GIT_SSH_PRIVATE_KEY: ${{ secrets.MATCH_GIT_SSH_PRIVATE_KEY }} | |
| MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} | |
| APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Ruby + Bundler | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.3' | |
| bundler-cache: true | |
| - name: Download iOS IPA artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ios-ipa | |
| path: build/ios/ipa | |
| - name: Deploy iOS build to TestFlight via Fastlane | |
| working-directory: ios | |
| env: | |
| APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} | |
| APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} | |
| APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} | |
| run: bundle exec fastlane ios beta_from_ipa | |
| android-package: | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| github.event_name == 'workflow_dispatch' | |
| needs: | |
| - linux-checks | |
| - ios-build | |
| - ios-integration-tests-result | |
| - android-build | |
| - android-integration-tests | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| env: | |
| BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Java (Zulu) | |
| id: setup_java_zulu | |
| uses: actions/setup-java@v5.6.0 | |
| continue-on-error: true | |
| with: | |
| distribution: 'zulu' | |
| java-version: '17' | |
| # Azul's download CDN intermittently returns HTTP 520, which fails the | |
| # whole job at the JDK-fetch step. Fall back to Temurin only when the | |
| # Zulu setup failed, so a single vendor's CDN blip no longer reds the | |
| # build. It now takes both vendors being down at once. | |
| - name: Setup Java (Temurin fallback) | |
| if: steps.setup_java_zulu.outcome == 'failure' | |
| uses: actions/setup-java@v5.6.0 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '17' | |
| - name: Setup Ruby + Bundler | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.3' | |
| bundler-cache: true | |
| - name: Setup Flutter + cache packages | |
| uses: ./.github/actions/setup-flutter-cache | |
| - name: Cache Gradle | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.gradle/caches | |
| ~/.gradle/wrapper | |
| key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} | |
| restore-keys: | | |
| ${{ runner.os }}-gradle- | |
| - name: Generate .env from GitHub Secrets | |
| uses: ./.github/actions/write-env-file | |
| env: | |
| SENTRY_DNS: ${{ secrets.SENTRY_DNS }} | |
| SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }} | |
| SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }} | |
| with: | |
| sentry_dns: ${{ env.SENTRY_DNS }} | |
| supabase_project_url: ${{ env.SUPABASE_PROJECT_URL }} | |
| supabase_project_anon_key: ${{ env.SUPABASE_PROJECT_ANON_KEY }} | |
| - name: Restore Android signing material | |
| env: | |
| ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} | |
| ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} | |
| ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | |
| ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} | |
| run: | | |
| : "${ANDROID_KEYSTORE_BASE64:?Missing ANDROID_KEYSTORE_BASE64 secret}" | |
| : "${ANDROID_KEYSTORE_PASSWORD:?Missing ANDROID_KEYSTORE_PASSWORD secret}" | |
| : "${ANDROID_KEY_ALIAS:?Missing ANDROID_KEY_ALIAS secret}" | |
| : "${ANDROID_KEY_PASSWORD:?Missing ANDROID_KEY_PASSWORD secret}" | |
| mkdir -p android/app | |
| printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/app/upload-keystore.jks | |
| cat > android/key.properties <<EOF | |
| storePassword=$ANDROID_KEYSTORE_PASSWORD | |
| keyPassword=$ANDROID_KEY_PASSWORD | |
| keyAlias=$ANDROID_KEY_ALIAS | |
| storeFile=../app/upload-keystore.jks | |
| EOF | |
| - name: Install Flutter packages | |
| run: flutter pub get | |
| - name: Generate code | |
| run: dart run build_runner build --delete-conflicting-outputs | |
| - name: Build Android App Bundle | |
| # --flavor full is the production-equivalent applicationId | |
| # (no .develop suffix); Play Store distribution requires this | |
| # flavor. | |
| run: flutter build appbundle --release --flavor full | |
| - name: Build Android APK | |
| run: flutter build apk --release --flavor full | |
| - name: Upload Android AAB artifact | |
| # The full flavor's AAB keeps the historic `app-release.aab` | |
| # filename via the rename in android/app/build.gradle so the | |
| # fastlane lane's default path and the GitHub release asset list | |
| # keep working unchanged. | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: android-aab | |
| path: build/app/outputs/bundle/fullRelease/app-release.aab | |
| if-no-files-found: error | |
| - name: Upload Android APK artifact | |
| # The Gradle outputFileName rename in android/app/build.gradle | |
| # keeps the historic `app-release.apk` filename, but only in | |
| # Gradle's native output directory (apk/<flavor>/<buildType>/). | |
| # Flutter's post-build copy at build/app/outputs/flutter-apk/ | |
| # bypasses the rename and writes `app-full-release.apk` there, | |
| # so we point the upload at the Gradle dir where the name matches | |
| # the historic GitHub release asset. | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: android-apk | |
| path: build/app/outputs/apk/full/release/app-release.apk | |
| if-no-files-found: error | |
| android-deploy: | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| github.event_name == 'workflow_dispatch' | |
| # Deploy only when BOTH platforms packaged successfully. A half-success | |
| # (e.g. iOS packaging fails) would otherwise still upload the Android AAB, | |
| # consuming a Play versionCode and forcing a build-number bump on the | |
| # retry. Gating both deploys on both package jobs keeps a release atomic. | |
| needs: | |
| - android-package | |
| - ios-package | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| env: | |
| BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Ruby + Bundler | |
| uses: ruby/setup-ruby@v1 | |
| with: | |
| ruby-version: '3.3' | |
| bundler-cache: true | |
| - name: Download Android AAB artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: android-aab | |
| path: release-assets/android | |
| # Tolerates exactly one upstream defect, and nothing else. | |
| # | |
| # Since 2.1.0 the bundle declares `android.permission.health.*`, and the | |
| # Play Publishing API rejects health-permission bundles at | |
| # `EditService.Validate`/`Commit` with "You must let us know whether your | |
| # app includes any health features" — **regardless of the declaration**, | |
| # which is complete and was re-verified across four console surfaces | |
| # (#942). Uploading the same bundle by hand through the console asks no | |
| # health question at all and succeeds. It is a known, open, unowned | |
| # defect: fastlane#22204 was closed unfixed, fastlane#27960 reopened it, | |
| # and expo/eas-cli#3275 reports it from a different toolchain, which | |
| # rules out fastlane's request construction as the sole cause. | |
| # | |
| # So the attempt stays. Deleting the step would go silently green and | |
| # nobody would notice the day Google fixes it; `continue-on-error` would | |
| # swallow real failures too. Matching the one error string keeps every | |
| # other failure — bad credentials, a consumed versionCode, a rejected | |
| # bundle — loud, and lets this heal itself with no further change. | |
| # | |
| # Note what a failing upload also costs, learned the hard way in #959: | |
| # the API never reaches the point where Play returns its release | |
| # warnings, so a minSdk bump that dropped 1,399 device models went | |
| # unseen for eight days. The manual upload is where those warnings | |
| # appear, which is why the summary below insists on reading them. | |
| - name: Upload Android App Bundle to Google Play Internal Testing via Fastlane | |
| working-directory: android | |
| env: | |
| GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} | |
| ANDROID_AAB_PATH: ${{ github.workspace }}/release-assets/android/app-release.aab | |
| run: | | |
| set +e | |
| log="$RUNNER_TEMP/play-upload.log" | |
| bundle exec fastlane android internal 2>&1 | tee "$log" | |
| status=${PIPESTATUS[0]} | |
| set -e | |
| [ "$status" -eq 0 ] && exit 0 | |
| if ! grep -qF \ | |
| 'You must let us know whether your app includes any health features' \ | |
| "$log"; then | |
| echo "::error::Play upload failed for a reason other than the known health-declaration defect (#942). Not tolerated." | |
| exit "$status" | |
| fi | |
| echo "::warning::Play rejected the API upload with the known health-declaration defect (#942). The Android bundle needs uploading by hand; iOS is unaffected." | |
| { | |
| echo '### Android upload needs doing by hand' | |
| echo | |
| echo 'The Play Publishing API rejected this bundle with:' | |
| echo | |
| echo '> Google Api Error: Invalid request - You must let us know whether your app includes any health features.' | |
| echo | |
| echo 'This is [#942](https://github.com/simonoppowa/OpenNutriTracker/issues/942):' | |
| echo 'a known upstream defect, not a missing declaration. The declaration is' | |
| echo 'complete, and the console asks no health question when the same bundle is' | |
| echo 'uploaded by hand.' | |
| echo | |
| echo '**To finish the release:**' | |
| echo | |
| echo '1. Download the `android-aab` artifact from this run, or take the AAB' | |
| echo ' attached to the GitHub release this workflow creates.' | |
| echo '2. Play Console → Testen und veröffentlichen → Interner Test →' | |
| echo ' **Neuen Release erstellen**, and drop the AAB in.' | |
| echo '3. **Read the warnings on the review step before publishing.** This is the' | |
| echo ' only place Play reports them, and a failing API upload never gets far' | |
| echo ' enough to return them — which is how the minSdk regression in' | |
| echo ' [#959](https://github.com/simonoppowa/OpenNutriTracker/issues/959) went' | |
| echo ' unnoticed for eight days.' | |
| echo | |
| echo 'This step will start passing on its own once Google fixes the API; nothing' | |
| echo 'here needs changing when that happens.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| github-release: | |
| if: | | |
| (github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| github.event_name == 'workflow_dispatch' | |
| needs: | |
| - ios-deploy | |
| - android-deploy | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Download iOS IPA artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ios-ipa | |
| path: release-assets/ios | |
| - name: Download Android AAB artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: android-aab | |
| path: release-assets/android | |
| - name: Download Android APK artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: android-apk | |
| path: release-assets/android | |
| - name: Prepare release tag and asset names | |
| run: | | |
| APP_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml) | |
| VERSION_NO_BUILD="${APP_VERSION%+*}" | |
| BUILD_NUMBER="${APP_VERSION##*+}" | |
| TAG_NAME="v${VERSION_NO_BUILD}" | |
| RELEASE_NAME="Release v${VERSION_NO_BUILD} (build ${BUILD_NUMBER})" | |
| echo "TAG_NAME=$TAG_NAME" >> "$GITHUB_ENV" | |
| echo "RELEASE_NAME=$RELEASE_NAME" >> "$GITHUB_ENV" | |
| IPA_PATH=$(ls -1 release-assets/ios/*.ipa | head -n 1) | |
| cp "$IPA_PATH" release-assets/opennutritracker.ipa | |
| - name: Create GitHub release and upload assets | |
| uses: softprops/action-gh-release@v3 | |
| with: | |
| tag_name: ${{ env.TAG_NAME }} | |
| name: ${{ env.RELEASE_NAME }} | |
| generate_release_notes: true | |
| files: | | |
| release-assets/opennutritracker.ipa | |
| release-assets/android/app-release.aab | |
| release-assets/android/app-release.apk | |
| # Historical reference — the original single-step `just ci` invocation that | |
| # was used before this workflow was split into linux-checks + ios-build. | |
| # Kept here so reviewers can see how the secrets-templated form would look | |
| # if/when real secrets are configured in repo Settings → Secrets and the | |
| # stub .env approach above is replaced. | |
| # | |
| # - name: Run CI | |
| # run: just ci | |
| # env: | |
| ## SENTRY_DNS: ${{ secrets.SENTRY_DNS }} | |
| # SUPABASE_PROJECT_ANON_KEY: ${{ secrets.SUPABASE_PROJECT_ANON_KEY }} | |
| # SUPABASE_PROJECT_URL: ${{ secrets.SUPABASE_PROJECT_URL }} |