fix(ci): only deploy from main, and never re-release a shipped version #1281
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. | |
| # | |
| # Kept after `feature/ai-assisted-meal-logging` merged, rather than | |
| # removed as spent. It is not leftover config: it is what gave #975, | |
| # #978, #980 and #981 any checks at all, and four of the five defects | |
| # that branch surrendered were found after CI passed on exactly those | |
| # PRs. The next long-lived branch will need it before anyone remembers | |
| # to add it back, and the failure mode is silent — a retargeted PR | |
| # still reports mergeable with no checks run. | |
| - 'feature/**' | |
| # Release branches, for the same reason and with the same failure mode. | |
| # `release/2.2.0` is cut from `main`, so a fix that landed on `develop` | |
| # is not in it, and the PR carrying that fix across got exactly one | |
| # check — the Copilot reviewer — while reporting `CLEAN`. A release | |
| # branch is the last place that should merge unverified: everything on | |
| # it is by definition about to become `main`. | |
| - 'release/**' | |
| 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 | |
| # Deploy gate, 0 of 5 — the only one that asks whether there is anything NEW | |
| # to ship. The five below ask whether it is safe to ship it. | |
| # | |
| # This job exists because of what fixing the deploy-chain skip exposed. Until | |
| # a8a78997 the five deploy jobs silently skipped on every push to `main`, so | |
| # it did not matter that they are keyed on nothing but the branch. They fire | |
| # for real now, and `push: main` fires on every merge — docs-only ones | |
| # included. With `pubspec.yaml` still reading `2.2.0+63` the morning after | |
| # 2.2.0 shipped, the next merge would re-run the whole chain against an | |
| # already-published version: | |
| # | |
| # * `ios-deploy` re-uploads build 63. Apple answers ITMS-90189 "Redundant | |
| # Binary Upload" and pilot raises; the lane has no rescue and the step no | |
| # `continue-on-error`, so the job goes RED — a red `main` for a docs | |
| # commit, after ~41 macOS runner minutes. | |
| # * `android-deploy` re-uploads versionCode 63. Whether that dies inside | |
| # supply's `upload_bundles` or limps to the same #942 commit failure | |
| # depends on whether the AAB was finished by hand in the console. Only | |
| # the second matches the #942 grep, so the first is correctly NOT | |
| # tolerated — also red. | |
| # * `github-release` derives `TAG_NAME=v2.2.0`, which already exists, and | |
| # softprops/action-gh-release does not fail on that — it UPDATES the | |
| # release in place. `overwrite_files` defaults to true, so it would | |
| # DELETE and re-upload the IPA, AAB and APK that 2.2.0 shipped and | |
| # regenerate the notes, while the tag stayed put (GitHub's REST docs: | |
| # `target_commitish` is "Unused if the Git tag already exists"). The | |
| # result is a published release whose binaries no longer match its tag. | |
| # | |
| # Today only ordering stops that last one: `ios-deploy` fails first, so | |
| # `github-release` never runs. That is luck, not design. Bump only the build | |
| # number — `2.2.0+64` — and iOS passes, `TAG_NAME` is still `v2.2.0`, and the | |
| # luck runs out on a published release. This job turns the ordering accident | |
| # into a rule. | |
| # | |
| # The ledger it reads is the one `github-release` writes: the `v<version>` | |
| # tag, created by the last step of the last job in the chain. Tag present | |
| # means a release carrying that exact version name ran to completion. No | |
| # tag, nothing shipped it, so ship. | |
| # | |
| # Deliberately does NOT gate the build and test jobs. A push to `main` should | |
| # still be compiled and tested; what it must not do is package and publish. | |
| # | |
| # Two consequences, both chosen rather than overlooked: | |
| # | |
| # * A build-only bump will not deploy. `2.2.0+64` still derives `v2.2.0`, | |
| # so it is refused. That is deliberate — a build-only bump is exactly the | |
| # input that arms the release-overwrite above — and it matches what | |
| # docs/RELEASING.md already asks for: bump `version:` in `pubspec.yaml`, | |
| # both halves. A rebuild that must reach TestFlight needs a new version | |
| # name, not a new build number. | |
| # * The tag records a release that FINISHED. A chain that died after the | |
| # TestFlight upload leaves no tag, so this job says ship again and the | |
| # retry hits ITMS-90189. Nothing local can detect a half-published | |
| # release; bump the build number before retrying one. | |
| # | |
| # It reports rather than fails when there is nothing to ship. A merge that is | |
| # not a release is the normal case, and a red X on `main` for every docs | |
| # commit teaches everyone to stop reading main's status — the exact habit | |
| # that let the deploy jobs skip unnoticed for a whole release cycle. | |
| release-gate: | |
| # The same ref/event test the five gates below carry (see ios-package). | |
| # Repeating it here means a skipped gate — result `skipped`, not | |
| # `success` — is a second, independent reason the deploy chain cannot run | |
| # from a pull request or from a branch other than `main`. | |
| # | |
| # No `!cancelled()` here, unlike the five below, and that is not an | |
| # oversight: this job has no `needs:`, so there is no upstream skip for | |
| # GitHub's implicit `success()` to inherit and nothing to escape. | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| # TWO answers, not one, because two different questions were being | |
| # conflated and they have different ledgers and different stakes: | |
| # | |
| # deploy — is there a NEW BUILD to send to the stores? Ledger: the | |
| # build NUMBER. Read by the four packaging and deploy jobs. | |
| # Getting this wrong costs a red run and two store | |
| # rejections; nothing is destroyed. | |
| # publish — is `v<version-name>` still unclaimed? Ledger: that tag. | |
| # Read by `github-release` and by nothing else, because that | |
| # is the only destructive path in this workflow. | |
| # | |
| # The single `ship` output these replace answered only the second | |
| # question and then gated the first on it, which is why a build-only | |
| # bump — 2.2.0+63 -> 2.2.0+64, same version NAME, new binary — was | |
| # refused: both derive `v2.2.0`. A build-only bump is a legitimate | |
| # TestFlight release and must ship. | |
| # | |
| # Both are the literal string 'true' and nothing else, so the empty | |
| # string a skipped or failed job leaves behind reads as "do not ship" | |
| # without any extra handling. | |
| deploy: ${{ steps.decide.outputs.deploy }} | |
| publish: ${{ steps.decide.outputs.publish }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Decide what this push may deploy and what it may publish | |
| id: decide | |
| run: | | |
| # Character-for-character the derivation `github-release` uses in its | |
| # "Prepare release tag and asset names" step. The two are deliberately | |
| # independent — passing the name between jobs would let an empty | |
| # output fall through to action-gh-release's documented `tag_name` | |
| # default of `github.ref_name`, i.e. a release tagged `main` — but | |
| # they must stay in step. Edit one, edit the other. | |
| APP_VERSION=$(awk '/^version:/ {print $2}' pubspec.yaml) | |
| if [ -z "$APP_VERSION" ]; then | |
| echo "::error::No 'version:' line in pubspec.yaml. Refusing to guess what this push would ship." | |
| exit 1 | |
| fi | |
| VERSION_NO_BUILD="${APP_VERSION%+*}" | |
| BUILD_NUMBER="${APP_VERSION##*+}" | |
| TAG_NAME="v${VERSION_NO_BUILD}" | |
| BUILD_TAG="deployed/${BUILD_NUMBER}" | |
| # The build number is compared numerically below, so it has to BE a | |
| # number. `2.2.0` with no `+` would leave BUILD_NUMBER as the whole | |
| # string and silently mis-answer; say so instead. | |
| case "$BUILD_NUMBER" in | |
| ''|*[!0-9]*) | |
| echo "::error::Build number '$BUILD_NUMBER' in version '$APP_VERSION' is not a plain integer. Refusing to guess whether this build has already shipped." | |
| exit 1 | |
| ;; | |
| esac | |
| # `git ls-remote --exit-code` rather than a REST call, because it | |
| # separates the three answers that matter into distinct exit codes: | |
| # 0 the ref exists, 2 it does not, anything else the question was | |
| # never answered. A 404 body cannot be told apart from a transient | |
| # failure that cheaply, and "could not reach the remote" must never | |
| # read as "no such ref, go ahead". | |
| # | |
| # It queries the REMOTE, which is why `actions/checkout`'s shallow, | |
| # tag-less fetch does not matter here — a local `git tag -l` would | |
| # answer "no such tag" for everything and wave every push through. | |
| # | |
| # The pattern is a full ref path, which matches exactly. That is not | |
| # decoration: `git ls-remote` matches a pattern "against the 'tail' | |
| # of a ref, starting either from the start of the ref or from a slash | |
| # separator", so the bare pattern `v1.3.2` MATCHES the existing | |
| # `refs/tags/v1.3.2+53-build.629` while `refs/tags/v1.3.2` correctly | |
| # exits 2. Always pass the full path. | |
| # | |
| # Sets a variable rather than echoing, so the `exit 1` below exits | |
| # this step. Inside `VAR=$(ref_exists ...)` it would only end the | |
| # command substitution's subshell. | |
| ref_exists() { | |
| set +e | |
| git ls-remote --exit-code --tags origin "$1" >/dev/null 2>&1 | |
| ls_status=$? | |
| set -e | |
| case "$ls_status" in | |
| 0) REF_EXISTS=yes ;; | |
| 2) REF_EXISTS=no ;; | |
| *) | |
| echo "::error::Could not read tags from origin (git ls-remote exit $ls_status) while asking whether refs/tags/$1 exists. Refusing to guess whether this build has already shipped." | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| ref_exists "refs/tags/$BUILD_TAG" | |
| BUILD_SHIPPED="$REF_EXISTS" | |
| ref_exists "refs/tags/$TAG_NAME" | |
| TAG_TAKEN="$REF_EXISTS" | |
| # THE LEDGER STARTS EMPTY, and an empty ledger says "never shipped" | |
| # about builds that demonstrably have. The `deployed/<n>` tags are | |
| # written by the deploy jobs below, and those jobs have never run | |
| # under this design, so on the push that LANDS this change — | |
| # pubspec.yaml still at 2.2.0+63, because a workflow fix does not | |
| # bump the version — `deployed/63` does not exist and build 63 would | |
| # read as new. Build 63 is on TestFlight and on the Play internal | |
| # track right now. Re-pushing it means ITMS-90189 "Redundant Binary | |
| # Upload" from Apple and "version code has already been used" from | |
| # Play — and that second string is NOT one the #942 tolerance grep in | |
| # android-deploy matches, so that job goes red rather than | |
| # warning-green. Worse, it would not self-heal: the deploy fails, so | |
| # no marker is written, so the next push repeats it forever. | |
| # | |
| # LAST_BUILD_BEFORE_LEDGER is the seed. It is the highest build | |
| # number that shipped before this ledger existed; anything at or | |
| # below it is already spoken for, tag or no tag. A constant rather | |
| # than a tag pushed by hand before the merge, because a hand-pushed | |
| # seed can be forgotten and forgetting it costs a red `main` and a | |
| # burnt build number, whereas a constant lands atomically with the | |
| # change and gets reviewed with it. | |
| # | |
| # It also closes a second hole this repo has actually fallen into: | |
| # build numbers are NOT monotonic here. `git log -p pubspec.yaml` | |
| # shows `1.3.1+51` and later `1.4.0+51` — the same Play versionCode | |
| # twice — and `1.4.0+51` follows `1.3.2+55`, a regression of four. A | |
| # ledger of marker tags cannot see a regression to a build that | |
| # predates it; this line can. | |
| # | |
| # DO NOT BUMP IT. It records history, not the current build. Every | |
| # build from 64 onward records itself. | |
| LAST_BUILD_BEFORE_LEDGER=63 | |
| if [ "$BUILD_SHIPPED" = no ] && [ "$BUILD_NUMBER" -le "$LAST_BUILD_BEFORE_LEDGER" ]; then | |
| BUILD_SHIPPED=yes | |
| echo "::notice::Build $BUILD_NUMBER is at or below the pre-ledger floor ($LAST_BUILD_BEFORE_LEDGER), so it shipped before this ledger existed. Treating it as already deployed." | |
| fi | |
| # Written out rather than `[ "$X" = no ] && DEPLOY=true`. That idiom | |
| # returns 1 on the false branch, and as the last command of a step it | |
| # would fail the job under the `bash -e` GitHub runs `run:` with. | |
| if [ "$BUILD_SHIPPED" = no ]; then DEPLOY=true; else DEPLOY=false; fi | |
| if [ "$TAG_TAKEN" = no ]; then PUBLISH=true; else PUBLISH=false; fi | |
| echo "deploy=$DEPLOY" >> "$GITHUB_OUTPUT" | |
| echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" | |
| # Four combinations, all reachable, each meaning something different | |
| # to a human. Say which one this is, every time. | |
| if [ "$DEPLOY" = true ] && [ "$PUBLISH" = true ]; then | |
| echo "::notice::Full release: $APP_VERSION deploys to both stores and is published as $TAG_NAME." | |
| { | |
| echo "### Releasing \`$APP_VERSION\` as \`$TAG_NAME\`" | |
| echo | |
| echo "Neither \`$BUILD_TAG\` nor \`$TAG_NAME\` exists, so this push packages," | |
| echo 'deploys to both stores and cuts the GitHub release.' | |
| echo | |
| echo "Watch \`android-deploy\`'s step summary — the Play upload usually still" | |
| echo 'needs doing by hand (#942).' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| elif [ "$DEPLOY" = true ]; then | |
| echo "::notice::Build-only bump: $APP_VERSION deploys to both stores. $TAG_NAME is already published, so no GitHub release is cut and github-release is skipped ON PURPOSE." | |
| { | |
| echo "### Deploying build \`$BUILD_NUMBER\`, with no GitHub release" | |
| echo | |
| echo "\`$BUILD_TAG\` does not exist, so build \`$BUILD_NUMBER\` has never gone to" | |
| echo 'the stores and this push ships it to TestFlight and Play. But' | |
| echo "\`$TAG_NAME\` **already exists**, and \`softprops/action-gh-release\` does" | |
| echo 'not fail on an existing tag — it updates the release in place, renames' | |
| echo 'it and appends regenerated notes to the published body. So' | |
| echo '`github-release` is skipped.' | |
| echo | |
| echo '**A skipped `github-release` on this path is the expected result, not the' | |
| echo '#1008 failure it resembles.** A skipped job reports as Success, nothing' | |
| echo 'in this workflow depends on `github-release`, and the run stays green.' | |
| echo | |
| echo '**Where the binaries are:** the `ios-ipa`, `android-aab` and' | |
| echo '`android-apk` artifacts on this run page, kept for 90 days (the' | |
| echo 'repository artifact retention setting). No release assets are published' | |
| echo "or replaced, so the APK attached to \`$TAG_NAME\` still belongs to the" | |
| echo 'build that release is named for. Do not "fix" that by re-running' | |
| echo '`github-release`; that is the destructive path this gate exists to shut.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| elif [ "$PUBLISH" = true ]; then | |
| # Build already consumed, version name never published. There is no | |
| # safe automatic answer here and both causes need a human, so fail | |
| # rather than skip: a green run that quietly published nothing is | |
| # exactly the shape of #1008, and this workflow does not get to | |
| # look like that again. | |
| echo "::error::Build $BUILD_NUMBER has already been deployed, but $TAG_NAME was never published. Nothing can ship from this run. See the step summary." | |
| { | |
| echo "### Refusing to run: build \`$BUILD_NUMBER\` is spent and \`$TAG_NAME\` is unpublished" | |
| echo | |
| echo "\`$BUILD_TAG\` exists (or the build is at or below the pre-ledger floor)," | |
| echo "so build \`$BUILD_NUMBER\` has already gone to the stores — a Play" | |
| echo "versionCode cannot be reused. But \`$TAG_NAME\` has no tag, so no GitHub" | |
| echo 'release was ever cut for it.' | |
| echo | |
| echo 'There are two ways to get here, and they need different fixes:' | |
| echo | |
| echo '1. **The version NAME was bumped without the build number.** Bump the' | |
| echo ' build number too, per `docs/RELEASING.md` — "Both halves. Nothing' | |
| echo ' bumps it for you."' | |
| echo '2. **A previous run deployed, then `github-release` failed, and someone' | |
| echo ' used *Re-run all jobs*.** That re-runs this gate, which now sees the' | |
| echo ' build recorded and shuts the whole chain. Use **Re-run failed jobs**' | |
| echo ' on the ORIGINAL run instead — it reuses this job'"'"'s cached outputs' | |
| echo ' and re-runs only what failed. If that run is gone, bump the build' | |
| echo ' number and ship again, or attach the artifacts to a release by hand.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| else | |
| echo "::notice::Nothing to ship: build $BUILD_NUMBER has already been deployed and $TAG_NAME is already released. The packaging and deploy jobs are skipped." | |
| { | |
| echo '### No release from this push' | |
| echo | |
| echo "\`pubspec.yaml\` is at \`$APP_VERSION\`; build \`$BUILD_NUMBER\` has already" | |
| echo "been deployed and \`$TAG_NAME\` already exists, so this build has shipped" | |
| echo 'and this version has been published. The packaging, deploy and release' | |
| echo 'jobs are skipped; the build and test jobs ran as normal.' | |
| echo | |
| echo 'This is the expected result for a docs fix or any merge that did not' | |
| echo 'bump the version. To ship, bump `version:` in `pubspec.yaml` per' | |
| echo 'docs/RELEASING.md — the build number alone for a TestFlight/Play-only' | |
| echo 'build, or both halves for a published release.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| # Deploy gate, 1 of 5 — read this one, the other four point back at it. | |
| # | |
| # Three things have to be true to package a release, and every one of them is | |
| # load-bearing: | |
| # | |
| # 1. the run is on `main` — tested first, and for EVERY event; | |
| # 2. the run is a push or a manual dispatch — a pull request must never | |
| # touch signing material or a store; | |
| # 3. every job in `needs:` actually SUCCEEDED. | |
| # | |
| # (1) is its own conjunct rather than half of (2) on purpose, and that is | |
| # #1011. The guard used to read | |
| # | |
| # ((github.event_name == 'push' && github.ref == 'refs/heads/main') || | |
| # github.event_name == 'workflow_dispatch') && ... | |
| # | |
| # which pins the ref on the `push` arm ONLY. `workflow_dispatch:` accepts no | |
| # branch filter — GitHub does not offer one — so the dispatch arm accepted | |
| # any branch or tag in the repository, and a manual run from a topic branch | |
| # got the full release: a TestFlight upload, a Play versionCode, and a `v*` | |
| # tag. Not hypothetical: run 26086317616 was a `workflow_dispatch` on | |
| # `integrate-cicd-deployment` and carried ios-package, android-package, | |
| # ios-deploy, android-deploy AND github-release all to success. | |
| # | |
| # We got away with it — that head was later merged, and every tag this repo | |
| # has cut points at a commit reachable from `main`. That was luck. Nothing | |
| # in the old guard required it. | |
| # | |
| # The parentheses around the event test are load-bearing: `&&` binds tighter | |
| # than `||`, so dropping them re-associates into | |
| # `(ref == main && event == push) || (event == dispatch && needs...)`, which | |
| # is the bug written out longhand. | |
| # | |
| # (2) used to be implicit: with no status check function in an `if:`, GitHub | |
| # ANDs a `success()` onto it for free. That free check is what broke the | |
| # 2.2.0 release. `ios-integration-tests-retry` is skipped on every healthy | |
| # run — the first runner passed, so there is nothing to retry — and a skip | |
| # travels the whole dependency chain, not one hop: "a failure or skip applies | |
| # to all jobs in the dependency chain from the point of failure or skip | |
| # onwards". `ios-integration-tests-result` steps out of the way of it with | |
| # its own `if: !cancelled()`, but that exempts that one job; the skip keeps | |
| # going. In push run 33547853809 every job below was green, this guard was | |
| # true, and the entire deploy chain skipped anyway: no v2.2.0 tag, no GitHub | |
| # release, nothing to TestFlight, no AAB — and the run still reported | |
| # `success`, so nothing looked wrong. | |
| # | |
| # `!cancelled()` is the escape: a default status check of `success()` is | |
| # applied unless the expression includes one of the status functions. | |
| # Including one drops that implicit `success()` and with it the inherited | |
| # skip. It is also why the gate then has to be written out by hand, one | |
| # `result == 'success'` per dependency — a bare `!cancelled()` on its own | |
| # would be strictly WORSE than the bug it fixes, because it would push an | |
| # integration suite that failed on two independent runners straight to | |
| # TestFlight and Play. | |
| # | |
| # Rules for anyone editing this: | |
| # * every entry in `needs:` needs a matching line in `if:`. The implicit | |
| # check is gone; an unmirrored dependency is an ungated dependency. | |
| # * `== 'success'` only. `!= 'failure'` is true for `skipped` and | |
| # `cancelled` too, which re-opens exactly this bug. | |
| # * `!cancelled()`, never `always()` — `always()` runs even when the run | |
| # was cancelled, i.e. it would deploy out of a run someone stopped. | |
| # * keep the event test as an AND-conjunct. Dropping it, or ORing it in, | |
| # deploys from pull requests. | |
| ios-package: | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| !cancelled() && | |
| needs.release-gate.result == 'success' && | |
| needs.release-gate.outputs.deploy == 'true' && | |
| needs.linux-checks.result == 'success' && | |
| needs.ios-build.result == 'success' && | |
| needs.ios-integration-tests-result.result == 'success' && | |
| needs.android-build.result == 'success' && | |
| needs.android-integration-tests.result == 'success' | |
| needs: | |
| - release-gate | |
| - 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 | |
| # Deploy gate, 3 of 5. See ios-package. Fixing only the package jobs would not | |
| # help: the skip from `ios-integration-tests-retry` reaches this job too, so | |
| # the packages would go green and this would still silently skip. | |
| ios-deploy: | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| !cancelled() && | |
| needs.release-gate.result == 'success' && | |
| needs.release-gate.outputs.deploy == 'true' && | |
| needs.ios-package.result == 'success' && | |
| needs.android-package.result == 'success' | |
| # Deploy only when BOTH platforms packaged successfully (see android-deploy) | |
| # so a one-sided failure never publishes half a release. | |
| needs: | |
| - release-gate | |
| - 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: | |
| # Widened from `read` for the ledger step at the end of this job, | |
| # which pushes one lightweight tag with the GITHUB_TOKEN credentials | |
| # actions/checkout persists. Nothing else in this job writes to the repo. | |
| contents: write | |
| 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 | |
| # Writes the ledger `release-gate` reads. Last step of the job, and of | |
| # the sibling deploy job too — both push it, deliberately. | |
| # | |
| # LAST, not first: a job that dies before the upload leaves no marker, so | |
| # docs/RELEASING.md's "re-running is the fix" for a `startup_failure` | |
| # still works. A marker written up front would record builds that never | |
| # left the runner. | |
| # | |
| # BOTH deploy jobs push it, rather than one shared job downstream, | |
| # because the tag means "a store has consumed this build number" and | |
| # consumption is per-store. If only the other job pushed and this half | |
| # were the one that failed, a retry would re-upload against a build | |
| # number the other store has already taken. Two writers means the record | |
| # survives either half failing — which is the honest answer, because a | |
| # half-failed deploy genuinely cannot be retried at the same build | |
| # number. Bump the build number and ship again. | |
| # | |
| # The race between the two jobs is a no-op: both tag the same | |
| # `github.sha`, so the loser's push reports "Everything up-to-date" and | |
| # exits 0. A push at a DIFFERENT sha would be rejected, which is why | |
| # there is no `--force` here — that would mean the gate had let an | |
| # already-deployed build through, and that needs a human. | |
| # | |
| # Pushing a tag cannot retrigger this workflow, twice over: `on.push` | |
| # declares only `branches:`, and "if you define only tags/tags-ignore or | |
| # only branches/branches-ignore, the workflow won't run for events | |
| # affecting the undefined Git ref"; and pushes made with the GITHUB_TOKEN | |
| # do not create workflow runs at all. No `[skip ci]` guard is needed. | |
| # | |
| # `deployed/<n>`, not `v<name>+<n>`: `v2.2.0+64` is valid | |
| # semver-with-build-metadata, and GitHub picks the previous tag for | |
| # `generate_release_notes` on a semver-ish heuristic, so it could become | |
| # the comparison base for a later release and silently truncate its | |
| # changelog. `deployed/64` is not semver-shaped and is keyed on the build | |
| # number ALONE — a Play versionCode is globally unique for the app | |
| # forever, so the number is the thing that is spent, independent of which | |
| # version name it shipped under. `git ls-remote --tags origin | |
| # 'refs/tags/deployed/*'` is the whole ledger. | |
| - name: Record that this build number has been deployed | |
| run: | | |
| BUILD_TAG="deployed/$(awk '/^version:/ {print $2}' pubspec.yaml | sed 's/.*+//')" | |
| # Lightweight tag: needs no user.name/user.email on the runner, and | |
| # there is nothing worth annotating. The push uses the credentials | |
| # actions/checkout persists by default, which is the only reason this | |
| # job carries `contents: write`. | |
| git tag -f "$BUILD_TAG" | |
| if git push origin "refs/tags/$BUILD_TAG"; then | |
| echo "::notice::Recorded $BUILD_TAG. No later push will deploy build number ${BUILD_TAG##*/} again." | |
| exit 0 | |
| fi | |
| # A rejected push is not automatically a failure — the sibling deploy | |
| # job may simply have won the race. Re-ask the remote instead of | |
| # parsing git's message, and fail only if the marker genuinely is not | |
| # there. | |
| if git ls-remote --exit-code --tags origin "refs/tags/$BUILD_TAG" >/dev/null 2>&1; then | |
| echo "::notice::$BUILD_TAG was already recorded (the sibling deploy job, or a concurrent run, won the race). The build number is spent either way, which is all the ledger claims." | |
| exit 0 | |
| fi | |
| echo "::error::Could not record $BUILD_TAG. This build HAS been uploaded but the ledger does not say so, so the next push to main would upload it again. Create the tag by hand before merging anything else: git tag $BUILD_TAG ${{ github.sha }} && git push origin $BUILD_TAG" | |
| exit 1 | |
| # Deploy gate, 2 of 5 — identical to ios-package by design: both package jobs | |
| # gate the same release on the same evidence, so they must stay in step. See | |
| # ios-package for why each dependency is checked by result instead of being | |
| # left to GitHub's implicit `success()`. | |
| android-package: | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| !cancelled() && | |
| needs.release-gate.result == 'success' && | |
| needs.release-gate.outputs.deploy == 'true' && | |
| needs.linux-checks.result == 'success' && | |
| needs.ios-build.result == 'success' && | |
| needs.ios-integration-tests-result.result == 'success' && | |
| needs.android-build.result == 'success' && | |
| needs.android-integration-tests.result == 'success' | |
| needs: | |
| - release-gate | |
| - 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 | |
| # Deploy gate, 4 of 5. See ios-package. The two `result == 'success'` lines | |
| # are also what now enforces the both-platforms rule described below — it is | |
| # stated outright here rather than left to an implicit check that a future | |
| # `needs:` edit could quietly widen. | |
| android-deploy: | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| !cancelled() && | |
| needs.release-gate.result == 'success' && | |
| needs.release-gate.outputs.deploy == 'true' && | |
| needs.android-package.result == 'success' && | |
| needs.ios-package.result == 'success' | |
| # 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: | |
| - release-gate | |
| - android-package | |
| - ios-package | |
| runs-on: ubuntu-latest | |
| permissions: | |
| # Widened from `read` for the ledger step at the end of this job, | |
| # which pushes one lightweight tag with the GITHUB_TOKEN credentials | |
| # actions/checkout persists. Nothing else in this job writes to the repo. | |
| contents: write | |
| 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 ' (a full release also attaches it to the GitHub release; a build-only' | |
| echo ' bump creates none, so the run artifact is the only copy).' | |
| 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" | |
| # Writes the ledger `release-gate` reads. Last step of the job, and of | |
| # the sibling deploy job too — both push it, deliberately. | |
| # | |
| # LAST, not first: a job that dies before the upload leaves no marker, so | |
| # docs/RELEASING.md's "re-running is the fix" for a `startup_failure` | |
| # still works. A marker written up front would record builds that never | |
| # left the runner. | |
| # | |
| # BOTH deploy jobs push it, rather than one shared job downstream, | |
| # because the tag means "a store has consumed this build number" and | |
| # consumption is per-store. If only the other job pushed and this half | |
| # were the one that failed, a retry would re-upload against a build | |
| # number the other store has already taken. Two writers means the record | |
| # survives either half failing — which is the honest answer, because a | |
| # half-failed deploy genuinely cannot be retried at the same build | |
| # number. Bump the build number and ship again. | |
| # | |
| # The race between the two jobs is a no-op: both tag the same | |
| # `github.sha`, so the loser's push reports "Everything up-to-date" and | |
| # exits 0. A push at a DIFFERENT sha would be rejected, which is why | |
| # there is no `--force` here — that would mean the gate had let an | |
| # already-deployed build through, and that needs a human. | |
| # | |
| # Pushing a tag cannot retrigger this workflow, twice over: `on.push` | |
| # declares only `branches:`, and "if you define only tags/tags-ignore or | |
| # only branches/branches-ignore, the workflow won't run for events | |
| # affecting the undefined Git ref"; and pushes made with the GITHUB_TOKEN | |
| # do not create workflow runs at all. No `[skip ci]` guard is needed. | |
| # | |
| # `deployed/<n>`, not `v<name>+<n>`: `v2.2.0+64` is valid | |
| # semver-with-build-metadata, and GitHub picks the previous tag for | |
| # `generate_release_notes` on a semver-ish heuristic, so it could become | |
| # the comparison base for a later release and silently truncate its | |
| # changelog. `deployed/64` is not semver-shaped and is keyed on the build | |
| # number ALONE — a Play versionCode is globally unique for the app | |
| # forever, so the number is the thing that is spent, independent of which | |
| # version name it shipped under. `git ls-remote --tags origin | |
| # 'refs/tags/deployed/*'` is the whole ledger. | |
| - name: Record that this build number has been deployed | |
| run: | | |
| BUILD_TAG="deployed/$(awk '/^version:/ {print $2}' pubspec.yaml | sed 's/.*+//')" | |
| # Lightweight tag: needs no user.name/user.email on the runner, and | |
| # there is nothing worth annotating. The push uses the credentials | |
| # actions/checkout persists by default, which is the only reason this | |
| # job carries `contents: write`. | |
| git tag -f "$BUILD_TAG" | |
| if git push origin "refs/tags/$BUILD_TAG"; then | |
| echo "::notice::Recorded $BUILD_TAG. No later push will deploy build number ${BUILD_TAG##*/} again." | |
| exit 0 | |
| fi | |
| # A rejected push is not automatically a failure — the sibling deploy | |
| # job may simply have won the race. Re-ask the remote instead of | |
| # parsing git's message, and fail only if the marker genuinely is not | |
| # there. | |
| if git ls-remote --exit-code --tags origin "refs/tags/$BUILD_TAG" >/dev/null 2>&1; then | |
| echo "::notice::$BUILD_TAG was already recorded (the sibling deploy job, or a concurrent run, won the race). The build number is spent either way, which is all the ledger claims." | |
| exit 0 | |
| fi | |
| echo "::error::Could not record $BUILD_TAG. This build HAS been uploaded but the ledger does not say so, so the next push to main would upload it again. Create the tag by hand before merging anything else: git tag $BUILD_TAG ${{ github.sha }} && git push origin $BUILD_TAG" | |
| exit 1 | |
| # Deploy gate, 5 of 5, and it needs the same treatment rather than inheriting | |
| # a fix from its parents — the skip propagates past them to here. The two | |
| # result checks also preserve behaviour this job already got right: in run | |
| # 33243607505 android-deploy FAILED and this job correctly skipped, so no tag | |
| # and no release was cut for a half-published build. A bare `!cancelled()` | |
| # here would have tagged and published it. | |
| github-release: | |
| if: | | |
| github.ref == 'refs/heads/main' && | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| !cancelled() && | |
| needs.release-gate.result == 'success' && | |
| needs.release-gate.outputs.publish == 'true' && | |
| needs.ios-deploy.result == 'success' && | |
| needs.android-deploy.result == 'success' | |
| needs: | |
| - release-gate | |
| - 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 }} | |
| # Belt and braces behind `release-gate`'s `publish` output, which | |
| # should mean this job never runs against an existing tag at all. If | |
| # one ever slips through, the action does NOT fail on an existing | |
| # release — it updates it in place, and `overwrite_files` defaults to | |
| # true, which deletes the published IPA/AAB/APK and re-uploads | |
| # binaries from a different commit while the tag stays where it was. | |
| # GitHub does not version release assets, so that deletion is | |
| # permanent. | |
| # | |
| # Do not read this as a tripwire, though — it is NOT one, and an | |
| # earlier version of this comment claimed otherwise. In | |
| # softprops/action-gh-release v3, `src/github.ts` handles the flag as | |
| # | |
| # if (config.input_overwrite_files === false) { | |
| # console.log(`Asset ${name} already exists and overwrite_files is false...`); | |
| # return null; | |
| # } | |
| # | |
| # — it logs and returns. No throw, and the job stays GREEN. It also | |
| # does not stop the rest of the existing-release path: `updateRelease` | |
| # is still called with `name`, so the published release is RENAMED | |
| # (`Release v2.2.0 (build 63)` becomes `(build 64)` over assets that | |
| # are still build 63), and because `generate_release_notes: true` | |
| # routes through `prepareReleaseMutation`, freshly generated notes are | |
| # APPENDED to the existing body rather than replacing it. | |
| # | |
| # So this flag protects the three published binaries and nothing else. | |
| # `publish` is the only thing that keeps this job away from a | |
| # published release, which is why it is a hard gate and not advice. | |
| overwrite_files: false | |
| 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 }} |