Skip to content

Commit 533d783

Browse files
authored
Merge pull request #988 from simonoppowa/release/2.2.0
Release 2.2.0: AI-assisted meal logging, workout import, and the 2.1.0 work that never shipped
2 parents fcc8f99 + fcb2323 commit 533d783

135 files changed

Lines changed: 34103 additions & 908 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/default_workflow.yml

Lines changed: 129 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@ on:
1515
branches:
1616
- main
1717
- 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/**'
1842
workflow_dispatch:
1943

2044
jobs:
@@ -263,129 +287,45 @@ jobs:
263287
# App Store install and isn't what CI should validate.
264288
run: flutter build ios --no-codesign --release --flavor full
265289

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.
266296
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
294315
permissions:
295316
contents: read
296317
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
385319
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
389329
android-build:
390330
runs-on: ubuntu-latest
391331
permissions:
@@ -611,7 +551,7 @@ jobs:
611551
needs:
612552
- linux-checks
613553
- ios-build
614-
- ios-integration-tests
554+
- ios-integration-tests-result
615555
- android-build
616556
- android-integration-tests
617557
runs-on: macos-26
@@ -773,7 +713,7 @@ jobs:
773713
needs:
774714
- linux-checks
775715
- ios-build
776-
- ios-integration-tests
716+
- ios-integration-tests-result
777717
- android-build
778718
- android-integration-tests
779719
runs-on: ubuntu-latest
@@ -926,12 +866,80 @@ jobs:
926866
name: android-aab
927867
path: release-assets/android
928868

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.
929893
- name: Upload Android App Bundle to Google Play Internal Testing via Fastlane
930894
working-directory: android
931-
run: bundle exec fastlane android internal
932895
env:
933896
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
934897
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"
935943
936944
github-release:
937945
if: |

0 commit comments

Comments
 (0)