// 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 'dart:convert' show JsonEncoder, jsonDecode; import 'package:file/file.dart' show File; import 'package:platform/platform.dart'; import './globals.dart' as globals; import './proto/conductor_state.pb.dart' as pb; import './proto/conductor_state.pbenum.dart' show ReleasePhase; const String kStateFileName = '.flutter_conductor_state.json'; const String betaPostReleaseMsg = """ 'Ensure the following post release steps are complete:', '\t 1. Post announcement to discord and press the publish button', '\t\t Discord: ${globals.discordReleaseChannel}', '\t 2. Post announcement flutter release hotline chat room', '\t\t Chatroom: ${globals.flutterReleaseHotline}', """; const String stablePostReleaseMsg = """ 'Ensure the following post release steps are complete:', '\t 1. Update hotfix to stable wiki following documentation best practices', '\t\t Wiki link: ${globals.hotfixToStableWiki}', '\t\t Best practices: ${globals.hotfixDocumentationBestPractices}', '\t 2. Post announcement to flutter-announce group', '\t\t Flutter Announce: ${globals.flutterAnnounceGroup}', '\t 3. Post announcement to discord and press the publish button', '\t\t Discord: ${globals.discordReleaseChannel}', '\t 4. Post announcement flutter release hotline chat room', '\t\t Chatroom: ${globals.flutterReleaseHotline}', """; // The helper functions in `state.dart` wrap the code-generated dart files in // `lib/src/proto/`. The most interesting of these functions is: // * `pb.ConductorState readStateFromFile(File)` - uses the code generated // `.mergeFromProto3Json()` method to deserialize the JSON content from the // config file into a Dart instance of the `ConductorState` class. // * `void writeStateFromFile(File, pb.ConductorState, List<String>)` // - similarly calls the `.toProto3Json()` method to serialize a // * `ConductorState` instance to a JSON string which is then written to disk. // `String phaseInstructions(pb.ConductorState state)` - returns instructions // for what the user is supposed to do next based on `state.currentPhase`. // * `String presentState(pb.ConductorState state)` - pretty print the state file. // This is a little easier to read than the raw JSON. String luciConsoleLink(String channel, String groupName) { assert( globals.kReleaseChannels.contains(channel), 'channel $channel not recognized', ); assert( <String>['flutter', 'engine', 'packaging'].contains(groupName), 'group named $groupName not recognized', ); final String consoleName = channel == 'master' ? groupName : '${channel}_$groupName'; if (groupName == 'packaging') { return 'https://luci-milo.appspot.com/p/dart-internal/g/flutter_packaging/console'; } return 'https://ci.chromium.org/p/flutter/g/$consoleName/console'; } String defaultStateFilePath(Platform platform) { final String? home = platform.environment['HOME']; if (home == null) { throw globals.ConductorException( r'Environment variable $HOME must be set!'); } return <String>[ home, kStateFileName, ].join(platform.pathSeparator); } 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())}'); buffer.writeln( 'Last updated at: ${DateTime.fromMillisecondsSinceEpoch(state.lastUpdatedDate.toInt())}'); buffer.writeln(); buffer.writeln('Engine Repo'); buffer.writeln('\tCandidate branch: ${state.engine.candidateBranch}'); buffer.writeln('\tStarting git HEAD: ${state.engine.startingGitHead}'); buffer.writeln('\tCurrent git HEAD: ${state.engine.currentGitHead}'); buffer.writeln('\tPath to checkout: ${state.engine.checkoutPath}'); buffer.writeln( '\tPost-submit LUCI dashboard: ${luciConsoleLink(state.releaseChannel, 'engine')}'); if (state.engine.cherrypicks.isNotEmpty) { buffer.writeln('${state.engine.cherrypicks.length} Engine Cherrypicks:'); for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) { buffer.writeln('\t${cherrypick.trunkRevision} - ${cherrypick.state}'); } } else { buffer.writeln('0 Engine cherrypicks.'); } if (state.engine.dartRevision.isNotEmpty) { buffer.writeln('New Dart SDK revision: ${state.engine.dartRevision}'); } buffer.writeln('Framework Repo'); buffer.writeln('\tCandidate branch: ${state.framework.candidateBranch}'); buffer.writeln('\tStarting git HEAD: ${state.framework.startingGitHead}'); buffer.writeln('\tCurrent git HEAD: ${state.framework.currentGitHead}'); buffer.writeln('\tPath to checkout: ${state.framework.checkoutPath}'); buffer.writeln( '\tPost-submit LUCI dashboard: ${luciConsoleLink(state.releaseChannel, 'flutter')}'); if (state.framework.cherrypicks.isNotEmpty) { buffer.writeln( '${state.framework.cherrypicks.length} Framework Cherrypicks:'); for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) { buffer.writeln('\t${cherrypick.trunkRevision} - ${cherrypick.state}'); } } else { buffer.writeln('0 Framework cherrypicks.'); } buffer.writeln(); 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 current phase is:'); buffer.writeln(presentPhases(state.currentPhase)); buffer.writeln(phaseInstructions(state)); buffer.writeln(); buffer.writeln('Issue `conductor next` when you are ready to proceed.'); return buffer.toString(); } String presentPhases(ReleasePhase currentPhase) { final StringBuffer buffer = StringBuffer(); bool phaseCompleted = true; for (final ReleasePhase phase in ReleasePhase.values) { if (phase == currentPhase) { // This phase will execute the next time `conductor next` is run. buffer.writeln('> ${phase.name} (current)'); phaseCompleted = false; } else if (phaseCompleted) { // This phase was already completed. buffer.writeln('✓ ${phase.name}'); } else { // This phase has not been completed yet. buffer.writeln(' ${phase.name}'); } } return buffer.toString(); } String phaseInstructions(pb.ConductorState state) { switch (state.currentPhase) { case ReleasePhase.APPLY_ENGINE_CHERRYPICKS: if (state.engine.cherrypicks.isEmpty) { return <String>[ 'There are no engine cherrypicks, so issue `conductor next` to continue', 'to the next step.', '\n', '******************************************************', '* Create a new entry in http://go/release-eng-retros *', '******************************************************', ].join('\n'); } return <String>[ 'You must now manually apply the following engine cherrypicks to the checkout', 'at ${state.engine.checkoutPath} in order:', for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) '\t${cherrypick.trunkRevision}', 'See ${globals.kReleaseDocumentationUrl} for more information.', ].join('\n'); case ReleasePhase.CODESIGN_ENGINE_BINARIES: if (!requiresEnginePR(state)) { return 'You must now codesign the engine binaries for commit ' '${state.engine.startingGitHead}.'; } // User's working branch was pushed to their mirror, but a PR needs to be // opened on GitHub. final String newPrLink = globals.getNewPrLink( userName: githubAccount(state.engine.mirror.url), repoName: 'engine', state: state, ); return <String>[ 'Your working branch ${state.engine.workingBranch} was pushed to your mirror.', 'You must now open a pull request at $newPrLink, verify pre-submit CI', 'builds on your engine pull request are successful, merge your pull request,', 'validate post-submit CI, and then codesign the binaries on the merge commit.', ].join('\n'); case ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS: final List<pb.Cherrypick> outstandingCherrypicks = state.framework.cherrypicks.where( (pb.Cherrypick cp) { return cp.state == pb.CherrypickState.PENDING || cp.state == pb.CherrypickState.PENDING_WITH_CONFLICT; }, ).toList(); if (outstandingCherrypicks.isNotEmpty) { return <String>[ 'You must now manually apply the following framework cherrypicks to the checkout', 'at ${state.framework.checkoutPath} in order:', for (final pb.Cherrypick cherrypick in outstandingCherrypicks) '\t${cherrypick.trunkRevision}', ].join('\n'); } return <String>[ 'Either all cherrypicks have been auto-applied or there were none.', ].join('\n'); case ReleasePhase.PUBLISH_VERSION: if (!requiresFrameworkPR(state)) { return 'Since there are no code changes in this release, no Framework ' 'PR is necessary.'; } final String newPrLink = globals.getNewPrLink( userName: githubAccount(state.framework.mirror.url), repoName: 'flutter', state: state, ); return <String>[ 'Your working branch ${state.framework.workingBranch} was pushed to your mirror.', 'You must now open a pull request at $newPrLink', 'verify pre-submit CI builds on your pull request are successful, merge your ', 'pull request, validate post-submit CI.', ].join('\n'); case ReleasePhase.PUBLISH_CHANNEL: 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: ${luciConsoleLink(state.releaseChannel, 'packaging')}'; case ReleasePhase.RELEASE_COMPLETED: if (state.releaseChannel == 'beta') { return <String>[ betaPostReleaseMsg, '-----------------------------------------------------------------------', 'This release has been completed.', ].join('\n'); } return <String>[ stablePostReleaseMsg, '-----------------------------------------------------------------------', 'This release has been completed.', ].join('\n'); } // For analyzer throw globals.ConductorException('Unimplemented phase ${state.currentPhase}'); } /// Regex pattern for git remote host URLs. /// /// First group = git host (currently must be github.com) /// Second group = account name /// Third group = repo name final RegExp githubRemotePattern = RegExp( r'^(git@github\.com:|https?:\/\/github\.com\/)([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)(\.git)?$'); /// Parses a Git remote URL and returns the account name. /// /// Uses [githubRemotePattern]. String githubAccount(String remoteUrl) { final String engineUrl = remoteUrl; final RegExpMatch? match = githubRemotePattern.firstMatch(engineUrl); if (match == null) { throw globals.ConductorException( 'Cannot determine the GitHub account from $engineUrl', ); } final String? accountName = match.group(2); if (accountName == null || accountName.isEmpty) { throw globals.ConductorException( 'Cannot determine the GitHub account from $match', ); } return accountName; } /// Returns the next phase in the ReleasePhase enum. /// /// Will throw a [ConductorException] if [ReleasePhase.RELEASE_COMPLETED] is /// passed as an argument, as there is no next phase. ReleasePhase getNextPhase(ReleasePhase currentPhase) { final ReleasePhase? nextPhase = ReleasePhase.valueOf(currentPhase.value + 1); if (nextPhase == null) { throw globals.ConductorException('There is no next ReleasePhase!'); } return nextPhase; } // Indent two spaces. const JsonEncoder _encoder = JsonEncoder.withIndent(' '); void writeStateToFile(File file, pb.ConductorState state, List<String> logs) { state.logs.addAll(logs); file.writeAsStringSync( _encoder.convert(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; } /// This release will require a new Engine PR. /// /// The logic is if there are engine cherrypicks that have not been abandoned OR /// there is a new Dart revision, then return true, else false. bool requiresEnginePR(pb.ConductorState state) { final bool hasRequiredCherrypicks = state.engine.cherrypicks.any( (pb.Cherrypick cp) => cp.state != pb.CherrypickState.ABANDONED, ); if (hasRequiredCherrypicks) { return true; } return state.engine.dartRevision.isNotEmpty; } /// This release will require a new Framework PR. /// /// The logic is if there was an Engine PR OR there are framework cherrypicks /// that have not been abandoned. bool requiresFrameworkPR(pb.ConductorState state) { if (requiresEnginePR(state)) { return true; } final bool hasRequiredCherrypicks = state.framework.cherrypicks .any((pb.Cherrypick cp) => cp.state != pb.CherrypickState.ABANDONED); if (hasRequiredCherrypicks) { return true; } return false; }