// 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:file/file.dart' show File; import 'package:meta/meta.dart' show visibleForTesting; import 'context.dart'; import 'git.dart'; import 'globals.dart'; import 'proto/conductor_state.pb.dart' as pb; import 'proto/conductor_state.pbenum.dart'; import 'repository.dart'; import 'state.dart' as state_import; const String kStateOption = 'state-file'; const String kYesFlag = 'yes'; /// Command to proceed from one [pb.ReleasePhase] to the next. /// /// After `conductor start`, the rest of the release steps are initiated by the /// user via `conductor next`. Thus this command's behavior is conditional upon /// which phase of the release the user is currently in. This is implemented /// with a switch case statement. class NextCommand extends Command { NextCommand({required this.checkouts}) { final String defaultPath = state_import.defaultStateFilePath(checkouts.platform); argParser.addOption( kStateOption, defaultsTo: defaultPath, help: 'Path to persistent state file. Defaults to $defaultPath', ); argParser.addFlag( kYesFlag, help: 'Auto-accept any confirmation prompts.', hide: true, // primarily for integration testing ); argParser.addFlag(kForceFlag, help: 'Force push when updating remote git branches.'); } final Checkouts checkouts; @override String get name => 'next'; @override String get description => 'Proceed to the next release phase.'; @override Future run() async { final File stateFile = checkouts.fileSystem.file(argResults![kStateOption]); if (!stateFile.existsSync()) { throw ConductorException('No persistent state file found at ${stateFile.path}.'); } final pb.ConductorState state = state_import.readStateFromFile(stateFile); await NextContext( autoAccept: argResults![kYesFlag] as bool, checkouts: checkouts, force: argResults![kForceFlag] as bool, stateFile: stateFile, ).run(state); } } /// Utility class for proceeding to the next step in a release. /// /// Any calls to functions that cause side effects are wrapped in methods to /// allow overriding in unit tests. class NextContext extends Context { const NextContext({ required this.autoAccept, required this.force, required super.checkouts, required super.stateFile, }); final bool autoAccept; final bool force; Future run(pb.ConductorState state) async { const List finishedStates = [ CherrypickState.COMPLETED, CherrypickState.ABANDONED, ]; switch (state.currentPhase) { case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS: final Remote upstream = Remote.upstream(state.engine.upstream.url); final EngineRepository engine = EngineRepository( checkouts, initialRef: state.engine.workingBranch, upstreamRemote: upstream, previousCheckoutLocation: state.engine.checkoutPath, ); if (!state_import.requiresEnginePR(state)) { stdio.printStatus('This release has no engine cherrypicks. No Engine PR is necessary.\n'); break; } final List unappliedCherrypicks = [ for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) if (!finishedStates.contains(cherrypick.state)) cherrypick, ]; if (unappliedCherrypicks.isEmpty) { stdio.printStatus('All engine cherrypicks have been auto-applied by the conductor.\n'); } else { if (unappliedCherrypicks.length == 1) { stdio.printStatus( 'There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.', ); } else { stdio.printStatus( 'There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.', ); } stdio.printStatus( 'These must be applied manually in the directory ' '${state.engine.checkoutPath} before proceeding.\n', ); } if (!autoAccept) { final bool response = await prompt( 'Are you ready to push your engine branch to the repository ' '${state.engine.mirror.url}?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } await pushWorkingBranch(engine, state.engine); case pb.ReleasePhase.VERIFY_ENGINE_CI: stdio.printStatus('You must validate post-submit CI for your engine PR and merge it'); if (!autoAccept) { final bool response = await prompt( 'Has CI passed for the engine PR?\n\n' '${state_import.luciConsoleLink(state.engine.candidateBranch, 'engine')}', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: final Remote engineUpstreamRemote = Remote.upstream(state.engine.upstream.url); final EngineRepository engine = EngineRepository( checkouts, // We explicitly want to check out the merged version from upstream initialRef: '${engineUpstreamRemote.name}/${state.engine.candidateBranch}', upstreamRemote: engineUpstreamRemote, previousCheckoutLocation: state.engine.checkoutPath, ); final String engineRevision = await engine.reverseParse('HEAD'); final Remote upstream = Remote.upstream(state.framework.upstream.url); final FrameworkRepository framework = FrameworkRepository( checkouts, initialRef: state.framework.workingBranch, upstreamRemote: upstream, previousCheckoutLocation: state.framework.checkoutPath, ); stdio.printStatus('Writing candidate branch...'); bool needsCommit = await framework.updateCandidateBranchVersion( state.framework.candidateBranch, ); if (needsCommit) { final String revision = await framework.commit( 'Create candidate branch version ${state.framework.candidateBranch} for ${state.releaseChannel}', addFirst: true, ); // append to list of cherrypicks so we know a PR is required state.framework.cherrypicks.add( pb.Cherrypick.create() ..appliedRevision = revision ..state = pb.CherrypickState.COMPLETED, ); } stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...'); needsCommit = await framework.updateEngineRevision(engineRevision); if (needsCommit) { final String revision = await framework.commit( 'Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}', addFirst: true, ); // append to list of cherrypicks so we know a PR is required state.framework.cherrypicks.add( pb.Cherrypick.create() ..appliedRevision = revision ..state = pb.CherrypickState.COMPLETED, ); } final List unappliedCherrypicks = [ for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) if (!finishedStates.contains(cherrypick.state)) cherrypick, ]; if (state.framework.cherrypicks.isEmpty) { stdio.printStatus( 'This release has no framework cherrypicks. However, a framework PR is still\n' 'required to roll engine cherrypicks.', ); } else if (unappliedCherrypicks.isEmpty) { stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.'); } else { if (unappliedCherrypicks.length == 1) { stdio.printStatus( 'There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.', ); } else { stdio.printStatus( 'There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.', ); } stdio.printStatus( 'These must be applied manually in the directory ' '${state.framework.checkoutPath} before proceeding.\n', ); } if (!autoAccept) { final bool response = await prompt( 'Are you ready to push your framework branch to the repository ' '${state.framework.mirror.url}?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } await pushWorkingBranch(framework, state.framework); case pb.ReleasePhase.PUBLISH_VERSION: final String command = ''' tool-proxy-cli --tool_proxy=/abns/dart-eng-tool-proxy/prod-dart-eng-tool-proxy-tool-proxy.annealed-tool-proxy \\ --block_on_mpa -I flutter_release \\ :git_branch ${state.framework.candidateBranch} \\ :release_channel ${state.releaseChannel} \\ :tag ${state.releaseVersion} \\ :force false '''; stdio.printStatus('Please ensure that you have merged your framework PR'); stdio.printStatus('and post-submit CI has finished successfully.\n'); stdio.printStatus('Run the following command, and ask a Googler'); stdio.printStatus('to review the request\n\n$command'); case pb.ReleasePhase.VERIFY_RELEASE: stdio.printStatus( 'The current status of packaging builds can be seen at:\n' '\t$kLuciPackagingConsoleLink', ); if (!autoAccept) { final bool response = await prompt( 'Have all packaging builds finished successfully and post release announcements been completed?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } case pb.ReleasePhase.RELEASE_COMPLETED: throw ConductorException('This release is finished.'); } final ReleasePhase nextPhase = state_import.getNextPhase(state.currentPhase); stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n'); state.currentPhase = nextPhase; stdio.printStatus(state_import.phaseInstructions(state)); updateState(state, stdio.logs); } /// Push the working branch to the user's mirror. /// /// [repository] represents the actual Git repository on disk, and is used to /// call `git push`, while [pbRepository] represents the user-specified /// configuration for the repository, and is used to read the name of the /// working branch and the mirror's remote name. /// /// May throw either a [ConductorException] if the user already has a branch /// of the same name on their mirror, or a [GitException] for any other /// failures from the underlying git process call. @visibleForTesting Future pushWorkingBranch(Repository repository, pb.Repository pbRepository) async { try { await repository.pushRef( fromRef: 'HEAD', // Explicitly create new branch toRef: 'refs/heads/${pbRepository.workingBranch}', remote: pbRepository.mirror.name, force: force, ); } on GitException catch (exception) { if (exception.type == GitExceptionType.PushRejected && !force) { throw ConductorException( 'Push failed because the working branch named ' '${pbRepository.workingBranch} already exists on your mirror. ' 'Re-run this command with --force to overwrite the remote branch.\n' '${exception.message}', ); } rethrow; } } }