blob: 4c0552c85201cbbcf8de7063092cf5832a3a949f [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 '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<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() 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<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)) 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.releaseChannel, 'engine')}'
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS:
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.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<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[
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<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) {
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;
}
}
}