[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

@ -1,5 +1,7 @@
## NEXT ## 0.8.6
- Adds `update-release-info` to apply changelog and optional version changes
across multiple packages.
- Fixes changelog validation when reverting to a `NEXT` state. - Fixes changelog validation when reverting to a `NEXT` state.
- Fixes multiplication of `--force` flag when publishing multiple packages. - Fixes multiplication of `--force` flag when publishing multiple packages.
- Adds minimum deployment target flags to `xcode-analyze` to allow - Adds minimum deployment target flags to `xcode-analyze` to allow

View File

@ -118,6 +118,31 @@ cd <repository root>
dart run ./script/tool/bin/flutter_plugin_tools.dart update-excerpts --packages plugin_name dart run ./script/tool/bin/flutter_plugin_tools.dart update-excerpts --packages plugin_name
``` ```
### Update CHANGELOG and Version
`update-release-info` will automatically update the version and `CHANGELOG.md`
following standard repository style and practice. It can be used for
single-package updates to handle the details of getting the `CHANGELOG.md`
format correct, but is especially useful for bulk updates across multiple packages.
For instance, if you add a new analysis option that requires production
code changes across many packages:
```sh
cd <repository root>
dart run ./script/tool/bin/flutter_plugin_tools.dart update-release-info \
--version=minimal \
--changelog="Fixes violations of new analysis option some_new_option."
```
The `minimal` option for `--version` will skip unchanged packages, and treat
each changed package as either `bugfix` or `next` depending on the files that
have changed in that package, so it is often the best choice for a bulk change.
For cases where you know the change time, `minor` or `bugfix` will make the
corresponding version bump, or `next` will update only `CHANGELOG.md` without
changing the version.
### Publish a Release ### Publish a Release
**Releases are automated for `flutter/plugins` and `flutter/packages`.** **Releases are automated for `flutter/plugins` and `flutter/packages`.**

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 'readme_check_command.dart';
import 'test_command.dart'; import 'test_command.dart';
import 'update_excerpts_command.dart'; import 'update_excerpts_command.dart';
import 'update_release_info_command.dart';
import 'version_check_command.dart'; import 'version_check_command.dart';
import 'xcode_analyze_command.dart'; import 'xcode_analyze_command.dart';
@ -70,6 +71,7 @@ void main(List<String> args) {
..addCommand(ReadmeCheckCommand(packagesDir)) ..addCommand(ReadmeCheckCommand(packagesDir))
..addCommand(TestCommand(packagesDir)) ..addCommand(TestCommand(packagesDir))
..addCommand(UpdateExcerptsCommand(packagesDir)) ..addCommand(UpdateExcerptsCommand(packagesDir))
..addCommand(UpdateReleaseInfoCommand(packagesDir))
..addCommand(VersionCheckCommand(packagesDir)) ..addCommand(VersionCheckCommand(packagesDir))
..addCommand(XcodeAnalyzeCommand(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/core.dart';
import 'common/git_version_finder.dart'; import 'common/git_version_finder.dart';
import 'common/package_looping_command.dart'; import 'common/package_looping_command.dart';
import 'common/package_state_utils.dart';
import 'common/process_runner.dart'; import 'common/process_runner.dart';
import 'common/pub_version_finder.dart'; import 'common/pub_version_finder.dart';
import 'common/repository_package.dart'; import 'common/repository_package.dart';
@ -531,44 +532,16 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog.
final Directory gitRoot = final Directory gitRoot =
packagesDir.fileSystem.directory((await gitDir).path); packagesDir.fileSystem.directory((await gitDir).path);
final String relativePackagePath = final String relativePackagePath =
'${getRelativePosixPath(package.directory, from: gitRoot)}/'; 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;
final List<String> components = p.posix.split(path); final PackageChangeState state = checkPackageChangeState(package,
final bool isChangelog = components.last == 'CHANGELOG.md'; changedPaths: _changedFiles, relativePackagePath: relativePackagePath);
if (isChangelog) {
hasChangelogChange = true;
}
if (!needsVersionChange && if (!state.hasChanges) {
!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) {
return null; return null;
} }
if (needsVersionChange) { if (state.needsVersionChange) {
if (_getChangeDescription().split('\n').any((String line) => if (_getChangeDescription().split('\n').any((String line) =>
line.startsWith(_missingVersionChangeJustificationMarker))) { line.startsWith(_missingVersionChangeJustificationMarker))) {
logWarning('Ignoring lack of version change due to ' 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) => if (_getChangeDescription().split('\n').any((String line) =>
line.startsWith(_missingChangelogChangeJustificationMarker))) { line.startsWith(_missingChangelogChangeJustificationMarker))) {
logWarning('Ignoring lack of CHANGELOG update due to ' logWarning('Ignoring lack of CHANGELOG update due to '

View File

@ -1,7 +1,7 @@
name: flutter_plugin_tools name: flutter_plugin_tools
description: Productivity utils for flutter/plugins and flutter/packages description: Productivity utils for flutter/plugins and flutter/packages
repository: https://github.com/flutter/plugins/tree/main/script/tool repository: https://github.com/flutter/plugins/tree/main/script/tool
version: 0.8.5 version: 0.8.6
dependencies: dependencies:
args: ^2.1.0 args: ^2.1.0

View File

@ -0,0 +1,140 @@
// 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:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/package_state_utils.dart';
import 'package:test/test.dart';
import '../util.dart';
void main() {
late FileSystem fileSystem;
late Directory packagesDir;
setUp(() {
fileSystem = MemoryFileSystem();
packagesDir = createPackagesDirectory(fileSystem: fileSystem);
});
group('checkPackageChangeState', () {
test('reports version change needed for code changes', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_package/lib/plugin.dart',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_package');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
});
test('handles trailing slash on package path', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_package/lib/plugin.dart',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_package/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
expect(state.hasChangelogChange, false);
});
test('does not report version change exempt changes', () async {
final RepositoryPackage package =
createFakePlugin('a_plugin', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_plugin/example/android/lint-baseline.xml',
'packages/a_plugin/example/android/src/androidTest/foo/bar/FooTest.java',
'packages/a_plugin/example/ios/RunnerTests/Foo.m',
'packages/a_plugin/example/ios/RunnerUITests/info.plist',
'packages/a_plugin/tool/a_development_tool.dart',
'packages/a_plugin/CHANGELOG.md',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_plugin/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, false);
expect(state.hasChangelogChange, true);
});
test('only considers a root "tool" folder to be special', () async {
final RepositoryPackage package =
createFakePlugin('a_plugin', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_plugin/lib/foo/tool/tool_thing.dart',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_plugin/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
});
test('requires a version change for example main', () async {
final RepositoryPackage package =
createFakePlugin('a_plugin', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_plugin/example/lib/main.dart',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_plugin/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
});
test('requires a version change for example readme.md', () async {
final RepositoryPackage package =
createFakePlugin('a_plugin', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_plugin/example/README.md',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_plugin/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
});
test('requires a version change for example example.md', () async {
final RepositoryPackage package =
createFakePlugin('a_plugin', packagesDir);
const List<String> changedFiles = <String>[
'packages/a_plugin/example/lib/example.md',
];
final PackageChangeState state = checkPackageChangeState(package,
changedPaths: changedFiles,
relativePackagePath: 'packages/a_plugin/');
expect(state.hasChanges, true);
expect(state.needsVersionChange, true);
});
});
}

View File

@ -0,0 +1,645 @@
// 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 'dart:io' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/update_release_info_command.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'common/plugin_command_test.mocks.dart';
import 'mocks.dart';
import 'util.dart';
void main() {
late FileSystem fileSystem;
late Directory packagesDir;
late MockGitDir gitDir;
late RecordingProcessRunner processRunner;
late CommandRunner<void> runner;
setUp(() {
fileSystem = MemoryFileSystem();
packagesDir = createPackagesDirectory(fileSystem: fileSystem);
processRunner = RecordingProcessRunner();
gitDir = MockGitDir();
when(gitDir.path).thenReturn(packagesDir.parent.path);
when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError')))
.thenAnswer((Invocation invocation) {
final List<String> arguments =
invocation.positionalArguments[0]! as List<String>;
// Route git calls through a process runner, to make mock output
// consistent with other processes. Attach the first argument to the
// command to make targeting the mock results easier.
final String gitCommand = arguments.removeAt(0);
return processRunner.run('git-$gitCommand', arguments);
});
final UpdateReleaseInfoCommand command = UpdateReleaseInfoCommand(
packagesDir,
gitDir: gitDir,
);
runner = CommandRunner<void>(
'update_release_info_command', 'Test for update_release_info_command');
runner.addCommand(command);
});
group('flags', () {
test('fails if --changelog is missing', () async {
Exception? commandError;
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
], exceptionHandler: (Exception e) {
commandError = e;
});
expect(commandError, isA<UsageException>());
});
test('fails if --changelog is blank', () async {
Exception? commandError;
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'',
], exceptionHandler: (Exception e) {
commandError = e;
});
expect(commandError, isA<UsageException>());
});
test('fails if --version is missing', () async {
Exception? commandError;
await runCapturingPrint(
runner, <String>['update-release-info', '--changelog', ''],
exceptionHandler: (Exception e) {
commandError = e;
});
expect(commandError, isA<UsageException>());
});
test('fails if --version is an unknown value', () async {
Exception? commandError;
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=foo',
'--changelog',
'',
], exceptionHandler: (Exception e) {
commandError = e;
});
expect(commandError, isA<UsageException>());
});
});
group('changelog', () {
test('adds new NEXT section', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## 1.0.0
* Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## NEXT
* A change.
$originalChangelog''';
expect(
output,
containsAllInOrder(<Matcher>[
contains(' Added a NEXT section.'),
]),
);
expect(newChangelog, expectedChangeLog);
});
test('adds to existing NEXT section', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## NEXT
* Already-pending changes.
## 1.0.0
* Old changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## NEXT
* A change.
* Already-pending changes.
## 1.0.0
* Old changes.
''';
expect(output,
containsAllInOrder(<Matcher>[contains(' Updated NEXT section.')]));
expect(newChangelog, expectedChangeLog);
});
test('adds new version section', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## 1.0.0
* Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## 1.0.1
* A change.
$originalChangelog''';
expect(
output,
containsAllInOrder(<Matcher>[
contains(' Added a 1.0.1 section.'),
]),
);
expect(newChangelog, expectedChangeLog);
});
test('converts existing NEXT section to version section', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## NEXT
* Already-pending changes.
## 1.0.0
* Old changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## 1.0.1
* A change.
* Already-pending changes.
## 1.0.0
* Old changes.
''';
expect(output,
containsAllInOrder(<Matcher>[contains(' Updated NEXT section.')]));
expect(newChangelog, expectedChangeLog);
});
test('treats multiple lines as multiple list items', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## 1.0.0
* Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'First change.\nSecond change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## 1.0.1
* First change.
* Second change.
$originalChangelog''';
expect(newChangelog, expectedChangeLog);
});
test('adds a period to any lines missing it, and removes whitespace',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## 1.0.0
* Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'First change \nSecond change'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## 1.0.1
* First change.
* Second change.
$originalChangelog''';
expect(newChangelog, expectedChangeLog);
});
test('handles non-standard changelog format', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
# 1.0.0
* A version with the wrong heading format.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## NEXT
* A change.
$originalChangelog''';
expect(output,
containsAllInOrder(<Matcher>[contains(' Added a NEXT section.')]));
expect(newChangelog, expectedChangeLog);
});
test('adds to existing NEXT section using - list style', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## NEXT
- Already-pending changes.
## 1.0.0
- Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'A change.'
]);
final String newChangelog = package.changelogFile.readAsStringSync();
const String expectedChangeLog = '''
## NEXT
- A change.
- Already-pending changes.
## 1.0.0
- Previous changes.
''';
expect(output,
containsAllInOrder(<Matcher>[contains(' Updated NEXT section.')]));
expect(newChangelog, expectedChangeLog);
});
test('skips for "minimal" when there are no changes at all', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.1');
processRunner.mockProcessesForExecutable['git-diff'] = <io.Process>[
MockProcess(stdout: '''
packages/different_package/test/plugin_test.dart
'''),
];
final String originalChangelog = package.changelogFile.readAsStringSync();
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minimal',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.0.1');
expect(package.changelogFile.readAsStringSync(), originalChangelog);
expect(
output,
containsAllInOrder(<Matcher>[
contains('No changes to package'),
contains('Skipped 1 package')
]));
});
test('fails if CHANGELOG.md is missing', () async {
createFakePackage('a_package', packagesDir, includeCommonFiles: false);
Error? commandError;
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minor',
'--changelog',
'A change.',
], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(output,
containsAllInOrder(<Matcher>[contains(' Missing CHANGELOG.md.')]));
});
test('fails if CHANGELOG.md has unexpected NEXT block format', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
const String originalChangelog = '''
## NEXT
Some free-form text that isn't a list.
## 1.0.0
- Previous changes.
''';
package.changelogFile.writeAsStringSync(originalChangelog);
Error? commandError;
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minor',
'--changelog',
'A change.',
], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains(' Existing NEXT section has unrecognized format.')
]));
});
});
group('pubspec', () {
test('does not change for --next', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.0');
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=next',
'--changelog',
'A change.'
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.0.0');
});
test('updates bugfix version for pre-1.0 without existing build number',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '0.1.0');
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '0.1.0+1');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 0.1.0+1')]));
});
test('updates bugfix version for pre-1.0 with existing build number',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '0.1.0+2');
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '0.1.0+3');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 0.1.0+3')]));
});
test('updates bugfix version for post-1.0', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.1');
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=bugfix',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.0.2');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 1.0.2')]));
});
test('updates minor version for pre-1.0', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '0.1.0+2');
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minor',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '0.1.1');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 0.1.1')]));
});
test('updates minor version for post-1.0', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.1');
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minor',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.1.0');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 1.1.0')]));
});
test('updates bugfix version for "minimal" with publish-worthy changes',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.1');
processRunner.mockProcessesForExecutable['git-diff'] = <io.Process>[
MockProcess(stdout: '''
packages/a_package/lib/plugin.dart
'''),
];
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minimal',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.0.2');
expect(
output,
containsAllInOrder(
<Matcher>[contains(' Incremented version to 1.0.2')]));
});
test('no version change for "minimal" with non-publish-worthy changes',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir, version: '1.0.1');
processRunner.mockProcessesForExecutable['git-diff'] = <io.Process>[
MockProcess(stdout: '''
packages/a_package/test/plugin_test.dart
'''),
];
await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minimal',
'--changelog',
'A change.',
]);
final String version = package.parsePubspec().version?.toString() ?? '';
expect(version, '1.0.1');
});
test('fails if there is no version in pubspec', () async {
createFakePackage('a_package', packagesDir, version: null);
Error? commandError;
final List<String> output = await runCapturingPrint(runner, <String>[
'update-release-info',
'--version=minor',
'--changelog',
'A change.',
], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(
<Matcher>[contains('Could not determine current version.')]));
});
});
}

View File

@ -310,14 +310,15 @@ String _pluginPlatformSection(
return entry; return entry;
} }
typedef ErrorHandler = void Function(Error error);
/// Run the command [runner] with the given [args] and return /// Run the command [runner] with the given [args] and return
/// what was printed. /// what was printed.
/// A custom [errorHandler] can be used to handle the runner error as desired without throwing. /// A custom [errorHandler] can be used to handle the runner error as desired without throwing.
Future<List<String>> runCapturingPrint( Future<List<String>> runCapturingPrint(
CommandRunner<void> runner, List<String> args, CommandRunner<void> runner,
{ErrorHandler? errorHandler}) async { List<String> args, {
Function(Error error)? errorHandler,
Function(Exception error)? exceptionHandler,
}) async {
final List<String> prints = <String>[]; final List<String> prints = <String>[];
final ZoneSpecification spec = ZoneSpecification( final ZoneSpecification spec = ZoneSpecification(
print: (_, __, ___, String message) { print: (_, __, ___, String message) {
@ -333,6 +334,11 @@ Future<List<String>> runCapturingPrint(
rethrow; rethrow;
} }
errorHandler(e); errorHandler(e);
} on Exception catch (e) {
if (exceptionHandler == null) {
rethrow;
}
exceptionHandler(e);
} }
return prints; return prints;