Home | History | Annotate | Download | only in release
      1 #!/usr/bin/env python
      2 # Copyright 2013 the V8 project authors. All rights reserved.
      3 # Redistribution and use in source and binary forms, with or without
      4 # modification, are permitted provided that the following conditions are
      5 # met:
      6 #
      7 #     * Redistributions of source code must retain the above copyright
      8 #       notice, this list of conditions and the following disclaimer.
      9 #     * Redistributions in binary form must reproduce the above
     10 #       copyright notice, this list of conditions and the following
     11 #       disclaimer in the documentation and/or other materials provided
     12 #       with the distribution.
     13 #     * Neither the name of Google Inc. nor the names of its
     14 #       contributors may be used to endorse or promote products derived
     15 #       from this software without specific prior written permission.
     16 #
     17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
     18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
     19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
     20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
     21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
     22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
     23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
     24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
     25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
     27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     28 
     29 import argparse
     30 import datetime
     31 import httplib
     32 import glob
     33 import imp
     34 import json
     35 import os
     36 import re
     37 import shutil
     38 import subprocess
     39 import sys
     40 import textwrap
     41 import time
     42 import urllib
     43 import urllib2
     44 
     45 from git_recipes import GitRecipesMixin
     46 from git_recipes import GitFailedException
     47 
     48 CHANGELOG_FILE = "ChangeLog"
     49 DAY_IN_SECONDS = 24 * 60 * 60
     50 PUSH_MSG_GIT_RE = re.compile(r".* \(based on (?P<git_rev>[a-fA-F0-9]+)\)$")
     51 PUSH_MSG_NEW_RE = re.compile(r"^Version \d+\.\d+\.\d+$")
     52 VERSION_FILE = os.path.join("include", "v8-version.h")
     53 WATCHLISTS_FILE = "WATCHLISTS"
     54 
     55 # V8 base directory.
     56 V8_BASE = os.path.dirname(
     57     os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
     58 
     59 
     60 def TextToFile(text, file_name):
     61   with open(file_name, "w") as f:
     62     f.write(text)
     63 
     64 
     65 def AppendToFile(text, file_name):
     66   with open(file_name, "a") as f:
     67     f.write(text)
     68 
     69 
     70 def LinesInFile(file_name):
     71   with open(file_name) as f:
     72     for line in f:
     73       yield line
     74 
     75 
     76 def FileToText(file_name):
     77   with open(file_name) as f:
     78     return f.read()
     79 
     80 
     81 def MSub(rexp, replacement, text):
     82   return re.sub(rexp, replacement, text, flags=re.MULTILINE)
     83 
     84 
     85 def Fill80(line):
     86   # Replace tabs and remove surrounding space.
     87   line = re.sub(r"\t", r"        ", line.strip())
     88 
     89   # Format with 8 characters indentation and line width 80.
     90   return textwrap.fill(line, width=80, initial_indent="        ",
     91                        subsequent_indent="        ")
     92 
     93 
     94 def MakeComment(text):
     95   return MSub(r"^( ?)", "#", text)
     96 
     97 
     98 def StripComments(text):
     99   # Use split not splitlines to keep terminal newlines.
    100   return "\n".join(filter(lambda x: not x.startswith("#"), text.split("\n")))
    101 
    102 
    103 def MakeChangeLogBody(commit_messages, auto_format=False):
    104   result = ""
    105   added_titles = set()
    106   for (title, body, author) in commit_messages:
    107     # TODO(machenbach): Better check for reverts. A revert should remove the
    108     # original CL from the actual log entry.
    109     title = title.strip()
    110     if auto_format:
    111       # Only add commits that set the LOG flag correctly.
    112       log_exp = r"^[ \t]*LOG[ \t]*=[ \t]*(?:(?:Y(?:ES)?)|TRUE)"
    113       if not re.search(log_exp, body, flags=re.I | re.M):
    114         continue
    115       # Never include reverts.
    116       if title.startswith("Revert "):
    117         continue
    118       # Don't include duplicates.
    119       if title in added_titles:
    120         continue
    121 
    122     # Add and format the commit's title and bug reference. Move dot to the end.
    123     added_titles.add(title)
    124     raw_title = re.sub(r"(\.|\?|!)$", "", title)
    125     bug_reference = MakeChangeLogBugReference(body)
    126     space = " " if bug_reference else ""
    127     result += "%s\n" % Fill80("%s%s%s." % (raw_title, space, bug_reference))
    128 
    129     # Append the commit's author for reference if not in auto-format mode.
    130     if not auto_format:
    131       result += "%s\n" % Fill80("(%s)" % author.strip())
    132 
    133     result += "\n"
    134   return result
    135 
    136 
    137 def MakeChangeLogBugReference(body):
    138   """Grep for "BUG=xxxx" lines in the commit message and convert them to
    139   "(issue xxxx)".
    140   """
    141   crbugs = []
    142   v8bugs = []
    143 
    144   def AddIssues(text):
    145     ref = re.match(r"^BUG[ \t]*=[ \t]*(.+)$", text.strip())
    146     if not ref:
    147       return
    148     for bug in ref.group(1).split(","):
    149       bug = bug.strip()
    150       match = re.match(r"^v8:(\d+)$", bug)
    151       if match: v8bugs.append(int(match.group(1)))
    152       else:
    153         match = re.match(r"^(?:chromium:)?(\d+)$", bug)
    154         if match: crbugs.append(int(match.group(1)))
    155 
    156   # Add issues to crbugs and v8bugs.
    157   map(AddIssues, body.splitlines())
    158 
    159   # Filter duplicates, sort, stringify.
    160   crbugs = map(str, sorted(set(crbugs)))
    161   v8bugs = map(str, sorted(set(v8bugs)))
    162 
    163   bug_groups = []
    164   def FormatIssues(prefix, bugs):
    165     if len(bugs) > 0:
    166       plural = "s" if len(bugs) > 1 else ""
    167       bug_groups.append("%sissue%s %s" % (prefix, plural, ", ".join(bugs)))
    168 
    169   FormatIssues("", v8bugs)
    170   FormatIssues("Chromium ", crbugs)
    171 
    172   if len(bug_groups) > 0:
    173     return "(%s)" % ", ".join(bug_groups)
    174   else:
    175     return ""
    176 
    177 
    178 def SortingKey(version):
    179   """Key for sorting version number strings: '3.11' > '3.2.1.1'"""
    180   version_keys = map(int, version.split("."))
    181   # Fill up to full version numbers to normalize comparison.
    182   while len(version_keys) < 4:  # pragma: no cover
    183     version_keys.append(0)
    184   # Fill digits.
    185   return ".".join(map("{0:04d}".format, version_keys))
    186 
    187 
    188 # Some commands don't like the pipe, e.g. calling vi from within the script or
    189 # from subscripts like git cl upload.
    190 def Command(cmd, args="", prefix="", pipe=True, cwd=None):
    191   cwd = cwd or os.getcwd()
    192   # TODO(machenbach): Use timeout.
    193   cmd_line = "%s %s %s" % (prefix, cmd, args)
    194   print "Command: %s" % cmd_line
    195   print "in %s" % cwd
    196   sys.stdout.flush()
    197   try:
    198     if pipe:
    199       return subprocess.check_output(cmd_line, shell=True, cwd=cwd)
    200     else:
    201       return subprocess.check_call(cmd_line, shell=True, cwd=cwd)
    202   except subprocess.CalledProcessError:
    203     return None
    204   finally:
    205     sys.stdout.flush()
    206     sys.stderr.flush()
    207 
    208 
    209 def SanitizeVersionTag(tag):
    210     version_without_prefix = re.compile(r"^\d+\.\d+\.\d+(?:\.\d+)?$")
    211     version_with_prefix = re.compile(r"^tags\/\d+\.\d+\.\d+(?:\.\d+)?$")
    212 
    213     if version_without_prefix.match(tag):
    214       return tag
    215     elif version_with_prefix.match(tag):
    216         return tag[len("tags/"):]
    217     else:
    218       return None
    219 
    220 
    221 def NormalizeVersionTags(version_tags):
    222   normalized_version_tags = []
    223 
    224   # Remove tags/ prefix because of packed refs.
    225   for current_tag in version_tags:
    226     version_tag = SanitizeVersionTag(current_tag)
    227     if version_tag != None:
    228       normalized_version_tags.append(version_tag)
    229 
    230   return normalized_version_tags
    231 
    232 
    233 # Wrapper for side effects.
    234 class SideEffectHandler(object):  # pragma: no cover
    235   def Call(self, fun, *args, **kwargs):
    236     return fun(*args, **kwargs)
    237 
    238   def Command(self, cmd, args="", prefix="", pipe=True, cwd=None):
    239     return Command(cmd, args, prefix, pipe, cwd=cwd)
    240 
    241   def ReadLine(self):
    242     return sys.stdin.readline().strip()
    243 
    244   def ReadURL(self, url, params=None):
    245     # pylint: disable=E1121
    246     url_fh = urllib2.urlopen(url, params, 60)
    247     try:
    248       return url_fh.read()
    249     finally:
    250       url_fh.close()
    251 
    252   def ReadClusterFuzzAPI(self, api_key, **params):
    253     params["api_key"] = api_key.strip()
    254     params = urllib.urlencode(params)
    255 
    256     headers = {"Content-type": "application/x-www-form-urlencoded"}
    257 
    258     conn = httplib.HTTPSConnection("backend-dot-cluster-fuzz.appspot.com")
    259     conn.request("POST", "/_api/", params, headers)
    260 
    261     response = conn.getresponse()
    262     data = response.read()
    263 
    264     try:
    265       return json.loads(data)
    266     except:
    267       print data
    268       print "ERROR: Could not read response. Is your key valid?"
    269       raise
    270 
    271   def Sleep(self, seconds):
    272     time.sleep(seconds)
    273 
    274   def GetDate(self):
    275     return datetime.date.today().strftime("%Y-%m-%d")
    276 
    277   def GetUTCStamp(self):
    278     return time.mktime(datetime.datetime.utcnow().timetuple())
    279 
    280 DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()
    281 
    282 
    283 class NoRetryException(Exception):
    284   pass
    285 
    286 
    287 class VCInterface(object):
    288   def InjectStep(self, step):
    289     self.step=step
    290 
    291   def Pull(self):
    292     raise NotImplementedError()
    293 
    294   def Fetch(self):
    295     raise NotImplementedError()
    296 
    297   def GetTags(self):
    298     raise NotImplementedError()
    299 
    300   def GetBranches(self):
    301     raise NotImplementedError()
    302 
    303   def MasterBranch(self):
    304     raise NotImplementedError()
    305 
    306   def CandidateBranch(self):
    307     raise NotImplementedError()
    308 
    309   def RemoteMasterBranch(self):
    310     raise NotImplementedError()
    311 
    312   def RemoteCandidateBranch(self):
    313     raise NotImplementedError()
    314 
    315   def RemoteBranch(self, name):
    316     raise NotImplementedError()
    317 
    318   def CLLand(self):
    319     raise NotImplementedError()
    320 
    321   def Tag(self, tag, remote, message):
    322     """Sets a tag for the current commit.
    323 
    324     Assumptions: The commit already landed and the commit message is unique.
    325     """
    326     raise NotImplementedError()
    327 
    328 
    329 class GitInterface(VCInterface):
    330   def Pull(self):
    331     self.step.GitPull()
    332 
    333   def Fetch(self):
    334     self.step.Git("fetch")
    335 
    336   def GetTags(self):
    337      return self.step.Git("tag").strip().splitlines()
    338 
    339   def GetBranches(self):
    340     # Get relevant remote branches, e.g. "branch-heads/3.25".
    341     branches = filter(
    342         lambda s: re.match(r"^branch\-heads/\d+\.\d+$", s),
    343         self.step.GitRemotes())
    344     # Remove 'branch-heads/' prefix.
    345     return map(lambda s: s[13:], branches)
    346 
    347   def MasterBranch(self):
    348     return "master"
    349 
    350   def CandidateBranch(self):
    351     return "candidates"
    352 
    353   def RemoteMasterBranch(self):
    354     return "origin/master"
    355 
    356   def RemoteCandidateBranch(self):
    357     return "origin/candidates"
    358 
    359   def RemoteBranch(self, name):
    360     # Assume that if someone "fully qualified" the ref, they know what they
    361     # want.
    362     if name.startswith('refs/'):
    363       return name
    364     if name in ["candidates", "master"]:
    365       return "refs/remotes/origin/%s" % name
    366     try:
    367       # Check if branch is in heads.
    368       if self.step.Git("show-ref refs/remotes/origin/%s" % name).strip():
    369         return "refs/remotes/origin/%s" % name
    370     except GitFailedException:
    371       pass
    372     try:
    373       # Check if branch is in branch-heads.
    374       if self.step.Git("show-ref refs/remotes/branch-heads/%s" % name).strip():
    375         return "refs/remotes/branch-heads/%s" % name
    376     except GitFailedException:
    377       pass
    378     self.Die("Can't find remote of %s" % name)
    379 
    380   def Tag(self, tag, remote, message):
    381     # Wait for the commit to appear. Assumes unique commit message titles (this
    382     # is the case for all automated merge and push commits - also no title is
    383     # the prefix of another title).
    384     commit = None
    385     for wait_interval in [10, 30, 60, 60, 60, 60, 60]:
    386       self.step.Git("fetch")
    387       commit = self.step.GitLog(n=1, format="%H", grep=message, branch=remote)
    388       if commit:
    389         break
    390       print("The commit has not replicated to git. Waiting for %s seconds." %
    391             wait_interval)
    392       self.step._side_effect_handler.Sleep(wait_interval)
    393     else:
    394       self.step.Die("Couldn't determine commit for setting the tag. Maybe the "
    395                     "git updater is lagging behind?")
    396 
    397     self.step.Git("tag %s %s" % (tag, commit))
    398     self.step.Git("push origin %s" % tag)
    399 
    400   def CLLand(self):
    401     self.step.GitCLLand()
    402 
    403 
    404 class Step(GitRecipesMixin):
    405   def __init__(self, text, number, config, state, options, handler):
    406     self._text = text
    407     self._number = number
    408     self._config = config
    409     self._state = state
    410     self._options = options
    411     self._side_effect_handler = handler
    412     self.vc = GitInterface()
    413     self.vc.InjectStep(self)
    414 
    415     # The testing configuration might set a different default cwd.
    416     self.default_cwd = (self._config.get("DEFAULT_CWD") or
    417                         os.path.join(self._options.work_dir, "v8"))
    418 
    419     assert self._number >= 0
    420     assert self._config is not None
    421     assert self._state is not None
    422     assert self._side_effect_handler is not None
    423 
    424   def __getitem__(self, key):
    425     # Convenience method to allow direct [] access on step classes for
    426     # manipulating the backed state dict.
    427     return self._state.get(key)
    428 
    429   def __setitem__(self, key, value):
    430     # Convenience method to allow direct [] access on step classes for
    431     # manipulating the backed state dict.
    432     self._state[key] = value
    433 
    434   def Config(self, key):
    435     return self._config[key]
    436 
    437   def Run(self):
    438     # Restore state.
    439     state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
    440     if not self._state and os.path.exists(state_file):
    441       self._state.update(json.loads(FileToText(state_file)))
    442 
    443     print ">>> Step %d: %s" % (self._number, self._text)
    444     try:
    445       return self.RunStep()
    446     finally:
    447       # Persist state.
    448       TextToFile(json.dumps(self._state), state_file)
    449 
    450   def RunStep(self):  # pragma: no cover
    451     raise NotImplementedError
    452 
    453   def Retry(self, cb, retry_on=None, wait_plan=None):
    454     """ Retry a function.
    455     Params:
    456       cb: The function to retry.
    457       retry_on: A callback that takes the result of the function and returns
    458                 True if the function should be retried. A function throwing an
    459                 exception is always retried.
    460       wait_plan: A list of waiting delays between retries in seconds. The
    461                  maximum number of retries is len(wait_plan).
    462     """
    463     retry_on = retry_on or (lambda x: False)
    464     wait_plan = list(wait_plan or [])
    465     wait_plan.reverse()
    466     while True:
    467       got_exception = False
    468       try:
    469         result = cb()
    470       except NoRetryException as e:
    471         raise e
    472       except Exception as e:
    473         got_exception = e
    474       if got_exception or retry_on(result):
    475         if not wait_plan:  # pragma: no cover
    476           raise Exception("Retried too often. Giving up. Reason: %s" %
    477                           str(got_exception))
    478         wait_time = wait_plan.pop()
    479         print "Waiting for %f seconds." % wait_time
    480         self._side_effect_handler.Sleep(wait_time)
    481         print "Retrying..."
    482       else:
    483         return result
    484 
    485   def ReadLine(self, default=None):
    486     # Don't prompt in forced mode.
    487     if self._options.force_readline_defaults and default is not None:
    488       print "%s (forced)" % default
    489       return default
    490     else:
    491       return self._side_effect_handler.ReadLine()
    492 
    493   def Command(self, name, args, cwd=None):
    494     cmd = lambda: self._side_effect_handler.Command(
    495         name, args, "", True, cwd=cwd or self.default_cwd)
    496     return self.Retry(cmd, None, [5])
    497 
    498   def Git(self, args="", prefix="", pipe=True, retry_on=None, cwd=None):
    499     cmd = lambda: self._side_effect_handler.Command(
    500         "git", args, prefix, pipe, cwd=cwd or self.default_cwd)
    501     result = self.Retry(cmd, retry_on, [5, 30])
    502     if result is None:
    503       raise GitFailedException("'git %s' failed." % args)
    504     return result
    505 
    506   def Editor(self, args):
    507     if self._options.requires_editor:
    508       return self._side_effect_handler.Command(
    509           os.environ["EDITOR"],
    510           args,
    511           pipe=False,
    512           cwd=self.default_cwd)
    513 
    514   def ReadURL(self, url, params=None, retry_on=None, wait_plan=None):
    515     wait_plan = wait_plan or [3, 60, 600]
    516     cmd = lambda: self._side_effect_handler.ReadURL(url, params)
    517     return self.Retry(cmd, retry_on, wait_plan)
    518 
    519   def GetDate(self):
    520     return self._side_effect_handler.GetDate()
    521 
    522   def Die(self, msg=""):
    523     if msg != "":
    524       print "Error: %s" % msg
    525     print "Exiting"
    526     raise Exception(msg)
    527 
    528   def DieNoManualMode(self, msg=""):
    529     if not self._options.manual:  # pragma: no cover
    530       msg = msg or "Only available in manual mode."
    531       self.Die(msg)
    532 
    533   def Confirm(self, msg):
    534     print "%s [Y/n] " % msg,
    535     answer = self.ReadLine(default="Y")
    536     return answer == "" or answer == "Y" or answer == "y"
    537 
    538   def DeleteBranch(self, name, cwd=None):
    539     for line in self.GitBranch(cwd=cwd).splitlines():
    540       if re.match(r"\*?\s*%s$" % re.escape(name), line):
    541         msg = "Branch %s exists, do you want to delete it?" % name
    542         if self.Confirm(msg):
    543           self.GitDeleteBranch(name, cwd=cwd)
    544           print "Branch %s deleted." % name
    545         else:
    546           msg = "Can't continue. Please delete branch %s and try again." % name
    547           self.Die(msg)
    548 
    549   def InitialEnvironmentChecks(self, cwd):
    550     # Cancel if this is not a git checkout.
    551     if not os.path.exists(os.path.join(cwd, ".git")):  # pragma: no cover
    552       self.Die("This is not a git checkout, this script won't work for you.")
    553 
    554     # Cancel if EDITOR is unset or not executable.
    555     if (self._options.requires_editor and (not os.environ.get("EDITOR") or
    556         self.Command(
    557             "which", os.environ["EDITOR"]) is None)):  # pragma: no cover
    558       self.Die("Please set your EDITOR environment variable, you'll need it.")
    559 
    560   def CommonPrepare(self):
    561     # Check for a clean workdir.
    562     if not self.GitIsWorkdirClean():  # pragma: no cover
    563       self.Die("Workspace is not clean. Please commit or undo your changes.")
    564 
    565     # Checkout master in case the script was left on a work branch.
    566     self.GitCheckout('origin/master')
    567 
    568     # Fetch unfetched revisions.
    569     self.vc.Fetch()
    570 
    571   def PrepareBranch(self):
    572     # Delete the branch that will be created later if it exists already.
    573     self.DeleteBranch(self._config["BRANCHNAME"])
    574 
    575   def CommonCleanup(self):
    576     self.GitCheckout('origin/master')
    577     self.GitDeleteBranch(self._config["BRANCHNAME"])
    578 
    579     # Clean up all temporary files.
    580     for f in glob.iglob("%s*" % self._config["PERSISTFILE_BASENAME"]):
    581       if os.path.isfile(f):
    582         os.remove(f)
    583       if os.path.isdir(f):
    584         shutil.rmtree(f)
    585 
    586   def ReadAndPersistVersion(self, prefix=""):
    587     def ReadAndPersist(var_name, def_name):
    588       match = re.match(r"^#define %s\s+(\d*)" % def_name, line)
    589       if match:
    590         value = match.group(1)
    591         self["%s%s" % (prefix, var_name)] = value
    592     for line in LinesInFile(os.path.join(self.default_cwd, VERSION_FILE)):
    593       for (var_name, def_name) in [("major", "V8_MAJOR_VERSION"),
    594                                    ("minor", "V8_MINOR_VERSION"),
    595                                    ("build", "V8_BUILD_NUMBER"),
    596                                    ("patch", "V8_PATCH_LEVEL")]:
    597         ReadAndPersist(var_name, def_name)
    598 
    599   def WaitForLGTM(self):
    600     print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit "
    601            "your change. (If you need to iterate on the patch or double check "
    602            "that it's sane, do so in another shell, but remember to not "
    603            "change the headline of the uploaded CL.")
    604     answer = ""
    605     while answer != "LGTM":
    606       print "> ",
    607       answer = self.ReadLine(None if self._options.wait_for_lgtm else "LGTM")
    608       if answer != "LGTM":
    609         print "That was not 'LGTM'."
    610 
    611   def WaitForResolvingConflicts(self, patch_file):
    612     print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", "
    613           "or resolve the conflicts, stage *all* touched files with "
    614           "'git add', and type \"RESOLVED<Return>\"")
    615     self.DieNoManualMode()
    616     answer = ""
    617     while answer != "RESOLVED":
    618       if answer == "ABORT":
    619         self.Die("Applying the patch failed.")
    620       if answer != "":
    621         print "That was not 'RESOLVED' or 'ABORT'."
    622       print "> ",
    623       answer = self.ReadLine()
    624 
    625   # Takes a file containing the patch to apply as first argument.
    626   def ApplyPatch(self, patch_file, revert=False):
    627     try:
    628       self.GitApplyPatch(patch_file, revert)
    629     except GitFailedException:
    630       self.WaitForResolvingConflicts(patch_file)
    631 
    632   def GetVersionTag(self, revision):
    633     tag = self.Git("describe --tags %s" % revision).strip()
    634     return SanitizeVersionTag(tag)
    635 
    636   def GetRecentReleases(self, max_age):
    637     # Make sure tags are fetched.
    638     self.Git("fetch origin +refs/tags/*:refs/tags/*")
    639 
    640     # Current timestamp.
    641     time_now = int(self._side_effect_handler.GetUTCStamp())
    642 
    643     # List every tag from a given period.
    644     revisions = self.Git("rev-list --max-age=%d --tags" %
    645                          int(time_now - max_age)).strip()
    646 
    647     # Filter out revisions who's tag is off by one or more commits.
    648     return filter(lambda r: self.GetVersionTag(r), revisions.splitlines())
    649 
    650   def GetLatestVersion(self):
    651     # Use cached version if available.
    652     if self["latest_version"]:
    653       return self["latest_version"]
    654 
    655     # Make sure tags are fetched.
    656     self.Git("fetch origin +refs/tags/*:refs/tags/*")
    657 
    658     all_tags = self.vc.GetTags()
    659     only_version_tags = NormalizeVersionTags(all_tags)
    660 
    661     version = sorted(only_version_tags,
    662                      key=SortingKey, reverse=True)[0]
    663     self["latest_version"] = version
    664     return version
    665 
    666   def GetLatestRelease(self):
    667     """The latest release is the git hash of the latest tagged version.
    668 
    669     This revision should be rolled into chromium.
    670     """
    671     latest_version = self.GetLatestVersion()
    672 
    673     # The latest release.
    674     latest_hash = self.GitLog(n=1, format="%H", branch=latest_version)
    675     assert latest_hash
    676     return latest_hash
    677 
    678   def GetLatestReleaseBase(self, version=None):
    679     """The latest release base is the latest revision that is covered in the
    680     last change log file. It doesn't include cherry-picked patches.
    681     """
    682     latest_version = version or self.GetLatestVersion()
    683 
    684     # Strip patch level if it exists.
    685     latest_version = ".".join(latest_version.split(".")[:3])
    686 
    687     # The latest release base.
    688     latest_hash = self.GitLog(n=1, format="%H", branch=latest_version)
    689     assert latest_hash
    690 
    691     title = self.GitLog(n=1, format="%s", git_hash=latest_hash)
    692     match = PUSH_MSG_GIT_RE.match(title)
    693     if match:
    694       # Legacy: In the old process there's one level of indirection. The
    695       # version is on the candidates branch and points to the real release
    696       # base on master through the commit message.
    697       return match.group("git_rev")
    698     match = PUSH_MSG_NEW_RE.match(title)
    699     if match:
    700       # This is a new-style v8 version branched from master. The commit
    701       # "latest_hash" is the version-file change. Its parent is the release
    702       # base on master.
    703       return self.GitLog(n=1, format="%H", git_hash="%s^" % latest_hash)
    704 
    705     self.Die("Unknown latest release: %s" % latest_hash)
    706 
    707   def ArrayToVersion(self, prefix):
    708     return ".".join([self[prefix + "major"],
    709                      self[prefix + "minor"],
    710                      self[prefix + "build"],
    711                      self[prefix + "patch"]])
    712 
    713   def StoreVersion(self, version, prefix):
    714     version_parts = version.split(".")
    715     if len(version_parts) == 3:
    716       version_parts.append("0")
    717     major, minor, build, patch = version_parts
    718     self[prefix + "major"] = major
    719     self[prefix + "minor"] = minor
    720     self[prefix + "build"] = build
    721     self[prefix + "patch"] = patch
    722 
    723   def SetVersion(self, version_file, prefix):
    724     output = ""
    725     for line in FileToText(version_file).splitlines():
    726       if line.startswith("#define V8_MAJOR_VERSION"):
    727         line = re.sub("\d+$", self[prefix + "major"], line)
    728       elif line.startswith("#define V8_MINOR_VERSION"):
    729         line = re.sub("\d+$", self[prefix + "minor"], line)
    730       elif line.startswith("#define V8_BUILD_NUMBER"):
    731         line = re.sub("\d+$", self[prefix + "build"], line)
    732       elif line.startswith("#define V8_PATCH_LEVEL"):
    733         line = re.sub("\d+$", self[prefix + "patch"], line)
    734       elif (self[prefix + "candidate"] and
    735             line.startswith("#define V8_IS_CANDIDATE_VERSION")):
    736         line = re.sub("\d+$", self[prefix + "candidate"], line)
    737       output += "%s\n" % line
    738     TextToFile(output, version_file)
    739 
    740 
    741 class BootstrapStep(Step):
    742   MESSAGE = "Bootstrapping checkout and state."
    743 
    744   def RunStep(self):
    745     # Reserve state entry for json output.
    746     self['json_output'] = {}
    747 
    748     if os.path.realpath(self.default_cwd) == os.path.realpath(V8_BASE):
    749       self.Die("Can't use v8 checkout with calling script as work checkout.")
    750     # Directory containing the working v8 checkout.
    751     if not os.path.exists(self._options.work_dir):
    752       os.makedirs(self._options.work_dir)
    753     if not os.path.exists(self.default_cwd):
    754       self.Command("fetch", "v8", cwd=self._options.work_dir)
    755 
    756 
    757 class UploadStep(Step):
    758   MESSAGE = "Upload for code review."
    759 
    760   def RunStep(self):
    761     if self._options.reviewer:
    762       print "Using account %s for review." % self._options.reviewer
    763       reviewer = self._options.reviewer
    764     else:
    765       print "Please enter the email address of a V8 reviewer for your patch: ",
    766       self.DieNoManualMode("A reviewer must be specified in forced mode.")
    767       reviewer = self.ReadLine()
    768     self.GitUpload(reviewer, self._options.author, self._options.force_upload,
    769                    bypass_hooks=self._options.bypass_upload_hooks,
    770                    cc=self._options.cc)
    771 
    772 
    773 def MakeStep(step_class=Step, number=0, state=None, config=None,
    774              options=None, side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER):
    775     # Allow to pass in empty dictionaries.
    776     state = state if state is not None else {}
    777     config = config if config is not None else {}
    778 
    779     try:
    780       message = step_class.MESSAGE
    781     except AttributeError:
    782       message = step_class.__name__
    783 
    784     return step_class(message, number=number, config=config,
    785                       state=state, options=options,
    786                       handler=side_effect_handler)
    787 
    788 
    789 class ScriptsBase(object):
    790   def __init__(self,
    791                config=None,
    792                side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER,
    793                state=None):
    794     self._config = config or self._Config()
    795     self._side_effect_handler = side_effect_handler
    796     self._state = state if state is not None else {}
    797 
    798   def _Description(self):
    799     return None
    800 
    801   def _PrepareOptions(self, parser):
    802     pass
    803 
    804   def _ProcessOptions(self, options):
    805     return True
    806 
    807   def _Steps(self):  # pragma: no cover
    808     raise Exception("Not implemented.")
    809 
    810   def _Config(self):
    811     return {}
    812 
    813   def MakeOptions(self, args=None):
    814     parser = argparse.ArgumentParser(description=self._Description())
    815     parser.add_argument("-a", "--author", default="",
    816                         help="The author email used for rietveld.")
    817     parser.add_argument("--dry-run", default=False, action="store_true",
    818                         help="Perform only read-only actions.")
    819     parser.add_argument("--json-output",
    820                         help="File to write results summary to.")
    821     parser.add_argument("-r", "--reviewer", default="",
    822                         help="The account name to be used for reviews.")
    823     parser.add_argument("-s", "--step",
    824         help="Specify the step where to start work. Default: 0.",
    825         default=0, type=int)
    826     parser.add_argument("--work-dir",
    827                         help=("Location where to bootstrap a working v8 "
    828                               "checkout."))
    829     self._PrepareOptions(parser)
    830 
    831     if args is None:  # pragma: no cover
    832       options = parser.parse_args()
    833     else:
    834       options = parser.parse_args(args)
    835 
    836     # Process common options.
    837     if options.step < 0:  # pragma: no cover
    838       print "Bad step number %d" % options.step
    839       parser.print_help()
    840       return None
    841 
    842     # Defaults for options, common to all scripts.
    843     options.manual = getattr(options, "manual", True)
    844     options.force = getattr(options, "force", False)
    845     options.bypass_upload_hooks = False
    846 
    847     # Derived options.
    848     options.requires_editor = not options.force
    849     options.wait_for_lgtm = not options.force
    850     options.force_readline_defaults = not options.manual
    851     options.force_upload = not options.manual
    852 
    853     # Process script specific options.
    854     if not self._ProcessOptions(options):
    855       parser.print_help()
    856       return None
    857 
    858     if not options.work_dir:
    859       options.work_dir = "/tmp/v8-release-scripts-work-dir"
    860     return options
    861 
    862   def RunSteps(self, step_classes, args=None):
    863     options = self.MakeOptions(args)
    864     if not options:
    865       return 1
    866 
    867     state_file = "%s-state.json" % self._config["PERSISTFILE_BASENAME"]
    868     if options.step == 0 and os.path.exists(state_file):
    869       os.remove(state_file)
    870 
    871     steps = []
    872     for (number, step_class) in enumerate([BootstrapStep] + step_classes):
    873       steps.append(MakeStep(step_class, number, self._state, self._config,
    874                             options, self._side_effect_handler))
    875 
    876     try:
    877       for step in steps[options.step:]:
    878         if step.Run():
    879           return 0
    880     finally:
    881       if options.json_output:
    882         with open(options.json_output, "w") as f:
    883           json.dump(self._state['json_output'], f)
    884 
    885     return 0
    886 
    887   def Run(self, args=None):
    888     return self.RunSteps(self._Steps(), args)
    889