diff --git a/dev/conductor/README.md b/dev/conductor/README.md index 4a1c6abbf82..8e9f5bd78c7 100644 --- a/dev/conductor/README.md +++ b/dev/conductor/README.md @@ -29,8 +29,8 @@ Releases are initialized with the `start` sub-command, like: conductor start \ --candidate-branch=flutter-2.2-candidate.10 \ --release-channel=beta \ - --framework-mirror=git@github.com:flutter-contributor/flutter.git \ - --engine-mirror=git@github.com:flutter-contributor/engine.git \ + --framework-mirror=git@github.com:username/flutter.git \ + --engine-mirror=git@github.com:username/engine.git \ --engine-cherrypicks=72114dafe28c8700f1d5d629c6ae9d34172ba395 \ --framework-cherrypicks=a3e66b396746f6581b2b7efd1b0d0f0074215128,d8d853436206e86f416236b930e97779b143a100 \ --dart-revision=4511eb2a779a612d9d6b2012123575013e0aef12 @@ -54,3 +54,51 @@ Upon successful completion of the release, the following command will remove the persistent state file: `conductor clean` + +## Steps + +Once the user has finished manual steps for each step, they proceed to the next +step with the command: + +`conductor next` + +### Apply Engine Cherrypicks + +The tool will attempt to auto-apply all engine cherrypicks. However, any +cherrypicks that result in a merge conflict will be reverted and it is left to +the user to manually cherry-pick them (with the command `git cherry-pick +$REVISION`) and resolve the merge conflict in their checkout. + +Once a PR is opened, the user must validate CI builds. If there are regressions +(or if the `licenses_check` fails, then +`//engine/ci/licenses_golden/licenses_third_party` must be updated to match the +output of the failing test), then the user must fix these tests in their local +checkout and push their changes again. + +### Codesign Engine Binaries + +The user must validate post-submit CI builds for their merged engine PR have +passed. A link to the web dashboard is available via `conductor status`. Once +the post-submit CI builds have all passed, the user must codesign engine +binaries for the **merged** engine commit. + +### Apply Framework Cherrypicks + +The tool will attempt to auto-apply all framework cherrypicks. However, any +cherrypicks that result in a merge conflict will be reverted and it is left to +the user to manually cherry-pick them (with the command `git cherry-pick +$REVISION`) and resolve the merge conflict in their checkout. + +### Publish Version + +This step will add a version git tag to the final Framework commit and push it +to the upstream repository. + +### Publish Channel + +This step will update the upstream release branch. + +### Verify Release + +For the final step, the user must manually verify that packaging builds have +finished successfully. diff --git a/dev/conductor/bin/conductor.dart b/dev/conductor/bin/conductor.dart index 4906ff9351a..26243efe56f 100644 --- a/dev/conductor/bin/conductor.dart +++ b/dev/conductor/bin/conductor.dart @@ -13,6 +13,7 @@ import 'package:conductor/candidates.dart'; import 'package:conductor/clean.dart'; import 'package:conductor/codesign.dart'; import 'package:conductor/globals.dart'; +import 'package:conductor/next.dart'; import 'package:conductor/repository.dart'; import 'package:conductor/roll_dev.dart'; import 'package:conductor/start.dart'; @@ -74,6 +75,9 @@ Future main(List args) async { checkouts: checkouts, flutterRoot: localFlutterRoot, ), + NextCommand( + checkouts: checkouts, + ), ].forEach(runner.addCommand); if (!assertsEnabled()) { diff --git a/dev/conductor/lib/codesign.dart b/dev/conductor/lib/codesign.dart index a69df0e4c64..3831ccc89ae 100644 --- a/dev/conductor/lib/codesign.dart +++ b/dev/conductor/lib/codesign.dart @@ -115,8 +115,7 @@ class CodesignCommand extends Command { revision = (processManager.runSync( ['git', 'rev-parse', 'HEAD'], workingDirectory: framework.checkoutDirectory.path, - ).stdout as String) - .trim(); + ).stdout as String).trim(); assert(revision.isNotEmpty); } @@ -291,7 +290,7 @@ class CodesignCommand extends Command { if (wrongEntitlementBinaries.isNotEmpty) { stdio.printError( 'Found ${wrongEntitlementBinaries.length} binaries with unexpected entitlements:'); - wrongEntitlementBinaries.forEach(print); + wrongEntitlementBinaries.forEach(stdio.printError); } if (unexpectedBinaries.isNotEmpty) { diff --git a/dev/conductor/lib/globals.dart b/dev/conductor/lib/globals.dart index a0e5c35826f..54a5d0f1ad1 100644 --- a/dev/conductor/lib/globals.dart +++ b/dev/conductor/lib/globals.dart @@ -20,6 +20,8 @@ const List kReleaseChannels = [ const String kReleaseDocumentationUrl = 'https://github.com/flutter/flutter/wiki/Flutter-Cherrypick-Process'; +const String kLuciPackagingConsoleLink = 'https://ci.chromium.org/p/flutter/g/packaging/console'; + final RegExp releaseCandidateBranchRegex = RegExp( r'flutter-(\d+)\.(\d+)-candidate\.(\d+)', ); diff --git a/dev/conductor/lib/next.dart b/dev/conductor/lib/next.dart new file mode 100644 index 00000000000..2b59f238efc --- /dev/null +++ b/dev/conductor/lib/next.dart @@ -0,0 +1,288 @@ +// 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. + +// @dart = 2.8 + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart' show File; +import 'package:meta/meta.dart' show required, 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 { + 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 finishedStates = [ + 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 List unappliedCherrypicks = []; + for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) { + if (!finishedStates.contains(cherrypick.state)) { + unappliedCherrypicks.add(cherrypick); + } + } + + if (state.engine.cherrypicks.isEmpty) { + stdio.printStatus('This release has no engine cherrypicks.'); + break; + } else if (unappliedCherrypicks.isEmpty) { + stdio.printStatus('All engine cherrypicks have been auto-applied by ' + 'the conductor.\n'); + if (autoAccept == false) { + final bool response = prompt( + 'Are you ready to push your changes to the repository ' + '${state.engine.mirror.url}?', + stdio, + ); + if (!response) { + stdio.printError('Aborting command.'); + writeStateToFile(stateFile, state, stdio.logs); + return; + } + } + } 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; + } + } + } + break; + case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES: + 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: + final List unappliedCherrypicks = []; + 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.'); + break; + } else if (unappliedCherrypicks.isEmpty) { + stdio.printStatus('All framework cherrypicks have been auto-applied by ' + 'the conductor.\n'); + if (autoAccept == false) { + final bool response = prompt( + 'Are you ready to push your changes to the repository ' + '${state.framework.mirror.url}?', + stdio, + ); + if (!response) { + stdio.printError('Aborting command.'); + writeStateToFile(stateFile, state, stdio.logs); + return; + } + } + } 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; + } + } + } + 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, + initialRef: state.framework.candidateBranch, + upstreamRemote: upstream, + previousCheckoutLocation: state.framework.checkoutPath, + ); + final String headRevision = framework.reverseParse('HEAD'); + if (autoAccept == false) { + final bool response = prompt( + 'Has CI passed for the framework PR?', + 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, + initialRef: 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 publish release ${state.releaseVersion} to ' + 'channel ${state.releaseChannel} at ${state.framework.upstream.url}?', + stdio, + ); + if (!response) { + stdio.printError('Aborting command.'); + writeStateToFile(stateFile, state, stdio.logs); + return; + } + } + framework.updateChannel( + headRevision, + state.framework.upstream.url, + state.releaseChannel, + 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.'); + break; + } + 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); +} diff --git a/dev/conductor/lib/proto/compile_proto.sh b/dev/conductor/lib/proto/compile_proto.sh index ff81e0fa101..b5e928d0040 100755 --- a/dev/conductor/lib/proto/compile_proto.sh +++ b/dev/conductor/lib/proto/compile_proto.sh @@ -32,7 +32,7 @@ for SOURCE_FILE in $(ls "$DIR"/*.pb*.dart); do "$DARTFMT" --overwrite --line-length 120 "$SOURCE_FILE" # Create temp copy with the license header prepended - cp license_header.txt "${SOURCE_FILE}.tmp" + cp "$DIR/license_header.txt" "${SOURCE_FILE}.tmp" # Add an extra newline required by analysis (analysis also prevents # license_header.txt from having the trailing newline) diff --git a/dev/conductor/lib/proto/conductor_state.pb.dart b/dev/conductor/lib/proto/conductor_state.pb.dart index 6bba337e047..6b4fe931b89 100644 --- a/dev/conductor/lib/proto/conductor_state.pb.dart +++ b/dev/conductor/lib/proto/conductor_state.pb.dart @@ -378,12 +378,13 @@ class ConductorState extends $pb.GeneratedMessage { protoName: 'lastUpdatedDate') ..pPS(8, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'logs') ..e( - 9, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'lastPhase', $pb.PbFieldType.OE, - protoName: 'lastPhase', - defaultOrMaker: ReleasePhase.INITIALIZE, + 9, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'currentPhase', $pb.PbFieldType.OE, + protoName: 'currentPhase', + defaultOrMaker: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, valueOf: ReleasePhase.valueOf, enumValues: ReleasePhase.values) - ..aOS(10, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'conductorVersion') + ..aOS(10, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'conductorVersion', + protoName: 'conductorVersion') ..hasRequiredFields = false; ConductorState._() : super(); @@ -395,7 +396,7 @@ class ConductorState extends $pb.GeneratedMessage { $fixnum.Int64 createdDate, $fixnum.Int64 lastUpdatedDate, $core.Iterable<$core.String> logs, - ReleasePhase lastPhase, + ReleasePhase currentPhase, $core.String conductorVersion, }) { final _result = create(); @@ -420,8 +421,8 @@ class ConductorState extends $pb.GeneratedMessage { if (logs != null) { _result.logs.addAll(logs); } - if (lastPhase != null) { - _result.lastPhase = lastPhase; + if (currentPhase != null) { + _result.currentPhase = currentPhase; } if (conductorVersion != null) { _result.conductorVersion = conductorVersion; @@ -531,16 +532,16 @@ class ConductorState extends $pb.GeneratedMessage { $core.List<$core.String> get logs => $_getList(6); @$pb.TagNumber(9) - ReleasePhase get lastPhase => $_getN(7); + ReleasePhase get currentPhase => $_getN(7); @$pb.TagNumber(9) - set lastPhase(ReleasePhase v) { + set currentPhase(ReleasePhase v) { setField(9, v); } @$pb.TagNumber(9) - $core.bool hasLastPhase() => $_has(7); + $core.bool hasCurrentPhase() => $_has(7); @$pb.TagNumber(9) - void clearLastPhase() => clearField(9); + void clearCurrentPhase() => clearField(9); @$pb.TagNumber(10) $core.String get conductorVersion => $_getSZ(8); diff --git a/dev/conductor/lib/proto/conductor_state.pbenum.dart b/dev/conductor/lib/proto/conductor_state.pbenum.dart index 57a84639f67..0e14fba55c7 100644 --- a/dev/conductor/lib/proto/conductor_state.pbenum.dart +++ b/dev/conductor/lib/proto/conductor_state.pbenum.dart @@ -14,29 +14,29 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; class ReleasePhase extends $pb.ProtobufEnum { - static const ReleasePhase INITIALIZE = - ReleasePhase._(0, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'INITIALIZE'); static const ReleasePhase APPLY_ENGINE_CHERRYPICKS = - ReleasePhase._(1, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'APPLY_ENGINE_CHERRYPICKS'); + ReleasePhase._(0, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'APPLY_ENGINE_CHERRYPICKS'); static const ReleasePhase CODESIGN_ENGINE_BINARIES = - ReleasePhase._(2, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'CODESIGN_ENGINE_BINARIES'); + ReleasePhase._(1, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'CODESIGN_ENGINE_BINARIES'); static const ReleasePhase APPLY_FRAMEWORK_CHERRYPICKS = ReleasePhase._( - 3, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'APPLY_FRAMEWORK_CHERRYPICKS'); + 2, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'APPLY_FRAMEWORK_CHERRYPICKS'); static const ReleasePhase PUBLISH_VERSION = - ReleasePhase._(4, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'PUBLISH_VERSION'); + ReleasePhase._(3, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'PUBLISH_VERSION'); static const ReleasePhase PUBLISH_CHANNEL = - ReleasePhase._(5, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'PUBLISH_CHANNEL'); + ReleasePhase._(4, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'PUBLISH_CHANNEL'); static const ReleasePhase VERIFY_RELEASE = - ReleasePhase._(6, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'VERIFY_RELEASE'); + ReleasePhase._(5, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'VERIFY_RELEASE'); + static const ReleasePhase RELEASE_COMPLETED = + ReleasePhase._(6, const $core.bool.fromEnvironment('protobuf.omit_enum_names') ? '' : 'RELEASE_COMPLETED'); static const $core.List values = [ - INITIALIZE, APPLY_ENGINE_CHERRYPICKS, CODESIGN_ENGINE_BINARIES, APPLY_FRAMEWORK_CHERRYPICKS, PUBLISH_VERSION, PUBLISH_CHANNEL, VERIFY_RELEASE, + RELEASE_COMPLETED, ]; static final $core.Map<$core.int, ReleasePhase> _byValue = $pb.ProtobufEnum.initByValue(values); diff --git a/dev/conductor/lib/proto/conductor_state.pbjson.dart b/dev/conductor/lib/proto/conductor_state.pbjson.dart index 33d22956581..6898536e039 100644 --- a/dev/conductor/lib/proto/conductor_state.pbjson.dart +++ b/dev/conductor/lib/proto/conductor_state.pbjson.dart @@ -17,19 +17,19 @@ import 'dart:typed_data' as $typed_data; const ReleasePhase$json = const { '1': 'ReleasePhase', '2': const [ - const {'1': 'INITIALIZE', '2': 0}, - const {'1': 'APPLY_ENGINE_CHERRYPICKS', '2': 1}, - const {'1': 'CODESIGN_ENGINE_BINARIES', '2': 2}, - const {'1': 'APPLY_FRAMEWORK_CHERRYPICKS', '2': 3}, - const {'1': 'PUBLISH_VERSION', '2': 4}, - const {'1': 'PUBLISH_CHANNEL', '2': 5}, - const {'1': 'VERIFY_RELEASE', '2': 6}, + const {'1': 'APPLY_ENGINE_CHERRYPICKS', '2': 0}, + const {'1': 'CODESIGN_ENGINE_BINARIES', '2': 1}, + const {'1': 'APPLY_FRAMEWORK_CHERRYPICKS', '2': 2}, + const {'1': 'PUBLISH_VERSION', '2': 3}, + const {'1': 'PUBLISH_CHANNEL', '2': 4}, + const {'1': 'VERIFY_RELEASE', '2': 5}, + const {'1': 'RELEASE_COMPLETED', '2': 6}, ], }; /// Descriptor for `ReleasePhase`. Decode as a `google.protobuf.EnumDescriptorProto`. final $typed_data.Uint8List releasePhaseDescriptor = $convert.base64Decode( - 'CgxSZWxlYXNlUGhhc2USDgoKSU5JVElBTElaRRAAEhwKGEFQUExZX0VOR0lORV9DSEVSUllQSUNLUxABEhwKGENPREVTSUdOX0VOR0lORV9CSU5BUklFUxACEh8KG0FQUExZX0ZSQU1FV09SS19DSEVSUllQSUNLUxADEhMKD1BVQkxJU0hfVkVSU0lPThAEEhMKD1BVQkxJU0hfQ0hBTk5FTBAFEhIKDlZFUklGWV9SRUxFQVNFEAY='); + 'CgxSZWxlYXNlUGhhc2USHAoYQVBQTFlfRU5HSU5FX0NIRVJSWVBJQ0tTEAASHAoYQ09ERVNJR05fRU5HSU5FX0JJTkFSSUVTEAESHwobQVBQTFlfRlJBTUVXT1JLX0NIRVJSWVBJQ0tTEAISEwoPUFVCTElTSF9WRVJTSU9OEAMSEwoPUFVCTElTSF9DSEFOTkVMEAQSEgoOVkVSSUZZX1JFTEVBU0UQBRIVChFSRUxFQVNFX0NPTVBMRVRFRBAG'); @$core.Deprecated('Use cherrypickStateDescriptor instead') const CherrypickState$json = const { '1': 'CherrypickState', @@ -98,11 +98,11 @@ const ConductorState$json = const { const {'1': 'createdDate', '3': 6, '4': 1, '5': 3, '10': 'createdDate'}, const {'1': 'lastUpdatedDate', '3': 7, '4': 1, '5': 3, '10': 'lastUpdatedDate'}, const {'1': 'logs', '3': 8, '4': 3, '5': 9, '10': 'logs'}, - const {'1': 'lastPhase', '3': 9, '4': 1, '5': 14, '6': '.conductor_state.ReleasePhase', '10': 'lastPhase'}, - const {'1': 'conductor_version', '3': 10, '4': 1, '5': 9, '10': 'conductorVersion'}, + const {'1': 'currentPhase', '3': 9, '4': 1, '5': 14, '6': '.conductor_state.ReleasePhase', '10': 'currentPhase'}, + const {'1': 'conductorVersion', '3': 10, '4': 1, '5': 9, '10': 'conductorVersion'}, ], }; /// Descriptor for `ConductorState`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List conductorStateDescriptor = $convert.base64Decode( - 'Cg5Db25kdWN0b3JTdGF0ZRImCg5yZWxlYXNlQ2hhbm5lbBgBIAEoCVIOcmVsZWFzZUNoYW5uZWwSJgoOcmVsZWFzZVZlcnNpb24YAiABKAlSDnJlbGVhc2VWZXJzaW9uEjMKBmVuZ2luZRgEIAEoCzIbLmNvbmR1Y3Rvcl9zdGF0ZS5SZXBvc2l0b3J5UgZlbmdpbmUSOQoJZnJhbWV3b3JrGAUgASgLMhsuY29uZHVjdG9yX3N0YXRlLlJlcG9zaXRvcnlSCWZyYW1ld29yaxIgCgtjcmVhdGVkRGF0ZRgGIAEoA1ILY3JlYXRlZERhdGUSKAoPbGFzdFVwZGF0ZWREYXRlGAcgASgDUg9sYXN0VXBkYXRlZERhdGUSEgoEbG9ncxgIIAMoCVIEbG9ncxI7CglsYXN0UGhhc2UYCSABKA4yHS5jb25kdWN0b3Jfc3RhdGUuUmVsZWFzZVBoYXNlUglsYXN0UGhhc2USKwoRY29uZHVjdG9yX3ZlcnNpb24YCiABKAlSEGNvbmR1Y3RvclZlcnNpb24='); + 'Cg5Db25kdWN0b3JTdGF0ZRImCg5yZWxlYXNlQ2hhbm5lbBgBIAEoCVIOcmVsZWFzZUNoYW5uZWwSJgoOcmVsZWFzZVZlcnNpb24YAiABKAlSDnJlbGVhc2VWZXJzaW9uEjMKBmVuZ2luZRgEIAEoCzIbLmNvbmR1Y3Rvcl9zdGF0ZS5SZXBvc2l0b3J5UgZlbmdpbmUSOQoJZnJhbWV3b3JrGAUgASgLMhsuY29uZHVjdG9yX3N0YXRlLlJlcG9zaXRvcnlSCWZyYW1ld29yaxIgCgtjcmVhdGVkRGF0ZRgGIAEoA1ILY3JlYXRlZERhdGUSKAoPbGFzdFVwZGF0ZWREYXRlGAcgASgDUg9sYXN0VXBkYXRlZERhdGUSEgoEbG9ncxgIIAMoCVIEbG9ncxJBCgxjdXJyZW50UGhhc2UYCSABKA4yHS5jb25kdWN0b3Jfc3RhdGUuUmVsZWFzZVBoYXNlUgxjdXJyZW50UGhhc2USKgoQY29uZHVjdG9yVmVyc2lvbhgKIAEoCVIQY29uZHVjdG9yVmVyc2lvbg=='); diff --git a/dev/conductor/lib/proto/conductor_state.proto b/dev/conductor/lib/proto/conductor_state.proto index 50b75942bce..721b5eb990a 100644 --- a/dev/conductor/lib/proto/conductor_state.proto +++ b/dev/conductor/lib/proto/conductor_state.proto @@ -10,21 +10,23 @@ message Remote { enum ReleasePhase { // Release was started with `conductor start` and repositories cloned. - INITIALIZE = 0; - APPLY_ENGINE_CHERRYPICKS = 1; - CODESIGN_ENGINE_BINARIES = 2; - APPLY_FRAMEWORK_CHERRYPICKS = 3; + APPLY_ENGINE_CHERRYPICKS = 0; + CODESIGN_ENGINE_BINARIES = 1; + APPLY_FRAMEWORK_CHERRYPICKS = 2; // Git tag applied to framework RC branch HEAD and pushed upstream. - PUBLISH_VERSION = 4; + PUBLISH_VERSION = 3; // RC branch HEAD pushed to upstream release branch. // // For example, flutter-1.2-candidate.3 -> upstream/beta - PUBLISH_CHANNEL = 5; + PUBLISH_CHANNEL = 4; // Package artifacts verified to exist on cloud storage. - VERIFY_RELEASE = 6; + VERIFY_RELEASE = 5; + + // There is no further work to be done. + RELEASE_COMPLETED = 6; } enum CherrypickState { @@ -98,9 +100,9 @@ message ConductorState { repeated string logs = 8; - // The last [ReleasePhase] that was successfully completed. - ReleasePhase lastPhase = 9; + // The current [ReleasePhase] that has yet to be completed. + ReleasePhase currentPhase = 9; // Commit hash of the Conductor tool. - string conductor_version = 10; + string conductorVersion = 10; } diff --git a/dev/conductor/lib/repository.dart b/dev/conductor/lib/repository.dart index 6ccf10e2140..64e0a3f7efd 100644 --- a/dev/conductor/lib/repository.dart +++ b/dev/conductor/lib/repository.dart @@ -25,7 +25,7 @@ class Remote { const Remote({ required RemoteName name, required this.url, - }) : _name = name; + }) : _name = name, assert(url != null), assert (url != ''); final RemoteName _name; @@ -47,7 +47,7 @@ class Remote { abstract class Repository { Repository({ required this.name, - required this.fetchRemote, + required this.upstreamRemote, required this.processManager, required this.stdio, required this.platform, @@ -55,20 +55,34 @@ abstract class Repository { required this.parentDirectory, this.initialRef, this.localUpstream = false, - this.useExistingCheckout = false, - this.pushRemote, + String? previousCheckoutLocation, + this.mirrorRemote, }) : git = Git(processManager), assert(localUpstream != null), - assert(useExistingCheckout != null); + assert(upstreamRemote.url.isNotEmpty) { + if (previousCheckoutLocation != null) { + _checkoutDirectory = fileSystem.directory(previousCheckoutLocation); + if (!_checkoutDirectory!.existsSync()) { + throw ConductorException('Provided previousCheckoutLocation $previousCheckoutLocation does not exist on disk!'); + } + if (initialRef != null) { + git.run( + ['checkout', '${upstreamRemote.name}/$initialRef'], + 'Checking out initialRef $initialRef', + workingDirectory: _checkoutDirectory!.path, + ); + } + } + } final String name; - final Remote fetchRemote; + final Remote upstreamRemote; - /// Remote to publish tags and commits to. + /// Remote for user's mirror. /// - /// This value can be null, in which case attempting to publish will lead to + /// This value can be null, in which case attempting to access it will lead to /// a [ConductorException]. - final Remote? pushRemote; + final Remote? mirrorRemote; /// The initial ref (branch or commit name) to check out. final String? initialRef; @@ -78,7 +92,6 @@ abstract class Repository { final Platform platform; final FileSystem fileSystem; final Directory parentDirectory; - final bool useExistingCheckout; /// If the repository will be used as an upstream for a test repo. final bool localUpstream; @@ -100,55 +113,53 @@ abstract class Repository { /// Ensure the repository is cloned to disk and initialized with proper state. void lazilyInitialize(Directory checkoutDirectory) { - if (!useExistingCheckout && checkoutDirectory.existsSync()) { + if (checkoutDirectory.existsSync()) { stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...'); checkoutDirectory.deleteSync(recursive: true); } - if (!checkoutDirectory.existsSync()) { - stdio.printTrace( - 'Cloning $name from ${fetchRemote.url} to ${checkoutDirectory.path}...', + stdio.printTrace( + 'Cloning $name from ${upstreamRemote.url} to ${checkoutDirectory.path}...', + ); + git.run( + [ + 'clone', + '--origin', + upstreamRemote.name, + '--', + upstreamRemote.url, + checkoutDirectory.path + ], + 'Cloning $name repo', + workingDirectory: parentDirectory.path, + ); + if (mirrorRemote != null) { + git.run( + ['remote', 'add', mirrorRemote!.name, mirrorRemote!.url], + 'Adding remote ${mirrorRemote!.url} as ${mirrorRemote!.name}', + workingDirectory: checkoutDirectory.path, ); git.run( - [ - 'clone', - '--origin', - fetchRemote.name, - '--', - fetchRemote.url, - checkoutDirectory.path - ], - 'Cloning $name repo', - workingDirectory: parentDirectory.path, + ['fetch', mirrorRemote!.name], + 'Fetching git remote ${mirrorRemote!.name}', + workingDirectory: checkoutDirectory.path, ); - if (pushRemote != null) { + } + if (localUpstream) { + // These branches must exist locally for the repo that depends on it + // to fetch and push to. + for (final String channel in kReleaseChannels) { git.run( - ['remote', 'add', pushRemote!.name, pushRemote!.url], - 'Adding remote ${pushRemote!.url} as ${pushRemote!.name}', + ['checkout', channel, '--'], + 'check out branch $channel locally', workingDirectory: checkoutDirectory.path, ); - git.run( - ['fetch', pushRemote!.name], - 'Fetching git remote ${pushRemote!.name}', - workingDirectory: checkoutDirectory.path, - ); - } - if (localUpstream) { - // These branches must exist locally for the repo that depends on it - // to fetch and push to. - for (final String channel in kReleaseChannels) { - git.run( - ['checkout', channel, '--'], - 'check out branch $channel locally', - workingDirectory: checkoutDirectory.path, - ); - } } } if (initialRef != null) { git.run( - ['checkout', '${fetchRemote.name}/$initialRef'], + ['checkout', '${upstreamRemote.name}/$initialRef'], 'Checking out initialRef $initialRef', workingDirectory: checkoutDirectory.path, ); @@ -217,13 +228,25 @@ abstract class Repository { ); } - /// Obtain the version tag of the previous dev release. - String getFullTag(String remoteName) { - const String glob = '*.*.*-*.*.pre'; + /// Obtain the version tag at the tip of a release branch. + String getFullTag( + String remoteName, + String branchName, { + bool exact = true, + }) { + // includes both stable (e.g. 1.2.3) and dev tags (e.g. 1.2.3-4.5.pre) + const String glob = '*.*.*'; // describe the latest dev release - final String ref = 'refs/remotes/$remoteName/dev'; + final String ref = 'refs/remotes/$remoteName/$branchName'; return git.getOutput( - ['describe', '--match', glob, '--exact-match', '--tags', ref], + [ + 'describe', + '--match', + glob, + if (exact) '--exact-match', + '--tags', + ref, + ], 'obtain last released version number', workingDirectory: checkoutDirectory.path, ); @@ -235,7 +258,7 @@ abstract class Repository { .getOutput( ['rev-list', ...args], 'rev-list with args ${args.join(' ')}', - workingDirectory: checkoutDirectory.path, + workingDirectory: checkoutDirectory.path ) .trim() .split('\n'); @@ -332,20 +355,6 @@ abstract class Repository { ); } - /// Tag [commit] and push the tag to the remote. - void tag(String commit, String tagName, String remote) { - git.run( - ['tag', tagName, commit], - 'tag the commit with the version label', - workingDirectory: checkoutDirectory.path, - ); - git.run( - ['push', remote, tagName], - 'publish the tag to the repo', - workingDirectory: checkoutDirectory.path, - ); - } - /// Push [commit] to the release channel [branch]. void updateChannel( String commit, @@ -419,16 +428,16 @@ class FrameworkRepository extends Repository { FrameworkRepository( this.checkouts, { String name = 'framework', - Remote fetchRemote = const Remote( + Remote upstreamRemote = const Remote( name: RemoteName.upstream, url: FrameworkRepository.defaultUpstream), bool localUpstream = false, - bool useExistingCheckout = false, + String? previousCheckoutLocation, String? initialRef, - Remote? pushRemote, + Remote? mirrorRemote, }) : super( name: name, - fetchRemote: fetchRemote, - pushRemote: pushRemote, + upstreamRemote: upstreamRemote, + mirrorRemote: mirrorRemote, initialRef: initialRef, fileSystem: checkouts.fileSystem, localUpstream: localUpstream, @@ -436,7 +445,7 @@ class FrameworkRepository extends Repository { platform: checkouts.platform, processManager: checkouts.processManager, stdio: checkouts.stdio, - useExistingCheckout: useExistingCheckout, + previousCheckoutLocation: previousCheckoutLocation, ); /// A [FrameworkRepository] with the host conductor's repo set as upstream. @@ -446,18 +455,18 @@ class FrameworkRepository extends Repository { factory FrameworkRepository.localRepoAsUpstream( Checkouts checkouts, { String name = 'framework', - bool useExistingCheckout = false, + String? previousCheckoutLocation, required String upstreamPath, }) { return FrameworkRepository( checkouts, name: name, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: 'file://$upstreamPath/', ), localUpstream: false, - useExistingCheckout: useExistingCheckout, + previousCheckoutLocation: previousCheckoutLocation, ); } @@ -473,6 +482,27 @@ class FrameworkRepository extends Repository { 'cache', ); + /// Tag [commit] and push the tag to the remote. + void tag(String commit, String tagName, String remote) { + assert(commit.isNotEmpty); + assert(tagName.isNotEmpty); + assert(remote.isNotEmpty); + stdio.printStatus('About to tag commit $commit as $tagName...'); + git.run( + ['tag', tagName, commit], + 'tag the commit with the version label', + workingDirectory: checkoutDirectory.path, + ); + stdio.printStatus('Tagging successful.'); + stdio.printStatus('About to push $tagName to remote $remote...'); + git.run( + ['push', remote, tagName], + 'publish the tag to the repo', + workingDirectory: checkoutDirectory.path, + ); + stdio.printStatus('Tag push successful.'); + } + @override Repository cloneRepository(String? cloneName) { assert(localUpstream); @@ -480,9 +510,8 @@ class FrameworkRepository extends Repository { return FrameworkRepository( checkouts, name: cloneName, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'), - useExistingCheckout: useExistingCheckout, ); } @@ -547,17 +576,15 @@ class HostFrameworkRepository extends FrameworkRepository { HostFrameworkRepository({ required Checkouts checkouts, String name = 'host-framework', - bool useExistingCheckout = false, required String upstreamPath, }) : super( checkouts, name: name, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: 'file://$upstreamPath/', ), localUpstream: false, - useExistingCheckout: useExistingCheckout, ) { _checkoutDirectory = checkouts.fileSystem.directory(upstreamPath); } @@ -613,15 +640,15 @@ class EngineRepository extends Repository { this.checkouts, { String name = 'engine', String initialRef = EngineRepository.defaultBranch, - Remote fetchRemote = const Remote( + Remote upstreamRemote = const Remote( name: RemoteName.upstream, url: EngineRepository.defaultUpstream), bool localUpstream = false, - bool useExistingCheckout = false, - Remote? pushRemote, + String? previousCheckoutLocation, + Remote? mirrorRemote, }) : super( name: name, - fetchRemote: fetchRemote, - pushRemote: pushRemote, + upstreamRemote: upstreamRemote, + mirrorRemote: mirrorRemote, initialRef: initialRef, fileSystem: checkouts.fileSystem, localUpstream: localUpstream, @@ -629,7 +656,7 @@ class EngineRepository extends Repository { platform: checkouts.platform, processManager: checkouts.processManager, stdio: checkouts.stdio, - useExistingCheckout: useExistingCheckout, + previousCheckoutLocation: previousCheckoutLocation, ); final Checkouts checkouts; @@ -669,9 +696,8 @@ class EngineRepository extends Repository { return EngineRepository( checkouts, name: cloneName, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: 'file://${checkoutDirectory.path}/'), - useExistingCheckout: useExistingCheckout, ); } } diff --git a/dev/conductor/lib/roll_dev.dart b/dev/conductor/lib/roll_dev.dart index 977d808748f..116eec3eb91 100644 --- a/dev/conductor/lib/roll_dev.dart +++ b/dev/conductor/lib/roll_dev.dart @@ -13,7 +13,7 @@ import './stdio.dart'; import './version.dart'; const String kIncrement = 'increment'; -const String kCommit = 'commit'; +const String kCandidateBranch = 'candidate-branch'; const String kRemoteName = 'remote'; const String kJustPrint = 'just-print'; const String kYes = 'yes'; @@ -40,9 +40,9 @@ class RollDevCommand extends Command { }, ); argParser.addOption( - kCommit, - help: 'Specifies which git commit to roll to the dev branch. Required.', - valueHelp: 'hash', + kCandidateBranch, + help: 'Specifies which git branch to roll to the dev branch. Required.', + valueHelp: 'branch', defaultsTo: null, // This option is required ); argParser.addFlag( @@ -112,17 +112,16 @@ bool rollDev({ }) { final String remoteName = argResults[kRemoteName] as String; final String level = argResults[kIncrement] as String; - final String commit = argResults[kCommit] as String; + final String candidateBranch = argResults[kCandidateBranch] as String; final bool justPrint = argResults[kJustPrint] as bool; final bool autoApprove = argResults[kYes] as bool; final bool force = argResults[kForce] as bool; final bool skipTagging = argResults[kSkipTagging] as bool; - if (level == null || commit == null) { - stdio.printStatus( - 'roll_dev.dart --increment=level --commit=hash • update the version tags ' + if (level == null || candidateBranch == null) { + throw Exception( + 'roll_dev.dart --$kIncrement=level --$kCandidateBranch=branch • update the version tags ' 'and roll a new dev build.\n$usage'); - return false; } final String remoteUrl = repository.remoteUrl(remoteName); @@ -136,14 +135,16 @@ bool rollDev({ repository.fetch(remoteName); // Verify [commit] is valid - repository.reverseParse(commit); + final String commit = repository.reverseParse(candidateBranch); stdio.printStatus('remoteName is $remoteName'); - final Version lastVersion = - Version.fromString(repository.getFullTag(remoteName)); + // Get the name of the last dev release + final Version lastVersion = Version.fromString( + repository.getFullTag(remoteName, 'dev'), + ); final Version version = - skipTagging ? lastVersion : Version.increment(lastVersion, level); + skipTagging ? lastVersion : Version.fromCandidateBranch(candidateBranch); final String tagName = version.toString(); if (repository.reverseParse(lastVersion.toString()).contains(commit.trim())) { diff --git a/dev/conductor/lib/start.dart b/dev/conductor/lib/start.dart index dfa7f83832e..c9957afd088 100644 --- a/dev/conductor/lib/start.dart +++ b/dev/conductor/lib/start.dart @@ -4,8 +4,6 @@ // @dart = 2.8 -import 'dart:convert' show jsonEncode; - import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:fixnum/fixnum.dart'; @@ -20,6 +18,7 @@ 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'; @@ -28,6 +27,7 @@ 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'; @@ -91,6 +91,18 @@ class StartCommand extends Command { 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: ['y', 'z', 'm', 'n'], + allowedHelp: { + '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 release.', + }, + ); final Git git = Git(processManager); conductorVersion = git.getOutput( ['rev-parse', 'HEAD'], @@ -183,6 +195,12 @@ class StartCommand extends Command { platform.environment, allowNull: true, ); + final String incrementLetter = getValueFromEnvOrArgs( + kIncrementOption, + argResults, + platform.environment, + ); + if (!releaseCandidateBranchRegex.hasMatch(candidateBranch)) { throw ConductorException( 'Invalid release candidate branch "$candidateBranch". Text should ' @@ -200,11 +218,11 @@ class StartCommand extends Command { final EngineRepository engine = EngineRepository( checkouts, initialRef: candidateBranch, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: engineUpstream, ), - pushRemote: Remote( + mirrorRemote: Remote( name: RemoteName.mirror, url: engineMirror, ), @@ -249,15 +267,17 @@ class StartCommand extends Command { 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, - fetchRemote: Remote( + upstreamRemote: Remote( name: RemoteName.upstream, url: frameworkUpstream, ), - pushRemote: Remote( + mirrorRemote: Remote( name: RemoteName.mirror, url: frameworkMirror, ), @@ -287,6 +307,16 @@ class StartCommand extends Command { } } + // 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, @@ -294,20 +324,17 @@ class StartCommand extends Command { 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.lastPhase = ReleasePhase.INITIALIZE; + state.currentPhase = ReleasePhase.APPLY_ENGINE_CHERRYPICKS; state.conductorVersion = conductorVersion; stdio.printTrace('Writing state to file ${stateFile.path}...'); - state.logs.addAll(stdio.logs); - - stateFile.writeAsStringSync( - jsonEncode(state.toProto3Json()), - flush: true, - ); + writeStateToFile(stateFile, state, stdio.logs); stdio.printStatus(presentState(state)); } @@ -340,8 +367,8 @@ class StartCommand extends Command { } final String branchPoint = repository.branchPoint( - '${repository.fetchRemote.name}/$upstreamRef', - '${repository.fetchRemote.name}/$releaseRef', + '${repository.upstreamRemote.name}/$upstreamRef', + '${repository.upstreamRemote.name}/$releaseRef', ); // `git rev-list` returns newest first, so reverse this list diff --git a/dev/conductor/lib/state.dart b/dev/conductor/lib/state.dart index 2bc915eccec..786b5f74e29 100644 --- a/dev/conductor/lib/state.dart +++ b/dev/conductor/lib/state.dart @@ -4,6 +4,9 @@ // @dart = 2.8 +import 'dart:convert' show jsonDecode, jsonEncode; + +import 'package:file/file.dart' show File; import 'package:platform/platform.dart'; import './globals.dart'; @@ -37,6 +40,7 @@ String presentState(pb.ConductorState state) { final StringBuffer buffer = StringBuffer(); buffer.writeln('Conductor version: ${state.conductorVersion}'); buffer.writeln('Release channel: ${state.releaseChannel}'); + buffer.writeln('Release version: ${state.releaseVersion}'); buffer.writeln(''); buffer.writeln( 'Release started at: ${DateTime.fromMillisecondsSinceEpoch(state.createdDate.toInt())}'); @@ -76,14 +80,14 @@ String presentState(pb.ConductorState state) { buffer.writeln('0 Framework cherrypicks.'); } buffer.writeln(''); - if (state.lastPhase == ReleasePhase.VERIFY_RELEASE) { + if (state.currentPhase == ReleasePhase.VERIFY_RELEASE) { buffer.writeln( '${state.releaseChannel} release ${state.releaseVersion} has been published and verified.\n', ); return buffer.toString(); } - buffer.writeln('The next step is:'); - buffer.writeln(presentPhases(state.lastPhase)); + buffer.writeln('The current phase is:'); + buffer.writeln(presentPhases(state.currentPhase)); buffer.writeln(phaseInstructions(state)); buffer.writeln(''); @@ -91,15 +95,14 @@ String presentState(pb.ConductorState state) { return buffer.toString(); } -String presentPhases(ReleasePhase lastPhase) { - final ReleasePhase nextPhase = getNextPhase(lastPhase); +String presentPhases(ReleasePhase currentPhase) { final StringBuffer buffer = StringBuffer(); bool phaseCompleted = true; for (final ReleasePhase phase in ReleasePhase.values) { - if (phase == nextPhase) { + if (phase == currentPhase) { // This phase will execute the next time `conductor next` is run. - buffer.writeln('> ${phase.name} (next)'); + buffer.writeln('> ${phase.name} (current)'); phaseCompleted = false; } else if (phaseCompleted) { // This phase was already completed. @@ -113,8 +116,8 @@ String presentPhases(ReleasePhase lastPhase) { } String phaseInstructions(pb.ConductorState state) { - switch (state.lastPhase) { - case ReleasePhase.INITIALIZE: + switch (state.currentPhase) { + case ReleasePhase.APPLY_ENGINE_CHERRYPICKS: if (state.engine.cherrypicks.isEmpty) { return [ 'There are no engine cherrypicks, so issue `conductor next` to continue', @@ -128,31 +131,33 @@ String phaseInstructions(pb.ConductorState state) { '\t${cherrypick.trunkRevision}', 'See $kReleaseDocumentationUrl for more information.', ].join('\n'); - case ReleasePhase.APPLY_ENGINE_CHERRYPICKS: + case ReleasePhase.CODESIGN_ENGINE_BINARIES: return [ 'You must verify Engine CI builds are successful and then codesign the', 'binaries at revision ${state.engine.currentGitHead}.', ].join('\n'); - case ReleasePhase.CODESIGN_ENGINE_BINARIES: + case ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: + final List outstandingCherrypicks = state.framework.cherrypicks.where( + (pb.Cherrypick cp) { + return cp.state == pb.CherrypickState.PENDING || cp.state == pb.CherrypickState.PENDING_WITH_CONFLICT; + }, + ).toList(); return [ 'You must now manually apply the following framework cherrypicks to the checkout', 'at ${state.framework.checkoutPath} in order:', - for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) + for (final pb.Cherrypick cherrypick in outstandingCherrypicks) '\t${cherrypick.trunkRevision}', ].join('\n'); - case ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: + case ReleasePhase.PUBLISH_VERSION: return [ 'You must verify Framework CI builds are successful.', 'See $kReleaseDocumentationUrl for more information.', ].join('\n'); - case ReleasePhase.PUBLISH_VERSION: - return 'Issue `conductor next` to publish your release to the release branch.'; case ReleasePhase.PUBLISH_CHANNEL: - return [ - 'Release archive packages must be verified on cloud storage. Issue', - '`conductor next` to check if they are ready.', - ].join('\n'); + return 'Issue `conductor next` to publish your release to the release branch.'; case ReleasePhase.VERIFY_RELEASE: + return 'Release archive packages must be verified on cloud storage.'; + case ReleasePhase.RELEASE_COMPLETED: return 'This release has been completed.'; } assert(false); @@ -161,12 +166,29 @@ String phaseInstructions(pb.ConductorState state) { /// Returns the next phase in the ReleasePhase enum. /// -/// Will throw a [ConductorException] if [ReleasePhase.RELEASE_VERIFIED] is +/// Will throw a [ConductorException] if [ReleasePhase.RELEASE_COMPLETED] is /// passed as an argument, as there is no next phase. ReleasePhase getNextPhase(ReleasePhase previousPhase) { assert(previousPhase != null); - if (previousPhase == ReleasePhase.VERIFY_RELEASE) { + if (previousPhase == ReleasePhase.RELEASE_COMPLETED) { throw ConductorException('There is no next ReleasePhase!'); } return ReleasePhase.valueOf(previousPhase.value + 1); } + +void writeStateToFile(File file, pb.ConductorState state, List logs) { + state.logs.addAll(logs); + file.writeAsStringSync( + jsonEncode(state.toProto3Json()), + flush: true, + ); +} + +pb.ConductorState readStateFromFile(File file) { + final pb.ConductorState state = pb.ConductorState(); + final String stateAsString = file.readAsStringSync(); + state.mergeFromProto3Json( + jsonDecode(stateAsString), + ); + return state; +} diff --git a/dev/conductor/lib/status.dart b/dev/conductor/lib/status.dart index 3fd454f22e4..0548ef58d37 100644 --- a/dev/conductor/lib/status.dart +++ b/dev/conductor/lib/status.dart @@ -4,8 +4,6 @@ // @dart = 2.8 -import 'dart:convert' show jsonDecode; - import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:meta/meta.dart'; @@ -59,8 +57,8 @@ class StatusCommand extends Command { 'No persistent state file found at ${argResults[kStateOption]}.'); return; } - final pb.ConductorState state = pb.ConductorState(); - state.mergeFromProto3Json(jsonDecode(stateFile.readAsStringSync())); + final pb.ConductorState state = readStateFromFile(stateFile); + stdio.printStatus(presentState(state)); if (argResults[kVerboseFlag] as bool) { stdio.printStatus('\nLogs:'); diff --git a/dev/conductor/lib/version.dart b/dev/conductor/lib/version.dart index 6a93beae44e..20e090ec9e0 100644 --- a/dev/conductor/lib/version.dart +++ b/dev/conductor/lib/version.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import './globals.dart' show ConductorException; + /// Possible string formats that `flutter --version` can return. enum VersionType { /// A stable flutter release. @@ -20,12 +22,18 @@ enum VersionType { /// /// The last number is the number of commits past the last tagged version. latest, + + /// A master channel flutter version from git describe. + /// + /// Example: '1.2.3-4.0.pre-10-gabc123'. + gitDescribe, } final Map versionPatterns = { VersionType.stable: RegExp(r'^(\d+)\.(\d+)\.(\d+)$'), VersionType.development: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$'), VersionType.latest: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre\.(\d+)$'), + VersionType.gitDescribe: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre-(\d+)-g[a-f0-9]+$'), }; class Version { @@ -54,6 +62,10 @@ class Version { assert(n != null); assert(commits != null); break; + case VersionType.gitDescribe: + throw ConductorException( + 'VersionType.gitDescribe not supported! Use VersionType.latest instead.', + ); } } @@ -115,6 +127,24 @@ class Version { type: VersionType.latest, ); } + match = versionPatterns[VersionType.gitDescribe]!.firstMatch(versionString); + if (match != null) { + // parse latest + final List parts = match.groups( + [1, 2, 3, 4, 5, 6], + ).map( + (String? s) => int.parse(s!), + ).toList(); + return Version( + x: parts[0], + y: parts[1], + z: parts[2], + m: parts[3], + n: parts[4], + commits: parts[5], + type: VersionType.latest, + ); + } throw Exception('${versionString.trim()} cannot be parsed'); } @@ -131,7 +161,7 @@ class Version { int? nextM = previousVersion.m; int? nextN = previousVersion.n; if (nextVersionType == null) { - if (previousVersion.type == VersionType.latest) { + if (previousVersion.type == VersionType.latest || previousVersion.type == VersionType.gitDescribe) { nextVersionType = VersionType.development; } else { nextVersionType = previousVersion.type; @@ -157,10 +187,7 @@ class Version { nextZ += 1; break; case 'm': - // Regular dev release. - assert(previousVersion.type == VersionType.development); - nextM = nextM! + 1; - nextN = 0; + assert(false, "Do not increment 'm' via Version.increment, use instead Version.fromCandidateBranch()"); break; case 'n': // Hotfix to internal roll. @@ -179,6 +206,31 @@ class Version { ); } + factory Version.fromCandidateBranch(String branchName) { + // Regular dev release. + final RegExp pattern = RegExp(r'flutter-(\d+)\.(\d+)-candidate.(\d+)'); + final RegExpMatch? match = pattern.firstMatch(branchName); + late final int x; + late final int y; + late final int m; + try { + x = int.parse(match!.group(1)!); + y = int.parse(match.group(2)!); + m = int.parse(match.group(3)!); + } on Exception { + throw ConductorException('branch named $branchName not recognized as a valid candidate branch'); + } + + return Version( + type: VersionType.development, + x: x, + y: y, + z: 0, + m: m, + n: 0, + ); + } + /// Major version. final int x; @@ -208,6 +260,8 @@ class Version { return '$x.$y.$z-$m.$n.pre'; case VersionType.latest: return '$x.$y.$z-$m.$n.pre.$commits'; + case VersionType.gitDescribe: + return '$x.$y.$z-$m.$n.pre.$commits'; } } } diff --git a/dev/conductor/test/codesign_test.dart b/dev/conductor/test/codesign_test.dart index 5a93c08b1f1..e0348874a60 100644 --- a/dev/conductor/test/codesign_test.dart +++ b/dev/conductor/test/codesign_test.dart @@ -4,7 +4,6 @@ import 'package:args/command_runner.dart'; import 'package:conductor/codesign.dart'; -import 'package:conductor/globals.dart'; import 'package:conductor/repository.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -16,7 +15,7 @@ import '../../../packages/flutter_tools/test/src/fake_process_manager.dart'; void main() { group('codesign command', () { const String flutterRoot = '/flutter'; - const String checkoutsParentDirectory = '$flutterRoot/dev/tools/'; + const String checkoutsParentDirectory = '$flutterRoot/dev/conductor/'; const String flutterCache = '${checkoutsParentDirectory}flutter_conductor_checkouts/framework/bin/cache'; const String flutterBin = @@ -387,18 +386,13 @@ void main() { stdout: 'application/x-mach-binary', ), ]); - try { - await runner.run([ - 'codesign', - '--$kVerify', - '--no-$kSignatures', - '--$kRevision', - revision, - ]); - } on ConductorException { - //print(stdio.error); - rethrow; - } + await runner.run([ + 'codesign', + '--$kVerify', + '--no-$kSignatures', + '--$kRevision', + revision, + ]); expect( processManager.hasRemainingExpectations, false, diff --git a/dev/conductor/test/common.dart b/dev/conductor/test/common.dart index 55bda80d4f1..a397dc3afe8 100644 --- a/dev/conductor/test/common.dart +++ b/dev/conductor/test/common.dart @@ -9,6 +9,16 @@ import 'package:test/test.dart'; export 'package:test/test.dart' hide isInstanceOf; +Matcher throwsAssertionWith(String messageSubString) { + return throwsA( + isA().having( + (AssertionError e) => e.toString(), + 'description', + contains(messageSubString), + ), + ); +} + Matcher throwsExceptionWith(String messageSubString) { return throwsA( isA().having( @@ -28,11 +38,12 @@ class TestStdio extends Stdio { String get error => logs.where((String log) => log.startsWith(r'[error] ')).join('\n'); String get stdout => logs.where((String log) { - return log.startsWith(r'[status] ') || log.startsWith(r'[trace] '); + return log.startsWith(r'[status] ') || log.startsWith(r'[trace] ') || log.startsWith(r'[write] '); }).join('\n'); final bool verbose; late final List _stdin; + List get stdin => _stdin; @override String readLineSync() { @@ -46,7 +57,7 @@ class TestStdio extends Stdio { class FakeArgResults implements ArgResults { FakeArgResults({ required String level, - required String commit, + required String candidateBranch, String remote = 'upstream', bool justPrint = false, bool autoApprove = true, // so we don't have to mock stdin @@ -55,7 +66,7 @@ class FakeArgResults implements ArgResults { bool skipTagging = false, }) : _parsedArgs = { 'increment': level, - 'commit': commit, + 'candidate-branch': candidateBranch, 'remote': remote, 'just-print': justPrint, 'yes': autoApprove, diff --git a/dev/conductor/test/next_test.dart b/dev/conductor/test/next_test.dart new file mode 100644 index 00000000000..6b66280c1d3 --- /dev/null +++ b/dev/conductor/test/next_test.dart @@ -0,0 +1,572 @@ +// 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. + +// @dart = 2.8 + +import 'package:args/command_runner.dart'; +import 'package:conductor/next.dart'; +import 'package:conductor/proto/conductor_state.pb.dart' as pb; +import 'package:conductor/proto/conductor_state.pbenum.dart' show ReleasePhase; +import 'package:conductor/repository.dart'; +import 'package:conductor/state.dart'; +import 'package:file/memory.dart'; +import 'package:meta/meta.dart'; +import 'package:platform/platform.dart'; + +import './common.dart'; +import '../../../packages/flutter_tools/test/src/fake_process_manager.dart'; + +void main() { + group('next command', () { + const String flutterRoot = '/flutter'; + const String checkoutsParentDirectory = '$flutterRoot/dev/tools/'; + const String candidateBranch = 'flutter-1.2-candidate.3'; + final String localPathSeparator = const LocalPlatform().pathSeparator; + final String localOperatingSystem = const LocalPlatform().pathSeparator; + const String revision1 = 'abc123'; + MemoryFileSystem fileSystem; + TestStdio stdio; + const String stateFile = '/state-file.json'; + + setUp(() { + stdio = TestStdio(); + fileSystem = MemoryFileSystem.test(); + }); + + CommandRunner createRunner({ + @required Checkouts checkouts, + }) { + final NextCommand command = NextCommand( + checkouts: checkouts, + ); + return CommandRunner('codesign-test', '')..addCommand(command); + } + + test('throws if no state file found', () async { + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + expect( + () async => runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]), + throwsExceptionWith('No persistent state file found at $stateFile'), + ); + }); + + test('does not prompt user and updates state.currentPhase from APPLY_ENGINE_CHERRYPICKS to CODESIGN_ENGINE_BINARIES if there are no engine cherrypicks', () async { + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error, isEmpty); + }); + + + test('updates state.lastPhase from APPLY_ENGINE_CHERRYPICKS to CODESIGN_ENGINE_BINARIES if user responds yes', () async { + const String remoteUrl = 'https://githost.com/org/repo.git'; + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + engine: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + mirror: pb.Remote(url: remoteUrl), + ), + currentPhase: ReleasePhase.APPLY_ENGINE_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains( + 'Are you ready to push your engine branch to the repository $remoteUrl? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error, isEmpty); + }); + + test('does not update state.currentPhase from CODESIGN_ENGINE_BINARIES if user responds no', () async { + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + engine: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + ), + currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.CODESIGN_ENGINE_BINARIES); + expect(stdio.error.contains('Aborting command.'), true); + }); + + test('updates state.currentPhase from CODESIGN_ENGINE_BINARIES to APPLY_FRAMEWORK_CHERRYPICKS if user responds yes', () async { + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.CODESIGN_ENGINE_BINARIES, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Has CI passed for the engine PR and binaries been codesigned? (y/n) ')); + expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); + }); + + test('does not prompt user and updates state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION if there are no framework cherrypicks', () async { + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, isNot(contains('Did you apply all framework cherrypicks? (y/n) '))); + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect(stdio.error, isEmpty); + }); + + test('does not update state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS if user responds no', () async { + const String remoteUrl = 'https://githost.com/org/repo.git'; + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + framework: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + mirror: pb.Remote(url: remoteUrl), + ), + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Are you ready to push your framework branch to the repository $remoteUrl? (y/n) ')); + expect(stdio.error, contains('Aborting command.')); + expect(finalState.currentPhase, ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS); + }); + + test('updates state.currentPhase from APPLY_FRAMEWORK_CHERRYPICKS to PUBLISH_VERSION if user responds yes', () async { + const String remoteUrl = 'https://githost.com/org/repo.git'; + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + framework: pb.Repository( + cherrypicks: [ + pb.Cherrypick( + trunkRevision: 'abc123', + state: pb.CherrypickState.PENDING, + ), + ], + mirror: pb.Remote(url: remoteUrl), + ), + currentPhase: ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect(stdio.stdout, contains('Are you ready to push your framework branch to the repository $remoteUrl? (y/n)')); + }); + + + test('does not update state.currentPhase from PUBLISH_VERSION if user responds no', () async { + const String remoteName = 'upstream'; + stdio.stdin.add('n'); + final FakeProcessManager processManager = FakeProcessManager.list( + [ + const FakeCommand( + command: ['git', 'checkout', '$remoteName/$candidateBranch'], + ), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + ], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.PUBLISH_VERSION, + framework: pb.Repository( + candidateBranch: candidateBranch, + upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), + ), + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(stdio.stdout, contains('Has CI passed for the framework PR?')); + expect(stdio.error, contains('Aborting command.')); + expect(finalState.currentPhase, ReleasePhase.PUBLISH_VERSION); + expect(finalState.logs, stdio.logs); + expect(processManager.hasRemainingExpectations, false); + }); + + test('updates state.currentPhase from PUBLISH_VERSION to PUBLISH_CHANNEL if user responds yes', () async { + const String remoteName = 'upstream'; + const String releaseVersion = '1.2.0-3.0.pre'; + stdio.stdin.add('y'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand( + command: ['git', 'checkout', '$remoteName/$candidateBranch'], + ), + const FakeCommand( + command: ['git', 'rev-parse', 'HEAD'], + stdout: revision1, + ), + const FakeCommand( + command: ['git', 'tag', releaseVersion, revision1], + ), + const FakeCommand( + command: ['git', 'push', remoteName, releaseVersion], + ), + ]); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.PUBLISH_VERSION, + framework: pb.Repository( + candidateBranch: candidateBranch, + upstream: pb.Remote(url: FrameworkRepository.defaultUpstream), + ), + releaseVersion: releaseVersion, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + await runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]); + + final pb.ConductorState finalState = readStateFromFile( + fileSystem.file(stateFile), + ); + + expect(finalState.currentPhase, ReleasePhase.PUBLISH_CHANNEL); + expect(stdio.stdout, contains('Has CI passed for the framework PR?')); + expect(finalState.logs, stdio.logs); + expect(processManager.hasRemainingExpectations, false); + }); + + test('throws exception if state.currentPhase is RELEASE_COMPLETED', () async { + final FakeProcessManager processManager = FakeProcessManager.list( + [], + ); + final FakePlatform platform = FakePlatform( + environment: { + 'HOME': ['path', 'to', 'home'].join(localPathSeparator), + }, + operatingSystem: localOperatingSystem, + pathSeparator: localPathSeparator, + ); + final pb.ConductorState state = pb.ConductorState( + currentPhase: ReleasePhase.RELEASE_COMPLETED, + ); + writeStateToFile( + fileSystem.file(stateFile), + state, + [], + ); + final Checkouts checkouts = Checkouts( + fileSystem: fileSystem, + parentDirectory: fileSystem.directory(checkoutsParentDirectory)..createSync(recursive: true), + platform: platform, + processManager: processManager, + stdio: stdio, + ); + final CommandRunner runner = createRunner(checkouts: checkouts); + expect( + () async => runner.run([ + 'next', + '--$kStateOption', + stateFile, + ]), + throwsExceptionWith('This release is finished.'), + ); + }); + }, onPlatform: { + 'windows': const Skip('Flutter Conductor only supported on macos/linux'), + }); +} diff --git a/dev/conductor/test/roll_dev_integration_test.dart b/dev/conductor/test/roll_dev_integration_test.dart deleted file mode 100644 index bcdd7ed8147..00000000000 --- a/dev/conductor/test/roll_dev_integration_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -// 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:conductor/repository.dart'; -import 'package:conductor/roll_dev.dart' show rollDev; -import 'package:conductor/version.dart'; -import 'package:file/file.dart'; -import 'package:file/local.dart'; -import 'package:platform/platform.dart'; -import 'package:process/process.dart'; - -import './common.dart'; - -void main() { - group('roll-dev', () { - late TestStdio stdio; - late Platform platform; - late ProcessManager processManager; - late FileSystem fileSystem; - const String usageString = 'Usage: flutter conductor.'; - - late Checkouts checkouts; - late FrameworkRepository frameworkUpstream; - late FrameworkRepository framework; - late Directory tempDir; - - setUp(() { - platform = const LocalPlatform(); - fileSystem = const LocalFileSystem(); - processManager = const LocalProcessManager(); - stdio = TestStdio(verbose: true); - tempDir = fileSystem.systemTempDirectory.createTempSync('flutter_conductor_checkouts.'); - checkouts = Checkouts( - fileSystem: fileSystem, - parentDirectory: tempDir, - platform: platform, - processManager: processManager, - stdio: stdio, - ); - - frameworkUpstream = FrameworkRepository(checkouts, localUpstream: true); - - // This repository has [frameworkUpstream] set as its push/pull remote. - framework = FrameworkRepository( - checkouts, - name: 'test-framework', - fetchRemote: Remote(name: RemoteName.upstream, url: 'file://${frameworkUpstream.checkoutDirectory.path}/'), - ); - }); - - test('increment m', () { - final Version initialVersion = framework.flutterVersion(); - - final String latestCommit = framework.authorEmptyCommit(); - - final FakeArgResults fakeArgResults = FakeArgResults( - level: 'm', - commit: latestCommit, - // Ensure this test passes after a dev release with hotfixes - force: true, - remote: 'upstream', - ); - - expect( - rollDev( - usage: usageString, - argResults: fakeArgResults, - stdio: stdio, - repository: framework, - ), - true, - ); - expect( - stdio.stdout, - contains(RegExp(r'Publishing Flutter \d+\.\d+\.\d+-\d+\.\d+\.pre \(')), - ); - - final Version finalVersion = framework.flutterVersion(); - expect( - initialVersion.toString() != finalVersion.toString(), - true, - reason: 'initialVersion = $initialVersion; finalVersion = $finalVersion', - ); - expect(finalVersion.n, 0); - expect(finalVersion.commits, null); - }); - }, onPlatform: { - 'windows': const Skip('Flutter Conductor only supported on macos/linux'), - }); -} diff --git a/dev/conductor/test/roll_dev_test.dart b/dev/conductor/test/roll_dev_test.dart index 6101e817e36..86e507b518e 100644 --- a/dev/conductor/test/roll_dev_test.dart +++ b/dev/conductor/test/roll_dev_test.dart @@ -20,7 +20,8 @@ void main() { const String commit = 'abcde012345'; const String remote = 'origin'; const String lastVersion = '1.2.0-0.0.pre'; - const String nextVersion = '1.2.0-1.0.pre'; + const String nextVersion = '1.2.0-2.0.pre'; + const String candidateBranch = 'flutter-1.2-candidate.2'; const String checkoutsParentDirectory = '/path/to/directory/'; FakeArgResults fakeArgResults; MemoryFileSystem fileSystem; @@ -45,37 +46,20 @@ void main() { repo = FrameworkRepository(checkouts); }); - test('returns false if level not provided', () { + test('throws Exception if level not provided', () { fakeArgResults = FakeArgResults( level: null, - commit: commit, + candidateBranch: candidateBranch, remote: remote, ); expect( - rollDev( + () => rollDev( argResults: fakeArgResults, repository: repo, stdio: stdio, usage: usage, ), - false, - ); - }); - - test('returns false if commit not provided', () { - fakeArgResults = FakeArgResults( - level: level, - commit: null, - remote: remote, - ); - expect( - rollDev( - argResults: fakeArgResults, - repository: repo, - stdio: stdio, - usage: usage, - ), - false, + throwsExceptionWith(usage), ); }); @@ -109,24 +93,18 @@ void main() { ]); fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, ); - Exception exception; - try { - rollDev( + expect( + () => rollDev( argResults: fakeArgResults, repository: repo, stdio: stdio, usage: usage, - ); - } on Exception catch (e) { - exception = e; - } - const String pattern = r'Your git repository is not clean. Try running ' - '"git clean -fd". Warning, this will delete files! Run with -n to find ' - 'out which ones.'; - expect(exception?.toString(), contains(pattern)); + ), + throwsExceptionWith('Your git repository is not clean.'), + ); }); test('does not reset or tag if --just-print is specified', () { @@ -165,13 +143,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -185,7 +163,7 @@ void main() { fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, justPrint: true, ); @@ -237,13 +215,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -268,7 +246,7 @@ void main() { fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, skipTagging: true, ); @@ -319,13 +297,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -339,7 +317,7 @@ void main() { ]); fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, justPrint: true, ); @@ -394,13 +372,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -421,7 +399,7 @@ void main() { fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, ); const String errorMessage = 'The previous dev tag $lastVersion is not a ' @@ -473,13 +451,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -517,7 +495,7 @@ void main() { ]); fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, skipTagging: true, ); @@ -568,13 +546,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -617,7 +595,7 @@ void main() { ]); fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, ); expect( @@ -667,13 +645,13 @@ void main() { const FakeCommand(command: [ 'git', 'rev-parse', - commit, + candidateBranch, ], stdout: commit), const FakeCommand(command: [ 'git', 'describe', '--match', - '*.*.*-*.*.pre', + '*.*.*', '--exact-match', '--tags', 'refs/remotes/$remote/dev', @@ -711,7 +689,7 @@ void main() { fakeArgResults = FakeArgResults( level: level, - commit: commit, + candidateBranch: candidateBranch, remote: remote, force: true, ); diff --git a/dev/conductor/test/start_test.dart b/dev/conductor/test/start_test.dart index 3639bfbb5c2..0279c8777c8 100644 --- a/dev/conductor/test/start_test.dart +++ b/dev/conductor/test/start_test.dart @@ -122,6 +122,8 @@ void main() { const String revision3 = '123abc'; const String previousDartRevision = '171876a4e6cf56ee6da1f97d203926bd7afda7ef'; const String nextDartRevision = 'f6c91128be6b77aef8351e1e3a9d07c85bc2e46e'; + const String previousVersion = '1.2.0-1.0.pre'; + const String nextVersion = '1.2.0-3.0.pre'; final Directory engine = fileSystem.directory(checkoutsParentDirectory) .childDirectory('flutter_conductor_checkouts') @@ -182,6 +184,7 @@ void main() { stdout: revision2, ), ]; + final List frameworkCommands = [ FakeCommand( command: [ @@ -219,11 +222,23 @@ void main() { 'cherrypicks-$candidateBranch', ], ), + const FakeCommand( + command: [ + 'git', + 'describe', + '--match', + '*.*.*', + '--tags', + 'refs/remotes/upstream/$candidateBranch', + ], + stdout: '$previousVersion-42-gabc123', + ), const FakeCommand( command: ['git', 'rev-parse', 'HEAD'], stdout: revision3, ), ]; + final CommandRunner runner = createRunner( commands: [ const FakeCommand( @@ -254,6 +269,8 @@ void main() { stateFilePath, '--$kDartRevisionOption', nextDartRevision, + '--$kIncrementOption', + 'm', ]); final File stateFile = fileSystem.file(stateFilePath); @@ -265,12 +282,13 @@ void main() { expect(state.isInitialized(), true); expect(state.releaseChannel, releaseChannel); + expect(state.releaseVersion, nextVersion); expect(state.engine.candidateBranch, candidateBranch); expect(state.engine.startingGitHead, revision2); expect(state.engine.dartRevision, nextDartRevision); expect(state.framework.candidateBranch, candidateBranch); expect(state.framework.startingGitHead, revision3); - expect(state.lastPhase, ReleasePhase.INITIALIZE); + expect(state.currentPhase, ReleasePhase.APPLY_ENGINE_CHERRYPICKS); expect(state.conductorVersion, revision); }); }, onPlatform: { diff --git a/dev/conductor/test/version_test.dart b/dev/conductor/test/version_test.dart index 78e34cb8729..cad4fad6bf9 100644 --- a/dev/conductor/test/version_test.dart +++ b/dev/conductor/test/version_test.dart @@ -44,29 +44,26 @@ void main() { }); test('successfully increments z', () { - const String level = 'm'; + const String level = 'z'; - Version version = Version.fromString('1.0.0-0.0.pre'); - expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre'); + Version version = Version.fromString('1.0.0'); + expect(Version.increment(version, level).toString(), '1.0.1'); - version = Version.fromString('10.20.0-40.50.pre'); - expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre'); + version = Version.fromString('10.20.0'); + expect(Version.increment(version, level).toString(), '10.20.1'); - version = Version.fromString('1.18.0-3.0.pre'); - expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre'); + version = Version.fromString('1.18.3'); + expect(Version.increment(version, level).toString(), '1.18.4'); }); - test('successfully increments m', () { + test('does not support incrementing m', () { const String level = 'm'; - Version version = Version.fromString('1.0.0-0.0.pre'); - expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre'); - - version = Version.fromString('10.20.0-40.50.pre'); - expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre'); - - version = Version.fromString('1.18.0-3.0.pre'); - expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre'); + final Version version = Version.fromString('1.0.0-0.0.pre'); + expect( + () => Version.increment(version, level).toString(), + throwsAssertionWith("Do not increment 'm' via Version.increment"), + ); }); test('successfully increments n', () {