blob: 31a9cab423d41ba6f0edbb1bd8ab7cb75a9d8d05 [file] [log] [blame]
// 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';
import 'package:args/args.dart';
import 'package:meta/meta.dart';
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';
void main(List<String> args) {
final ArgParser argParser = ArgParser(allowTrailingOptions: false);
ArgResults argResults;
try {
argResults = parseArguments(argParser, args);
} on ArgParserException catch (error) {
print(error.message);
print(argParser.usage);
exit(1);
}
try {
run(
usage: argParser.usage,
argResults: argResults,
git: const Git(),
);
} on Exception catch (e) {
print(e.toString());
exit(1);
}
}
/// Main script execution.
///
/// Returns true if publishing was successful, else false.
bool run({
@required String usage,
@required ArgResults argResults,
@required Git git,
}) {
final String level = argResults[kIncrement] as String;
final String commit = argResults[kCommit] as String;
final String origin = argResults[kOrigin] as String;
final bool justPrint = argResults[kJustPrint] as bool;
final bool autoApprove = argResults[kYes] as bool;
final bool help = argResults[kHelp] as bool;
final bool force = argResults[kForce] as bool;
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'
);
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'
);
}
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.'
);
}
git.run('fetch $origin', 'fetch $origin');
final String lastVersion = getFullTag(git, origin);
final String version = skipTagging
? lastVersion
: incrementLevel(lastVersion, level);
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 (justPrint) {
print(version);
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 (!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',
);
}
git.run('reset $commit --hard', 'reset to the release commit');
final String hash = git.getOutput('rev-parse HEAD', 'Get git hash for $commit');
// 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') {
print('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');
}
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
);
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',
);
}
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);
}