Unverified Commit d56af3ca authored by Christopher Fujino's avatar Christopher Fujino Committed by GitHub

Add --force to `roll_dev.dart` (#56501)

parent 7ba5078f
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
import 'dart:io'; import 'dart:io';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:meta/meta.dart';
const String kIncrement = 'increment'; const String kIncrement = 'increment';
const String kX = 'x'; const String kX = 'x';
...@@ -20,131 +21,93 @@ const String kOrigin = 'origin'; ...@@ -20,131 +21,93 @@ const String kOrigin = 'origin';
const String kJustPrint = 'just-print'; const String kJustPrint = 'just-print';
const String kYes = 'yes'; const String kYes = 'yes';
const String kHelp = 'help'; const String kHelp = 'help';
const String kForce = 'force';
const String kUpstreamRemote = 'git@github.com:flutter/flutter.git'; const String kUpstreamRemote = 'git@github.com:flutter/flutter.git';
void main(List<String> args) { void main(List<String> args) {
final ArgParser argParser = ArgParser(allowTrailingOptions: false); final ArgParser argParser = ArgParser(allowTrailingOptions: false);
argParser.addOption(
kIncrement,
help: 'Specifies which part of the x.y.z version number to increment. Required.',
valueHelp: 'level',
allowed: <String>[kX, kY, kZ],
allowedHelp: <String, String>{
kX: 'Indicates a major development, e.g. typically changed after a big press event.',
kY: 'Indicates a minor development, e.g. typically changed after a beta release.',
kZ: 'Indicates the least notable level of change. You normally want this.',
},
);
argParser.addOption(
kCommit,
help: 'Specifies which git commit to roll to the dev branch. Required.',
valueHelp: 'hash',
defaultsTo: null, // This option is required
);
argParser.addOption(
kOrigin,
help: 'Specifies the name of the upstream repository',
valueHelp: 'repository',
defaultsTo: 'upstream',
);
argParser.addFlag(
kJustPrint,
negatable: false,
help:
"Don't actually roll the dev channel; "
'just print the would-be version and quit.',
);
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; ArgResults argResults;
try { try {
argResults = argParser.parse(args); argResults = parseArguments(argParser, args);
} on ArgParserException catch (error) { } on ArgParserException catch (error) {
print(error.message); print(error.message);
print(argParser.usage); print(argParser.usage);
exit(1); 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 level = argResults[kIncrement] as String;
final String commit = argResults[kCommit] as String; final String commit = argResults[kCommit] as String;
final String origin = argResults[kOrigin] as String; final String origin = argResults[kOrigin] as String;
final bool justPrint = argResults[kJustPrint] as bool; final bool justPrint = argResults[kJustPrint] as bool;
final bool autoApprove = argResults[kYes] as bool; final bool autoApprove = argResults[kYes] as bool;
final bool help = argResults[kHelp] as bool; final bool help = argResults[kHelp] as bool;
final bool force = argResults[kForce] as bool;
if (help || level == null || commit == null) { 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(
print(argParser.usage); 'roll_dev.dart --increment=level --commit=hash • update the version tags '
exit(0); 'and roll a new dev build.\n$usage'
);
return false;
} }
if (getGitOutput('remote get-url $origin', 'check whether this is a flutter checkout') != kUpstreamRemote) { final String remote = git.getOutput(
print('The current directory is not a Flutter repository checkout with a correctly configured upstream remote.'); 'remote get-url $origin',
print('For more details see: https://github.com/flutter/flutter/wiki/Release-process'); 'check whether this is a flutter checkout',
exit(1); );
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 (getGitOutput('status --porcelain', 'check status of your local checkout') != '') { if (git.getOutput('status --porcelain', 'check status of your local checkout') != '') {
print('Your git repository is not clean. Try running "git clean -fd". Warning, this '); throw Exception(
print('will delete files! Run with -n to find out which ones.'); 'Your git repository is not clean. Try running "git clean -fd". Warning, '
exit(1); 'this will delete files! Run with -n to find out which ones.'
);
} }
runGit('fetch $origin', 'fetch $origin'); // TODO(fujino): move this after `justPrint`
runGit('reset $commit --hard', 'reset to the release commit'); git.run('fetch $origin', 'fetch $origin');
git.run('reset $commit --hard', 'reset to the release commit');
String version = getFullTag(); String version = getFullTag(git);
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);
}
final List<int> parts = match.groups(<int>[1, 2, 3, 4, 5]).map<int>(int.parse).toList(); version = incrementLevel(version, level);
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;
parts[1] = 0;
parts[2] = 0;
parts[3] = 0;
parts[4] = 0;
break;
case kY:
parts[1] += 1;
parts[2] = 0;
parts[3] = 0;
parts[4] = 0;
break;
case kZ:
parts[2] = 0;
parts[3] += 1;
parts[4] = 0;
break;
default:
print('Unknown increment level. The valid values are "$kX", "$kY", and "$kZ".');
exit(1);
}
version = getVersionFromParts(parts);
if (justPrint) { if (justPrint) {
print(version); print(version);
exit(0); return false;
} }
final String hash = getGitOutput('rev-parse HEAD', 'Get git hash for $commit'); final String hash = git.getOutput('rev-parse HEAD', 'Get git hash for $commit');
runGit('tag $version', 'tag the commit with the version label'); git.run('tag $version', 'tag the commit with the version label');
// PROMPT // PROMPT
...@@ -155,29 +118,79 @@ void main(List<String> args) { ...@@ -155,29 +118,79 @@ void main(List<String> args) {
'to the "dev" channel.'); 'to the "dev" channel.');
stdout.write('Are you? [yes/no] '); stdout.write('Are you? [yes/no] ');
if (stdin.readLineSync() != 'yes') { if (stdin.readLineSync() != 'yes') {
runGit('tag -d $version', 'remove the tag you did not want to publish'); git.run('tag -d $version', 'remove the tag you did not want to publish');
print('The dev roll has been aborted.'); print('The dev roll has been aborted.');
exit(0); return false;
} }
} }
runGit('push $origin $version', 'publish the version'); git.run('push $origin $version', 'publish the version');
runGit('push $origin HEAD:dev', 'land the new version on the "dev" branch'); 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!'); 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.',
valueHelp: 'level',
allowed: <String>[kX, kY, kZ],
allowedHelp: <String, String>{
kX: 'Indicates a major development, e.g. typically changed after a big press event.',
kY: 'Indicates a minor development, e.g. typically changed after a beta release.',
kZ: 'Indicates the least notable level of change. You normally want this.',
},
);
argParser.addOption(
kCommit,
help: 'Specifies which git commit to roll to the dev branch. Required.',
valueHelp: 'hash',
defaultsTo: null, // This option is required
);
argParser.addOption(
kOrigin,
help: 'Specifies the name of the upstream repository',
valueHelp: 'repository',
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:
"Don't actually roll the dev channel; "
'just print the would-be version and quit.',
);
argParser.addFlag(kYes, negatable: false, abbr: 'y', help: 'Skip the confirmation prompt.');
argParser.addFlag(kHelp, negatable: false, help: 'Show this help message.', hide: true);
return argParser.parse(args);
} }
String getFullTag() { /// Obtain the version tag of the previous dev release.
String getFullTag(Git git) {
const String glob = '*.*.*-*.*.pre'; const String glob = '*.*.*-*.*.pre';
return getGitOutput( // describe the latest dev release
'describe --match $glob --first-parent --long --tags', const String ref = 'refs/heads/dev';
return git.getOutput(
'describe --match $glob --exact-match --tags $ref',
'obtain last released version number', 'obtain last released version number',
); );
} }
Match parseFullTag(String version) { Match parseFullTag(String version) {
// of the form: x.y.z-m.n.pre-c-g<revision> // of the form: x.y.z-m.n.pre
final RegExp versionPattern = RegExp( final RegExp versionPattern = RegExp(
r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre-(\d+)-g([a-f0-9]+)$'); r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$');
return versionPattern.matchAsPrefix(version); return versionPattern.matchAsPrefix(version);
} }
...@@ -195,33 +208,78 @@ String getVersionFromParts(List<int> parts) { ...@@ -195,33 +208,78 @@ String getVersionFromParts(List<int> parts) {
return buf.toString(); return buf.toString();
} }
String getGitOutput(String command, String explanation) { class Git {
final ProcessResult result = _runGit(command); const Git();
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) { String getOutput(String command, String explanation) {
final ProcessResult result = _runGit(command); final ProcessResult result = _run(command);
if (result.exitCode != 0) if ((result.stderr as String).isEmpty && result.exitCode == 0)
_reportGitFailureAndExit(result, explanation); 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(' '));
}
ProcessResult _runGit(String command) { void _reportFailureAndExit(ProcessResult result, String explanation) {
return Process.runSync('git', command.split(' ')); 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);
}
} }
void _reportGitFailureAndExit(ProcessResult result, String explanation) { /// Return a copy of the [version] with [level] incremented by one.
if (result.exitCode != 0) { String incrementLevel(String version, String level) {
print('Failed to $explanation. Git exited with error code ${result.exitCode}.'); final Match match = parseFullTag(version);
} else { if (match == null) {
print('Failed to $explanation.'); 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();
switch (level) {
case kX:
parts[0] += 1;
parts[1] = 0;
parts[2] = 0;
parts[3] = 0;
parts[4] = 0;
break;
case kY:
parts[1] += 1;
parts[2] = 0;
parts[3] = 0;
parts[4] = 0;
break;
case kZ:
parts[2] = 0;
parts[3] += 1;
parts[4] = 0;
break;
default:
throw Exception('Unknown increment level. The valid values are "$kX", "$kY", and "$kZ".');
} }
if ((result.stdout as String).isNotEmpty) return getVersionFromParts(parts);
print('stdout from git:\n${result.stdout}\n');
if ((result.stderr as String).isNotEmpty)
print('stderr from git:\n${result.stderr}\n');
exit(1);
} }
...@@ -2,20 +2,227 @@ ...@@ -2,20 +2,227 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:args/args.dart';
import 'package:dev_tools/roll_dev.dart'; import 'package:dev_tools/roll_dev.dart';
import 'package:mockito/mockito.dart';
import './common.dart'; import './common.dart';
void main() { 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', () { group('parseFullTag', () {
test('returns match on valid version input', () { test('returns match on valid version input', () {
final List<String> validTags = <String>[ final List<String> validTags = <String>[
'1.2.3-1.2.pre-3-gabc123', '1.2.3-1.2.pre',
'10.2.30-12.22.pre-45-gabc123', '10.2.30-12.22.pre',
'1.18.0-0.0.pre-0-gf0adb240a', '1.18.0-0.0.pre',
'2.0.0-1.99.pre-45-gf0adb240a', '2.0.0-1.99.pre',
'12.34.56-78.90.pre-12-g9db2703a2', '12.34.56-78.90.pre',
'0.0.1-0.0.pre-1-g07601eb95ff82f01e870566586340ed2e87b9cbb', '0.0.1-0.0.pre',
'958.80.144-6.224.pre-7803-g06e90', '958.80.144-6.224.pre',
]; ];
for (final String validTag in validTags) { for (final String validTag in validTags) {
final Match match = parseFullTag(validTag); final Match match = parseFullTag(validTag);
...@@ -25,15 +232,15 @@ void main() { ...@@ -25,15 +232,15 @@ void main() {
test('returns null on invalid version input', () { test('returns null on invalid version input', () {
final List<String> invalidTags = <String>[ final List<String> invalidTags = <String>[
'1.2.3-dev.1.2-3-gabc123', '1.2.3-1.2.pre-3-gabc123',
'1.2.3-1.2-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', 'v1.2.3',
'2.0.0', '2.0.0',
'v1.2.3-1.2.pre-3-gabc123', 'v1.2.3-1.2.pre',
'10.0.1-0.0.pre-gf0adb240a', '1.2.3-1.2.pre_',
'10.0.1-0.0.pre-3-gggggggggg',
'1.2.3-1.2.pre-3-abc123',
'1.2.3-1.2.pre-3-gabc123_',
]; ];
for (final String invalidTag in invalidTags) { for (final String invalidTag in invalidTags) {
final Match match = parseFullTag(invalidTag); final Match match = parseFullTag(invalidTag);
...@@ -51,4 +258,124 @@ void main() { ...@@ -51,4 +258,124 @@ void main() {
expect(getVersionFromParts(parts), '11.2.33-1.0.pre'); 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 {}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment