1#!/usr/bin/env python3
2#
3# Copyright (C) 2022 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#   http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16"""Manages the release process for Bazel binary.
17
18This script walks the user through all steps required to cut a new
19Bazel binary (and related artifacts) for AOSP. This script is intended
20for use only by the current Bazel release manager.
21
22Use './release_bazel.py --help` for usage details.
23"""
24
25import argparse
26import glob
27import os
28import pathlib
29import re
30import subprocess
31import sys
32import tempfile
33
34from typing import Final
35
36# String formatting constants for prettyprint output.
37BOLD: Final[str] = "\033[1m"
38RESET: Final[str] = "\033[0m"
39
40# The sourceroot-relative-path of the shell script which updates
41# Bazel-related prebuilts to a given commit.
42UPDATE_SCRIPT_PATH: Final[str] = "prebuilts/bazel/common/update.sh"
43# All project directories that may be changed as a result of updating
44# release prebuilts.
45AFFECTED_PROJECT_DIRECTORIES: Final[list[str]] = [
46    "prebuilts/bazel/common",
47    "prebuilts/bazel/linux-x86_64",
48    "prebuilts/bazel/darwin-x86_64",
49]
50MIXED_DROID_PATH: Final[str] = "build/bazel/ci/mixed_droid.sh"
51
52# Global that represents the value of --dry-run
53dry_run: bool
54
55# Temporary directory used for log files.
56# This directory should be unique per run of this script, and should
57# thus only be initialized on demand and then reused for the rest
58# of the run.
59log_dir: str = None
60
61
62def print_step_header(description):
63  """Print the release process step with the given description."""
64  print()
65  print(f"{BOLD}===== {description}{RESET}")
66
67
68def temp_file_path(filename) -> pathlib.Path:
69  global log_dir
70  parent_dir=os.path.expanduser("~/.cache/bazel-aosp")
71  if not os.path.exists(parent_dir):
72    os.makedirs(parent_dir)
73  if log_dir is None:
74    log_dir = tempfile.mkdtemp(dir=parent_dir)
75  result = pathlib.Path(log_dir).joinpath(filename)
76  result.touch()
77  return result
78
79
80def temp_dir_path(dirname) -> pathlib.Path:
81  global log_dir
82  parent_dir=os.path.expanduser("~/.cache/bazel-aosp")
83  if not os.path.exists(parent_dir):
84    os.makedirs(parent_dir)
85  if log_dir is None:
86    log_dir = tempfile.mkdtemp(dir=parent_dir)
87  result = pathlib.Path(log_dir).joinpath(dirname)
88  result.mkdir(exist_ok=True)
89  return result
90
91
92def prompt(s) -> bool:
93  """Prompts the user for y/n input using the given string.
94
95  Will not return until the user specifies either "y" or "n".
96  Returns True if the user responded "y" and False if "n".
97  """
98  while True:
99    response = input(s + " (y/n): ")
100    if response == "y":
101      print()
102      return True
103    elif response == "n":
104      print()
105      return False
106    else:
107      print("'%s' invalid, please specify y or n." % response)
108
109
110
111def current_bazel_commit():
112  """Returns the commit of the current checked-in Bazel binary."""
113  current_bazel_files = glob.glob(
114      "prebuilts/bazel/linux-x86_64/bazel_nojdk-*-linux-x86_64")
115  if len(current_bazel_files) < 1:
116    print("could not find an existing bazel named " +
117          "prebuilts/bazel/linux-x86_64/bazel_nojdk.*")
118    sys.exit(1)
119  if len(current_bazel_files) > 1:
120    print("found multiple bazel binaries under " +
121          "prebuilts/bazel/linux-x86_64. Ensure that project is clean " +
122          f"and synced. Found: {current_bazel_files}")
123    sys.exit(1)
124  match_group = re.search(
125      "^prebuilts/bazel/linux-x86_64/bazel_nojdk-(.*)-linux-x86_64$",
126      current_bazel_files[0])
127  return match_group.group(1)
128
129
130def ensure_commit_is_new(target_commit, bazel_src_dir):
131  """Verify that the target commit is newer than the current Bazel."""
132
133  curr_commit = current_bazel_commit()
134
135  if target_commit == curr_commit:
136    print("Requested commit matches current Bazel binary version.\n" +
137          "If this is your first time running this script, this indicates " +
138          "that no new release is necessary.\n\n" +
139          "Alternatively:\n" +
140          "  - If you want to rerun release verification after already " +
141          "running this script, specify --verify-only.\n" +
142          "  - If you want to rerun the update anyway (for example, " +
143          "in the case that updating other tools failed), specify -f.")
144    sys.exit(1)
145
146  result = subprocess.run(
147      ["git", "merge-base", "--is-ancestor", curr_commit, target_commit],
148      cwd=bazel_src_dir,
149      check=False)
150  if result.returncode != 0:
151    print(f"Requested commit {target_commit} is not a descendant of " +
152          f"current Bazel binary commit {curr_commit}. Are you trying to " +
153          "update to an older commit?\n" +
154          "To force an update anyway, specify -f.")
155    sys.exit(1)
156
157
158def checkout_bazel_at(commit) -> pathlib.Path:
159  clone_dir = temp_dir_path("bazelsrc")
160  print(f"Cloning Bazel into {clone_dir}...")
161  result = subprocess.run(
162      ["git", "clone", "https://github.com/bazelbuild/bazel.git"],
163      cwd=clone_dir,
164      check=False)
165  if result.returncode != 0:
166    print("Clone failed.")
167    sys.exit(1)
168
169  bazel_src_dir = clone_dir.joinpath("bazel")
170  result = subprocess.run(
171      ["git", "checkout", commit],
172      cwd=bazel_src_dir,
173      check=False)
174  if result.returncode != 0:
175    print("Sync @%s failed." % commit)
176  return bazel_src_dir
177
178
179def ensure_projects_clean():
180  """Ensure that relevant projects in the working directory are ready.
181
182  The relevant projects must be clean, have fresh branches, and synced.
183  """
184  # TODO(b/239044269): Automate instead of asking the user.
185  print_step_header("Manual step: Clear and sync all local projects.")
186  is_new_input = prompt("Are all relevant local projects in your working " +
187                        "directory clean (fresh branches) and synced to " +
188                        "HEAD?")
189  if not is_new_input:
190    print("Please ready your local projects before continuing with the " +
191          "release script")
192    sys.exit(1)
193
194
195def run_update(commit: str, bazel_src_dir: pathlib.Path):
196  """Run the update script to update prebuilts.
197
198  Retrieves a prebuilt bazel at the given commit, and updates other checked
199  in bazel prebuilts using bazel source tree at that commit.
200  """
201
202  print_step_header("Updating prebuilts...")
203  update_script_path = pathlib.Path(UPDATE_SCRIPT_PATH).resolve()
204
205  cmd_args = [f"./{update_script_path.name}", commit, str(bazel_src_dir.absolute())]
206  target_cwd = update_script_path.parent.absolute()
207  print(f"Runnning update script (CWD: {target_cwd}): {' '.join(cmd_args)}")
208
209  if not dry_run:
210    logfile_path = temp_file_path("update.log")
211    print(f"Streaming results to {logfile_path}")
212    with logfile_path.open("w") as logfile:
213      result = subprocess.run(
214          cmd_args, cwd=target_cwd, check=False, stdout=logfile, stderr=logfile)
215      if result.returncode != 0:
216        print(f"Update failed. Check {logfile_path} for failure info.")
217        sys.exit(1)
218  else:
219    print("Dry run: actual update skipped")
220
221  print("Updated prebuilts successfully.")
222  print("Note this may have changed the following directories:")
223  for directory in AFFECTED_PROJECT_DIRECTORIES:
224    print("  " + directory)
225
226
227def verify_update():
228  """Run tests to verify the integrity of the Bazel release.
229
230  Failure during this step will require manual intervention by the user;
231  a failure might be fixed by updating other dependencies in the Android
232  tree, or might indicate that Bazel at the given commit is problematic
233  and the release may need to be abandoned.
234  """
235
236  print_step_header("Verifying the update...")
237  cmd_args = [MIXED_DROID_PATH]
238  env = {
239      "TARGET_BUILD_VARIANT": "userdebug",
240      "TARGET_PRODUCT": "aosp_arm64",
241      "PATH": os.environ["PATH"]
242  }
243  env_string = " ".join([k + "=" + v for k, v in env.items()])
244  cmd_string = " ".join(cmd_args)
245
246  print(f"Running {env_string} {cmd_string}")
247  if dry_run:
248    print("Dry run: Verification skipped")
249    return
250
251  logfile_path = temp_file_path("verify.log")
252  print(f"Streaming results to {logfile_path}")
253  with logfile_path.open("w") as logfile:
254    result = subprocess.run(
255        cmd_args, env=env, check=False, stdout=logfile, stderr=logfile)
256
257  if result.returncode != 0:
258    print(f"Verification failed. Check {logfile_path} for failure info.")
259    print("Please remedy all issues until verification runs successfully.\n" +
260          "You may skip to the verify step in this script by using " +
261          "--verify-only")
262    sys.exit(1)
263  print("Verification successful.")
264
265
266def create_commits():
267  """Create commits for all projects related to the Bazel release."""
268  print_step_header(
269      "Manual step: Create CLs for all projects that need to be " + "updated.")
270  # TODO(b/239044269): Automate instead of asking the user.
271  commits_created = prompt("Have you created CLs for all projects that need " +
272                           "to be updated?")
273  if not commits_created:
274    print(
275        "Create CLs for all projects. After approval and CL submission, the " +
276        "release is complete.")
277    sys.exit(1)
278
279
280def verify_run_from_top():
281  """Verifies that this script is being run from the workspace root.
282
283  Prints an error and exits if this is not the case.
284  """
285  if not pathlib.Path(UPDATE_SCRIPT_PATH).is_file():
286    print(f"{UPDATE_SCRIPT_PATH} not found. Are you running from the " +
287          "source root?")
288    sys.exit(1)
289
290
291def main():
292  verify_run_from_top()
293
294  parser = argparse.ArgumentParser(
295      description="Walks the user through all steps required to cut a new " +
296      "Bazel binary (and related artifacts) for AOSP. This script is " +
297      "intended for use only by the current Bazel release manager.")
298  parser.add_argument(
299      "--commit",
300      default=None,
301    # TODO(b/239044269): Obtain the most recent pre-release Bazel commit
302    # from github.
303      nargs="?",
304      help="The bazel commit hash to use. Must be specified.")
305  parser.add_argument(
306      "--force",
307      "-f",
308      action=argparse.BooleanOptionalAction,
309      help="If true, will update bazel to the given commit " +
310      "even if it is older than the current bazel binary.")
311  parser.add_argument(
312      "--verify-only",
313      action=argparse.BooleanOptionalAction,
314      help="If true, will only do verification and CL " +
315      "creation if verification passes.")
316  parser.add_argument(
317      "--dry-run",
318      action=argparse.BooleanOptionalAction,
319      help="If true, will not make any changes to local " +
320      "projects, and will instead output commands that " +
321      "should be run to do so.")
322  args = parser.parse_args()
323  global dry_run
324  dry_run = args.dry_run
325  if not args.verify_only:
326    commit = args.commit
327    if not commit:
328      raise Exception("Must specify a value for --commit")
329    bazel_src_dir = checkout_bazel_at(commit)
330    if not args.force:
331      ensure_commit_is_new(commit, bazel_src_dir)
332    ensure_projects_clean()
333    run_update(commit, bazel_src_dir)
334
335  verify_update()
336  create_commits()
337
338  print("Bazel release CLs created. After approval and " +
339        "CL submission, the release is complete.")
340
341
342if __name__ == "__main__":
343  main()
344