blob: 4cb356d50e06724a5f65e5a9045c1642e6e542c5 [file] [log] [blame]
// 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<void> {
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
void run() {
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
void runNext({
required bool autoAccept,
required bool force,
required Checkouts checkouts,
required File stateFile,
}) {
final Stdio stdio = checkouts.stdio;
const List<CherrypickState> finishedStates = <CherrypickState>[
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
if (!engine.ciYaml.enabledBranches.contains(state.engine.candidateBranch)) {
engine.ciYaml.enableBranch(state.engine.candidateBranch);
// commit
final String revision = 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<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 = 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;
}
}
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(<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 = 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 = 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 (!framework.ciYaml.enabledBranches.contains(state.framework.candidateBranch)) {
framework.ciYaml.enableBranch(state.framework.candidateBranch);
// commit
final String revision = 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 = framework.updateEngineRevision(engineRevision);
if (needsCommit) {
final String revision = 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 = 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;
}
}
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 = 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;
}
}
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 = framework.reverseParse('HEAD');
if (autoAccept == false) {
// dryRun: true means print out git command
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;
}
}
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);
}