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

[flutter_conductor] update dev/tools with release tool (#69791)

parent 1f210275
#!/usr/bin/env bash
set -euo pipefail
# Needed because if it is set, cd may print the path it changed to.
unset CDPATH
# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one
# link at a time, and then cds into the link destination and find out where it
# ends up.
#
# The returned filesystem path must be a format usable by Dart's URI parser,
# since the Dart command line tool treats its argument as a file URI, not a
# filename. For instance, multiple consecutive slashes should be reduced to a
# single slash, since double-slashes indicate a URI "authority", and these are
# supposed to be filenames. There is an edge case where this will return
# multiple slashes: when the input resolves to the root directory. However, if
# that were the case, we wouldn't be running this shell, so we don't do anything
# about it.
#
# The function is enclosed in a subshell to avoid changing the working directory
# of the caller.
function follow_links() (
cd -P "$(dirname -- "$1")"
file="$PWD/$(basename -- "$1")"
while [[ -h "$file" ]]; do
cd -P "$(dirname -- "$file")"
file="$(readlink -- "$file")"
cd -P "$(dirname -- "$file")"
file="$PWD/$(basename -- "$file")"
done
echo "$file"
)
PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")"
BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
DART_BIN="$BIN_DIR/../../../bin/dart"
"$DART_BIN" --enable-asserts "$BIN_DIR/conductor.dart" "$@"
// 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.
// Rolls the dev channel.
// Only tested on Linux.
//
// See: https://github.com/flutter/flutter/wiki/Release-process
import 'dart:io' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import 'package:dev_tools/repository.dart';
import 'package:dev_tools/roll_dev.dart';
import 'package:dev_tools/stdio.dart';
void main(List<String> args) {
const FileSystem fileSystem = LocalFileSystem();
const ProcessManager processManager = LocalProcessManager();
const Platform platform = LocalPlatform();
final Stdio stdio = VerboseStdio(
stdout: io.stdout,
stderr: io.stderr,
stdin: io.stdin,
);
final Checkouts checkouts = Checkouts(
fileSystem: fileSystem,
platform: platform,
processManager: processManager,
);
final CommandRunner<void> runner = CommandRunner<void>(
'conductor',
'A tool for coordinating Flutter releases.',
usageLineLength: 80,
);
<Command<void>>[
RollDev(
fileSystem: fileSystem,
platform: platform,
repository: checkouts.addRepo(
fileSystem: fileSystem,
platform: platform,
repoType: RepositoryType.framework,
stdio: stdio,
),
stdio: stdio,
),
].forEach(runner.addCommand);
if (!assertsEnabled()) {
stdio.printError('The conductor tool must be run with --enable-asserts.');
io.exit(1);
}
try {
runner.run(args);
} on Exception catch (e) {
stdio.printError(e.toString());
io.exit(1);
}
}
bool assertsEnabled() {
// Verify asserts enabled
bool assertsEnabled = false;
assert(() {
assertsEnabled = true;
return true;
}());
return assertsEnabled;
}
// 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:io';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import './globals.dart';
/// A wrapper around git process calls that can be mocked for unit testing.
class Git {
Git(this.processManager) : assert(processManager != null);
final ProcessManager processManager;
String getOutput(
List<String> args,
String explanation, {
@required String workingDirectory,
}) {
final ProcessResult result = _run(args, workingDirectory);
if (result.exitCode == 0) {
return stdoutToString(result.stdout);
}
_reportFailureAndExit(args, workingDirectory, result, explanation);
return null; // for the analyzer's sake
}
int run(
List<String> args,
String explanation, {
bool allowNonZeroExitCode = false,
@required String workingDirectory,
}) {
final ProcessResult result = _run(args, workingDirectory);
if (result.exitCode != 0 && !allowNonZeroExitCode) {
_reportFailureAndExit(args, workingDirectory, result, explanation);
}
return result.exitCode;
}
ProcessResult _run(List<String> args, String workingDirectory) {
return processManager.runSync(
<String>['git', ...args],
workingDirectory: workingDirectory,
);
}
void _reportFailureAndExit(
List<String> args,
String workingDirectory,
ProcessResult result,
String explanation,
) {
final StringBuffer message = StringBuffer();
if (result.exitCode != 0) {
message.writeln(
'Command "git ${args.join(' ')}" failed in directory "$workingDirectory" to '
'$explanation. Git exited with error code ${result.exitCode}.',
);
} else {
message.writeln('Command "git ${args.join(' ')}" failed to $explanation.');
}
if ((result.stdout as String).isNotEmpty)
message.writeln('stdout from git:\n${result.stdout}\n');
if ((result.stderr as String).isNotEmpty)
message.writeln('stderr from git:\n${result.stderr}\n');
throw Exception(message);
}
}
// 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.
const String kIncrement = 'increment';
const String kCommit = 'commit';
const String kRemoteName = 'remote';
const String kJustPrint = 'just-print';
const String kYes = 'yes';
const String kForce = 'force';
const String kSkipTagging = 'skip-tagging';
const String kUpstreamRemote = 'https://github.com/flutter/flutter.git';
const List<String> kReleaseChannels = <String>[
'stable',
'beta',
'dev',
'master',
];
/// Cast a dynamic to String and trim.
String stdoutToString(dynamic input) {
final String str = input as String;
return str.trim();
}
// 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 jsonDecode;
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:platform/platform.dart';
import './git.dart';
import './globals.dart' as globals;
import './stdio.dart';
import './version.dart';
/// A source code repository.
class Repository {
Repository({
@required this.name,
@required this.upstream,
@required this.processManager,
@required this.stdio,
@required this.platform,
@required this.fileSystem,
@required this.parentDirectory,
this.localUpstream = false,
this.useExistingCheckout = false,
}) : git = Git(processManager),
assert(localUpstream != null),
assert(useExistingCheckout != null);
final String name;
final String upstream;
final Git git;
final ProcessManager processManager;
final Stdio stdio;
final Platform platform;
final FileSystem fileSystem;
final Directory parentDirectory;
final bool useExistingCheckout;
/// If the repository will be used as an upstream for a test repo.
final bool localUpstream;
Directory _checkoutDirectory;
/// Lazily-loaded directory for the repository checkout.
///
/// Cloning a repository is time-consuming, thus the repository is not cloned
/// until this getter is called.
Directory get checkoutDirectory {
if (_checkoutDirectory != null) {
return _checkoutDirectory;
}
_checkoutDirectory = parentDirectory.childDirectory(name);
if (checkoutDirectory.existsSync() && !useExistingCheckout) {
deleteDirectory();
}
if (!checkoutDirectory.existsSync()) {
stdio.printTrace('Cloning $name to ${checkoutDirectory.path}...');
git.run(
<String>['clone', '--', upstream, checkoutDirectory.path],
'Cloning $name repo',
workingDirectory: parentDirectory.path,
);
if (localUpstream) {
// These branches must exist locally for the repo that depends on it
// to fetch and push to.
for (final String channel in globals.kReleaseChannels) {
git.run(
<String>['checkout', channel, '--'],
'check out branch $channel locally',
workingDirectory: checkoutDirectory.path,
);
}
}
} else {
stdio.printTrace(
'Using existing $name repo at ${checkoutDirectory.path}...',
);
}
return _checkoutDirectory;
}
void deleteDirectory() {
if (!checkoutDirectory.existsSync()) {
stdio.printTrace(
'Tried to delete ${checkoutDirectory.path} but it does not exist.',
);
return;
}
stdio.printTrace('Deleting $name from ${checkoutDirectory.path}...');
checkoutDirectory.deleteSync(recursive: true);
}
/// The URL of the remote named [remoteName].
String remoteUrl(String remoteName) {
assert(remoteName != null);
return git.getOutput(
<String>['remote', 'get-url', remoteName],
'verify the URL of the $remoteName remote',
workingDirectory: checkoutDirectory.path,
);
}
/// Verify the repository's git checkout is clean.
bool gitCheckoutClean() {
final String output = git.getOutput(
<String>['status', '--porcelain'],
'check that the git checkout is clean',
workingDirectory: checkoutDirectory.path,
);
return output == '';
}
/// Fetch all branches and associated commits and tags from [remoteName].
void fetch(String remoteName) {
git.run(
<String>['fetch', remoteName, '--tags'],
'fetch $remoteName --tags',
workingDirectory: checkoutDirectory.path,
);
}
/// Obtain the version tag of the previous dev release.
String getFullTag(String remoteName) {
const String glob = '*.*.*-*.*.pre';
// describe the latest dev release
final String ref = 'refs/remotes/$remoteName/dev';
return git.getOutput(
<String>['describe', '--match', glob, '--exact-match', '--tags', ref],
'obtain last released version number',
workingDirectory: checkoutDirectory.path,
);
}
/// Look up the commit for [ref].
String reverseParse(String ref) {
final String revisionHash = git.getOutput(
<String>['rev-parse', ref],
'look up the commit for the ref $ref',
workingDirectory: checkoutDirectory.path,
);
assert(revisionHash.isNotEmpty);
return revisionHash;
}
/// Determines if one ref is an ancestor for another.
bool isAncestor(String possibleAncestor, String possibleDescendant) {
final int exitcode = git.run(
<String>[
'merge-base',
'--is-ancestor',
possibleDescendant,
possibleAncestor
],
'verify $possibleAncestor is a direct ancestor of $possibleDescendant.',
allowNonZeroExitCode: true,
workingDirectory: checkoutDirectory.path,
);
return exitcode == 0;
}
/// Determines if a given commit has a tag.
bool isCommitTagged(String commit) {
final int exitcode = git.run(
<String>['describe', '--exact-match', '--tags', commit],
'verify $commit is already tagged',
allowNonZeroExitCode: true,
workingDirectory: checkoutDirectory.path,
);
return exitcode == 0;
}
/// Resets repository HEAD to [commit].
void reset(String commit) {
git.run(
<String>['reset', commit, '--hard'],
'reset to the release commit',
workingDirectory: checkoutDirectory.path,
);
}
/// Tag [commit] and push the tag to the remote.
void tag(String commit, String tagName, String remote) {
git.run(
<String>['tag', tagName, commit],
'tag the commit with the version label',
workingDirectory: checkoutDirectory.path,
);
git.run(
<String>['push', remote, tagName],
'publish the tag to the repo',
workingDirectory: checkoutDirectory.path,
);
}
/// Push [commit] to the release channel [branch].
void updateChannel(
String commit,
String remote,
String branch, {
bool force = false,
}) {
git.run(
<String>[
'push',
if (force) '--force',
remote,
'$commit:$branch',
],
'update the release branch with the commit',
workingDirectory: checkoutDirectory.path,
);
}
Version flutterVersion() {
// Build tool
processManager.runSync(<String>[
fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
'help',
]);
// Check version
final io.ProcessResult result = processManager.runSync(<String>[
fileSystem.path.join(checkoutDirectory.path, 'bin', 'flutter'),
'--version',
'--machine',
]);
final Map<String, dynamic> versionJson = jsonDecode(
globals.stdoutToString(result.stdout),
) as Map<String, dynamic>;
return Version.fromString(versionJson['frameworkVersion'] as String);
}
/// Create an empty commit and return the revision.
@visibleForTesting
String authorEmptyCommit([String message = 'An empty commit']) {
git.run(
<String>[
'-c',
'user.name=Conductor',
'-c',
'user.email=conductor@flutter.dev',
'commit',
'--allow-empty',
'-m',
'\'$message\'',
],
'create an empty commit',
workingDirectory: checkoutDirectory.path,
);
return reverseParse('HEAD');
}
/// Create a new clone of the current repository.
///
/// The returned repository will inherit all properties from this one, except
/// for the upstream, which will be the path to this repository on disk.
///
/// This method is for testing purposes.
@visibleForTesting
Repository cloneRepository(String cloneName) {
assert(localUpstream);
cloneName ??= 'clone-of-$name';
return Repository(
fileSystem: fileSystem,
name: cloneName,
parentDirectory: parentDirectory,
platform: platform,
processManager: processManager,
stdio: stdio,
upstream: 'file://${checkoutDirectory.path}/',
useExistingCheckout: useExistingCheckout,
);
}
}
/// An enum of all the repositories that the Conductor supports.
enum RepositoryType {
framework,
engine,
}
class Checkouts {
Checkouts({
@required Platform platform,
@required this.fileSystem,
@required this.processManager,
Directory parentDirectory,
String directoryName = 'checkouts',
}) {
if (parentDirectory != null) {
directory = parentDirectory.childDirectory(directoryName);
} else {
String filePath;
// If a test
if (platform.script.scheme == 'data') {
final RegExp pattern = RegExp(
r'(file:\/\/[^"]*[/\\]dev\/tools[/\\][^"]+\.dart)',
multiLine: true,
);
final Match match =
pattern.firstMatch(Uri.decodeFull(platform.script.path));
if (match == null) {
throw Exception(
'Cannot determine path of script!\n${platform.script.path}',
);
}
filePath = Uri.parse(match.group(1)).path.replaceAll(r'%20', ' ');
} else {
filePath = platform.script.toFilePath();
}
final String checkoutsDirname = fileSystem.path.normalize(
fileSystem.path.join(
fileSystem.path.dirname(filePath),
'..',
'checkouts',
),
);
directory = fileSystem.directory(checkoutsDirname);
}
if (!directory.existsSync()) {
directory.createSync(recursive: true);
}
}
Directory directory;
final FileSystem fileSystem;
final ProcessManager processManager;
Repository addRepo({
@required RepositoryType repoType,
@required Stdio stdio,
@required Platform platform,
FileSystem fileSystem,
String upstream,
String name,
bool localUpstream = false,
bool useExistingCheckout = false,
}) {
switch (repoType) {
case RepositoryType.framework:
name ??= 'framework';
upstream ??= 'https://github.com/flutter/flutter.git';
break;
case RepositoryType.engine:
name ??= 'engine';
upstream ??= 'https://github.com/flutter/engine.git';
break;
}
return Repository(
name: name,
upstream: upstream,
stdio: stdio,
platform: platform,
fileSystem: fileSystem,
parentDirectory: directory,
processManager: processManager,
localUpstream: localUpstream,
useExistingCheckout: useExistingCheckout,
);
}
}
......@@ -2,319 +2,190 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Rolls the dev channel.
// Only tested on Linux.
//
// See: https://github.com/flutter/flutter/wiki/Release-process
import 'dart:io';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import './globals.dart';
import './repository.dart';
import './stdio.dart';
import './version.dart';
/// Create a new dev release without cherry picks.
class RollDev extends Command<void> {
RollDev({
this.fileSystem,
this.platform,
this.repository,
this.stdio,
}) {
argParser.addOption(
kIncrement,
help: 'Specifies which part of the x.y.z version number to increment. Required.',
valueHelp: 'level',
allowed: <String>['y', 'z', 'm'],
allowedHelp: <String, String>{
'y': 'Indicates the first dev release after a beta release.',
'z': 'Indicates a hotfix to a stable release.',
'm': 'Indicates a standard dev release.',
},
);
argParser.addOption(
kCommit,
help: 'Specifies which git commit to roll to the dev branch. Required.',
valueHelp: 'hash',
defaultsTo: null, // This option is required
);
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(
kSkipTagging,
negatable: false,
help: 'Do not create tag and push to remote, only update release branch. '
'For recovering when the script fails trying to git push to the release branch.'
);
argParser.addFlag(kYes, negatable: false, abbr: 'y', help: 'Skip the confirmation prompt.');
}
const String kIncrement = 'increment';
const String kX = 'x';
const String kY = 'y';
const String kZ = 'z';
const String kCommit = 'commit';
const String kOrigin = 'origin';
const String kJustPrint = 'just-print';
const String kYes = 'yes';
const String kHelp = 'help';
const String kForce = 'force';
const String kSkipTagging = 'skip-tagging';
const String kUpstreamRemote = 'git@github.com:flutter/flutter.git';
final FileSystem fileSystem;
final Platform platform;
final Stdio stdio;
final Repository repository;
void main(List<String> args) {
final ArgParser argParser = ArgParser(allowTrailingOptions: false);
@override
String get name => 'roll-dev';
ArgResults argResults;
try {
argResults = parseArguments(argParser, args);
} on ArgParserException catch (error) {
print(error.message);
print(argParser.usage);
exit(1);
}
@override
String get description =>
'For publishing a dev release without cherry picks.';
try {
run(
usage: argParser.usage,
@override
void run() {
rollDev(
argResults: argResults,
git: const Git(),
fileSystem: fileSystem,
platform: platform,
repository: repository,
stdio: stdio,
usage: argParser.usage,
);
} on Exception catch (e) {
print(e.toString());
exit(1);
}
}
/// Main script execution.
///
/// Returns true if publishing was successful, else false.
bool run({
@visibleForTesting
bool rollDev({
@required String usage,
@required ArgResults argResults,
@required Git git,
@required Stdio stdio,
@required Platform platform,
@required FileSystem fileSystem,
@required Repository repository,
String remoteName = 'origin',
}) {
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;
final bool skipTagging = argResults[kSkipTagging] 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'
);
if (level == null || commit == null) {
stdio.printStatus(
'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 remote named $origin is set to $remote, when $kUpstreamRemote was '
'expected.\nFor more details see: '
'https://github.com/flutter/flutter/wiki/Release-process'
);
}
final String remoteUrl = repository.remoteUrl(remoteName);
if (git.getOutput('status --porcelain', 'check status of your local checkout') != '') {
if (!repository.gitCheckoutClean()) {
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.'
);
'Your git repository is not clean. Try running "git clean -fd". Warning, '
'this will delete files! Run with -n to find out which ones.');
}
git.run('fetch $origin', 'fetch $origin');
repository.fetch(remoteName);
// Verify [commit] is valid
repository.reverseParse(commit);
final String lastVersion = getFullTag(git, origin);
stdio.printStatus('remoteName is $remoteName');
final Version lastVersion =
Version.fromString(repository.getFullTag(remoteName));
final String version = skipTagging
? lastVersion
: incrementLevel(lastVersion, level);
final Version version =
skipTagging ? lastVersion : Version.increment(lastVersion, level);
final String tagName = version.toString();
if (git.getOutput(
'rev-parse $lastVersion',
'check if commit is already on dev',
).contains(commit.trim())) {
throw Exception('Commit $commit is already on the dev branch as $lastVersion.');
if (repository.reverseParse(lastVersion.toString()).contains(commit.trim())) {
throw Exception(
'Commit $commit is already on the dev branch as $lastVersion.');
}
if (justPrint) {
print(version);
stdio.printStatus(tagName);
return false;
}
if (skipTagging) {
git.run(
'describe --exact-match --tags $commit',
'verify $commit is already tagged. You can only use the flag '
'`$kSkipTagging` if the commit has already been tagged.'
);
if (skipTagging && !repository.isCommitTagged(commit)) {
throw Exception(
'The $kSkipTagging flag is only supported for tagged commits.');
}
if (!force) {
git.run(
'merge-base --is-ancestor $lastVersion $commit',
'verify $lastVersion is a direct ancestor of $commit. The flag `$kForce`'
'is required to force push a new release past a cherry-pick',
);
if (!force && !repository.isAncestor(commit, lastVersion.toString())) {
throw Exception(
'The previous dev tag $lastVersion is not a direct ancestor of $commit.\n'
'The flag "$kForce" is required to force push a new release past a cherry-pick.');
}
git.run('reset $commit --hard', 'reset to the release commit');
final String hash = repository.reverseParse(commit);
final String hash = git.getOutput('rev-parse HEAD', 'Get git hash for $commit');
// [commit] can be a prefix for [hash].
assert(hash.startsWith(commit));
// PROMPT
if (autoApprove) {
print('Publishing Flutter $version (${hash.substring(0, 10)}) to the "dev" channel.');
stdio.printStatus(
'Publishing Flutter $version ($hash) 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') {
print('The dev roll has been aborted.');
stdio.printStatus('Your tree is ready to publish Flutter $version '
'($hash) to the "dev" channel.');
stdio.write('Are you? [yes/no] ');
if (stdio.readLineSync() != 'yes') {
stdio.printError('The dev roll has been aborted.');
return false;
}
}
if (!skipTagging) {
git.run('tag $version', 'tag the commit with the version label');
git.run('push $origin $version', 'publish the version');
repository.tag(commit, version.toString(), remoteName);
}
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.',
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
repository.updateChannel(
commit,
remoteName,
'dev',
force: force,
);
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(
kSkipTagging,
negatable: false,
help: 'Do not create tag and push to remote, only update release branch. '
'For recovering when the script fails trying to git push to the release branch.'
);
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);
}
/// Obtain the version tag of the previous dev release.
String getFullTag(Git git, String remote) {
const String glob = '*.*.*-*.*.pre';
// describe the latest dev release
final String ref = 'refs/remotes/$remote/dev';
return git.getOutput(
'describe --match $glob --exact-match --tags $ref',
'obtain last released version number',
stdio.printStatus(
'Flutter version $version has been rolled to the "dev" channel at $remoteUrl.',
);
}
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();
}
/// A wrapper around git process calls that can be mocked for unit testing.
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) {
final StringBuffer message = StringBuffer();
if (result.exitCode != 0) {
message.writeln('Failed to $explanation. Git exited with error code ${result.exitCode}.');
} else {
message.writeln('Failed to $explanation.');
}
if ((result.stdout as String).isNotEmpty)
message.writeln('stdout from git:\n${result.stdout}\n');
if ((result.stderr as String).isNotEmpty)
message.writeln('stderr from git:\n${result.stderr}\n');
throw Exception(message);
}
}
/// 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) {
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".');
}
return getVersionFromParts(parts);
return true;
}
// 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:io';
import 'package:meta/meta.dart';
abstract class Stdio {
/// Error/warning messages printed to STDERR.
void printError(String message);
/// Ordinary STDOUT messages.
void printStatus(String message);
/// Debug messages that are only printed in verbose mode.
void printTrace(String message);
/// Write string to STDOUT without trailing newline.
void write(String message);
/// Read a line of text from STDIN.
String readLineSync();
}
/// A logger that will print out trace messages.
class VerboseStdio extends Stdio {
VerboseStdio({
@required this.stdout,
@required this.stderr,
@required this.stdin,
}) : assert(stdout != null), assert(stderr != null), assert(stdin != null);
final Stdout stdout;
final Stdout stderr;
final Stdin stdin;
@override
void printError(String message) {
stderr.writeln(message);
}
@override
void printStatus(String message) {
stdout.writeln(message);
}
@override
void printTrace(String message) {
stdout.writeln(message);
}
@override
void write(String message) {
stdout.write(message);
}
@override
String readLineSync() {
return stdin.readLineSync();
}
}
// 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:meta/meta.dart';
/// Possible string formats that `flutter --version` can return.
enum VersionType {
/// A stable flutter release.
///
/// Example: '1.2.3'
stable,
/// A pre-stable flutter release.
///
/// Example: '1.2.3-4.5.pre'
development,
/// A master channel flutter version.
///
/// Example: '1.2.3-4.0.pre.10'
///
/// The last number is the number of commits past the last tagged version.
latest,
}
final Map<VersionType, RegExp> versionPatterns = <VersionType, RegExp>{
VersionType.stable: RegExp(r'^(\d+)\.(\d+)\.(\d+)$'),
VersionType.development: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$'),
VersionType.latest: RegExp(r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre\.(\d+)$'),
};
class Version {
Version({
@required this.x,
@required this.y,
@required this.z,
this.m,
this.n,
this.commits,
@required this.type,
}) {
switch (type) {
case VersionType.stable:
assert(m == null);
assert(n == null);
assert(commits == null);
break;
case VersionType.development:
assert(m != null);
assert(n != null);
assert(commits == null);
break;
case VersionType.latest:
assert(m != null);
assert(n != null);
assert(commits != null);
break;
}
}
/// Create a new [Version] from a version string.
///
/// It is expected that [versionString] will be generated by
/// `flutter --version` and match one of `stablePattern`, `developmentPattern`
/// and `latestPattern`.
factory Version.fromString(String versionString) {
assert(versionString != null);
versionString = versionString.trim();
// stable tag
Match match = versionPatterns[VersionType.stable].firstMatch(versionString);
if (match != null) {
// parse stable
final List<int> parts =
match.groups(<int>[1, 2, 3]).map(int.parse).toList();
return Version(
x: parts[0],
y: parts[1],
z: parts[2],
type: VersionType.stable,
);
}
// development tag
match = versionPatterns[VersionType.development].firstMatch(versionString);
if (match != null) {
// parse development
final List<int> parts =
match.groups(<int>[1, 2, 3, 4, 5]).map(int.parse).toList();
return Version(
x: parts[0],
y: parts[1],
z: parts[2],
m: parts[3],
n: parts[4],
type: VersionType.development,
);
}
// latest tag
match = versionPatterns[VersionType.latest].firstMatch(versionString);
if (match != null) {
// parse latest
final List<int> parts =
match.groups(<int>[1, 2, 3, 4, 5, 6]).map(int.parse).toList();
return Version(
x: parts[0],
y: parts[1],
z: parts[2],
m: parts[3],
n: parts[4],
commits: parts[5],
type: VersionType.latest,
);
}
throw Exception('${versionString.trim()} cannot be parsed');
}
// Returns a new version with the given [increment] part incremented.
// NOTE new version must be of same type as previousVersion.
factory Version.increment(
Version previousVersion,
String increment, {
VersionType nextVersionType,
}) {
final int nextX = previousVersion.x;
int nextY = previousVersion.y;
int nextZ = previousVersion.z;
int nextM = previousVersion.m;
int nextN = previousVersion.n;
if (nextVersionType == null) {
if (previousVersion.type == VersionType.latest) {
nextVersionType = VersionType.development;
} else {
nextVersionType = previousVersion.type;
}
}
switch (increment) {
case 'x':
// This was probably a mistake.
throw Exception('Incrementing x is not supported by this tool.');
break;
case 'y':
// Dev release following a beta release.
nextY += 1;
nextZ = 0;
if (previousVersion.type != VersionType.stable) {
nextM = 0;
nextN = 0;
}
break;
case 'z':
// Hotfix to stable release.
assert(previousVersion.type == VersionType.stable);
nextZ += 1;
break;
case 'm':
// Regular dev release.
assert(previousVersion.type == VersionType.development);
assert(nextM != null);
nextM += 1;
nextN = 0;
break;
case 'n':
// Hotfix to internal roll.
nextN += 1;
break;
default:
throw Exception('Unknown increment level $increment.');
}
return Version(
x: nextX,
y: nextY,
z: nextZ,
m: nextM,
n: nextN,
type: nextVersionType,
);
}
/// Major version.
final int x;
/// Zero-indexed count of beta releases after a major release.
final int y;
/// Number of hotfix releases after a stable release.
final int z;
/// Zero-indexed count of dev releases after a beta release.
final int m;
/// Number of hotfixes required to make a dev release.
final int n;
/// Number of commits past last tagged dev release.
final int commits;
final VersionType type;
@override
String toString() {
switch (type) {
case VersionType.stable:
return '$x.$y.$z';
case VersionType.development:
return '$x.$y.$z-$m.$n.pre';
case VersionType.latest:
return '$x.$y.$z-$m.$n.pre.$commits';
}
return null; // For analyzer
}
}
......@@ -8,6 +8,8 @@ environment:
dependencies:
archive: 2.0.13
args: 1.6.0
flutter_tools:
path: '../../packages/flutter_tools'
http: 0.12.2
intl: 0.16.1
meta: 1.3.0-nullsafety.6
......
......@@ -7,6 +7,10 @@ import 'dart:io';
import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
import 'package:test/test.dart' as test_package show TypeMatcher;
import 'package:dev_tools/stdio.dart';
import 'package:args/args.dart';
export 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
// Defines a 'package:test' shim.
......@@ -25,3 +29,111 @@ void tryToDelete(Directory directory) {
print('Failed to delete ${directory.path}: $error');
}
}
Matcher throwsExceptionWith(String messageSubString) {
return throwsA(
isA<Exception>().having(
(Exception e) => e.toString(),
'description',
contains(messageSubString),
),
);
}
class TestStdio implements Stdio {
TestStdio({
this.verbose = false,
List<String> stdin,
}) {
_stdin = stdin ?? <String>[];
}
final StringBuffer _error = StringBuffer();
String get error => _error.toString();
final StringBuffer _stdout = StringBuffer();
String get stdout => _stdout.toString();
final bool verbose;
List<String> _stdin;
@override
void printError(String message) {
_error.writeln(message);
}
@override
void printStatus(String message) {
_stdout.writeln(message);
}
@override
void printTrace(String message) {
if (verbose) {
_stdout.writeln(message);
}
}
@override
void write(String message) {
_stdout.write(message);
}
@override
String readLineSync() {
if (_stdin.isEmpty) {
throw Exception('Unexpected call to readLineSync!');
}
return _stdin.removeAt(0);
}
}
class FakeArgResults implements ArgResults {
FakeArgResults({
String level,
String commit,
String remote,
bool justPrint = false,
bool autoApprove = true, // so we don't have to mock stdin
bool help = false,
bool force = false,
bool skipTagging = false,
}) : _parsedArgs = <String, dynamic>{
'increment': level,
'commit': commit,
'remote': remote,
'just-print': justPrint,
'yes': autoApprove,
'help': help,
'force': force,
'skip-tagging': skipTagging,
};
@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;
}
}
// 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:file/file.dart';
import 'package:file/local.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import 'package:dev_tools/roll_dev.dart' show rollDev;
import 'package:dev_tools/repository.dart';
import 'package:dev_tools/version.dart';
import './common.dart';
void main() {
group('roll-dev', () {
TestStdio stdio;
Platform platform;
ProcessManager processManager;
FileSystem fileSystem;
const String usageString = 'Usage: flutter conductor.';
Checkouts checkouts;
Repository frameworkUpstream;
Repository framework;
setUp(() {
platform = const LocalPlatform();
fileSystem = const LocalFileSystem();
processManager = const LocalProcessManager();
stdio = TestStdio(verbose: true);
checkouts = Checkouts(
fileSystem: fileSystem,
platform: platform,
processManager: processManager,
);
frameworkUpstream = checkouts.addRepo(
repoType: RepositoryType.framework,
name: 'framework-upstream',
stdio: stdio,
platform: platform,
localUpstream: true,
fileSystem: fileSystem,
useExistingCheckout: false,
);
// This repository has [frameworkUpstream] set as its push/pull remote.
framework = frameworkUpstream.cloneRepository('test-framework');
});
test('increment m', () {
final Version initialVersion = framework.flutterVersion();
final String latestCommit = framework.authorEmptyCommit();
final FakeArgResults fakeArgResults = FakeArgResults(
level: 'm',
commit: latestCommit,
remote: 'origin',
);
expect(
rollDev(
usage: usageString,
argResults: fakeArgResults,
stdio: stdio,
fileSystem: fileSystem,
platform: platform,
repository: framework,
),
true,
);
expect(
stdio.stdout,
contains(RegExp(r'Publishing Flutter \d+\.\d+\.\d+-\d+\.\d+\.pre \(')),
);
final Version finalVersion = framework.flutterVersion();
expect(
initialVersion.toString() != finalVersion.toString(),
true,
reason: 'initialVersion = $initialVersion; finalVersion = $finalVersion',
);
expect(finalVersion.n, 0);
expect(finalVersion.commits, null);
});
test('increment y', () {
final Version initialVersion = framework.flutterVersion();
final String latestCommit = framework.authorEmptyCommit();
final FakeArgResults fakeArgResults = FakeArgResults(
level: 'y',
commit: latestCommit,
remote: 'origin',
);
expect(
rollDev(
usage: usageString,
argResults: fakeArgResults,
stdio: stdio,
fileSystem: fileSystem,
platform: platform,
repository: framework,
),
true,
);
expect(
stdio.stdout,
contains(RegExp(r'Publishing Flutter \d+\.\d+\.\d+-\d+\.\d+\.pre \(')),
);
final Version finalVersion = framework.flutterVersion();
expect(
initialVersion.toString() != finalVersion.toString(),
true,
reason: 'initialVersion = $initialVersion; finalVersion = $finalVersion',
);
expect(finalVersion.y, initialVersion.y + 1);
expect(finalVersion.z, 0);
expect(finalVersion.m, 0);
expect(finalVersion.n, 0);
expect(finalVersion.commits, null);
});
}, onPlatform: <String, dynamic>{
'windows': const Skip('Flutter Conductor only supported on macos/linux'),
});
}
......@@ -2,41 +2,48 @@
// 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:file/memory.dart';
import 'package:platform/platform.dart';
import 'package:dev_tools/roll_dev.dart';
import 'package:mockito/mockito.dart';
import 'package:dev_tools/globals.dart';
import 'package:dev_tools/repository.dart';
import '../../../packages/flutter_tools/test/src/fake_process_manager.dart';
import './common.dart';
void main() {
group('run()', () {
group('rollDev()', () {
const String usage = 'usage info...';
const String level = 'z';
const String level = 'm';
const String commit = 'abcde012345';
const String origin = 'upstream';
const String remote = 'origin';
const String lastVersion = '1.2.0-0.0.pre';
const String nextVersion = '1.2.0-1.0.pre';
const String checkoutsParentDirectory = '/path/to/directory/';
FakeArgResults fakeArgResults;
MockGit mockGit;
MemoryFileSystem fileSystem;
TestStdio stdio;
Repository repo;
Checkouts checkouts;
FakePlatform platform;
FakeProcessManager processManager;
setUp(() {
mockGit = MockGit();
});
test('returns false if help requested', () {
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
help: true,
stdio = TestStdio();
fileSystem = MemoryFileSystem.test();
platform = FakePlatform();
processManager = FakeProcessManager.list(<FakeCommand>[]);
checkouts = Checkouts(
fileSystem: fileSystem,
parentDirectory: fileSystem.directory(checkoutsParentDirectory),
platform: platform,
processManager: processManager,
);
expect(
run(
usage: usage,
argResults: fakeArgResults,
git: mockGit,
),
false,
repo = checkouts.addRepo(
platform: platform,
repoType: RepositoryType.framework,
stdio: stdio,
);
});
......@@ -44,13 +51,16 @@ void main() {
fakeArgResults = FakeArgResults(
level: null,
commit: commit,
origin: origin,
remote: remote,
);
expect(
run(
usage: usage,
rollDev(
argResults: fakeArgResults,
git: mockGit,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
usage: usage,
),
false,
);
......@@ -60,470 +70,631 @@ void main() {
fakeArgResults = FakeArgResults(
level: level,
commit: null,
origin: origin,
remote: remote,
);
expect(
run(
usage: usage,
rollDev(
argResults: fakeArgResults,
git: mockGit,
),
false,
);
});
test('throws exception if upstream remote wrong', () {
const String remote = 'wrong-remote';
when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(remote);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
);
const String errorMessage = 'The remote named $origin is set to $remote, when $kUpstreamRemote was expected.';
expect(
() => run(
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
usage: usage,
argResults: fakeArgResults,
git: mockGit,
),
throwsExceptionWith(errorMessage),
false,
);
});
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',
);
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
], stdout: ' M dev/conductor/bin/conductor.dart'),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
);
Exception exception;
try {
run(
usage: usage,
rollDev(
argResults: fakeArgResults,
git: mockGit,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
usage: usage,
);
} 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.';
'"git clean -fd". Warning, this will delete files! Run with -n to find '
'out which ones.';
expect(exception?.toString(), contains(pattern));
});
test('does not reset or 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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
justPrint: true,
);
expect(run(
usage: usage,
argResults: fakeArgResults,
git: mockGit,
), false);
verify(mockGit.run('fetch $origin', any));
verifyNever(mockGit.run('reset $commit --hard', any));
verifyNever(mockGit.getOutput('rev-parse HEAD', any));
expect(
rollDev(
usage: usage,
argResults: fakeArgResults,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
),
false,
);
expect(stdio.stdout.contains(nextVersion), true);
});
test('exits with exception if --skip-tagging is provided but commit isn\'t '
'already tagged', () {
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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
const String exceptionMessage = 'Failed to verify $commit is already '
'tagged. You can only use the flag `$kSkipTagging` if the commit has '
'already been tagged.';
when(mockGit.run(
'describe --exact-match --tags $commit',
any,
)).thenThrow(Exception(exceptionMessage));
test(
'exits with exception if --skip-tagging is provided but commit isn\'t '
'already tagged', () {
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
const FakeCommand(command: <String>[
'git',
'describe',
'--exact-match',
'--tags',
commit,
], exitCode: 1),
]);
const String exceptionMessage =
'The $kSkipTagging flag is only supported '
'for tagged commits.';
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
skipTagging: true,
);
expect(
() => run(
() => rollDev(
usage: usage,
argResults: fakeArgResults,
git: mockGit,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
),
throwsExceptionWith(exceptionMessage),
);
verify(mockGit.run('fetch $origin', any));
verifyNever(mockGit.run('reset $commit --hard', any));
verifyNever(mockGit.getOutput('rev-parse HEAD', any));
});
test('throws exception if desired commit is already tip of dev branch', () {
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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn(commit);
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
// [commit] is already [lastVersion]
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: commit),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
justPrint: true,
);
expect(
() => run(
() => rollDev(
usage: usage,
argResults: fakeArgResults,
git: mockGit,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
),
throwsExceptionWith(
'Commit $commit is already on the dev branch as $lastVersion',
),
throwsExceptionWith('is already on the dev branch as'),
);
verify(mockGit.run('fetch $origin', any));
verifyNever(mockGit.run('reset $commit --hard', any));
verifyNever(mockGit.getOutput('rev-parse HEAD', any));
});
test('does not tag if last release is not direct ancestor of desired '
test(
'does not tag if last release is not direct ancestor of desired '
'commit and --force not supplied', () {
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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
when(mockGit.run('merge-base --is-ancestor $lastVersion $commit', any))
.thenThrow(Exception(
'Failed to verify $lastVersion is a direct ancestor of $commit. The '
'flag `--force` is required to force push a new release past a '
'cherry-pick',
));
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
const FakeCommand(command: <String>[
'git',
'merge-base',
'--is-ancestor',
lastVersion,
commit,
], exitCode: 1),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
);
const String errorMessage = 'Failed to verify $lastVersion is a direct '
'ancestor of $commit. The flag `--force` is required to force push a '
'new release past a cherry-pick';
const String errorMessage = 'The previous dev tag $lastVersion is not a '
'direct ancestor of $commit.';
expect(
() => run(
() => rollDev(
argResults: fakeArgResults,
git: mockGit,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
usage: usage,
),
throwsExceptionWith(errorMessage),
);
verify(mockGit.run('fetch $origin', any));
verifyNever(mockGit.run('reset $commit --hard', any));
verifyNever(mockGit.run('push $origin HEAD:dev', any));
verifyNever(mockGit.run('tag $nextVersion', any));
});
test('does not tag but updates branch if --skip-tagging provided', () {
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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
const FakeCommand(command: <String>[
'git',
'describe',
'--exact-match',
'--tags',
commit,
]),
const FakeCommand(command: <String>[
'git',
'merge-base',
'--is-ancestor',
lastVersion,
commit,
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'push',
remote,
'$commit:dev',
]),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
skipTagging: true,
);
expect(run(
usage: usage,
argResults: fakeArgResults,
git: mockGit,
), true);
verify(mockGit.run('fetch $origin', any));
verify(mockGit.run('reset $commit --hard', any));
verifyNever(mockGit.run('tag $nextVersion', any));
verifyNever(mockGit.run('push $origin $nextVersion', any));
verify(mockGit.run('push $origin HEAD:dev', any));
expect(
rollDev(
usage: usage,
argResults: fakeArgResults,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
),
true,
);
});
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/remotes/$origin/dev',
any,
)).thenReturn('1.2.0-0.0.pre');
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
const FakeCommand(command: <String>[
'git',
'merge-base',
'--is-ancestor',
lastVersion,
commit,
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'tag',
nextVersion,
commit,
]),
const FakeCommand(command: <String>[
'git',
'push',
remote,
nextVersion,
]),
const FakeCommand(command: <String>[
'git',
'push',
remote,
'$commit:dev',
]),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
);
expect(
rollDev(
usage: usage,
argResults: fakeArgResults,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
),
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 $nextVersion', any));
verify(mockGit.run('push $origin $nextVersion', 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/remotes/$origin/dev',
any,
)).thenReturn(lastVersion);
when(mockGit.getOutput(
'rev-parse $lastVersion',
any,
)).thenReturn('zxy321');
when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
processManager.addCommands(<FakeCommand>[
const FakeCommand(command: <String>[
'git',
'clone',
'--',
kUpstreamRemote,
'${checkoutsParentDirectory}checkouts/framework',
]),
const FakeCommand(command: <String>[
'git',
'remote',
'get-url',
remote,
], stdout: kUpstreamRemote),
const FakeCommand(command: <String>[
'git',
'status',
'--porcelain',
]),
const FakeCommand(command: <String>[
'git',
'fetch',
remote,
'--tags',
]),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'describe',
'--match',
'*.*.*-*.*.pre',
'--exact-match',
'--tags',
'refs/remotes/$remote/dev',
], stdout: lastVersion),
const FakeCommand(command: <String>[
'git',
'rev-parse',
lastVersion,
], stdout: 'zxy321'),
const FakeCommand(command: <String>[
'git',
'rev-parse',
commit,
], stdout: commit),
const FakeCommand(command: <String>[
'git',
'tag',
nextVersion,
commit,
]),
const FakeCommand(command: <String>[
'git',
'push',
remote,
nextVersion,
]),
const FakeCommand(command: <String>[
'git',
'push',
'--force',
remote,
'$commit:dev',
]),
]);
fakeArgResults = FakeArgResults(
level: level,
commit: commit,
origin: origin,
remote: remote,
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 $nextVersion', 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',
'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);
expect(match, isNotNull, reason: 'Expected $validTag to be parsed');
}
});
test('returns null on invalid version input', () {
final List<String> invalidTags = <String>[
'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',
'1.2.3-1.2.pre_',
];
for (final String invalidTag in invalidTags) {
final Match match = parseFullTag(invalidTag);
expect(match, null, reason: 'Expected $invalidTag to not be parsed');
}
});
});
group('getVersionFromParts', () {
test('returns correct string from valid parts', () {
List<int> parts = <int>[1, 2, 3, 4, 5];
expect(getVersionFromParts(parts), '1.2.3-4.5.pre');
parts = <int>[11, 2, 33, 1, 0];
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),
throwsExceptionWith('Git reported the latest version as "$version"'),
reason: 'should throw because $version should be an exact tag',
);
version = '1.2.3';
expect(
() => incrementLevel(version, level),
throwsExceptionWith('Git reported the latest version as "$version"'),
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),
throwsExceptionWith('Git reported the latest version as "$version"'),
reason: 'should throw because $level is unsupported',
rollDev(
argResults: fakeArgResults,
fileSystem: fileSystem,
platform: platform,
repository: repo,
stdio: stdio,
usage: usage,
),
true,
);
expect(processManager.hasRemainingExpectations, false);
});
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');
});
}, onPlatform: <String, dynamic>{
'windows': const Skip('Flutter Conductor only supported on macos/linux'),
});
}
Matcher throwsExceptionWith(String messageSubString) {
return throwsA(
isA<Exception>().having(
(Exception e) => e.toString(),
'description',
contains(messageSubString),
),
);
}
class FakeArgResults implements ArgResults {
FakeArgResults({
String level,
String commit,
String origin,
bool justPrint = false,
bool autoApprove = true, // so we don't have to mock stdin
bool help = false,
bool force = false,
bool skipTagging = false,
}) : _parsedArgs = <String, dynamic>{
'increment': level,
'commit': commit,
'origin': origin,
'just-print': justPrint,
'yes': autoApprove,
'help': help,
'force': force,
'skip-tagging': skipTagging,
};
@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 {}
// 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:dev_tools/version.dart';
import './common.dart';
void main() {
group('Version.increment()', () {
test('throws exception on nonsensical `level`', () {
final List<String> levels = <String>['f', '0', 'xyz'];
for (final String level in levels) {
final Version version = Version.fromString('1.0.0-0.0.pre');
expect(
() => Version.increment(version, level).toString(),
throwsExceptionWith('Unknown increment level $level.'),
);
}
});
test('does not support incrementing x', () {
const String level = 'x';
final Version version = Version.fromString('1.0.0-0.0.pre');
expect(
() => Version.increment(version, level).toString(),
throwsExceptionWith(
'Incrementing $level is not supported by this tool'),
);
});
test('successfully increments y', () {
const String level = 'y';
Version version = Version.fromString('1.0.0-0.0.pre');
expect(Version.increment(version, level).toString(), '1.1.0-0.0.pre');
version = Version.fromString('10.20.0-40.50.pre');
expect(Version.increment(version, level).toString(), '10.21.0-0.0.pre');
version = Version.fromString('1.18.0-3.0.pre');
expect(Version.increment(version, level).toString(), '1.19.0-0.0.pre');
});
test('successfully increments z', () {
const String level = 'm';
Version version = Version.fromString('1.0.0-0.0.pre');
expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre');
version = Version.fromString('10.20.0-40.50.pre');
expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre');
version = Version.fromString('1.18.0-3.0.pre');
expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre');
});
test('successfully increments m', () {
const String level = 'm';
Version version = Version.fromString('1.0.0-0.0.pre');
expect(Version.increment(version, level).toString(), '1.0.0-1.0.pre');
version = Version.fromString('10.20.0-40.50.pre');
expect(Version.increment(version, level).toString(), '10.20.0-41.0.pre');
version = Version.fromString('1.18.0-3.0.pre');
expect(Version.increment(version, level).toString(), '1.18.0-4.0.pre');
});
test('successfully increments n', () {
const String level = 'n';
Version version = Version.fromString('1.0.0-0.0.pre');
expect(Version.increment(version, level).toString(), '1.0.0-0.1.pre');
version = Version.fromString('10.20.0-40.50.pre');
expect(Version.increment(version, level).toString(), '10.20.0-40.51.pre');
version = Version.fromString('1.18.0-3.0.pre');
expect(Version.increment(version, level).toString(), '1.18.0-3.1.pre');
});
}, onPlatform: <String, dynamic>{
'windows': const Skip('Flutter Conductor only supported on macos/linux'),
});
}
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