// 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 './globals.dart'; import './proto/conductor_state.pb.dart' as pb; import './proto/conductor_state.pbenum.dart'; import './repository.dart'; import './state.dart'; import './stdio.dart'; const String kStateOption = 'state-file'; const String kYesFlag = 'yes'; const String kForceFlag = 'force'; /// Command to proceed from one [pb.ReleasePhase] to the next. class NextCommand extends Command { NextCommand({ required this.checkouts, }) { final String defaultPath = 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 { await runNext( autoAccept: argResults![kYesFlag] as bool, checkouts: checkouts, force: argResults![kForceFlag] as bool, stateFile: checkouts.fileSystem.file(argResults![kStateOption]), ); } } @visibleForTesting bool prompt(String message, Stdio stdio) { stdio.write('${message.trim()} (y/n) '); final String response = stdio.readLineSync().trim(); final String firstChar = response[0].toUpperCase(); if (firstChar == 'Y') { return true; } if (firstChar == 'N') { return false; } throw ConductorException( 'Unknown user input (expected "y" or "n"): $response', ); } @visibleForTesting Future runNext({ required bool autoAccept, required bool force, required Checkouts checkouts, required File stateFile, }) async { final Stdio stdio = checkouts.stdio; const List finishedStates = [ CherrypickState.COMPLETED, CherrypickState.ABANDONED, ]; if (!stateFile.existsSync()) { throw ConductorException( 'No persistent state file found at ${stateFile.path}.', ); } final pb.ConductorState state = readStateFromFile(stateFile); 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, ); // check if the candidate branch is enabled in .ci.yaml final CiYaml engineCiYaml = await engine.ciYaml; if (!engineCiYaml.enabledBranches.contains(state.engine.candidateBranch)) { engineCiYaml.enableBranch(state.engine.candidateBranch); // commit final String revision = await engine.commit( 'add branch ${state.engine.candidateBranch} to enabled_branches in .ci.yaml', addFirst: true, ); // append to list of cherrypicks so we know a PR is required state.engine.cherrypicks.add(pb.Cherrypick( appliedRevision: revision, state: pb.CherrypickState.COMPLETED, )); } if (!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)) { 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 = prompt( 'Are you ready to push your engine branch to the repository ' '${state.engine.mirror.url}?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, state, stdio.logs); return; } } await engine.pushRef( fromRef: 'HEAD', // Explicitly create new branch toRef: 'refs/heads/${state.engine.workingBranch}', remote: state.engine.mirror.name, ); break; case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES: stdio.printStatus([ '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 = prompt( 'Has CI passed for the engine PR and binaries been codesigned?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, 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, ); // Check if the current candidate branch is enabled if (!(await framework.ciYaml).enabledBranches.contains(state.framework.candidateBranch)) { (await framework.ciYaml).enableBranch(state.framework.candidateBranch); // commit final String revision = await framework.commit( 'add branch ${state.framework.candidateBranch} to enabled_branches in .ci.yaml', 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...'); final bool 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 unappliedCherrypicks = []; 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 = prompt( 'Are you ready to push your framework branch to the repository ' '${state.framework.mirror.url}?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, state, stdio.logs); return; } } await framework.pushRef( fromRef: 'HEAD', // Explicitly create new branch toRef: 'refs/heads/${state.framework.workingBranch}', remote: state.framework.mirror.name, ); 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 = prompt( 'Are you ready to tag commit $headRevision as ${state.releaseVersion}\n' 'and push to remote ${state.framework.upstream.url}?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, 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'); if (autoAccept == false) { // dryRun: true means print out git command await framework.pushRef( fromRef: headRevision, toRef: state.releaseChannel, remote: state.framework.upstream.url, force: force, dryRun: true, ); final bool response = prompt( 'Are you ready to publish this release?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, state, stdio.logs); return; } } await framework.pushRef( fromRef: headRevision, toRef: state.releaseChannel, 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 = prompt( 'Have all packaging builds finished successfully?', stdio, ); if (!response) { stdio.printError('Aborting command.'); writeStateToFile(stateFile, state, stdio.logs); return; } } break; case pb.ReleasePhase.RELEASE_COMPLETED: throw ConductorException('This release is finished.'); } final ReleasePhase nextPhase = getNextPhase(state.currentPhase); stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n'); state.currentPhase = nextPhase; stdio.printStatus(phaseInstructions(state)); writeStateToFile(stateFile, state, stdio.logs); }