// 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/next.dart'; 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:platform/platform.dart'; import './common.dart'; import '../../../packages/flutter_tools/test/src/fake_process_manager.dart'; void main() { group('next command', () { 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'; final String localPathSeparator = const LocalPlatform().pathSeparator; final String localOperatingSystem = const LocalPlatform().pathSeparator; const String revision1 = 'd3af60d18e01fcb36e0c0fa06c8502e4935ed095'; const String revision2 = 'f99555c1e1392bf2a8135056b9446680c2af4ddf'; const String revision3 = '98a5ca242b9d270ce000b26309b8a3cdc9c89df5'; const String revision4 = '280e23318a0d8341415c66aa32581352a421d974'; const String releaseVersion = '1.2.0-3.0.pre'; const String releaseChannel = 'beta'; late MemoryFileSystem fileSystem; late TestStdio stdio; const String stateFile = '/state-file.json'; setUp(() { stdio = TestStdio(); fileSystem = MemoryFileSystem.test(); }); CommandRunner createRunner({ required Checkouts checkouts, }) { final NextCommand command = NextCommand( checkouts: checkouts, ); return CommandRunner('codesign-test', '')..addCommand(command); } test('throws if no state file found', () async { final FakeProcessManager processManager = FakeProcessManager.list( [], ); final FakePlatform platform = FakePlatform( environment: { 'HOME': ['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 runner = createRunner(checkouts: checkouts); expect( () async => runner.run([ '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.list([ const FakeCommand(command: ['git', 'fetch', 'upstream']), const FakeCommand( command: ['git', 'checkout', workingBranch], ), ]); final FakePlatform platform = FakePlatform( environment: { 'HOME': ['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: [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, [], ); 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(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.list([ const FakeCommand(command: ['git', 'fetch', 'upstream']), const FakeCommand(command: ['git', 'checkout', workingBranch]), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM blah', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='add branch $candidateBranch to enabled_branches in .ci.yaml'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision2, ), ]); final FakePlatform platform = FakePlatform( environment: { 'HOME': ['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final pb.ConductorState state = pb.ConductorState( engine: pb.Repository( candidateBranch: candidateBranch, cherrypicks: [ 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, [], ); 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://github.com/org/repo.git'; const String releaseChannel = 'dev'; stdio.stdin.add('y'); final FakeProcessManager processManager = FakeProcessManager.list([ const FakeCommand( command: ['git', 'fetch', 'upstream'], ), FakeCommand( command: const ['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/engine/.ci.yaml') ..createSync(recursive: true); _initializeCiYamlFile(file); }, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM .ci.yaml', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: ['git', 'commit', "--message='add branch $candidateBranch to enabled_branches in .ci.yaml'"]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision2, ), const FakeCommand(command: ['git', 'push', 'mirror', 'HEAD:refs/heads/$workingBranch']), ]); 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, engine: pb.Repository( candidateBranch: candidateBranch, checkoutPath: fileSystem.path.join(checkoutsParentDirectory, 'engine'), cherrypicks: [ 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, [], ); // 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 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('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( trunkRevision: 'abc123', state: pb.CherrypickState.PENDING, ), ], ), currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, ); processManager = FakeProcessManager.empty(); 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); }); }); 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: { '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: 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, [], ); 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']), // we want merged upstream commit, not local working commit const FakeCommand(command: ['git', 'checkout', 'upstream/$candidateBranch']), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand(command: ['git', 'fetch', 'upstream']), FakeCommand( command: const ['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM /path/to/.ci.yaml', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='add branch $candidateBranch to enabled_branches in .ci.yaml'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM /path/to/engine.version', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: ['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, [], ); 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('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([ const FakeCommand(command: ['git', 'fetch', 'upstream']), // we want merged upstream commit, not local working commit FakeCommand( command: const ['git', 'checkout', 'upstream/$candidateBranch'], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision1, ), const FakeCommand(command: ['git', 'fetch', 'upstream']), const FakeCommand(command: ['git', 'checkout', workingBranch]), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM path/to/.ci.yaml', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='add branch $candidateBranch to enabled_branches in .ci.yaml'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM path/to/engine.version', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision4, ), ]); 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([ // Engine repo const FakeCommand(command: ['git', 'fetch', 'upstream']), // we want merged upstream commit, not local working commit const FakeCommand(command: ['git', 'checkout', 'upstream/$candidateBranch']), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision1, ), // Framework repo const FakeCommand(command: ['git', 'fetch', 'upstream']), FakeCommand( command: const ['git', 'checkout', workingBranch], onRun: () { final File file = fileSystem.file('$checkoutsParentDirectory/framework/.ci.yaml') ..createSync(); _initializeCiYamlFile(file); }, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM path/to/.ci.yaml', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='add branch $candidateBranch to enabled_branches in .ci.yaml'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision3, ), const FakeCommand( command: ['git', 'status', '--porcelain'], stdout: 'MM path/to/engine.version', ), const FakeCommand(command: ['git', 'add', '--all']), const FakeCommand(command: [ 'git', 'commit', "--message='Update Engine revision to $revision1 for $releaseChannel release $releaseVersion'", ]), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision4, ), const FakeCommand( command: ['git', 'push', 'mirror', 'HEAD:refs/heads/$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 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: { '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'], ), 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, ); 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(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: { 'HOME': ['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([ 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.empty(); final FakePlatform platform = FakePlatform( environment: { 'HOME': ['path', 'to', 'home'].join(localPathSeparator), }, operatingSystem: localOperatingSystem, pathSeparator: localPathSeparator, ); final pb.ConductorState state = pb.ConductorState( currentPhase: ReleasePhase.RELEASE_COMPLETED, ); 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); expect( () async => runner.run([ 'next', '--$kStateOption', stateFile, ]), throwsExceptionWith('This release is finished.'), ); }); }, onPlatform: { 'windows': const Skip('Flutter Conductor only supported on macos/linux'), }); } void _initializeCiYamlFile( File file, { List? enabledBranches, }) { enabledBranches ??= ['master', 'dev', '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()); }