[tools] Allow pre-release versions (#6061)

diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index 9606eed..da14eae 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.8.8
+
+- Allows pre-release versions in `version-check`.
+
 ## 0.8.7
 
 - Supports empty custom analysis allow list files.
diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart
index 62abdb2..246382d 100644
--- a/script/tool/lib/src/version_check_command.dart
+++ b/script/tool/lib/src/version_check_command.dart
@@ -31,8 +31,8 @@
   /// A bugfix change.
   PATCH,
 
-  /// The release of an existing prerelease version.
-  RELEASE,
+  /// The release of an existing pre-1.0 version.
+  V1_RELEASE,
 }
 
 /// The state of a package's version relative to the comparison base.
@@ -53,8 +53,8 @@
   unknown,
 }
 
-/// Returns the set of allowed next versions, with their change type, for
-/// [version].
+/// Returns the set of allowed next non-prerelease versions, with their change
+/// type, for [version].
 ///
 /// [newVersion] is used to check whether this is a pre-1.0 version bump, as
 /// those have different semver rules.
@@ -78,17 +78,17 @@
       final int currentBuildNumber = version.build.first as int;
       nextBuildNumber = currentBuildNumber + 1;
     }
-    final Version preReleaseVersion = Version(
+    final Version nextBuildVersion = Version(
       version.major,
       version.minor,
       version.patch,
       build: nextBuildNumber.toString(),
     );
     allowedNextVersions.clear();
-    allowedNextVersions[version.nextMajor] = NextVersionType.RELEASE;
+    allowedNextVersions[version.nextMajor] = NextVersionType.V1_RELEASE;
     allowedNextVersions[version.nextMinor] = NextVersionType.BREAKING_MAJOR;
     allowedNextVersions[version.nextPatch] = NextVersionType.MINOR;
-    allowedNextVersions[preReleaseVersion] = NextVersionType.PATCH;
+    allowedNextVersions[nextBuildVersion] = NextVersionType.PATCH;
   }
   return allowedNextVersions;
 }
