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