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