diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml new file mode 100644 index 00000000000..190096b7d27 --- /dev/null +++ b/.github/workflows/batch_release_pr.yml @@ -0,0 +1,33 @@ +name: "Creates Batch Release for go_router 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 }}" + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "Batch release PR for ${{ github.event.client_payload.package }} package" + title: "Batch Release PR for ${{ github.event.client_payload.package }} package" + body: "This PR was created automatically to batch release the ${{ github.event.client_payload.package }} 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..75513a6a3b7 --- /dev/null +++ b/.github/workflows/go_router_batch.yml @@ -0,0 +1,16 @@ +name: "Creates Batch Release for go_router package" + +on: + schedule: + - cron: "0 0 * * *" + +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..c4a0c9dcf4f --- /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: | + - Added 'batch' option to CI config for go_router package. + - Updated 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..c4a0c9dcf4f --- /dev/null +++ b/packages/go_router/pending_changelogs/test_only_2.yaml @@ -0,0 +1,6 @@ +# This file is for test purposes only. +# TODO(chuntai): remove this file before publishing. +changelog: | + - Added 'batch' option to CI config for go_router package. + - Updated GitHub Actions workflow for batch releases of go_router. +version: major 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..e1a6dc472e5 --- /dev/null +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -0,0 +1,311 @@ +// 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 '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 = 2; +const int _kGitFailedToPush = 3; + +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 to push the release PR 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 = argResults!['branch'] as String; + + 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()); + final ReleaseInfo releaseInfo = + _getReleaseInfo(pendingChangelogs.entries, pubspec.version!); + + if (releaseInfo.newVersion == null) { + print('No version change specified in pending changelogs for ' + '${package.displayName}.'); + return; + } + + print('Creating and pushing release branch...'); + await _pushBranch( + git: repository, + package: package, + branchName: branchName, + pendingChangelogFiles: pendingChangelogs.files, + releaseInfo: releaseInfo, + ); + } + + Future _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 entries = pendingChangelogFiles + .map( + (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 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]; + + Version? newVersion; + switch (effectiveVersionChange) { + case VersionChange.skip: + break; + case VersionChange.major: + newVersion = Version( + oldVersion.major + 1, + 0, + 0, + ); + case VersionChange.minor: + newVersion = Version( + oldVersion.major, + oldVersion.minor + 1, + 0, + ); + case VersionChange.patch: + newVersion = Version( + oldVersion.major, + oldVersion.minor, + oldVersion.patch + 1, + ); + } + return ReleaseInfo(newVersion, changelogs); + } + + Future _pushBranch({ + required GitDir git, + required RepositoryPackage package, + required String branchName, + required List pendingChangelogFiles, + required ReleaseInfo releaseInfo, + }) 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); + } + + print(' Updating pubspec.yaml to version ${releaseInfo.newVersion}...'); + // Update pubspec.yaml. + final YamlEditor editablePubspec = + YamlEditor(package.pubspecFile.readAsStringSync()); + editablePubspec + .update(['version'], releaseInfo.newVersion.toString()); + package.pubspecFile.writeAsStringSync(editablePubspec.toString()); + + print(' Updating CHANGELOG.md...'); + // Update CHANGELOG.md. + 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()); + + print(' Removing pending changelog files...'); + 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); + } + } + + print(' Staging changes...'); + 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); + } + + print(' Committing changes...'); + final io.ProcessResult commitResult = await git.runCommand([ + 'commit', + '-m', + '${package.displayName}: Prepare for release' + ]); + if (commitResult.exitCode != 0) { + printError('Failed to commit: ${commitResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + + print(' Pushing to remote...'); + final io.ProcessResult pushResult = + await git.runCommand(['push', 'origin', 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 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. +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..fe4ada92e23 --- /dev/null +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -0,0 +1,309 @@ +// 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/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; + late RepositoryPackage package; + + // A helper function to create a changelog file with the given content. + void createChangelogFile(String name, String content) { + final File pendingChangelog = package.directory + .childDirectory('pending_changelogs') + .childFile(name) + ..createSync(recursive: true); + pendingChangelog.writeAsStringSync(content); + } + + 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); + 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(); + }); + + tearDown(() { + package.directory.deleteSync(recursive: true); + }); + + Future testReleaseBranch({ + required Map changelogs, + required List expectedOutput, + String? expectedVersion, + List expectedChangelogSnippets = const [], + List expectedGitCommands = const [], + }) async { + for (final MapEntry entry in changelogs.entries) { + createChangelogFile(entry.key, entry.value); + } + + final List output = await runCapturingPrint(runner, [ + 'branch-for-batch-release', + '--packages=a_package', + '--branch=release-branch', + ]); + + expect(output, containsAllInOrder(expectedOutput)); + + if (expectedVersion != null) { + expect(package.pubspecFile.readAsStringSync(), + contains('version: $expectedVersion')); + final String changelogContent = package.changelogFile.readAsStringSync(); + expect(changelogContent, startsWith('## $expectedVersion')); + for (final String snippet in expectedChangelogSnippets) { + expect(changelogContent, contains(snippet)); + } + } + expect(gitProcessRunner.recordedCalls, orderedEquals(expectedGitCommands)); + } + + group('happy path', () { + test('can bump minor', () async { + await testReleaseBranch( + changelogs: { + 'a.yaml': ''' +changelog: A new feature +version: minor +''' + }, + expectedVersion: '1.1.0', + expectedChangelogSnippets: ['A new feature'], + expectedOutput: [ + 'Parsing package "a_package"...', + 'Creating and pushing release branch...', + ' Creating new branch "release-branch"...', + ' Updating pubspec.yaml to version 1.1.0...', + ' Updating CHANGELOG.md...', + ], + expectedGitCommands: [ + const ProcessCall( + 'git-checkout', ['-b', 'release-branch'], null), + const ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], null), + const ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + const ProcessCall('git-commit', + ['-m', 'a_package: Prepare for release'], null), + const ProcessCall( + 'git-push', ['origin', 'release-branch'], null), + ], + ); + }); + + test('can bump major', () async { + await testReleaseBranch( + changelogs: { + 'a.yaml': ''' +changelog: A new feature +version: major +''' + }, + expectedVersion: '2.0.0', + expectedChangelogSnippets: ['A new feature'], + expectedOutput: [ + 'Parsing package "a_package"...', + 'Creating and pushing release branch...', + ' Creating new branch "release-branch"...', + ' Updating pubspec.yaml to version 2.0.0...', + ' Updating CHANGELOG.md...', + ], + expectedGitCommands: [ + const ProcessCall( + 'git-checkout', ['-b', 'release-branch'], null), + const ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], null), + const ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + const ProcessCall('git-commit', + ['-m', 'a_package: Prepare for release'], null), + const ProcessCall( + 'git-push', ['origin', 'release-branch'], null), + ], + ); + }); + + test('can bump patch', () async { + await testReleaseBranch( + changelogs: { + 'a.yaml': ''' +changelog: A new feature +version: patch +''' + }, + expectedVersion: '1.0.1', + expectedChangelogSnippets: ['A new feature'], + expectedOutput: [ + 'Parsing package "a_package"...', + 'Creating and pushing release branch...', + ' Creating new branch "release-branch"...', + ' Updating pubspec.yaml to version 1.0.1...', + ' Updating CHANGELOG.md...', + ], + expectedGitCommands: [ + const ProcessCall( + 'git-checkout', ['-b', 'release-branch'], null), + const ProcessCall('git-rm', + ['/packages/a_package/pending_changelogs/a.yaml'], null), + const ProcessCall( + 'git-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + const ProcessCall('git-commit', + ['-m', 'a_package: Prepare for release'], null), + const ProcessCall( + 'git-push', ['origin', 'release-branch'], null), + ], + ); + }); + + test('merges multiple changelogs', () async { + await testReleaseBranch( + changelogs: { + 'a.yaml': ''' +changelog: A new feature +version: minor +''', + 'b.yaml': ''' +changelog: A breaking change +version: major +''' + }, + expectedVersion: '2.0.0', + expectedChangelogSnippets: [ + 'A new feature', + 'A breaking change' + ], + expectedOutput: [ + 'Parsing package "a_package"...', + 'Creating and pushing release branch...', + ' Creating new branch "release-branch"...', + ' Updating pubspec.yaml to version 2.0.0...', + ' Updating CHANGELOG.md...', + ], + expectedGitCommands: [ + 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-add', + [ + '/packages/a_package/pubspec.yaml', + '/packages/a_package/CHANGELOG.md' + ], + null), + const ProcessCall('git-commit', + ['-m', 'a_package: Prepare for release'], null), + const ProcessCall( + 'git-push', ['origin', 'release-branch'], null), + ], + ); + }); + + test('skips version update', () async { + await testReleaseBranch( + changelogs: { + 'a.yaml': ''' +changelog: A new feature +version: skip +''' + }, + expectedOutput: [ + '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 { + await testReleaseBranch( + changelogs: {}, + expectedOutput: [ + '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([])); + }); + }); + + test('throw when github fails', () async { + createChangelogFile('a.yaml', ''' +changelog: A new feature +version: major +'''); + gitProcessRunner.mockProcessesForExecutable['git-rm'] = [ + FakeProcessInfo(MockProcess(stderr: 'error', exitCode: 1)), + ]; + Object? error; + try { + await runCapturingPrint(runner, [ + 'branch-for-batch-release', + '--packages=a_package', + '--branch=release-branch', + ]); + } catch (e) { + error = e; + } + + expect(error, isA()); + }); +}