blob: b22f7904854f4aa520fd970e011e1fe2f3088c6e [file] [log] [blame] [edit]
// 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/args.dart';
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:fixnum/fixnum.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import './git.dart';
import './globals.dart';
import './proto/conductor_state.pb.dart' as pb;
import './proto/conductor_state.pbenum.dart' show ReleasePhase;
import './repository.dart';
import './state.dart';
import './stdio.dart';
import './version.dart';
const String kCandidateOption = 'candidate-branch';
const String kDartRevisionOption = 'dart-revision';
const String kEngineCherrypicksOption = 'engine-cherrypicks';
const String kEngineUpstreamOption = 'engine-upstream';
const String kFrameworkCherrypicksOption = 'framework-cherrypicks';
const String kFrameworkMirrorOption = 'framework-mirror';
const String kFrameworkUpstreamOption = 'framework-upstream';
const String kIncrementOption = 'increment';
const String kEngineMirrorOption = 'engine-mirror';
const String kReleaseOption = 'release-channel';
const String kStateOption = 'state-file';
/// Command to print the status of the current Flutter release.
class StartCommand extends Command<void> {
StartCommand({
required this.checkouts,
required this.flutterRoot,
}) : platform = checkouts.platform,
processManager = checkouts.processManager,
fileSystem = checkouts.fileSystem,
stdio = checkouts.stdio {
final String defaultPath = defaultStateFilePath(platform);
argParser.addOption(
kCandidateOption,
help: 'The candidate branch the release will be based on.',
);
argParser.addOption(
kReleaseOption,
help: 'The target release channel for the release.',
allowed: <String>['stable', 'beta', 'dev'],
);
argParser.addOption(
kFrameworkUpstreamOption,
defaultsTo: FrameworkRepository.defaultUpstream,
help:
'Configurable Framework repo upstream remote. Primarily for testing.',
hide: true,
);
argParser.addOption(
kEngineUpstreamOption,
defaultsTo: EngineRepository.defaultUpstream,
help: 'Configurable Engine repo upstream remote. Primarily for testing.',
hide: true,
);
argParser.addOption(
kFrameworkMirrorOption,
help: 'Framework repo mirror remote.',
);
argParser.addOption(
kEngineMirrorOption,
help: 'Engine repo mirror remote.',
);
argParser.addOption(
kStateOption,
defaultsTo: defaultPath,
help: 'Path to persistent state file. Defaults to $defaultPath',
);
argParser.addMultiOption(
kEngineCherrypicksOption,
help: 'Engine cherrypick hashes to be applied.',
defaultsTo: <String>[],
);
argParser.addMultiOption(
kFrameworkCherrypicksOption,
help: 'Framework cherrypick hashes to be applied.',
defaultsTo: <String>[],
);
argParser.addOption(
kDartRevisionOption,
help: 'New Dart revision to cherrypick.',
);
argParser.addOption(
kIncrementOption,
help: 'Specifies which part of the x.y.z version number to increment. Required.',
valueHelp: 'level',
allowed: <String>['y', 'z', 'm', 'n'],
allowedHelp: <String, String>{
'y': 'Indicates the first dev release after a beta release.',
'z': 'Indicates a hotfix to a stable release.',
'm': 'Indicates a standard dev release.',
'n': 'Indicates a hotfix to a dev or beta release.',
},
);
final Git git = Git(processManager);
conductorVersion = git.getOutput(
<String>['rev-parse', 'HEAD'],
'look up the current revision.',
workingDirectory: flutterRoot.path,
).trim();
assert(conductorVersion.isNotEmpty);
}
final Checkouts checkouts;
/// The root directory of the Flutter repository that houses the Conductor.
///
/// This directory is used to check the git revision of the Conductor.
final Directory flutterRoot;
final FileSystem fileSystem;
final Platform platform;
final ProcessManager processManager;
final Stdio stdio;
/// Git revision for the currently running Conductor.
late final String conductorVersion;
@override
String get name => 'start';
@override
String get description => 'Initialize a new Flutter release.';
@override
void run() {
final ArgResults argumentResults = argResults!;
if (!platform.isMacOS && !platform.isLinux) {
throw ConductorException(
'Error! This tool is only supported on macOS and Linux',
);
}
final File stateFile = checkouts.fileSystem.file(
getValueFromEnvOrArgs(kStateOption, argumentResults, platform.environment),
);
if (stateFile.existsSync()) {
throw ConductorException(
'Error! A persistent state file already found at ${argResults![kStateOption]}.\n\n'
'Run `conductor clean` to cancel a previous release.');
}
final String frameworkUpstream = getValueFromEnvOrArgs(
kFrameworkUpstreamOption,
argumentResults,
platform.environment,
)!;
final String frameworkMirror = getValueFromEnvOrArgs(
kFrameworkMirrorOption,
argumentResults,
platform.environment,
)!;
final String engineUpstream = getValueFromEnvOrArgs(
kEngineUpstreamOption,
argumentResults,
platform.environment,
)!;
final String engineMirror = getValueFromEnvOrArgs(
kEngineMirrorOption,
argumentResults,
platform.environment,
)!;
final String candidateBranch = getValueFromEnvOrArgs(
kCandidateOption,
argumentResults,
platform.environment,
)!;
final String releaseChannel = getValueFromEnvOrArgs(
kReleaseOption,
argumentResults,
platform.environment,
)!;
final List<String> frameworkCherrypickRevisions = getValuesFromEnvOrArgs(
kFrameworkCherrypicksOption,
argumentResults,
platform.environment,
);
final List<String> engineCherrypickRevisions = getValuesFromEnvOrArgs(
kEngineCherrypicksOption,
argumentResults,
platform.environment,
);
final String? dartRevision = getValueFromEnvOrArgs(
kDartRevisionOption,
argumentResults,
platform.environment,
allowNull: true,
);
final String incrementLetter = getValueFromEnvOrArgs(
kIncrementOption,
argumentResults,
platform.environment,
)!;
if (!releaseCandidateBranchRegex.hasMatch(candidateBranch)) {
throw ConductorException(
'Invalid release candidate branch "$candidateBranch". Text should '
'match the regex pattern /${releaseCandidateBranchRegex.pattern}/.',
);
}
final Int64 unixDate = Int64(DateTime.now().millisecondsSinceEpoch);
final pb.ConductorState state = pb.ConductorState();
state.releaseChannel = releaseChannel;
state.createdDate = unixDate;
state.lastUpdatedDate = unixDate;
state.incrementLevel = incrementLetter;
final EngineRepository engine = EngineRepository(
checkouts,
initialRef: candidateBranch,
upstreamRemote: Remote(
name: RemoteName.upstream,
url: engineUpstream,
),
mirrorRemote: Remote(
name: RemoteName.mirror,
url: engineMirror,
),
);
// Create a new branch so that we don't accidentally push to upstream
// candidateBranch.
final String workingBranchName = 'cherrypicks-$candidateBranch';
engine.newBranch(workingBranchName);
if (dartRevision != null && dartRevision.isNotEmpty) {
engine.updateDartRevision(dartRevision);
engine.commit('Update Dart SDK to $dartRevision', addFirst: true);
}
final List<pb.Cherrypick> engineCherrypicks = _sortCherrypicks(
repository: engine,
cherrypicks: engineCherrypickRevisions,
upstreamRef: EngineRepository.defaultBranch,
releaseRef: candidateBranch,
).map((String revision) => pb.Cherrypick(
trunkRevision: revision,
state: pb.CherrypickState.PENDING,
)).toList();
for (final pb.Cherrypick cherrypick in engineCherrypicks) {
final String revision = cherrypick.trunkRevision;
final bool success = engine.canCherryPick(revision);
stdio.printTrace(
'Attempt to cherrypick $revision ${success ? 'succeeded' : 'failed'}',
);
if (success) {
engine.cherryPick(revision);
cherrypick.state = pb.CherrypickState.COMPLETED;
} else {
cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
}
}
final String engineHead = engine.reverseParse('HEAD');
state.engine = pb.Repository(
candidateBranch: candidateBranch,
workingBranch: workingBranchName,
startingGitHead: engineHead,
currentGitHead: engineHead,
checkoutPath: engine.checkoutDirectory.path,
cherrypicks: engineCherrypicks,
dartRevision: dartRevision,
upstream: pb.Remote(name: 'upstream', url: engine.upstreamRemote.url),
mirror: pb.Remote(name: 'mirror', url: engine.mirrorRemote!.url),
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
initialRef: candidateBranch,
upstreamRemote: Remote(
name: RemoteName.upstream,
url: frameworkUpstream,
),
mirrorRemote: Remote(
name: RemoteName.mirror,
url: frameworkMirror,
),
);
framework.newBranch(workingBranchName);
final List<pb.Cherrypick> frameworkCherrypicks = _sortCherrypicks(
repository: framework,
cherrypicks: frameworkCherrypickRevisions,
upstreamRef: FrameworkRepository.defaultBranch,
releaseRef: candidateBranch,
).map((String revision) => pb.Cherrypick(
trunkRevision: revision,
state: pb.CherrypickState.PENDING,
)).toList();
for (final pb.Cherrypick cherrypick in frameworkCherrypicks) {
final String revision = cherrypick.trunkRevision;
final bool success = framework.canCherryPick(revision);
stdio.printTrace(
'Attempt to cherrypick $cherrypick ${success ? 'succeeded' : 'failed'}',
);
if (success) {
framework.cherryPick(revision);
cherrypick.state = pb.CherrypickState.COMPLETED;
} else {
cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
}
}
// Get framework version
final Version lastVersion = Version.fromString(framework.getFullTag(framework.upstreamRemote.name, candidateBranch, exact: false));
Version nextVersion;
if (incrementLetter == 'm') {
nextVersion = Version.fromCandidateBranch(candidateBranch);
} else {
nextVersion = Version.increment(lastVersion, incrementLetter);
}
state.releaseVersion = nextVersion.toString();
final String frameworkHead = framework.reverseParse('HEAD');
state.framework = pb.Repository(
candidateBranch: candidateBranch,
workingBranch: workingBranchName,
startingGitHead: frameworkHead,
currentGitHead: frameworkHead,
checkoutPath: framework.checkoutDirectory.path,
cherrypicks: frameworkCherrypicks,
upstream: pb.Remote(name: 'upstream', url: framework.upstreamRemote.url),
mirror: pb.Remote(name: 'mirror', url: framework.mirrorRemote!.url),
);
state.currentPhase = ReleasePhase.APPLY_ENGINE_CHERRYPICKS;
state.conductorVersion = conductorVersion;
stdio.printTrace('Writing state to file ${stateFile.path}...');
writeStateToFile(stateFile, state, stdio.logs);
stdio.printStatus(presentState(state));
}
// To minimize merge conflicts, sort the commits by rev-list order.
List<String> _sortCherrypicks({
required Repository repository,
required List<String> cherrypicks,
required String upstreamRef,
required String releaseRef,
}) {
if (cherrypicks.isEmpty) {
return cherrypicks;
}
// Input cherrypick hashes that failed to be parsed by git.
final List<String> unknownCherrypicks = <String>[];
// Full 40-char hashes parsed by git.
final List<String> validatedCherrypicks = <String>[];
// Final, validated, sorted list of cherrypicks to be applied.
final List<String> sortedCherrypicks = <String>[];
for (final String cherrypick in cherrypicks) {
try {
final String fullRef = repository.reverseParse(cherrypick);
validatedCherrypicks.add(fullRef);
} on GitException {
// Catch this exception so that we can validate the rest.
unknownCherrypicks.add(cherrypick);
}
}
final String branchPoint = repository.branchPoint(
'${repository.upstreamRemote.name}/$upstreamRef',
'${repository.upstreamRemote.name}/$releaseRef',
);
// `git rev-list` returns newest first, so reverse this list
final List<String> upstreamRevlist = repository.revList(<String>[
'--ancestry-path',
'$branchPoint..$upstreamRef',
]).reversed.toList();
stdio.printStatus('upstreamRevList:\n${upstreamRevlist.join('\n')}\n');
stdio.printStatus('validatedCherrypicks:\n${validatedCherrypicks.join('\n')}\n');
for (final String upstreamRevision in upstreamRevlist) {
if (validatedCherrypicks.contains(upstreamRevision)) {
validatedCherrypicks.remove(upstreamRevision);
sortedCherrypicks.add(upstreamRevision);
if (unknownCherrypicks.isEmpty && validatedCherrypicks.isEmpty) {
return sortedCherrypicks;
}
}
}
// We were given input cherrypicks that were not present in the upstream
// rev-list
stdio.printError(
'The following ${repository.name} cherrypicks were not found in the '
'upstream $upstreamRef branch:',
);
for (final String cp in <String>[...validatedCherrypicks, ...unknownCherrypicks]) {
stdio.printError('\t$cp');
}
throw ConductorException(
'${validatedCherrypicks.length + unknownCherrypicks.length} unknown cherrypicks provided!',
);
}
}