[tools] Add update-release-info (#5643)

This commit is contained in:
stuartmorgan
2022-05-18 19:17:29 -04:00
committed by GitHub
parent ca9a81d653
commit dd2fc61b1c
10 changed files with 1238 additions and 40 deletions

View File

@ -0,0 +1,95 @@
// 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:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'repository_package.dart';
/// The state of a package on disk relative to git state.
@immutable
class PackageChangeState {
/// Creates a new immutable state instance.
const PackageChangeState({
required this.hasChanges,
required this.hasChangelogChange,
required this.needsVersionChange,
});
/// True if there are any changes to files in the package.
final bool hasChanges;
/// True if the package's CHANGELOG.md has been changed.
final bool hasChangelogChange;
/// True if any changes in the package require a version change according
/// to repository policy.
final bool needsVersionChange;
}
/// Checks [package] against [changedPaths] to determine what changes it has
/// and how those changes relate to repository policy about CHANGELOG and
/// version updates.
///
/// [changedPaths] should be a list of POSIX-style paths from a common root,
/// and [relativePackagePath] should be the path to [package] from that same
/// root. Commonly these will come from `gitVersionFinder.getChangedFiles()`
/// and `getRelativePoixPath(package.directory, gitDir.path)` respectively;
/// they are arguments mainly to allow for caching the changed paths for an
/// entire command run.
PackageChangeState checkPackageChangeState(
RepositoryPackage package, {
required List<String> changedPaths,
required String relativePackagePath,
}) {
final String packagePrefix = relativePackagePath.endsWith('/')
? relativePackagePath
: '$relativePackagePath/';
bool hasChanges = false;
bool hasChangelogChange = false;
bool needsVersionChange = false;
for (final String path in changedPaths) {
// Only consider files within the package.
if (!path.startsWith(packagePrefix)) {
continue;
}
final String packageRelativePath = path.substring(packagePrefix.length);
hasChanges = true;
final List<String> components = p.posix.split(packageRelativePath);
if (components.isEmpty) {
continue;
}
final bool isChangelog = components.first == 'CHANGELOG.md';
if (isChangelog) {
hasChangelogChange = true;
}
if (!needsVersionChange &&
!isChangelog &&
// One of a few special files example will be shown on pub.dev, but for
// anything else in the example publishing has no purpose.
!(components.first == 'example' &&
!<String>{'main.dart', 'readme.md', 'example.md'}
.contains(components.last.toLowerCase())) &&
// Changes to tests don't need to be published.
!components.contains('test') &&
!components.contains('androidTest') &&
!components.contains('RunnerTests') &&
!components.contains('RunnerUITests') &&
// The top-level "tool" directory is for non-client-facing utility code,
// so doesn't need to be published.
components.first != 'tool' &&
// Ignoring lints doesn't affect clients.
!components.contains('lint-baseline.xml')) {
needsVersionChange = true;
}
}
return PackageChangeState(
hasChanges: hasChanges,
hasChangelogChange: hasChangelogChange,
needsVersionChange: needsVersionChange);
}

View File

@ -29,6 +29,7 @@ import 'pubspec_check_command.dart';
import 'readme_check_command.dart';
import 'test_command.dart';
import 'update_excerpts_command.dart';
import 'update_release_info_command.dart';
import 'version_check_command.dart';
import 'xcode_analyze_command.dart';
@ -70,6 +71,7 @@ void main(List<String> args) {
..addCommand(ReadmeCheckCommand(packagesDir))
..addCommand(TestCommand(packagesDir))
..addCommand(UpdateExcerptsCommand(packagesDir))
..addCommand(UpdateReleaseInfoCommand(packagesDir))
..addCommand(VersionCheckCommand(packagesDir))
..addCommand(XcodeAnalyzeCommand(packagesDir));

View File

@ -0,0 +1,310 @@
// 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:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:git/git.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml_edit/yaml_edit.dart';
import 'common/git_version_finder.dart';
import 'common/package_looping_command.dart';
import 'common/package_state_utils.dart';
import 'common/repository_package.dart';
/// Supported version change types, from smallest to largest component.
enum _VersionIncrementType { build, bugfix, minor }
/// Possible results of attempting to update a CHANGELOG.md file.
enum _ChangelogUpdateOutcome { addedSection, updatedSection, failed }
/// A state machine for the process of updating a CHANGELOG.md.
enum _ChangelogUpdateState {
/// Looking for the first version section.
findingFirstSection,
/// Looking for the first list entry in an existing section.
findingFirstListItem,
/// Finished with updates.
finishedUpdating,
}
/// A command to update the changelog, and optionally version, of packages.
class UpdateReleaseInfoCommand extends PackageLoopingCommand {
/// Creates a publish metadata updater command instance.
UpdateReleaseInfoCommand(
Directory packagesDir, {
GitDir? gitDir,
}) : super(packagesDir, gitDir: gitDir) {
argParser.addOption(_changelogFlag,
mandatory: true,
help: 'The changelog entry to add. '
'Each line will be a separate list entry.');
argParser.addOption(_versionTypeFlag,
mandatory: true,
help: 'The version change level',
allowed: <String>[
_versionNext,
_versionMinimal,
_versionBugfix,
_versionMinor,
],
allowedHelp: <String, String>{
_versionNext:
'No version change; just adds a NEXT entry to the changelog.',
_versionBugfix: 'Increments the bugfix version.',
_versionMinor: 'Increments the minor version.',
_versionMinimal: 'Depending on the changes to each package: '
'increments the bugfix version (for publishable changes), '
"uses NEXT (for changes that don't need to be published), "
'or skips (if no changes).',
});
}
static const String _changelogFlag = 'changelog';
static const String _versionTypeFlag = 'version';
static const String _versionNext = 'next';
static const String _versionBugfix = 'bugfix';
static const String _versionMinor = 'minor';
static const String _versionMinimal = 'minimal';
// The version change type, if there is a set type for all platforms.
//
// If null, either there is no version change, or it is dynamic (`minimal`).
_VersionIncrementType? _versionChange;
// The cache of changed files, for dynamic version change determination.
//
// Only set for `minimal` version change.
late final List<String> _changedFiles;
@override
final String name = 'update-release-info';
@override
final String description = 'Updates CHANGELOG.md files, and optionally the '
'version in pubspec.yaml, in a way that is consistent with version-check '
'enforcement.';
@override
bool get hasLongOutput => false;
@override
Future<void> initializeRun() async {
if (getStringArg(_changelogFlag).trim().isEmpty) {
throw UsageException('Changelog message must not be empty.', usage);
}
switch (getStringArg(_versionTypeFlag)) {
case _versionMinor:
_versionChange = _VersionIncrementType.minor;
break;
case _versionBugfix:
_versionChange = _VersionIncrementType.bugfix;
break;
case _versionMinimal:
final GitVersionFinder gitVersionFinder = await retrieveVersionFinder();
_changedFiles = await gitVersionFinder.getChangedFiles();
// Anothing other than a fixed change is null.
_versionChange = null;
break;
case _versionNext:
_versionChange = null;
break;
default:
throw UnimplementedError('Unimplemented version change type');
}
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
String nextVersionString;
_VersionIncrementType? versionChange = _versionChange;
// If the change type is `minimal` determine what changes, if any, are
// needed.
if (versionChange == null &&
getStringArg(_versionTypeFlag) == _versionMinimal) {
final Directory gitRoot =
packagesDir.fileSystem.directory((await gitDir).path);
final String relativePackagePath =
getRelativePosixPath(package.directory, from: gitRoot);
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: _changedFiles,
relativePackagePath: relativePackagePath);
if (!state.hasChanges) {
return PackageResult.skip('No changes to package');
}
if (state.needsVersionChange) {
versionChange = _VersionIncrementType.bugfix;
}
}
if (versionChange != null) {
final Version? updatedVersion =
_updatePubspecVersion(package, versionChange);
if (updatedVersion == null) {
return PackageResult.fail(
<String>['Could not determine current version.']);
}
nextVersionString = updatedVersion.toString();
print('${indentation}Incremented version to $nextVersionString.');
} else {
nextVersionString = 'NEXT';
}
final _ChangelogUpdateOutcome updateOutcome =
_updateChangelog(package, nextVersionString);
switch (updateOutcome) {
case _ChangelogUpdateOutcome.addedSection:
print('${indentation}Added a $nextVersionString section.');
break;
case _ChangelogUpdateOutcome.updatedSection:
print('${indentation}Updated NEXT section.');
break;
case _ChangelogUpdateOutcome.failed:
return PackageResult.fail(<String>['Could not update CHANGELOG.md.']);
}
return PackageResult.success();
}
_ChangelogUpdateOutcome _updateChangelog(
RepositoryPackage package, String version) {
if (!package.changelogFile.existsSync()) {
printError('${indentation}Missing CHANGELOG.md.');
return _ChangelogUpdateOutcome.failed;
}
final String newHeader = '## $version';
final RegExp listItemPattern = RegExp(r'^(\s*[-*])');
final StringBuffer newChangelog = StringBuffer();
_ChangelogUpdateState state = _ChangelogUpdateState.findingFirstSection;
bool updatedExistingSection = false;
for (final String line in package.changelogFile.readAsLinesSync()) {
switch (state) {
case _ChangelogUpdateState.findingFirstSection:
final String trimmedLine = line.trim();
if (trimmedLine.isEmpty) {
// Discard any whitespace at the top of the file.
} else if (trimmedLine == '## NEXT') {
// Replace the header with the new version (which may also be NEXT).
newChangelog.writeln(newHeader);
// Find the existing list to add to.
state = _ChangelogUpdateState.findingFirstListItem;
} else {
// The first content in the file isn't a NEXT section, so just add
// the new section.
<String>[
newHeader,
'',
..._changelogAdditionsAsList(),
'',
line, // Don't drop the current line.
].forEach(newChangelog.writeln);
state = _ChangelogUpdateState.finishedUpdating;
}
break;
case _ChangelogUpdateState.findingFirstListItem:
final RegExpMatch? match = listItemPattern.firstMatch(line);
if (match != null) {
final String listMarker = match[1]!;
// Add the new items on top. If the new change is changing the
// version, then the new item should be more relevant to package
// clients than anything that was already there. If it's still
// NEXT, the order doesn't matter.
<String>[
..._changelogAdditionsAsList(listMarker: listMarker),
line, // Don't drop the current line.
].forEach(newChangelog.writeln);
state = _ChangelogUpdateState.finishedUpdating;
updatedExistingSection = true;
} else if (line.trim().isEmpty) {
// Scan past empty lines, but keep them.
newChangelog.writeln(line);
} else {
printError(' Existing NEXT section has unrecognized format.');
return _ChangelogUpdateOutcome.failed;
}
break;
case _ChangelogUpdateState.finishedUpdating:
// Once changes are done, add the rest of the lines as-is.
newChangelog.writeln(line);
break;
}
}
package.changelogFile.writeAsStringSync(newChangelog.toString());
return updatedExistingSection
? _ChangelogUpdateOutcome.updatedSection
: _ChangelogUpdateOutcome.addedSection;
}
/// Returns the changelog to add as a Markdown list, using the given list
/// bullet style (default to the repository standard of '*'), and adding
/// any missing periods.
///
/// E.g., 'A line\nAnother line.' will become:
/// ```
/// [ '* A line.', '* Another line.' ]
/// ```
Iterable<String> _changelogAdditionsAsList({String listMarker = '*'}) {
return getStringArg(_changelogFlag).split('\n').map((String entry) {
String standardizedEntry = entry.trim();
if (!standardizedEntry.endsWith('.')) {
standardizedEntry = '$standardizedEntry.';
}
return '$listMarker $standardizedEntry';
});
}
/// Updates the version in [package]'s pubspec according to [type], returning
/// the new version, or null if there was an error updating the version.
Version? _updatePubspecVersion(
RepositoryPackage package, _VersionIncrementType type) {
final Pubspec pubspec = package.parsePubspec();
final Version? currentVersion = pubspec.version;
if (currentVersion == null) {
printError('${indentation}No version in pubspec.yaml');
return null;
}
// For versions less than 1.0, shift the change down one component per
// Dart versioning conventions.
final _VersionIncrementType adjustedType = currentVersion.major > 0
? type
: _VersionIncrementType.values[type.index - 1];
final Version newVersion = _nextVersion(currentVersion, adjustedType);
// Write the new version to the pubspec.
final YamlEditor editablePubspec =
YamlEditor(package.pubspecFile.readAsStringSync());
editablePubspec.update(<String>['version'], newVersion.toString());
package.pubspecFile.writeAsStringSync(editablePubspec.toString());
return newVersion;
}
Version _nextVersion(Version version, _VersionIncrementType type) {
switch (type) {
case _VersionIncrementType.minor:
return version.nextMinor;
case _VersionIncrementType.bugfix:
return version.nextPatch;
case _VersionIncrementType.build:
final int buildNumber =
version.build.isEmpty ? 0 : version.build.first as int;
return Version(version.major, version.minor, version.patch,
build: '${buildNumber + 1}');
}
}
}

View File

@ -13,6 +13,7 @@ import 'package:pub_semver/pub_semver.dart';
import 'common/core.dart';
import 'common/git_version_finder.dart';
import 'common/package_looping_command.dart';
import 'common/package_state_utils.dart';
import 'common/process_runner.dart';
import 'common/pub_version_finder.dart';
import 'common/repository_package.dart';
@ -531,44 +532,16 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog.
final Directory gitRoot =
packagesDir.fileSystem.directory((await gitDir).path);
final String relativePackagePath =
'${getRelativePosixPath(package.directory, from: gitRoot)}/';
bool hasChanges = false;
bool needsVersionChange = false;
bool hasChangelogChange = false;
for (final String path in _changedFiles) {
// Only consider files within the package.
if (!path.startsWith(relativePackagePath)) {
continue;
}
hasChanges = true;
getRelativePosixPath(package.directory, from: gitRoot);
final List<String> components = p.posix.split(path);
final bool isChangelog = components.last == 'CHANGELOG.md';
if (isChangelog) {
hasChangelogChange = true;
}
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: _changedFiles, relativePackagePath: relativePackagePath);
if (!needsVersionChange &&
!isChangelog &&
// The example's main.dart is shown on pub.dev, but for anything else
// in the example publishing has no purpose.
!(components.contains('example') && components.last != 'main.dart') &&
// Changes to tests don't need to be published.
!components.contains('test') &&
!components.contains('androidTest') &&
!components.contains('RunnerTests') &&
!components.contains('RunnerUITests') &&
// Ignoring lints doesn't affect clients.
!components.contains('lint-baseline.xml')) {
needsVersionChange = true;
}
}
if (!hasChanges) {
if (!state.hasChanges) {
return null;
}
if (needsVersionChange) {
if (state.needsVersionChange) {
if (_getChangeDescription().split('\n').any((String line) =>
line.startsWith(_missingVersionChangeJustificationMarker))) {
logWarning('Ignoring lack of version change due to '
@ -586,7 +559,7 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog.
}
}
if (!hasChangelogChange) {
if (!state.hasChangelogChange) {
if (_getChangeDescription().split('\n').any((String line) =>
line.startsWith(_missingChangelogChangeJustificationMarker))) {
logWarning('Ignoring lack of CHANGELOG update due to '