From 30167d45cd943201f4f35555cbeac6a496efe589 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 17:05:40 +0100 Subject: [PATCH 01/10] ci(repo): install the Android NDK before builds and retry a stalled e2e start Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .../actions/install-android-ndk/action.yml | 43 ++++++++++++++++ .github/workflows/e2e_test.yml | 2 + .github/workflows/stream_flutter_workflow.yml | 2 + e2e_app/fastlane/Fastfile | 51 ++++++++++++++++--- 4 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 .github/actions/install-android-ndk/action.yml diff --git a/.github/actions/install-android-ndk/action.yml b/.github/actions/install-android-ndk/action.yml new file mode 100644 index 0000000000..fb580d4c1e --- /dev/null +++ b/.github/actions/install-android-ndk/action.yml @@ -0,0 +1,43 @@ +name: 'Install Android NDK' +description: >- + Install the NDK and CMake that Flutter plugins with native code build with + (e.g. `jni`, used by `path_provider_android`), so Gradle doesn't download them + mid-build. Needs Flutter set up first. +runs: + using: "composite" + steps: + - name: Install the NDK and CMake + shell: bash + run: | + # The NDK Flutter sets for plugins (`flutter.ndkVersion`), read from the + # installed Flutter so an upgrade keeps it in step. + ndk=$(grep -oP 'ndkVersion: String = "\K[0-9.]+' \ + "$FLUTTER_ROOT/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt") + if [ -z "$ndk" ]; then + echo "::error::Could not read flutter.ndkVersion from the Flutter SDK" + exit 1 + fi + + # The CMake version the Android Gradle Plugin builds native code with. + cmake=3.22.1 + ndk_dir="$ANDROID_HOME/ndk/$ndk" + installed() { + [ -f "$ndk_dir/source.properties" ] && [ -x "$ANDROID_HOME/cmake/$cmake/bin/cmake" ] + } + + # sdkmanager unpacks the download itself. Gradle's own NDK download can + # arrive corrupt ("Archive is not a ZIP archive"), and then every retry + # of the build reuses the broken copy. + for attempt in 1 2 3; do + yes | sdkmanager --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true + installed && break + echo "::warning::Installing NDK $ndk and CMake $cmake failed (attempt $attempt); retrying" + rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" + sleep 15 + done + + if ! installed; then + echo "::error::Could not install NDK $ndk and CMake $cmake" + exit 1 + fi + echo "Installed NDK $ndk and CMake $cmake" diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index 326b680a84..ee40490d2f 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -110,6 +110,8 @@ jobs: - uses: ./.github/actions/setup-flutter + - uses: ./.github/actions/install-android-ndk + - uses: ./.github/actions/enable-kvm - name: Bootstrap diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 0f5c7f7884..16c061be2b 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -182,6 +182,8 @@ jobs: if: matrix.platform == 'ios' - uses: ./.github/actions/cache-gradle if: matrix.platform == 'android' + - uses: ./.github/actions/install-android-ndk + if: matrix.platform == 'android' - name: "Install Tools" run: flutter pub global activate melos - name: "Bootstrap Workspace" diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index d9eeb6c9d6..bd8f833439 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -240,6 +240,43 @@ def run_time_limit(target, selected, files) [(15 + (2 * [tests, 1].max)) * 60, 45 * 60].min end +# How long the app may take, once its APK is installed, to print anything. A healthy +# run reports its first test within seconds; seen on CI, an app that installed but +# never started a test would otherwise hold the run until its time limit. +STARTUP_LIMIT = 4 * 60 + +# Runs [cmd], whose output is copied to [log], and returns whether it succeeded. A run +# whose app installs but prints nothing within STARTUP_LIMIT is stopped and fails, +# after [on_stalled] is called. +def run_watching_startup(cmd, log, on_stalled:) + pid = Process.spawn(cmd, pgroup: true) + installed_at = nil + loop do + _, status = Process.wait2(pid, Process::WNOHANG) + return status.success? if status + + lines = File.exist?(log) ? File.readlines(log) : [] + install = lines.rindex { |line| line.include?('Installing build/') } + # Started: only the run's time limit applies from here. + return Process.wait2(pid).last.success? if install && install < lines.size - 1 + + installed_at ||= Time.now if install + if installed_at && Time.now - installed_at >= STARTUP_LIMIT + on_stalled.call + # The whole group: flutter, the tee pipeline and the `timeout` guard. + Process.kill('TERM', -pid) rescue nil + begin + Timeout.timeout(30) { Process.wait(pid) } + rescue Timeout::Error + Process.kill('KILL', -pid) rescue nil + Process.wait(pid) rescue nil + end + return false + end + sleep(5) + end +end + def boot_simulator(udid:) return if `xcrun simctl list devices booted`.to_s.include?(udid) return unless `xcrun simctl list devices`.to_s.include?(udid) @@ -499,12 +536,14 @@ lane :run_e2e_test do |options| # `timeout` signals its whole process group, so flutter and the tee pipeline go too. guard = timeout_bin.empty? ? '' : "#{timeout_bin} --kill-after=30 #{time_limit} " started_at = Time.now - ok = begin - sh("#{guard}bash -o pipefail -c #{Shellwords.escape(cmd)}") - true - rescue StandardError - false - end + ok = run_watching_startup( + "#{guard}bash -o pipefail -c #{Shellwords.escape(cmd)}", + attempt_log, + on_stalled: lambda { + UI.important("#{target} (attempt #{attempt}/#{retries + 1}): the app installed but no test " \ + "started within #{STARTUP_LIMIT / 60} min") + }, + ) if !ok && !guard.empty? && Time.now - started_at >= time_limit UI.important("#{target} (attempt #{attempt}/#{retries + 1}): killed after #{time_limit / 60} min " \ 'with no exit from flutter test; the device may have died') From e39d1d1cfbf96c84aec57fd37aa9aa3fac980d46 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 17:13:06 +0100 Subject: [PATCH 02/10] ci(repo): stop a stalled e2e start without a shell between the watchdog and timeout Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- e2e_app/fastlane/Fastfile | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index bd8f833439..14bcc205ec 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -245,11 +245,12 @@ end # never started a test would otherwise hold the run until its time limit. STARTUP_LIMIT = 4 * 60 -# Runs [cmd], whose output is copied to [log], and returns whether it succeeded. A run +# Runs [argv], whose output is copied to [log], and returns whether it succeeded. A run # whose app installs but prints nothing within STARTUP_LIMIT is stopped and fails, -# after [on_stalled] is called. -def run_watching_startup(cmd, log, on_stalled:) - pid = Process.spawn(cmd, pgroup: true) +# after [on_stalled] is called. [argv] runs without a shell in between, so its process +# group is the one `timeout` creates and stopping it reaches flutter too. +def run_watching_startup(argv, log, on_stalled:) + pid = Process.spawn(*argv, pgroup: true) installed_at = nil loop do _, status = Process.wait2(pid, Process::WNOHANG) @@ -534,10 +535,10 @@ lane :run_e2e_test do |options| "{ grep --line-buffered -vE 'ALLURE-(RESULT|ATTACH)::|E2E-TEST::' || true; }" time_limit = run_time_limit(target, selected, files) # `timeout` signals its whole process group, so flutter and the tee pipeline go too. - guard = timeout_bin.empty? ? '' : "#{timeout_bin} --kill-after=30 #{time_limit} " + guard = timeout_bin.empty? ? [] : [timeout_bin, '--kill-after=30', time_limit.to_s] started_at = Time.now ok = run_watching_startup( - "#{guard}bash -o pipefail -c #{Shellwords.escape(cmd)}", + [*guard, 'bash', '-o', 'pipefail', '-c', cmd], attempt_log, on_stalled: lambda { UI.important("#{target} (attempt #{attempt}/#{retries + 1}): the app installed but no test " \ From c48ae1d0f89f339cc2c37b66fe2728219f80f68e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 17:16:52 +0100 Subject: [PATCH 03/10] ci(repo): call sdkmanager by its path on the runner Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .github/actions/install-android-ndk/action.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/actions/install-android-ndk/action.yml b/.github/actions/install-android-ndk/action.yml index fb580d4c1e..2c6686c9ef 100644 --- a/.github/actions/install-android-ndk/action.yml +++ b/.github/actions/install-android-ndk/action.yml @@ -20,6 +20,12 @@ runs: # The CMake version the Android Gradle Plugin builds native code with. cmake=3.22.1 + # Not on the runner's PATH. + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdkmanager" ]; then + echo "::error::sdkmanager not found at $sdkmanager" + exit 1 + fi ndk_dir="$ANDROID_HOME/ndk/$ndk" installed() { [ -f "$ndk_dir/source.properties" ] && [ -x "$ANDROID_HOME/cmake/$cmake/bin/cmake" ] @@ -29,7 +35,7 @@ runs: # arrive corrupt ("Archive is not a ZIP archive"), and then every retry # of the build reuses the broken copy. for attempt in 1 2 3; do - yes | sdkmanager --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true + yes | "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true installed && break echo "::warning::Installing NDK $ndk and CMake $cmake failed (attempt $attempt); retrying" rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" From 688549261fce436caa8a000f3bb750d3f8a86ded Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 17:22:52 +0100 Subject: [PATCH 04/10] ci(repo): run the watched e2e attempt from the app folder Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- e2e_app/fastlane/Fastfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 14bcc205ec..2124d0f253 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -247,10 +247,11 @@ STARTUP_LIMIT = 4 * 60 # Runs [argv], whose output is copied to [log], and returns whether it succeeded. A run # whose app installs but prints nothing within STARTUP_LIMIT is stopped and fails, -# after [on_stalled] is called. [argv] runs without a shell in between, so its process -# group is the one `timeout` creates and stopping it reaches flutter too. +# after [on_stalled] is called. [argv] runs from the app folder, like `sh`, and without +# a shell in between, so its process group is the one `timeout` creates and stopping +# it reaches flutter too. def run_watching_startup(argv, log, on_stalled:) - pid = Process.spawn(*argv, pgroup: true) + pid = Process.spawn(*argv, pgroup: true, chdir: root_path) installed_at = nil loop do _, status = Process.wait2(pid, Process::WNOHANG) From 03192e95cb85765095df37b23cde774d438162ce Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 17:35:14 +0100 Subject: [PATCH 05/10] ci(repo): bound sdkmanager, skip it when installed, and keep a spawn failure a failed attempt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .github/actions/install-android-ndk/action.yml | 13 ++++++++----- e2e_app/fastlane/Fastfile | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/actions/install-android-ndk/action.yml b/.github/actions/install-android-ndk/action.yml index 2c6686c9ef..daa66a59e1 100644 --- a/.github/actions/install-android-ndk/action.yml +++ b/.github/actions/install-android-ndk/action.yml @@ -12,7 +12,7 @@ runs: # The NDK Flutter sets for plugins (`flutter.ndkVersion`), read from the # installed Flutter so an upgrade keeps it in step. ndk=$(grep -oP 'ndkVersion: String = "\K[0-9.]+' \ - "$FLUTTER_ROOT/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt") + "$FLUTTER_ROOT/packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt" || true) if [ -z "$ndk" ]; then echo "::error::Could not read flutter.ndkVersion from the Flutter SDK" exit 1 @@ -34,12 +34,15 @@ runs: # sdkmanager unpacks the download itself. Gradle's own NDK download can # arrive corrupt ("Archive is not a ZIP archive"), and then every retry # of the build reuses the broken copy. + # A stalled sdkmanager fails its attempt instead of holding the job. for attempt in 1 2 3; do - yes | "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true installed && break - echo "::warning::Installing NDK $ndk and CMake $cmake failed (attempt $attempt); retrying" - rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" - sleep 15 + if [ "$attempt" -gt 1 ]; then + echo "::warning::Installing NDK $ndk and CMake $cmake failed; retrying (attempt $attempt)" + rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" + sleep 15 + fi + yes | timeout 600 "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true done if ! installed; then diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 2124d0f253..9cfabd56fc 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -240,18 +240,25 @@ def run_time_limit(target, selected, files) [(15 + (2 * [tests, 1].max)) * 60, 45 * 60].min end -# How long the app may take, once its APK is installed, to print anything. A healthy -# run reports its first test within seconds; seen on CI, an app that installed but -# never started a test would otherwise hold the run until its time limit. +# How long the app may take, once its APK starts installing, to print anything. A +# healthy run installs and reports its first test within seconds; seen on CI, an app +# that installed but never started a test would otherwise hold the run until its time +# limit. STARTUP_LIMIT = 4 * 60 # Runs [argv], whose output is copied to [log], and returns whether it succeeded. A run # whose app installs but prints nothing within STARTUP_LIMIT is stopped and fails, -# after [on_stalled] is called. [argv] runs from the app folder, like `sh`, and without +# after [on_stalled] is called, and so does one that can't be started. [argv] runs from the app folder, like `sh`, and without # a shell in between, so its process group is the one `timeout` creates and stopping # it reaches flutter too. def run_watching_startup(argv, log, on_stalled:) - pid = Process.spawn(*argv, pgroup: true, chdir: root_path) + pid = + begin + Process.spawn(*argv, pgroup: true, chdir: root_path) + rescue StandardError => e + UI.important("Could not start flutter test: #{e.message}") + return false + end installed_at = nil loop do _, status = Process.wait2(pid, Process::WNOHANG) From 1ae5825c48f08e404cb2cb6363e0d84bf49b89f5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 18:25:49 +0100 Subject: [PATCH 06/10] ci(repo): keep the NDK install retries inside the build job's time limit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .github/actions/install-android-ndk/action.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-android-ndk/action.yml b/.github/actions/install-android-ndk/action.yml index daa66a59e1..b2158ce2b0 100644 --- a/.github/actions/install-android-ndk/action.yml +++ b/.github/actions/install-android-ndk/action.yml @@ -34,7 +34,8 @@ runs: # sdkmanager unpacks the download itself. Gradle's own NDK download can # arrive corrupt ("Archive is not a ZIP archive"), and then every retry # of the build reuses the broken copy. - # A stalled sdkmanager fails its attempt instead of holding the job. + # A stalled sdkmanager fails its attempt instead of holding the job; three + # 5-minute attempts stay inside the 30-minute build job. for attempt in 1 2 3; do installed && break if [ "$attempt" -gt 1 ]; then @@ -42,7 +43,7 @@ runs: rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" sleep 15 fi - yes | timeout 600 "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true + yes | timeout 300 "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true done if ! installed; then From 3b105a001b2bfa6c889d38b0c7a01356001114d4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 18:53:10 +0100 Subject: [PATCH 07/10] ci(repo): keep sdkmanager output for a failed install and tidy the new comments Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .github/actions/install-android-ndk/action.yml | 13 +++++++------ e2e_app/fastlane/Fastfile | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/actions/install-android-ndk/action.yml b/.github/actions/install-android-ndk/action.yml index b2158ce2b0..372ad2f88d 100644 --- a/.github/actions/install-android-ndk/action.yml +++ b/.github/actions/install-android-ndk/action.yml @@ -1,14 +1,14 @@ name: 'Install Android NDK' -description: >- - Install the NDK and CMake that Flutter plugins with native code build with - (e.g. `jni`, used by `path_provider_android`), so Gradle doesn't download them - mid-build. Needs Flutter set up first. +description: 'Install the NDK and CMake that Flutter plugins build native code with' runs: using: "composite" steps: - name: Install the NDK and CMake shell: bash run: | + # Plugins with native code (e.g. `jni`, used by `path_provider_android`) + # otherwise make Gradle download these mid-build. Needs Flutter set up. + # # The NDK Flutter sets for plugins (`flutter.ndkVersion`), read from the # installed Flutter so an upgrade keeps it in step. ndk=$(grep -oP 'ndkVersion: String = "\K[0-9.]+' \ @@ -20,7 +20,7 @@ runs: # The CMake version the Android Gradle Plugin builds native code with. cmake=3.22.1 - # Not on the runner's PATH. + # By full path, so the runner's own SDK is the one updated. sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" if [ ! -x "$sdkmanager" ]; then echo "::error::sdkmanager not found at $sdkmanager" @@ -43,10 +43,11 @@ runs: rm -rf "$ndk_dir" "$ANDROID_HOME/cmake/$cmake" "$ANDROID_HOME/.temp" sleep 15 fi - yes | timeout 300 "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > /dev/null || true + yes | timeout 300 "$sdkmanager" --install "ndk;$ndk" "cmake;$cmake" > "$RUNNER_TEMP/sdkmanager.log" 2>&1 || true done if ! installed; then + tail -n 30 "$RUNNER_TEMP/sdkmanager.log" echo "::error::Could not install NDK $ndk and CMake $cmake" exit 1 fi diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 9cfabd56fc..f4fdf1503f 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -246,11 +246,11 @@ end # limit. STARTUP_LIMIT = 4 * 60 -# Runs [argv], whose output is copied to [log], and returns whether it succeeded. A run -# whose app installs but prints nothing within STARTUP_LIMIT is stopped and fails, -# after [on_stalled] is called, and so does one that can't be started. [argv] runs from the app folder, like `sh`, and without -# a shell in between, so its process group is the one `timeout` creates and stopping -# it reaches flutter too. +# Runs [argv], whose output is copied to [log], and returns whether it succeeded. +# A run whose app installs but prints nothing within STARTUP_LIMIT calls +# [on_stalled], is stopped and fails; a run that can't be started fails too. +# [argv] runs from the app folder with no shell in between, so its process group +# is the one `timeout` leads and stopping it reaches flutter too. def run_watching_startup(argv, log, on_stalled:) pid = begin @@ -548,7 +548,7 @@ lane :run_e2e_test do |options| ok = run_watching_startup( [*guard, 'bash', '-o', 'pipefail', '-c', cmd], attempt_log, - on_stalled: lambda { + on_stalled: -> { UI.important("#{target} (attempt #{attempt}/#{retries + 1}): the app installed but no test " \ "started within #{STARTUP_LIMIT / 60} min") }, From be581d888f6990798400b7c7d693063c217284bf Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 2 Nov 2026 19:33:17 +0100 Subject: [PATCH 08/10] ci(repo): end the e2e startup watch only once the app reports a test Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- e2e_app/fastlane/Fastfile | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index f4fdf1503f..3a0feb8227 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -240,14 +240,17 @@ def run_time_limit(target, selected, files) [(15 + (2 * [tests, 1].max)) * 60, 45 * 60].min end -# How long the app may take, once its APK starts installing, to print anything. A -# healthy run installs and reports its first test within seconds; seen on CI, an app +# How long the app may take, once its APK starts installing, to report its first +# test. A healthy run installs and reports its first test within seconds; seen on CI, an app # that installed but never started a test would otherwise hold the run until its time # limit. STARTUP_LIMIT = 4 * 60 +# Printed by the app as its tests run. +TEST_REPORTED = /ALLURE-(RESULT|ATTACH)::|E2E-TEST::/ + # Runs [argv], whose output is copied to [log], and returns whether it succeeded. -# A run whose app installs but prints nothing within STARTUP_LIMIT calls +# A run whose app installs but reports no test within STARTUP_LIMIT calls # [on_stalled], is stopped and fails; a run that can't be started fails too. # [argv] runs from the app folder with no shell in between, so its process group # is the one `timeout` leads and stopping it reaches flutter too. @@ -266,8 +269,9 @@ def run_watching_startup(argv, log, on_stalled:) lines = File.exist?(log) ? File.readlines(log) : [] install = lines.rindex { |line| line.include?('Installing build/') } - # Started: only the run's time limit applies from here. - return Process.wait2(pid).last.success? if install && install < lines.size - 1 + reported = install && lines.drop(install + 1).any? { |line| line.match?(TEST_REPORTED) } + # A test reported: only the run's time limit applies from here. + return Process.wait2(pid).last.success? if reported installed_at ||= Time.now if install if installed_at && Time.now - installed_at >= STARTUP_LIMIT From cce365e6c7dc4d9fa31e3bb3d5022cc1ac31a52b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 02:16:54 +0100 Subject: [PATCH 09/10] ci(repo): end the e2e startup watch when the first test starts Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- e2e_app/fastlane/Fastfile | 14 +++++++------- e2e_app/integration_test/allure/allure.dart | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 3a0feb8227..ff3b428d03 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -240,17 +240,17 @@ def run_time_limit(target, selected, files) [(15 + (2 * [tests, 1].max)) * 60, 45 * 60].min end -# How long the app may take, once its APK starts installing, to report its first -# test. A healthy run installs and reports its first test within seconds; seen on CI, an app -# that installed but never started a test would otherwise hold the run until its time -# limit. +# How long the app may take, once its APK starts installing, to start its first +# test. A healthy run installs and starts its first test within seconds; seen on CI, +# an app that installed but never started a test would otherwise hold the run until +# its time limit. STARTUP_LIMIT = 4 * 60 -# Printed by the app as its tests run. +# Printed by the app as each test starts and ends. TEST_REPORTED = /ALLURE-(RESULT|ATTACH)::|E2E-TEST::/ # Runs [argv], whose output is copied to [log], and returns whether it succeeded. -# A run whose app installs but reports no test within STARTUP_LIMIT calls +# A run whose app installs but starts no test within STARTUP_LIMIT calls # [on_stalled], is stopped and fails; a run that can't be started fails too. # [argv] runs from the app folder with no shell in between, so its process group # is the one `timeout` leads and stopping it reaches flutter too. @@ -270,7 +270,7 @@ def run_watching_startup(argv, log, on_stalled:) lines = File.exist?(log) ? File.readlines(log) : [] install = lines.rindex { |line| line.include?('Installing build/') } reported = install && lines.drop(install + 1).any? { |line| line.match?(TEST_REPORTED) } - # A test reported: only the run's time limit applies from here. + # A test started: only the run's time limit applies from here. return Process.wait2(pid).last.success? if reported installed_at ||= Time.now if install diff --git a/e2e_app/integration_test/allure/allure.dart b/e2e_app/integration_test/allure/allure.dart index 98ab1a9225..4ade9bc435 100644 --- a/e2e_app/integration_test/allure/allure.dart +++ b/e2e_app/integration_test/allure/allure.dart @@ -7,7 +7,8 @@ const allureResultMarker = 'ALLURE-RESULT::'; const allureAttachmentMarker = 'ALLURE-ATTACH::'; /// Lightweight per-test outcome marker, emitted once per test as -/// `E2E-TEST::::`. The Fastlane lane parses these to +/// `E2E-TEST::::`, and as `E2E-TEST::started::…` when the +/// test starts. The Fastlane lane parses these to /// retry **only** the tests that failed in an attempt (rather than re-running /// the whole file); the name is base64-encoded to survive any characters in /// the test description. Separate from [allureResultMarker] (which carries the @@ -110,6 +111,9 @@ class Allure { for (final entry in labels.entries) {'name': entry.key, 'value': entry.value}, ], ); + + // Lets the Fastlane lane tell a test that runs long from an app that never started one. + print('${allureTestStatusMarker}started::${base64.encode(utf8.encode(name))}'); } /// Records a BDD step marker. The statements that run after this call, up to From 652241bbfa0d6ddcc8217ede5522ee4e8e241370 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 3 Nov 2026 11:18:27 +0100 Subject: [PATCH 10/10] ci(repo): install the Android NDK in the nightly e2e and distribution builds Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --- .github/workflows/distribute_external.yml | 2 ++ .github/workflows/distribute_internal.yml | 2 ++ .github/workflows/e2e_test_cron.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/distribute_external.yml b/.github/workflows/distribute_external.yml index 774032d694..aad833cc65 100644 --- a/.github/workflows/distribute_external.yml +++ b/.github/workflows/distribute_external.yml @@ -69,6 +69,8 @@ jobs: - uses: ./.github/actions/setup-flutter + - uses: ./.github/actions/install-android-ndk + - uses: ./.github/actions/setup-ruby - name: Distribute to S3 diff --git a/.github/workflows/distribute_internal.yml b/.github/workflows/distribute_internal.yml index 907f83a5dc..4fa787c17e 100644 --- a/.github/workflows/distribute_internal.yml +++ b/.github/workflows/distribute_internal.yml @@ -73,6 +73,8 @@ jobs: - uses: ./.github/actions/setup-flutter + - uses: ./.github/actions/install-android-ndk + - name: "Install Tools" run: flutter pub global activate melos diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index fe6ca4859f..09e99dc534 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -54,6 +54,8 @@ jobs: - uses: ./.github/actions/setup-flutter + - uses: ./.github/actions/install-android-ndk + - uses: ./.github/actions/enable-kvm # The emulator, Gradle and flutter test together can briefly need more than the runner's