diff --git a/dev/tools/lib/roll_dev.dart b/dev/tools/lib/roll_dev.dart index cc766b3148..12ead146ed 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 kOrigin = 'origin'; 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 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 args) { argParser.addOption( kIncrement, help: 'Specifies which part of the x.y.z version number to increment. Required.', @@ -49,6 +157,12 @@ void main(List args) { 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, @@ -59,59 +173,92 @@ void main(List args) { 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 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 parts = match.groups([1, 2, 3, 4, 5]).map(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 @@ void main(List args) { 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 - final RegExp versionPattern = RegExp( - r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre-(\d+)-g([a-f0-9]+)$'); - return versionPattern.matchAsPrefix(version); -} - -String getVersionFromParts(List 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 f456fa44ce..600dda8195 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 validTags = [ - '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 @@ void main() { test('returns null on invalid version input', () { final List invalidTags = [ - '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 @@ void main() { 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 = { + 'increment': level, + 'commit': commit, + 'origin': origin, + 'just-print': justPrint, + 'yes': autoApprove, + 'help': help, + 'force': force, + }; + + @override + String name; + + @override + ArgResults command; + + @override + final List rest = []; + + @override + List arguments; + + final Map _parsedArgs; + + @override + Iterable 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 {}