@@ -337,12 +337,11 @@
 
     // Check for reverts when doing local validation.
     if (!getBoolArg(_againstPubFlag) && currentVersion < previousVersion) {
-      final Map<Version, NextVersionType> possibleVersionsFromNewVersion =
-          getAllowedNextVersions(currentVersion, newVersion: previousVersion);
       // Since this skips validation, try to ensure that it really is likely
       // to be a revert rather than a typo by checking that the transition
       // from the lower version to the new version would have been valid.
-      if (possibleVersionsFromNewVersion.containsKey(previousVersion)) {
+      if (_shouldAllowVersionChange(
+          oldVersion: currentVersion, newVersion: previousVersion)) {
         logWarning('${indentation}New version is lower than previous version. '
             'This is assumed to be a revert.');
         return _CurrentVersionState.validRevert;
@@ -352,7 +351,8 @@
     final Map<Version, NextVersionType> allowedNextVersions =
         getAllowedNextVersions(previousVersion, newVersion: currentVersion);
 
-    if (allowedNextVersions.containsKey(currentVersion)) {
+    if (_shouldAllowVersionChange(
+        oldVersion: previousVersion, newVersion: currentVersion)) {
       print('$indentation$previousVersion -> $currentVersion');
     } else {
       printError('${indentation}Incorrectly updated version.\n'
@@ -361,7 +361,13 @@
       return _CurrentVersionState.invalidChange;
     }
 
-    if (allowedNextVersions[currentVersion] == NextVersionType.BREAKING_MAJOR &&
+    // Check whether the version (or for a pre-release, the version that
+    // pre-release would eventually be released as) is a breaking change, and
+    // if so, validate it.
+    final Version targetReleaseVersion =
+        currentVersion.isPreRelease ? currentVersion.nextPatch : currentVersion;
+    if (allowedNextVersions[targetReleaseVersion] ==
+            NextVersionType.BREAKING_MAJOR &&
         !_validateBreakingChange(package)) {
       printError('${indentation}Breaking change detected.\n'
           '${indentation}Breaking changes to platform interfaces are not '
@@ -520,6 +526,27 @@
     return file.readAsStringSync();
   }
 
+  /// Returns true if the given version transition should be allowed.
+  bool _shouldAllowVersionChange(
+      {required Version oldVersion, required Version newVersion}) {
+    // Get the non-pre-release next version mapping.
+    final Map<Version, NextVersionType> allowedNextVersions =
+        getAllowedNextVersions(oldVersion, newVersion: newVersion);
+
+    if (allowedNextVersions.containsKey(newVersion)) {
+      return true;
+    }
+    // Allow a pre-release version of a version that would be a valid
+    // transition.
+    if (newVersion.isPreRelease) {
+      final Version targetReleaseVersion = newVersion.nextPatch;
+      if (allowedNextVersions.containsKey(targetReleaseVersion)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /// Returns an error string if the changes to this package should have
   /// resulted in a version change, or shoud have resulted in a CHANGELOG change
   /// but didn't.
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
index 1e631b1..b8233de 100644
--- a/script/tool/pubspec.yaml
+++ b/script/tool/pubspec.yaml
@@ -1,7 +1,7 @@
 name: flutter_plugin_tools
 description: Productivity utils for flutter/plugins and flutter/packages
 repository: https://github.com/flutter/plugins/tree/main/script/tool
-version: 0.8.7
+version: 0.8.8
 
 dependencies:
   args: ^2.1.0
diff --git a/script/tool/test/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart
index a310f0f..8f8d510 100644
--- a/script/tool/test/version_check_command_test.dart
+++ b/script/tool/test/version_check_command_test.dart
@@ -1178,6 +1178,186 @@
         ]),
       );
     });
+
+    group('prelease versions', () {
+      test(
+          'allow an otherwise-valid transition that also adds a pre-release component',
+          () async {
+        createFakePlugin('plugin', packagesDir, version: '2.0.0-dev');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 1.0.0'),
+        ];
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main']);
+
+        expect(
+          output,
+          containsAllInOrder(<Matcher>[
+            contains('Running for plugin'),
+            contains('1.0.0 -> 2.0.0-dev'),
+          ]),
+        );
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      test('allow releasing a pre-release', () async {
+        createFakePlugin('plugin', packagesDir, version: '1.2.0');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 1.2.0-dev'),
+        ];
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main']);
+
+        expect(
+          output,
+          containsAllInOrder(<Matcher>[
+            contains('Running for plugin'),
+            contains('1.2.0-dev -> 1.2.0'),
+          ]),
+        );
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      // Allow abandoning a pre-release version in favor of a different version
+      // change type.
+      test(
+          'allow an otherwise-valid transition that also removes a pre-release component',
+          () async {
+        createFakePlugin('plugin', packagesDir, version: '2.0.0');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 1.2.0-dev'),
+        ];
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main']);
+
+        expect(
+          output,
+          containsAllInOrder(<Matcher>[
+            contains('Running for plugin'),
+            contains('1.2.0-dev -> 2.0.0'),
+          ]),
+        );
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      test('allow changing only the pre-release version', () async {
+        createFakePlugin('plugin', packagesDir, version: '1.2.0-dev.2');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 1.2.0-dev.1'),
+        ];
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main']);
+
+        expect(
+          output,
+          containsAllInOrder(<Matcher>[
+            contains('Running for plugin'),
+            contains('1.2.0-dev.1 -> 1.2.0-dev.2'),
+          ]),
+        );
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      test('denies invalid version change that also adds a pre-release',
+          () async {
+        createFakePlugin('plugin', packagesDir, version: '0.2.0-dev');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 0.0.1'),
+        ];
+        Error? commandError;
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main'],
+            errorHandler: (Error e) {
+          commandError = e;
+        });
+
+        expect(commandError, isA<ToolExit>());
+        expect(
+            output,
+            containsAllInOrder(<Matcher>[
+              contains('Incorrectly updated version.'),
+            ]));
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      test('denies invalid version change that also removes a pre-release',
+          () async {
+        createFakePlugin('plugin', packagesDir, version: '0.2.0');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 0.0.1-dev'),
+        ];
+        Error? commandError;
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main'],
+            errorHandler: (Error e) {
+          commandError = e;
+        });
+
+        expect(commandError, isA<ToolExit>());
+        expect(
+            output,
+            containsAllInOrder(<Matcher>[
+              contains('Incorrectly updated version.'),
+            ]));
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+
+      test('denies invalid version change between pre-releases', () async {
+        createFakePlugin('plugin', packagesDir, version: '0.2.0-dev');
+        processRunner.mockProcessesForExecutable['git-show'] = <io.Process>[
+          MockProcess(stdout: 'version: 0.0.1-dev'),
+        ];
+        Error? commandError;
+        final List<String> output = await runCapturingPrint(
+            runner, <String>['version-check', '--base-sha=main'],
+            errorHandler: (Error e) {
+          commandError = e;
+        });
+
+        expect(commandError, isA<ToolExit>());
+        expect(
+            output,
+            containsAllInOrder(<Matcher>[
+              contains('Incorrectly updated version.'),
+            ]));
+        expect(
+            processRunner.recordedCalls,
+            containsAllInOrder(const <ProcessCall>[
+              ProcessCall('git-show',
+                  <String>['main:packages/plugin/pubspec.yaml'], null)
+            ]));
+      });
+    });
   });
 
   group('Pre 1.0', () {