From b97d450ad206ac5075a961fbb1f82c8f82aefc76 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Fri, 24 Oct 2025 10:54:53 -0700 Subject: [PATCH 01/16] Adds github action for creating batch release --- .github/workflows/batch_release_pr.yml | 28 +++++ script/tool/lib/src/batch_command.dart | 163 +++++++++++++++++++++++++ script/tool/lib/src/main.dart | 4 +- 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/batch_release_pr.yml create mode 100644 script/tool/lib/src/batch_command.dart diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml new file mode 100644 index 00000000000..44e242d8793 --- /dev/null +++ b/.github/workflows/batch_release_pr.yml @@ -0,0 +1,28 @@ +name: "Creates Batch Release PR" + +on: + schedule: + # Runs at 00:00 UTC every day + - cron: '0 0 * * *' + workflow_dispatch: + +jobs: + create_release_pr: + runs-on: ubuntu-latest + steps: + - name: checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - name: Set up tools + run: dart pub get + working-directory: ${{ github.workspace }}/script/tool + # Give some time for LUCI checks to start becoming populated. + # Because of latency in Github Webhooks, we need to wait for a while + # before being able to look at checks scheduled by LUCI. + - name: Give webhooks a minute + run: sleep 60s + shell: bash + - 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 batch diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart new file mode 100644 index 00000000000..83c81d17842 --- /dev/null +++ b/script/tool/lib/src/batch_command.dart @@ -0,0 +1,163 @@ +// 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:github/github.dart'; +import 'package:yaml/yaml.dart'; + +import 'common/core.dart'; +import 'common/process_runner.dart'; +import 'common/repository_package.dart'; + +const String _baseBranch = 'release'; +const String _headBranch = 'main'; + +/// A command to create a pull request for a batch release. +class BatchCommand extends Command { + /// Creates a new `batch` command. + BatchCommand(this.packagesDir); + + /// The directory containing the packages. + final Directory packagesDir; + + @override + final String name = 'batch'; + + @override + final String description = + 'Creates a batch release PR based on unreleased changes.'; + + @override + Future run() async { + final String? githubToken = io.Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + print('This command requires a GITHUB_TOKEN environment variable.'); + throw ToolExit(1); + } + + final ProcessRunner processRunner = ProcessRunner(); + final String remote = await processRunner.runAndExitOnError( + 'git', ['remote', 'get-url', 'origin'], + workingDir: packagesDir.parent.parent); // run git from root + final RepositorySlug slug = RepositorySlug.fromUrl(remote); + + final ReleaseInfo releaseInfo = await _getReleaseInfo(); + + if (releaseInfo.packagesToRelease.isEmpty) { + print( + 'No packages with unreleased changes are configured for batch releases.'); + return; + } + + final String prTitle = + 'chore: Batch release for ${releaseInfo.packagesToRelease.length} packages'; + + final GitHub github = GitHub(auth: Authentication.withToken(githubToken)); + + try { + final PullRequest pr = await github.pullRequests.create( + slug, + CreatePullRequest( + prTitle, + _headBranch, + _baseBranch, + body: releaseInfo.prBody, + ), + ); + print('Successfully created pull request: ${pr.htmlUrl}'); + } on GitHubError catch (e) { + if (e.toString().contains('A pull request already exists')) { + print('A pull request already exists for these branches. Nothing to do.'); + } else { + print('Failed to create pull request: $e'); + throw ToolExit(1); + } + } catch (e) { + print('An unexpected error occurred: $e'); + throw ToolExit(1); + } finally { + github.dispose(); + } + } + + Future _getReleaseInfo() async { + final List packages = + await getPackages(packagesDir).toList(); + final List packagesToRelease = []; + final StringBuffer prBody = StringBuffer(); + + prBody.writeln('The following packages are included in this batch release:'); + prBody.writeln(); + + for (final RepositoryPackage package in packages) { + final File ciConfig = package.directory.childFile('ci_config.yaml'); + if (!ciConfig.existsSync()) { + continue; + } + + try { + final dynamic yaml = loadYaml(ciConfig.readAsStringSync()); + if (yaml['release']?['batch'] != true) { + continue; + } + } on YamlException catch (e) { + print('Error parsing ${ciConfig.path}: $e'); + continue; + } + + final Directory unreleasedDir = + package.directory.childDirectory('unreleased'); + if (!unreleasedDir.existsSync()) { + continue; + } + + final List unreleasedFiles = unreleasedDir.listSync() + ..removeWhere((FileSystemEntity entity) => entity.basename == '.gitkeep'); + if (unreleasedFiles.isEmpty) { + continue; + } + + packagesToRelease.add(package); + prBody.writeln('### ${package.displayName}'); + for (final FileSystemEntity file in unreleasedFiles) { + if (file is File) { + prBody.writeln(file.readAsStringSync()); + } + } + prBody.writeln(); + } + + return ReleaseInfo(packagesToRelease, prBody.toString()); + } +} + +/// A data class to hold information about a pending release. +class ReleaseInfo { + /// Creates a new ReleaseInfo. + const ReleaseInfo(this.packagesToRelease, this.prBody); + + /// The packages that are part of this release. + final List packagesToRelease; + + /// The generated pull request body. + final String prBody; +} + + +/// Extension to get a [RepositorySlug] from a git remote URL. +extension on RepositorySlug { + /// Creates a [RepositorySlug] from a git remote URL. + static RepositorySlug fromUrl(String remoteUrl) { + final Uri remoteUri = Uri.parse(remoteUrl.trim()); + final List pathSegments = remoteUri.pathSegments; + // The path for https is e.g., /flutter/packages.git, and for git is + // e.g., flutter/packages.git. + final String owner = pathSegments[pathSegments.length - 2]; + final String name = pathSegments.last.replaceAll('.git', ''); + return RepositorySlug(owner, name); + } +} \ No newline at end of file diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index 307429fa198..694b54bfad6 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -38,6 +38,7 @@ import 'update_excerpts_command.dart'; import 'update_min_sdk_command.dart'; import 'update_release_info_command.dart'; import 'version_check_command.dart'; +import 'batch_command.dart'; void main(List args) { const FileSystem fileSystem = LocalFileSystem(); @@ -86,7 +87,8 @@ void main(List args) { ..addCommand(UpdateExcerptsCommand(packagesDir)) ..addCommand(UpdateMinSdkCommand(packagesDir)) ..addCommand(UpdateReleaseInfoCommand(packagesDir)) - ..addCommand(VersionCheckCommand(packagesDir)); + ..addCommand(VersionCheckCommand(packagesDir)) + ..addCommand(BatchCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { final ToolExit toolExit = e as ToolExit; From 72295404c2ea6fb07bac30302d9f0ecf3c9e9164 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 11:29:09 -0700 Subject: [PATCH 02/16] adds github workflow for batch release --- script/tool/lib/src/batch_command.dart | 330 ++++++++++++++++--------- 1 file changed, 218 insertions(+), 112 deletions(-) diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart index 83c81d17842..f01d4136718 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/batch_command.dart @@ -3,161 +3,267 @@ // found in the LICENSE file. import 'dart:io' as io; +import 'dart:math' as math; -import 'package:args/command_runner.dart'; import 'package:file/file.dart'; -import 'package:github/github.dart'; +import 'package:git/git.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; import 'common/core.dart'; -import 'common/process_runner.dart'; +import 'common/output_utils.dart'; +import 'common/package_command.dart'; import 'common/repository_package.dart'; -const String _baseBranch = 'release'; -const String _headBranch = 'main'; +const int _exitPackageMalformed = 2; +const String _kRemote = 'origin'; +const String _kMainBranch = 'main'; -/// A command to create a pull request for a batch release. -class BatchCommand extends Command { +/// A command to create a pull request for a single package release. +class BatchCommand extends PackageCommand { /// Creates a new `batch` command. - BatchCommand(this.packagesDir); - - /// The directory containing the packages. - final Directory packagesDir; + BatchCommand( + super.packagesDir, { + super.processRunner, + super.platform, + super.gitDir, + }) { + argParser.addOption( + 'package', + mandatory: true, + abbr: 'p', + help: 'The package to create a release PR for.', + ); + argParser.addOption( + 'branch', + mandatory: true, + abbr: 'b', + help: 'The branch to push the release PR to.', + ); + } @override final String name = 'batch'; @override - final String description = - 'Creates a batch release PR based on unreleased changes.'; + final String description = 'Creates a release PR for a single package.'; @override Future run() async { - final String? githubToken = io.Platform.environment['GITHUB_TOKEN']; - if (githubToken == null) { - print('This command requires a GITHUB_TOKEN environment variable.'); - throw ToolExit(1); - } + final String packageName = argResults!['package'] as String; + final String branchName = argResults!['branch'] as String; - final ProcessRunner processRunner = ProcessRunner(); - final String remote = await processRunner.runAndExitOnError( - 'git', ['remote', 'get-url', 'origin'], - workingDir: packagesDir.parent.parent); // run git from root - final RepositorySlug slug = RepositorySlug.fromUrl(remote); + final GitDir repository = await gitDir; - final ReleaseInfo releaseInfo = await _getReleaseInfo(); + final RepositoryPackage package = await _getPackage(packageName); - if (releaseInfo.packagesToRelease.isEmpty) { - print( - 'No packages with unreleased changes are configured for batch releases.'); + final UnreleasedChanges unreleasedChanges = + await _getUnreleasedChanges(package); + if (unreleasedChanges.entries.isEmpty) { + print('No unreleased changes found for $packageName.'); return; } - final String prTitle = - 'chore: Batch release for ${releaseInfo.packagesToRelease.length} packages'; + final Pubspec pubspec = + Pubspec.parse(package.pubspecFile.readAsStringSync()); + final ReleaseInfo releaseInfo = + _getReleaseInfo(unreleasedChanges.entries, pubspec.version!); - final GitHub github = GitHub(auth: Authentication.withToken(githubToken)); - - try { - final PullRequest pr = await github.pullRequests.create( - slug, - CreatePullRequest( - prTitle, - _headBranch, - _baseBranch, - body: releaseInfo.prBody, - ), - ); - print('Successfully created pull request: ${pr.htmlUrl}'); - } on GitHubError catch (e) { - if (e.toString().contains('A pull request already exists')) { - print('A pull request already exists for these branches. Nothing to do.'); - } else { - print('Failed to create pull request: $e'); - throw ToolExit(1); - } - } catch (e) { - print('An unexpected error occurred: $e'); - throw ToolExit(1); - } finally { - github.dispose(); + if (releaseInfo.newVersion == null) { + print('No version change specified in unreleased changelog for ' + '$packageName.'); + return; } + + await _pushBranch( + repository: repository, + packageName: packageName, + branchName: branchName, + unreleasedFiles: unreleasedChanges.files); } - Future _getReleaseInfo() async { - final List packages = - await getPackages(packagesDir).toList(); - final List packagesToRelease = []; - final StringBuffer prBody = StringBuffer(); + Future _getPackage(String packageName) async { + return getTargetPackages() + .map( + (PackageEnumerationEntry entry) => entry.package) + .firstWhere((RepositoryPackage p) => p.displayName.split('/').last == packageName); + } - prBody.writeln('The following packages are included in this batch release:'); - prBody.writeln(); + Future _getUnreleasedChanges( + RepositoryPackage package) async { + final Directory unreleasedDir = + package.directory.childDirectory('unreleased'); + if (!unreleasedDir.existsSync()) { + printError('No unreleased folder found for ${package.displayName}.'); + throw ToolExit(_exitPackageMalformed); + } + final List unreleasedFiles = unreleasedDir + .listSync() + .whereType() + .where((File f) => f.basename.endsWith('.yaml')) + .toList(); + try { + final List entries = unreleasedFiles + .map( + (File f) => UnreleasedEntry.parse(f.readAsStringSync())) + .toList(); + return UnreleasedChanges(entries, unreleasedFiles); + } on FormatException catch (e) { + printError('Malformed unreleased changelog file: $e'); + throw ToolExit(_exitPackageMalformed); + } + } - for (final RepositoryPackage package in packages) { - final File ciConfig = package.directory.childFile('ci_config.yaml'); - if (!ciConfig.existsSync()) { - continue; - } + ReleaseInfo _getReleaseInfo( + List unreleasedEntries, Version oldVersion) { + final List changelogs = []; + int versionIndex = VersionChange.skip.index; + for (final UnreleasedEntry entry in unreleasedEntries) { + changelogs.addAll(entry.changelog); + versionIndex = math.min(versionIndex, entry.version.index); + } + final VersionChange effectiveVersionChange = + VersionChange.values[versionIndex]; - try { - final dynamic yaml = loadYaml(ciConfig.readAsStringSync()); - if (yaml['release']?['batch'] != true) { - continue; - } - } on YamlException catch (e) { - print('Error parsing ${ciConfig.path}: $e'); - continue; - } + Version? newVersion; + print('Effective version change: $effectiveVersionChange'); + 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); + } - final Directory unreleasedDir = - package.directory.childDirectory('unreleased'); - if (!unreleasedDir.existsSync()) { - continue; - } + Future _pushBranch( + {required GitDir repository, + required String packageName, + required String branchName, + required List unreleasedFiles}) async { + final io.ProcessResult checkoutResult = await repository.runCommand( + ['checkout', '-b', branchName, '$_kRemote/$_kMainBranch']); + if (checkoutResult.exitCode != 0) { + print('Failed to checkout branch $branchName: ${checkoutResult.stderr}'); + throw ToolExit(checkoutResult.exitCode); + } - final List unreleasedFiles = unreleasedDir.listSync() - ..removeWhere((FileSystemEntity entity) => entity.basename == '.gitkeep'); - if (unreleasedFiles.isEmpty) { - continue; + for (final File file in unreleasedFiles) { + final io.ProcessResult rmResult = + await repository.runCommand(['rm', file.path]); + if (rmResult.exitCode != 0) { + print('Failed to rm ${file.path}: ${rmResult.stderr}'); + throw ToolExit(rmResult.exitCode); } + } - packagesToRelease.add(package); - prBody.writeln('### ${package.displayName}'); - for (final FileSystemEntity file in unreleasedFiles) { - if (file is File) { - prBody.writeln(file.readAsStringSync()); - } - } - prBody.writeln(); + final io.ProcessResult commitResult = await repository.runCommand( + ['commit', '-m', '$packageName: Prepare for release']); + if (commitResult.exitCode != 0) { + print('Failed to commit: ${commitResult.stderr}'); + throw ToolExit(commitResult.exitCode); } - return ReleaseInfo(packagesToRelease, prBody.toString()); + final io.ProcessResult pushResult = + await repository.runCommand(['push', 'origin', branchName]); + if (pushResult.exitCode != 0) { + print('Failed to push to $branchName: ${pushResult.stderr}'); + throw ToolExit(pushResult.exitCode); + } } } -/// A data class to hold information about a pending release. +/// A data class for unreleased changes. +class UnreleasedChanges { + /// Creates a new instance. + UnreleasedChanges(this.entries, this.files); + + /// The parsed unreleased entries. + final List entries; + + /// The files that the unreleased entries were parsed from. + final List files; +} + +/// A data class for processed release information. class ReleaseInfo { - /// Creates a new ReleaseInfo. - const ReleaseInfo(this.packagesToRelease, this.prBody); + /// 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, - /// The packages that are part of this release. - final List packagesToRelease; + /// A minor version change (e.g., 1.2.3 -> 1.3.0). + minor, - /// The generated pull request body. - final String prBody; + /// A patch version change (e.g., 1.2.3 -> 1.2.4). + patch, + + /// No version change. + skip, } +/// Represents a single entry in the unreleased changelog. +class UnreleasedEntry { + /// Creates a new unreleased entry. + UnreleasedEntry({required this.changelog, required this.version}); + + /// Creates an UnreleasedEntry from a YAML string. + factory UnreleasedEntry.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! YamlList) { + throw FormatException('Expected "changelog" to be a list, but found ${changelogYaml.runtimeType}.'); + } + final List changelog = changelogYaml.nodes + .map((YamlNode node) => node.value as String) + .toList(); + + 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'), + ); -/// Extension to get a [RepositorySlug] from a git remote URL. -extension on RepositorySlug { - /// Creates a [RepositorySlug] from a git remote URL. - static RepositorySlug fromUrl(String remoteUrl) { - final Uri remoteUri = Uri.parse(remoteUrl.trim()); - final List pathSegments = remoteUri.pathSegments; - // The path for https is e.g., /flutter/packages.git, and for git is - // e.g., flutter/packages.git. - final String owner = pathSegments[pathSegments.length - 2]; - final String name = pathSegments.last.replaceAll('.git', ''); - return RepositorySlug(owner, name); + return UnreleasedEntry(changelog: changelog, version: version); } -} \ No newline at end of file + + /// The changelog messages for this entry. + final List changelog; + + /// The type of version change for this entry. + final VersionChange version; +} From 40763ef6a9db9b804bde960141fbc69a0e5ad69e Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 11:29:28 -0700 Subject: [PATCH 03/16] push change --- .github/workflows/batch_release_pr.yml | 25 ++++++++++++++----- .github/workflows/go_router_batch.yml | 16 ++++++++++++ .../go_router/unreleased/test_only_1.yaml | 6 +++++ .../go_router/unreleased/test_only_2.yaml | 6 +++++ 4 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/go_router_batch.yml create mode 100644 packages/go_router/unreleased/test_only_1.yaml create mode 100644 packages/go_router/unreleased/test_only_2.yaml diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml index 44e242d8793..9ad2a85fb48 100644 --- a/.github/workflows/batch_release_pr.yml +++ b/.github/workflows/batch_release_pr.yml @@ -1,14 +1,14 @@ -name: "Creates Batch Release PR" +name: "Creates Batch Release for go_router package" on: - schedule: - # Runs at 00:00 UTC every day - - cron: '0 0 * * *' - workflow_dispatch: + 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 @@ -25,4 +25,17 @@ jobs: run: | git config --global user.name ${{ secrets.USER_NAME }} git config --global user.email ${{ secrets.USER_EMAIL }} - dart ./script/tool/lib/src/main.dart batch + dart ./script/tool/lib/src/main.dart batch --package=${{ github.event.client_payload.package }} --branch=${{ env.BRANCH_NAME }}" + - name: Give tool a minute to create the branch + run: sleep 60s + - 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..e6ae75ebea4 --- /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"}' \ No newline at end of file diff --git a/packages/go_router/unreleased/test_only_1.yaml b/packages/go_router/unreleased/test_only_1.yaml new file mode 100644 index 00000000000..cbea7ead4f8 --- /dev/null +++ b/packages/go_router/unreleased/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 \ No newline at end of file diff --git a/packages/go_router/unreleased/test_only_2.yaml b/packages/go_router/unreleased/test_only_2.yaml new file mode 100644 index 00000000000..cbea7ead4f8 --- /dev/null +++ b/packages/go_router/unreleased/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 \ No newline at end of file From 0d172ddadbb2ac196f1e75db7149caebe1887f56 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 13:34:42 -0700 Subject: [PATCH 04/16] update --- script/tool/lib/src/batch_command.dart | 40 ++++++++++++++++---------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart index f01d4136718..997acc199c8 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/batch_command.dart @@ -15,9 +15,12 @@ import 'common/output_utils.dart'; import 'common/package_command.dart'; import 'common/repository_package.dart'; -const int _exitPackageMalformed = 2; +const int _kExitPackageMalformed = 2; +const int _kGitFailedToPush = 3; + const String _kRemote = 'origin'; const String _kMainBranch = 'main'; +const String _kTemplateFileName = 'template.yaml'; /// A command to create a pull request for a single package release. class BatchCommand extends PackageCommand { @@ -60,7 +63,7 @@ class BatchCommand extends PackageCommand { final UnreleasedChanges unreleasedChanges = await _getUnreleasedChanges(package); if (unreleasedChanges.entries.isEmpty) { - print('No unreleased changes found for $packageName.'); + printError('No unreleased changes found for $packageName.'); return; } @@ -70,7 +73,7 @@ class BatchCommand extends PackageCommand { _getReleaseInfo(unreleasedChanges.entries, pubspec.version!); if (releaseInfo.newVersion == null) { - print('No version change specified in unreleased changelog for ' + printError('No version change specified in unreleased changelog for ' '$packageName.'); return; } @@ -95,12 +98,12 @@ class BatchCommand extends PackageCommand { package.directory.childDirectory('unreleased'); if (!unreleasedDir.existsSync()) { printError('No unreleased folder found for ${package.displayName}.'); - throw ToolExit(_exitPackageMalformed); + throw ToolExit(_kExitPackageMalformed); } final List unreleasedFiles = unreleasedDir .listSync() .whereType() - .where((File f) => f.basename.endsWith('.yaml')) + .where((File f) => f.basename.endsWith('.yaml') && f.basename != _kTemplateFileName) .toList(); try { final List entries = unreleasedFiles @@ -110,7 +113,7 @@ class BatchCommand extends PackageCommand { return UnreleasedChanges(entries, unreleasedFiles); } on FormatException catch (e) { printError('Malformed unreleased changelog file: $e'); - throw ToolExit(_exitPackageMalformed); + throw ToolExit(_kExitPackageMalformed); } } @@ -126,7 +129,7 @@ class BatchCommand extends PackageCommand { VersionChange.values[versionIndex]; Version? newVersion; - print('Effective version change: $effectiveVersionChange'); + printError('Effective version change: $effectiveVersionChange'); switch (effectiveVersionChange) { case VersionChange.skip: break; @@ -157,34 +160,41 @@ class BatchCommand extends PackageCommand { required String packageName, required String branchName, required List unreleasedFiles}) async { + final io.ProcessResult deleteBranchResult = + await repository.runCommand(['branch', '-D', branchName]); + if (deleteBranchResult.exitCode != 0) { + printError('Failed to delete branch $branchName: ${deleteBranchResult.stderr}'); + throw ToolExit(_kGitFailedToPush); + } + final io.ProcessResult checkoutResult = await repository.runCommand( ['checkout', '-b', branchName, '$_kRemote/$_kMainBranch']); if (checkoutResult.exitCode != 0) { - print('Failed to checkout branch $branchName: ${checkoutResult.stderr}'); - throw ToolExit(checkoutResult.exitCode); + printError('Failed to checkout branch $branchName: ${checkoutResult.stderr}'); + throw ToolExit(_kGitFailedToPush); } for (final File file in unreleasedFiles) { final io.ProcessResult rmResult = await repository.runCommand(['rm', file.path]); if (rmResult.exitCode != 0) { - print('Failed to rm ${file.path}: ${rmResult.stderr}'); - throw ToolExit(rmResult.exitCode); + printError('Failed to rm ${file.path}: ${rmResult.stderr}'); + throw ToolExit(_kGitFailedToPush); } } final io.ProcessResult commitResult = await repository.runCommand( ['commit', '-m', '$packageName: Prepare for release']); if (commitResult.exitCode != 0) { - print('Failed to commit: ${commitResult.stderr}'); - throw ToolExit(commitResult.exitCode); + printError('Failed to commit: ${commitResult.stderr}'); + throw ToolExit(_kGitFailedToPush); } final io.ProcessResult pushResult = await repository.runCommand(['push', 'origin', branchName]); if (pushResult.exitCode != 0) { - print('Failed to push to $branchName: ${pushResult.stderr}'); - throw ToolExit(pushResult.exitCode); + printError('Failed to push to $branchName: ${pushResult.stderr}'); + throw ToolExit(_kGitFailedToPush); } } } From 92d5522db84feb0435b6ffa6e7e390f83dcc91b5 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 13:34:54 -0700 Subject: [PATCH 05/16] update --- packages/go_router/unreleased/template.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/go_router/unreleased/template.yaml diff --git a/packages/go_router/unreleased/template.yaml b/packages/go_router/unreleased/template.yaml new file mode 100644 index 00000000000..2c09b2cfbf9 --- /dev/null +++ b/packages/go_router/unreleased/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: + - + - +version: \ No newline at end of file From 19c4dd33a2b870e6035015ae840fc4e4d75ff283 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 14:41:31 -0700 Subject: [PATCH 06/16] update --- script/tool/lib/src/batch_command.dart | 77 +++++++++++++++++++++----- 1 file changed, 64 insertions(+), 13 deletions(-) diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart index 997acc199c8..67776408261 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/batch_command.dart @@ -9,6 +9,7 @@ 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'; @@ -79,10 +80,12 @@ class BatchCommand extends PackageCommand { } await _pushBranch( - repository: repository, - packageName: packageName, - branchName: branchName, - unreleasedFiles: unreleasedChanges.files); + repository: repository, + package: package, + branchName: branchName, + unreleasedFiles: unreleasedChanges.files, + releaseInfo: releaseInfo, + ); } Future _getPackage(String packageName) async { @@ -155,25 +158,63 @@ class BatchCommand extends PackageCommand { return ReleaseInfo(newVersion, changelogs); } - Future _pushBranch( - {required GitDir repository, - required String packageName, - required String branchName, - required List unreleasedFiles}) async { + Future _pushBranch({ + required GitDir repository, + required RepositoryPackage package, + required String branchName, + required List unreleasedFiles, + required ReleaseInfo releaseInfo, + }) async { final io.ProcessResult deleteBranchResult = await repository.runCommand(['branch', '-D', branchName]); if (deleteBranchResult.exitCode != 0) { - printError('Failed to delete branch $branchName: ${deleteBranchResult.stderr}'); + printError( + 'Failed to delete branch $branchName: ${deleteBranchResult.stderr}'); throw ToolExit(_kGitFailedToPush); } final io.ProcessResult checkoutResult = await repository.runCommand( ['checkout', '-b', branchName, '$_kRemote/$_kMainBranch']); if (checkoutResult.exitCode != 0) { - printError('Failed to checkout branch $branchName: ${checkoutResult.stderr}'); + printError( + 'Failed to checkout branch $branchName: ${checkoutResult.stderr}'); throw ToolExit(_kGitFailedToPush); } + // Update pubspec.yaml. + final YamlEditor editablePubspec = + YamlEditor(package.pubspecFile.readAsStringSync()); + editablePubspec.update(['version'], releaseInfo.newVersion.toString()); + package.pubspecFile.writeAsStringSync(editablePubspec.toString()); + + // Update CHANGELOG.md. + final String newHeader = '## ${releaseInfo.newVersion}'; + final List newEntries = releaseInfo.changelogs + .map((String line) => '- $line') + .toList(); + + final List changelogLines = package.changelogFile.readAsLinesSync(); + final StringBuffer newChangelog = StringBuffer(); + + bool inserted = false; + for (final String line in changelogLines) { + if (!inserted && line.startsWith('## ')) { + newChangelog.writeln(newHeader); + newChangelog.writeln(); + newChangelog.writeln(newEntries.join('\n')); + newChangelog.writeln(); + inserted = true; + } + newChangelog.writeln(line); + } + + if (!inserted) { + printError("Can't parse existing CHANGELOG.md."); + throw ToolExit(_kExitPackageMalformed); + } + + package.changelogFile.writeAsStringSync(newChangelog.toString()); + for (final File file in unreleasedFiles) { final io.ProcessResult rmResult = await repository.runCommand(['rm', file.path]); @@ -183,8 +224,18 @@ class BatchCommand extends PackageCommand { } } - final io.ProcessResult commitResult = await repository.runCommand( - ['commit', '-m', '$packageName: Prepare for release']); + final io.ProcessResult addResult = await repository + .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 repository.runCommand([ + 'commit', + '-m', + '${package.displayName}: Prepare for release' + ]); if (commitResult.exitCode != 0) { printError('Failed to commit: ${commitResult.stderr}'); throw ToolExit(_kGitFailedToPush); From 0c29594bfb5d3c6f8cf1f31f6ee08c0c587f1ff9 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 14:42:17 -0700 Subject: [PATCH 07/16] update --- script/tool/lib/src/batch_command.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart index 67776408261..3286ea639ae 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/batch_command.dart @@ -19,8 +19,6 @@ import 'common/repository_package.dart'; const int _kExitPackageMalformed = 2; const int _kGitFailedToPush = 3; -const String _kRemote = 'origin'; -const String _kMainBranch = 'main'; const String _kTemplateFileName = 'template.yaml'; /// A command to create a pull request for a single package release. @@ -174,7 +172,7 @@ class BatchCommand extends PackageCommand { } final io.ProcessResult checkoutResult = await repository.runCommand( - ['checkout', '-b', branchName, '$_kRemote/$_kMainBranch']); + ['checkout', '-b', branchName]); if (checkoutResult.exitCode != 0) { printError( 'Failed to checkout branch $branchName: ${checkoutResult.stderr}'); From 9c8f0a190582befbf6a6f72e0e34e71adcb80477 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 27 Oct 2025 14:56:53 -0700 Subject: [PATCH 08/16] update --- script/tool/lib/src/batch_command.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/batch_command.dart index 3286ea639ae..2e182b78f90 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/batch_command.dart @@ -57,8 +57,10 @@ class BatchCommand extends PackageCommand { final GitDir repository = await gitDir; + print('Fetching package "$packageName"...'); final RepositoryPackage package = await _getPackage(packageName); + print('Checking for unreleased changes...'); final UnreleasedChanges unreleasedChanges = await _getUnreleasedChanges(package); if (unreleasedChanges.entries.isEmpty) { @@ -68,6 +70,7 @@ class BatchCommand extends PackageCommand { final Pubspec pubspec = Pubspec.parse(package.pubspecFile.readAsStringSync()); + print('Determining release version...'); final ReleaseInfo releaseInfo = _getReleaseInfo(unreleasedChanges.entries, pubspec.version!); @@ -77,6 +80,7 @@ class BatchCommand extends PackageCommand { return; } + print('Creating and pushing release branch...'); await _pushBranch( repository: repository, package: package, @@ -130,7 +134,6 @@ class BatchCommand extends PackageCommand { VersionChange.values[versionIndex]; Version? newVersion; - printError('Effective version change: $effectiveVersionChange'); switch (effectiveVersionChange) { case VersionChange.skip: break; @@ -163,6 +166,7 @@ class BatchCommand extends PackageCommand { required List unreleasedFiles, required ReleaseInfo releaseInfo, }) async { + print(' Deleting old branch "$branchName"...'); final io.ProcessResult deleteBranchResult = await repository.runCommand(['branch', '-D', branchName]); if (deleteBranchResult.exitCode != 0) { @@ -171,6 +175,7 @@ class BatchCommand extends PackageCommand { throw ToolExit(_kGitFailedToPush); } + print(' Creating new branch "$branchName"...'); final io.ProcessResult checkoutResult = await repository.runCommand( ['checkout', '-b', branchName]); if (checkoutResult.exitCode != 0) { @@ -179,12 +184,14 @@ class BatchCommand extends PackageCommand { 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 @@ -213,6 +220,7 @@ class BatchCommand extends PackageCommand { package.changelogFile.writeAsStringSync(newChangelog.toString()); + print(' Removing unreleased change files...'); for (final File file in unreleasedFiles) { final io.ProcessResult rmResult = await repository.runCommand(['rm', file.path]); @@ -222,6 +230,7 @@ class BatchCommand extends PackageCommand { } } + print(' Staging changes...'); final io.ProcessResult addResult = await repository .runCommand(['add', package.pubspecFile.path, package.changelogFile.path]); if (addResult.exitCode != 0) { @@ -229,6 +238,7 @@ class BatchCommand extends PackageCommand { throw ToolExit(_kGitFailedToPush); } + print(' Committing changes...'); final io.ProcessResult commitResult = await repository.runCommand([ 'commit', '-m', @@ -239,8 +249,9 @@ class BatchCommand extends PackageCommand { throw ToolExit(_kGitFailedToPush); } + print(' Pushing to remote...'); final io.ProcessResult pushResult = - await repository.runCommand(['push', 'origin', branchName]); + await repository.runCommand(['push', 'origin', branchName, '--force']); if (pushResult.exitCode != 0) { printError('Failed to push to $branchName: ${pushResult.stderr}'); throw ToolExit(_kGitFailedToPush); From db62a0667217dc33950379f86f31eb0cf7748bb1 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Tue, 28 Oct 2025 11:18:53 -0700 Subject: [PATCH 09/16] update --- .github/workflows/batch_release_pr.yml | 4 +- .github/workflows/go_router_batch.yml | 2 +- .../template.yaml | 8 +- .../test_only_1.yaml | 4 +- .../test_only_2.yaml | 4 +- ... => branch_for_batch_release_command.dart} | 186 ++++++++---------- script/tool/lib/src/main.dart | 4 +- 7 files changed, 91 insertions(+), 121 deletions(-) rename packages/go_router/{unreleased => pending_changelogs}/template.yaml (57%) rename packages/go_router/{unreleased => pending_changelogs}/test_only_1.yaml (88%) rename packages/go_router/{unreleased => pending_changelogs}/test_only_2.yaml (88%) rename script/tool/lib/src/{batch_command.dart => branch_for_batch_release_command.dart} (55%) diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml index 9ad2a85fb48..bef8b5bd64f 100644 --- a/.github/workflows/batch_release_pr.yml +++ b/.github/workflows/batch_release_pr.yml @@ -25,9 +25,7 @@ jobs: run: | git config --global user.name ${{ secrets.USER_NAME }} git config --global user.email ${{ secrets.USER_EMAIL }} - dart ./script/tool/lib/src/main.dart batch --package=${{ github.event.client_payload.package }} --branch=${{ env.BRANCH_NAME }}" - - name: Give tool a minute to create the branch - run: sleep 60s + 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: diff --git a/.github/workflows/go_router_batch.yml b/.github/workflows/go_router_batch.yml index e6ae75ebea4..75513a6a3b7 100644 --- a/.github/workflows/go_router_batch.yml +++ b/.github/workflows/go_router_batch.yml @@ -13,4 +13,4 @@ jobs: with: token: "${{ secrets.GITHUB_TOKEN }}" event-type: batch_release_pr - client-payload: '{"package": "go_router"}' \ No newline at end of file + client-payload: '{"package": "go_router"}' diff --git a/packages/go_router/unreleased/template.yaml b/packages/go_router/pending_changelogs/template.yaml similarity index 57% rename from packages/go_router/unreleased/template.yaml rename to packages/go_router/pending_changelogs/template.yaml index 2c09b2cfbf9..97107d891a9 100644 --- a/packages/go_router/unreleased/template.yaml +++ b/packages/go_router/pending_changelogs/template.yaml @@ -1,6 +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: - - - - -version: \ No newline at end of file +changelog: | + - Can include a list of changes. + - with markdown supported. +version: diff --git a/packages/go_router/unreleased/test_only_1.yaml b/packages/go_router/pending_changelogs/test_only_1.yaml similarity index 88% rename from packages/go_router/unreleased/test_only_1.yaml rename to packages/go_router/pending_changelogs/test_only_1.yaml index cbea7ead4f8..c4a0c9dcf4f 100644 --- a/packages/go_router/unreleased/test_only_1.yaml +++ b/packages/go_router/pending_changelogs/test_only_1.yaml @@ -1,6 +1,6 @@ # This file is for test purposes only. # TODO(chuntai): remove this file before publishing. -changelog: +changelog: | - Added 'batch' option to CI config for go_router package. - Updated GitHub Actions workflow for batch releases of go_router. -version: major \ No newline at end of file +version: major diff --git a/packages/go_router/unreleased/test_only_2.yaml b/packages/go_router/pending_changelogs/test_only_2.yaml similarity index 88% rename from packages/go_router/unreleased/test_only_2.yaml rename to packages/go_router/pending_changelogs/test_only_2.yaml index cbea7ead4f8..c4a0c9dcf4f 100644 --- a/packages/go_router/unreleased/test_only_2.yaml +++ b/packages/go_router/pending_changelogs/test_only_2.yaml @@ -1,6 +1,6 @@ # This file is for test purposes only. # TODO(chuntai): remove this file before publishing. -changelog: +changelog: | - Added 'batch' option to CI config for go_router package. - Updated GitHub Actions workflow for batch releases of go_router. -version: major \ No newline at end of file +version: major diff --git a/script/tool/lib/src/batch_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart similarity index 55% rename from script/tool/lib/src/batch_command.dart rename to script/tool/lib/src/branch_for_batch_release_command.dart index 2e182b78f90..9886b3e0d25 100644 --- a/script/tool/lib/src/batch_command.dart +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -21,21 +21,15 @@ const int _kGitFailedToPush = 3; const String _kTemplateFileName = 'template.yaml'; -/// A command to create a pull request for a single package release. -class BatchCommand extends PackageCommand { - /// Creates a new `batch` command. - BatchCommand( +/// 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( - 'package', - mandatory: true, - abbr: 'p', - help: 'The package to create a release PR for.', - ); argParser.addOption( 'branch', mandatory: true, @@ -45,38 +39,42 @@ class BatchCommand extends PackageCommand { } @override - final String name = 'batch'; + 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 packageName = argResults!['package'] as String; final String branchName = argResults!['branch'] as String; - final GitDir repository = await gitDir; + 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; - print('Fetching package "$packageName"...'); - final RepositoryPackage package = await _getPackage(packageName); + final GitDir repository = await gitDir; - print('Checking for unreleased changes...'); - final UnreleasedChanges unreleasedChanges = - await _getUnreleasedChanges(package); - if (unreleasedChanges.entries.isEmpty) { - printError('No unreleased changes found for $packageName.'); + 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()); - print('Determining release version...'); final ReleaseInfo releaseInfo = - _getReleaseInfo(unreleasedChanges.entries, pubspec.version!); + _getReleaseInfo(pendingChangelogs.entries, pubspec.version!); if (releaseInfo.newVersion == null) { - printError('No version change specified in unreleased changelog for ' - '$packageName.'); + print('No version change specified in pending changelogs for ' + '${package.displayName}.'); return; } @@ -85,49 +83,44 @@ class BatchCommand extends PackageCommand { repository: repository, package: package, branchName: branchName, - unreleasedFiles: unreleasedChanges.files, + pendingChangelogFiles: pendingChangelogs.files, releaseInfo: releaseInfo, ); } - Future _getPackage(String packageName) async { - return getTargetPackages() - .map( - (PackageEnumerationEntry entry) => entry.package) - .firstWhere((RepositoryPackage p) => p.displayName.split('/').last == packageName); - } - - Future _getUnreleasedChanges( + Future _getPendingChangelogs( RepositoryPackage package) async { - final Directory unreleasedDir = - package.directory.childDirectory('unreleased'); - if (!unreleasedDir.existsSync()) { - printError('No unreleased folder found for ${package.displayName}.'); + 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 unreleasedFiles = unreleasedDir + final List pendingChangelogFiles = pendingChangelogsDir .listSync() .whereType() - .where((File f) => f.basename.endsWith('.yaml') && f.basename != _kTemplateFileName) + .where((File f) => + f.basename.endsWith('.yaml') && f.basename != _kTemplateFileName) .toList(); try { - final List entries = unreleasedFiles - .map( - (File f) => UnreleasedEntry.parse(f.readAsStringSync())) + final List entries = pendingChangelogFiles + .map( + (File f) => PendingChangelogEntry.parse(f.readAsStringSync())) .toList(); - return UnreleasedChanges(entries, unreleasedFiles); + return PendingChangelogs(entries, pendingChangelogFiles); } on FormatException catch (e) { - printError('Malformed unreleased changelog file: $e'); + printError('Malformed pending changelog file: $e'); throw ToolExit(_kExitPackageMalformed); } } ReleaseInfo _getReleaseInfo( - List unreleasedEntries, Version oldVersion) { + List pendingChangelogEntries, Version oldVersion) { final List changelogs = []; int versionIndex = VersionChange.skip.index; - for (final UnreleasedEntry entry in unreleasedEntries) { - changelogs.addAll(entry.changelog); + for (final PendingChangelogEntry entry in pendingChangelogEntries) { + changelogs.add(entry.changelog); versionIndex = math.min(versionIndex, entry.version.index); } final VersionChange effectiveVersionChange = @@ -163,24 +156,15 @@ class BatchCommand extends PackageCommand { required GitDir repository, required RepositoryPackage package, required String branchName, - required List unreleasedFiles, + required List pendingChangelogFiles, required ReleaseInfo releaseInfo, }) async { - print(' Deleting old branch "$branchName"...'); - final io.ProcessResult deleteBranchResult = - await repository.runCommand(['branch', '-D', branchName]); - if (deleteBranchResult.exitCode != 0) { - printError( - 'Failed to delete branch $branchName: ${deleteBranchResult.stderr}'); - throw ToolExit(_kGitFailedToPush); - } - print(' Creating new branch "$branchName"...'); - final io.ProcessResult checkoutResult = await repository.runCommand( - ['checkout', '-b', branchName]); + final io.ProcessResult checkoutResult = + await repository.runCommand(['checkout', '-b', branchName]); if (checkoutResult.exitCode != 0) { printError( - 'Failed to checkout branch $branchName: ${checkoutResult.stderr}'); + 'Failed to create branch $branchName: ${checkoutResult.stderr}'); throw ToolExit(_kGitFailedToPush); } @@ -188,40 +172,28 @@ class BatchCommand extends PackageCommand { // Update pubspec.yaml. final YamlEditor editablePubspec = YamlEditor(package.pubspecFile.readAsStringSync()); - editablePubspec.update(['version'], releaseInfo.newVersion.toString()); + 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 - .map((String line) => '- $line') - .toList(); + final List newEntries = releaseInfo.changelogs; - final List changelogLines = package.changelogFile.readAsLinesSync(); + final String oldChangelogContent = package.changelogFile.readAsStringSync(); final StringBuffer newChangelog = StringBuffer(); - bool inserted = false; - for (final String line in changelogLines) { - if (!inserted && line.startsWith('## ')) { - newChangelog.writeln(newHeader); - newChangelog.writeln(); - newChangelog.writeln(newEntries.join('\n')); - newChangelog.writeln(); - inserted = true; - } - newChangelog.writeln(line); - } - - if (!inserted) { - printError("Can't parse existing CHANGELOG.md."); - throw ToolExit(_kExitPackageMalformed); - } + newChangelog.writeln(newHeader); + newChangelog.writeln(); + newChangelog.writeln(newEntries.join('\n')); + newChangelog.writeln(); + newChangelog.write(oldChangelogContent); package.changelogFile.writeAsStringSync(newChangelog.toString()); - print(' Removing unreleased change files...'); - for (final File file in unreleasedFiles) { + print(' Removing pending changelog files...'); + for (final File file in pendingChangelogFiles) { final io.ProcessResult rmResult = await repository.runCommand(['rm', file.path]); if (rmResult.exitCode != 0) { @@ -231,8 +203,8 @@ class BatchCommand extends PackageCommand { } print(' Staging changes...'); - final io.ProcessResult addResult = await repository - .runCommand(['add', package.pubspecFile.path, package.changelogFile.path]); + final io.ProcessResult addResult = await repository.runCommand( + ['add', package.pubspecFile.path, package.changelogFile.path]); if (addResult.exitCode != 0) { printError('Failed to git add: ${addResult.stderr}'); throw ToolExit(_kGitFailedToPush); @@ -250,8 +222,8 @@ class BatchCommand extends PackageCommand { } print(' Pushing to remote...'); - final io.ProcessResult pushResult = - await repository.runCommand(['push', 'origin', branchName, '--force']); + final io.ProcessResult pushResult = await repository + .runCommand(['push', 'origin', branchName, '--force']); if (pushResult.exitCode != 0) { printError('Failed to push to $branchName: ${pushResult.stderr}'); throw ToolExit(_kGitFailedToPush); @@ -259,15 +231,15 @@ class BatchCommand extends PackageCommand { } } -/// A data class for unreleased changes. -class UnreleasedChanges { +/// A data class for pending changelogs. +class PendingChangelogs { /// Creates a new instance. - UnreleasedChanges(this.entries, this.files); + PendingChangelogs(this.entries, this.files); - /// The parsed unreleased entries. - final List entries; + /// The parsed pending changelog entries. + final List entries; - /// The files that the unreleased entries were parsed from. + /// The files that the pending changelog entries were parsed from. final List files; } @@ -298,25 +270,25 @@ enum VersionChange { skip, } -/// Represents a single entry in the unreleased changelog. -class UnreleasedEntry { - /// Creates a new unreleased entry. - UnreleasedEntry({required this.changelog, required this.version}); +/// Represents a single entry in the pending changelog. +class PendingChangelogEntry { + /// Creates a new pending changelog entry. + PendingChangelogEntry({required this.changelog, required this.version}); - /// Creates an UnreleasedEntry from a YAML string. - factory UnreleasedEntry.parse(String yamlContent) { + /// 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}.'); + throw FormatException( + 'Expected a YAML map, but found ${yaml.runtimeType}.'); } final dynamic changelogYaml = yaml['changelog']; - if (changelogYaml is! YamlList) { - throw FormatException('Expected "changelog" to be a list, but found ${changelogYaml.runtimeType}.'); + if (changelogYaml is! String) { + throw FormatException( + 'Expected "changelog" to be a string, but found ${changelogYaml.runtimeType}.'); } - final List changelog = changelogYaml.nodes - .map((YamlNode node) => node.value as String) - .toList(); + final String changelog = changelogYaml; final String? versionString = yaml['version'] as String?; if (versionString == null) { @@ -328,11 +300,11 @@ class UnreleasedEntry { throw FormatException('Invalid version type: $versionString'), ); - return UnreleasedEntry(changelog: changelog, version: version); + return PendingChangelogEntry(changelog: changelog, version: version); } /// The changelog messages for this entry. - final List changelog; + 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 694b54bfad6..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'; @@ -38,7 +39,6 @@ import 'update_excerpts_command.dart'; import 'update_min_sdk_command.dart'; import 'update_release_info_command.dart'; import 'version_check_command.dart'; -import 'batch_command.dart'; void main(List args) { const FileSystem fileSystem = LocalFileSystem(); @@ -88,7 +88,7 @@ void main(List args) { ..addCommand(UpdateMinSdkCommand(packagesDir)) ..addCommand(UpdateReleaseInfoCommand(packagesDir)) ..addCommand(VersionCheckCommand(packagesDir)) - ..addCommand(BatchCommand(packagesDir)); + ..addCommand(BranchForBatchReleaseCommand(packagesDir)); commandRunner.run(args).catchError((Object e) { final ToolExit toolExit = e as ToolExit; From ca8d0f87b0cc702dcbe17df402e213f675e3535c Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Tue, 28 Oct 2025 11:24:06 -0700 Subject: [PATCH 10/16] update --- script/tool/lib/src/branch_for_batch_release_command.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/tool/lib/src/branch_for_batch_release_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart index 9886b3e0d25..65b05272006 100644 --- a/script/tool/lib/src/branch_for_batch_release_command.dart +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -186,7 +186,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { newChangelog.writeln(newHeader); newChangelog.writeln(); - newChangelog.writeln(newEntries.join('\n')); + newChangelog.writeln(newEntries.join()); newChangelog.writeln(); newChangelog.write(oldChangelogContent); From 6922c94cc4ff56fdaf0acbd47c7df99f956bed23 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Tue, 28 Oct 2025 11:29:16 -0700 Subject: [PATCH 11/16] update --- script/tool/lib/src/branch_for_batch_release_command.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/tool/lib/src/branch_for_batch_release_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart index 65b05272006..df0a0f03c71 100644 --- a/script/tool/lib/src/branch_for_batch_release_command.dart +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -186,7 +186,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { newChangelog.writeln(newHeader); newChangelog.writeln(); - newChangelog.writeln(newEntries.join()); + newChangelog.writeln(newEntries.join('\n')); newChangelog.writeln(); newChangelog.write(oldChangelogContent); @@ -288,7 +288,7 @@ class PendingChangelogEntry { throw FormatException( 'Expected "changelog" to be a string, but found ${changelogYaml.runtimeType}.'); } - final String changelog = changelogYaml; + final String changelog = changelogYaml.trim(); final String? versionString = yaml['version'] as String?; if (versionString == null) { From 554daddf4a592a5c1da8ddfbad9915e3ba6d864b Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Tue, 28 Oct 2025 16:06:10 -0700 Subject: [PATCH 12/16] add test --- ...branch_for_batch_release_command_test.dart | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 script/tool/test/branch_for_batch_release_command_test.dart 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..171a7635dec --- /dev/null +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -0,0 +1,156 @@ +// 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/repository_package.dart'; +import 'package:git/git.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; + +import 'util.dart'; + +void main() { + late Directory packagesDir; + late RecordingProcessRunner processRunner; + late RecordingProcessRunner gitProcessRunner; + late CommandRunner runner; + + setUp(() { + final GitDir gitDir; + (:packagesDir, :processRunner, :gitProcessRunner, :gitDir) = + configureBaseCommandMocks(); + + final BranchForBatchReleaseCommand command = BranchForBatchReleaseCommand( + packagesDir, + processRunner: processRunner, + gitDir: gitDir, + ); + runner = CommandRunner('branch_for_batch_release_command', + 'Test for branch_for_batch_release_command'); + runner.addCommand(command); + }); + + group('happy path', () { + late RepositoryPackage package; + setUp(() { + package = createFakePackage('a_package', packagesDir); + + package.changelogFile.writeAsStringSync(''' +## 1.0.0 + +- Old changes +'''); + package.pubspecFile.writeAsStringSync(''' +name: a_package +version: 1.0.0 +'''); + final File pendingChangelog = package.directory + .childDirectory('pending_changelogs') + .childFile('a.yaml') + ..createSync(recursive: true); + pendingChangelog.writeAsStringSync(''' +changelog: A new feature +version: minor +'''); + }); + + tearDown(() { + package.directory.deleteSync(recursive: true); + }); + + test('makes a branch', () async { + // Set up a mock for the git calls to have side-effects. + gitProcessRunner.mockProcessesForExecutable['git'] = [ + // checkout + FakeProcessInfo( + MockProcess(), const ['checkout', '-b', 'release-branch']), + // rm + FakeProcessInfo(MockProcess(), [ + 'rm', + package.directory + .childDirectory('pending_changelogs') + .childFile('a.yaml') + .path + ], () { + package.directory + .childDirectory('pending_changelogs') + .childFile('a.yaml') + .deleteSync(); + }), + // add + FakeProcessInfo(MockProcess(), [ + 'add', + package.pubspecFile.path, + package.changelogFile.path + ]), + // commit + FakeProcessInfo(MockProcess(), + const ['commit', '-m', 'a_package: Prepare for release']), + // push + FakeProcessInfo(MockProcess(), + const ['push', 'origin', 'release-branch', '--force']), + ]; + final List output = await runCapturingPrint(runner, [ + 'branch-for-batch-release', + '--packages=a_package', + '--branch=release-branch', + ]); + + expect( + output, + containsAllInOrder([ + '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...', + ' Removing pending changelog files...', + ' Staging changes...', + ' Committing changes...', + ' Pushing to remote...', + ])); + + expect(package.pubspecFile.readAsStringSync(), ''' +name: a_package +version: 1.1.0 +'''); + expect(package.changelogFile.readAsStringSync(), ''' +## 1.1.0 + +A new feature + +## 1.0.0 + +- Old changes +'''); + + expect( + gitProcessRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + 'git-checkout', ['-b', 'release-branch'], null), + ProcessCall( + 'git-rm', + [ + package.directory + .childDirectory('pending_changelogs') + .childFile('a.yaml') + .path + ], + null), + ProcessCall( + 'git-add', + [package.pubspecFile.path, package.changelogFile.path], + null), + const ProcessCall('git-commit', + ['-m', 'a_package: Prepare for release'], null), + const ProcessCall('git-push', + ['origin', 'release-branch', '--force'], null), + ])); + }); + }); +} From 0824de8ae00a74b91a20bf5d60c5a6bf3224f418 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Wed, 29 Oct 2025 14:01:03 -0700 Subject: [PATCH 13/16] add tests --- .../src/branch_for_batch_release_command.dart | 16 +- ...branch_for_batch_release_command_test.dart | 287 +++++++++++------- 2 files changed, 192 insertions(+), 111 deletions(-) diff --git a/script/tool/lib/src/branch_for_batch_release_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart index df0a0f03c71..e1a6dc472e5 100644 --- a/script/tool/lib/src/branch_for_batch_release_command.dart +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -80,7 +80,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { print('Creating and pushing release branch...'); await _pushBranch( - repository: repository, + git: repository, package: package, branchName: branchName, pendingChangelogFiles: pendingChangelogs.files, @@ -153,7 +153,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { } Future _pushBranch({ - required GitDir repository, + required GitDir git, required RepositoryPackage package, required String branchName, required List pendingChangelogFiles, @@ -161,7 +161,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { }) async { print(' Creating new branch "$branchName"...'); final io.ProcessResult checkoutResult = - await repository.runCommand(['checkout', '-b', branchName]); + await git.runCommand(['checkout', '-b', branchName]); if (checkoutResult.exitCode != 0) { printError( 'Failed to create branch $branchName: ${checkoutResult.stderr}'); @@ -195,7 +195,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { print(' Removing pending changelog files...'); for (final File file in pendingChangelogFiles) { final io.ProcessResult rmResult = - await repository.runCommand(['rm', file.path]); + await git.runCommand(['rm', file.path]); if (rmResult.exitCode != 0) { printError('Failed to rm ${file.path}: ${rmResult.stderr}'); throw ToolExit(_kGitFailedToPush); @@ -203,7 +203,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { } print(' Staging changes...'); - final io.ProcessResult addResult = await repository.runCommand( + 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}'); @@ -211,7 +211,7 @@ class BranchForBatchReleaseCommand extends PackageCommand { } print(' Committing changes...'); - final io.ProcessResult commitResult = await repository.runCommand([ + final io.ProcessResult commitResult = await git.runCommand([ 'commit', '-m', '${package.displayName}: Prepare for release' @@ -222,8 +222,8 @@ class BranchForBatchReleaseCommand extends PackageCommand { } print(' Pushing to remote...'); - final io.ProcessResult pushResult = await repository - .runCommand(['push', 'origin', branchName, '--force']); + 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); diff --git a/script/tool/test/branch_for_batch_release_command_test.dart b/script/tool/test/branch_for_batch_release_command_test.dart index 171a7635dec..60b55932b6e 100644 --- a/script/tool/test/branch_for_batch_release_command_test.dart +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -5,7 +5,7 @@ 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/repository_package.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:git/git.dart'; import 'package:test/test.dart'; @@ -14,143 +14,224 @@ 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(); - + 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', () { - late RepositoryPackage package; - setUp(() { - package = createFakePackage('a_package', packagesDir); + package = createFakePackage('a_package', packagesDir); - package.changelogFile.writeAsStringSync(''' + package.changelogFile.writeAsStringSync(''' ## 1.0.0 - Old changes '''); - package.pubspecFile.writeAsStringSync(''' + package.pubspecFile.writeAsStringSync(''' name: a_package version: 1.0.0 '''); - final File pendingChangelog = package.directory - .childDirectory('pending_changelogs') - .childFile('a.yaml') - ..createSync(recursive: true); - pendingChangelog.writeAsStringSync(''' + 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 [], + }) 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)); + } + } + } + + 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...', + ], + ); }); - tearDown(() { - package.directory.deleteSync(recursive: true); + 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...', + ], + ); }); - test('makes a branch', () async { - // Set up a mock for the git calls to have side-effects. - gitProcessRunner.mockProcessesForExecutable['git'] = [ - // checkout - FakeProcessInfo( - MockProcess(), const ['checkout', '-b', 'release-branch']), - // rm - FakeProcessInfo(MockProcess(), [ - 'rm', - package.directory - .childDirectory('pending_changelogs') - .childFile('a.yaml') - .path - ], () { - package.directory - .childDirectory('pending_changelogs') - .childFile('a.yaml') - .deleteSync(); - }), - // add - FakeProcessInfo(MockProcess(), [ - 'add', - package.pubspecFile.path, - package.changelogFile.path - ]), - // commit - FakeProcessInfo(MockProcess(), - const ['commit', '-m', 'a_package: Prepare for release']), - // push - FakeProcessInfo(MockProcess(), - const ['push', 'origin', 'release-branch', '--force']), - ]; - final List output = await runCapturingPrint(runner, [ - 'branch-for-batch-release', - '--packages=a_package', - '--branch=release-branch', - ]); + 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...', + ], + ); + }); - expect( - output, - containsAllInOrder([ - '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...', - ' Removing pending changelog files...', - ' Staging changes...', - ' Committing changes...', - ' Pushing to remote...', - ])); - - expect(package.pubspecFile.readAsStringSync(), ''' -name: a_package -version: 1.1.0 -'''); - expect(package.changelogFile.readAsStringSync(), ''' -## 1.1.0 + 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...', + ], + ); + }); -A new feature + 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([])); + }); -## 1.0.0 + 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([])); + }); + }); -- Old changes + 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( - gitProcessRunner.recordedCalls, - orderedEquals([ - const ProcessCall( - 'git-checkout', ['-b', 'release-branch'], null), - ProcessCall( - 'git-rm', - [ - package.directory - .childDirectory('pending_changelogs') - .childFile('a.yaml') - .path - ], - null), - ProcessCall( - 'git-add', - [package.pubspecFile.path, package.changelogFile.path], - null), - const ProcessCall('git-commit', - ['-m', 'a_package: Prepare for release'], null), - const ProcessCall('git-push', - ['origin', 'release-branch', '--force'], null), - ])); - }); + expect(error, isA()); }); } From b7ba3ae1f950b07cebfd7cd3cba67e1704557196 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Wed, 29 Oct 2025 14:14:00 -0700 Subject: [PATCH 14/16] update --- ...branch_for_batch_release_command_test.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/script/tool/test/branch_for_batch_release_command_test.dart b/script/tool/test/branch_for_batch_release_command_test.dart index 60b55932b6e..fe4ada92e23 100644 --- a/script/tool/test/branch_for_batch_release_command_test.dart +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -67,6 +67,7 @@ version: 1.0.0 required List expectedOutput, String? expectedVersion, List expectedChangelogSnippets = const [], + List expectedGitCommands = const [], }) async { for (final MapEntry entry in changelogs.entries) { createChangelogFile(entry.key, entry.value); @@ -89,6 +90,7 @@ version: 1.0.0 expect(changelogContent, contains(snippet)); } } + expect(gitProcessRunner.recordedCalls, orderedEquals(expectedGitCommands)); } group('happy path', () { @@ -109,6 +111,23 @@ version: minor ' 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), + ], ); }); @@ -129,6 +148,23 @@ version: major ' 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), + ], ); }); @@ -149,6 +185,23 @@ version: patch ' 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), + ], ); }); @@ -176,6 +229,25 @@ version: major ' 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), + ], ); }); From 94ef01e4df7b0c58636a29c77b435f49caa19a4d Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Wed, 29 Oct 2025 14:16:07 -0700 Subject: [PATCH 15/16] clean up --- .github/workflows/batch_release_pr.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/batch_release_pr.yml b/.github/workflows/batch_release_pr.yml index bef8b5bd64f..190096b7d27 100644 --- a/.github/workflows/batch_release_pr.yml +++ b/.github/workflows/batch_release_pr.yml @@ -15,12 +15,6 @@ jobs: - name: Set up tools run: dart pub get working-directory: ${{ github.workspace }}/script/tool - # Give some time for LUCI checks to start becoming populated. - # Because of latency in Github Webhooks, we need to wait for a while - # before being able to look at checks scheduled by LUCI. - - name: Give webhooks a minute - run: sleep 60s - shell: bash - name: create batch release PR run: | git config --global user.name ${{ secrets.USER_NAME }} From 5853905c0f33abf20b536dfd072a9d632a329386 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Thu, 30 Oct 2025 10:12:12 -0700 Subject: [PATCH 16/16] license --- script/tool/lib/src/branch_for_batch_release_command.dart | 2 +- script/tool/test/branch_for_batch_release_command_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/tool/lib/src/branch_for_batch_release_command.dart b/script/tool/lib/src/branch_for_batch_release_command.dart index e1a6dc472e5..ab5bef3927d 100644 --- a/script/tool/lib/src/branch_for_batch_release_command.dart +++ b/script/tool/lib/src/branch_for_batch_release_command.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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. diff --git a/script/tool/test/branch_for_batch_release_command_test.dart b/script/tool/test/branch_for_batch_release_command_test.dart index fe4ada92e23..7626d380aa0 100644 --- a/script/tool/test/branch_for_batch_release_command_test.dart +++ b/script/tool/test/branch_for_batch_release_command_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// 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.