diff --git a/.github/workflows/release_from_branches.yml b/.github/workflows/release_from_branches.yml new file mode 100644 index 000000000000..a235a2576ef3 --- /dev/null +++ b/.github/workflows/release_from_branches.yml @@ -0,0 +1,12 @@ +name: Batch Release +on: + push: + branches: + - 'release-go_router' +jobs: + release: + uses: ./.github/workflows/resuable_release.yml + with: + is-batch-release: true + branch-name: '${{ github.ref_name }}' + secrets: inherit diff --git a/.github/workflows/reusable_release.yml b/.github/workflows/reusable_release.yml new file mode 100644 index 000000000000..9a0894507c67 --- /dev/null +++ b/.github/workflows/reusable_release.yml @@ -0,0 +1,84 @@ +name: Reusable Release +on: + workflow_call: + inputs: + is-batch-release: + required: true + type: boolean + branch-name: + required: true + type: string +# Declare default permissions as read only. +permissions: read-all +jobs: + release: + if: github.repository_owner == 'flutter' + name: release + permissions: + # Release needs to push a tag back to the repo. + contents: write + runs-on: ubuntu-latest + steps: + # Checks out a copy of the repo. + - name: Check out code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + fetch-depth: 0 # Fetch all history so the tool can get all the tags to determine version. + ref: ${{ inputs.branch-name }} + - name: "Install Flutter" + uses: ./.github/workflows/internals/install_flutter + - 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 + + # The next step waits for all tests, but when there are issues with the + # hooks it can take a long time for the tests to even be registered. If + # "Wait on all tests" runs before that happens, it will pass immediately + # because there doesn't appear to be anything to wait for. To avoid that, + # explicitly wait for one LUCI test by name first. + - name: Wait for test check-in + uses: lewagon/wait-on-check-action@0dceb95e7c4cad8cc7422aee3885998f5cab9c79 + with: + ref: ${{ github.sha }} + check-name: 'Linux ci_yaml packages roller' + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 30 # seconds + allowed-conclusions: success,neutral + # verbose:true will produce too many logs that hang github actions web UI. + verbose: false + + # This workflow should be the last to run. So wait for all the other tests to succeed. + - name: Wait on all tests + uses: lewagon/wait-on-check-action@0dceb95e7c4cad8cc7422aee3885998f5cab9c79 + with: + ref: ${{ github.sha }} + running-workflow-name: 'release' + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 180 # seconds + allowed-conclusions: success,neutral + # verbose:true will produce too many logs that hang github actions web UI. + verbose: false + + - name: run release + run: | + git config --global user.name "${{ secrets.USER_NAME }}" + git config --global user.email "${{ secrets.USER_EMAIL }}" + + # Build the flag string based on the input + BATCH_FLAG="" + if [ "${{ inputs.is-batch-release }}" = "true" ]; then + BATCH_FLAG="--batch-release-branch=${{ inputs.branch-name }}" + fi + dart ./script/tool/lib/src/main.dart publish \ + --all-changed \ + $BATCH_FLAG \ + --base-sha=HEAD~ \ + --skip-confirmation + env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} diff --git a/script/tool/lib/src/publish_command.dart b/script/tool/lib/src/publish_command.dart index 4d9da43ddc40..d043da0a60c3 100644 --- a/script/tool/lib/src/publish_command.dart +++ b/script/tool/lib/src/publish_command.dart @@ -83,6 +83,10 @@ class PublishCommand extends PackageLoopingCommand { 'Release all packages that contains pubspec changes at the current commit compares to the base-sha.\n' 'The --packages option is ignored if this is on.', ); + argParser.addOption( + _batchReleaseBranchOption, + help: 'batch release a package from its release branch', + ); argParser.addFlag( _dryRunFlag, help: @@ -109,6 +113,7 @@ class PublishCommand extends PackageLoopingCommand { static const String _pubFlagsOption = 'pub-publish-flags'; static const String _remoteOption = 'remote'; static const String _allChangedFlag = 'all-changed'; + static const String _batchReleaseBranchOption = 'batch-release-branch'; static const String _dryRunFlag = 'dry-run'; static const String _skipConfirmationFlag = 'skip-confirmation'; static const String _tagForAutoPublishFlag = 'tag-for-auto-publish'; @@ -186,6 +191,9 @@ class PublishCommand extends PackageLoopingCommand { @override Stream getPackagesToProcess() async* { + final String batchReleaseBranchName = getStringArg( + _batchReleaseBranchOption, + ); if (getBoolArg(_allChangedFlag)) { print( 'Publishing all packages that have changed relative to "$baseSha"\n', @@ -196,6 +204,40 @@ class PublishCommand extends PackageLoopingCommand { .toList(); for (final pubspecPath in changedPubspecs) { + // Read the ci_config.yaml file if it exists + final String packageName = p.basename(p.dirname(pubspecPath)); + final bool isBatchReleasePackage; + try { + final File ciConfigFile = RepositoryPackage( + packagesDir.fileSystem.file(pubspecPath).parent, + ).ciConfigFile; + + if (!ciConfigFile.existsSync()) { + isBatchReleasePackage = false; + } else { + final ciConfig = CIConfig.parse(ciConfigFile.readAsStringSync()); + isBatchReleasePackage = ciConfig.isBatchRelease; + } + } catch (e) { + printError('Could not parse ci_config.yaml for $packageName: $e'); + throw ToolExit(exitCommandFoundErrors); + } + + // When releasing from the main branch, skip the batch release packages. + if (batchReleaseBranchName.isEmpty) { + if (isBatchReleasePackage) { + continue; + } + } else { + // When releasing from a batch release branch, verify the package has + // the opt-in flag and that the package name matches the branch suffix. + // Example: branch "release-go_router" matches package "go_router". + if (!isBatchReleasePackage || + batchReleaseBranchName != 'release-$packageName') { + continue; + } + } + // git outputs a relativa, Posix-style path. final File pubspecFile = childFileWithSubcomponents( packagesDir.fileSystem.directory((await gitDir).path), diff --git a/script/tool/test/publish_command_test.dart b/script/tool/test/publish_command_test.dart index f106f0349561..c12d91f71721 100644 --- a/script/tool/test/publish_command_test.dart +++ b/script/tool/test/publish_command_test.dart @@ -1284,6 +1284,274 @@ void main() { ); }); + group('--batch-release-branch flag', () { + test( + 'filters packages based on the existence of ci_config.yaml', + () async { + // Mock pub.dev responses. + mockHttpResponses['package1'] = { + 'name': 'package1', + 'versions': ['0.0.1'], + }; + mockHttpResponses['package2'] = { + 'name': 'package2', + 'versions': ['0.0.1'], + }; + + // Mock packages. + final RepositoryPackage package1 = createFakePackage( + 'package1', + packagesDir, + version: '0.0.2', + ); + createFakeCiConfig(package: package1, batchRelease: true); + + final RepositoryPackage package2 = createFakePackage( + 'package2', + packagesDir, + version: '0.0.2', + ); + + expect(package1.ciConfigFile.existsSync(), true); + expect(package2.ciConfigFile.existsSync(), false); + + // Mock git diff to show both packages have changed. + processRunner + .mockProcessesForExecutable['git-diff'] = [ + FakeProcessInfo( + MockProcess( + stdout: + '${package1.pubspecFile.path}\n${package2.pubspecFile.path}', + ), + ), + ]; + + mockStdin.readLineOutput = 'y'; + + final List output = + await runCapturingPrint(commandRunner, [ + 'publish', + '--all-changed', + '--base-sha=HEAD~', + '--batch-release-branch=release-package1', + ]); + // Package1 is published in batch realease, pacakge2 is not. + expect( + output, + containsAllInOrder([ + contains('Running `pub publish ` in ${package1.path}...'), + contains('Published package1 successfully!'), + ]), + ); + + expect( + output, + isNot( + contains( + contains('Running `pub publish ` in ${package2.path}...!'), + ), + ), + ); + expect( + output, + isNot(contains(contains('Published package2 successfully!'))), + ); + }, + ); + + test( + 'filters packages based on the batch release flag value in ci_config.yaml', + () async { + // Mock pub.dev responses. + mockHttpResponses['package1'] = { + 'name': 'package1', + 'versions': ['0.0.1'], + }; + mockHttpResponses['package2'] = { + 'name': 'package2', + 'versions': ['0.0.1'], + }; + + // Mock packages. + final RepositoryPackage package1 = createFakePackage( + 'package1', + packagesDir, + version: '0.0.2', + ); + createFakeCiConfig(package: package1, batchRelease: true); + final RepositoryPackage package2 = createFakePackage( + 'package2', + packagesDir, + version: '0.0.2', + ); + createFakeCiConfig(package: package2, batchRelease: false); + + // Mock git diff to show both packages have changed. + processRunner + .mockProcessesForExecutable['git-diff'] = [ + FakeProcessInfo( + MockProcess( + stdout: + '${package1.pubspecFile.path}\n${package2.pubspecFile.path}', + ), + ), + ]; + + mockStdin.readLineOutput = 'y'; + + final List output = + await runCapturingPrint(commandRunner, [ + 'publish', + '--all-changed', + '--base-sha=HEAD~', + '--batch-release-branch=release-package1', + ]); + // Package1 is published in batch realease, pacakge2 is not. + expect( + output, + containsAllInOrder([ + contains('Running `pub publish ` in ${package1.path}...'), + contains('Published package1 successfully!'), + ]), + ); + + expect( + output, + isNot( + contains( + contains('Running `pub publish ` in ${package2.path}...!'), + ), + ), + ); + expect( + output, + isNot(contains(contains('Published package2 successfully!'))), + ); + }, + ); + + test( + 'when --batch-release-branch flag value is empty, batch release packages are filtered out', + () async { + // Mock pub.dev responses. + mockHttpResponses['package1'] = { + 'name': 'package1', + 'versions': ['0.0.1'], + }; + mockHttpResponses['package2'] = { + 'name': 'package2', + 'versions': ['0.0.1'], + }; + + // Mock packages. + final RepositoryPackage package1 = createFakePackage( + 'package1', + packagesDir, + version: '0.0.2', + ); + + final RepositoryPackage package2 = createFakePackage( + 'package2', + packagesDir, + version: '0.0.2', + ); + createFakeCiConfig(package: package2, batchRelease: true); + + // Mock git diff to show both packages have changed. + processRunner + .mockProcessesForExecutable['git-diff'] = [ + FakeProcessInfo( + MockProcess( + stdout: + '${package1.pubspecFile.path}\n${package2.pubspecFile.path}', + ), + ), + ]; + + mockStdin.readLineOutput = 'y'; + + final List output = await runCapturingPrint( + commandRunner, + ['publish', '--all-changed', '--base-sha=HEAD~'], + ); + // Package1 is published in batch realease, pacakge2 is not. + expect( + output, + containsAllInOrder([ + contains('Running `pub publish ` in ${package1.path}...'), + contains('Published package1 successfully!'), + ]), + ); + + expect( + output, + isNot( + contains( + contains('Running `pub publish ` in ${package2.path}...!'), + ), + ), + ); + expect( + output, + isNot(contains(contains('Published package2 successfully!'))), + ); + }, + ); + test(' throw tool exit when could not parse ci config file', () async { + // Mock pub.dev responses. + mockHttpResponses['package1'] = { + 'name': 'package1', + 'versions': ['0.0.1'], + }; + + // Mock packages. + final RepositoryPackage package1 = createFakePackage( + 'package1', + packagesDir, + version: '0.0.2', + ); + createFakeCiConfig(package: package1, batchRelease: false); + package1.ciConfigFile.writeAsStringSync( + 'wrong format of ci config file', + ); + + // Mock git diff to show both packages have changed. + processRunner.mockProcessesForExecutable['git-diff'] = + [ + FakeProcessInfo( + MockProcess(stdout: '${package1.pubspecFile.path}\n'), + ), + ]; + + mockStdin.readLineOutput = 'y'; + + Error? commandError; + // Package1 is published in batch realease, pacakge2 is not. + final List output = await runCapturingPrint( + commandRunner, + [ + 'publish', + '--all-changed', + '--base-sha=HEAD~', + '--batch-release-branch=release-package1', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Could not parse ci_config.yaml for package1: FormatException: Root of ci_config.yaml must be a map.', + ), + ]), + ); + }); + }); + test('Do not release flutter_plugin_tools', () async { mockHttpResponses['plugin1'] = { 'name': 'flutter_plugin_tools', diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 493e4641309f..a6d94f6fa928 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -287,6 +287,21 @@ $pluginSection package.pubspecFile.writeAsStringSync(yaml); } +/// Creates a `ci_config.yaml` file for [package]. +void createFakeCiConfig({ + required RepositoryPackage package, + required bool batchRelease, +}) { + final yaml = + ''' +release: + batch: $batchRelease +'''; + + package.ciConfigFile.createSync(); + package.ciConfigFile.writeAsStringSync(yaml); +} + String _pluginPlatformSection( String platform, PlatformDetails support,