[ci] Run analysis with older versions of Flutter (#5000)

diff --git a/.cirrus.yml b/.cirrus.yml
index f9454cb..8f6a6f7 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -28,10 +28,10 @@
     - git fetch origin
     # Switch to the requested channel.
     - git checkout $TARGET_TREEISH
-    # When using a branch rather than a hash, reset to the upstream branch
-    # rather than using pull, since the base image can sometimes be in a state
-    # where it has diverged from upstream (!).
-    - if [[ "$TARGET_TREEISH" == "$CHANNEL" ]]; then
+    # When using a branch rather than a hash or version tag, reset to the
+    # upstream branch rather than using pull, since the base image can sometimes
+    # be in a state where it has diverged from upstream (!).
+    - if [[ "$TARGET_TREEISH" == "$CHANNEL" ]] && [[ "$CHANNEL" != *"."* ]]; then
     -   git reset --hard @{u}
     - fi
     # Run doctor to allow auditing of what version of Flutter the run is using.
@@ -138,6 +138,22 @@
         # Restore the tree to a clean state, to avoid accidental issues if
         # other script steps are added to this task.
         - git checkout .
+    # Does a sanity check that plugins at least pass analysis on the N-1 and N-2
+    # versions of Flutter stable if the plugin claims to support that version.
+    # This is to minimize accidentally making changes that break old versions
+    # (which we don't commit to supporting, but don't want to actively break)
+    # without updating the constraints.
+    # Note: The versions below should be manually updated after a new stable
+    # version comes out.
+    - name: legacy-version-analyze
+      depends_on: analyze
+      env:
+        matrix:
+          CHANNEL: "2.5.3"
+          CHANNEL: "2.8.1"
+      analyze_script:
+        - ./script/tool_runner.sh analyze --skip-if-not-supporting-flutter-version="$CHANNEL" --custom-analysis=script/configs/custom_analysis.yaml
+        - echo "If this test fails, the minumum Flutter version should be updated"
     ### Web tasks ###
     - name: web-build_all_plugins
       env:
diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md
index c72e31f..9e48666 100644
--- a/packages/camera/camera_web/CHANGELOG.md
+++ b/packages/camera/camera_web/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Updates minimum Flutter version for changes in 0.2.1+3.
+
 ## 0.2.1+3
 
 * Internal code cleanup for stricter analysis options.
diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml
index 6d6f110..cb6aa19 100644
--- a/packages/camera/camera_web/pubspec.yaml
+++ b/packages/camera/camera_web/pubspec.yaml
@@ -6,7 +6,7 @@
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=2.0.0"
+  flutter: ">=2.8.0"
 
 flutter:
   plugin:
diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md
index c1a1606..e35caf7 100644
--- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md
+++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Updates minimum Flutter version to 2.8.
+
 ## 0.10.0+5
 
 * Internal code cleanup for stricter analysis options.
diff --git a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml
index 5a51cd5..1bdb2f0 100644
--- a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml
+++ b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml
@@ -3,7 +3,7 @@
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=2.2.0"
+  flutter: ">=2.8.0"
 
 dependencies:
   flutter:
diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml
index 3bc05d1..d97a7c4 100644
--- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml
+++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml
@@ -7,7 +7,7 @@
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=2.0.0"
+  flutter: ">=2.8.0"
 
 flutter:
   plugin:
diff --git a/packages/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md
index fe59608..e95d56c 100644
--- a/packages/quick_actions/quick_actions/CHANGELOG.md
+++ b/packages/quick_actions/quick_actions/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Updates minimum Flutter version to 2.8.
+
 ## 0.6.0+10
 
 * Moves Android and iOS implementations to federated packages.
diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index a44f0e3..5d56b15 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Updates minimum Flutter version to 2.10.
+
 ## 2.3.0
 
 * Adds `allowBackgroundPlayback` to `VideoPlayerOptions`.
diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml
index 6032f3c..e6b51f9 100644
--- a/packages/video_player/video_player/example/pubspec.yaml
+++ b/packages/video_player/video_player/example/pubspec.yaml
@@ -4,7 +4,7 @@
 
 environment:
   sdk: ">=2.12.0 <3.0.0"
-  flutter: ">=1.12.13+hotfix.5"
+  flutter: ">=2.10.0"
 
 dependencies:
   flutter:
diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml
index d58de12..88e45c5 100644
--- a/packages/video_player/video_player/pubspec.yaml
+++ b/packages/video_player/video_player/pubspec.yaml
@@ -7,7 +7,7 @@
 
 environment:
   sdk: ">=2.14.0 <3.0.0"
-  flutter: ">=2.8.0"
+  flutter: ">=2.10.0"
 
 flutter:
   plugin:
diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index 8f2807f..8e5ae21 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -19,6 +19,9 @@
   `flutter` behavior.
 - Validates `default_package` entries in plugins.
 - Removes `allow-warnings` from the `podspecs` command.
+- Adds `skip-if-not-supporting-flutter-version` to allow running tests using a
+  version of Flutter that not all packages support. (E.g., to allow for running
+  some tests against old versions of Flutter to help avoid accidental breakage.)
 
 ## 0.7.3
 
diff --git a/script/tool/lib/src/analyze_command.dart b/script/tool/lib/src/analyze_command.dart
index faad7f4..d8b17bf 100644
--- a/script/tool/lib/src/analyze_command.dart
+++ b/script/tool/lib/src/analyze_command.dart
@@ -10,12 +10,9 @@
 
 import 'common/core.dart';
 import 'common/package_looping_command.dart';
-import 'common/plugin_command.dart';
 import 'common/process_runner.dart';
 import 'common/repository_package.dart';
 
-const int _exitPackagesGetFailed = 3;
-
 /// A command to run Dart analysis on packages.
 class AnalyzeCommand extends PackageLoopingCommand {
   /// Creates a analysis command instance.
@@ -84,41 +81,8 @@
     return false;
   }
 
-  /// Ensures that the dependent packages have been fetched for all packages
-  /// (including their sub-packages) that will be analyzed.
-  Future<bool> _runPackagesGetOnTargetPackages() async {
-    final List<Directory> packageDirectories =
-        await getTargetPackagesAndSubpackages()
-            .map((PackageEnumerationEntry entry) => entry.package.directory)
-            .toList();
-    final Set<String> packagePaths =
-        packageDirectories.map((Directory dir) => dir.path).toSet();
-    packageDirectories.removeWhere((Directory directory) {
-      // Remove the 'example' subdirectories; 'flutter packages get'
-      // automatically runs 'pub get' there as part of handling the parent
-      // directory.
-      return directory.basename == 'example' &&
-          packagePaths.contains(directory.parent.path);
-    });
-    for (final Directory package in packageDirectories) {
-      final int exitCode = await processRunner.runAndStream(
-          flutterCommand, <String>['packages', 'get'],
-          workingDir: package);
-      if (exitCode != 0) {
-        return false;
-      }
-    }
-    return true;
-  }
-
   @override
   Future<void> initializeRun() async {
-    print('Fetching dependencies...');
-    if (!await _runPackagesGetOnTargetPackages()) {
-      printError('Unable to get dependencies.');
-      throw ToolExit(_exitPackagesGetFailed);
-    }
-
     _allowedCustomAnalysisDirectories =
         getStringListArg(_customAnalysisFlag).expand<String>((String item) {
       if (item.endsWith('.yaml')) {
@@ -138,6 +102,19 @@
 
   @override
   Future<PackageResult> runForPackage(RepositoryPackage package) async {
+    // For non-example packages, fetch dependencies. 'flutter packages get'
+    // automatically runs 'pub get' in examples as part of handling the parent
+    // directory, which is guaranteed to come first in the package enumeration.
+    if (package.directory.basename != 'example' ||
+        !RepositoryPackage(package.directory.parent).pubspecFile.existsSync()) {
+      final int exitCode = await processRunner.runAndStream(
+          flutterCommand, <String>['packages', 'get'],
+          workingDir: package.directory);
+      if (exitCode != 0) {
+        return PackageResult.fail(<String>['Unable to get dependencies']);
+      }
+    }
+
     if (_hasUnexpecetdAnalysisOptions(package)) {
       return PackageResult.fail(<String>['Unexpected local analysis options']);
     }
diff --git a/script/tool/lib/src/common/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart
index bfee71a..b75aaa4 100644
--- a/script/tool/lib/src/common/package_looping_command.dart
+++ b/script/tool/lib/src/common/package_looping_command.dart
@@ -9,6 +9,8 @@
 import 'package:git/git.dart';
 import 'package:path/path.dart' as p;
 import 'package:platform/platform.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
 
 import 'core.dart';
 import 'plugin_command.dart';
@@ -75,7 +77,16 @@
     Platform platform = const LocalPlatform(),
     GitDir? gitDir,
   }) : super(packagesDir,
-            processRunner: processRunner, platform: platform, gitDir: gitDir);
+            processRunner: processRunner, platform: platform, gitDir: gitDir) {
+    argParser.addOption(
+      _skipByFlutterVersionArg,
+      help: 'Skip any packages that require a Flutter version newer than '
+          'the provided version.',
+    );
+  }
+
+  static const String _skipByFlutterVersionArg =
+      'skip-if-not-supporting-flutter-version';
 
   /// Packages that had at least one [logWarning] call.
   final Set<PackageEnumerationEntry> _packagesWithWarnings =
@@ -219,6 +230,11 @@
     _otherWarningCount = 0;
     _currentPackageEntry = null;
 
+    final String minFlutterVersionArg = getStringArg(_skipByFlutterVersionArg);
+    final Version? minFlutterVersion = minFlutterVersionArg.isEmpty
+        ? null
+        : Version.parse(minFlutterVersionArg);
+
     final DateTime runStart = DateTime.now();
 
     await initializeRun();
@@ -242,7 +258,8 @@
 
       PackageResult result;
       try {
-        result = await runForPackage(entry.package);
+        result = await _runForPackageIfSupported(entry.package,
+            minFlutterVersion: minFlutterVersion);
       } catch (e, stack) {
         printError(e.toString());
         printError(stack.toString());
@@ -285,6 +302,26 @@
     return true;
   }
 
+  /// Returns the result of running [runForPackage] if the package is supported
+  /// by any run constraints, or a skip result if it is not.
+  Future<PackageResult> _runForPackageIfSupported(
+    RepositoryPackage package, {
+    Version? minFlutterVersion,
+  }) async {
+    if (minFlutterVersion != null) {
+      final Pubspec pubspec = package.parsePubspec();
+      final VersionConstraint? flutterConstraint =
+          pubspec.environment?['flutter'];
+      if (flutterConstraint != null &&
+          !flutterConstraint.allows(minFlutterVersion)) {
+        return PackageResult.skip(
+            'Does not support Flutter ${minFlutterVersion.toString()}');
+      }
+    }
+
+    return await runForPackage(package);
+  }
+
   void _printSuccess(String message) {
     captureOutput ? print(message) : printSuccess(message);
   }
diff --git a/script/tool/lib/src/common/plugin_command.dart b/script/tool/lib/src/common/plugin_command.dart
index 1846635..fcc87c9 100644
--- a/script/tool/lib/src/common/plugin_command.dart
+++ b/script/tool/lib/src/common/plugin_command.dart
@@ -409,6 +409,9 @@
   ///
   /// By default, packages excluded via --exclude will not be in the stream, but
   /// they can be included by passing false for [filterExcluded].
+  ///
+  /// Subpackages are guaranteed to be after the containing package in the
+  /// stream.
   Stream<PackageEnumerationEntry> getTargetPackagesAndSubpackages(
       {bool filterExcluded = true}) async* {
     await for (final PackageEnumerationEntry plugin
diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart
index 878facd..087e5ad 100644
--- a/script/tool/test/analyze_command_test.dart
+++ b/script/tool/test/analyze_command_test.dart
@@ -47,10 +47,10 @@
         orderedEquals(<ProcessCall>[
           ProcessCall(
               'flutter', const <String>['packages', 'get'], plugin1Dir.path),
-          ProcessCall(
-              'flutter', const <String>['packages', 'get'], plugin2Dir.path),
           ProcessCall('dart', const <String>['analyze', '--fatal-infos'],
               plugin1Dir.path),
+          ProcessCall(
+              'flutter', const <String>['packages', 'get'], plugin2Dir.path),
           ProcessCall('dart', const <String>['analyze', '--fatal-infos'],
               plugin2Dir.path),
         ]));
@@ -82,10 +82,10 @@
         orderedEquals(<ProcessCall>[
           ProcessCall(
               'flutter', const <String>['packages', 'get'], plugin1Dir.path),
-          ProcessCall(
-              'flutter', const <String>['packages', 'get'], plugin2Dir.path),
           ProcessCall('dart', const <String>['analyze', '--fatal-infos'],
               plugin1Dir.path),
+          ProcessCall(
+              'flutter', const <String>['packages', 'get'], plugin2Dir.path),
           ProcessCall('dart', const <String>['analyze', '--fatal-infos'],
               plugin2Dir.path),
         ]));
diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart
index 6e46a33..ea02bd4 100644
--- a/script/tool/test/common/package_looping_command_test.dart
+++ b/script/tool/test/common/package_looping_command_test.dart
@@ -236,6 +236,34 @@
       expect(command.checkedPackages,
           isNot(contains(excluded.childDirectory('example2').path)));
     });
+
+    test('skips unsupported versions when requested', () async {
+      final Directory excluded = createFakePlugin('a_plugin', packagesDir,
+          flutterConstraint: '>=2.10.0');
+      final Directory included = createFakePackage('a_package', packagesDir);
+
+      final TestPackageLoopingCommand command =
+          createTestCommand(includeSubpackages: true, hasLongOutput: false);
+      final List<String> output = await runCommand(command, arguments: <String>[
+        '--skip-if-not-supporting-flutter-version=2.5.0'
+      ]);
+
+      expect(
+          command.checkedPackages,
+          unorderedEquals(<String>[
+            included.path,
+            included.childDirectory('example').path,
+          ]));
+      expect(command.checkedPackages, isNot(contains(excluded.path)));
+
+      expect(
+          output,
+          containsAllInOrder(<String>[
+            '${_startHeadingColor}Running for a_package...$_endColor',
+            '${_startHeadingColor}Running for a_plugin...$_endColor',
+            '$_startSkipColor  SKIPPING: Does not support Flutter 2.5.0$_endColor',
+          ]));
+    });
   });
 
   group('output', () {
diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart
index 28a03c6..782ea72 100644
--- a/script/tool/test/common/plugin_command_test.dart
+++ b/script/tool/test/common/plugin_command_test.dart
@@ -253,6 +253,29 @@
           unorderedEquals(<String>[platformInterfacePackage.path]));
     });
 
