// 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. class NextCommand extends Command<void> { 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<void> run() { 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); return 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<void> run(pb.ConductorState state) async { const List<CherrypickState> finishedStates = <CherrypickState>[ CherrypickState.COMPLETED, CherrypickState.ABANDONED, ]; switch (state.currentPhase) { case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS: final Remote upstream = Remote( name: RemoteName.upstream, url: state.engine.upstream.url, ); final EngineRepository engine = EngineRepository( checkouts, initialRef: state.engine.workingBranch, upstreamRemote: upstream, previousCheckoutLocation: state.engine.checkoutPath, ); if (!state_import.requiresEnginePR(state)) { stdio.printStatus( 'This release has no engine cherrypicks. No Engine PR is necessary.\n', ); break; } final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[]; for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) { if (!finishedStates.contains(cherrypick.state)) { unappliedCherrypicks.add(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 == false) { 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); break; case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES: stdio.printStatus(<String>[ 'You must validate pre-submit CI for your engine PR, merge it, and codesign', 'binaries before proceeding.\n', ].join('\n')); if (autoAccept == false) { // TODO(fujino): actually test if binaries have been codesigned on macOS final bool response = await prompt( 'Has CI passed for the engine PR and binaries been codesigned?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } break; case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) { stdio.printStatus( 'This release has no engine cherrypicks, and thus the engine.version file\n' 'in the framework does not need to be updated.', ); if (state.framework.cherrypicks.isEmpty) { stdio.printStatus( 'This release also has no framework cherrypicks. Therefore, a framework\n' 'pull request is not required.', ); break; } } final Remote engineUpstreamRemote = Remote( name: RemoteName.upstream, url: 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( name: RemoteName.upstream, url: 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( 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( appliedRevision: revision, state: pb.CherrypickState.COMPLETED, )); } final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[]; for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) { if (!finishedStates.contains(cherrypick.state)) { unappliedCherrypicks.add(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 == false) { 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); break; case pb.ReleasePhase.PUBLISH_VERSION: stdio.printStatus('Please ensure that you have merged your framework PR and that'); stdio.printStatus('post-submit CI has finished successfully.\n'); final Remote upstream = Remote( name: RemoteName.upstream, url: state.framework.upstream.url, ); final FrameworkRepository framework = FrameworkRepository( checkouts, // We explicitly want to check out the merged version from upstream initialRef: '${upstream.name}/${state.framework.candidateBranch}', upstreamRemote: upstream, previousCheckoutLocation: state.framework.checkoutPath, ); final String headRevision = await framework.reverseParse('HEAD'); if (autoAccept == false) { final bool response = await prompt( 'Are you ready to tag commit $headRevision as ${state.releaseVersion}\n' 'and push to remote ${state.framework.upstream.url}?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } await framework.tag(headRevision, state.releaseVersion, upstream.name); break; case pb.ReleasePhase.PUBLISH_CHANNEL: final Remote upstream = Remote( name: RemoteName.upstream, url: state.framework.upstream.url, ); final FrameworkRepository framework = FrameworkRepository( checkouts, // We explicitly want to check out the merged version from upstream initialRef: '${upstream.name}/${state.framework.candidateBranch}', upstreamRemote: upstream, previousCheckoutLocation: state.framework.checkoutPath, ); final String headRevision = await framework.reverseParse('HEAD'); final List<String> releaseRefs = <String>[state.releaseChannel]; if (kSynchronizeDevWithBeta && state.releaseChannel == 'beta') { releaseRefs.add('dev'); } for (final String releaseRef in releaseRefs) { if (autoAccept == false) { // dryRun: true means print out git command await framework.pushRef( fromRef: headRevision, toRef: releaseRef, remote: state.framework.upstream.url, force: force, dryRun: true, ); final bool response = await prompt( 'Are you ready to publish version ${state.releaseVersion} to $releaseRef?', ); if (!response) { stdio.printError('Aborting command.'); updateState(state, stdio.logs); return; } } await framework.pushRef( fromRef: headRevision, toRef: releaseRef, remote: state.framework.upstream.url, force: force, ); } break; case pb.ReleasePhase.VERIFY_RELEASE: stdio.printStatus( 'The current status of packaging builds can be seen at:\n' '\t$kLuciPackagingConsoleLink', ); if (autoAccept == false) { 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; } } break; 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<void> 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 == false) { 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; } } }