blob: 41cb9077d854de9c1963d1ceecf4947aa145990e [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/args.dart';
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:fixnum/fixnum.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
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;
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 kEngineMirrorOption = 'engine-mirror';
const String kReleaseOption = 'release-channel';
const String kStateOption = 'state-file';
const String kVersionOverrideOption = 'version-override';
const String kGithubUsernameOption = 'github-username';
/// Command to print the status of the current Flutter release.
class StartCommand extends Command<void> {
StartCommand({
required this.checkouts,
required this.conductorVersion,
}) : platform = checkouts.platform,
processManager = checkouts.processManager,
fileSystem = checkouts.fileSystem,
stdio = checkouts.stdio {
final String defaultPath = state_import.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: kBaseReleaseChannels,
);
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(
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.addFlag(
kForceFlag,
abbr: 'f',
help: 'Override all validations of the command line inputs.',
);
argParser.addOption(
kVersionOverrideOption,
help: 'Explicitly set the desired version. This should only be used if '
'the version computed by the tool is not correct.',
);
argParser.addOption(
kGithubUsernameOption,
help: 'Github username',
);
}
final Checkouts checkouts;
final String conductorVersion;
final FileSystem fileSystem;
final Platform platform;
final ProcessManager processManager;
final Stdio stdio;
@override
String get name => 'start';
@override
String get description => 'Initialize a new Flutter release.';
@override
Future<void> run() async {
final ArgResults argumentResults = argResults!;
if (!platform.isMacOS && !platform.isLinux) {
throw ConductorException(
'Error! This tool is only supported on macOS and Linux',
);
}
final String frameworkUpstream = getValueFromEnvOrArgs(
kFrameworkUpstreamOption,
argumentResults,
platform.environment,
)!;
final String githubUsername = getValueFromEnvOrArgs(
kGithubUsernameOption,
argumentResults,
platform.environment,
)!;
final String frameworkMirror =
'https://github.com/$githubUsername/flutter.git';
final String engineUpstream = getValueFromEnvOrArgs(
kEngineUpstreamOption,
argumentResults,
platform.environment,
)!;
final String engineMirror = 'https://github.com/$githubUsername/engine.git';
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 bool force = getBoolFromEnvOrArgs(
kForceFlag,
argumentResults,
platform.environment,
);
final File stateFile = checkouts.fileSystem.file(
getValueFromEnvOrArgs(
kStateOption, argumentResults, platform.environment),
);
final String? versionOverrideString = getValueFromEnvOrArgs(
kVersionOverrideOption,
argumentResults,
platform.environment,
allowNull: true,
);
Version? versionOverride;
if (versionOverrideString != null) {
versionOverride = Version.fromString(versionOverrideString);
}
final StartContext context = StartContext(
candidateBranch: candidateBranch,
checkouts: checkouts,
dartRevision: dartRevision,
engineCherrypickRevisions: engineCherrypickRevisions,
engineMirror: engineMirror,
engineUpstream: engineUpstream,
conductorVersion: conductorVersion,
frameworkCherrypickRevisions: frameworkCherrypickRevisions,
frameworkMirror: frameworkMirror,
frameworkUpstream: frameworkUpstream,
processManager: processManager,
releaseChannel: releaseChannel,
stateFile: stateFile,
force: force,
versionOverride: versionOverride,
githubUsername: githubUsername,
);
return context.run();
}
}
/// Context for starting a new release.
///
/// This is a frontend-agnostic implementation.
class StartContext extends Context {
StartContext({
required this.candidateBranch,
required this.dartRevision,
required this.engineCherrypickRevisions,
required this.engineMirror,
required this.engineUpstream,
required this.frameworkCherrypickRevisions,
required this.frameworkMirror,
required this.frameworkUpstream,
required this.conductorVersion,
required this.processManager,
required this.releaseChannel,
required this.githubUsername,
required super.checkouts,
required super.stateFile,
this.force = false,
this.versionOverride,
}) : git = Git(processManager),
engine = EngineRepository(
checkouts,
initialRef: 'upstream/$candidateBranch',
upstreamRemote: Remote(
name: RemoteName.upstream,
url: engineUpstream,
),
mirrorRemote: Remote(
name: RemoteName.mirror,
url: engineMirror,
),
),
framework = FrameworkRepository(
checkouts,
initialRef: 'upstream/$candidateBranch',
upstreamRemote: Remote(
name: RemoteName.upstream,
url: frameworkUpstream,
),
mirrorRemote: Remote(
name: RemoteName.mirror,
url: frameworkMirror,
),
);
final String candidateBranch;
final String? dartRevision;
final List<String> engineCherrypickRevisions;
final String engineMirror;
final String engineUpstream;
final List<String> frameworkCherrypickRevisions;
final String frameworkMirror;
final String frameworkUpstream;
final String conductorVersion;
final Git git;
final ProcessManager processManager;
final String releaseChannel;
final Version? versionOverride;
final String githubUsername;
/// If validations should be overridden.
final bool force;
final EngineRepository engine;
final FrameworkRepository framework;
/// Determine which part of the version to increment in the next release.
///
/// If [atBranchPoint] is true, then this is a [ReleaseType.BETA_INITIAL].
@visibleForTesting
ReleaseType computeReleaseType(Version lastVersion, bool atBranchPoint) {
if (atBranchPoint) {
return ReleaseType.BETA_INITIAL;
}
if (releaseChannel == 'stable') {
if (lastVersion.type == VersionType.stable) {
return ReleaseType.STABLE_HOTFIX;
} else {
return ReleaseType.STABLE_INITIAL;
}
}
return ReleaseType.BETA_HOTFIX;
}
Future<void> run() async {
if (stateFile.existsSync()) {
throw ConductorException(
'Error! A persistent state file already found at ${stateFile.path}.\n\n'
'Run `conductor clean` to cancel a previous release.');
}
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;
// Create a new branch so that we don't accidentally push to upstream
// candidateBranch.
final String workingBranchName = 'cherrypicks-$candidateBranch';
await engine.newBranch(workingBranchName);
if (dartRevision != null && dartRevision!.isNotEmpty) {
await engine.updateDartRevision(dartRevision!);
await engine.commit('Update Dart SDK to $dartRevision', addFirst: true);
}
final List<pb.Cherrypick> engineCherrypicks = (await _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 = await engine.canCherryPick(revision);
stdio.printTrace(
'Attempt to cherrypick $revision ${success ? 'succeeded' : 'failed'}',
);
if (success) {
await engine.cherryPick(revision);
cherrypick.state = pb.CherrypickState.COMPLETED;
} else {
cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
}
}
final String engineHead = await engine.reverseParse('HEAD');
state.engine = pb.Repository(
candidateBranch: candidateBranch,
workingBranch: workingBranchName,
startingGitHead: engineHead,
currentGitHead: engineHead,
checkoutPath: (await 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),
);
await framework.newBranch(workingBranchName);
final List<pb.Cherrypick> frameworkCherrypicks = (await _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 = await framework.canCherryPick(revision);
stdio.printTrace(
'Attempt to cherrypick $cherrypick ${success ? 'succeeded' : 'failed'}',
);
if (success) {
await framework.cherryPick(revision);
cherrypick.state = pb.CherrypickState.COMPLETED;
} else {
cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
}
}
// Get framework version
final Version lastVersion = Version.fromString(await framework.getFullTag(
framework.upstreamRemote.name,
candidateBranch,
exact: false,
));
final String frameworkHead = await framework.reverseParse('HEAD');
final String branchPoint = await framework.branchPoint(
'${framework.upstreamRemote.name}/$candidateBranch',
'${framework.upstreamRemote.name}/${FrameworkRepository.defaultBranch}',
);
final bool atBranchPoint = branchPoint == frameworkHead;
final ReleaseType releaseType =
computeReleaseType(lastVersion, atBranchPoint);
state.releaseType = releaseType;
try {
lastVersion.ensureValid(candidateBranch, releaseType);
} on ConductorException catch (e) {
// Let the user know, but resume execution
stdio.printError(e.message);
}
Version nextVersion;
if (versionOverride != null) {
nextVersion = versionOverride!;
} else {
nextVersion = calculateNextVersion(lastVersion, releaseType);
nextVersion = await ensureBranchPointTagged(
branchPoint: branchPoint,
requestedVersion: nextVersion,
framework: framework,
);
}
state.releaseVersion = nextVersion.toString();
state.framework = pb.Repository(
candidateBranch: candidateBranch,
workingBranch: workingBranchName,
startingGitHead: frameworkHead,
currentGitHead: frameworkHead,
checkoutPath: (await 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}...');
updateState(state, stdio.logs);
stdio.printStatus(state_import.presentState(state));
}
/// Determine this release's version number from the [lastVersion] and the [incrementLetter].
Version calculateNextVersion(Version lastVersion, ReleaseType releaseType) {
late final Version nextVersion;
switch (releaseType) {
case ReleaseType.STABLE_INITIAL:
nextVersion = Version(
x: lastVersion.x,
y: lastVersion.y,
z: 0,
type: VersionType.stable,
);
break;
case ReleaseType.STABLE_HOTFIX:
nextVersion = Version.increment(lastVersion, 'z');
break;
case ReleaseType.BETA_INITIAL:
nextVersion = Version.fromCandidateBranch(candidateBranch);
break;
case ReleaseType.BETA_HOTFIX:
nextVersion = Version.increment(lastVersion, 'n');
break;
}
return nextVersion;
}
/// Ensures the branch point [candidateBranch] and `master` has a version tag.
///
/// This is necessary for version reporting for users on the `master` channel
/// to be correct.
Future<Version> ensureBranchPointTagged({
required Version requestedVersion,
required String branchPoint,
required FrameworkRepository framework,
}) async {
if (await framework.isCommitTagged(branchPoint)) {
// The branch point is tagged, no work to be done
return requestedVersion;
}
if (requestedVersion.n != 0) {
stdio.printError(
'Tried to tag the branch point, however the target version is '
'$requestedVersion, which does not have n == 0!',
);
return requestedVersion;
}
final bool response = await prompt(
'About to tag the release candidate branch branchpoint of $branchPoint '
'as $requestedVersion and push it to ${framework.upstreamRemote.url}. '
'Is this correct?',
);
if (!response) {
throw ConductorException('Aborting command.');
}
stdio.printStatus(
'Applying the tag $requestedVersion at the branch point $branchPoint');
await framework.tag(
branchPoint,
requestedVersion.toString(),
frameworkUpstream,
);
final Version nextVersion = Version.increment(requestedVersion, 'n');
stdio.printStatus('The actual release will be version $nextVersion.');
return nextVersion;
}
// To minimize merge conflicts, sort the commits by rev-list order.
Future<List<String>> _sortCherrypicks({
required Repository repository,
required List<String> cherrypicks,
required String upstreamRef,
required String releaseRef,
}) async {
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 = await repository.reverseParse(cherrypick);
validatedCherrypicks.add(fullRef);
} on GitException {
// Catch this exception so that we can validate the rest.
unknownCherrypicks.add(cherrypick);
}
}
final String branchPoint = await repository.branchPoint(
'${repository.upstreamRemote.name}/$upstreamRef',
'${repository.upstreamRemote.name}/$releaseRef',
);
// `git rev-list` returns newest first, so reverse this list
final List<String> upstreamRevlist = (await 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!',
);
}
}