From 1aad8c8c22c0f972c5e20b37f21c7a7f36f5fa74 Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Wed, 7 Jul 2021 17:50:31 -0700 Subject: [PATCH] [flutter_conductor] support pushing local changes to remote (#85797) --- dev/conductor/lib/next.dart | 194 ++- .../lib/proto/conductor_state.pb.dart | 18 + .../lib/proto/conductor_state.pbjson.dart | 3 +- dev/conductor/lib/proto/conductor_state.proto | 6 + dev/conductor/lib/repository.dart | 118 +- dev/conductor/lib/roll_dev.dart | 8 +- dev/conductor/lib/start.dart | 9 +- dev/conductor/lib/state.dart | 14 +- dev/conductor/test/next_test.dart | 1220 +++++++++++------ 9 files changed, 1038 insertions(+), 552 deletions(-) diff --git a/dev/conductor/lib/next.dart b/dev/conductor/lib/next.dart index 2b59f238efc..368b6161111 100644 --- a/dev/conductor/lib/next.dart +++ b/dev/conductor/lib/next.dart @@ -104,44 +104,58 @@ void runNext({ } } - if (state.engine.cherrypicks.isEmpty) { - stdio.printStatus('This release has no engine cherrypicks.'); + if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) { + stdio.printStatus( + 'This release has no engine cherrypicks. No Engine PR is necessary.\n', + ); break; - } else if (unappliedCherrypicks.isEmpty) { + } + + if (unappliedCherrypicks.isEmpty) { stdio.printStatus('All engine cherrypicks have been auto-applied by ' 'the conductor.\n'); - if (autoAccept == false) { - final bool response = prompt( - 'Are you ready to push your changes to the repository ' - '${state.engine.mirror.url}?', - stdio, - ); - if (!response) { - stdio.printError('Aborting command.'); - writeStateToFile(stateFile, state, stdio.logs); - return; - } - } } else { stdio.printStatus( 'There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.'); stdio.printStatus('These must be applied manually in the directory ' '${state.engine.checkoutPath} before proceeding.\n'); - if (autoAccept == false) { - final bool response = prompt( - 'Are you ready to push your engine branch to the repository ' - '${state.engine.mirror.url}?', - stdio, - ); - if (!response) { - stdio.printError('Aborting command.'); - writeStateToFile(stateFile, state, stdio.logs); - return; - } + } + if (autoAccept == false) { + final bool response = prompt( + 'Are you ready to push your engine branch to the repository ' + '${state.engine.mirror.url}?', + stdio, + ); + if (!response) { + stdio.printError('Aborting command.'); + writeStateToFile(stateFile, state, stdio.logs); + return; } } + final Remote upstream = Remote( + name: RemoteName.upstream, + url: state.engine.upstream.url, + ); + final EngineRepository engine = EngineRepository( + checkouts, + initialRef: state.engine.workingBranch, + upstreamRemote: upstream, + previousCheckoutLocation: state.engine.checkoutPath, + ); + final String headRevision = engine.reverseParse('HEAD'); + + engine.pushRef( + fromRef: headRevision, + toRef: state.engine.workingBranch, + remote: state.engine.mirror.name, + ); + break; case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES: + stdio.printStatus([ + 'You must validate pre-submit CI for your engine PR, merge it, and codesign', + 'binaries before proceeding.\n', + ].join('\n')); if (autoAccept == false) { // TODO(fujino): actually test if binaries have been codesigned on macOS final bool response = prompt( @@ -163,42 +177,83 @@ void runNext({ } } - if (state.framework.cherrypicks.isEmpty) { - stdio.printStatus('This release has no framework cherrypicks.'); - break; - } else if (unappliedCherrypicks.isEmpty) { - stdio.printStatus('All framework cherrypicks have been auto-applied by ' - 'the conductor.\n'); - if (autoAccept == false) { - final bool response = prompt( - 'Are you ready to push your changes to the repository ' - '${state.framework.mirror.url}?', - stdio, - ); - if (!response) { - stdio.printError('Aborting command.'); - writeStateToFile(stateFile, state, stdio.logs); - return; - } - } - } else { + if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) { stdio.printStatus( - 'There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.'); - stdio.printStatus('These must be applied manually in the directory ' - '${state.framework.checkoutPath} before proceeding.\n'); - if (autoAccept == false) { - final bool response = prompt( - 'Are you ready to push your framework branch to the repository ' - '${state.framework.mirror.url}?', - stdio, + 'This release has no engine cherrypicks, and thus the engine.version file\n' + 'in the framework does not need to be updated.', + ); + + if (state.framework.cherrypicks.isEmpty) { + stdio.printStatus( + 'This release also has no framework cherrypicks. Therefore, a framework\n' + 'pull request is not required.', ); - if (!response) { - stdio.printError('Aborting command.'); - writeStateToFile(stateFile, state, stdio.logs); - return; - } + break; } } + final EngineRepository engine = EngineRepository( + checkouts, + initialRef: state.engine.candidateBranch, + upstreamRemote: Remote( + name: RemoteName.upstream, + url: state.engine.upstream.url, + ), + previousCheckoutLocation: state.engine.checkoutPath, + ); + + final String engineRevision = engine.reverseParse('HEAD'); + + final Remote upstream = Remote( + name: RemoteName.upstream, + url: state.framework.upstream.url, + ); + final FrameworkRepository framework = FrameworkRepository( + checkouts, + initialRef: state.framework.workingBranch, + upstreamRemote: upstream, + previousCheckoutLocation: state.framework.checkoutPath, + ); + final String headRevision = framework.reverseParse('HEAD'); + + stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...'); + framework.updateEngineRevision(engineRevision); + framework.commit('Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}', addFirst: true); + + if (state.framework.cherrypicks.isEmpty) { + stdio.printStatus( + 'This release has no framework cherrypicks. However, a framework PR is still\n' + 'required to roll engine cherrypicks.', + ); + } else if (unappliedCherrypicks.isEmpty) { + stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.'); + } else { + stdio.printStatus( + 'There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.', + ); + stdio.printStatus( + 'These must be applied manually in the directory ' + '${state.framework.checkoutPath} before proceeding.\n', + ); + } + + if (autoAccept == false) { + final bool response = prompt( + 'Are you ready to push your framework branch to the repository ' + '${state.framework.mirror.url}?', + stdio, + ); + if (!response) { + stdio.printError('Aborting command.'); + writeStateToFile(stateFile, state, stdio.logs); + return; + } + } + + framework.pushRef( + fromRef: headRevision, + toRef: state.framework.workingBranch, + remote: state.framework.mirror.name, + ); break; case pb.ReleasePhase.PUBLISH_VERSION: stdio.printStatus('Please ensure that you have merged your framework PR and that'); @@ -216,7 +271,8 @@ void runNext({ final String headRevision = framework.reverseParse('HEAD'); if (autoAccept == false) { final bool response = prompt( - 'Has CI passed for the framework PR?', + 'Are you ready to tag commit $headRevision as ${state.releaseVersion}\n' + 'and push to remote ${state.framework.upstream.url}?', stdio, ); if (!response) { @@ -240,9 +296,17 @@ void runNext({ ); final String headRevision = framework.reverseParse('HEAD'); if (autoAccept == false) { + // dryRun: true means print out git command + framework.pushRef( + fromRef: headRevision, + toRef: state.releaseChannel, + remote: state.framework.upstream.url, + force: force, + dryRun: true, + ); + final bool response = prompt( - 'Are you ready to publish release ${state.releaseVersion} to ' - 'channel ${state.releaseChannel} at ${state.framework.upstream.url}?', + 'Are you ready to publish this release?', stdio, ); if (!response) { @@ -251,10 +315,10 @@ void runNext({ return; } } - framework.updateChannel( - headRevision, - state.framework.upstream.url, - state.releaseChannel, + framework.pushRef( + fromRef: headRevision, + toRef: state.releaseChannel, + remote: state.framework.upstream.url, force: force, ); break; diff --git a/dev/conductor/lib/proto/conductor_state.pb.dart b/dev/conductor/lib/proto/conductor_state.pb.dart index 6b4fe931b89..8df5f2659f3 100644 --- a/dev/conductor/lib/proto/conductor_state.pb.dart +++ b/dev/conductor/lib/proto/conductor_state.pb.dart @@ -204,6 +204,8 @@ class Repository extends $pb.GeneratedMessage { subBuilder: Cherrypick.create) ..aOS(8, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'dartRevision', protoName: 'dartRevision') + ..aOS(9, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'workingBranch', + protoName: 'workingBranch') ..hasRequiredFields = false; Repository._() : super(); @@ -216,6 +218,7 @@ class Repository extends $pb.GeneratedMessage { Remote mirror, $core.Iterable cherrypicks, $core.String dartRevision, + $core.String workingBranch, }) { final _result = create(); if (candidateBranch != null) { @@ -242,6 +245,9 @@ class Repository extends $pb.GeneratedMessage { if (dartRevision != null) { _result.dartRevision = dartRevision; } + if (workingBranch != null) { + _result.workingBranch = workingBranch; + } return _result; } factory Repository.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => @@ -356,6 +362,18 @@ class Repository extends $pb.GeneratedMessage { $core.bool hasDartRevision() => $_has(7); @$pb.TagNumber(8) void clearDartRevision() => clearField(8); + + @$pb.TagNumber(9) + $core.String get workingBranch => $_getSZ(8); + @$pb.TagNumber(9) + set workingBranch($core.String v) { + $_setString(8, v); + } + + @$pb.TagNumber(9) + $core.bool hasWorkingBranch() => $_has(8); + @$pb.TagNumber(9) + void clearWorkingBranch() => clearField(9); } class ConductorState extends $pb.GeneratedMessage { diff --git a/dev/conductor/lib/proto/conductor_state.pbjson.dart b/dev/conductor/lib/proto/conductor_state.pbjson.dart index 6898536e039..55bdb97c42c 100644 --- a/dev/conductor/lib/proto/conductor_state.pbjson.dart +++ b/dev/conductor/lib/proto/conductor_state.pbjson.dart @@ -81,12 +81,13 @@ const Repository$json = const { const {'1': 'mirror', '3': 6, '4': 1, '5': 11, '6': '.conductor_state.Remote', '10': 'mirror'}, const {'1': 'cherrypicks', '3': 7, '4': 3, '5': 11, '6': '.conductor_state.Cherrypick', '10': 'cherrypicks'}, const {'1': 'dartRevision', '3': 8, '4': 1, '5': 9, '10': 'dartRevision'}, + const {'1': 'workingBranch', '3': 9, '4': 1, '5': 9, '10': 'workingBranch'}, ], }; /// Descriptor for `Repository`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List repositoryDescriptor = $convert.base64Decode( - 'CgpSZXBvc2l0b3J5EigKD2NhbmRpZGF0ZUJyYW5jaBgBIAEoCVIPY2FuZGlkYXRlQnJhbmNoEigKD3N0YXJ0aW5nR2l0SGVhZBgCIAEoCVIPc3RhcnRpbmdHaXRIZWFkEiYKDmN1cnJlbnRHaXRIZWFkGAMgASgJUg5jdXJyZW50R2l0SGVhZBIiCgxjaGVja291dFBhdGgYBCABKAlSDGNoZWNrb3V0UGF0aBIzCgh1cHN0cmVhbRgFIAEoCzIXLmNvbmR1Y3Rvcl9zdGF0ZS5SZW1vdGVSCHVwc3RyZWFtEi8KBm1pcnJvchgGIAEoCzIXLmNvbmR1Y3Rvcl9zdGF0ZS5SZW1vdGVSBm1pcnJvchI9CgtjaGVycnlwaWNrcxgHIAMoCzIbLmNvbmR1Y3Rvcl9zdGF0ZS5DaGVycnlwaWNrUgtjaGVycnlwaWNrcxIiCgxkYXJ0UmV2aXNpb24YCCABKAlSDGRhcnRSZXZpc2lvbg=='); + 'CgpSZXBvc2l0b3J5EigKD2NhbmRpZGF0ZUJyYW5jaBgBIAEoCVIPY2FuZGlkYXRlQnJhbmNoEigKD3N0YXJ0aW5nR2l0SGVhZBgCIAEoCVIPc3RhcnRpbmdHaXRIZWFkEiYKDmN1cnJlbnRHaXRIZWFkGAMgASgJUg5jdXJyZW50R2l0SGVhZBIiCgxjaGVja291dFBhdGgYBCABKAlSDGNoZWNrb3V0UGF0aBIzCgh1cHN0cmVhbRgFIAEoCzIXLmNvbmR1Y3Rvcl9zdGF0ZS5SZW1vdGVSCHVwc3RyZWFtEi8KBm1pcnJvchgGIAEoCzIXLmNvbmR1Y3Rvcl9zdGF0ZS5SZW1vdGVSBm1pcnJvchI9CgtjaGVycnlwaWNrcxgHIAMoCzIbLmNvbmR1Y3Rvcl9zdGF0ZS5DaGVycnlwaWNrUgtjaGVycnlwaWNrcxIiCgxkYXJ0UmV2aXNpb24YCCABKAlSDGRhcnRSZXZpc2lvbhIkCg13b3JraW5nQnJhbmNoGAkgASgJUg13b3JraW5nQnJhbmNo'); @$core.Deprecated('Use conductorStateDescriptor instead') const ConductorState$json = const { '1': 'ConductorState', diff --git a/dev/conductor/lib/proto/conductor_state.proto b/dev/conductor/lib/proto/conductor_state.proto index 721b5eb990a..126f9c7060d 100644 --- a/dev/conductor/lib/proto/conductor_state.proto +++ b/dev/conductor/lib/proto/conductor_state.proto @@ -84,6 +84,12 @@ message Repository { // Only for engine repositories. string dartRevision = 8; + + // Name of local and remote branch for applying cherrypicks. + // + // When the pull request is merged, all commits here will be squashed to a + // single commit on the [candidateBranch]. + string workingBranch = 9; } message ConductorState { diff --git a/dev/conductor/lib/repository.dart b/dev/conductor/lib/repository.dart index 64e0a3f7efd..3a12a4d739b 100644 --- a/dev/conductor/lib/repository.dart +++ b/dev/conductor/lib/repository.dart @@ -25,7 +25,9 @@ class Remote { const Remote({ required RemoteName name, required this.url, - }) : _name = name, assert(url != null), assert (url != ''); + }) : _name = name, + assert(url != null), + assert(url != ''); final RemoteName _name; @@ -63,9 +65,15 @@ abstract class Repository { if (previousCheckoutLocation != null) { _checkoutDirectory = fileSystem.directory(previousCheckoutLocation); if (!_checkoutDirectory!.existsSync()) { - throw ConductorException('Provided previousCheckoutLocation $previousCheckoutLocation does not exist on disk!'); + throw ConductorException( + 'Provided previousCheckoutLocation $previousCheckoutLocation does not exist on disk!'); } if (initialRef != null) { + git.run( + ['fetch', upstreamRemote.name], + 'Fetch ${upstreamRemote.name} to ensure we have latest refs', + workingDirectory: _checkoutDirectory!.path, + ); git.run( ['checkout', '${upstreamRemote.name}/$initialRef'], 'Checking out initialRef $initialRef', @@ -255,11 +263,9 @@ abstract class Repository { /// List commits in reverse chronological order. List revList(List args) { return git - .getOutput( - ['rev-list', ...args], - 'rev-list with args ${args.join(' ')}', - workingDirectory: checkoutDirectory.path - ) + .getOutput(['rev-list', ...args], + 'rev-list with args ${args.join(' ')}', + workingDirectory: checkoutDirectory.path) .trim() .split('\n'); } @@ -356,22 +362,33 @@ abstract class Repository { } /// Push [commit] to the release channel [branch]. - void updateChannel( - String commit, - String remote, - String branch, { + void pushRef({ + required String fromRef, + required String remote, + required String toRef, bool force = false, + bool dryRun = false, }) { - git.run( - [ - 'push', - if (force) '--force', - remote, - '$commit:$branch', - ], - 'update the release branch with the commit', - workingDirectory: checkoutDirectory.path, - ); + final List args = [ + 'push', + if (force) '--force', + remote, + '$fromRef:$toRef', + ]; + final String command = [ + 'git', + ...args, + ].join(' '); + if (dryRun) { + stdio.printStatus('About to execute command: `$command`'); + } else { + git.run( + args, + 'update the release branch with the commit', + workingDirectory: checkoutDirectory.path, + ); + stdio.printStatus('Executed command: `$command`'); + } } String commit( @@ -566,6 +583,21 @@ class FrameworkRepository extends Repository { ) as Map; return Version.fromString(versionJson['frameworkVersion'] as String); } + + void updateEngineRevision( + String newEngine, { + @visibleForTesting File? engineVersionFile, + }) { + assert(newEngine.isNotEmpty); + engineVersionFile ??= checkoutDirectory + .childDirectory('bin') + .childDirectory('internal') + .childFile('engine.version'); + assert(engineVersionFile.existsSync()); + final String oldEngine = engineVersionFile.readAsStringSync(); + stdio.printStatus('Updating engine revision from $oldEngine to $newEngine'); + engineVersionFile.writeAsStringSync(newEngine.trim(), flush: true); + } } /// A wrapper around the host repository that is executing the conductor. @@ -578,14 +610,14 @@ class HostFrameworkRepository extends FrameworkRepository { String name = 'host-framework', required String upstreamPath, }) : super( - checkouts, - name: name, - upstreamRemote: Remote( - name: RemoteName.upstream, - url: 'file://$upstreamPath/', - ), - localUpstream: false, - ) { + checkouts, + name: name, + upstreamRemote: Remote( + name: RemoteName.upstream, + url: 'file://$upstreamPath/', + ), + localUpstream: false, + ) { _checkoutDirectory = checkouts.fileSystem.directory(upstreamPath); } @@ -594,17 +626,20 @@ class HostFrameworkRepository extends FrameworkRepository { @override void newBranch(String branchName) { - throw ConductorException('newBranch not implemented for the host repository'); + throw ConductorException( + 'newBranch not implemented for the host repository'); } @override void checkout(String ref) { - throw ConductorException('checkout not implemented for the host repository'); + throw ConductorException( + 'checkout not implemented for the host repository'); } @override String cherryPick(String commit) { - throw ConductorException('cherryPick not implemented for the host repository'); + throw ConductorException( + 'cherryPick not implemented for the host repository'); } @override @@ -617,14 +652,15 @@ class HostFrameworkRepository extends FrameworkRepository { throw ConductorException('tag not implemented for the host repository'); } - @override void updateChannel( String commit, String remote, String branch, { bool force = false, + bool dryRun = false, }) { - throw ConductorException('updateChannel not implemented for the host repository'); + throw ConductorException( + 'updateChannel not implemented for the host repository'); } @override @@ -673,20 +709,20 @@ class EngineRepository extends Repository { depsFile ??= checkoutDirectory.childFile('DEPS'); final String fileContent = depsFile.readAsStringSync(); final RegExp dartPattern = RegExp("[ ]+'dart_revision': '([a-z0-9]{40})',"); - final Iterable allMatches = dartPattern.allMatches(fileContent); + final Iterable allMatches = + dartPattern.allMatches(fileContent); if (allMatches.length != 1) { throw ConductorException( - 'Unexpected content in the DEPS file at ${depsFile.path}\n' - 'Expected to find pattern ${dartPattern.pattern} 1 times, but got ' - '${allMatches.length}.' - ); + 'Unexpected content in the DEPS file at ${depsFile.path}\n' + 'Expected to find pattern ${dartPattern.pattern} 1 times, but got ' + '${allMatches.length}.'); } final String updatedFileContent = fileContent.replaceFirst( dartPattern, " 'dart_revision': '$newRevision',", ); - depsFile.writeAsStringSync(updatedFileContent); + depsFile.writeAsStringSync(updatedFileContent, flush: true); } @override @@ -716,7 +752,7 @@ class Checkouts { required this.stdio, required Directory parentDirectory, String directoryName = 'flutter_conductor_checkouts', - }) : directory = parentDirectory.childDirectory(directoryName) { + }) : directory = parentDirectory.childDirectory(directoryName) { if (!directory.existsSync()) { directory.createSync(recursive: true); } diff --git a/dev/conductor/lib/roll_dev.dart b/dev/conductor/lib/roll_dev.dart index 116eec3eb91..4a5c4c3a2d2 100644 --- a/dev/conductor/lib/roll_dev.dart +++ b/dev/conductor/lib/roll_dev.dart @@ -191,10 +191,10 @@ bool rollDev({ repository.tag(commit, version.toString(), remoteName); } - repository.updateChannel( - commit, - remoteName, - 'dev', + repository.pushRef( + fromRef: commit, + remote: remoteName, + toRef: 'dev', force: force, ); diff --git a/dev/conductor/lib/start.dart b/dev/conductor/lib/start.dart index c9957afd088..31b38257046 100644 --- a/dev/conductor/lib/start.dart +++ b/dev/conductor/lib/start.dart @@ -100,7 +100,7 @@ class StartCommand extends Command { 'y': 'Indicates the first dev release after a beta release.', 'z': 'Indicates a hotfix to a stable release.', 'm': 'Indicates a standard dev release.', - 'n': 'Indicates a hotfix to a dev release.', + 'n': 'Indicates a hotfix to a dev or beta release.', }, ); final Git git = Git(processManager); @@ -230,7 +230,8 @@ class StartCommand extends Command { // Create a new branch so that we don't accidentally push to upstream // candidateBranch. - engine.newBranch('cherrypicks-$candidateBranch'); + final String workingBranchName = 'cherrypicks-$candidateBranch'; + engine.newBranch(workingBranchName); if (dartRevision != null && dartRevision.isNotEmpty) { engine.updateDartRevision(dartRevision); @@ -262,6 +263,7 @@ class StartCommand extends Command { final String engineHead = engine.reverseParse('HEAD'); state.engine = pb.Repository( candidateBranch: candidateBranch, + workingBranch: workingBranchName, startingGitHead: engineHead, currentGitHead: engineHead, checkoutPath: engine.checkoutDirectory.path, @@ -282,7 +284,7 @@ class StartCommand extends Command { url: frameworkMirror, ), ); - framework.newBranch('cherrypicks-$candidateBranch'); + framework.newBranch(workingBranchName); final List frameworkCherrypicks = _sortCherrypicks( repository: framework, cherrypicks: frameworkCherrypickRevisions, @@ -320,6 +322,7 @@ class StartCommand extends Command { final String frameworkHead = framework.reverseParse('HEAD'); state.framework = pb.Repository( candidateBranch: candidateBranch, + workingBranch: workingBranchName, startingGitHead: frameworkHead, currentGitHead: frameworkHead, checkoutPath: framework.checkoutDirectory.path, diff --git a/dev/conductor/lib/state.dart b/dev/conductor/lib/state.dart index 786b5f74e29..c0482208e35 100644 --- a/dev/conductor/lib/state.dart +++ b/dev/conductor/lib/state.dart @@ -21,7 +21,7 @@ String luciConsoleLink(String channel, String groupName) { 'channel $channel not recognized', ); assert( - ['framework', 'engine', 'devicelab'].contains(groupName), + ['framework', 'engine', 'devicelab', 'packaging'].contains(groupName), 'group named $groupName not recognized', ); final String consoleName = channel == 'master' ? groupName : '${channel}_$groupName'; @@ -133,8 +133,9 @@ String phaseInstructions(pb.ConductorState state) { ].join('\n'); case ReleasePhase.CODESIGN_ENGINE_BINARIES: return [ - 'You must verify Engine CI builds are successful and then codesign the', - 'binaries at revision ${state.engine.currentGitHead}.', + 'You must verify pre-submit CI builds on your engine pull request are successful,', + 'merge your pull request, validate post-submit CI, and then codesign the binaries ', + 'on the merge commit.', ].join('\n'); case ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: final List outstandingCherrypicks = state.framework.cherrypicks.where( @@ -150,13 +151,14 @@ String phaseInstructions(pb.ConductorState state) { ].join('\n'); case ReleasePhase.PUBLISH_VERSION: return [ - 'You must verify Framework CI builds are successful.', - 'See $kReleaseDocumentationUrl for more information.', + 'You must verify pre-submit CI builds on your framework pull request are successful,', + 'merge your pull request, and validate post-submit CI. See $kReleaseDocumentationUrl,', + 'for more information.', ].join('\n'); case ReleasePhase.PUBLISH_CHANNEL: return 'Issue `conductor next` to publish your release to the release branch.'; case ReleasePhase.VERIFY_RELEASE: - return 'Release archive packages must be verified on cloud storage.'; + return 'Release archive packages must be verified on cloud storage: ${luciConsoleLink(state.releaseChannel, 'packaging')}'; case ReleasePhase.RELEASE_COMPLETED: return 'This release has been completed.'; } diff --git a/dev/conductor/test/next_test.dart b/dev/conductor/test/next_test.dart index 6b66280c1d3..caa7c773fa7 100644 --- a/dev/conductor/test/next_test.dart +++ b/dev/conductor/test/next_test.dart @@ -10,6 +10,7 @@ import 'package:conductor/proto/conductor_state.pb.dart' as pb; import 'package:conductor/proto/conductor_state.pbenum.dart' show ReleasePhase; import 'package:conductor/repository.dart'; import 'package:conductor/state.dart'; +import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:meta/meta.dart'; import 'package:platform/platform.dart'; @@ -20,11 +21,16 @@ import '../../../packages/flutter_tools/test/src/fake_process_manager.dart'; void main() { group('next command', () { const String flutterRoot = '/flutter'; - const String checkoutsParentDirectory = '$flutterRoot/dev/tools/'; + const String checkoutsParentDirectory = '$flutterRoot/dev/conductor'; const String candidateBranch = 'flutter-1.2-candidate.3'; + const String workingBranch = 'cherrypicks-$candidateBranch'; final String localPathSeparator = const LocalPlatform().pathSeparator; final String localOperatingSystem = const LocalPlatform().pathSeparator; const String revision1 = 'abc123'; + const String revision2 = 'def456'; + const String revision3 = '789aaa'; + const String releaseVersion = '1.2.0-3.0.pre'; + const String releaseChannel = 'beta'; MemoryFileSystem fileSystem; TestStdio stdio; const String stateFile = '/state-file.json'; @@ -72,348 +78,641 @@ void main() { ); }); - test('does not prompt user and updates state.currentPhase from APPLY_ENGINE_CHERRYPICKS to CODESIGN_ENGINE_BINARIES if there are no engine cherrypicks', () async { - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); + group('APPLY_ENGINE_CHERRYPICKS to CODESIGN_ENGINE_BINARIES', () { + test('does not prompt user and updates currentPhase if there are no engine cherrypicks', () async { + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); - expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); - expect(stdio.error, isEmpty); + expect(processManager, hasNoRemainingExpectations); + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error, isEmpty); + }); + + test('confirms to stdout when all engine cherrypicks were auto-applied', () async { + const String remoteUrl = 'https://githost.com/org/repo.git'; + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.empty(); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + engine: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.COMPLETED, + ), + ], + workingBranch: workingBranch, + upstream: pb.Remote(name: 'upstream', url: remoteUrl), + mirror: pb.Remote(name: 'mirror', url: remoteUrl), + ), + currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + expect(processManager, hasNoRemainingExpectations); + expect( + stdio.stdout, + contains('All engine cherrypicks have been auto-applied by the conductor'), + ); + }); + + test('updates lastPhase if user responds yes', () async { + const String remoteUrl = 'https://githost.com/org/repo.git'; + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['git', 'fetch', 'upstream'], + ), + const FakeCommand(command: ['git', 'checkout', 'upstream/$workingBranch']), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + const FakeCommand(command: ['git', 'push', 'mirror', '$revision1:$workingBranch']), + ]); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + engine: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + workingBranch: workingBranch, + upstream: pb.Remote(name: 'upstream', url: remoteUrl), + mirror: pb.Remote(name: 'mirror', url: remoteUrl), + ), + currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(stdio.stdout, contains( + 'Are you ready to push your engine branch to the repository $remoteUrl? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error, isEmpty); + }); }); + group('CODESIGN_ENGINE_BINARIES to APPLY_FRAMEWORK_CHERRYPICKS', () { + pb.ConductorState state; + FakeProcessManager processManager; + FakePlatform platform; - test('updates state.lastPhase from APPLY_ENGINE_CHERRYPICKS to CODESIGN_ENGINE_BINARIES if user responds yes', () async { - const String remoteUrl = 'https://githost.com/org/repo.git'; - stdio.stdin.add('y'); - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - engine: pb.Repository( - cherrypicks: [ - pb.Cherrypick( - trunkRevision: 'abc123', - state: pb.CherrypickState.PENDING, - ), - ], - mirror: pb.Remote(url: remoteUrl), - ), - currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); + setUp(() { + state = pb.ConductorState( + engine: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + ), + currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, + ); - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); + processManager = FakeProcessManager.empty(); - expect(stdio.stdout, contains( - 'Are you ready to push your engine branch to the repository $remoteUrl? (y/n) ')); - expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); - expect(stdio.error, isEmpty); + platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + }); + + test('does not update currentPhase if user responds no', () async { + stdio.stdin.add('n'); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error.contains('Aborting command.'), true); + }); + + test('updates currentPhase if user responds yes', () async { + stdio.stdin.add('y'); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); + }); }); - test('does not update state.currentPhase from CODESIGN_ENGINE_BINARIES if user responds no', () async { - stdio.stdin.add('n'); - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - engine: pb.Repository( - cherrypicks: [ - pb.Cherrypick( - trunkRevision: 'abc123', - state: pb.CherrypickState.PENDING, - ), - ], - ), - currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); + group('APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION', () { + const String mirrorRemoteUrl = 'https://githost.com/org/repo.git'; + const String upstreamRemoteUrl = 'https://githost.com/mirror/repo.git'; + const String engineUpstreamRemoteUrl = 'https://githost.com/mirror/engine.git'; + const String frameworkCheckoutPath = '$checkoutsParentDirectory/framework'; + const String engineCheckoutPath = '$checkoutsParentDirectory/engine'; + const String oldEngineVersion = '000000001'; + FakeProcessManager processManager; + FakePlatform platform; + pb.ConductorState state; - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); + setUp(() { + processManager = FakeProcessManager.empty(); + platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + state = pb.ConductorState( + releaseChannel: releaseChannel, + releaseVersion: releaseVersion, + framework: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: frameworkCheckoutPath, + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + mirror: pb.Remote(name: 'mirror', url: mirrorRemoteUrl), + upstream: pb.Remote(name: 'upstream', url: upstreamRemoteUrl), + workingBranch: workingBranch, + ), + engine: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: engineCheckoutPath, + dartRevision: 'cdef0123', + upstream: pb.Remote(name: 'upstream', url: engineUpstreamRemoteUrl), + ), + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + ); + // create engine repo + fileSystem.directory(engineCheckoutPath).createSync(recursive: true); + // create framework repo + final Directory frameworkDir = fileSystem.directory(frameworkCheckoutPath); + final File engineRevisionFile = frameworkDir + .childDirectory('bin') + .childDirectory('internal') + .childFile('engine.version'); + engineRevisionFile.createSync(recursive: true); + engineRevisionFile.writeAsStringSync(oldEngineVersion, flush: true); + }); - expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); - expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); - expect(stdio.error.contains('Aborting command.'), true); + test('with no dart, engine or framework cherrypicks, no user input, no PR needed', () async { + state = pb.ConductorState( + framework: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: frameworkCheckoutPath, + mirror: pb.Remote(name: 'mirror', url: mirrorRemoteUrl), + upstream: pb.Remote(name: 'upstream', url: upstreamRemoteUrl), + workingBranch: workingBranch, + ), + engine: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: engineCheckoutPath, + upstream: pb.Remote(name: 'upstream', url: engineUpstreamRemoteUrl), + ), + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + ); + + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect(stdio.error, isEmpty); + expect( + stdio.stdout, + contains('pull request is not required'), + ); + }); + + test('with no engine cherrypicks but a dart revision update, updates engine revision', () async { + stdio.stdin.add('n'); + processManager.addCommands(const [ + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$candidateBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$workingBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision2, + ), + FakeCommand(command: ['git', 'add', '--all']), + FakeCommand(command: [ + 'git', + 'commit', + "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + ]), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision3, + ), + ]); + final pb.ConductorState state = pb.ConductorState( + releaseChannel: releaseChannel, + releaseVersion: releaseVersion, + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + framework: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: frameworkCheckoutPath, + mirror: pb.Remote(name: 'mirror', url: mirrorRemoteUrl), + upstream: pb.Remote(name: 'upstream', url: upstreamRemoteUrl), + workingBranch: workingBranch, + ), + engine: pb.Repository( + candidateBranch: candidateBranch, + checkoutPath: engineCheckoutPath, + upstream: pb.Remote(name: 'upstream', url: engineUpstreamRemoteUrl), + dartRevision: 'abc123', + ), + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + expect(processManager, hasNoRemainingExpectations); + expect(stdio.stdout, contains('Updating engine revision from $oldEngineVersion to $revision1')); + expect(stdio.stdout, contains('a framework PR is still\nrequired to roll engine cherrypicks.')); + }); + + test('does not update state.currentPhase if user responds no', () async { + stdio.stdin.add('n'); + processManager.addCommands(const [ + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$candidateBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$workingBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision2, + ), + FakeCommand(command: ['git', 'add', '--all']), + FakeCommand(command: [ + 'git', + 'commit', + "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + ]), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision3, + ), + ]); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Are you ready to push your framework branch to the repository $mirrorRemoteUrl? (y/n) ')); + expect(stdio.error, contains('Aborting command.')); + expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); + }); + + test('updates state.currentPhase if user responds yes', () async { + stdio.stdin.add('y'); + processManager.addCommands(const [ + // Engine repo + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$candidateBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + // Framework repo + FakeCommand(command: ['git', 'fetch', 'upstream']), + FakeCommand(command: ['git', 'checkout', 'upstream/$workingBranch']), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision2, + ), + FakeCommand(command: ['git', 'add', '--all']), + FakeCommand(command: [ + 'git', + 'commit', + "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", + ]), + FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision3, + ), + FakeCommand( + command: ['git', 'push', 'mirror', '$revision2:$workingBranch'], + ), + ]); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect( + stdio.stdout, + contains('Rolling new engine hash $revision1 to framework checkout...'), + ); + expect( + stdio.stdout, + contains('There were 1 cherrypicks that were not auto-applied'), + ); + expect( + stdio.stdout, + contains('Are you ready to push your framework branch to the repository $mirrorRemoteUrl? (y/n)'), + ); + expect( + stdio.stdout, + contains('Executed command: `git push mirror $revision2:$workingBranch`'), + ); + expect(stdio.error, isEmpty); + }); }); - test('updates state.currentPhase from CODESIGN_ENGINE_BINARIES to APPLY_FRAMEWORK_CHERRYPICKS if user responds yes', () async { - stdio.stdin.add('y'); - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); - - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); - - expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); - expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); - }); - - test('does not prompt user and updates state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION if there are no framework cherrypicks', () async { - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); - - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); - - expect(stdio.stdout, isNot(contains('Did you apply all framework cherrypicks? (y/n) '))); - expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); - expect(stdio.error, isEmpty); - }); - - test('does not update state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS if user responds no', () async { - const String remoteUrl = 'https://githost.com/org/repo.git'; - stdio.stdin.add('n'); - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - framework: pb.Repository( - cherrypicks: [ - pb.Cherrypick( - trunkRevision: 'abc123', - state: pb.CherrypickState.PENDING, - ), - ], - mirror: pb.Remote(url: remoteUrl), - ), - currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); - - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); - - expect(stdio.stdout, contains('Are you ready to push your framework branch to the repository $remoteUrl? (y/n) ')); - expect(stdio.error, contains('Aborting command.')); - expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); - }); - - test('updates state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION if user responds yes', () async { - const String remoteUrl = 'https://githost.com/org/repo.git'; - stdio.stdin.add('y'); - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - framework: pb.Repository( - cherrypicks: [ - pb.Cherrypick( - trunkRevision: 'abc123', - state: pb.CherrypickState.PENDING, - ), - ], - mirror: pb.Remote(url: remoteUrl), - ), - currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); - - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); - - expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); - expect(stdio.stdout, contains('Are you ready to push your framework branch to the repository $remoteUrl? (y/n)')); - }); - - - test('does not update state.currentPhase from PUBLISH_VERSION if user responds no', () async { + group('PUBLISH_VERSION to PUBLISH_CHANNEL', () { const String remoteName = 'upstream'; - stdio.stdin.add('n'); - final FakeProcessManager processManager = FakeProcessManager.list( - [ + const String releaseVersion = '1.2.0-3.0.pre'; + pb.ConductorState state; + FakePlatform platform; + + setUp(() { + state = pb.ConductorState( + currentPhase: ReleasePhase.PUBLISH_VERSION, + framework: pb.Repository( + candidateBranch: candidateBranch, + upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), + ), + releaseVersion: releaseVersion, + ); + platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + }); + + test('does not update state.currentPhase if user responds no', () async { + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.list( + [ + const FakeCommand( + command: ['git', 'fetch', 'upstream'], + ), + const FakeCommand( + command: ['git', 'checkout', '$remoteName/$candidateBranch'], + ), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + ], + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(stdio.stdout, contains('Are you ready to tag commit $revision1 as $releaseVersion')); + expect(stdio.error, contains('Aborting command.')); + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect(finalState.logs, stdio.logs); + }); + + test('updates state.currentPhase if user responds yes', () async { + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['git', 'fetch', 'upstream'], + ), const FakeCommand( command: ['git', 'checkout', '$remoteName/$candidateBranch'], ), @@ -421,119 +720,176 @@ void main() { command: ['git', 'rev-parse', 'HEAD'], stdout: revision1, ), - ], - ); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - currentPhase: ReleasePhase.PUBLISH_VERSION, - framework: pb.Repository( - candidateBranch: candidateBranch, - upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), - ), - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); + const FakeCommand( + command: ['git', 'tag', releaseVersion, revision1], + ), + const FakeCommand( + command: ['git', 'push', remoteName, releaseVersion], + ), + ]); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); - expect(stdio.stdout, contains('Has CI passed for the framework PR?')); - expect(stdio.error, contains('Aborting command.')); - expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); - expect(finalState.logs, stdio.logs); - expect(processManager.hasRemainingExpectations, false); + expect(processManager, hasNoRemainingExpectations); + expect(finalState.currentPhase, ReleasePhase.PUBLISH_CHANNEL); + expect(stdio.stdout, contains('Are you ready to tag commit $revision1 as $releaseVersion')); + expect(finalState.logs, stdio.logs); + }); }); - test('updates state.currentPhase from PUBLISH_VERSION to PUBLISH_CHANNEL if user responds yes', () async { + group('PUBLISH_CHANNEL to VERIFY_RELEASE', () { const String remoteName = 'upstream'; - const String releaseVersion = '1.2.0-3.0.pre'; - stdio.stdin.add('y'); - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand( - command: ['git', 'checkout', '$remoteName/$candidateBranch'], - ), - const FakeCommand( - command: ['git', 'rev-parse', 'HEAD'], - stdout: revision1, - ), - const FakeCommand( - command: ['git', 'tag', releaseVersion, revision1], - ), - const FakeCommand( - command: ['git', 'push', remoteName, releaseVersion], - ), - ]); - final FakePlatform platform = FakePlatform( - environment: { - 'HOME': ['path', 'to', 'home'].join(localPathSeparator), - }, - operatingSystem: localOperatingSystem, - pathSeparator: localPathSeparator, - ); - final pb.ConductorState state = pb.ConductorState( - currentPhase: ReleasePhase.PUBLISH_VERSION, - framework: pb.Repository( - candidateBranch: candidateBranch, - upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), - ), - releaseVersion: releaseVersion, - ); - writeStateToFile( - fileSystem.file(stateFile), - state, - [], - ); - final Checkouts checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), - platform: platform, - processManager: processManager, - stdio: stdio, - ); - final CommandRunner runner = createRunner(checkouts: checkouts); - await runner.run([ - 'next', - '--$kStateOption', - stateFile, - ]); + pb.ConductorState state; + FakePlatform platform; - final pb.ConductorState finalState = readStateFromFile( - fileSystem.file(stateFile), - ); + setUp(() { + state = pb.ConductorState( + currentPhase: ReleasePhase.PUBLISH_CHANNEL, + framework: pb.Repository( + candidateBranch: candidateBranch, + upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), + ), + releaseChannel: releaseChannel, + releaseVersion: releaseVersion, + ); + platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + }); - expect(finalState.currentPhase, ReleasePhase.PUBLISH_CHANNEL); - expect(stdio.stdout, contains('Has CI passed for the framework PR?')); - expect(finalState.logs, stdio.logs); - expect(processManager.hasRemainingExpectations, false); + test('does not update currentPhase if user responds no', () async { + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['git', 'fetch', 'upstream'], + ), + const FakeCommand( + command: ['git', 'checkout', '$remoteName/$candidateBranch'], + ), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + ]); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(stdio.error, contains('Aborting command.')); + expect( + stdio.stdout, + contains('About to execute command: `git push ${FrameworkRepository.defaultUpstream} $revision1:$releaseChannel`'), + ); + expect(finalState.currentPhase, ReleasePhase.PUBLISH_CHANNEL); + }); + + test('updates currentPhase if user responds yes', () async { + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['git', 'fetch', 'upstream'], + ), + const FakeCommand( + command: ['git', 'checkout', '$remoteName/$candidateBranch'], + ), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + const FakeCommand( + command: ['git', 'push', FrameworkRepository.defaultUpstream, '$revision1:$releaseChannel'], + ), + ]); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(stdio.error, isEmpty); + expect( + stdio.stdout, + contains('About to execute command: `git push ${FrameworkRepository.defaultUpstream} $revision1:$releaseChannel`'), + ); + expect( + stdio.stdout, + contains('Release archive packages must be verified on cloud storage: https://ci.chromium.org/p/flutter/g/beta_packaging/console'), + ); + expect(finalState.currentPhase, ReleasePhase.VERIFY_RELEASE); + }); }); test('throws exception if state.currentPhase is RELEASE_COMPLETED', () async { - final FakeProcessManager processManager = FakeProcessManager.list( - [], - ); + final FakeProcessManager processManager = FakeProcessManager.empty(); final FakePlatform platform = FakePlatform( environment: { 'HOME': ['path', 'to', 'home'].join(localPathSeparator),