roll_dev.dart 9.45 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12
// 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';
13
import 'package:meta/meta.dart';
14 15 16 17 18

const String kIncrement = 'increment';
const String kX = 'x';
const String kY = 'y';
const String kZ = 'z';
19
const String kCommit = 'commit';
20
const String kOrigin = 'origin';
21
const String kJustPrint = 'just-print';
22
const String kYes = 'yes';
23
const String kHelp = 'help';
24
const String kForce = 'force';
25
const String kSkipTagging = 'skip-tagging';
26

27
const String kUpstreamRemote = 'git@github.com:flutter/flutter.git';
28

29
void main(List<String> args) {
30
  final ArgParser argParser = ArgParser(allowTrailingOptions: false);
31

32 33
  ArgResults argResults;
  try {
34
    argResults = parseArguments(argParser, args);
35 36 37 38 39 40
  } on ArgParserException catch (error) {
    print(error.message);
    print(argParser.usage);
    exit(1);
  }

41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
  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,
}) {
61 62 63 64 65 66
  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;
67
  final bool force = argResults[kForce] as bool;
68
  final bool skipTagging = argResults[kSkipTagging] as bool;
69

70
  if (help || level == null || commit == null) {
71 72 73 74 75
    print(
      'roll_dev.dart --increment=level --commit=hash • update the version tags '
      'and roll a new dev build.\n$usage'
    );
    return false;
76 77
  }

78 79 80 81 82 83
  final String remote = git.getOutput(
    'remote get-url $origin',
    'check whether this is a flutter checkout',
  );
  if (remote != kUpstreamRemote) {
    throw Exception(
84 85
      'The remote named $origin is set to $remote, when $kUpstreamRemote was '
      'expected.\nFor more details see: '
86 87
      'https://github.com/flutter/flutter/wiki/Release-process'
    );
88 89
  }

90 91 92 93 94
  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.'
    );
95 96
  }

97
  git.run('fetch $origin', 'fetch $origin');
98

99 100 101 102 103
  final String lastVersion = getFullTag(git, origin);

  final String version = skipTagging
    ? lastVersion
    : incrementLevel(lastVersion, level);
104

105 106 107 108 109 110
  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.');
  }
111

112 113
  if (justPrint) {
    print(version);
114
    return false;
115 116
  }

117 118 119 120 121 122 123
  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.'
    );
  }
124

125 126 127 128 129 130 131 132 133 134 135
  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');
136 137

  // PROMPT
138

139 140 141 142 143 144 145 146
  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.');
147
      return false;
148
    }
149 150
  }

151 152 153 154
  if (!skipTagging) {
    git.run('tag $version', 'tag the commit with the version label');
    git.run('push $origin $version', 'publish the version');
  }
155 156 157 158
  git.run(
    'push ${force ? "--force " : ""}$origin HEAD:dev',
    'land the new version on the "dev" branch',
  );
159
  print('Flutter version $version has been rolled to the "dev" channel!');
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
  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.',
  );
200 201 202 203 204 205
  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.'
  );
206 207 208 209
  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);
210 211
}

212
/// Obtain the version tag of the previous dev release.
Christopher Fujino's avatar
Christopher Fujino committed
213
String getFullTag(Git git, String remote) {
214
  const String glob = '*.*.*-*.*.pre';
215
  // describe the latest dev release
Christopher Fujino's avatar
Christopher Fujino committed
216
  final String ref = 'refs/remotes/$remote/dev';
217 218
  return git.getOutput(
    'describe --match $glob --exact-match --tags $ref',
219 220 221 222 223
    'obtain last released version number',
  );
}

Match parseFullTag(String version) {
224
  // of the form: x.y.z-m.n.pre
225
  final RegExp versionPattern = RegExp(
226
    r'^(\d+)\.(\d+)\.(\d+)-(\d+)\.(\d+)\.pre$');
227 228 229
  return versionPattern.matchAsPrefix(version);
}

230
String getVersionFromParts(List<int> parts) {
231
  // where parts correspond to [x, y, z, m, n] from tag
232 233
  assert(parts.length == 5);
  final StringBuffer buf = StringBuffer()
234
    // take x, y, and z
235
    ..write(parts.take(3).join('.'))
236 237 238 239 240
    ..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'
241 242 243
  return buf.toString();
}

244
/// A wrapper around git process calls that can be mocked for unit testing.
245 246
class Git {
  const Git();
247

248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
  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(' '));
  }
265

266
  void _reportFailureAndExit(ProcessResult result, String explanation) {
267
    final StringBuffer message = StringBuffer();
268
    if (result.exitCode != 0) {
269
      message.writeln('Failed to $explanation. Git exited with error code ${result.exitCode}.');
270
    } else {
271
      message.writeln('Failed to $explanation.');
272 273
    }
    if ((result.stdout as String).isNotEmpty)
274
      message.writeln('stdout from git:\n${result.stdout}\n');
275
    if ((result.stderr as String).isNotEmpty)
276 277
      message.writeln('stderr from git:\n${result.stderr}\n');
    throw Exception(message);
278
  }
279 280
}

281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317
/// 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".');
318
  }
319
  return getVersionFromParts(parts);
320
}