| // 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:http/http.dart' as http; |
| import 'package:meta/meta.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:pub_semver/pub_semver.dart'; |
| |
| import 'common/git_version_finder.dart'; |
| import 'common/output_utils.dart'; |
| import 'common/package_looping_command.dart'; |
| import 'common/package_state_utils.dart'; |
| import 'common/pub_version_finder.dart'; |
| import 'common/repository_package.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 pre-1.0 version. |
| V1_RELEASE, |
| } |
| |
| /// The state of a package's version relative to the comparison base. |
| enum _CurrentVersionState { |
| /// The version is unchanged. |
| unchanged, |
| |
| /// The version has increased, and the transition is valid. |
| validIncrease, |
| |
| /// The version has decrease, and the transition is a valid revert. |
| validRevert, |
| |
| /// The version has changed, and the transition is invalid. |
| invalidChange, |
| |
| /// The package is new. |
| newPackage, |
| |
| /// There was an error determining the version state. |
| unknown, |
| } |
| |
| /// 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. |
| @visibleForTesting |
| Map<Version, NextVersionType> getAllowedNextVersions( |
| Version version, { |
| required Version newVersion, |
| }) { |
| final Map<Version, NextVersionType> allowedNextVersions = |
| <Version, NextVersionType>{ |
| version.nextMajor: NextVersionType.BREAKING_MAJOR, |
| version.nextMinor: NextVersionType.MINOR, |
| version.nextPatch: NextVersionType.PATCH, |
| }; |
| |
| if (version.major < 1 && newVersion.major < 1) { |
| int nextBuildNumber = -1; |
| if (version.build.isEmpty) { |
| nextBuildNumber = 1; |
| } else { |
| final int currentBuildNumber = version.build.first as int; |
| nextBuildNumber = currentBuildNumber + 1; |
| } |
| final Version nextBuildVersion = Version( |
| version.major, |
| version.minor, |
| version.patch, |
| build: nextBuildNumber.toString(), |
| ); |
| allowedNextVersions.clear(); |
| allowedNextVersions[version.nextMajor] = NextVersionType.V1_RELEASE; |
| allowedNextVersions[version.nextMinor] = NextVersionType.BREAKING_MAJOR; |
| allowedNextVersions[version.nextPatch] = NextVersionType.MINOR; |
| allowedNextVersions[nextBuildVersion] = NextVersionType.PATCH; |
| } |
| return allowedNextVersions; |
| } |
| |
| /// A command to validate version changes to packages. |
| class VersionCheckCommand extends PackageLoopingCommand { |
| /// Creates an instance of the version check command. |
| VersionCheckCommand( |
| super.packagesDir, { |
| super.processRunner, |
| super.platform, |
| super.gitDir, |
| http.Client? httpClient, |
| }) : _pubVersionFinder = |
| PubVersionFinder(httpClient: httpClient ?? http.Client()) { |
| 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.', |
| ); |
| argParser.addOption(_prLabelsArg, |
| help: 'A comma-separated list of labels associated with this PR, ' |
| 'if applicable.\n\n' |
| 'If supplied, this may be to allow overrides to some version ' |
| 'checks.'); |
| argParser.addFlag(_checkForMissingChanges, |
| help: 'Validates that changes to packages include CHANGELOG and ' |
| 'version changes unless they meet an established exemption.\n\n' |
| 'If used with --$_prLabelsArg, this is should only be ' |
| 'used in pre-submit CI checks, to prevent post-submit breakage ' |
| 'when labels are no longer applicable.', |
| hide: true); |
| argParser.addFlag(_ignorePlatformInterfaceBreaks, |
| help: 'Bypasses the check that platform interfaces do not contain ' |
| 'breaking changes.\n\n' |
| 'This is only intended for use in post-submit CI checks, to ' |
| 'prevent post-submit breakage when overriding the check with ' |
| 'labels. Pre-submit checks should always use ' |
| '--$_prLabelsArg instead.', |
| hide: true); |
| } |
| |
| static const String _againstPubFlag = 'against-pub'; |
| static const String _prLabelsArg = 'pr-labels'; |
| static const String _checkForMissingChanges = 'check-for-missing-changes'; |
| static const String _ignorePlatformInterfaceBreaks = |
| 'ignore-platform-interface-breaks'; |
| |
| /// The label that must be on a PR to allow a breaking |
| /// change to a platform interface. |
| static const String _breakingChangeOverrideLabel = |
| 'override: allow breaking change'; |
| |
| /// The label that must be on a PR to allow skipping a version change for a PR |
| /// that would normally require one. |
| static const String _missingVersionChangeOverrideLabel = |
| 'override: no versioning needed'; |
| |
| /// The label that must be on a PR to allow skipping a CHANGELOG change for a |
| /// PR that would normally require one. |
| static const String _missingChangelogChangeOverrideLabel = |
| 'override: no changelog needed'; |
| |
| final PubVersionFinder _pubVersionFinder; |
| |
| late final GitVersionFinder _gitVersionFinder; |
| late final String _mergeBase; |
| late final List<String> _changedFiles; |
| |
| late final Set<String> _prLabels = _getPRLabels(); |
| |
| @override |
| final String name = 'version-check'; |
| |
| @override |
| List<String> get aliases => <String>['check-version']; |
| |
| @override |
| final String description = |
| 'Checks if the versions of packages 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.'; |
| |
| @override |
| bool get hasLongOutput => false; |
| |
| @override |
| Future<void> initializeRun() async { |
| _gitVersionFinder = await retrieveVersionFinder(); |
| _mergeBase = await _gitVersionFinder.getBaseSha(); |
| _changedFiles = await _gitVersionFinder.getChangedFiles(); |
| } |
| |
| @override |
| Future<PackageResult> runForPackage(RepositoryPackage package) async { |
| final Pubspec? pubspec = _tryParsePubspec(package); |
| if (pubspec == null) { |
| // No remaining checks make sense, so fail immediately. |
| return PackageResult.fail(<String>['Invalid pubspec.yaml.']); |
| } |
| |
| if (pubspec.publishTo == 'none') { |
| return PackageResult.skip('Found "publish_to: none".'); |
| } |
| |
| final Version? currentPubspecVersion = pubspec.version; |
| if (currentPubspecVersion == null) { |
| printError('${indentation}No version found in pubspec.yaml. A package ' |
| 'that intentionally has no version should be marked ' |
| '"publish_to: none".'); |
| // No remaining checks make sense, so fail immediately. |
| return PackageResult.fail(<String>['No pubspec.yaml version.']); |
| } |
| |
| final List<String> errors = <String>[]; |
| |
| bool versionChanged; |
| final _CurrentVersionState versionState = |
| await _getVersionState(package, pubspec: pubspec); |
| switch (versionState) { |
| case _CurrentVersionState.unchanged: |
| versionChanged = false; |
| case _CurrentVersionState.validIncrease: |
| case _CurrentVersionState.validRevert: |
| case _CurrentVersionState.newPackage: |
| versionChanged = true; |
| case _CurrentVersionState.invalidChange: |
| versionChanged = true; |
| errors.add('Disallowed version change.'); |
| case _CurrentVersionState.unknown: |
| versionChanged = false; |
| errors.add('Unable to determine previous version.'); |
| } |
| |
| if (!(await _validateChangelogVersion(package, |
| pubspec: pubspec, pubspecVersionState: versionState))) { |
| errors.add('CHANGELOG.md failed validation.'); |
| } |
| |
| // If there are no other issues, make sure that there isn't a missing |
| // change to the version and/or CHANGELOG. |
| if (getBoolArg(_checkForMissingChanges) && |
| !versionChanged && |
| errors.isEmpty) { |
| final String? error = await _checkForMissingChangeError(package); |
| if (error != null) { |
| errors.add(error); |
| } |
| } |
| |
| return errors.isEmpty |
| ? PackageResult.success() |
| : PackageResult.fail(errors); |
| } |
| |
| @override |
| Future<void> completeRun() async { |
| _pubVersionFinder.httpClient.close(); |
| } |
| |
| /// Returns the previous published version of [package]. |
| /// |
| /// [packageName] must be the actual name of the package as published (i.e., |
| /// the name from pubspec.yaml, not the on disk name if different.) |
| Future<Version?> _fetchPreviousVersionFromPub(String packageName) async { |
| final PubVersionFinderResponse pubVersionFinderResponse = |
| await _pubVersionFinder.getPackageVersion(packageName: packageName); |
| switch (pubVersionFinderResponse.result) { |
| case PubVersionFinderResult.success: |
| return pubVersionFinderResponse.versions.first; |
| case PubVersionFinderResult.fail: |
| printError(''' |
| ${indentation}Error fetching version on pub for $packageName. |
| ${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} |
| ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} |
| '''); |
| return null; |
| case PubVersionFinderResult.noPackageFound: |
| return Version.none; |
| } |
| } |
| |
| /// Returns the version of [package] from git at the base comparison hash. |
| Future<Version?> _getPreviousVersionFromGit(RepositoryPackage package) async { |
| final File pubspecFile = package.pubspecFile; |
| final String relativePath = |
| path.relative(pubspecFile.absolute.path, from: (await gitDir).path); |
| // Use Posix-style paths for git. |
| final String gitPath = path.style == p.Style.windows |
| ? p.posix.joinAll(path.split(relativePath)) |
| : relativePath; |
| return _gitVersionFinder.getPackageVersion(gitPath, gitRef: _mergeBase); |
| } |
| |
| /// Returns the state of the verison of [package] relative to the comparison |
| /// base (git or pub, depending on flags). |
| Future<_CurrentVersionState> _getVersionState( |
| RepositoryPackage package, { |
| required Pubspec pubspec, |
| }) async { |
| // This method isn't called unless `version` is non-null. |
| final Version currentVersion = pubspec.version!; |
| Version? previousVersion; |
| String previousVersionSource; |
| if (getBoolArg(_againstPubFlag)) { |
| previousVersionSource = 'pub'; |
| previousVersion = await _fetchPreviousVersionFromPub(pubspec.name); |
| if (previousVersion == null) { |
| return _CurrentVersionState.unknown; |
| } |
| if (previousVersion != Version.none) { |
| print( |
| '$indentation${pubspec.name}: Current largest version on pub: $previousVersion'); |
| } |
| } else { |
| previousVersionSource = _mergeBase; |
| previousVersion = |
| await _getPreviousVersionFromGit(package) ?? Version.none; |
| } |
| if (previousVersion == Version.none) { |
| print('${indentation}Unable to find previous version ' |
| '${getBoolArg(_againstPubFlag) ? 'on pub server' : 'at git base'}.'); |
| logWarning( |
| '${indentation}If this package is not new, something has gone wrong.'); |
| return _CurrentVersionState.newPackage; |
| } |
| |
| if (previousVersion == currentVersion) { |
| print('${indentation}No version change.'); |
| return _CurrentVersionState.unchanged; |
| } |
| |
| // Check for reverts when doing local validation. |
| if (!getBoolArg(_againstPubFlag) && currentVersion < 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 (_shouldAllowVersionChange( |
| oldVersion: currentVersion, newVersion: previousVersion)) { |
| logWarning('${indentation}New version is lower than previous version. ' |
| 'This is assumed to be a revert.'); |
| return _CurrentVersionState.validRevert; |
| } |
| } |
| |
| final Map<Version, NextVersionType> allowedNextVersions = |
| getAllowedNextVersions(previousVersion, newVersion: currentVersion); |
| |
| if (_shouldAllowVersionChange( |
| oldVersion: previousVersion, newVersion: currentVersion)) { |
| print('$indentation$previousVersion -> $currentVersion'); |
| } else { |
| printError('${indentation}Incorrectly updated version.\n' |
| '${indentation}HEAD: $currentVersion, $previousVersionSource: $previousVersion.\n' |
| '${indentation}Allowed versions: $allowedNextVersions'); |
| return _CurrentVersionState.invalidChange; |
| } |
| |
| // 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 ' |
| 'allowed without explicit justification.\n' |
| '${indentation}See ' |
| 'https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md ' |
| 'for more information.'); |
| return _CurrentVersionState.invalidChange; |
| } |
| |
| return _CurrentVersionState.validIncrease; |
| } |
| |
| /// Checks whether or not [package]'s CHANGELOG's versioning is correct, |
| /// both that it matches [pubspec] and that NEXT is used correctly, printing |
| /// the results of its checks. |
| /// |
| /// Returns false if the CHANGELOG fails validation. |
| Future<bool> _validateChangelogVersion( |
| RepositoryPackage package, { |
| required Pubspec pubspec, |
| required _CurrentVersionState pubspecVersionState, |
| }) async { |
| // This method isn't called unless `version` is non-null. |
| final Version fromPubspec = pubspec.version!; |
| |
| // get first version from CHANGELOG |
| final File changelog = package.changelogFile; |
| 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; |
| String? leadingMarkdown = firstLineWithText?.split(' ').first; |
| |
| final String badNextErrorMessage = '${indentation}When bumping the version ' |
| 'for release, the NEXT section should be incorporated into the new ' |
| "version's release notes."; |
| |
| // 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) { |
| // NEXT should not be present in a commit that increases the version. |
| if (pubspecVersionState == _CurrentVersionState.validIncrease || |
| pubspecVersionState == _CurrentVersionState.invalidChange) { |
| printError(badNextErrorMessage); |
| return false; |
| } |
| print( |
| '${indentation}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 should |
| // pass the normal validation. |
| versionString = null; |
| leadingMarkdown = null; |
| while (iterator.moveNext()) { |
| if (iterator.current.trim().startsWith('## ')) { |
| versionString = iterator.current.trim().split(' ').last; |
| leadingMarkdown = iterator.current.trim().split(' ').first; |
| break; |
| } |
| } |
| } |
| |
| final bool validLeadingMarkdown = leadingMarkdown == '##'; |
| if (versionString == null || !validLeadingMarkdown) { |
| printError('${indentation}Unable to find a version in CHANGELOG.md'); |
| print('${indentation}The current version should be on a line starting ' |
| 'with "## ", either on the first non-empty line or after a "## NEXT" ' |
| 'section.'); |
| return false; |
| } |
| |
| final Version fromChangeLog; |
| try { |
| fromChangeLog = Version.parse(versionString); |
| } on FormatException { |
| printError('"$versionString" could not be parsed as a version.'); |
| return false; |
| } |
| |
| if (fromPubspec != fromChangeLog) { |
| printError(''' |
| ${indentation}Versions in CHANGELOG.md and pubspec.yaml do not match. |
| ${indentation}The version in pubspec.yaml is $fromPubspec. |
| ${indentation}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(badNextErrorMessage); |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| Pubspec? _tryParsePubspec(RepositoryPackage package) { |
| try { |
| final Pubspec pubspec = package.parsePubspec(); |
| return pubspec; |
| } on Exception catch (exception) { |
| printError('${indentation}Failed to parse `pubspec.yaml`: $exception}'); |
| return null; |
| } |
| } |
| |
| /// Checks whether the current breaking change to [package] should be allowed, |
| /// logging extra information for auditing when allowing unusual cases. |
| bool _validateBreakingChange(RepositoryPackage package) { |
| // Only platform interfaces have breaking change restrictions. |
| if (!package.isPlatformInterface) { |
| return true; |
| } |
| |
| if (getBoolArg(_ignorePlatformInterfaceBreaks)) { |
| logWarning( |
| '${indentation}Allowing breaking change to ${package.displayName} ' |
| 'due to --$_ignorePlatformInterfaceBreaks'); |
| return true; |
| } |
| |
| if (_prLabels.contains(_breakingChangeOverrideLabel)) { |
| logWarning( |
| '${indentation}Allowing breaking change to ${package.displayName} ' |
| 'due to the "$_breakingChangeOverrideLabel" label.'); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /// Returns the labels associated with this PR, if any, or an empty set |
| /// if that flag is not provided. |
| Set<String> _getPRLabels() { |
| final String labels = getStringArg(_prLabelsArg); |
| if (labels.isEmpty) { |
| return <String>{}; |
| } |
| return labels.split(',').map((String label) => label.trim()).toSet(); |
| } |
| |
| /// 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. |
| /// |
| /// This should only be called if the version did not change. |
| Future<String?> _checkForMissingChangeError(RepositoryPackage package) async { |
| // Find the relative path to the current package, as it would appear at the |
| // beginning of a path reported by getChangedFiles() (which always uses |
| // Posix paths). |
| final Directory gitRoot = |
| packagesDir.fileSystem.directory((await gitDir).path); |
| final String relativePackagePath = |
| getRelativePosixPath(package.directory, from: gitRoot); |
| |
| final PackageChangeState state = await checkPackageChangeState(package, |
| changedPaths: _changedFiles, |
| relativePackagePath: relativePackagePath, |
| git: await retrieveVersionFinder()); |
| |
| if (!state.hasChanges) { |
| return null; |
| } |
| |
| if (state.needsVersionChange) { |
| if (_prLabels.contains(_missingVersionChangeOverrideLabel)) { |
| logWarning('Ignoring lack of version change due to the ' |
| '"$_missingVersionChangeOverrideLabel" label.'); |
| } else { |
| printError( |
| 'No version change found, but the change to this package could ' |
| 'not be verified to be exempt\n' |
| 'from version changes according to repository policy.\n' |
| 'If this is a false positive, please comment in ' |
| 'the PR to explain why the PR\n' |
| 'is exempt, and add (or ask your reviewer to add) the ' |
| '"$_missingVersionChangeOverrideLabel" label.'); |
| return 'Missing version change'; |
| } |
| } |
| |
| if (!state.hasChangelogChange && state.needsChangelogChange) { |
| if (_prLabels.contains(_missingChangelogChangeOverrideLabel)) { |
| logWarning('Ignoring lack of CHANGELOG update due to the ' |
| '"$_missingChangelogChangeOverrideLabel" label.'); |
| } else { |
| printError('No CHANGELOG change found.\n' |
| 'If this PR needs an exemption from the standard policy of listing ' |
| 'all changes in the CHANGELOG,\n' |
| 'comment in the PR to explain why the PR is exempt, and add (or ' |
| 'ask your reviewer to add) the\n' |
| '"$_missingChangelogChangeOverrideLabel" label.\n' |
| 'Otherwise, please add a NEXT entry in the CHANGELOG as described in ' |
| 'the contributing guide.'); |
| return 'Missing CHANGELOG change'; |
| } |
| } |
| |
| return null; |
| } |
| } |