// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:args/command_runner.dart'; import 'package:conductor_core/src/git.dart'; import 'package:conductor_core/src/globals.dart'; import 'package:conductor_core/src/next.dart'; import 'package:conductor_core/src/proto/conductor_state.pb.dart' as pb; import 'package:conductor_core/src/proto/conductor_state.pbenum.dart' show ReleasePhase; import 'package:conductor_core/src/repository.dart'; import 'package:conductor_core/src/state.dart'; import 'package:conductor_core/src/stdio.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:platform/platform.dart'; import './common.dart'; void main() { const String flutterRoot = '/flutter'; const String checkoutsParentDirectory = '$flutterRoot/dev/conductor'; const String candidateBranch = 'flutter-1.2-candidate.3'; const String workingBranch = 'cherrypicks-$candidateBranch'; const String remoteUrl = 'https://github.com/org/repo.git'; const String revision1 = 'd3af60d18e01fcb36e0c0fa06c8502e4935ed095'; const String revision2 = 'f99555c1e1392bf2a8135056b9446680c2af4ddf'; const String revision3 = 'ffffffffffffffffffffffffffffffffffffffff'; const String revision4 = '280e23318a0d8341415c66aa32581352a421d974'; const String releaseVersion = '1.2.0-3.0.pre'; const String releaseChannel = 'beta'; const String stateFile = '/state-file.json'; final String localPathSeparator = const LocalPlatform().pathSeparator; final String localOperatingSystem = const LocalPlatform().pathSeparator; group('next command', () { late MemoryFileSystem fileSystem; late TestStdio stdio; setUp(() { stdio = TestStdio(); fileSystem = MemoryFileSystem.test(); }); CommandRunner<void> createRunner({ required Checkouts checkouts, }) { final NextCommand command = NextCommand( checkouts: checkouts, ); return CommandRunner<void>('codesign-test', '')..addCommand(command); } test('throws if no state file found', () async { final FakeProcessManager processManager = FakeProcessManager.list( <FakeCommand>[], ); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); expect( () async => runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]), throwsExceptionWith('No persistent state file found at $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.empty(); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final File ciYaml = fileSystem.file('$checkoutsParentDirectory/engine/.ci.yaml') ..createSync(recursive: true); // this branch already present in ciYaml _initializeCiYamlFile(ciYaml, enabledBranches: <String>[candidateBranch]); final pb.ConductorState state = pb.ConductorState( currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, engine: pb.Repository( candidateBranch: candidateBranch, checkoutPath: fileSystem.path.join(checkoutsParentDirectory, 'engine'), workingBranch: workingBranch, startingGitHead: revision1, upstream: pb.Remote(name: 'upstream', url: remoteUrl), ), ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); final pb.ConductorState finalState = readStateFromFile( fileSystem.file(stateFile), ); expect(processManager, hasNoRemainingExpectations); expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); expect(stdio.error, isEmpty); expect( stdio.stdout, contains('You must now codesign the engine binaries for commit $revision1')); }); test('confirms to stdout when all engine cherrypicks were auto-applied', () async { stdio.stdin.add('n'); final File ciYaml = fileSystem.file('$checkoutsParentDirectory/engine/.ci.yaml') ..createSync(recursive: true); _initializeCiYamlFile(ciYaml); final FakeProcessManager processManager = FakeProcessManager.empty(); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final pb.ConductorState state = pb.ConductorState( engine: pb.Repository( candidateBranch: candidateBranch, cherrypicks: <pb.Cherrypick>[ pb.Cherrypick( trunkRevision: 'abc123', state: pb.CherrypickState.COMPLETED, ), ], checkoutPath: fileSystem.path.join(checkoutsParentDirectory, 'engine'), 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, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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://github.com/org/repo.git'; const String releaseChannel = 'beta'; stdio.stdin.add('y'); final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ const FakeCommand( command: <String>['git', 'fetch', 'upstream'], ), FakeCommand( command: const <String>['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/engine/.ci.yaml') ..createSync(recursive: true); _initializeCiYamlFile(file); }, ), const FakeCommand(command: <String>['git', 'push', 'mirror', 'HEAD:refs/heads/$workingBranch']), ]); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final pb.ConductorState state = pb.ConductorState( currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, engine: pb.Repository( candidateBranch: candidateBranch, checkoutPath: fileSystem.path.join(checkoutsParentDirectory, 'engine'), cherrypicks: <pb.Cherrypick>[ pb.Cherrypick( trunkRevision: revision2, state: pb.CherrypickState.PENDING, ), ], workingBranch: workingBranch, upstream: pb.Remote(name: 'upstream', url: remoteUrl), mirror: pb.Remote(name: 'mirror', url: remoteUrl), ), releaseChannel: releaseChannel, releaseVersion: releaseVersion, ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); // engine dir is expected to already exist fileSystem.directory(checkoutsParentDirectory).childDirectory('engine').createSync(recursive: true); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); final pb.ConductorState finalState = readStateFromFile( fileSystem.file(stateFile), ); expect(processManager, hasNoRemainingExpectations); expect( stdio.stdout, contains('You must now open a pull request at https://github.com/flutter/engine/compare/flutter-1.2-candidate.3...org:cherrypicks-flutter-1.2-candidate.3?expand=1')); 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', () { late pb.ConductorState state; late FakeProcessManager processManager; late FakePlatform platform; setUp(() { state = pb.ConductorState( engine: pb.Repository( cherrypicks: <pb.Cherrypick>[ pb.Cherrypick( trunkRevision: 'abc123', state: pb.CherrypickState.PENDING, ), ], ), currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, ); processManager = FakeProcessManager.empty(); platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['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, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); final pb.ConductorState finalState = readStateFromFile( fileSystem.file(stateFile), ); expect(processManager, hasNoRemainingExpectations); 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: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); final pb.ConductorState finalState = readStateFromFile( fileSystem.file(stateFile), ); expect(processManager, hasNoRemainingExpectations); expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); }); }); group('APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION', () { const String mirrorRemoteUrl = 'https://github.com/org/repo.git'; const String upstreamRemoteUrl = 'https://github.com/mirror/repo.git'; const String engineUpstreamRemoteUrl = 'https://github.com/mirror/engine.git'; const String frameworkCheckoutPath = '$checkoutsParentDirectory/framework'; const String engineCheckoutPath = '$checkoutsParentDirectory/engine'; const String oldEngineVersion = '000000001'; const String frameworkCherrypick = '431ae69b4dd2dd48f7ba0153671e0311014c958b'; late FakeProcessManager processManager; late FakePlatform platform; late pb.ConductorState state; setUp(() { processManager = FakeProcessManager.empty(); platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['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>[ pb.Cherrypick( trunkRevision: frameworkCherrypick, 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', workingBranch: workingBranch, 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); }); 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, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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(<FakeCommand>[ const FakeCommand(command: <String>['git', 'fetch', 'upstream']), // we want merged upstream commit, not local working commit const FakeCommand(command: <String>['git', 'checkout', 'upstream/$candidateBranch']), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand(command: <String>['git', 'fetch', 'upstream']), FakeCommand( command: const <String>['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/release-candidate-branch.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Create candidate branch version $candidateBranch for $releaseChannel'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/engine.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision4, ), ]); 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, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); expect(processManager, hasNoRemainingExpectations); expect(stdio.stdout, contains('release-candidate-branch.version containing $candidateBranch')); expect(stdio.stdout, contains('Updating engine revision from $oldEngineVersion to $revision1')); expect(stdio.stdout, contains('Are you ready to push your framework branch')); }); test('does not update state.currentPhase if user responds no', () async { stdio.stdin.add('n'); processManager.addCommands(<FakeCommand>[ const FakeCommand(command: <String>['git', 'fetch', 'upstream']), // we want merged upstream commit, not local working commit FakeCommand( command: const <String>['git', 'checkout', 'upstream/$candidateBranch'], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand(command: <String>['git', 'fetch', 'upstream']), const FakeCommand(command: <String>['git', 'checkout', workingBranch]), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/release-candidate-branch.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Create candidate branch version $candidateBranch for $releaseChannel'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/engine.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision4, ), ]); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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(<FakeCommand>[ // Engine repo const FakeCommand(command: <String>['git', 'fetch', 'upstream']), // we want merged upstream commit, not local working commit const FakeCommand(command: <String>['git', 'checkout', 'upstream/$candidateBranch']), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), // Framework repo const FakeCommand(command: <String>['git', 'fetch', 'upstream']), FakeCommand( command: const <String>['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/release-candidate-branch.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Create candidate branch version $candidateBranch for $releaseChannel'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: <String>['git', 'status', '--porcelain'], stdout: 'MM bin/internal/engine.version', ), const FakeCommand(command: <String>['git', 'add', '--all']), const FakeCommand(command: <String>[ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision4, ), const FakeCommand( command: <String>['git', 'push', 'mirror', 'HEAD:refs/heads/$workingBranch'], ), ]); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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 was 1 cherrypick that was 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 HEAD:refs/heads/$workingBranch`'), ); expect(stdio.error, isEmpty); }); }); group('PUBLISH_VERSION to PUBLISH_CHANNEL', () { const String remoteName = 'upstream'; const String releaseVersion = '1.2.0-3.0.pre'; late pb.ConductorState state; late 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: <String, String>{ 'HOME': <String>['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( <FakeCommand>[ const FakeCommand( command: <String>['git', 'fetch', 'upstream'], ), const FakeCommand( command: <String>['git', 'checkout', '$remoteName/$candidateBranch'], ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), ], ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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(<FakeCommand>[ const FakeCommand( command: <String>['git', 'fetch', 'upstream'], ), const FakeCommand( command: <String>['git', 'checkout', '$remoteName/$candidateBranch'], ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand( command: <String>['git', 'tag', releaseVersion, revision1], ), const FakeCommand( command: <String>['git', 'push', remoteName, releaseVersion], ), ]); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]); final pb.ConductorState finalState = readStateFromFile( fileSystem.file(stateFile), ); 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); }); }); group('PUBLISH_CHANNEL to VERIFY_RELEASE', () { const String remoteName = 'upstream'; late pb.ConductorState state; late FakePlatform platform; 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: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); }); test('does not update currentPhase if user responds no', () async { stdio.stdin.add('n'); final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ const FakeCommand( command: <String>['git', 'fetch', 'upstream'], ), const FakeCommand( command: <String>['git', 'checkout', '$remoteName/$candidateBranch'], ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), ]); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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'); // for kSynchronizeDevWithBeta stdio.stdin.add('y'); final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[ const FakeCommand( command: <String>['git', 'fetch', 'upstream'], ), const FakeCommand( command: <String>['git', 'checkout', '$remoteName/$candidateBranch'], ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand( command: <String>['git', 'push', FrameworkRepository.defaultUpstream, '$revision1:$releaseChannel'], ), // for kSynchronizeDevWithBeta const FakeCommand( command: <String>['git', 'push', FrameworkRepository.defaultUpstream, '$revision1:dev'], ), ]); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); await runner.run(<String>[ '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.empty(); final FakePlatform platform = FakePlatform( environment: <String, String>{ 'HOME': <String>['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final pb.ConductorState state = pb.ConductorState( currentPhase: ReleasePhase.RELEASE_COMPLETED, ); writeStateToFile( fileSystem.file(stateFile), state, <String>[], ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: processManager, stdio: stdio, ); final CommandRunner<void> runner = createRunner(checkouts: checkouts); expect( () async => runner.run(<String>[ 'next', '--$kStateOption', stateFile, ]), throwsExceptionWith('This release is finished.'), ); }); }, onPlatform: <String, dynamic>{ 'windows': const Skip('Flutter Conductor only supported on macos/linux'), }); group('prompt', () { test('can be overridden for different frontend implementations', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final Stdio stdio = _UnimplementedStdio.instance; final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory('/'), platform: FakePlatform(), processManager: FakeProcessManager.empty(), stdio: stdio, ); final _TestNextContext context = _TestNextContext( checkouts: checkouts, stateFile: fileSystem.file('/statefile.json'), ); final bool response = await context.prompt( 'A prompt that will immediately be agreed to', ); expect(response, true); }); test('throws if user inputs character that is not "y" or "n"', () { final FileSystem fileSystem = MemoryFileSystem.test(); final TestStdio stdio = TestStdio( stdin: <String>['x'], verbose: true, ); final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory('/'), platform: FakePlatform(), processManager: FakeProcessManager.empty(), stdio: stdio, ); final NextContext context = NextContext( autoAccept: false, force: false, checkouts: checkouts, stateFile: fileSystem.file('/statefile.json'), ); expect( () => context.prompt('Asking a question?'), throwsExceptionWith('Unknown user input (expected "y" or "n")'), ); }); }); group('.pushWorkingBranch()', () { late MemoryFileSystem fileSystem; late TestStdio stdio; late Platform platform; setUp(() { stdio = TestStdio(); fileSystem = MemoryFileSystem.test(); platform = FakePlatform(); }); test('catches GitException if the push was rejected and instead throws a helpful ConductorException', () async { const String gitPushErrorMessage = ''' To github.com:user/engine.git ! [rejected] HEAD -> cherrypicks-flutter-2.8-candidate.3 (non-fast-forward) error: failed to push some refs to 'github.com:user/engine.git' hint: Updates were rejected because the tip of your current branch is behind hint: its remote counterpart. Integrate the remote changes (e.g. hint: 'git pull ...') before pushing again. hint: See the 'Note about fast-forwards' in 'git push --help' for details. '''; final Checkouts checkouts = Checkouts( fileSystem: fileSystem, parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), platform: platform, processManager: FakeProcessManager.empty(), stdio: stdio, ); final Repository testRepository = _TestRepository.fromCheckouts(checkouts); final pb.Repository testPbRepository = pb.Repository(); (checkouts.processManager as FakeProcessManager).addCommands(<FakeCommand>[ FakeCommand( command: <String>['git', 'clone', '--origin', 'upstream', '--', testRepository.upstreamRemote.url, '/flutter/dev/conductor/flutter_conductor_checkouts/test-repo/test-repo'], ), const FakeCommand( command: <String>['git', 'rev-parse', 'HEAD'], stdout: revision1, ), FakeCommand( command: const <String>['git', 'push', '', 'HEAD:refs/heads/'], exception: GitException(gitPushErrorMessage, <String>['git', 'push', '--force', '', 'HEAD:refs/heads/']), ), ]); final NextContext nextContext = NextContext( autoAccept: false, checkouts: checkouts, force: false, stateFile: fileSystem.file(stateFile), ); expect( () => nextContext.pushWorkingBranch(testRepository, testPbRepository), throwsA(isA<ConductorException>().having( (ConductorException exception) => exception.message, 'has correct message', contains('Re-run this command with --force to overwrite the remote branch'), )), ); }); }); } /// A [Stdio] that will throw an exception if any of its methods are called. class _UnimplementedStdio implements Stdio { const _UnimplementedStdio(); static const _UnimplementedStdio _instance = _UnimplementedStdio(); static _UnimplementedStdio get instance => _instance; Never _throw() => throw Exception('Unimplemented!'); @override List<String> get logs => _throw(); @override void printError(String message) => _throw(); @override void printWarning(String message) => _throw(); @override void printStatus(String message) => _throw(); @override void printTrace(String message) => _throw(); @override void write(String message) => _throw(); @override String readLineSync() => _throw(); } class _TestRepository extends Repository { _TestRepository.fromCheckouts(Checkouts checkouts, [String name = 'test-repo']) : super( fileSystem: checkouts.fileSystem, parentDirectory: checkouts.directory.childDirectory(name), platform: checkouts.platform, processManager: checkouts.processManager, name: name, requiredLocalBranches: <String>[], stdio: checkouts.stdio, upstreamRemote: const Remote(name: RemoteName.upstream, url: 'git@github.com:upstream/repo.git'), ); @override Future<_TestRepository> cloneRepository(String? cloneName) async { throw Exception('Unimplemented!'); } } class _TestNextContext extends NextContext { const _TestNextContext({ required super.stateFile, required super.checkouts, }) : super(autoAccept: false, force: false); @override Future<bool> prompt(String message) { // always say yes return Future<bool>.value(true); } } void _initializeCiYamlFile( File file, { List<String>? enabledBranches, }) { enabledBranches ??= <String>['master', 'beta', 'stable']; file.createSync(recursive: true); final StringBuffer buffer = StringBuffer('enabled_branches:\n'); for (final String branch in enabledBranches) { buffer.writeln(' - $branch'); } buffer.writeln(''' platform_properties: linux: properties: caches: ["name":"openjdk","path":"java"] targets: - name: Linux analyze recipe: flutter/flutter timeout: 60 properties: tags: > ["framework","hostonly"] validation: analyze validation_name: Analyze scheduler: luci '''); file.writeAsStringSync(buffer.toString()); }