// 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 kEngineUpstreamOption = 'engine-upstream'; 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. /// /// This command has many required options which the user must provide /// via command line arguments (or optionally environment variables). /// /// This command is the one with the worst user experience (as the user has to /// carefully type out many different options into their terminal) and the one /// that would benefit the most from a GUI frontend. This command will /// optionally read its options from an environment variable to facilitate a workflow /// in which configuration is provided by editing a bash script that sets environment /// variables and then invokes the conductor tool. 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.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 = 'git@github.com:$githubUsername/flutter.git'; final String engineUpstream = getValueFromEnvOrArgs( kEngineUpstreamOption, argumentResults, platform.environment, )!; final String engineMirror = 'git@github.com:$githubUsername/engine.git'; final String candidateBranch = getValueFromEnvOrArgs( kCandidateOption, argumentResults, platform.environment, )!; final String releaseChannel = getValueFromEnvOrArgs( kReleaseOption, 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, engineMirror: engineMirror, engineUpstream: engineUpstream, conductorVersion: conductorVersion, 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.engineMirror, required this.engineUpstream, 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 String engineMirror; final String engineUpstream; 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 String engineHead = await engine.reverseParse('HEAD'); state.engine = pb.Repository( candidateBranch: candidateBranch, workingBranch: workingBranchName, startingGitHead: engineHead, currentGitHead: engineHead, checkoutPath: (await engine.checkoutDirectory).path, dartRevision: dartRevision, upstream: pb.Remote(name: 'upstream', url: engine.upstreamRemote.url), mirror: pb.Remote(name: 'mirror', url: engine.mirrorRemote!.url), ); await framework.newBranch(workingBranchName); // 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, 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; } }