| // 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().operatingSystem; |
| |
| 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'); |
| 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'], |
| ), |
| ]); |
| 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()); |
| } |