|
15 | 15 | branches: |
16 | 16 | - main |
17 | 17 | - develop |
| 18 | + # Long-lived integration branches, where a multi-PR feature is staged |
| 19 | + # before it reaches `develop` as one merge. Without an entry here a PR |
| 20 | + # retargeted onto one of these silently gets *no* checks at all — the |
| 21 | + # PR still reports mergeable, so the absence is easy to miss. |
| 22 | + # |
| 23 | + # This is scoped to `feature/**` rather than `**` on purpose: it should |
| 24 | + # cover deliberate integration branches without firing a full CI run on |
| 25 | + # every PR between two arbitrary topic branches. |
| 26 | + # |
| 27 | + # Kept after `feature/ai-assisted-meal-logging` merged, rather than |
| 28 | + # removed as spent. It is not leftover config: it is what gave #975, |
| 29 | + # #978, #980 and #981 any checks at all, and four of the five defects |
| 30 | + # that branch surrendered were found after CI passed on exactly those |
| 31 | + # PRs. The next long-lived branch will need it before anyone remembers |
| 32 | + # to add it back, and the failure mode is silent — a retargeted PR |
| 33 | + # still reports mergeable with no checks run. |
| 34 | + - 'feature/**' |
| 35 | + # Release branches, for the same reason and with the same failure mode. |
| 36 | + # `release/2.2.0` is cut from `main`, so a fix that landed on `develop` |
| 37 | + # is not in it, and the PR carrying that fix across got exactly one |
| 38 | + # check — the Copilot reviewer — while reporting `CLEAN`. A release |
| 39 | + # branch is the last place that should merge unverified: everything on |
| 40 | + # it is by definition about to become `main`. |
| 41 | + - 'release/**' |
18 | 42 | workflow_dispatch: |
19 | 43 |
|
20 | 44 | jobs: |
@@ -263,129 +287,45 @@ jobs: |
263 | 287 | # App Store install and isn't what CI should validate. |
264 | 288 | run: flutter build ios --no-codesign --release --flavor full |
265 | 289 |
|
| 290 | + # Two calls on two runners, not two attempts on one. See the header of |
| 291 | + # .github/workflows/ios-integration-attempt.yml for why: the hang this suite |
| 292 | + # suffers is runner-sticky, so an in-place retry never recovers from it while |
| 293 | + # a fresh runner has every time. Neither call fails on its own — each reports |
| 294 | + # a verdict, and ios-integration-tests-result below turns the pair into the |
| 295 | + # single required check. |
266 | 296 | ios-integration-tests: |
267 | | - # Runs from the start of the workflow, in parallel with everything else. |
268 | | - # Not gated on ios-build: this job rebuilds the app itself (it compiles |
269 | | - # its own test binary), so the dependency shared no output and only |
270 | | - # serialised ~6-11 min of build in front of it. The cost is one extra |
271 | | - # macOS build when the app doesn't compile, which this job's own build |
272 | | - # surfaces regardless. |
273 | | - # |
274 | | - # Pinned to macos-15, not macos-26. On the macos-26 image Flutter builds |
275 | | - # and launches the app on the iPhone 17 Pro / iOS 26.4 simulator but then |
276 | | - # hangs forever on "Waiting for VM Service port to be available" — the Dart |
277 | | - # VM-service URL is never discovered on that simulator, so the test never |
278 | | - # connects (the same suite passes on the Android emulator and on the iOS 18 |
279 | | - # simulator here). macos-15 ships the Xcode 16 / iOS 18 simulator this |
280 | | - # Flutter supports. Revisit macos-26 once Flutter discovers the iOS 26 |
281 | | - # simulator's VM service. ios-build / package / deploy stay on macos-26 — |
282 | | - # they build/sign and never launch a simulator, so they're unaffected. |
283 | | - runs-on: macos-15 |
284 | | - # The backstop, not the thing that catches hangs. Each attempt below |
285 | | - # carries its own 20-minute bound, so a hang is a failed attempt the |
286 | | - # retry can act on; this bound only stops a job drifting toward GitHub's |
287 | | - # 6-hour default if something escapes both. It has to hold two cold |
288 | | - # builds — 30 was too tight and got cancelled mid-retry. |
289 | | - # |
290 | | - # It used to be the only bound, and a hang cost the whole 50 minutes of |
291 | | - # macOS time, reported as a cancelled job rather than a failed test — |
292 | | - # which `gh pr checks` renders as `fail` on a PR that is fine (#823). |
293 | | - timeout-minutes: 50 |
| 297 | + uses: ./.github/workflows/ios-integration-attempt.yml |
| 298 | + |
| 299 | + # Only when the first runner did not pass. An empty output counts as "did not |
| 300 | + # pass": that is what a job killed by its own timeout-minutes leaves behind. |
| 301 | + ios-integration-tests-retry: |
| 302 | + needs: ios-integration-tests |
| 303 | + if: ${{ !cancelled() && needs.ios-integration-tests.outputs.passed != 'true' }} |
| 304 | + uses: ./.github/workflows/ios-integration-attempt.yml |
| 305 | + |
| 306 | + # The check to require in branch protection — the two jobs above are green |
| 307 | + # even when their tests fail, by design, so requiring either of them directly |
| 308 | + # would require nothing at all. |
| 309 | + ios-integration-tests-result: |
| 310 | + needs: |
| 311 | + - ios-integration-tests |
| 312 | + - ios-integration-tests-retry |
| 313 | + if: ${{ !cancelled() }} |
| 314 | + runs-on: ubuntu-latest |
294 | 315 | permissions: |
295 | 316 | contents: read |
296 | 317 | steps: |
297 | | - - name: Checkout code |
298 | | - uses: actions/checkout@v7 |
299 | | - |
300 | | - - name: Setup Flutter + cache packages |
301 | | - uses: ./.github/actions/setup-flutter-cache |
302 | | - |
303 | | - # Match ios-podfile-lock-guard and ios-build: SPM is enabled by |
304 | | - # default on Flutter's stable channel, so `flutter test` would spend |
305 | | - # ~150s "Adding Swift Package Manager integration" and resolve plugins |
306 | | - # via the SPM graph instead of the CocoaPods one those jobs pin to. |
307 | | - # Disable it here too so this job builds the same pinned graph and |
308 | | - # skips that avoidable per-build cost. |
309 | | - - name: Disable Swift Package Manager |
310 | | - run: flutter config --no-enable-swift-package-manager |
311 | | - |
312 | | - - name: Cache CocoaPods |
313 | | - uses: actions/cache@v6 |
314 | | - with: |
315 | | - path: ios/Pods |
316 | | - key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }} |
317 | | - restore-keys: | |
318 | | - ${{ runner.os }}-pods- |
319 | | -
|
320 | | - - name: Generate stub .env for CI |
321 | | - uses: ./.github/actions/write-env-file |
322 | | - with: |
323 | | - sentry_dns: https://stub@sentry.io/0 |
324 | | - supabase_project_url: https://stub.supabase.co |
325 | | - supabase_project_anon_key: ci-stub |
326 | | - |
327 | | - - name: Install Flutter packages |
328 | | - run: flutter pub get |
329 | | - |
330 | | - - name: Generate code |
331 | | - run: dart run build_runner build --delete-conflicting-outputs |
332 | | - |
333 | | - - name: Pod install (regenerate lockfile if constraints have shifted) |
334 | | - run: .github/scripts/pod_install_with_targeted_fallback.sh |
335 | | - |
336 | | - # Pick any available iPhone simulator on the runner. macos-26 |
337 | | - # ships several preinstalled; we don't care which exact model — the |
338 | | - # boot-smoke test is platform-bridge driven, not display-pixel driven. |
339 | | - - name: Pick + boot iPhone simulator |
340 | | - run: | |
341 | | - DEVICE_UDID=$(xcrun simctl list devices available iPhone -j | python3 -c " |
342 | | - import sys, json |
343 | | - data = json.load(sys.stdin) |
344 | | - devices = [d for runtime in data['devices'].values() for d in runtime if d.get('isAvailable')] |
345 | | - if not devices: |
346 | | - sys.exit('No available iPhone simulators on this runner') |
347 | | - print(devices[0]['udid']) |
348 | | - ") |
349 | | - echo "Booting simulator UDID: $DEVICE_UDID" |
350 | | - xcrun simctl boot "$DEVICE_UDID" |
351 | | - xcrun simctl bootstatus "$DEVICE_UDID" |
352 | | - echo "DEVICE_UDID=$DEVICE_UDID" >> $GITHUB_ENV |
353 | | -
|
354 | | - # Two steps rather than a shell loop, matching android-integration-tests |
355 | | - # below. The loop this replaces retried on a non-zero exit, and so had |
356 | | - # nothing to say about `flutter test` never exiting at all: a hung |
357 | | - # attempt blocked until the job's own `timeout-minutes` killed it, and |
358 | | - # the retry — the mechanism meant to absorb simulator flakes — was |
359 | | - # skipped by the one flake mode that actually happens here. Three of |
360 | | - # sixteen runs on 2026-08-24 burned the full 50 minutes that way (#823). |
361 | | - # |
362 | | - # A step-level `timeout-minutes` turns a hang into a failed attempt, so |
363 | | - # the retry fires on it exactly as it does on a failure. |
364 | | - # |
365 | | - # 20 minutes per attempt: the slowest healthy run of this step in the |
366 | | - # last twenty was 15m36s (the range is 8m53s-15m36s, and it includes the |
367 | | - # cold Xcode build), and two 20-minute attempts still fit the job's 50 |
368 | | - # with the ~5m40s of setup and teardown around them. |
369 | | - - name: Run integration tests (attempt 1) |
370 | | - id: ios-integration |
371 | | - continue-on-error: true |
372 | | - timeout-minutes: 20 |
373 | | - # --flavor full so the integration-test app matches the production |
374 | | - # iOS scheme (bundle id + display name) rather than the develop one. |
375 | | - # The whole integration_test/ directory runs from a single build. |
376 | | - run: flutter test integration_test/ -d "$DEVICE_UDID" --flavor full --reporter expanded |
377 | | - |
378 | | - # Retry once: integration tests occasionally flake on a slow simulator |
379 | | - # (a dropped frame, a transient launch hiccup, a build that never |
380 | | - # returns). A second attempt on the same booted sim clears those without |
381 | | - # masking a real failure, since a genuine break fails both attempts. |
382 | | - - name: Run integration tests (retry once) |
383 | | - if: steps.ios-integration.outcome == 'failure' |
384 | | - timeout-minutes: 20 |
| 318 | + - name: Require one of the two runners to have passed |
385 | 319 | run: | |
386 | | - echo "::warning::iOS integration tests failed or hung; retrying (attempt 2 of 2)" |
387 | | - flutter test integration_test/ -d "$DEVICE_UDID" --flavor full --reporter expanded |
388 | | -
|
| 320 | + first='${{ needs.ios-integration-tests.outputs.passed }}' |
| 321 | + second='${{ needs.ios-integration-tests-retry.outputs.passed }}' |
| 322 | + echo "first runner: ${first:-<no verdict — the job was killed>}" |
| 323 | + echo "second runner: ${second:-<not run>}" |
| 324 | + if [ "$first" = 'true' ] || [ "$second" = 'true' ]; then |
| 325 | + exit 0 |
| 326 | + fi |
| 327 | + echo "::error::iOS integration tests failed on two independent runners — treat this as a real break, not a flake." |
| 328 | + exit 1 |
389 | 329 | android-build: |
390 | 330 | runs-on: ubuntu-latest |
391 | 331 | permissions: |
@@ -611,7 +551,7 @@ jobs: |
611 | 551 | needs: |
612 | 552 | - linux-checks |
613 | 553 | - ios-build |
614 | | - - ios-integration-tests |
| 554 | + - ios-integration-tests-result |
615 | 555 | - android-build |
616 | 556 | - android-integration-tests |
617 | 557 | runs-on: macos-26 |
@@ -773,7 +713,7 @@ jobs: |
773 | 713 | needs: |
774 | 714 | - linux-checks |
775 | 715 | - ios-build |
776 | | - - ios-integration-tests |
| 716 | + - ios-integration-tests-result |
777 | 717 | - android-build |
778 | 718 | - android-integration-tests |
779 | 719 | runs-on: ubuntu-latest |
@@ -926,12 +866,80 @@ jobs: |
926 | 866 | name: android-aab |
927 | 867 | path: release-assets/android |
928 | 868 |
|
| 869 | + # Tolerates exactly one upstream defect, and nothing else. |
| 870 | + # |
| 871 | + # Since 2.1.0 the bundle declares `android.permission.health.*`, and the |
| 872 | + # Play Publishing API rejects health-permission bundles at |
| 873 | + # `EditService.Validate`/`Commit` with "You must let us know whether your |
| 874 | + # app includes any health features" — **regardless of the declaration**, |
| 875 | + # which is complete and was re-verified across four console surfaces |
| 876 | + # (#942). Uploading the same bundle by hand through the console asks no |
| 877 | + # health question at all and succeeds. It is a known, open, unowned |
| 878 | + # defect: fastlane#22204 was closed unfixed, fastlane#27960 reopened it, |
| 879 | + # and expo/eas-cli#3275 reports it from a different toolchain, which |
| 880 | + # rules out fastlane's request construction as the sole cause. |
| 881 | + # |
| 882 | + # So the attempt stays. Deleting the step would go silently green and |
| 883 | + # nobody would notice the day Google fixes it; `continue-on-error` would |
| 884 | + # swallow real failures too. Matching the one error string keeps every |
| 885 | + # other failure — bad credentials, a consumed versionCode, a rejected |
| 886 | + # bundle — loud, and lets this heal itself with no further change. |
| 887 | + # |
| 888 | + # Note what a failing upload also costs, learned the hard way in #959: |
| 889 | + # the API never reaches the point where Play returns its release |
| 890 | + # warnings, so a minSdk bump that dropped 1,399 device models went |
| 891 | + # unseen for eight days. The manual upload is where those warnings |
| 892 | + # appear, which is why the summary below insists on reading them. |
929 | 893 | - name: Upload Android App Bundle to Google Play Internal Testing via Fastlane |
930 | 894 | working-directory: android |
931 | | - run: bundle exec fastlane android internal |
932 | 895 | env: |
933 | 896 | GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} |
934 | 897 | ANDROID_AAB_PATH: ${{ github.workspace }}/release-assets/android/app-release.aab |
| 898 | + run: | |
| 899 | + set +e |
| 900 | + log="$RUNNER_TEMP/play-upload.log" |
| 901 | + bundle exec fastlane android internal 2>&1 | tee "$log" |
| 902 | + status=${PIPESTATUS[0]} |
| 903 | + set -e |
| 904 | +
|
| 905 | + [ "$status" -eq 0 ] && exit 0 |
| 906 | +
|
| 907 | + if ! grep -qF \ |
| 908 | + 'You must let us know whether your app includes any health features' \ |
| 909 | + "$log"; then |
| 910 | + echo "::error::Play upload failed for a reason other than the known health-declaration defect (#942). Not tolerated." |
| 911 | + exit "$status" |
| 912 | + fi |
| 913 | +
|
| 914 | + echo "::warning::Play rejected the API upload with the known health-declaration defect (#942). The Android bundle needs uploading by hand; iOS is unaffected." |
| 915 | +
|
| 916 | + { |
| 917 | + echo '### Android upload needs doing by hand' |
| 918 | + echo |
| 919 | + echo 'The Play Publishing API rejected this bundle with:' |
| 920 | + echo |
| 921 | + echo '> Google Api Error: Invalid request - You must let us know whether your app includes any health features.' |
| 922 | + echo |
| 923 | + echo 'This is [#942](https://github.com/simonoppowa/OpenNutriTracker/issues/942):' |
| 924 | + echo 'a known upstream defect, not a missing declaration. The declaration is' |
| 925 | + echo 'complete, and the console asks no health question when the same bundle is' |
| 926 | + echo 'uploaded by hand.' |
| 927 | + echo |
| 928 | + echo '**To finish the release:**' |
| 929 | + echo |
| 930 | + echo '1. Download the `android-aab` artifact from this run, or take the AAB' |
| 931 | + echo ' attached to the GitHub release this workflow creates.' |
| 932 | + echo '2. Play Console → Testen und veröffentlichen → Interner Test →' |
| 933 | + echo ' **Neuen Release erstellen**, and drop the AAB in.' |
| 934 | + echo '3. **Read the warnings on the review step before publishing.** This is the' |
| 935 | + echo ' only place Play reports them, and a failing API upload never gets far' |
| 936 | + echo ' enough to return them — which is how the minSdk regression in' |
| 937 | + echo ' [#959](https://github.com/simonoppowa/OpenNutriTracker/issues/959) went' |
| 938 | + echo ' unnoticed for eight days.' |
| 939 | + echo |
| 940 | + echo 'This step will start passing on its own once Google fixes the API; nothing' |
| 941 | + echo 'here needs changing when that happens.' |
| 942 | + } >> "$GITHUB_STEP_SUMMARY" |
935 | 943 |
|
936 | 944 | github-release: |
937 | 945 | if: | |
|
0 commit comments