| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:file/file.dart'; |
| import 'package:git/git.dart'; |
| import 'package:http/http.dart' as http; |
| import 'package:meta/meta.dart'; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:pubspec_parse/pubspec_parse.dart'; |
| |
| import 'common/core.dart'; |
| import 'common/git_version_finder.dart'; |
| import 'common/plugin_command.dart'; |
| import 'common/process_runner.dart'; |
| import 'common/pub_version_finder.dart'; |
| |
| /// Categories of version change types. |
| enum NextVersionType { |
| /// A breaking change. |
| BREAKING_MAJOR, |
| |
| /// A minor change (e.g., added feature). |
| MINOR, |
| |
| /// A bugfix change. |
| PATCH, |
| |
| /// The release of an existing prerelease version. |
| RELEASE, |
| } |
| |
| /// Returns the set of allowed next versions, with their change type, for |
| /// [masterVersion]. |
| /// |
| /// [headVerison] is used to check whether this is a pre-1.0 version bump, as |
| /// those have different semver rules. |
| @visibleForTesting |
| Map<Version, NextVersionType> getAllowedNextVersions( |
| {required Version masterVersion, required Version headVersion}) { |
| final Map<Version, NextVersionType> allowedNextVersions = |
| <Version, NextVersionType>{ |
| masterVersion.nextMajor: NextVersionType.BREAKING_MAJOR, |
| masterVersion.nextMinor: NextVersionType.MINOR, |
| masterVersion.nextPatch: NextVersionType.PATCH, |
| }; |
| |
| if (masterVersion.major < 1 && headVersion.major < 1) { |
| int nextBuildNumber = -1; |
| if (masterVersion.build.isEmpty) { |
| nextBuildNumber = 1; |
| } else { |
| final int currentBuildNumber = masterVersion.build.first as int; |
| nextBuildNumber = currentBuildNumber + 1; |
| } |
| final Version preReleaseVersion = Version( |
| masterVersion.major, |
| masterVersion.minor, |
| masterVersion.patch, |
| build: nextBuildNumber.toString(), |
| ); |
| allowedNextVersions.clear(); |
| allowedNextVersions[masterVersion.nextMajor] = NextVersionType.RELEASE; |
| allowedNextVersions[masterVersion.nextMinor] = |
| NextVersionType.BREAKING_MAJOR; |
| allowedNextVersions[masterVersion.nextPatch] = NextVersionType.MINOR; |
| allowedNextVersions[preReleaseVersion] = NextVersionType.PATCH; |
| } |
| return allowedNextVersions; |
| } |
| |
| /// A command to validate version changes to packages. |
| class VersionCheckCommand extends PluginCommand { |
| /// Creates an instance of the version check command. |
| VersionCheckCommand( |
| Directory packagesDir, { |
| ProcessRunner processRunner = const ProcessRunner(), |
| GitDir? gitDir, |
| http.Client? httpClient, |
| }) : _pubVersionFinder = |
| PubVersionFinder(httpClient: httpClient ?? http.Client()), |
| super(packagesDir, processRunner: processRunner, gitDir: gitDir) { |
| argParser.addFlag( |
| _againstPubFlag, |
| help: 'Whether the version check should run against the version on pub.\n' |
| 'Defaults to false, which means the version check only run against the previous version in code.', |
| defaultsTo: false, |
| negatable: true, |
| ); |
| } |
| |
| static const String _againstPubFlag = 'against-pub'; |
| |
| @override |
| final String name = 'version-check'; |
| |
| @override |
| final String description = |
| 'Checks if the versions of the plugins have been incremented per pub specification.\n' |
| 'Also checks if the latest version in CHANGELOG matches the version in pubspec.\n\n' |
| 'This command requires "pub" and "flutter" to be in your path.'; |
| |
| final PubVersionFinder _pubVersionFinder; |
| |
| @override |
| Future<void> run() async { |
| final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); |
| |
| final List<String> changedPubspecs = |
| await gitVersionFinder.getChangedPubSpecs(); |
| |
| final List<String> badVersionChangePubspecs = <String>[]; |
| |
| const String indentation = ' '; |
| for (final String pubspecPath in changedPubspecs) { |
| print('Checking versions for $pubspecPath...'); |
| final File pubspecFile = packagesDir.fileSystem.file(pubspecPath); |
| if (!pubspecFile.existsSync()) { |
| print('${indentation}Deleted; skipping.'); |
| continue; |
| } |
| final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); |
| if (pubspec.publishTo == 'none') { |
| print('${indentation}Found "publish_to: none"; skipping.'); |
| continue; |
| } |
| |
| final Version? headVersion = |
| await gitVersionFinder.getPackageVersion(pubspecPath, gitRef: 'HEAD'); |
| if (headVersion == null) { |
| printError('${indentation}No version found. A package that ' |
| 'intentionally has no version should be marked ' |
| '"publish_to: none".'); |
| badVersionChangePubspecs.add(pubspecPath); |
| continue; |
| } |
| Version? sourceVersion; |
| if (getBoolArg(_againstPubFlag)) { |
| final String packageName = pubspecFile.parent.basename; |
| final PubVersionFinderResponse pubVersionFinderResponse = |
| await _pubVersionFinder.getPackageVersion(package: packageName); |
| switch (pubVersionFinderResponse.result) { |
| case PubVersionFinderResult.success: |
| sourceVersion = pubVersionFinderResponse.versions.first; |
| print( |
| '$indentation$packageName: Current largest version on pub: $sourceVersion'); |
| break; |
| case PubVersionFinderResult.fail: |
| printError(''' |
| ${indentation}Error fetching version on pub for $packageName. |
| ${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} |
| ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} |
| '''); |
| badVersionChangePubspecs.add(pubspecPath); |
| continue; |
| case PubVersionFinderResult.noPackageFound: |
| sourceVersion = null; |
| break; |
| } |
| } else { |
| sourceVersion = await gitVersionFinder.getPackageVersion(pubspecPath); |
| } |
| if (sourceVersion == null) { |
| String safeToIgnoreMessage; |
| if (getBoolArg(_againstPubFlag)) { |
| safeToIgnoreMessage = |
| '${indentation}Unable to find package on pub server.'; |
| } else { |
| safeToIgnoreMessage = |
| '${indentation}Unable to find pubspec in master.'; |
| } |
| print('$safeToIgnoreMessage Safe to ignore if the project is new.'); |
| continue; |
| } |
| |
| if (sourceVersion == headVersion) { |
| print('${indentation}No version change.'); |
| continue; |
| } |
| |
| // Check for reverts when doing local validation. |
| if (!getBoolArg(_againstPubFlag) && headVersion < sourceVersion) { |
| final Map<Version, NextVersionType> possibleVersionsFromNewVersion = |
| getAllowedNextVersions( |
| masterVersion: headVersion, headVersion: sourceVersion); |
| // 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(sourceVersion)) { |
| print('${indentation}New version is lower than previous version. ' |
| 'This is assumed to be a revert.'); |
| continue; |
| } |
| } |
| |
| final Map<Version, NextVersionType> allowedNextVersions = |
| getAllowedNextVersions( |
| masterVersion: sourceVersion, headVersion: headVersion); |
| |
| if (!allowedNextVersions.containsKey(headVersion)) { |
| final String source = (getBoolArg(_againstPubFlag)) ? 'pub' : 'master'; |
| printError('${indentation}Incorrectly updated version.\n' |
| '${indentation}HEAD: $headVersion, $source: $sourceVersion.\n' |
| '${indentation}Allowed versions: $allowedNextVersions'); |
| badVersionChangePubspecs.add(pubspecPath); |
| continue; |
| } else { |
| print('$indentation$headVersion -> $sourceVersion'); |
| } |
| |
| final bool isPlatformInterface = |
| pubspec.name.endsWith('_platform_interface'); |
| if (isPlatformInterface && |
| allowedNextVersions[headVersion] == NextVersionType.BREAKING_MAJOR) { |
| printError('$pubspecPath breaking change detected.\n' |
| 'Breaking changes to platform interfaces are strongly discouraged.\n'); |
| badVersionChangePubspecs.add(pubspecPath); |
| continue; |
| } |
| } |
| _pubVersionFinder.httpClient.close(); |
| |
| // TODO(stuartmorgan): Unify the way iteration works for these checks; the |
| // two checks shouldn't be operating independently on different lists. |
| final List<String> mismatchedVersionPlugins = <String>[]; |
| await for (final Directory plugin in getPlugins()) { |
| if (!(await _checkVersionsMatch(plugin))) { |
| mismatchedVersionPlugins.add(plugin.basename); |
| } |
| } |
| |
| bool passed = true; |
| if (badVersionChangePubspecs.isNotEmpty) { |
| passed = false; |
| printError(''' |
| The following pubspecs failed validaton: |
| $indentation${badVersionChangePubspecs.join('\n$indentation')} |
| '''); |
| } |
| if (mismatchedVersionPlugins.isNotEmpty) { |
| passed = false; |
| printError(''' |
| The following pubspecs have different versions in pubspec.yaml and CHANGELOG.md: |
| $indentation${mismatchedVersionPlugins.join('\n$indentation')} |
| '''); |
| } |
| if (!passed) { |
| throw ToolExit(1); |
| } |
| |
| print('No version check errors found!'); |
| } |
| |
| /// Returns whether or not the pubspec version and CHANGELOG version for |
| /// [plugin] match. |
| Future<bool> _checkVersionsMatch(Directory plugin) async { |
| // get version from pubspec |
| final String packageName = plugin.basename; |
| print('-----------------------------------------'); |
| print( |
| 'Checking the first version listed in CHANGELOG.md matches the version in pubspec.yaml for $packageName.'); |
| |
| final Pubspec? pubspec = _tryParsePubspec(plugin); |
| if (pubspec == null) { |
| printError('Cannot parse version from pubspec.yaml'); |
| return false; |
| } |
| final Version? fromPubspec = pubspec.version; |
| |
| // get first version from CHANGELOG |
| final File changelog = plugin.childFile('CHANGELOG.md'); |
| final List<String> lines = changelog.readAsLinesSync(); |
| String? firstLineWithText; |
| final Iterator<String> iterator = lines.iterator; |
| while (iterator.moveNext()) { |
| if (iterator.current.trim().isNotEmpty) { |
| firstLineWithText = iterator.current.trim(); |
| break; |
| } |
| } |
| // Remove all leading mark down syntax from the version line. |
| String? versionString = firstLineWithText?.split(' ').last; |
| |
| // Skip validation for the special NEXT version that's used to accumulate |
| // changes that don't warrant publishing on their own. |
| final bool hasNextSection = versionString == 'NEXT'; |
| if (hasNextSection) { |
| print('Found NEXT; validating next version in the CHANGELOG.'); |
| // Ensure that the version in pubspec hasn't changed without updating |
| // CHANGELOG. That means the next version entry in the CHANGELOG pass the |
| // normal validation. |
| while (iterator.moveNext()) { |
| if (iterator.current.trim().startsWith('## ')) { |
| versionString = iterator.current.trim().split(' ').last; |
| break; |
| } |
| } |
| } |
| |
| final Version? fromChangeLog = |
| versionString == null ? null : Version.parse(versionString); |
| if (fromChangeLog == null) { |
| printError( |
| 'Cannot find version on the first line of ${plugin.path}/CHANGELOG.md'); |
| return false; |
| } |
| |
| if (fromPubspec != fromChangeLog) { |
| printError(''' |
| versions for $packageName in CHANGELOG.md and pubspec.yaml do not match. |
| The version in pubspec.yaml is $fromPubspec. |
| The first version listed in CHANGELOG.md is $fromChangeLog. |
| '''); |
| return false; |
| } |
| |
| // If NEXT wasn't the first section, it should not exist at all. |
| if (!hasNextSection) { |
| final RegExp nextRegex = RegExp(r'^#+\s*NEXT\s*$'); |
| if (lines.any((String line) => nextRegex.hasMatch(line))) { |
| printError(''' |
| When bumping the version for release, the NEXT section should be incorporated |
| into the new version's release notes. |
| '''); |
| return false; |
| } |
| } |
| |
| print('$packageName passed version check'); |
| return true; |
| } |
| |
| Pubspec? _tryParsePubspec(Directory package) { |
| final File pubspecFile = package.childFile('pubspec.yaml'); |
| |
| try { |
| final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); |
| return pubspec; |
| } on Exception catch (exception) { |
| printError( |
| 'Failed to parse `pubspec.yaml` at ${pubspecFile.path}: $exception}'); |
| } |
| return null; |
| } |
| } |