diff --git a/.github/workflows/go_router_batch.yml b/.github/workflows/go_router_batch.yml index 06a8be7d90e..3687b074108 100644 --- a/.github/workflows/go_router_batch.yml +++ b/.github/workflows/go_router_batch.yml @@ -2,10 +2,9 @@ name: "Creates Batch Release for go_router" on: workflow_dispatch: - # Uncomment the cron schedule when ready. - # schedule: - # # Run every Monday at 8:00 AM - # - cron: "0 8 * * 1" + schedule: + # Run every Monday at 8:00 AM + - cron: "0 8 * * 1" jobs: dispatch_release_pr: diff --git a/packages/go_router/ci_config.yaml b/packages/go_router/ci_config.yaml index f7160fb4571..5784c6eb1d5 100644 --- a/packages/go_router/ci_config.yaml +++ b/packages/go_router/ci_config.yaml @@ -1,3 +1,2 @@ release: - # TODO(chunhtai): Opt in when ready. - batch: false + batch: true diff --git a/packages/go_router/pending_changelogs/convert_batch.yaml b/packages/go_router/pending_changelogs/convert_batch.yaml new file mode 100644 index 00000000000..1f2c2e52466 --- /dev/null +++ b/packages/go_router/pending_changelogs/convert_batch.yaml @@ -0,0 +1,3 @@ +changelog: | + - Converts go_router to use batch release. +version: skip diff --git a/packages/go_router/pending_changelogs/template.yaml b/packages/go_router/pending_changelogs/template.yaml new file mode 100644 index 00000000000..97107d891a9 --- /dev/null +++ b/packages/go_router/pending_changelogs/template.yaml @@ -0,0 +1,6 @@ +# Use this file as template to draft a unreleased changelog file. +# Make a copy of this file in the same directory, rename it, and fill in the details. +changelog: | + - Can include a list of changes. + - with markdown supported. +version: diff --git a/script/tool/lib/src/repo_package_info_check_command.dart b/script/tool/lib/src/repo_package_info_check_command.dart index 23af0030ee2..d3ab5d8bdcc 100644 --- a/script/tool/lib/src/repo_package_info_check_command.dart +++ b/script/tool/lib/src/repo_package_info_check_command.dart @@ -2,7 +2,10 @@ // 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:file/file.dart'; +import 'package:yaml/yaml.dart'; import 'common/core.dart'; import 'common/output_utils.dart'; @@ -149,6 +152,8 @@ class RepoPackageInfoCheckCommand extends PackageLoopingCommand { errors.add(e.message); } + errors.addAll(await _validateFilesBasedOnReleaseStrategy(package)); + // All published packages should have a README.md entry. if (package.isPublishable()) { errors.addAll(_validateRootReadme(package)); @@ -264,4 +269,206 @@ class RepoPackageInfoCheckCommand extends PackageLoopingCommand { return 'p: $packageName'; } } + + Future> _validateFilesBasedOnReleaseStrategy( + RepositoryPackage package, + ) async { + final errors = []; + final bool isBatchRelease = + package.parseCIConfig()?.isBatchRelease ?? false; + final String packageName = package.directory.basename; + final Directory workflowDir = _repoRoot + .childDirectory('.github') + .childDirectory('workflows'); + + errors.addAll( + _validateSpecificBatchWorkflow( + packageName, + workflowDir: workflowDir, + isBatchRelease: isBatchRelease, + ), + ); + + errors.addAll( + _validateGlobalWorkflowTrigger( + 'release_from_branches.yml', + workflowDir: workflowDir, + isBatchRelease: isBatchRelease, + packageName: packageName, + ), + ); + errors.addAll( + _validateGlobalWorkflowTrigger( + 'sync_release_pr.yml', + workflowDir: workflowDir, + isBatchRelease: isBatchRelease, + packageName: packageName, + ), + ); + + errors.addAll( + await _validateRemoteReleaseBranch( + packageName, + isBatchRelease: isBatchRelease, + ), + ); + + return errors; + } + + Future> _validateRemoteReleaseBranch( + String packageName, { + required bool isBatchRelease, + }) async { + final errors = []; + // Verify release branch exists on remote flutter/packages if it is a batch release package. + final io.ProcessResult result = await (await gitDir).runCommand([ + 'ls-remote', + '--exit-code', + '--heads', + 'origin', + 'release-$packageName', + ], throwOnError: false); + final branchExists = result.exitCode == 0; + if (isBatchRelease && !branchExists) { + errors.add( + 'Branch release-$packageName does not exist on remote flutter/packages\n' + 'See https://github.com/flutter/flutter/blob/master/docs/ecosystem/release/README.md#batch-release', + ); + } + // Allow branch to exist on remote flutter/packages for non-batch release packages. + // Otherwise, it will be hard to opt package out of batch release. + // + // Enforcing this will run into a deadlock where the ci in the PR to opts out of batch release + // will require removal of the release branch. but removing the release branch will immediately break + // the latest main. + return errors; + } + + List _validateSpecificBatchWorkflow( + String packageName, { + required Directory workflowDir, + required bool isBatchRelease, + }) { + final errors = []; + final File batchWorkflowFile = workflowDir.childFile( + '${packageName}_batch.yml', + ); + if (isBatchRelease) { + if (!batchWorkflowFile.existsSync()) { + errors.add( + 'Missing batch workflow file: .github/workflows/${packageName}_batch.yml\n' + 'See https://github.com/flutter/flutter/blob/master/docs/ecosystem/release/README.md#batch-release', + ); + } else { + // Validate content. + final String content = batchWorkflowFile.readAsStringSync(); + YamlMap? yaml; + try { + yaml = loadYaml(content) as YamlMap?; + } catch (e) { + errors.add('Invalid YAML in ${packageName}_batch.yml: $e'); + } + + if (yaml != null) { + var foundDispatch = false; + final jobs = yaml['jobs'] as YamlMap?; + if (jobs != null) { + for (final Object? job in jobs.values) { + if (job is YamlMap && job['steps'] is YamlList) { + final steps = job['steps'] as YamlList; + for (final Object? step in steps) { + if (step is YamlMap && + step['uses'] is String && + (step['uses'] as String).startsWith( + 'peter-evans/repository-dispatch', + )) { + final withArgs = step['with'] as YamlMap?; + if (withArgs != null && + withArgs['event-type'] == 'batch-release-pr' && + withArgs['client-payload'] == + '{"package": "$packageName"}') { + foundDispatch = true; + } + } + } + } + } + } + + if (!foundDispatch) { + errors.add( + 'Invalid batch workflow content in ${packageName}_batch.yml. ' + 'Must contain a step using peter-evans/repository-dispatch with:\n' + ' event-type: batch-release-pr\n' + ' client-payload: \'{"package": "$packageName"}\'\n' + 'See https://github.com/flutter/flutter/blob/master/docs/ecosystem/release/README.md#batch-release', + ); + } + } + } + } else { + if (batchWorkflowFile.existsSync()) { + errors.add( + 'Unexpected batch workflow file: .github/workflows/${packageName}_batch.yml\n', + ); + } + } + return errors; + } + + List _validateGlobalWorkflowTrigger( + String workflowName, { + required Directory workflowDir, + required bool isBatchRelease, + required String packageName, + }) { + final errors = []; + final File workflowFile = workflowDir.childFile(workflowName); + if (!workflowFile.existsSync()) { + if (isBatchRelease) { + errors.add( + 'Missing global workflow file: .github/workflows/$workflowName\n' + 'See https://github.com/flutter/flutter/blob/master/docs/ecosystem/release/README.md#batch-release', + ); + } + return errors; + } + + final String content = workflowFile.readAsStringSync(); + YamlMap? yaml; + try { + yaml = loadYaml(content) as YamlMap?; + } catch (e) { + errors.add('Invalid YAML in $workflowName: $e'); + } + + var hasTrigger = false; + if (yaml != null) { + final on = yaml['on'] as YamlMap?; + if (on is YamlMap) { + final push = on['push'] as YamlMap?; + if (push is YamlMap) { + final branches = push['branches'] as YamlList?; + if (branches is YamlList) { + if (branches.contains('release-$packageName')) { + hasTrigger = true; + } + } + } + } + } + + if (isBatchRelease && !hasTrigger) { + errors.add( + 'Missing trigger for release-$packageName in .github/workflows/$workflowName\n' + 'See https://github.com/flutter/flutter/blob/master/docs/ecosystem/release/README.md#batch-release', + ); + } else if (!isBatchRelease && hasTrigger) { + errors.add( + 'Unexpected trigger for release-$packageName in .github/workflows/$workflowName\n', + ); + } + return errors; + } } diff --git a/script/tool/test/repo_package_info_check_command_test.dart b/script/tool/test/repo_package_info_check_command_test.dart index e82e9469f24..c13fa3c3fed 100644 --- a/script/tool/test/repo_package_info_check_command_test.dart +++ b/script/tool/test/repo_package_info_check_command_test.dart @@ -9,16 +9,18 @@ import 'package:flutter_plugin_tools/src/repo_package_info_check_command.dart'; import 'package:git/git.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; import 'util.dart'; void main() { late CommandRunner runner; late Directory root; late Directory packagesDir; + late RecordingProcessRunner gitProcessRunner; setUp(() { final GitDir gitDir; - (:packagesDir, processRunner: _, gitProcessRunner: _, :gitDir) = + (:packagesDir, processRunner: _, :gitProcessRunner, :gitDir) = configureBaseCommandMocks(); root = packagesDir.fileSystem.currentDirectory; @@ -28,6 +30,11 @@ void main() { 'Test for $RepoPackageInfoCheckCommand', ); runner.addCommand(command); + + // Default to failing these checks so that tests of non-batch-release packages + // (the default) don't fail due to "unexpected" branches/labels being found. + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [FakeProcessInfo(MockProcess(exitCode: 1))]; }); String readmeTableHeader() { @@ -121,9 +128,19 @@ ${readmeTableEntry('a_package')} ${readmeTableHeader()} ${readmeTableEntry(pluginName)} '''); + writeAutoLabelerYaml([packages.first]); writeAutoLabelerYaml([packages.first]); writeCodeOwners(packages); + // 4 packages * 2 checks (git, gh) = 8 calls. + // Default mocks in setUp cover 1 call each. We need 3 more each. + gitProcessRunner.mockProcessesForExecutable['git-ls-remote']! + .addAll([ + FakeProcessInfo(MockProcess(exitCode: 1)), + FakeProcessInfo(MockProcess(exitCode: 1)), + FakeProcessInfo(MockProcess(exitCode: 1)), + ]); + final List output = await runCapturingPrint(runner, [ 'repo-package-info-check', ]); @@ -669,4 +686,333 @@ release: ); }); }); + + group('release strategy check', () { + RepositoryPackage setupReleaseStrategyTest() { + final RepositoryPackage package = createFakePackage( + 'a_package', + packagesDir, + ); + + root.childFile('README.md').writeAsStringSync(''' +${readmeTableHeader()} +${readmeTableEntry('a_package')} +'''); + writeAutoLabelerYaml([package]); + writeCodeOwners([package]); + return package; + } + + void writeBatchConfig(RepositoryPackage package) { + package.ciConfigFile.writeAsStringSync(''' +release: + batch: true +'''); + } + + void writeWorkflowFiles({ + bool validBatchFile = true, + bool validReleaseFromBranches = true, + bool validSyncRelease = true, + }) { + final Directory workflowDir = root + .childDirectory('.github') + .childDirectory('workflows'); + workflowDir.createSync(recursive: true); + + if (validBatchFile) { + workflowDir.childFile('a_package_batch.yml').writeAsStringSync(''' +name: Batch Release +on: + schedule: + - cron: "0 8 * * 1" +jobs: + dispatch_release_pr: + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f + with: + event-type: batch-release-pr + client-payload: '{"package": "a_package"}' +'''); + } + + if (validReleaseFromBranches) { + workflowDir.childFile('release_from_branches.yml').writeAsStringSync(''' +on: + push: + branches: + - 'release-a_package' +'''); + } + + if (validSyncRelease) { + workflowDir.childFile('sync_release_pr.yml').writeAsStringSync(''' +on: + push: + branches: + - 'release-a_package' +'''); + } + } + + test( + 'ignores non-batch release packages if they have no artifacts', + () async { + setupReleaseStrategyTest(); + // No config, so batch is false by default. + + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [ + FakeProcessInfo( + MockProcess(exitCode: 1), + ), // git ls-remote fails (branch doesn't exist) + ]; + + final List output = await runCapturingPrint(runner, [ + 'repo-package-info-check', + ]); + + expect( + output, + containsAllInOrder([contains('No issues found!')]), + ); + }, + ); + + test('fails if non-batch package has batch artifacts', () async { + setupReleaseStrategyTest(); + // batch defaults to false + writeWorkflowFiles(); + + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [ + FakeProcessInfo( + MockProcess(), + ), // git ls-remote succeeds (branch exists) + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + contains( + contains( + 'Unexpected batch workflow file: .github/workflows/a_package_batch.yml', + ), + ), + ); + expect( + output, + contains( + contains( + 'Unexpected trigger for release-a_package in .github/workflows/release_from_branches.yml', + ), + ), + ); + expect( + output, + contains( + contains( + 'Unexpected trigger for release-a_package in .github/workflows/sync_release_pr.yml', + ), + ), + ); + }); + + test('fails if batch workflow file is missing', () async { + final RepositoryPackage package = setupReleaseStrategyTest(); + writeBatchConfig(package); + // Don't write workflow files. + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + contains( + contains( + 'Missing batch workflow file: .github/workflows/a_package_batch.yml', + ), + ), + ); + }); + + test('fails if batch workflow content is invalid', () async { + final RepositoryPackage package = setupReleaseStrategyTest(); + writeBatchConfig(package); + final Directory workflowDir = root + .childDirectory('.github') + .childDirectory('workflows'); + workflowDir.createSync(recursive: true); + workflowDir.childFile('a_package_batch.yml').writeAsStringSync(''' +name: Batch Release +jobs: + dispatch_release_pr: + steps: + - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f + with: + event-type: something-else + client-payload: '{"package": "a_package"}' +'''); + // Write other files to be valid so we focus on this error + workflowDir + .childFile('release_from_branches.yml') + .writeAsStringSync("- 'release-a_package'"); + workflowDir + .childFile('sync_release_pr.yml') + .writeAsStringSync("- 'release-a_package'"); + + // Mock successful git and gh calls + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [FakeProcessInfo(MockProcess())]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + contains( + contains( + 'Invalid batch workflow content in a_package_batch.yml. ' + 'Must contain a step using peter-evans/repository-dispatch with:\n' + ' event-type: batch-release-pr\n' + ' client-payload: \'{"package": "a_package"}\'', + ), + ), + ); + }); + + test('fails if global workflows are missing triggers', () async { + final RepositoryPackage package = setupReleaseStrategyTest(); + writeBatchConfig(package); + writeWorkflowFiles( + validReleaseFromBranches: false, + validSyncRelease: false, + ); + // Create files but without correct content + final Directory workflowDir = root + .childDirectory('.github') + .childDirectory('workflows'); + workflowDir + .childFile('release_from_branches.yml') + .writeAsStringSync('something'); + workflowDir + .childFile('sync_release_pr.yml') + .writeAsStringSync('something'); + + gitProcessRunner.mockProcessesForExecutable['git'] = [ + FakeProcessInfo(MockProcess()), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + contains( + contains( + 'Missing trigger for release-a_package in .github/workflows/release_from_branches.yml', + ), + ), + ); + expect( + output, + contains( + contains( + 'Missing trigger for release-a_package in .github/workflows/sync_release_pr.yml', + ), + ), + ); + }); + + test('fails if remote branch check fails', () async { + final RepositoryPackage package = setupReleaseStrategyTest(); + writeBatchConfig(package); + writeWorkflowFiles(); + + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [ + FakeProcessInfo(MockProcess(exitCode: 1)), // git ls-remote fails + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + contains( + contains( + 'Branch release-a_package does not exist on remote flutter/packages', + ), + ), + ); + }); + + test('passes if all checks pass', () async { + final RepositoryPackage package = setupReleaseStrategyTest(); + writeBatchConfig(package); + writeWorkflowFiles(); + + gitProcessRunner.mockProcessesForExecutable['git-ls-remote'] = + [FakeProcessInfo(MockProcess())]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + ['repo-package-info-check'], + errorHandler: (Error e) { + commandError = e; + }, + ); + + if (commandError != null) { + print('ERROR: Command failed in "passes if all checks pass"'); + print('Output:\n${output.join('\n')}'); + } + + expect(commandError, isNull); + expect( + output, + containsAllInOrder([contains('No issues found!')]), + ); + }); + }); }