diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml new file mode 100644 index 00000000000..d388ed27478 --- /dev/null +++ b/.github/workflows/batch_release_pr.yml @@ -0,0 +1,40 @@ +name: "Creates Batch Release for A Package" + +on: + repository_dispatch: + types: [batch_release_pr] + +jobs: + create_release_pr: + runs-on: ubuntu-latest + env: + BRANCH_NAME: ${{ github.event.client_payload.package }}-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - name: Set up tools + run: dart pub get + working-directory: ${{ github.workspace }}/script/tool + - name: create batch release PR + run: | + git config --global user.name ${{ secrets.USER_NAME }} + git config --global user.email ${{ secrets.USER_EMAIL }} + dart ./script/tool/lib/src/main.dart branch-for-batch-release --packages=${{ github.event.client_payload.package }} --branch=${{ env.BRANCH_NAME }} --remote=origin + - name: Check if branch was created + id: check-branch-exists + uses: GuillaumeFalourd/branch-exists@v1.1 + with: + branch: ${{ env.BRANCH_NAME }} + + - name: Create Pull Request + if: steps.check-branch-exists.outputs.exists == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "[${{ github.event.client_payload.package }}] Batch release" + title: "[${{ github.event.client_payload.package }}] Batch release" + body: "This PR was created automatically to batch release the `${{ github.event.client_payload.package }}`." + branch: ${{ env.BRANCH_NAME }} + base: release + + diff --git a/.github/workflows/go_router_batch.yml b/.github/workflows/go_router_batch.yml new file mode 100644 index 00000000000..f07cb5b242c --- /dev/null +++ b/.github/workflows/go_router_batch.yml @@ -0,0 +1,17 @@ +name: "Creates Batch Release for go_router" + +on: + schedule: + # Run every Monday at 8:00 AM + - cron: "0 8 * * 1" + +jobs: + dispatch_release_pr: + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v4 + with: + token: "${{ secrets.GITHUB_TOKEN }}" + event-type: batch_release_pr + client-payload: '{"package": "go_router"}' diff --git a/packages/go_router/pending_changelogs/template.yaml b/packages/go_router/pending_changelogs/template.yaml new file mode 100644 index 00000000000..97107d891a9 --- /dev/null +++ b/packages/go_router/pending_changelogs/template.yaml @@ -0,0 +1,6 @@ +# Use this file as template to draft a unreleased changelog file. +# Make a copy of this file in the same directory, rename it, and fill in the details. +changelog: | + - Can include a list of changes. + - with markdown supported. +version: diff --git a/packages/go_router/pending_changelogs/test_only_1.yaml b/packages/go_router/pending_changelogs/test_only_1.yaml new file mode 100644 index 00000000000..fcaa8a27736 --- /dev/null +++ b/packages/go_router/pending_changelogs/test_only_1.yaml @@ -0,0 +1,6 @@ +# This file is for test purposes only. +# TODO(chuntai): remove this file before publishing. +changelog: | + - Adds 'batch' option to CI config for go_router package. + - Updates GitHub Actions workflow for batch releases of go_router. +version: major diff --git a/packages/go_router/pending_changelogs/test_only_2.yaml b/packages/go_router/pending_changelogs/test_only_2.yaml new file mode 100644 index 00000000000..dbecb2c8e07 --- /dev/null +++ b/packages/go_router/pending_changelogs/test_only_2.yaml @@ -0,0 +1,5 @@ +# This file is for test purposes only. +# TODO(chuntai): remove this file before publishing. +changelog: | + - Adds some other features +version: minor diff --git a/script/tool/lib/src/branch_for_batch_release_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart new file mode 100644 index 00000000000..7f9468f48a8 --- /dev/null +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -0,0 +1,327 @@ +// Copyright 2013 The Flutter Authors +// 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 'dart:math' as math; + +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import 'common/core.dart'; +import 'common/output_utils.dart'; +import 'common/package_command.dart'; +import 'common/repository_package.dart'; + +const int _kExitPackageMalformed = 3; +const int _kGitFailedToPush = 4; + +// The template file name used to draft a pending changelog file. +// This file will not be picked up by the batch release process. +const String _kTemplateFileName = 'template.yaml'; + +/// A command to create a remote branch with release changes for a single package. +class BranchForBatchReleaseCommand extends PackageCommand { + /// Creates a new `branch-for-batch-release` command. + BranchForBatchReleaseCommand( + super.packagesDir, { + super.processRunner, + super.platform, + super.gitDir, + }) { + argParser.addOption( + 'branch', + mandatory: true, + abbr: 'b', + help: 'The branch name to contain the release commit', + ); + argParser.addOption( + 'remote', + mandatory: true, + abbr: 'r', + help: 'The remote to push the branch to.', + ); + } + + @override + final String name = 'branch-for-batch-release'; + + @override + final String description = 'Creates a release PR for a single package.'; + + @override + Future run() async { + final String branchName = getStringArg('branch'); + final String remoteName = getStringArg('remote'); + + final List packages = await getTargetPackages() + .map((PackageEnumerationEntry e) => e.package) + .toList(); + if (packages.length != 1) { + printError('Exactly one package must be specified.'); + throw ToolExit(2); + } + final RepositoryPackage package = packages.single; + + final GitDir repository = await gitDir; + + print('Parsing package "${package.displayName}"...'); + final _PendingChangelogs pendingChangelogs = + await _getPendingChangelogs(package); + if (pendingChangelogs.entries.isEmpty) { + print('No pending changelogs found for ${package.displayName}.'); + return; + } + + final Pubspec pubspec = + Pubspec.parse(package.pubspecFile.readAsStringSync()); + if (pubspec.version == null || pubspec.version!.major < 1) { + printError( + 'This script only supports packages with version >= 1.0.0. Current version: ${pubspec.version}. Package: ${package.displayName}.'); + throw ToolExit(_kExitPackageMalformed); + } + final _ReleaseInfo releaseInfo = + _getReleaseInfo(pendingChangelogs.entries, pubspec.version!); + + if (releaseInfo.newVersion == null) { + print('No version change specified in pending changelogs for ' + '${package.displayName}.'); + return; + } + + await _generateCommitAndBranch( + git: repository, + package: package, + branchName: branchName, + pendingChangelogFiles: pendingChangelogs.files, + releaseInfo: releaseInfo, + remoteName: remoteName, + ); + } + + Future<_PendingChangelogs> _getPendingChangelogs( + RepositoryPackage package) async { + final Directory pendingChangelogsDir = + package.directory.childDirectory('pending_changelogs'); + if (!pendingChangelogsDir.existsSync()) { + printError( + 'No pending_changelogs folder found for ${package.displayName}.'); + throw ToolExit(_kExitPackageMalformed); + } + final List pendingChangelogFiles = pendingChangelogsDir + .listSync() + .whereType() + .where((File f) => + f.basename.endsWith('.yaml') && f.basename != _kTemplateFileName) + .toList(); + try { + final List<_PendingChangelogEntry> entries = pendingChangelogFiles + .map<_PendingChangelogEntry>( + (File f) => _PendingChangelogEntry.parse(f.readAsStringSync())) + .toList(); + return _PendingChangelogs(entries, pendingChangelogFiles); + } on FormatException catch (e) { + printError('Malformed pending changelog file: $e'); + throw ToolExit(_kExitPackageMalformed); + } + } + + _ReleaseInfo _getReleaseInfo( + List<_PendingChangelogEntry> pendingChangelogEntries, + Version oldVersion) { + final List changelogs = []; + int versionIndex = _VersionChange.skip.index; + for (final _PendingChangelogEntry entry in pendingChangelogEntries) { + changelogs.add(entry.changelog); + versionIndex = math.min(versionIndex, entry.version.index); + } + final _VersionChange effectiveVersionChange = + _VersionChange.values[versionIndex]; + + final Version? newVersion = switch (effectiveVersionChange) { + _VersionChange.skip => null, + _VersionChange.major => Version(oldVersion.major + 1, 0, 0), + _VersionChange.minor => + Version(oldVersion.major, oldVersion.minor + 1, 0), + _VersionChange.patch => + Version(oldVersion.major, oldVersion.minor, oldVersion.patch + 1), + }; + return _ReleaseInfo(newVersion, changelogs); + } + + Future _generateCommitAndBranch({ + required GitDir git, + required RepositoryPackage package, + required String branchName, + required List pendingChangelogFiles, + required _ReleaseInfo releaseInfo, + required String remoteName, + }) async { + print(' Creating new branch "$branchName"...'); + final io.ProcessResult checkoutResult = + await git.runCommand(['checkout', '-b', branchName]); + if (checkoutResult.exitCode != 0) { + printError( + 'Failed to create branch $branchName: ${checkoutResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + + _updatePubspec(package, releaseInfo.newVersion!); + _updateChangelog(package, releaseInfo); + await _removePendingChangelogs(git, pendingChangelogFiles); + await _stageAndCommitChanges(git, package); + await _pushBranch(git, remoteName, branchName); + } + + void _updatePubspec(RepositoryPackage package, Version newVersion) { + final YamlEditor editablePubspec = + YamlEditor(package.pubspecFile.readAsStringSync()); + editablePubspec.update(['version'], newVersion.toString()); + package.pubspecFile.writeAsStringSync(editablePubspec.toString()); + } + + void _updateChangelog(RepositoryPackage package, _ReleaseInfo releaseInfo) { + final String newHeader = '## ${releaseInfo.newVersion}'; + final List newEntries = releaseInfo.changelogs; + + final String oldChangelogContent = package.changelogFile.readAsStringSync(); + final StringBuffer newChangelog = StringBuffer(); + + newChangelog.writeln(newHeader); + newChangelog.writeln(); + newChangelog.writeln(newEntries.join('\n')); + newChangelog.writeln(); + newChangelog.write(oldChangelogContent); + + package.changelogFile.writeAsStringSync(newChangelog.toString()); + } + + Future _removePendingChangelogs( + GitDir git, List pendingChangelogFiles) async { + for (final File file in pendingChangelogFiles) { + final io.ProcessResult rmResult = + await git.runCommand(['rm', file.path]); + if (rmResult.exitCode != 0) { + printError('Failed to rm ${file.path}: ${rmResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + } + } + + Future _stageAndCommitChanges( + GitDir git, RepositoryPackage package) async { + final io.ProcessResult addResult = await git.runCommand( + ['add', package.pubspecFile.path, package.changelogFile.path]); + if (addResult.exitCode != 0) { + printError('Failed to git add: ${addResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + + final io.ProcessResult commitResult = await git.runCommand([ + 'commit', + '-m', + '[${package.displayName}] Prepares for batch release' + ]); + if (commitResult.exitCode != 0) { + printError('Failed to commit: ${commitResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + } + + Future _pushBranch( + GitDir git, String remoteName, String branchName) async { + print(' Pushing branch $branchName to remote $remoteName...'); + final io.ProcessResult pushResult = + await git.runCommand(['push', remoteName, branchName]); + if (pushResult.exitCode != 0) { + printError('Failed to push to $branchName: ${pushResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + } +} + +/// A data class for pending changelogs. +class _PendingChangelogs { + /// Creates a new instance. + _PendingChangelogs(this.entries, this.files); + + /// The parsed pending changelog entries. + final List<_PendingChangelogEntry> entries; + + /// The files that the pending changelog entries were parsed from. + final List files; +} + +/// A data class for processed release information. +class _ReleaseInfo { + /// Creates a new instance. + _ReleaseInfo(this.newVersion, this.changelogs); + + /// The new version for the release, or null if there is no version change. + final Version? newVersion; + + /// The combined changelog entries. + final List changelogs; +} + +/// The type of version change for a release. +/// +/// The order of the enum values is important as it is used to determine which version +/// take priority when multiple version changes are specified. The top most value +/// (the samller the index) has the highest priority. +enum _VersionChange { + /// A major version change (e.g., 1.2.3 -> 2.0.0). + major, + + /// A minor version change (e.g., 1.2.3 -> 1.3.0). + minor, + + /// A patch version change (e.g., 1.2.3 -> 1.2.4). + patch, + + /// No version change. + skip, +} + +/// Represents a single entry in the pending changelog. +class _PendingChangelogEntry { + /// Creates a new pending changelog entry. + _PendingChangelogEntry({required this.changelog, required this.version}); + + /// Creates a PendingChangelogEntry from a YAML string. + factory _PendingChangelogEntry.parse(String yamlContent) { + final dynamic yaml = loadYaml(yamlContent); + if (yaml is! YamlMap) { + throw FormatException( + 'Expected a YAML map, but found ${yaml.runtimeType}.'); + } + + final dynamic changelogYaml = yaml['changelog']; + if (changelogYaml is! String) { + throw FormatException( + 'Expected "changelog" to be a string, but found ${changelogYaml.runtimeType}.'); + } + final String changelog = changelogYaml.trim(); + + final String? versionString = yaml['version'] as String?; + if (versionString == null) { + throw const FormatException('Missing "version" key.'); + } + final _VersionChange version = _VersionChange.values.firstWhere( + (_VersionChange e) => e.name == versionString, + orElse: () => + throw FormatException('Invalid version type: $versionString'), + ); + + return _PendingChangelogEntry(changelog: changelog, version: version); + } + + /// The changelog messages for this entry. + final String changelog; + + /// The type of version change for this entry. + final _VersionChange version; +} diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index 307429fa198..8bd58f07867 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -9,6 +9,7 @@ import 'package:file/file.dart'; import 'package:file/local.dart'; import 'analyze_command.dart'; +import 'branch_for_batch_release_command.dart'; import 'build_examples_command.dart'; import 'common/core.dart'; import 'create_all_packages_app_command.dart'; @@ -86,7 +87,8 @@ void main(List args) { ..addCommand(UpdateExcerptsCommand(packagesDir)) ..addCommand(UpdateMinSdkCommand(packagesDir)) ..addCommand(UpdateReleaseInfoCommand(packagesDir)) - ..addCommand(VersionCheckCommand(packagesDir)); + ..addCommand(VersionCheckCommand(packagesDir)) + ..addCommand(BranchForBatchReleaseCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { final ToolExit toolExit = e as ToolExit; diff --git a/script/tool/test/branch_for_batch_release_command_test.dart b/script/tool/test/branch_for_batch_release_command_test.dart new file mode 100644 index 00000000000..029fabc6246 --- /dev/null +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -0,0 +1,628 @@ +// Copyright 2013 The Flutter Authors +// 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/branch_for_batch_release_command.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:git/git.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; + +import 'util.dart'; + +void main() { + late MockPlatform mockPlatform; + late Directory packagesDir; + late RecordingProcessRunner processRunner; + late RecordingProcessRunner gitProcessRunner; + late CommandRunner runner; + + void createPendingChangelogFile( + RepositoryPackage package, String name, String content) { + final File pendingChangelog = package.directory + .childDirectory('pending_changelogs') + .childFile(name) + ..createSync(recursive: true); + pendingChangelog.writeAsStringSync(content); + } + + Future> runBatchCommand( + {void Function(Error error)? errorHandler}) => + runCapturingPrint( + runner, + [ + 'branch-for-batch-release', + '--packages=a_package', + '--branch=release-branch', + '--remote=origin' + ], + errorHandler: errorHandler); + + RepositoryPackage createTestPackage() { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir); + + package.changelogFile.writeAsStringSync(''' +## 1.0.0 + +- Old changes +'''); + package.pubspecFile.writeAsStringSync(''' +name: a_package +version: 1.0.0 +'''); + package.directory.childDirectory('pending_changelogs').createSync(); + return package; + } + + const List expectedGitCallsForABFiles = [ + ProcessCall('git-checkout', ['-b', 'release-branch'], null), + ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], null), + ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/b.yaml'], null), + ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + ProcessCall('git-commit', + ['-m', '[a_package] Prepares for batch release'], null), + ProcessCall('git-push', ['origin', 'release-branch'], null), + ]; + const List expectedGitCallsForAFiles = [ + ProcessCall('git-checkout', ['-b', 'release-branch'], null), + ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], null), + ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + ProcessCall('git-commit', + ['-m', '[a_package] Prepares for batch release'], null), + ProcessCall('git-push', ['origin', 'release-branch'], null), + ]; + + setUp(() { + mockPlatform = MockPlatform(); + final GitDir gitDir; + (:packagesDir, :processRunner, :gitProcessRunner, :gitDir) = + configureBaseCommandMocks(platform: mockPlatform); + final BranchForBatchReleaseCommand command = BranchForBatchReleaseCommand( + packagesDir, + processRunner: processRunner, + gitDir: gitDir, + platform: mockPlatform, + ); + runner = CommandRunner('branch_for_batch_release_command', + 'Test for branch_for_batch_release_command'); + runner.addCommand(command); + }); + + group('happy path', () { + test('can bump minor', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.1.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 1.1.0')); + expect(changelogContent, contains('A new feature')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForAFiles)); + }); + + test('can bump major', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 2.0.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 2.0.0')); + expect(changelogContent, contains('A new feature')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForAFiles)); + }); + + test('can bump patch', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: patch +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.0.1')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 1.0.1')); + expect(changelogContent, contains('A new feature')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForAFiles)); + }); + + test('merges multiple changelogs, minor and major', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: A breaking change +version: major +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 2.0.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 2.0.0')); + expect(changelogContent, contains('A new feature')); + expect(changelogContent, contains('A breaking change')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForABFiles)); + }); + + test('merges multiple changelogs, minor and patch', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: A bug fix +version: patch +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.1.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 1.1.0')); + expect(changelogContent, contains('A new feature')); + expect(changelogContent, contains('A bug fix')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForABFiles)); + }); + + test('merges multiple changelogs, major and patch', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A breaking change +version: major +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: A bug fix +version: patch +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 2.0.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 2.0.0')); + expect(changelogContent, contains('A breaking change')); + expect(changelogContent, contains('A bug fix')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForABFiles)); + }); + + test('merges multiple changelogs, minor, major and patch', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: A breaking change +version: major +'''); + createPendingChangelogFile(package, 'c.yaml', ''' +changelog: A bug fix +version: patch +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 2.0.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 2.0.0')); + expect(changelogContent, contains('A new feature')); + expect(changelogContent, contains('A breaking change')); + expect(changelogContent, contains('A bug fix')); + expect( + gitProcessRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + 'git-checkout', ['-b', 'release-branch'], null), + const ProcessCall( + 'git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], + null), + const ProcessCall( + 'git-rm', + ['/packages/a_package/pending_changelogs/b.yaml'], + null), + const ProcessCall( + 'git-rm', + ['/packages/a_package/pending_changelogs/c.yaml'], + null), + const ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + const ProcessCall('git-commit', + ['-m', '[a_package] Prepares for batch release'], null), + const ProcessCall( + 'git-push', ['origin', 'release-branch'], null), + ])); + }); + + test('merges multiple changelogs with same version', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: Another new feature +version: minor +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.1.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 1.1.0')); + expect(changelogContent, contains('A new feature')); + expect(changelogContent, contains('Another new feature')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForABFiles)); + }); + + test('mix of skip and other version changes', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + createPendingChangelogFile(package, 'b.yaml', ''' +changelog: A documentation update +version: skip +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + ' Creating new branch "release-branch"...', + ' Pushing branch release-branch to remote origin...', + ])); + + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.1.0')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## 1.1.0')); + expect(changelogContent, contains('A new feature')); + expect(changelogContent, contains('A documentation update')); + expect(gitProcessRunner.recordedCalls, + orderedEquals(expectedGitCallsForABFiles)); + }); + + test('skips version update', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: skip +'''); + + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + 'No version change specified in pending changelogs for a_package.', + ])); + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.0.0')); + expect(package.changelogFile.readAsStringSync(), startsWith('## 1.0.0')); + expect(gitProcessRunner.recordedCalls, orderedEquals([])); + }); + + test('handles no changelog files', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + final List output = await runBatchCommand(); + + expect( + output, + containsAllInOrder([ + 'Parsing package "a_package"...', + 'No pending changelogs found for a_package.', + ])); + expect( + package.pubspecFile.readAsStringSync(), contains('version: 1.0.0')); + expect(package.changelogFile.readAsStringSync(), startsWith('## 1.0.0')); + expect(gitProcessRunner.recordedCalls, orderedEquals([])); + }); + }); + + group('error handling', () { + test('throw when git-checkout fails', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-checkout'] = + [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 4); + }); + + expect(output.last, + contains('Failed to create branch release-branch: error')); + }); + + test('throw when git-rm fails', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-rm'] = [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 4); + }); + + expect( + output.last, + contains( + 'Failed to rm /packages/a_package/pending_changelogs/a.yaml: error')); + }); + + test('throw when git-add fails', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-add'] = + [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 4); + }); + + expect(output.last, contains('Failed to git add: error')); + }); + + test('throw when git-commit fails', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-commit'] = + [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 4); + }); + + expect(output.last, contains('Failed to commit: error')); + }); + + test('throw when git-push fails', () async { + final RepositoryPackage package = createTestPackage(); + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-push'] = + [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 4); + }); + + expect(output.last, contains('Failed to push to release-branch: error')); + }); + + test('throws for pre-1.0.0 packages', () async { + final RepositoryPackage package = + createFakePackage('a_package', packagesDir); + + addTearDown(() { + package.directory.deleteSync(recursive: true); + }); + + // Set a pre-1.0.0 version. + package.changelogFile.writeAsStringSync(''' +## 0.5.0 + +- Old changes +'''); + package.pubspecFile.writeAsStringSync(''' +name: a_package +version: 0.5.0 +'''); + package.directory.childDirectory('pending_changelogs').createSync(); + createPendingChangelogFile(package, 'a.yaml', ''' +changelog: A new feature +version: minor +'''); + + final List output = + await runBatchCommand(errorHandler: (Error e) { + expect(e, isA()); + expect((e as ToolExit).exitCode, 3); + }); + + expect( + output.last, + contains( + 'This script only supports packages with version >= 1.0.0. Current version: 0.5.0. Package: a_package.')); + }); + }); +}