From bc1f706d4bcd14825bc6e314f266f942b84bd5c7 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Sun, 25 Oct 2026 12:14:25 +0100 Subject: [PATCH 1/3] fix(ios): wait for simulator logs before launching tests --- .github/actions/setup-flutter/README.md | 7 + .github/patches/README.md | 20 ++ .../patches/flutter-ios-log-readiness.patch | 339 ++++++++++++++++++ .github/scripts/prepare_ios_flutter.sh | 55 +++ .github/scripts/tests/test_ios_flutter.py | 78 ++++ .github/workflows/pr_check_ios.yml | 11 +- .github/workflows/release_workflow.yml | 10 +- .github/workflows/simulator_contract.yml | 22 +- 8 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 .github/patches/README.md create mode 100644 .github/patches/flutter-ios-log-readiness.patch create mode 100644 .github/scripts/prepare_ios_flutter.sh create mode 100644 .github/scripts/tests/test_ios_flutter.py diff --git a/.github/actions/setup-flutter/README.md b/.github/actions/setup-flutter/README.md index 3ae2b72b..390bffe5 100644 --- a/.github/actions/setup-flutter/README.md +++ b/.github/actions/setup-flutter/README.md @@ -24,6 +24,13 @@ and removes only its own simulator UUID. Setup does not install Xcode or accept Apple agreements. The reconciliation keeps develop's existing test matrices, coverage thresholds and complete package publication workflow. +The iOS integration and release-candidate jobs additionally use a disposable +copy of Flutter 3.47.2 with the official simulator log-readiness fix backported +from flutter/flutter#193142. The SDK in the shared tool cache is unchanged. +The job's existing launcher selects the private copy through `FLUTTER_ROOT`, +and cleanup removes only that job's marked directory. See +[`../../patches/README.md`](../../patches/README.md) for provenance and removal. + The Linux dependency action reuses installed packages and downloads missing packages into a private prefix with their dependency closure. It never runs sudo, package maintainer scripts, dpkg repair or host package installation. diff --git a/.github/patches/README.md b/.github/patches/README.md new file mode 100644 index 00000000..02a0da01 --- /dev/null +++ b/.github/patches/README.md @@ -0,0 +1,20 @@ +# Flutter iOS log readiness backport + +`flutter-ios-log-readiness.patch` backports Flutter's official fix and its three +regression tests from commit `c3ed85ef987759ee54a6bce275490cde2a238dc1` +([flutter/flutter#193142](https://github.com/flutter/flutter/pull/193142)). +The first hunk's unchanged context is shortened to fit stable 3.47.2, and the +test fixture omits the newer simulator constructor's `cpuArch` argument. +The production Dart change is identical to upstream. Flutter's source and +tests retain their existing BSD license headers. + +The simulator log stream must deliver its readiness header before Flutter +launches the test app. Otherwise it can miss the app's one-time VM service URL +and wait indefinitely after a successful build (flutter/flutter#181771). + +The setup script accepts only the reviewed Flutter 3.47.2 commit +`d3b14c876900e553bc736ca19295fc09e3853e8e`. It patches a job-owned SDK copy, +rebuilds that copy's tool snapshot on its next invocation and leaves the shared +SDK unchanged. The original tests and the 900-second integration limit remain. +CI runs the upstream delayed-log-stream regression tests against the patched +SDK. Remove this backport when the selected stable SDK includes the fix. diff --git a/.github/patches/flutter-ios-log-readiness.patch b/.github/patches/flutter-ios-log-readiness.patch new file mode 100644 index 00000000..70f57f2e --- /dev/null +++ b/.github/patches/flutter-ios-log-readiness.patch @@ -0,0 +1,339 @@ +diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart +--- a/packages/flutter_tools/lib/src/ios/simulators.dart ++++ b/packages/flutter_tools/lib/src/ios/simulators.dart +@@ -398,4 +398,4 @@ class IOSSimulator extends Device { +- final _logReaders = {}; ++ final _logReaders = {}; + _IOSSimulatorDevicePortForwarder? _portForwarder; + + @override +@@ -496,13 +496,25 @@ class IOSSimulator extends Device { + + ProtocolDiscovery? vmServiceDiscovery; + if (debuggingOptions.debuggingEnabled) { ++ final _IOSSimulatorLogReader logReader = getLogReader(app: package); + vmServiceDiscovery = ProtocolDiscovery.vmService( +- getLogReader(app: package), ++ logReader, + ipv6: debuggingOptions.ipv6, + hostPort: debuggingOptions.hostVmServicePort, + devicePort: debuggingOptions.deviceVmServicePort, + logger: globals.logger, + ); ++ // Launch the app only after the log stream is ready; otherwise the ++ // Dart VM Service URL it logs on startup can be missed. ++ const warningDelay = Duration(seconds: 30); ++ final timer = Timer(warningDelay, () { ++ globals.printError( ++ 'The iOS simulator log stream did not start after ${warningDelay.inSeconds} seconds. ' ++ 'This is taking much longer than expected...', ++ ); ++ }); ++ await logReader._ready.future; ++ timer.cancel(); + } + + // Launch the updated application in the simulator. +@@ -630,7 +642,7 @@ class IOSSimulator extends Device { + } + + @override +- DeviceLogReader getLogReader({covariant IOSApp? app, bool includePastLogs = false}) { ++ _IOSSimulatorLogReader getLogReader({covariant IOSApp? app, bool includePastLogs = false}) { + assert(!includePastLogs, 'Past log reading not supported on iOS simulators.'); + return _logReaders.putIfAbsent(app, () => _IOSSimulatorLogReader(this, app)); + } +@@ -857,6 +869,14 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { + onCancel: _stop, + ); + ++ /// Completes once the log stream is ready to deliver events, or has exited. ++ /// ++ /// `log stream` is ready once it prints its first line (a "Filtering the log ++ /// data using ..." header). ++ /// ++ /// Recreated on every listen, since each one starts a new `log stream`. ++ var _ready = Completer(); ++ + @override + @visibleForTesting + StreamController get linesController => _linesController; +@@ -872,10 +892,18 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { + String get name => device.name; + + Future _start() async { ++ // Use a local so that a previous process exiting late cannot complete it. ++ _ready = Completer(); ++ final Completer ready = _ready; + // Unified logging iOS 11 and greater (introduced in iOS 10). + if (await device.sdkMajorVersion >= 11) { + _deviceProcess = await launchDeviceUnifiedLogging(device, _appName); +- _deviceProcess?.stdout.transform(utf8LineDecoder).listen(_onUnifiedLoggingLine); ++ _deviceProcess?.stdout.transform(utf8LineDecoder).listen((String line) { ++ if (!ready.isCompleted) { ++ ready.complete(); ++ } ++ _onUnifiedLoggingLine(line); ++ }); + _deviceProcess?.stderr.transform(utf8LineDecoder).listen(_onUnifiedLoggingLine); + } else { + // Fall back to syslog parsing. +@@ -897,6 +925,9 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { + // cleanup in the callback. + unawaited( + _deviceProcess?.exitCode.whenComplete(() { ++ if (!ready.isCompleted) { ++ ready.complete(); ++ } + if (_linesController.hasListener) { + _linesController.close(); + } +diff --git a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart +--- a/packages/flutter_tools/test/general.shard/ios/simulators_test.dart ++++ b/packages/flutter_tools/test/general.shard/ios/simulators_test.dart +@@ -4,6 +4,7 @@ + + import 'dart:async'; + ++import 'package:fake_async/fake_async.dart'; + import 'package:file/memory.dart'; + import 'package:flutter_tools/src/base/common.dart'; + import 'package:flutter_tools/src/base/file_system.dart'; +@@ -14,6 +15,7 @@ import 'package:flutter_tools/src/base/platform.dart'; + import 'package:flutter_tools/src/base/process.dart'; + import 'package:flutter_tools/src/base/version.dart'; + import 'package:flutter_tools/src/build_info.dart'; ++import 'package:flutter_tools/src/convert.dart'; + import 'package:flutter_tools/src/devfs.dart'; + import 'package:flutter_tools/src/device.dart'; + import 'package:flutter_tools/src/device_port_forwarder.dart'; +@@ -29,7 +31,7 @@ import 'package:test/fake.dart'; + import '../../src/common.dart'; + import '../../src/context.dart'; + import '../../src/fake_process_manager.dart'; +-import '../../src/fakes.dart'; ++import '../../src/fakes.dart' show FakeLogger, FakePlistParser; + + final Platform macosPlatform = FakePlatform( + operatingSystem: 'macos', +@@ -1679,6 +1681,182 @@ Dec 20 17:04:32 md32-11-vm1 Another App[88374]: Ignore this text''', + Xcode: () => xcode, + }, + ); ++ ++ group('waiting for the log stream', () { ++ final logStreamCommand = [ ++ 'xcrun', ++ 'simctl', ++ 'spawn', ++ 'x', ++ 'log', ++ 'stream', ++ '--style', ++ 'json', ++ '--predicate', ++ RegExp('.*'), ++ ]; ++ ++ late FakeProcessManager processManager; ++ ++ setUp(() { ++ processManager = FakeProcessManager.empty(); ++ testPlistParser.setProperty('CFBundleIdentifier', 'correct'); ++ }); ++ ++ IOSSimulator buildDevice() => IOSSimulator( ++ 'x', ++ name: 'iPhone SE', ++ simulatorCategory: 'iOS 11.2', ++ simControl: simControl, ++ logger: logger, ++ ); ++ ++ IOSApp buildPackage() => PrebuiltIOSApp( ++ projectBundleId: 'correct', ++ bundleName: 'name', ++ uncompressedBundle: globals.fs.currentDirectory, ++ applicationPackage: globals.fs.currentDirectory, ++ ); ++ ++ testUsingContext( ++ 'startApp waits for the log stream to be ready before each launch', ++ () { ++ // Regression test for https://github.com/flutter/flutter/issues/181771. ++ fakeAsync((FakeAsync async) { ++ final firstLogProcess = FakeStreamingProcess(); ++ final secondLogProcess = FakeStreamingProcess(); ++ processManager.addCommand( ++ FakeCommand(command: logStreamCommand, process: firstLogProcess), ++ ); ++ processManager.addCommand( ++ FakeCommand(command: logStreamCommand, process: secondLogProcess), ++ ); ++ final IOSSimulator device = buildDevice(); ++ final IOSApp package = buildPackage(); ++ ++ device.startApp( ++ package, ++ prebuiltApplication: true, ++ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), ++ ); ++ async.elapse(const Duration(seconds: 1)); ++ expect(simControl.requests, isEmpty); ++ ++ firstLogProcess.write('Filtering the log data using "type == 1024"\n'); ++ async.flushMicrotasks(); ++ expect(simControl.requests, hasLength(1)); ++ ++ firstLogProcess.write( ++ '"eventMessage" : "The Dart VM service is listening on http://127.0.0.1:1234/abcdef/"\n', ++ ); ++ async.elapse(const Duration(seconds: 1)); ++ ++ // The first launch stopped the log stream, so this one starts a new one. ++ device.startApp( ++ package, ++ prebuiltApplication: true, ++ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), ++ ); ++ async.elapse(const Duration(seconds: 1)); ++ expect(simControl.requests, hasLength(1)); ++ ++ secondLogProcess.write('Filtering the log data using "type == 1024"\n'); ++ async.flushMicrotasks(); ++ expect(simControl.requests, hasLength(2)); ++ ++ async.elapse(const Duration(seconds: 30)); ++ expect(logger.errorText, isEmpty); ++ expect(processManager, hasNoRemainingExpectations); ++ }); ++ }, ++ overrides: { ++ PlistParser: () => testPlistParser, ++ FileSystem: () => fileSystem, ++ ProcessManager: () => processManager, ++ Logger: () => logger, ++ Xcode: () => xcode, ++ }, ++ ); ++ ++ testUsingContext( ++ 'startApp finds the VM Service when the log reader is listened to before startApp', ++ () async { ++ // `flutter run` listens to the log reader to echo logs before it calls startApp. ++ final logProcess = FakeStreamingProcess(); ++ processManager.addCommand( ++ FakeCommand( ++ command: logStreamCommand, ++ onRun: (_) => logProcess.write('Filtering the log data using "type == 1024"\n'), ++ process: logProcess, ++ ), ++ ); ++ simControl = FakeSimControl( ++ onLaunch: () { ++ logProcess.write( ++ '"eventMessage" : "The Dart VM service is listening on http://127.0.0.1:1234/abcdef/"\n', ++ ); ++ }, ++ ); ++ final IOSSimulator device = buildDevice(); ++ final IOSApp package = buildPackage(); ++ final echoedLines = []; ++ device.getLogReader(app: package).logLines.listen(echoedLines.add); ++ ++ final LaunchResult result = await device.startApp( ++ package, ++ prebuiltApplication: true, ++ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), ++ ); ++ ++ expect(result.vmServiceUri, Uri.parse('http://127.0.0.1:1234/abcdef/')); ++ expect(echoedLines, [ ++ 'The Dart VM service is listening on http://127.0.0.1:1234/abcdef/', ++ ]); ++ expect(processManager, hasNoRemainingExpectations); ++ }, ++ overrides: { ++ PlistParser: () => testPlistParser, ++ FileSystem: () => fileSystem, ++ ProcessManager: () => processManager, ++ Logger: () => logger, ++ Xcode: () => xcode, ++ }, ++ ); ++ ++ testUsingContext( ++ 'startApp warns and keeps waiting if the log stream is slow to become ready', ++ () { ++ fakeAsync((FakeAsync async) { ++ final logProcess = FakeStreamingProcess(); ++ processManager.addCommand(FakeCommand(command: logStreamCommand, process: logProcess)); ++ ++ buildDevice().startApp( ++ buildPackage(), ++ prebuiltApplication: true, ++ debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), ++ ); ++ async.elapse(const Duration(seconds: 30)); ++ expect( ++ logger.errorText, ++ contains('The iOS simulator log stream did not start after 30 seconds.'), ++ ); ++ expect(simControl.requests, isEmpty); ++ ++ logProcess.write('Filtering the log data using "type == 1024"\n'); ++ async.flushMicrotasks(); ++ expect(simControl.requests, hasLength(1)); ++ expect(processManager, hasNoRemainingExpectations); ++ }); ++ }, ++ overrides: { ++ PlistParser: () => testPlistParser, ++ FileSystem: () => fileSystem, ++ ProcessManager: () => processManager, ++ Logger: () => logger, ++ Xcode: () => xcode, ++ }, ++ ); ++ }); + }); + + group('IOSDevice.isSupportedForProject', () { +@@ -1797,14 +1975,19 @@ class FakeIosProject extends Fake implements IosProject { + } + + class FakeSimControl extends Fake implements SimControl { ++ FakeSimControl({this.onLaunch}); ++ + final requests = []; + ++ final void Function()? onLaunch; ++ + @override + Future launch( + String deviceId, + String appIdentifier, [ + List? launchArgs, + ]) async { ++ onLaunch?.call(); + requests.add(LaunchRequest(appIdentifier, launchArgs)); + return RunResult(ProcessResult(0, 0, '', ''), ['test']); + } +@@ -1815,5 +1998,17 @@ class FakeSimControl extends Fake implements SimControl { + } + } + ++/// A [FakeProcess] that keeps running, and whose stdout is written by the test. ++class FakeStreamingProcess extends FakeProcess { ++ FakeStreamingProcess() : super(completer: Completer()); ++ ++ final _stdoutController = StreamController>(); ++ ++ @override ++ Stream> get stdout => _stdoutController.stream; ++ ++ void write(String output) => _stdoutController.add(utf8.encode(output)); ++} ++ + class LaunchRequest { + const LaunchRequest(this.appIdentifier, this.launchArgs); diff --git a/.github/scripts/prepare_ios_flutter.sh b/.github/scripts/prepare_ios_flutter.sh new file mode 100644 index 00000000..bf641eb6 --- /dev/null +++ b/.github/scripts/prepare_ios_flutter.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Backport flutter/flutter#193142 into a disposable SDK, never the shared SDK. +set -euo pipefail + +task_temp="$(cd "${RUNNER_TEMP:?RUNNER_TEMP is required}" && pwd -P)" +expected_revision=d3b14c876900e553bc736ca19295fc09e3853e8e + +if [[ "${1:-}" == --cleanup ]]; then + owned="${ZSTANDARD_IOS_FLUTTER_DIR:?The owned SDK directory is required}" + if [[ "${owned%/*}" != "$task_temp" || + "${owned##*/}" != zstandard-ios-flutter.* || + -L "$owned" || ! -f "$owned/.zstandard-ios-flutter" ]]; then + echo 'Refusing to remove a directory not owned by this iOS SDK job.' >&2 + exit 1 + fi + if [[ "$(cat "$owned/.zstandard-ios-flutter")" != "$expected_revision" ]]; then + echo 'Refusing to remove an iOS SDK directory with an unknown marker.' >&2 + exit 1 + fi + rm -rf -- "$owned" || exit 1 + exit 0 +fi + +sdk="$(cd "${FLUTTER_ROOT:?FLUTTER_ROOT is required}" && pwd -P)" +[[ "$(git -C "$sdk" rev-parse HEAD)" == "$expected_revision" ]] || { + echo 'Review the iOS log-readiness backport before changing the Flutter SDK.' >&2 + exit 1 +} +git -C "$sdk" diff --exit-code HEAD -- \ + packages/flutter_tools/lib/src/ios/simulators.dart \ + packages/flutter_tools/test/general.shard/ios/simulators_test.dart || exit 1 + +owned="$(mktemp -d "$task_temp/zstandard-ios-flutter.XXXXXX")" +printf '%s\n' "$expected_revision" > "$owned/.zstandard-ios-flutter" +# Record ownership before copying so failures can also clean up their own SDK. +printf 'ZSTANDARD_IOS_FLUTTER_DIR=%s\n' "$owned" >> "${GITHUB_ENV:?GITHUB_ENV is required}" + +case "$(uname -s)" in + Darwin) cp -cR "$sdk" "$owned/flutter" || exit 1 ;; + Linux) cp --reflink=auto -R "$sdk" "$owned/flutter" || exit 1 ;; + *) echo 'The iOS backport requires a macOS or Linux verification runner.' >&2; exit 1 ;; +esac + +repository="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +backport="$repository/.github/patches/flutter-ios-log-readiness.patch" +git -C "$owned/flutter" apply --check "$backport" || exit 1 +git -C "$owned/flutter" apply "$backport" || exit 1 +# The copied snapshot predates the patched source. Invalidate only this copy. +rm -f "$owned/flutter/bin/cache/flutter_tools.snapshot" \ + "$owned/flutter/bin/cache/flutter_tools.stamp" +git -C "$sdk" diff --exit-code HEAD -- \ + packages/flutter_tools/lib/src/ios/simulators.dart \ + packages/flutter_tools/test/general.shard/ios/simulators_test.dart || exit 1 +printf 'FLUTTER_ROOT=%s\n' "$owned/flutter" >> "$GITHUB_ENV" +printf 'Prepared private iOS Flutter SDK: %s\n' "$owned/flutter" diff --git a/.github/scripts/tests/test_ios_flutter.py b/.github/scripts/tests/test_ios_flutter.py new file mode 100644 index 00000000..a3f57752 --- /dev/null +++ b/.github/scripts/tests/test_ios_flutter.py @@ -0,0 +1,78 @@ +import os +import pathlib +import subprocess +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[1] / "prepare_ios_flutter.sh" +REVISION = "d3b14c876900e553bc736ca19295fc09e3853e8e" + + +class PrivateFlutterTests(unittest.TestCase): + def cleanup(self, temporary, folder): + return subprocess.run( + ["bash", str(SCRIPT), "--cleanup"], + env={**os.environ, "RUNNER_TEMP": str(temporary), + "ZSTANDARD_IOS_FLUTTER_DIR": str(folder)}, + capture_output=True, text=True, timeout=10, + ) + + def folder(self, root, name, marker=None): + folder = root / name + folder.mkdir() + (folder / "keep").write_text("owned fixture", encoding="utf-8") + if marker is not None: + (folder / ".zstandard-ios-flutter").write_text(marker, encoding="utf-8") + return folder + + def test_unrelated_directory_survives_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + folder = self.folder(root, "diagnostics", REVISION) + self.assertNotEqual(self.cleanup(root, folder).returncode, 0) + self.assertTrue((folder / "keep").is_file()) + + def test_missing_marker_survives_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + folder = self.folder(root, "zstandard-ios-flutter.unowned") + self.assertNotEqual(self.cleanup(root, folder).returncode, 0) + self.assertTrue((folder / "keep").is_file()) + + def test_unknown_marker_survives_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + folder = self.folder(root, "zstandard-ios-flutter.foreign", "another SDK") + self.assertNotEqual(self.cleanup(root, folder).returncode, 0) + self.assertTrue((folder / "keep").is_file()) + + def test_symlink_target_survives_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + folder = self.folder(root, "target", REVISION) + link = root / "zstandard-ios-flutter.link" + link.symlink_to(folder, target_is_directory=True) + self.assertNotEqual(self.cleanup(root, link).returncode, 0) + self.assertTrue(link.is_symlink()) + self.assertTrue((folder / "keep").is_file()) + + def test_directory_outside_job_temp_survives_cleanup(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + job = root / "job" + job.mkdir() + folder = self.folder(root, "zstandard-ios-flutter.other-job", REVISION) + self.assertNotEqual(self.cleanup(job, folder).returncode, 0) + self.assertTrue((folder / "keep").is_file()) + + def test_marked_job_directory_can_be_removed(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary).resolve() + folder = self.folder(root, "zstandard-ios-flutter.owned", REVISION) + self.assertEqual(self.cleanup(root, folder).returncode, 0) + self.assertFalse(folder.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/pr_check_ios.yml b/.github/workflows/pr_check_ios.yml index 45626845..f2d7871d 100644 --- a/.github/workflows/pr_check_ios.yml +++ b/.github/workflows/pr_check_ios.yml @@ -82,6 +82,10 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Prepare private Flutter SDK with upstream iOS startup fix + timeout-minutes: 10 + run: bash .github/scripts/prepare_ios_flutter.sh + - uses: ./.github/actions/prepare-candidate-dependencies with: source: ${{ matrix.source }} @@ -151,7 +155,7 @@ jobs: cd zstandard_ios/example bash ../../scripts/run_with_timeout.sh 900 \ flutter test integration_test/ios_integration_test.dart \ - -d "$DEVICE_ID" --reporter expanded + -d "$DEVICE_ID" --reporter expanded --verbose - name: Capture iOS simulator diagnostics if: failure() @@ -206,6 +210,11 @@ jobs: test ! -e zstandard_native && test ! -L zstandard_native mv "$ZSTANDARD_NATIVE_ORIGINAL_PATH" zstandard_native + - name: Remove this job's patched Flutter SDK + if: ${{ always() && env.ZSTANDARD_IOS_FLUTTER_DIR != '' }} + timeout-minutes: 3 + run: bash .github/scripts/prepare_ios_flutter.sh --cleanup + check_ios_publish_dry_run: name: Dry Run Publish iOS if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index db7ab500..93654723 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -632,6 +632,10 @@ jobs: - uses: ./.github/actions/setup-flutter with: flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Prepare private Flutter SDK with upstream iOS startup fix + if: matrix.platform == 'ios' + timeout-minutes: 10 + run: bash .github/scripts/prepare_ios_flutter.sh - name: Select dependency manager and local native source env: PLATFORM: ${{ matrix.platform }} @@ -688,7 +692,7 @@ jobs: trap cleanup_simulator EXIT GITHUB_OUTPUT="$simulator_output" python3 .github/scripts/start_ios_simulator.py device_id="$(sed -n 's/^device-id=//p' "$simulator_output")" - (cd zstandard_ios/example && bash ../../scripts/run_with_timeout.sh 900 flutter test integration_test/ios_integration_test.dart -d "$device_id" --reporter expanded) + (cd zstandard_ios/example && bash ../../scripts/run_with_timeout.sh 900 flutter test integration_test/ios_integration_test.dart -d "$device_id" --reporter expanded --verbose) else trap 'rm -f zstandard_macos/example/pubspec_overrides.yaml' EXIT (cd zstandard_macos/example && bash ../../scripts/run_with_timeout.sh 900 flutter test integration_test/macos_integration_test.dart -d macos --reporter expanded) @@ -706,6 +710,10 @@ jobs: - name: Restore Flutter dependency-manager default if: always() run: flutter config --no-enable-swift-package-manager + - name: Remove this job's patched Flutter SDK + if: ${{ always() && env.ZSTANDARD_IOS_FLUTTER_DIR != '' }} + timeout-minutes: 3 + run: bash .github/scripts/prepare_ios_flutter.sh --cleanup ready_to_tag: name: Release candidate ready to tag diff --git a/.github/workflows/simulator_contract.yml b/.github/workflows/simulator_contract.yml index 8591dcb7..2d8f7139 100644 --- a/.github/workflows/simulator_contract.yml +++ b/.github/workflows/simulator_contract.yml @@ -4,6 +4,9 @@ on: paths: - '.github/scripts/start_ios_simulator.py' - '.github/scripts/tests/test_ios_simulator.py' + - '.github/scripts/tests/test_ios_flutter.py' + - '.github/scripts/prepare_ios_flutter.sh' + - '.github/patches/flutter-ios-log-readiness.patch' - '.github/workflows/pr_check_ios.yml' - '.github/workflows/simulator_contract.yml' jobs: @@ -15,10 +18,25 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11' - run: python -m unittest discover -s .github/scripts/tests -p test_ios_simulator.py -v + - run: python -m unittest discover -s .github/scripts/tests -p test_ios_flutter.py -v + - uses: ./.github/actions/setup-flutter + with: + flutter-version: '3.47.2' + - name: Prepare the exact SDK backport without changing the shared SDK + timeout-minutes: 10 + run: bash .github/scripts/prepare_ios_flutter.sh + - name: Verify upstream delayed-log-stream startup regressions + timeout-minutes: 10 + working-directory: ${{ env.FLUTTER_ROOT }}/packages/flutter_tools + run: ../../bin/dart test test/general.shard/ios/simulators_test.dart --name 'waiting for the log stream' + - name: Remove this job's patched Flutter SDK + if: ${{ always() && env.ZSTANDARD_IOS_FLUTTER_DIR != '' }} + timeout-minutes: 3 + run: bash .github/scripts/prepare_ios_flutter.sh --cleanup From e825984dff49ed4da5584f4124f656d96cc59acb Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Sun, 25 Oct 2026 12:29:45 +0100 Subject: [PATCH 2/3] ci: require version tags for GitHub Actions --- .github/actions/setup-flutter/action.yml | 2 +- .github/scripts/check_action_versions.py | 45 +++++++++++++++++++ .../package_preparation_contract.yml | 12 +++-- .github/workflows/pr_check_ios.yml | 8 ++-- .github/workflows/publish_workflow.yml | 12 ++--- .github/workflows/pull_request.yml | 8 ++-- .github/workflows/release_workflow.yml | 38 ++++++++-------- .github/workflows/simulator_contract.yml | 4 +- docs/development/ci-cd.md | 6 ++- 9 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 .github/scripts/check_action_versions.py diff --git a/.github/actions/setup-flutter/action.yml b/.github/actions/setup-flutter/action.yml index c90fb97c..788aed4b 100644 --- a/.github/actions/setup-flutter/action.yml +++ b/.github/actions/setup-flutter/action.yml @@ -10,7 +10,7 @@ runs: using: composite steps: - name: Install Flutter - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + uses: subosito/flutter-action@v2 with: channel: stable flutter-version: ${{ inputs.flutter-version }} diff --git a/.github/scripts/check_action_versions.py b/.github/scripts/check_action_versions.py new file mode 100644 index 00000000..192ab221 --- /dev/null +++ b/.github/scripts/check_action_versions.py @@ -0,0 +1,45 @@ +"""Require version tags for every remote workflow and composite Action use.""" + +import pathlib +import re +import sys + +import yaml + + +VERSION = re.compile(r"v[0-9]+(?:\.[0-9]+){0,2}(?:-[0-9A-Za-z.-]+)?") + + +def references(node): + if isinstance(node, dict): + if "uses" in node: + yield node["uses"] + for value in node.values(): + yield from references(value) + elif isinstance(node, list): + for value in node: + yield from references(value) + + +def main(root): + paths = sorted((root / ".github/workflows").glob("*.y*ml")) + paths += sorted((root / ".github/actions").rglob("action.y*ml")) + errors = [] + count = 0 + for path in paths: + for reference in references(yaml.safe_load(path.read_text(encoding="utf-8"))): + count += 1 + if isinstance(reference, str) and reference.startswith("./"): + continue + if not isinstance(reference, str) or "@" not in reference or not VERSION.fullmatch(reference.rsplit("@", 1)[-1]): + errors.append(f"{path.relative_to(root)}: {reference}: use a version tag such as @v1") + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + print(f"Verified {count} Action references use local paths or version tags.") + return 0 + + +if __name__ == "__main__": + repository = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[2] + sys.exit(main(repository)) diff --git a/.github/workflows/package_preparation_contract.yml b/.github/workflows/package_preparation_contract.yml index d5b96a56..7da85bd8 100644 --- a/.github/workflows/package_preparation_contract.yml +++ b/.github/workflows/package_preparation_contract.yml @@ -2,7 +2,9 @@ name: Package preparation contract on: pull_request: paths: - - '.github/actions/prepare-package-release/**' + - '.github/actions/**' + - '.github/workflows/**' + - '.github/scripts/check_action_versions.py' - '.github/scripts/update_versions.dart' - '.github/scripts/tests/test_package_preparation.py' - '.github/scripts/tests/test_hotfix_ref.py' @@ -26,22 +28,24 @@ jobs: labels: [self-hosted, codex, Linux] timeout-minutes: 10 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@v6 with: persist-credentials: false - uses: ./.github/actions/setup-flutter with: flutter-version: '3.47.2' - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-python@v6 with: python-version: '3.11' - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + - uses: actions/setup-node@v7 with: node-version: '24' - name: Install isolated YAML parser run: | python -m venv "$RUNNER_TEMP/preparation-contract" "$RUNNER_TEMP/preparation-contract/bin/python" -m pip install PyYAML==6.0.3 + - name: Require version tags for workflow and composite Actions + run: '"$RUNNER_TEMP/preparation-contract/bin/python" .github/scripts/check_action_versions.py' - name: Verify real metadata preparation and failure behavior run: '"$RUNNER_TEMP/preparation-contract/bin/python" -m unittest discover -s .github/scripts/tests -p test_package_preparation.py -v' - name: Reject preparation outside the matching hotfix branch diff --git a/.github/workflows/pr_check_ios.yml b/.github/workflows/pr_check_ios.yml index f2d7871d..7d286bba 100644 --- a/.github/workflows/pr_check_ios.yml +++ b/.github/workflows/pr_check_ios.yml @@ -25,7 +25,7 @@ jobs: labels: [self-hosted, macOS, ARM64] timeout-minutes: 45 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@v6 - uses: ./.github/actions/preserve-flutter-project-files with: @@ -71,7 +71,7 @@ jobs: dependency_manager: [swiftpm, cocoapods] source: [workspace, pub-cache] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@v6 - uses: ./.github/actions/preserve-flutter-project-files with: @@ -174,7 +174,7 @@ jobs: - name: Upload iOS simulator diagnostics if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@v5 with: name: ios-simulator-diagnostics-${{ matrix.dependency_manager }}-${{ matrix.source }}-${{ github.run_id }} path: | @@ -223,7 +223,7 @@ jobs: labels: [self-hosted, macOS, ARM64] timeout-minutes: 45 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@v6 - uses: ./.github/actions/preserve-flutter-project-files with: diff --git a/.github/workflows/publish_workflow.yml b/.github/workflows/publish_workflow.yml index 2ca0e7be..e8b23134 100644 --- a/.github/workflows/publish_workflow.yml +++ b/.github/workflows/publish_workflow.yml @@ -34,7 +34,7 @@ jobs: issue: ${{ steps.candidate.outputs.issue }} operation-id: ${{ steps.candidate.outputs.operation-id }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -101,7 +101,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.validate_tag.outputs.commit }} fetch-depth: 0 @@ -109,7 +109,7 @@ jobs: # setup-dart provisions the short-lived pub.dev credential from GitHub's # OIDC token. Flutter is installed afterwards so its bundled Dart stays # first on PATH while using the same Pub credentials. - - uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d + - uses: dart-lang/setup-dart@v1 - uses: ./.github/actions/setup-flutter with: @@ -149,7 +149,7 @@ jobs: timeout-minutes: 30 needs: [validate_tag, publish] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.validate_tag.outputs.commit }} @@ -185,7 +185,7 @@ jobs: cancel-in-progress: false queue: max steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.validate_tag.outputs.commit }} @@ -243,7 +243,7 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.validate_tag.outputs.commit }} persist-credentials: false diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5db67911..1cd69c7b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -38,7 +38,7 @@ jobs: - zstandard_cli - zstandard steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup-flutter with: @@ -91,7 +91,7 @@ jobs: flag: zstandard threshold: 76 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup-flutter with: @@ -131,7 +131,7 @@ jobs: if: >- github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ matrix.package }}/coverage/lcov.info @@ -147,7 +147,7 @@ jobs: labels: [self-hosted, codex, Linux] timeout-minutes: 20 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup-flutter with: diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index 93654723..22b3c175 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -62,7 +62,7 @@ jobs: permissions: {} steps: - name: Validate bounded orchestration inputs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@v9 env: MODE: ${{ inputs.mode }} OPERATION_ID: ${{ inputs.operation-id }} @@ -108,7 +108,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -197,7 +197,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.update_files.outputs.commit }} fetch-depth: 0 @@ -252,7 +252,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_macos_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -315,7 +315,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_linux_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -382,7 +382,7 @@ jobs: timeout-minutes: 45 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -415,7 +415,7 @@ jobs: timeout-minutes: 120 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -425,11 +425,11 @@ jobs: - uses: ./.github/actions/setup-flutter with: flutter-version: ${{ env.FLUTTER_VERSION }} - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 + - uses: actions/setup-java@v5 with: distribution: temurin java-version: '17' - - uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 + - uses: android-actions/setup-android@v4 - name: Build, run native tests and run both Android suites run: | set -euo pipefail @@ -472,7 +472,7 @@ jobs: timeout-minutes: 60 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -507,7 +507,7 @@ jobs: timeout-minutes: 90 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -518,7 +518,7 @@ jobs: - uses: ./.github/actions/setup-flutter with: flutter-version: ${{ env.FLUTTER_VERSION }} - - uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 + - uses: browser-actions/setup-chrome@v2 id: chrome with: chrome-version: stable @@ -545,7 +545,7 @@ jobs: timeout-minutes: 75 needs: [update_files, cli_build_windows_precompiled_libs] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -621,7 +621,7 @@ jobs: platform: [ios, macos] dependency_manager: [swiftpm, cocoapods] steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -733,7 +733,7 @@ jobs: - candidate_windows - candidate_apple steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@v6 with: ref: ${{ needs.cli_build_windows_precompiled_libs.outputs.commit }} fetch-depth: 0 @@ -770,7 +770,7 @@ jobs: VERSION: ${{ inputs.version }} ISSUE: ${{ inputs.issue }} run: python3 scripts/release_candidate.py record "$VERSION" "$ISSUE" "$RUNNER_TEMP/release-candidate.json" - - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + - uses: actions/upload-artifact@v5 with: name: release-candidate-${{ inputs.version }} path: ${{ runner.temp }}/release-candidate.json @@ -795,7 +795,7 @@ jobs: pull-requests: write actions: write steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Create or resume promotion PR @@ -845,7 +845,7 @@ jobs: pull-requests: write actions: write steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/checkout@v5 with: persist-credentials: false ref: ${{ vars.MAIN_BRANCH || 'master' }} @@ -893,7 +893,7 @@ jobs: pull-requests: write steps: - name: Verify published operation before recovery - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@v9 env: ISSUE: ${{ inputs.issue }} OPERATION_ID: ${{ inputs.operation-id }} diff --git a/.github/workflows/simulator_contract.yml b/.github/workflows/simulator_contract.yml index 2d8f7139..8c9b86a5 100644 --- a/.github/workflows/simulator_contract.yml +++ b/.github/workflows/simulator_contract.yml @@ -18,10 +18,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + - uses: actions/setup-python@v6 with: python-version: '3.11' - run: python -m unittest discover -s .github/scripts/tests -p test_ios_simulator.py -v diff --git a/docs/development/ci-cd.md b/docs/development/ci-cd.md index d633bed7..e5ed24c6 100644 --- a/docs/development/ci-cd.md +++ b/docs/development/ci-cd.md @@ -22,8 +22,10 @@ They provide Android emulator/instrumentation, Linux desktop, ChromeDriver, Windows desktop/native, and both SwiftPM and CocoaPods Apple integration. They also run publish dry-runs and coverage where applicable. -Every third-party action reference is pinned to a full commit SHA. Copilot -automation uses `pull_request_target` or issue metadata without checking out +Actions in workflows and composite actions must use version tags such as +`@v1`, `@v2` or `@v3`. Commit SHA references are not permitted. The package +preparation contract checks this policy when workflow or action files change. +Copilot automation uses `pull_request_target` or issue metadata without checking out untrusted pull-request code, grants read-only repository contents, and only uses its PAT for OWNER, MEMBER, or COLLABORATOR actors. From 19c31f2603897147295b8f32ae706de2fcd890f7 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Sun, 25 Oct 2026 13:21:47 +0100 Subject: [PATCH 3/3] fix(ci): isolate Android Gradle state and select the job JDK --- .../actions/prepare-android-gradle/action.yml | 17 +++++++++++++++++ .github/actions/setup-flutter/README.md | 7 +++++++ .github/workflows/pr_check_android.yml | 7 +++++++ .github/workflows/release_workflow.yml | 7 ++++++- 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/actions/prepare-android-gradle/action.yml diff --git a/.github/actions/prepare-android-gradle/action.yml b/.github/actions/prepare-android-gradle/action.yml new file mode 100644 index 00000000..db03c5dc --- /dev/null +++ b/.github/actions/prepare-android-gradle/action.yml @@ -0,0 +1,17 @@ +name: Prepare private Android Gradle state +description: Avoid persistent runner Gradle caches and select the job JDK. +runs: + using: composite + steps: + - name: Isolate Gradle and select the job JDK + shell: bash + run: | + set -euo pipefail + gradle_root="$(mktemp -d "$RUNNER_TEMP/zstandard-gradle.XXXXXX")" + # RUNNER_TEMP is removed by the runner after this job. Never clear the + # operator's Gradle cache or stop daemons owned by other jobs. + printf 'org.gradle.daemon=false\norg.gradle.workers.max=2\n' > "$gradle_root/gradle.properties" + printf 'GRADLE_USER_HOME=%s\n' "$gradle_root" >> "$GITHUB_ENV" + # Flutter otherwise prefers an installed Android Studio JBR. The + # setup-flutter launcher keeps this setting in the job's own profile. + flutter config --jdk-dir="${JAVA_HOME:?The job JDK is required}" diff --git a/.github/actions/setup-flutter/README.md b/.github/actions/setup-flutter/README.md index 390bffe5..b87ff8ae 100644 --- a/.github/actions/setup-flutter/README.md +++ b/.github/actions/setup-flutter/README.md @@ -51,3 +51,10 @@ search variables that would override the NDK sysroot. The mixed x86_64/AArch64 release job retains private native metadata and clears those variables only for the cross build, preserving the AArch64 compiler sysroot. WebAssembly regeneration uses the same isolation for Emscripten. + +Android integration and candidate builds use `prepare-android-gradle` after JDK +setup. Its job-owned Gradle home bypasses persistent runner caches, bounds worker +concurrency and keeps daemons local to the invocation. Candidate validation also +separates modern and legacy consumer caches. Flutter selects the job's JDK in its +private settings profile; the operator's Gradle cache and Flutter settings stay +unchanged. diff --git a/.github/workflows/pr_check_android.yml b/.github/workflows/pr_check_android.yml index 25b7b7be..e8c5dff9 100644 --- a/.github/workflows/pr_check_android.yml +++ b/.github/workflows/pr_check_android.yml @@ -126,6 +126,8 @@ jobs: - name: Set up Android SDK tools uses: android-actions/setup-android@v4 + - uses: ./.github/actions/prepare-android-gradle + - name: Verify Android SDK prerequisites run: bash scripts/check_android_ci_prerequisites.sh @@ -145,6 +147,11 @@ jobs: working-directory: zstandard_android/${{ matrix.example }} run: flutter build apk --debug --split-per-abi --target-platform android-arm,android-arm64,android-x64 + - name: Diagnose Gradle configuration failures + if: failure() + working-directory: zstandard_android/${{ matrix.example }}/android + run: bash ./gradlew --no-daemon help --stacktrace + - name: Verify selected Gradle runtime working-directory: zstandard_android/${{ matrix.example }}/android run: bash ./gradlew --no-daemon --version diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index 22b3c175..bb35ba86 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -430,6 +430,7 @@ jobs: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v4 + - uses: ./.github/actions/prepare-android-gradle - name: Build, run native tests and run both Android suites run: | set -euo pipefail @@ -441,6 +442,9 @@ jobs: (cd zstandard_android && flutter pub get && flutter analyze lib test && flutter test) bash scripts/check_android_ci_prerequisites.sh for example in example example_legacy; do + # Keep modern and legacy Gradle state separate within this job too. + mkdir -p "$GRADLE_USER_HOME/$example" + cp "$GRADLE_USER_HOME/gradle.properties" "$GRADLE_USER_HOME/$example/gradle.properties" if [[ "$example" == example ]]; then grep -F 'id "com.android.application" version "9.1.0" apply false' "zstandard_android/$example/android/settings.gradle" grep -F 'gradle-9.3.1-all.zip' "zstandard_android/$example/android/gradle/wrapper/gradle-wrapper.properties" @@ -448,13 +452,14 @@ jobs: grep -F 'id "com.android.application" version "8.11.1" apply false' "zstandard_android/$example/android/settings.gradle" grep -F 'gradle-8.14-all.zip' "zstandard_android/$example/android/gradle/wrapper/gradle-wrapper.properties" fi - (cd "zstandard_android/$example" && flutter pub get && flutter clean && flutter pub get && flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64) + (export GRADLE_USER_HOME="$GRADLE_USER_HOME/$example"; cd "zstandard_android/$example" && flutter pub get && flutter clean && flutter pub get && flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64) bash scripts/check_android_apk_abis.sh "zstandard_android/$example/build/app/outputs/flutter-apk" release done bash scripts/run_android_emulator_ci.sh bash -c ' set -euo pipefail for example in example example_legacy; do ( + export GRADLE_USER_HOME="$GRADLE_USER_HOME/$example" cd "zstandard_android/$example" timeout --foreground 10m flutter test integration_test/android_compression_integration_test.dart -d "emulator-${EMULATOR_PORT}" --reporter expanded timeout --foreground 10m flutter test integration_test/android_properties_integration_test.dart -d "emulator-${EMULATOR_PORT}" --reporter expanded