Componentize VariationsService into //components/variations/service
[chromium-blink-merge.git] / tools / bisect-builds.py
blobae7bf63b774d2526ae31b3d0bdede7246b190dcf
1 #!/usr/bin/env python
2 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
6 """Snapshot Build Bisect Tool
8 This script bisects a snapshot archive using binary search. It starts at
9 a bad revision (it will try to guess HEAD) and asks for a last known-good
10 revision. It will then binary search across this revision range by downloading,
11 unzipping, and opening Chromium for you. After testing the specific revision,
12 it will ask you whether it is good or bad before continuing the search.
13 """
15 # The base URL for stored build archives.
16 CHROMIUM_BASE_URL = ('http://commondatastorage.googleapis.com'
17 '/chromium-browser-snapshots')
18 WEBKIT_BASE_URL = ('http://commondatastorage.googleapis.com'
19 '/chromium-webkit-snapshots')
20 ASAN_BASE_URL = ('http://commondatastorage.googleapis.com'
21 '/chromium-browser-asan')
23 # GS bucket name.
24 GS_BUCKET_NAME = 'chrome-unsigned/desktop-W15K3Y'
26 # Base URL for downloading official builds.
27 GOOGLE_APIS_URL = 'commondatastorage.googleapis.com'
29 # The base URL for official builds.
30 OFFICIAL_BASE_URL = 'http://%s/%s' % (GOOGLE_APIS_URL, GS_BUCKET_NAME)
32 # URL template for viewing changelogs between revisions.
33 CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/src/+log/%s..%s')
35 # URL to convert SVN revision to git hash.
36 CRREV_URL = ('https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/')
38 # URL template for viewing changelogs between official versions.
39 OFFICIAL_CHANGELOG_URL = ('https://chromium.googlesource.com/chromium/'
40 'src/+log/%s..%s?pretty=full')
42 # DEPS file URL.
43 DEPS_FILE_OLD = ('http://src.chromium.org/viewvc/chrome/trunk/src/'
44 'DEPS?revision=%d')
45 DEPS_FILE_NEW = ('https://chromium.googlesource.com/chromium/src/+/%s/DEPS')
47 # Blink changelogs URL.
48 BLINK_CHANGELOG_URL = ('http://build.chromium.org'
49 '/f/chromium/perf/dashboard/ui/changelog_blink.html'
50 '?url=/trunk&range=%d%%3A%d')
52 DONE_MESSAGE_GOOD_MIN = ('You are probably looking for a change made after %s ('
53 'known good), but no later than %s (first known bad).')
54 DONE_MESSAGE_GOOD_MAX = ('You are probably looking for a change made after %s ('
55 'known bad), but no later than %s (first known good).')
57 CHROMIUM_GITHASH_TO_SVN_URL = (
58 'https://chromium.googlesource.com/chromium/src/+/%s?format=json')
60 BLINK_GITHASH_TO_SVN_URL = (
61 'https://chromium.googlesource.com/chromium/blink/+/%s?format=json')
63 GITHASH_TO_SVN_URL = {
64 'chromium': CHROMIUM_GITHASH_TO_SVN_URL,
65 'blink': BLINK_GITHASH_TO_SVN_URL,
68 # Search pattern to be matched in the JSON output from
69 # CHROMIUM_GITHASH_TO_SVN_URL to get the chromium revision (svn revision).
70 CHROMIUM_SEARCH_PATTERN_OLD = (
71 r'.*git-svn-id: svn://svn.chromium.org/chrome/trunk/src@(\d+) ')
72 CHROMIUM_SEARCH_PATTERN = (
73 r'Cr-Commit-Position: refs/heads/master@{#(\d+)}')
75 # Search pattern to be matched in the json output from
76 # BLINK_GITHASH_TO_SVN_URL to get the blink revision (svn revision).
77 BLINK_SEARCH_PATTERN = (
78 r'.*git-svn-id: svn://svn.chromium.org/blink/trunk@(\d+) ')
80 SEARCH_PATTERN = {
81 'chromium': CHROMIUM_SEARCH_PATTERN,
82 'blink': BLINK_SEARCH_PATTERN,
85 CREDENTIAL_ERROR_MESSAGE = ('You are attempting to access protected data with '
86 'no configured credentials')
88 ###############################################################################
90 import httplib
91 import json
92 import optparse
93 import os
94 import re
95 import shlex
96 import shutil
97 import subprocess
98 import sys
99 import tempfile
100 import threading
101 import urllib
102 from distutils.version import LooseVersion
103 from xml.etree import ElementTree
104 import zipfile
107 class PathContext(object):
108 """A PathContext is used to carry the information used to construct URLs and
109 paths when dealing with the storage server and archives."""
110 def __init__(self, base_url, platform, good_revision, bad_revision,
111 is_official, is_asan, use_local_cache, flash_path = None):
112 super(PathContext, self).__init__()
113 # Store off the input parameters.
114 self.base_url = base_url
115 self.platform = platform # What's passed in to the '-a/--archive' option.
116 self.good_revision = good_revision
117 self.bad_revision = bad_revision
118 self.is_official = is_official
119 self.is_asan = is_asan
120 self.build_type = 'release'
121 self.flash_path = flash_path
122 # Dictionary which stores svn revision number as key and it's
123 # corresponding git hash as value. This data is populated in
124 # _FetchAndParse and used later in GetDownloadURL while downloading
125 # the build.
126 self.githash_svn_dict = {}
127 # The name of the ZIP file in a revision directory on the server.
128 self.archive_name = None
130 # Whether to cache and use the list of known revisions in a local file to
131 # speed up the initialization of the script at the next run.
132 self.use_local_cache = use_local_cache
134 # Locate the local checkout to speed up the script by using locally stored
135 # metadata.
136 abs_file_path = os.path.abspath(os.path.realpath(__file__))
137 local_src_path = os.path.join(os.path.dirname(abs_file_path), '..')
138 if abs_file_path.endswith(os.path.join('tools', 'bisect-builds.py')) and\
139 os.path.exists(os.path.join(local_src_path, '.git')):
140 self.local_src_path = os.path.normpath(local_src_path)
141 else:
142 self.local_src_path = None
144 # Set some internal members:
145 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'.
146 # _archive_extract_dir = Uncompressed directory in the archive_name file.
147 # _binary_name = The name of the executable to run.
148 if self.platform in ('linux', 'linux64', 'linux-arm', 'chromeos'):
149 self._binary_name = 'chrome'
150 elif self.platform in ('mac', 'mac64'):
151 self.archive_name = 'chrome-mac.zip'
152 self._archive_extract_dir = 'chrome-mac'
153 elif self.platform in ('win', 'win64'):
154 self.archive_name = 'chrome-win32.zip'
155 self._archive_extract_dir = 'chrome-win32'
156 self._binary_name = 'chrome.exe'
157 else:
158 raise Exception('Invalid platform: %s' % self.platform)
160 if is_official:
161 if self.platform == 'linux':
162 self._listing_platform_dir = 'precise32/'
163 self.archive_name = 'chrome-precise32.zip'
164 self._archive_extract_dir = 'chrome-precise32'
165 elif self.platform == 'linux64':
166 self._listing_platform_dir = 'precise64/'
167 self.archive_name = 'chrome-precise64.zip'
168 self._archive_extract_dir = 'chrome-precise64'
169 elif self.platform == 'mac':
170 self._listing_platform_dir = 'mac/'
171 self._binary_name = 'Google Chrome.app/Contents/MacOS/Google Chrome'
172 elif self.platform == 'mac64':
173 self._listing_platform_dir = 'mac64/'
174 self._binary_name = 'Google Chrome.app/Contents/MacOS/Google Chrome'
175 elif self.platform == 'win':
176 self._listing_platform_dir = 'win/'
177 self.archive_name = 'chrome-win.zip'
178 self._archive_extract_dir = 'chrome-win'
179 elif self.platform == 'win64':
180 self._listing_platform_dir = 'win64/'
181 self.archive_name = 'chrome-win64.zip'
182 self._archive_extract_dir = 'chrome-win64'
183 else:
184 if self.platform in ('linux', 'linux64', 'linux-arm', 'chromeos'):
185 self.archive_name = 'chrome-linux.zip'
186 self._archive_extract_dir = 'chrome-linux'
187 if self.platform == 'linux':
188 self._listing_platform_dir = 'Linux/'
189 elif self.platform == 'linux64':
190 self._listing_platform_dir = 'Linux_x64/'
191 elif self.platform == 'linux-arm':
192 self._listing_platform_dir = 'Linux_ARM_Cross-Compile/'
193 elif self.platform == 'chromeos':
194 self._listing_platform_dir = 'Linux_ChromiumOS_Full/'
195 elif self.platform == 'mac':
196 self._listing_platform_dir = 'Mac/'
197 self._binary_name = 'Chromium.app/Contents/MacOS/Chromium'
198 elif self.platform == 'win':
199 self._listing_platform_dir = 'Win/'
201 def GetASANPlatformDir(self):
202 """ASAN builds are in directories like "linux-release", or have filenames
203 like "asan-win32-release-277079.zip". This aligns to our platform names
204 except in the case of Windows where they use "win32" instead of "win"."""
205 if self.platform == 'win':
206 return 'win32'
207 else:
208 return self.platform
210 def GetListingURL(self, marker=None):
211 """Returns the URL for a directory listing, with an optional marker."""
212 marker_param = ''
213 if marker:
214 marker_param = '&marker=' + str(marker)
215 if self.is_asan:
216 prefix = '%s-%s' % (self.GetASANPlatformDir(), self.build_type)
217 return self.base_url + '/?delimiter=&prefix=' + prefix + marker_param
218 else:
219 return (self.base_url + '/?delimiter=/&prefix=' +
220 self._listing_platform_dir + marker_param)
222 def GetDownloadURL(self, revision):
223 """Gets the download URL for a build archive of a specific revision."""
224 if self.is_asan:
225 return '%s/%s-%s/%s-%d.zip' % (
226 ASAN_BASE_URL, self.GetASANPlatformDir(), self.build_type,
227 self.GetASANBaseName(), revision)
228 if self.is_official:
229 return '%s/%s/%s%s' % (
230 OFFICIAL_BASE_URL, revision, self._listing_platform_dir,
231 self.archive_name)
232 else:
233 if str(revision) in self.githash_svn_dict:
234 revision = self.githash_svn_dict[str(revision)]
235 return '%s/%s%s/%s' % (self.base_url, self._listing_platform_dir,
236 revision, self.archive_name)
238 def GetLastChangeURL(self):
239 """Returns a URL to the LAST_CHANGE file."""
240 return self.base_url + '/' + self._listing_platform_dir + 'LAST_CHANGE'
242 def GetASANBaseName(self):
243 """Returns the base name of the ASAN zip file."""
244 if 'linux' in self.platform:
245 return 'asan-symbolized-%s-%s' % (self.GetASANPlatformDir(),
246 self.build_type)
247 else:
248 return 'asan-%s-%s' % (self.GetASANPlatformDir(), self.build_type)
250 def GetLaunchPath(self, revision):
251 """Returns a relative path (presumably from the archive extraction location)
252 that is used to run the executable."""
253 if self.is_asan:
254 extract_dir = '%s-%d' % (self.GetASANBaseName(), revision)
255 else:
256 extract_dir = self._archive_extract_dir
257 return os.path.join(extract_dir, self._binary_name)
259 def ParseDirectoryIndex(self, last_known_rev):
260 """Parses the Google Storage directory listing into a list of revision
261 numbers."""
263 def _GetMarkerForRev(revision):
264 if self.is_asan:
265 return '%s-%s/%s-%d.zip' % (
266 self.GetASANPlatformDir(), self.build_type,
267 self.GetASANBaseName(), revision)
268 return '%s%d' % (self._listing_platform_dir, revision)
270 def _FetchAndParse(url):
271 """Fetches a URL and returns a 2-Tuple of ([revisions], next-marker). If
272 next-marker is not None, then the listing is a partial listing and another
273 fetch should be performed with next-marker being the marker= GET
274 parameter."""
275 handle = urllib.urlopen(url)
276 document = ElementTree.parse(handle)
278 # All nodes in the tree are namespaced. Get the root's tag name to extract
279 # the namespace. Etree does namespaces as |{namespace}tag|.
280 root_tag = document.getroot().tag
281 end_ns_pos = root_tag.find('}')
282 if end_ns_pos == -1:
283 raise Exception('Could not locate end namespace for directory index')
284 namespace = root_tag[:end_ns_pos + 1]
286 # Find the prefix (_listing_platform_dir) and whether or not the list is
287 # truncated.
288 prefix_len = len(document.find(namespace + 'Prefix').text)
289 next_marker = None
290 is_truncated = document.find(namespace + 'IsTruncated')
291 if is_truncated is not None and is_truncated.text.lower() == 'true':
292 next_marker = document.find(namespace + 'NextMarker').text
293 # Get a list of all the revisions.
294 revisions = []
295 githash_svn_dict = {}
296 if self.is_asan:
297 asan_regex = re.compile(r'.*%s-(\d+)\.zip$' % (self.GetASANBaseName()))
298 # Non ASAN builds are in a <revision> directory. The ASAN builds are
299 # flat
300 all_prefixes = document.findall(namespace + 'Contents/' +
301 namespace + 'Key')
302 for prefix in all_prefixes:
303 m = asan_regex.match(prefix.text)
304 if m:
305 try:
306 revisions.append(int(m.group(1)))
307 except ValueError:
308 pass
309 else:
310 all_prefixes = document.findall(namespace + 'CommonPrefixes/' +
311 namespace + 'Prefix')
312 # The <Prefix> nodes have content of the form of
313 # |_listing_platform_dir/revision/|. Strip off the platform dir and the
314 # trailing slash to just have a number.
315 for prefix in all_prefixes:
316 revnum = prefix.text[prefix_len:-1]
317 try:
318 if not revnum.isdigit():
319 # During the svn-git migration, some items were stored by hash.
320 # These items may appear anywhere in the list of items.
321 # If |last_known_rev| is set, assume that the full list has been
322 # retrieved before (including the hashes), so we can safely skip
323 # all git hashes and focus on the numeric revision numbers.
324 if last_known_rev:
325 revnum = None
326 else:
327 git_hash = revnum
328 revnum = self.GetSVNRevisionFromGitHash(git_hash)
329 githash_svn_dict[revnum] = git_hash
330 if revnum is not None:
331 revnum = int(revnum)
332 revisions.append(revnum)
333 except ValueError:
334 pass
335 return (revisions, next_marker, githash_svn_dict)
337 # Fetch the first list of revisions.
338 if last_known_rev:
339 revisions = []
340 # Optimization: Start paging at the last known revision (local cache).
341 next_marker = _GetMarkerForRev(last_known_rev)
342 # Optimization: Stop paging at the last known revision (remote).
343 last_change_rev = GetChromiumRevision(self, self.GetLastChangeURL())
344 if last_known_rev == last_change_rev:
345 return []
346 else:
347 (revisions, next_marker, new_dict) = _FetchAndParse(self.GetListingURL())
348 self.githash_svn_dict.update(new_dict)
349 last_change_rev = None
351 # If the result list was truncated, refetch with the next marker. Do this
352 # until an entire directory listing is done.
353 while next_marker:
354 sys.stdout.write('\rFetching revisions at marker %s' % next_marker)
355 sys.stdout.flush()
357 next_url = self.GetListingURL(next_marker)
358 (new_revisions, next_marker, new_dict) = _FetchAndParse(next_url)
359 revisions.extend(new_revisions)
360 self.githash_svn_dict.update(new_dict)
361 if last_change_rev and last_change_rev in new_revisions:
362 break
363 sys.stdout.write('\r')
364 sys.stdout.flush()
365 return revisions
367 def _GetSVNRevisionFromGitHashWithoutGitCheckout(self, git_sha1, depot):
368 json_url = GITHASH_TO_SVN_URL[depot] % git_sha1
369 response = urllib.urlopen(json_url)
370 if response.getcode() == 200:
371 try:
372 data = json.loads(response.read()[4:])
373 except ValueError:
374 print 'ValueError for JSON URL: %s' % json_url
375 raise ValueError
376 else:
377 raise ValueError
378 if 'message' in data:
379 message = data['message'].split('\n')
380 message = [line for line in message if line.strip()]
381 search_pattern = re.compile(SEARCH_PATTERN[depot])
382 result = search_pattern.search(message[len(message)-1])
383 if result:
384 return result.group(1)
385 else:
386 if depot == 'chromium':
387 result = re.search(CHROMIUM_SEARCH_PATTERN_OLD,
388 message[len(message)-1])
389 if result:
390 return result.group(1)
391 print 'Failed to get svn revision number for %s' % git_sha1
392 raise ValueError
394 def _GetSVNRevisionFromGitHashFromGitCheckout(self, git_sha1, depot):
395 def _RunGit(command, path):
396 command = ['git'] + command
397 shell = sys.platform.startswith('win')
398 proc = subprocess.Popen(command, shell=shell, stdout=subprocess.PIPE,
399 stderr=subprocess.PIPE, cwd=path)
400 (output, _) = proc.communicate()
401 return (output, proc.returncode)
403 path = self.local_src_path
404 if depot == 'blink':
405 path = os.path.join(self.local_src_path, 'third_party', 'WebKit')
406 revision = None
407 try:
408 command = ['svn', 'find-rev', git_sha1]
409 (git_output, return_code) = _RunGit(command, path)
410 if not return_code:
411 revision = git_output.strip('\n')
412 except ValueError:
413 pass
414 if not revision:
415 command = ['log', '-n1', '--format=%s', git_sha1]
416 (git_output, return_code) = _RunGit(command, path)
417 if not return_code:
418 revision = re.match('SVN changes up to revision ([0-9]+)', git_output)
419 revision = revision.group(1) if revision else None
420 if revision:
421 return revision
422 raise ValueError
424 def GetSVNRevisionFromGitHash(self, git_sha1, depot='chromium'):
425 if not self.local_src_path:
426 return self._GetSVNRevisionFromGitHashWithoutGitCheckout(git_sha1, depot)
427 else:
428 return self._GetSVNRevisionFromGitHashFromGitCheckout(git_sha1, depot)
430 def GetRevList(self):
431 """Gets the list of revision numbers between self.good_revision and
432 self.bad_revision."""
434 cache = {}
435 # The cache is stored in the same directory as bisect-builds.py
436 cache_filename = os.path.join(
437 os.path.abspath(os.path.dirname(__file__)),
438 '.bisect-builds-cache.json')
439 cache_dict_key = self.GetListingURL()
441 def _LoadBucketFromCache():
442 if self.use_local_cache:
443 try:
444 with open(cache_filename) as cache_file:
445 for (key, value) in json.load(cache_file).items():
446 cache[key] = value
447 revisions = cache.get(cache_dict_key, [])
448 githash_svn_dict = cache.get('githash_svn_dict', {})
449 if revisions:
450 print 'Loaded revisions %d-%d from %s' % (revisions[0],
451 revisions[-1], cache_filename)
452 return (revisions, githash_svn_dict)
453 except (EnvironmentError, ValueError):
454 pass
455 return ([], {})
457 def _SaveBucketToCache():
458 """Save the list of revisions and the git-svn mappings to a file.
459 The list of revisions is assumed to be sorted."""
460 if self.use_local_cache:
461 cache[cache_dict_key] = revlist_all
462 cache['githash_svn_dict'] = self.githash_svn_dict
463 try:
464 with open(cache_filename, 'w') as cache_file:
465 json.dump(cache, cache_file)
466 print 'Saved revisions %d-%d to %s' % (
467 revlist_all[0], revlist_all[-1], cache_filename)
468 except EnvironmentError:
469 pass
471 # Download the revlist and filter for just the range between good and bad.
472 minrev = min(self.good_revision, self.bad_revision)
473 maxrev = max(self.good_revision, self.bad_revision)
475 (revlist_all, self.githash_svn_dict) = _LoadBucketFromCache()
476 last_known_rev = revlist_all[-1] if revlist_all else 0
477 if last_known_rev < maxrev:
478 revlist_all.extend(map(int, self.ParseDirectoryIndex(last_known_rev)))
479 revlist_all = list(set(revlist_all))
480 revlist_all.sort()
481 _SaveBucketToCache()
483 revlist = [x for x in revlist_all if x >= int(minrev) and x <= int(maxrev)]
485 # Set good and bad revisions to be legit revisions.
486 if revlist:
487 if self.good_revision < self.bad_revision:
488 self.good_revision = revlist[0]
489 self.bad_revision = revlist[-1]
490 else:
491 self.bad_revision = revlist[0]
492 self.good_revision = revlist[-1]
494 # Fix chromium rev so that the deps blink revision matches REVISIONS file.
495 if self.base_url == WEBKIT_BASE_URL:
496 revlist_all.sort()
497 self.good_revision = FixChromiumRevForBlink(revlist,
498 revlist_all,
499 self,
500 self.good_revision)
501 self.bad_revision = FixChromiumRevForBlink(revlist,
502 revlist_all,
503 self,
504 self.bad_revision)
505 return revlist
507 def GetOfficialBuildsList(self):
508 """Gets the list of official build numbers between self.good_revision and
509 self.bad_revision."""
511 def CheckDepotToolsInPath():
512 delimiter = ';' if sys.platform.startswith('win') else ':'
513 path_list = os.environ['PATH'].split(delimiter)
514 for path in path_list:
515 if path.rstrip(os.path.sep).endswith('depot_tools'):
516 return path
517 return None
519 def RunGsutilCommand(args):
520 gsutil_path = CheckDepotToolsInPath()
521 if gsutil_path is None:
522 print ('Follow the instructions in this document '
523 'http://dev.chromium.org/developers/how-tos/install-depot-tools'
524 ' to install depot_tools and then try again.')
525 sys.exit(1)
526 gsutil_path = os.path.join(gsutil_path, 'third_party', 'gsutil', 'gsutil')
527 gsutil = subprocess.Popen([sys.executable, gsutil_path] + args,
528 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
529 env=None)
530 stdout, stderr = gsutil.communicate()
531 if gsutil.returncode:
532 if (re.findall(r'status[ |=]40[1|3]', stderr) or
533 stderr.startswith(CREDENTIAL_ERROR_MESSAGE)):
534 print ('Follow these steps to configure your credentials and try'
535 ' running the bisect-builds.py again.:\n'
536 ' 1. Run "python %s config" and follow its instructions.\n'
537 ' 2. If you have a @google.com account, use that account.\n'
538 ' 3. For the project-id, just enter 0.' % gsutil_path)
539 sys.exit(1)
540 else:
541 raise Exception('Error running the gsutil command: %s' % stderr)
542 return stdout
544 def GsutilList(bucket):
545 query = 'gs://%s/' % bucket
546 stdout = RunGsutilCommand(['ls', query])
547 return [url[len(query):].strip('/') for url in stdout.splitlines()]
549 # Download the revlist and filter for just the range between good and bad.
550 minrev = min(self.good_revision, self.bad_revision)
551 maxrev = max(self.good_revision, self.bad_revision)
552 build_numbers = GsutilList(GS_BUCKET_NAME)
553 revision_re = re.compile(r'(\d\d\.\d\.\d{4}\.\d+)')
554 build_numbers = filter(lambda b: revision_re.search(b), build_numbers)
555 final_list = []
556 parsed_build_numbers = [LooseVersion(x) for x in build_numbers]
557 connection = httplib.HTTPConnection(GOOGLE_APIS_URL)
558 for build_number in sorted(parsed_build_numbers):
559 if build_number > maxrev:
560 break
561 if build_number < minrev:
562 continue
563 path = ('/' + GS_BUCKET_NAME + '/' + str(build_number) + '/' +
564 self._listing_platform_dir + self.archive_name)
565 connection.request('HEAD', path)
566 response = connection.getresponse()
567 if response.status == 200:
568 final_list.append(str(build_number))
569 response.read()
570 connection.close()
571 return final_list
573 def UnzipFilenameToDir(filename, directory):
574 """Unzip |filename| to |directory|."""
575 cwd = os.getcwd()
576 if not os.path.isabs(filename):
577 filename = os.path.join(cwd, filename)
578 zf = zipfile.ZipFile(filename)
579 # Make base.
580 if not os.path.isdir(directory):
581 os.mkdir(directory)
582 os.chdir(directory)
583 # Extract files.
584 for info in zf.infolist():
585 name = info.filename
586 if name.endswith('/'): # dir
587 if not os.path.isdir(name):
588 os.makedirs(name)
589 else: # file
590 directory = os.path.dirname(name)
591 if not os.path.isdir(directory):
592 os.makedirs(directory)
593 out = open(name, 'wb')
594 out.write(zf.read(name))
595 out.close()
596 # Set permissions. Permission info in external_attr is shifted 16 bits.
597 os.chmod(name, info.external_attr >> 16L)
598 os.chdir(cwd)
601 def FetchRevision(context, rev, filename, quit_event=None, progress_event=None):
602 """Downloads and unzips revision |rev|.
603 @param context A PathContext instance.
604 @param rev The Chromium revision number/tag to download.
605 @param filename The destination for the downloaded file.
606 @param quit_event A threading.Event which will be set by the master thread to
607 indicate that the download should be aborted.
608 @param progress_event A threading.Event which will be set by the master thread
609 to indicate that the progress of the download should be
610 displayed.
612 def ReportHook(blocknum, blocksize, totalsize):
613 if quit_event and quit_event.isSet():
614 raise RuntimeError('Aborting download of revision %s' % str(rev))
615 if progress_event and progress_event.isSet():
616 size = blocknum * blocksize
617 if totalsize == -1: # Total size not known.
618 progress = 'Received %d bytes' % size
619 else:
620 size = min(totalsize, size)
621 progress = 'Received %d of %d bytes, %.2f%%' % (
622 size, totalsize, 100.0 * size / totalsize)
623 # Send a \r to let all progress messages use just one line of output.
624 sys.stdout.write('\r' + progress)
625 sys.stdout.flush()
626 download_url = context.GetDownloadURL(rev)
627 try:
628 urllib.urlretrieve(download_url, filename, ReportHook)
629 if progress_event and progress_event.isSet():
630 print
632 except RuntimeError:
633 pass
636 def RunRevision(context, revision, zip_file, profile, num_runs, command, args):
637 """Given a zipped revision, unzip it and run the test."""
638 print 'Trying revision %s...' % str(revision)
640 # Create a temp directory and unzip the revision into it.
641 cwd = os.getcwd()
642 tempdir = tempfile.mkdtemp(prefix='bisect_tmp')
643 UnzipFilenameToDir(zip_file, tempdir)
645 # Hack: Chrome OS archives are missing icudtl.dat; try to copy it from
646 # the local directory.
647 if context.platform == 'chromeos':
648 icudtl_path = 'third_party/icu/source/data/in/icudtl.dat'
649 if not os.access(icudtl_path, os.F_OK):
650 print 'Couldn\'t find: ' + icudtl_path
651 sys.exit()
652 os.system('cp %s %s/chrome-linux/' % (icudtl_path, tempdir))
654 os.chdir(tempdir)
656 # Run the build as many times as specified.
657 testargs = ['--user-data-dir=%s' % profile] + args
658 # The sandbox must be run as root on Official Chrome, so bypass it.
659 if ((context.is_official or context.flash_path) and
660 context.platform.startswith('linux')):
661 testargs.append('--no-sandbox')
662 if context.flash_path:
663 testargs.append('--ppapi-flash-path=%s' % context.flash_path)
664 # We have to pass a large enough Flash version, which currently needs not
665 # be correct. Instead of requiring the user of the script to figure out and
666 # pass the correct version we just spoof it.
667 testargs.append('--ppapi-flash-version=99.9.999.999')
669 runcommand = []
670 for token in shlex.split(command):
671 if token == '%a':
672 runcommand.extend(testargs)
673 else:
674 runcommand.append(
675 token.replace('%p', os.path.abspath(context.GetLaunchPath(revision))).
676 replace('%s', ' '.join(testargs)))
678 results = []
679 for _ in range(num_runs):
680 subproc = subprocess.Popen(runcommand,
681 bufsize=-1,
682 stdout=subprocess.PIPE,
683 stderr=subprocess.PIPE)
684 (stdout, stderr) = subproc.communicate()
685 results.append((subproc.returncode, stdout, stderr))
686 os.chdir(cwd)
687 try:
688 shutil.rmtree(tempdir, True)
689 except Exception:
690 pass
692 for (returncode, stdout, stderr) in results:
693 if returncode:
694 return (returncode, stdout, stderr)
695 return results[0]
698 # The arguments official_builds, status, stdout and stderr are unused.
699 # They are present here because this function is passed to Bisect which then
700 # calls it with 5 arguments.
701 # pylint: disable=W0613
702 def AskIsGoodBuild(rev, official_builds, status, stdout, stderr):
703 """Asks the user whether build |rev| is good or bad."""
704 # Loop until we get a response that we can parse.
705 while True:
706 response = raw_input('Revision %s is '
707 '[(g)ood/(b)ad/(r)etry/(u)nknown/(q)uit]: ' %
708 str(rev))
709 if response and response in ('g', 'b', 'r', 'u'):
710 return response
711 if response and response == 'q':
712 raise SystemExit()
715 def IsGoodASANBuild(rev, official_builds, status, stdout, stderr):
716 """Determine if an ASAN build |rev| is good or bad
718 Will examine stderr looking for the error message emitted by ASAN. If not
719 found then will fallback to asking the user."""
720 if stderr:
721 bad_count = 0
722 for line in stderr.splitlines():
723 print line
724 if line.find('ERROR: AddressSanitizer:') != -1:
725 bad_count += 1
726 if bad_count > 0:
727 print 'Revision %d determined to be bad.' % rev
728 return 'b'
729 return AskIsGoodBuild(rev, official_builds, status, stdout, stderr)
731 class DownloadJob(object):
732 """DownloadJob represents a task to download a given Chromium revision."""
734 def __init__(self, context, name, rev, zip_file):
735 super(DownloadJob, self).__init__()
736 # Store off the input parameters.
737 self.context = context
738 self.name = name
739 self.rev = rev
740 self.zip_file = zip_file
741 self.quit_event = threading.Event()
742 self.progress_event = threading.Event()
743 self.thread = None
745 def Start(self):
746 """Starts the download."""
747 fetchargs = (self.context,
748 self.rev,
749 self.zip_file,
750 self.quit_event,
751 self.progress_event)
752 self.thread = threading.Thread(target=FetchRevision,
753 name=self.name,
754 args=fetchargs)
755 self.thread.start()
757 def Stop(self):
758 """Stops the download which must have been started previously."""
759 assert self.thread, 'DownloadJob must be started before Stop is called.'
760 self.quit_event.set()
761 self.thread.join()
762 os.unlink(self.zip_file)
764 def WaitFor(self):
765 """Prints a message and waits for the download to complete. The download
766 must have been started previously."""
767 assert self.thread, 'DownloadJob must be started before WaitFor is called.'
768 print 'Downloading revision %s...' % str(self.rev)
769 self.progress_event.set() # Display progress of download.
770 self.thread.join()
773 def Bisect(context,
774 num_runs=1,
775 command='%p %a',
776 try_args=(),
777 profile=None,
778 interactive=True,
779 evaluate=AskIsGoodBuild):
780 """Given known good and known bad revisions, run a binary search on all
781 archived revisions to determine the last known good revision.
783 @param context PathContext object initialized with user provided parameters.
784 @param num_runs Number of times to run each build for asking good/bad.
785 @param try_args A tuple of arguments to pass to the test application.
786 @param profile The name of the user profile to run with.
787 @param interactive If it is false, use command exit code for good or bad
788 judgment of the argument build.
789 @param evaluate A function which returns 'g' if the argument build is good,
790 'b' if it's bad or 'u' if unknown.
792 Threading is used to fetch Chromium revisions in the background, speeding up
793 the user's experience. For example, suppose the bounds of the search are
794 good_rev=0, bad_rev=100. The first revision to be checked is 50. Depending on
795 whether revision 50 is good or bad, the next revision to check will be either
796 25 or 75. So, while revision 50 is being checked, the script will download
797 revisions 25 and 75 in the background. Once the good/bad verdict on rev 50 is
798 known:
800 - If rev 50 is good, the download of rev 25 is cancelled, and the next test
801 is run on rev 75.
803 - If rev 50 is bad, the download of rev 75 is cancelled, and the next test
804 is run on rev 25.
807 if not profile:
808 profile = 'profile'
810 good_rev = context.good_revision
811 bad_rev = context.bad_revision
812 cwd = os.getcwd()
814 print 'Downloading list of known revisions...',
815 if not context.use_local_cache and not context.is_official:
816 print '(use --use-local-cache to cache and re-use the list of revisions)'
817 else:
818 print
819 _GetDownloadPath = lambda rev: os.path.join(cwd,
820 '%s-%s' % (str(rev), context.archive_name))
821 if context.is_official:
822 revlist = context.GetOfficialBuildsList()
823 else:
824 revlist = context.GetRevList()
826 # Get a list of revisions to bisect across.
827 if len(revlist) < 2: # Don't have enough builds to bisect.
828 msg = 'We don\'t have enough builds to bisect. revlist: %s' % revlist
829 raise RuntimeError(msg)
831 # Figure out our bookends and first pivot point; fetch the pivot revision.
832 minrev = 0
833 maxrev = len(revlist) - 1
834 pivot = maxrev / 2
835 rev = revlist[pivot]
836 zip_file = _GetDownloadPath(rev)
837 fetch = DownloadJob(context, 'initial_fetch', rev, zip_file)
838 fetch.Start()
839 fetch.WaitFor()
841 # Binary search time!
842 while fetch and fetch.zip_file and maxrev - minrev > 1:
843 if bad_rev < good_rev:
844 min_str, max_str = 'bad', 'good'
845 else:
846 min_str, max_str = 'good', 'bad'
847 print 'Bisecting range [%s (%s), %s (%s)].' % (revlist[minrev], min_str,
848 revlist[maxrev], max_str)
850 # Pre-fetch next two possible pivots
851 # - down_pivot is the next revision to check if the current revision turns
852 # out to be bad.
853 # - up_pivot is the next revision to check if the current revision turns
854 # out to be good.
855 down_pivot = int((pivot - minrev) / 2) + minrev
856 down_fetch = None
857 if down_pivot != pivot and down_pivot != minrev:
858 down_rev = revlist[down_pivot]
859 down_fetch = DownloadJob(context, 'down_fetch', down_rev,
860 _GetDownloadPath(down_rev))
861 down_fetch.Start()
863 up_pivot = int((maxrev - pivot) / 2) + pivot
864 up_fetch = None
865 if up_pivot != pivot and up_pivot != maxrev:
866 up_rev = revlist[up_pivot]
867 up_fetch = DownloadJob(context, 'up_fetch', up_rev,
868 _GetDownloadPath(up_rev))
869 up_fetch.Start()
871 # Run test on the pivot revision.
872 status = None
873 stdout = None
874 stderr = None
875 try:
876 (status, stdout, stderr) = RunRevision(context,
877 rev,
878 fetch.zip_file,
879 profile,
880 num_runs,
881 command,
882 try_args)
883 except Exception, e:
884 print >> sys.stderr, e
886 # Call the evaluate function to see if the current revision is good or bad.
887 # On that basis, kill one of the background downloads and complete the
888 # other, as described in the comments above.
889 try:
890 if not interactive:
891 if status:
892 answer = 'b'
893 print 'Bad revision: %s' % rev
894 else:
895 answer = 'g'
896 print 'Good revision: %s' % rev
897 else:
898 answer = evaluate(rev, context.is_official, status, stdout, stderr)
899 if ((answer == 'g' and good_rev < bad_rev)
900 or (answer == 'b' and bad_rev < good_rev)):
901 fetch.Stop()
902 minrev = pivot
903 if down_fetch:
904 down_fetch.Stop() # Kill the download of the older revision.
905 fetch = None
906 if up_fetch:
907 up_fetch.WaitFor()
908 pivot = up_pivot
909 fetch = up_fetch
910 elif ((answer == 'b' and good_rev < bad_rev)
911 or (answer == 'g' and bad_rev < good_rev)):
912 fetch.Stop()
913 maxrev = pivot
914 if up_fetch:
915 up_fetch.Stop() # Kill the download of the newer revision.
916 fetch = None
917 if down_fetch:
918 down_fetch.WaitFor()
919 pivot = down_pivot
920 fetch = down_fetch
921 elif answer == 'r':
922 pass # Retry requires no changes.
923 elif answer == 'u':
924 # Nuke the revision from the revlist and choose a new pivot.
925 fetch.Stop()
926 revlist.pop(pivot)
927 maxrev -= 1 # Assumes maxrev >= pivot.
929 if maxrev - minrev > 1:
930 # Alternate between using down_pivot or up_pivot for the new pivot
931 # point, without affecting the range. Do this instead of setting the
932 # pivot to the midpoint of the new range because adjacent revisions
933 # are likely affected by the same issue that caused the (u)nknown
934 # response.
935 if up_fetch and down_fetch:
936 fetch = [up_fetch, down_fetch][len(revlist) % 2]
937 elif up_fetch:
938 fetch = up_fetch
939 else:
940 fetch = down_fetch
941 fetch.WaitFor()
942 if fetch == up_fetch:
943 pivot = up_pivot - 1 # Subtracts 1 because revlist was resized.
944 else:
945 pivot = down_pivot
946 zip_file = fetch.zip_file
948 if down_fetch and fetch != down_fetch:
949 down_fetch.Stop()
950 if up_fetch and fetch != up_fetch:
951 up_fetch.Stop()
952 else:
953 assert False, 'Unexpected return value from evaluate(): ' + answer
954 except SystemExit:
955 print 'Cleaning up...'
956 for f in [_GetDownloadPath(revlist[down_pivot]),
957 _GetDownloadPath(revlist[up_pivot])]:
958 try:
959 os.unlink(f)
960 except OSError:
961 pass
962 sys.exit(0)
964 rev = revlist[pivot]
966 return (revlist[minrev], revlist[maxrev], context)
969 def GetBlinkDEPSRevisionForChromiumRevision(self, rev):
970 """Returns the blink revision that was in REVISIONS file at
971 chromium revision |rev|."""
973 def _GetBlinkRev(url, blink_re):
974 m = blink_re.search(url.read())
975 url.close()
976 if m:
977 return m.group(1)
979 url = urllib.urlopen(DEPS_FILE_OLD % rev)
980 if url.getcode() == 200:
981 # . doesn't match newlines without re.DOTALL, so this is safe.
982 blink_re = re.compile(r'webkit_revision\D*(\d+)')
983 return int(_GetBlinkRev(url, blink_re))
984 else:
985 url = urllib.urlopen(DEPS_FILE_NEW % GetGitHashFromSVNRevision(rev))
986 if url.getcode() == 200:
987 blink_re = re.compile(r'webkit_revision\D*\d+;\D*\d+;(\w+)')
988 blink_git_sha = _GetBlinkRev(url, blink_re)
989 return self.GetSVNRevisionFromGitHash(blink_git_sha, 'blink')
990 raise Exception('Could not get Blink revision for Chromium rev %d' % rev)
993 def GetBlinkRevisionForChromiumRevision(context, rev):
994 """Returns the blink revision that was in REVISIONS file at
995 chromium revision |rev|."""
996 def _IsRevisionNumber(revision):
997 if isinstance(revision, int):
998 return True
999 else:
1000 return revision.isdigit()
1001 if str(rev) in context.githash_svn_dict:
1002 rev = context.githash_svn_dict[str(rev)]
1003 file_url = '%s/%s%s/REVISIONS' % (context.base_url,
1004 context._listing_platform_dir, rev)
1005 url = urllib.urlopen(file_url)
1006 if url.getcode() == 200:
1007 try:
1008 data = json.loads(url.read())
1009 except ValueError:
1010 print 'ValueError for JSON URL: %s' % file_url
1011 raise ValueError
1012 else:
1013 raise ValueError
1014 url.close()
1015 if 'webkit_revision' in data:
1016 blink_rev = data['webkit_revision']
1017 if not _IsRevisionNumber(blink_rev):
1018 blink_rev = int(context.GetSVNRevisionFromGitHash(blink_rev, 'blink'))
1019 return blink_rev
1020 else:
1021 raise Exception('Could not get blink revision for cr rev %d' % rev)
1024 def FixChromiumRevForBlink(revisions_final, revisions, self, rev):
1025 """Returns the chromium revision that has the correct blink revision
1026 for blink bisect, DEPS and REVISIONS file might not match since
1027 blink snapshots point to tip of tree blink.
1028 Note: The revisions_final variable might get modified to include
1029 additional revisions."""
1030 blink_deps_rev = GetBlinkDEPSRevisionForChromiumRevision(self, rev)
1032 while (GetBlinkRevisionForChromiumRevision(self, rev) > blink_deps_rev):
1033 idx = revisions.index(rev)
1034 if idx > 0:
1035 rev = revisions[idx-1]
1036 if rev not in revisions_final:
1037 revisions_final.insert(0, rev)
1039 revisions_final.sort()
1040 return rev
1043 def GetChromiumRevision(context, url):
1044 """Returns the chromium revision read from given URL."""
1045 try:
1046 # Location of the latest build revision number
1047 latest_revision = urllib.urlopen(url).read()
1048 if latest_revision.isdigit():
1049 return int(latest_revision)
1050 return context.GetSVNRevisionFromGitHash(latest_revision)
1051 except Exception:
1052 print 'Could not determine latest revision. This could be bad...'
1053 return 999999999
1055 def GetGitHashFromSVNRevision(svn_revision):
1056 crrev_url = CRREV_URL + str(svn_revision)
1057 url = urllib.urlopen(crrev_url)
1058 if url.getcode() == 200:
1059 data = json.loads(url.read())
1060 if 'git_sha' in data:
1061 return data['git_sha']
1063 def PrintChangeLog(min_chromium_rev, max_chromium_rev):
1064 """Prints the changelog URL."""
1066 print (' ' + CHANGELOG_URL % (GetGitHashFromSVNRevision(min_chromium_rev),
1067 GetGitHashFromSVNRevision(max_chromium_rev)))
1070 def main():
1071 usage = ('%prog [options] [-- chromium-options]\n'
1072 'Perform binary search on the snapshot builds to find a minimal\n'
1073 'range of revisions where a behavior change happened. The\n'
1074 'behaviors are described as "good" and "bad".\n'
1075 'It is NOT assumed that the behavior of the later revision is\n'
1076 'the bad one.\n'
1077 '\n'
1078 'Revision numbers should use\n'
1079 ' Official versions (e.g. 1.0.1000.0) for official builds. (-o)\n'
1080 ' SVN revisions (e.g. 123456) for chromium builds, from trunk.\n'
1081 ' Use base_trunk_revision from http://omahaproxy.appspot.com/\n'
1082 ' for earlier revs.\n'
1083 ' Chrome\'s about: build number and omahaproxy branch_revision\n'
1084 ' are incorrect, they are from branches.\n'
1085 '\n'
1086 'Tip: add "-- --no-first-run" to bypass the first run prompts.')
1087 parser = optparse.OptionParser(usage=usage)
1088 # Strangely, the default help output doesn't include the choice list.
1089 choices = ['mac', 'mac64', 'win', 'win64', 'linux', 'linux64', 'linux-arm',
1090 'chromeos']
1091 parser.add_option('-a', '--archive',
1092 choices=choices,
1093 help='The buildbot archive to bisect [%s].' %
1094 '|'.join(choices))
1095 parser.add_option('-o',
1096 action='store_true',
1097 dest='official_builds',
1098 help='Bisect across official Chrome builds (internal '
1099 'only) instead of Chromium archives.')
1100 parser.add_option('-b', '--bad',
1101 type='str',
1102 help='A bad revision to start bisection. '
1103 'May be earlier or later than the good revision. '
1104 'Default is HEAD.')
1105 parser.add_option('-f', '--flash_path',
1106 type='str',
1107 help='Absolute path to a recent Adobe Pepper Flash '
1108 'binary to be used in this bisection (e.g. '
1109 'on Windows C:\...\pepflashplayer.dll and on Linux '
1110 '/opt/google/chrome/PepperFlash/'
1111 'libpepflashplayer.so).')
1112 parser.add_option('-g', '--good',
1113 type='str',
1114 help='A good revision to start bisection. ' +
1115 'May be earlier or later than the bad revision. ' +
1116 'Default is 0.')
1117 parser.add_option('-p', '--profile', '--user-data-dir',
1118 type='str',
1119 default='profile',
1120 help='Profile to use; this will not reset every run. '
1121 'Defaults to a clean profile.')
1122 parser.add_option('-t', '--times',
1123 type='int',
1124 default=1,
1125 help='Number of times to run each build before asking '
1126 'if it\'s good or bad. Temporary profiles are reused.')
1127 parser.add_option('-c', '--command',
1128 type='str',
1129 default='%p %a',
1130 help='Command to execute. %p and %a refer to Chrome '
1131 'executable and specified extra arguments '
1132 'respectively. Use %s to specify all extra arguments '
1133 'as one string. Defaults to "%p %a". Note that any '
1134 'extra paths specified should be absolute.')
1135 parser.add_option('-l', '--blink',
1136 action='store_true',
1137 help='Use Blink bisect instead of Chromium. ')
1138 parser.add_option('', '--not-interactive',
1139 action='store_true',
1140 default=False,
1141 help='Use command exit code to tell good/bad revision.')
1142 parser.add_option('--asan',
1143 dest='asan',
1144 action='store_true',
1145 default=False,
1146 help='Allow the script to bisect ASAN builds')
1147 parser.add_option('--use-local-cache',
1148 dest='use_local_cache',
1149 action='store_true',
1150 default=False,
1151 help='Use a local file in the current directory to cache '
1152 'a list of known revisions to speed up the '
1153 'initialization of this script.')
1155 (opts, args) = parser.parse_args()
1157 if opts.archive is None:
1158 print 'Error: missing required parameter: --archive'
1159 print
1160 parser.print_help()
1161 return 1
1163 if opts.asan:
1164 supported_platforms = ['linux', 'mac', 'win']
1165 if opts.archive not in supported_platforms:
1166 print 'Error: ASAN bisecting only supported on these platforms: [%s].' % (
1167 '|'.join(supported_platforms))
1168 return 1
1169 if opts.official_builds:
1170 print 'Error: Do not yet support bisecting official ASAN builds.'
1171 return 1
1173 if opts.asan:
1174 base_url = ASAN_BASE_URL
1175 elif opts.blink:
1176 base_url = WEBKIT_BASE_URL
1177 else:
1178 base_url = CHROMIUM_BASE_URL
1180 # Create the context. Initialize 0 for the revisions as they are set below.
1181 context = PathContext(base_url, opts.archive, opts.good, opts.bad,
1182 opts.official_builds, opts.asan, opts.use_local_cache,
1183 opts.flash_path)
1185 # Pick a starting point, try to get HEAD for this.
1186 if not opts.bad:
1187 context.bad_revision = '999.0.0.0'
1188 context.bad_revision = GetChromiumRevision(
1189 context, context.GetLastChangeURL())
1191 # Find out when we were good.
1192 if not opts.good:
1193 context.good_revision = '0.0.0.0' if opts.official_builds else 0
1195 if opts.flash_path:
1196 msg = 'Could not find Flash binary at %s' % opts.flash_path
1197 assert os.path.exists(opts.flash_path), msg
1199 if opts.official_builds:
1200 context.good_revision = LooseVersion(context.good_revision)
1201 context.bad_revision = LooseVersion(context.bad_revision)
1202 else:
1203 context.good_revision = int(context.good_revision)
1204 context.bad_revision = int(context.bad_revision)
1206 if opts.times < 1:
1207 print('Number of times to run (%d) must be greater than or equal to 1.' %
1208 opts.times)
1209 parser.print_help()
1210 return 1
1212 if opts.asan:
1213 evaluator = IsGoodASANBuild
1214 else:
1215 evaluator = AskIsGoodBuild
1217 # Save these revision numbers to compare when showing the changelog URL
1218 # after the bisect.
1219 good_rev = context.good_revision
1220 bad_rev = context.bad_revision
1222 (min_chromium_rev, max_chromium_rev, context) = Bisect(
1223 context, opts.times, opts.command, args, opts.profile,
1224 not opts.not_interactive, evaluator)
1226 # Get corresponding blink revisions.
1227 try:
1228 min_blink_rev = GetBlinkRevisionForChromiumRevision(context,
1229 min_chromium_rev)
1230 max_blink_rev = GetBlinkRevisionForChromiumRevision(context,
1231 max_chromium_rev)
1232 except Exception:
1233 # Silently ignore the failure.
1234 min_blink_rev, max_blink_rev = 0, 0
1236 if opts.blink:
1237 # We're done. Let the user know the results in an official manner.
1238 if good_rev > bad_rev:
1239 print DONE_MESSAGE_GOOD_MAX % (str(min_blink_rev), str(max_blink_rev))
1240 else:
1241 print DONE_MESSAGE_GOOD_MIN % (str(min_blink_rev), str(max_blink_rev))
1243 print 'BLINK CHANGELOG URL:'
1244 print ' ' + BLINK_CHANGELOG_URL % (max_blink_rev, min_blink_rev)
1246 else:
1247 # We're done. Let the user know the results in an official manner.
1248 if good_rev > bad_rev:
1249 print DONE_MESSAGE_GOOD_MAX % (str(min_chromium_rev),
1250 str(max_chromium_rev))
1251 else:
1252 print DONE_MESSAGE_GOOD_MIN % (str(min_chromium_rev),
1253 str(max_chromium_rev))
1254 if min_blink_rev != max_blink_rev:
1255 print ('NOTE: There is a Blink roll in the range, '
1256 'you might also want to do a Blink bisect.')
1258 print 'CHANGELOG URL:'
1259 if opts.official_builds:
1260 print OFFICIAL_CHANGELOG_URL % (min_chromium_rev, max_chromium_rev)
1261 else:
1262 PrintChangeLog(min_chromium_rev, max_chromium_rev)
1265 if __name__ == '__main__':
1266 sys.exit(main())