+    test('returns subpackages after the enclosing package', () async {
+      final SamplePluginCommand localCommand = SamplePluginCommand(
+        packagesDir,
+        processRunner: processRunner,
+        platform: mockPlatform,
+        gitDir: MockGitDir(),
+        includeSubpackages: true,
+      );
+      final CommandRunner<void> localRunner =
+          CommandRunner<void>('common_command', 'subpackage testing');
+      localRunner.addCommand(localCommand);
+
+      final Directory package = createFakePackage('apackage', packagesDir);
+
+      await runCapturingPrint(localRunner, <String>['sample']);
+      expect(
+          localCommand.plugins,
+          containsAllInOrder(<String>[
+            package.path,
+            package.childDirectory('example').path,
+          ]));
+    });
+
     group('conflicting package selection', () {
       test('does not allow --packages with --run-on-changed-packages',
           () async {
@@ -893,11 +916,14 @@
     ProcessRunner processRunner = const ProcessRunner(),
     Platform platform = const LocalPlatform(),
     GitDir? gitDir,
+    this.includeSubpackages = false,
   }) : super(packagesDir,
             processRunner: processRunner, platform: platform, gitDir: gitDir);
 
   final List<String> plugins = <String>[];
 
+  final bool includeSubpackages;
+
   @override
   final String name = 'sample';
 
@@ -906,7 +932,10 @@
 
   @override
   Future<void> run() async {
-    await for (final PackageEnumerationEntry entry in getTargetPackages()) {
+    final Stream<PackageEnumerationEntry> packages = includeSubpackages
+        ? getTargetPackagesAndSubpackages()
+        : getTargetPackages();
+    await for (final PackageEnumerationEntry entry in packages) {
       plugins.add(entry.package.path);
     }
   }
diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart
index 91d21c1..6f7d86e 100644
--- a/script/tool/test/util.dart
+++ b/script/tool/test/util.dart
@@ -85,12 +85,14 @@
   Map<String, PlatformDetails> platformSupport =
       const <String, PlatformDetails>{},
   String? version = '0.0.1',
+  String flutterConstraint = '>=2.5.0',
 }) {
   final Directory pluginDirectory = createFakePackage(name, parentDirectory,
       isFlutter: true,
       examples: examples,
       extraFiles: extraFiles,
-      version: version);
+      version: version,
+      flutterConstraint: flutterConstraint);
 
   createFakePubspec(
     pluginDirectory,
@@ -99,6 +101,7 @@
     isPlugin: true,
     platformSupport: platformSupport,
     version: version,
+    flutterConstraint: flutterConstraint,
   );
 
   return pluginDirectory;
@@ -116,12 +119,16 @@
   List<String> extraFiles = const <String>[],
   bool isFlutter = false,
   String? version = '0.0.1',
+  String flutterConstraint = '>=2.5.0',
 }) {
   final Directory packageDirectory = parentDirectory.childDirectory(name);
   packageDirectory.createSync(recursive: true);
 
   createFakePubspec(packageDirectory,
-      name: name, isFlutter: isFlutter, version: version);
+      name: name,
+      isFlutter: isFlutter,
+      version: version,
+      flutterConstraint: flutterConstraint);
   createFakeCHANGELOG(packageDirectory, '''
 ## $version
   * Some changes.
@@ -132,7 +139,10 @@
     final Directory exampleDir = packageDirectory.childDirectory(examples.first)
       ..createSync();
     createFakePubspec(exampleDir,
-        name: '${name}_example', isFlutter: isFlutter, publishTo: 'none');
+        name: '${name}_example',
+        isFlutter: isFlutter,
+        publishTo: 'none',
+        flutterConstraint: flutterConstraint);
   } else if (examples.isNotEmpty) {
     final Directory exampleDir = packageDirectory.childDirectory('example')
       ..createSync();
@@ -140,7 +150,10 @@
       final Directory currentExample = exampleDir.childDirectory(example)
         ..createSync();
       createFakePubspec(currentExample,
-          name: example, isFlutter: isFlutter, publishTo: 'none');
+          name: example,
+          isFlutter: isFlutter,
+          publishTo: 'none',
+          flutterConstraint: flutterConstraint);
     }
   }
 
@@ -172,40 +185,61 @@
       const <String, PlatformDetails>{},
   String publishTo = 'http://no_pub_server.com',
   String? version,
+  String dartConstraint = '>=2.0.0 <3.0.0',
+  String flutterConstraint = '>=2.5.0',
 }) {
   isPlugin |= platformSupport.isNotEmpty;
-  parent.childFile('pubspec.yaml').createSync();
-  String yaml = '''
-name: $name
+
+  String environmentSection = '''
+environment:
+  sdk: "$dartConstraint"
 ''';
+  String dependenciesSection = '''
+dependencies:
+''';
+  String pluginSection = '';
+
+  // Add Flutter-specific entries if requested.
   if (isFlutter) {
+    environmentSection += '''
+  flutter: "$flutterConstraint"
+''';
+    dependenciesSection += '''
+  flutter:
+    sdk: flutter
+''';
+
     if (isPlugin) {
-      yaml += '''
+      pluginSection += '''
 flutter:
   plugin:
     platforms:
 ''';
       for (final MapEntry<String, PlatformDetails> platform
           in platformSupport.entries) {
-        yaml += _pluginPlatformSection(platform.key, platform.value, name);
+        pluginSection +=
+            _pluginPlatformSection(platform.key, platform.value, name);
       }
     }
-    yaml += '''
-dependencies:
-  flutter:
-    sdk: flutter
-''';
   }
-  if (version != null) {
-    yaml += '''
-version: $version
+
+  String yaml = '''
+name: $name
+${(version != null) ? 'version: $version' : ''}
+
+$environmentSection
+
+$dependenciesSection
+
+$pluginSection
 ''';
-  }
+
   if (publishTo.isNotEmpty) {
     yaml += '''
 publish_to: $publishTo # Hardcoded safeguard to prevent this from somehow being published by a broken test.
 ''';
   }
+  parent.childFile('pubspec.yaml').createSync();
   parent.childFile('pubspec.yaml').writeAsStringSync(yaml);
 }