1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:args/command_runner.dart';
import 'package:file/file.dart' show File;
import 'package:meta/meta.dart' show visibleForTesting;
import 'context.dart';
import 'git.dart';
import 'globals.dart';
import 'proto/conductor_state.pb.dart' as pb;
import 'proto/conductor_state.pbenum.dart';
import 'repository.dart';
import 'state.dart' as state_import;
const String kStateOption = 'state-file';
const String kYesFlag = 'yes';
/// Command to proceed from one [pb.ReleasePhase] to the next.
class NextCommand extends Command<void> {
NextCommand({
required this.checkouts,
}) {
final String defaultPath = state_import.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
Future<void> run() async {
final File stateFile = checkouts.fileSystem.file(argResults![kStateOption]);
if (!stateFile.existsSync()) {
throw ConductorException(
'No persistent state file found at ${stateFile.path}.',
);
}
final pb.ConductorState state = state_import.readStateFromFile(stateFile);
await NextContext(
autoAccept: argResults![kYesFlag] as bool,
checkouts: checkouts,
force: argResults![kForceFlag] as bool,
stateFile: stateFile,
).run(state);
}
}
/// Utility class for proceeding to the next step in a release.
///
/// Any calls to functions that cause side effects are wrapped in methods to
/// allow overriding in unit tests.
class NextContext extends Context {
const NextContext({
required this.autoAccept,
required this.force,
required super.checkouts,
required super.stateFile,
});
final bool autoAccept;
final bool force;
Future<void> run(pb.ConductorState state) async {
const List<CherrypickState> finishedStates = <CherrypickState>[
CherrypickState.COMPLETED,
CherrypickState.ABANDONED,
];
switch (state.currentPhase) {
case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS:
final Remote upstream = Remote(
name: RemoteName.upstream,
url: state.engine.upstream.url,
);
final EngineRepository engine = EngineRepository(
checkouts,
initialRef: state.engine.workingBranch,
upstreamRemote: upstream,
previousCheckoutLocation: state.engine.checkoutPath,
);
if (!state_import.requiresEnginePR(state)) {
stdio.printStatus(
'This release has no engine cherrypicks. No Engine PR is necessary.\n',
);
break;
}
final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) {
if (!finishedStates.contains(cherrypick.state)) {
unappliedCherrypicks.add(cherrypick);
}
}
if (unappliedCherrypicks.isEmpty) {
stdio.printStatus('All engine cherrypicks have been auto-applied by the conductor.\n');
} else {
if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.');
} 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 = await prompt(
'Are you ready to push your engine branch to the repository '
'${state.engine.mirror.url}?',
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
await pushWorkingBranch(engine, state.engine);
break;
case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES:
stdio.printStatus(<String>[
'You must validate pre-submit CI for your engine PR, merge it, and codesign',
'binaries before proceeding.\n',
].join('\n'));
if (autoAccept == false) {
// TODO(fujino): actually test if binaries have been codesigned on macOS
final bool response = await prompt(
'Has CI passed for the engine PR and binaries been codesigned?',
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
break;
case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS:
if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) {
stdio.printStatus(
'This release has no engine cherrypicks, and thus the engine.version file\n'
'in the framework does not need to be updated.',
);
if (state.framework.cherrypicks.isEmpty) {
stdio.printStatus(
'This release also has no framework cherrypicks. Therefore, a framework\n'
'pull request is not required.',
);
break;
}
}
final Remote engineUpstreamRemote = Remote(
name: RemoteName.upstream,
url: state.engine.upstream.url,
);
final EngineRepository engine = EngineRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${engineUpstreamRemote.name}/${state.engine.candidateBranch}',
upstreamRemote: engineUpstreamRemote,
previousCheckoutLocation: state.engine.checkoutPath,
);
final String engineRevision = await engine.reverseParse('HEAD');
final Remote upstream = Remote(
name: RemoteName.upstream,
url: state.framework.upstream.url,
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
initialRef: state.framework.workingBranch,
upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath,
);
stdio.printStatus('Writing candidate branch...');
bool needsCommit = await framework.updateCandidateBranchVersion(state.framework.candidateBranch);
if (needsCommit) {
final String revision = await framework.commit(
'Create candidate branch version ${state.framework.candidateBranch} for ${state.releaseChannel}',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.framework.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...');
needsCommit = await framework.updateEngineRevision(engineRevision);
if (needsCommit) {
final String revision = await framework.commit(
'Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.framework.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
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. However, a framework PR is still\n'
'required to roll engine cherrypicks.',
);
} else if (unappliedCherrypicks.isEmpty) {
stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.');
} else {
if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.',);
}
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 = await prompt(
'Are you ready to push your framework branch to the repository '
'${state.framework.mirror.url}?',
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
await pushWorkingBranch(framework, state.framework);
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 frameworkUpstream = Remote(
name: RemoteName.upstream,
url: state.framework.upstream.url,
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${frameworkUpstream.name}/${state.framework.candidateBranch}',
upstreamRemote: frameworkUpstream,
previousCheckoutLocation: state.framework.checkoutPath,
);
final String frameworkHead = await framework.reverseParse('HEAD');
final Remote engineUpstream = Remote(
name: RemoteName.upstream,
url: state.engine.upstream.url,
);
final EngineRepository engine = EngineRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${engineUpstream.name}/${state.engine.candidateBranch}',
upstreamRemote: engineUpstream,
previousCheckoutLocation: state.engine.checkoutPath,
);
final String engineHead = await engine.reverseParse('HEAD');
if (autoAccept == false) {
final bool response = await prompt(
'Are you ready to tag commit $frameworkHead as ${state.releaseVersion}\n'
'and push to remote ${state.framework.upstream.url}?',
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
await framework.tag(frameworkHead, state.releaseVersion, frameworkUpstream.name);
await engine.tag(engineHead, state.releaseVersion, engineUpstream.name);
break;
case pb.ReleasePhase.PUBLISH_CHANNEL:
final Remote upstream = Remote(
name: RemoteName.upstream,
url: state.framework.upstream.url,
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${upstream.name}/${state.framework.candidateBranch}',
upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath,
);
final String headRevision = await framework.reverseParse('HEAD');
if (autoAccept == false) {
// dryRun: true means print out git command
await framework.pushRef(
fromRef: headRevision,
toRef: state.releaseChannel,
remote: state.framework.upstream.url,
force: force,
dryRun: true,
);
final bool response = await prompt(
'Are you ready to publish version ${state.releaseVersion} to ${state.releaseChannel}?',
);
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
await framework.pushRef(
fromRef: headRevision,
toRef: state.releaseChannel,
remote: state.framework.upstream.url,
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 = await prompt(
'Have all packaging builds finished successfully and post release announcements been completed?');
if (!response) {
stdio.printError('Aborting command.');
updateState(state, stdio.logs);
return;
}
}
break;
case pb.ReleasePhase.RELEASE_COMPLETED:
throw ConductorException('This release is finished.');
}
final ReleasePhase nextPhase = state_import.getNextPhase(state.currentPhase);
stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n');
state.currentPhase = nextPhase;
stdio.printStatus(state_import.phaseInstructions(state));
updateState(state, stdio.logs);
}
/// Push the working branch to the user's mirror.
///
/// [repository] represents the actual Git repository on disk, and is used to
/// call `git push`, while [pbRepository] represents the user-specified
/// configuration for the repository, and is used to read the name of the
/// working branch and the mirror's remote name.
///
/// May throw either a [ConductorException] if the user already has a branch
/// of the same name on their mirror, or a [GitException] for any other
/// failures from the underlying git process call.
@visibleForTesting
Future<void> pushWorkingBranch(Repository repository, pb.Repository pbRepository) async {
try {
await repository.pushRef(
fromRef: 'HEAD',
// Explicitly create new branch
toRef: 'refs/heads/${pbRepository.workingBranch}',
remote: pbRepository.mirror.name,
force: force,
);
} on GitException catch (exception) {
if (exception.type == GitExceptionType.PushRejected && force == false) {
throw ConductorException(
'Push failed because the working branch named '
'${pbRepository.workingBranch} already exists on your mirror. '
'Re-run this command with --force to overwrite the remote branch.\n'
'${exception.message}',
);
}
rethrow;
}
}
}