Add --force to `roll_dev.dart` (#56501)
diff --git a/dev/tools/lib/roll_dev.dart b/dev/tools/lib/roll_dev.dart
index cc766b3..12ead14 100644
--- a/dev/tools/lib/roll_dev.dart
+++ b/dev/tools/lib/roll_dev.dart
@@ -10,6 +10,7 @@
import 'dart:io';
import 'package:args/args.dart';
+import 'package:meta/meta.dart';
const String kIncrement = 'increment';
const String kX = 'x';
@@ -20,12 +21,119 @@
const String kJustPrint = 'just-print';
const String kYes = 'yes';
const String kHelp = 'help';
+const String kForce = 'force';
const String kUpstreamRemote = 'git@github.com:flutter/flutter.git';
void main(List<String> args) {
final ArgParser argParser = ArgParser(allowTrailingOptions: false);
+ ArgResults argResults;
+ try {
+ argResults = parseArguments(argParser, args);
+ } on ArgParserException catch (error) {
+ print(error.message);
+ print(argParser.usage);
+ exit(1);
+ }
+
+ try {
+ run(
+ usage: argParser.usage,
+ argResults: argResults,
+ git: const Git(),
+ );
+ } on Exception catch (e) {
+ print(e.toString());
+ exit(1);
+ }
+}
+
+/// Main script execution.
+///
+/// Returns true if publishing was successful, else false.
+bool run({
+ @required String usage,
+ @required ArgResults argResults,
+ @required Git git,
+}) {
+ final String level = argResults[kIncrement] as String;
+ final String commit = argResults[kCommit] as String;
+ final String origin = argResults[kOrigin] as String;
+ final bool justPrint = argResults[kJustPrint] as bool;
+ final bool autoApprove = argResults[kYes] as bool;
+ final bool help = argResults[kHelp] as bool;
+ final bool force = argResults[kForce] as bool;
+
+ if (help || level == null || commit == null) {
+ print(
+ 'roll_dev.dart --increment=level --commit=hash • update the version tags '
+ 'and roll a new dev build.\n$usage'
+ );
+ return false;
+ }
+
+ final String remote = git.getOutput(
+ 'remote get-url $origin',
+ 'check whether this is a flutter checkout',
+ );
+ if (remote != kUpstreamRemote) {
+ throw Exception(
+ 'The current directory is not a Flutter repository checkout with a '
+ 'correctly configured upstream remote.\nFor more details see: '
+ 'https://github.com/flutter/flutter/wiki/Release-process'
+ );
+ }
+
+ if (git.getOutput('status --porcelain', 'check status of your local checkout') != '') {
+ throw Exception(
+ 'Your git repository is not clean. Try running "git clean -fd". Warning, '
+ 'this will delete files! Run with -n to find out which ones.'
+ );
+ }
+
+ // TODO(fujino): move this after `justPrint`
+ git.run('fetch $origin', 'fetch $origin');
+ git.run('reset $commit --hard', 'reset to the release commit');
+
+ String version = getFullTag(git);
+
+ version = incrementLevel(version, level);
+
+ if (justPrint) {
+ print(version);
+ return false;
+ }
+
+ final String hash = git.getOutput('rev-parse HEAD', 'Get git hash for $commit');
+
+ git.run('tag $version', 'tag the commit with the version label');
+
+ // PROMPT
+
+ if (autoApprove) {
+ print('Publishing Flutter $version (${hash.substring(0, 10)}) to the "dev" channel.');
+ } else {
+ print('Your tree is ready to publish Flutter $version (${hash.substring(0, 10)}) '
+ 'to the "dev" channel.');
+ stdout.write('Are you? [yes/no] ');
+ if (stdin.readLineSync() != 'yes') {
+ git.run('tag -d $version', 'remove the tag you did not want to publish');
+ print('The dev roll has been aborted.');
+ return false;
+ }
+ }
+
+ git.run('push $origin $version', 'publish the version');
+ git.run(
+ 'push ${force ? "--force " : ""}$origin HEAD:dev',
+ 'land the new version on the "dev" branch',
+ );
+ print('Flutter version $version has been rolled to the "dev" channel!');
+ return true;
+}
+
+ArgResults parseArguments(ArgParser argParser, List<String> args) {
argParser.addOption(
kIncrement,
help: 'Specifies which part of the x.y.z version number to increment. Required.',
@@ -50,6 +158,12 @@
defaultsTo: 'upstream',
);
argParser.addFlag(
+ kForce,
+ abbr: 'f',
+ help: 'Force push. Necessary when the previous release had cherry-picks.',
+ negatable: false,
+ );
+ argParser.addFlag(
kJustPrint,
negatable: false,
help:
@@ -59,59 +173,92 @@
argParser.addFlag(kYes, negatable: false, abbr: 'y', help: 'Skip the confirmation prompt.');
argParser.addFlag(kHelp, negatable: false, help: 'Show this help message.', hide: true);
- ArgResults argResults;
- try {
- argResults = argParser.parse(args);
- } on ArgParserException catch (error) {
- print(error.message);
- print(argParser.usage);
+ return argParser.parse(args);
+}
+
+/// Obtain the version tag of the previous dev release.
+String getFullTag(Git git) {
+ const String glob = '*.*.*-*.*.pre';
+ // describe the latest dev release
+ const String ref = 'refs/heads/dev';
+ return git.getOutput(
+ 'describe --match $glob --exact-match --tags $ref',
+ 'obtain last released version number',
+ );
+}
+
+Match parseFullTag(String version) {
+ // of the form: x.y.z-m.n.pre
+ final RegExp versionPattern = RegExp(
+ r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$');
+ return versionPattern.matchAsPrefix(version);
+}
+
+String getVersionFromParts(List<int> parts) {
+ // where parts correspond to [x, y, z, m, n] from tag
+ assert(parts.length == 5);
+ final StringBuffer buf = StringBuffer()
+ // take x, y, and z
+ ..write(parts.take(3).join('.'))
+ ..write('-')
+ // skip x, y, and z, take m and n
+ ..write(parts.skip(3).take(2).join('.'))
+ ..write('.pre');
+ // return a string that looks like: '1.2.3-4.5.pre'
+ return buf.toString();
+}
+
+class Git {
+ const Git();
+
+ String getOutput(String command, String explanation) {
+ final ProcessResult result = _run(command);
+ if ((result.stderr as String).isEmpty && result.exitCode == 0)
+ return (result.stdout as String).trim();
+ _reportFailureAndExit(result, explanation);
+ return null; // for the analyzer's sake
+ }
+
+ void run(String command, String explanation) {
+ final ProcessResult result = _run(command);
+ if (result.exitCode != 0)
+ _reportFailureAndExit(result, explanation);
+ }
+
+ ProcessResult _run(String command) {
+ return Process.runSync('git', command.split(' '));
+ }
+
+ void _reportFailureAndExit(ProcessResult result, String explanation) {
+ if (result.exitCode != 0) {
+ print('Failed to $explanation. Git exited with error code ${result.exitCode}.');
+ } else {
+ print('Failed to $explanation.');
+ }
+ if ((result.stdout as String).isNotEmpty)
+ print('stdout from git:\n${result.stdout}\n');
+ if ((result.stderr as String).isNotEmpty)
+ print('stderr from git:\n${result.stderr}\n');
exit(1);
}
+}
- final String level = argResults[kIncrement] as String;
- final String commit = argResults[kCommit] as String;
- final String origin = argResults[kOrigin] as String;
- final bool justPrint = argResults[kJustPrint] as bool;
- final bool autoApprove = argResults[kYes] as bool;
- final bool help = argResults[kHelp] as bool;
-
- if (help || level == null || commit == null) {
- print('roll_dev.dart --increment=level --commit=hash • update the version tags and roll a new dev build.\n');
- print(argParser.usage);
- exit(0);
- }
-
- if (getGitOutput('remote get-url $origin', 'check whether this is a flutter checkout') != kUpstreamRemote) {
- print('The current directory is not a Flutter repository checkout with a correctly configured upstream remote.');
- print('For more details see: https://github.com/flutter/flutter/wiki/Release-process');
- exit(1);
- }
-
- if (getGitOutput('status --porcelain', 'check status of your local checkout') != '') {
- print('Your git repository is not clean. Try running "git clean -fd". Warning, this ');
- print('will delete files! Run with -n to find out which ones.');
- exit(1);
- }
-
- runGit('fetch $origin', 'fetch $origin');
- runGit('reset $commit --hard', 'reset to the release commit');
-
- String version = getFullTag();
+/// Return a copy of the [version] with [level] incremented by one.
+String incrementLevel(String version, String level) {
final Match match = parseFullTag(version);
if (match == null) {
- print('Could not determine the version for this build.');
- if (version.isNotEmpty)
- print('Git reported the latest version as "$version", which does not fit the expected pattern.');
- exit(1);
+ String errorMessage;
+ if (version.isEmpty) {
+ errorMessage = 'Could not determine the version for this build.';
+ } else {
+ errorMessage = 'Git reported the latest version as "$version", which '
+ 'does not fit the expected pattern.';
+ }
+ throw Exception(errorMessage);
}
final List<int> parts = match.groups(<int>[1, 2, 3, 4, 5]).map<int>(int.parse).toList();
- if (match.group(6) == '0') {
- print('This commit has already been released, as version ${getVersionFromParts(parts)}.');
- exit(0);
- }
-
switch (level) {
case kX:
parts[0] += 1;
@@ -132,96 +279,7 @@
parts[4] = 0;
break;
default:
- print('Unknown increment level. The valid values are "$kX", "$kY", and "$kZ".');
- exit(1);
+ throw Exception('Unknown increment level. The valid values are "$kX", "$kY", and "$kZ".');
}
- version = getVersionFromParts(parts);
-
- if (justPrint) {
- print(version);
- exit(0);
- }
-
- final String hash = getGitOutput('rev-parse HEAD', 'Get git hash for $commit');
-
- runGit('tag $version', 'tag the commit with the version label');
-
- // PROMPT
-
- if (autoApprove) {
- print('Publishing Flutter $version (${hash.substring(0, 10)}) to the "dev" channel.');
- } else {
- print('Your tree is ready to publish Flutter $version (${hash.substring(0, 10)}) '
- 'to the "dev" channel.');
- stdout.write('Are you? [yes/no] ');
- if (stdin.readLineSync() != 'yes') {
- runGit('tag -d $version', 'remove the tag you did not want to publish');
- print('The dev roll has been aborted.');
- exit(0);
- }
- }
-
- runGit('push $origin $version', 'publish the version');
- runGit('push $origin HEAD:dev', 'land the new version on the "dev" branch');
- print('Flutter version $version has been rolled to the "dev" channel!');
-}
-
-String getFullTag() {
- const String glob = '*.*.*-*.*.pre';
- return getGitOutput(
- 'describe --match $glob --first-parent --long --tags',
- 'obtain last released version number',
- );
-}
-
-Match parseFullTag(String version) {
- // of the form: x.y.z-m.n.pre-c-g<revision>
- final RegExp versionPattern = RegExp(
- r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre-(\d+)-g([a-f0-9]+)$');
- return versionPattern.matchAsPrefix(version);
-}
-
-String getVersionFromParts(List<int> parts) {
- // where parts correspond to [x, y, z, m, n] from tag
- assert(parts.length == 5);
- final StringBuffer buf = StringBuffer()
- // take x, y, and z
- ..write(parts.take(3).join('.'))
- ..write('-')
- // skip x, y, and z, take m and n
- ..write(parts.skip(3).take(2).join('.'))
- ..write('.pre');
- // return a string that looks like: '1.2.3-4.5.pre'
- return buf.toString();
-}
-
-String getGitOutput(String command, String explanation) {
- final ProcessResult result = _runGit(command);
- if ((result.stderr as String).isEmpty && result.exitCode == 0)
- return (result.stdout as String).trim();
- _reportGitFailureAndExit(result, explanation);
- return null; // for the analyzer's sake
-}
-
-void runGit(String command, String explanation) {
- final ProcessResult result = _runGit(command);
- if (result.exitCode != 0)
- _reportGitFailureAndExit(result, explanation);
-}
-
-ProcessResult _runGit(String command) {
- return Process.runSync('git', command.split(' '));
-}
-
-void _reportGitFailureAndExit(ProcessResult result, String explanation) {
- if (result.exitCode != 0) {
- print('Failed to $explanation. Git exited with error code ${result.exitCode}.');
- } else {
- print('Failed to $explanation.');
- }
- if ((result.stdout as String).isNotEmpty)
- print('stdout from git:\n${result.stdout}\n');
- if ((result.stderr as String).isNotEmpty)
- print('stderr from git:\n${result.stderr}\n');
- exit(1);
+ return getVersionFromParts(parts);
}
diff --git a/dev/tools/test/roll_dev_test.dart b/dev/tools/test/roll_dev_test.dart
index f456fa4..600dda8 100644
--- a/dev/tools/test/roll_dev_test.dart
+++ b/dev/tools/test/roll_dev_test.dart
@@ -2,20 +2,227 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:args/args.dart';
import 'package:dev_tools/roll_dev.dart';
+import 'package:mockito/mockito.dart';
+
import './common.dart';
void main() {
+ group('run()', () {
+ const String usage = 'usage info...';
+ const String level = 'z';
+ const String commit = 'abcde012345';
+ const String origin = 'upstream';
+ FakeArgResults fakeArgResults;
+ MockGit mockGit;
+
+ setUp(() {
+ mockGit = MockGit();
+ });
+
+ test('returns false if help requested', () {
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: true,
+ );
+ expect(
+ run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ),
+ false,
+ );
+ });
+
+ test('returns false if level not provided', () {
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: true,
+ );
+ expect(
+ run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ),
+ false,
+ );
+ });
+
+ test('returns false if commit not provided', () {
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: true,
+ );
+ expect(
+ run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ),
+ false,
+ );
+ });
+
+ test('throws exception if upstream remote wrong', () {
+ when(mockGit.getOutput('remote get-url $origin', any)).thenReturn('wrong-remote');
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: false,
+ );
+ Exception exception;
+ try {
+ run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ );
+ } on Exception catch (e) {
+ exception = e;
+ }
+ const String pattern = r'The current directory is not a Flutter '
+ 'repository checkout with a correctly configured upstream remote.';
+ expect(exception?.toString(), contains(pattern));
+ });
+
+ test('throws exception if git checkout not clean', () {
+ when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
+ when(mockGit.getOutput('status --porcelain', any)).thenReturn(
+ ' M dev/tools/test/roll_dev_test.dart',
+ );
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: false,
+ );
+ Exception exception;
+ try {
+ run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ );
+ } 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));
+ });
+
+ test('does not tag if --just-print is specified', () {
+ when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
+ when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
+ when(mockGit.getOutput(
+ 'describe --match *.*.*-*.*.pre --exact-match --tags refs/heads/dev',
+ any,
+ )).thenReturn('1.2.3-0.0.pre');
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: true,
+ autoApprove: true,
+ help: false,
+ );
+ expect(run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ), false);
+ verify(mockGit.run('fetch $origin', any));
+ verify(mockGit.run('reset $commit --hard', any));
+ verifyNever(mockGit.getOutput('rev-parse HEAD', any));
+ });
+
+ test('successfully tags and publishes release', () {
+ when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
+ when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
+ when(mockGit.getOutput(
+ 'describe --match *.*.*-*.*.pre --exact-match --tags refs/heads/dev',
+ any,
+ )).thenReturn('1.2.3-0.0.pre');
+ when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: false,
+ );
+ expect(run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ), true);
+ verify(mockGit.run('fetch $origin', any));
+ verify(mockGit.run('reset $commit --hard', any));
+ verify(mockGit.run('tag 1.2.0-1.0.pre', any));
+ verify(mockGit.run('push $origin HEAD:dev', any));
+ });
+
+ test('successfully publishes release with --force', () {
+ when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
+ when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
+ when(mockGit.getOutput(
+ 'describe --match *.*.*-*.*.pre --exact-match --tags refs/heads/dev',
+ any,
+ )).thenReturn('1.2.3-0.0.pre');
+ when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
+ fakeArgResults = FakeArgResults(
+ level: level,
+ commit: commit,
+ origin: origin,
+ justPrint: false,
+ autoApprove: true,
+ help: false,
+ force: true,
+ );
+ expect(run(
+ usage: usage,
+ argResults: fakeArgResults,
+ git: mockGit,
+ ), true);
+ verify(mockGit.run('fetch $origin', any));
+ verify(mockGit.run('reset $commit --hard', any));
+ verify(mockGit.run('tag 1.2.0-1.0.pre', any));
+ verify(mockGit.run('push --force $origin HEAD:dev', any));
+ });
+ });
+
group('parseFullTag', () {
test('returns match on valid version input', () {
final List<String> validTags = <String>[
- '1.2.3-1.2.pre-3-gabc123',
- '10.2.30-12.22.pre-45-gabc123',
- '1.18.0-0.0.pre-0-gf0adb240a',
- '2.0.0-1.99.pre-45-gf0adb240a',
- '12.34.56-78.90.pre-12-g9db2703a2',
- '0.0.1-0.0.pre-1-g07601eb95ff82f01e870566586340ed2e87b9cbb',
- '958.80.144-6.224.pre-7803-g06e90',
+ '1.2.3-1.2.pre',
+ '10.2.30-12.22.pre',
+ '1.18.0-0.0.pre',
+ '2.0.0-1.99.pre',
+ '12.34.56-78.90.pre',
+ '0.0.1-0.0.pre',
+ '958.80.144-6.224.pre',
];
for (final String validTag in validTags) {
final Match match = parseFullTag(validTag);
@@ -25,15 +232,15 @@
test('returns null on invalid version input', () {
final List<String> invalidTags = <String>[
- '1.2.3-dev.1.2-3-gabc123',
- '1.2.3-1.2-3-gabc123',
+ '1.2.3-1.2.pre-3-gabc123',
+ '1.2.3-1.2.3.pre',
+ '1.2.3.1.2.pre',
+ '1.2.3-dev.1.2',
+ '1.2.3-1.2-3',
'v1.2.3',
'2.0.0',
- 'v1.2.3-1.2.pre-3-gabc123',
- '10.0.1-0.0.pre-gf0adb240a',
- '10.0.1-0.0.pre-3-gggggggggg',
- '1.2.3-1.2.pre-3-abc123',
- '1.2.3-1.2.pre-3-gabc123_',
+ 'v1.2.3-1.2.pre',
+ '1.2.3-1.2.pre_',
];
for (final String invalidTag in invalidTags) {
final Match match = parseFullTag(invalidTag);
@@ -51,4 +258,124 @@
expect(getVersionFromParts(parts), '11.2.33-1.0.pre');
});
});
+
+ group('incrementLevel()', () {
+ const String hash = 'abc123';
+
+ test('throws exception if hash is not valid release candidate', () {
+ String level = 'z';
+
+ String version = '1.0.0-0.0.pre-1-g$hash';
+ expect(
+ () => incrementLevel(version, level),
+ throwsException,
+ reason: 'should throw because $version should be an exact tag',
+ );
+
+ version = '1.2.3';
+ expect(
+ () => incrementLevel(version, level),
+ throwsException,
+ reason: 'should throw because $version should be a dev tag, not stable.'
+ );
+
+ version = '1.0.0-0.0.pre-1-g$hash';
+ level = 'q';
+ expect(
+ () => incrementLevel(version, level),
+ throwsException,
+ reason: 'should throw because $level is unsupported',
+ );
+ });
+
+ test('successfully increments x', () {
+ const String level = 'x';
+
+ String version = '1.0.0-0.0.pre';
+ expect(incrementLevel(version, level), '2.0.0-0.0.pre');
+
+ version = '10.20.0-40.50.pre';
+ expect(incrementLevel(version, level), '11.0.0-0.0.pre');
+
+ version = '1.18.0-3.0.pre';
+ expect(incrementLevel(version, level), '2.0.0-0.0.pre');
+ });
+
+ test('successfully increments y', () {
+ const String level = 'y';
+
+ String version = '1.0.0-0.0.pre';
+ expect(incrementLevel(version, level), '1.1.0-0.0.pre');
+
+ version = '10.20.0-40.50.pre';
+ expect(incrementLevel(version, level), '10.21.0-0.0.pre');
+
+ version = '1.18.0-3.0.pre';
+ expect(incrementLevel(version, level), '1.19.0-0.0.pre');
+ });
+
+ test('successfully increments z', () {
+ const String level = 'z';
+
+ String version = '1.0.0-0.0.pre';
+ expect(incrementLevel(version, level), '1.0.0-1.0.pre');
+
+ version = '10.20.0-40.50.pre';
+ expect(incrementLevel(version, level), '10.20.0-41.0.pre');
+
+ version = '1.18.0-3.0.pre';
+ expect(incrementLevel(version, level), '1.18.0-4.0.pre');
+ });
+ });
}
+
+class FakeArgResults implements ArgResults {
+ FakeArgResults({
+ String level,
+ String commit,
+ String origin,
+ bool justPrint,
+ bool autoApprove,
+ bool help,
+ bool force = false,
+ }) : _parsedArgs = <String, dynamic>{
+ 'increment': level,
+ 'commit': commit,
+ 'origin': origin,
+ 'just-print': justPrint,
+ 'yes': autoApprove,
+ 'help': help,
+ 'force': force,
+ };
+
+ @override
+ String name;
+
+ @override
+ ArgResults command;
+
+ @override
+ final List<String> rest = <String>[];
+
+ @override
+ List<String> arguments;
+
+ final Map<String, dynamic> _parsedArgs;
+
+ @override
+ Iterable<String> get options {
+ return null;
+ }
+
+ @override
+ dynamic operator [](String name) {
+ return _parsedArgs[name];
+ }
+
+ @override
+ bool wasParsed(String name) {
+ return null;
+ }
+}
+
+class MockGit extends Mock implements Git {}