blob: 0fad8c6afd9216832d3f5017c5154e2e233522d8 [file] [log] [blame]
Greg Edelston5df44f12022-09-27 11:30:14 -06001#!/usr/bin/env vpython3
Greg Edelston81df4db2022-10-03 10:43:46 -06002# Copyright 2022 The ChromiumOS Authors
Greg Edelston5df44f12022-09-27 11:30:14 -06003# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Release a CrOS infra recipe bundle to prod."""
7
8import argparse
9import json
10import os
11import re
12import string
13import subprocess
14import sys
15import typing
16from typing import List
17from typing import Optional
18from typing import Tuple
19import urllib.parse
20
21RECIPES_DIR = os.path.dirname(os.path.realpath(__file__))
22RECIPE_BUNDLE = os.path.join('infra', 'recipe_bundles',
23 'chromium.googlesource.com', 'chromiumos', 'infra',
24 'recipes')
25RE_TRIVIAL_COMMIT = re.compile(r'Roll recipe.*\(trivial\)\.?$')
26STAGING_CHECKS = (
27 "LegacyNoopSuccess",
28 "staging-amd64-generic-direct-tast-vm",
29 "staging-amd64-generic-postsubmit",
30 "staging-Annealing",
31 "staging-backfiller",
32 "staging-chrome-pupr-generator",
George Engelbrechtb50e7662023-01-24 11:09:51 -070033 "staging-cq-orchestrator",
Greg Edelston5df44f12022-09-27 11:30:14 -060034 "staging-DutTracker",
35 "staging-firmware-ti50-postsubmit",
36 "staging-manifest-doctor",
37 "staging-release-main-orchestrator",
George Engelbrechta6a0e132022-10-08 13:22:58 -060038 "staging-release-triggerer",
Greg Edelston5df44f12022-09-27 11:30:14 -060039 "staging-RoboCrop",
George Engelbrechtd878ac32022-10-05 11:14:24 -060040 "staging_SourceCacheBuilder",
Greg Edelston5df44f12022-09-27 11:30:14 -060041 "staging-StarDoctor",
42)
43
44# CipdInstances are instance IDs, as found on the CIPD UI under "Instances".
45# For example: "M4HmuQVGbx8YQVkM61c6LnCHVFgpJsSy1bI4DpBjSTwC"
46CipdInstance = typing.NewType('CipdInstance', str)
47# CipdRefs are named refs, as found on the CIPD UI under "Refs".
48# For example: "prod" or "release_2022/09/23-12".
49CipdRef = typing.NewType('CipdRef', str)
50# CipdVersions can be either instance IDs or named refs.
51# These are commonly accepted by the cipd CLI's `-version` flag.
52CipdVersion = typing.Union[CipdInstance, CipdRef]
53# GitHashes are the SHA of a Git commit (in the recipes repo).
54# For example: "5d185ee5339976575a282971eef16f590405217f"
55GitHash = typing.NewType('GitHash', str)
56
57
58def main(argv: List[str]):
59 options = parse_args(argv)
60 setup()
61
62 # Figure out which hashes/instances to use.
63 git_prod = get_git_prod_hash()
64 (cipd_target, git_target) = determine_cipd_and_git_targets(options.instanceid)
65
66 # Prepare to update refs.
67 pending_changes = get_pending_changes(git_prod, git_target,
68 verbose=options.verbose)
69 report_pending_changes(pending_changes)
70 check_staging_builders(options.ignore_staging_failures)
71 quit_early_if_no_pending_changes(pending_changes)
72 if not options.force:
73 prompt_about_setting_git_target(git_target)
74
75 # Update refs.
76 update_cipd_refs(cipd_target, dry_run=options.dry_run)
77
78 # We did it!
79 print_email_link(pending_changes)
80
81
82class Commit:
83
84 def __init__(self, git_hash, username, message):
85 self.hash = git_hash
86 self.username = username
87 self.message = message
88
89 def color_str(self) -> str:
90 """Return a colorified string for printing to stdout."""
91 BOLDBLUE = '\033[1;34m'
92 BOLDGREEN = '\033[1;32m'
93 RESET = '\033[0m'
94 return f'{BOLDBLUE}{self.hash} {BOLDGREEN}[{self.username}] {RESET}{self.message}'
95
96 def plain_str(self, with_bullet: bool = False) -> str:
97 """Return a colorless string for printing to email."""
98 prefix = '* ' if with_bullet else ''
99 return f'{prefix}{self.hash} [{self.username}] {self.message}'
100
101
102def parse_args(args: List[str]) -> argparse.Namespace:
103 """Interpret command-line args."""
104 parser = argparse.ArgumentParser(
105 'Release recipes by moving the "prod" ref forward.')
106 parser.add_argument('-d', '--dry-run', action='store_true',
107 help='Dry run: Don\'t actually change any cipd refs.')
108 parser.add_argument('-f', '--force', action='store_true',
109 help='Bypass the prompt.')
110 parser.add_argument(
111 '-i', '--instanceid', type=CipdInstance,
112 help='Release up to the commit specified by the instanceid. '
113 'Instanceids are found at:\n'
114 'https://chrome-infra-packages.appspot.com/p/infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes/+\n'
115 'Click into an instance to see the commit attached to it.')
116 parser.add_argument('-s', '--ignore-staging-failures', action='store_true',
117 help='Release even if staging failures are present.')
118 parser.add_argument(
119 '-v', '--verbose', action='store_true',
120 help='Print all pending changes, including trivial recipe rolls.')
121 return parser.parse_args(args)
122
123
124def setup():
125 """Prepare for main logic."""
126 git_remote_update()
127 print_cipd_versions_url()
128
129
130def git_remote_update():
131 """Run `git remote update` in the recipes dir."""
132 subprocess.run(['git', 'remote', 'update'], stdout=subprocess.DEVNULL,
133 stderr=subprocess.DEVNULL, cwd=RECIPES_DIR, check=True)
134
135
136def print_cipd_versions_url():
137 """Tell the user where to get info about CIPD versions."""
138 print('CIPD versions (instances and refs) can be found here:')
139 print(
140 'https://chrome-infra-packages.appspot.com/p/infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes/+/'
141 )
142 print()
143
144
145def get_git_prod_hash() -> GitHash:
146 """Get the git hash for the current prod ref."""
147 return cipd_version_to_githash(CipdRef('prod'))
148
149
150def cipd_version_to_githash(version: CipdVersion) -> GitHash:
151 """Find the git hash for a recipes CIPD instance (whether named ref or ID).
152
153 Sample `cipd describe` output:
154 Package: infra/recipe_bundles/chromium.googlesource.com/chromiumos/infra/recipes
155 Instance ID: dv0onkHQ71tjY2cvQiePm-ve1tCfbHfvi40xJWs5EbAC
156 Registered by: user:infra-internal-recipe-bundler@chops-service-accounts.iam.gserviceaccount.com
157 Registered at: 2022-09-23 13:11:22.569365 -0600 MDT
158 Refs:
159 prod
160 release_2022/09/23-12
161 Tags:
162 git_revision:1114d30c71229c5cd470df15f863e9ddcfff6fb5
163 """
164 cmd = ['cipd', 'describe', '-version', version, RECIPE_BUNDLE]
165 p = subprocess.run(cmd, text=True, capture_output=True, check=True)
166 lines = p.stdout.split('\n')
167 git_rev_lines = [line for line in lines if 'git_revision' in line]
Greg Edelston7f723fb2022-10-03 11:08:56 -0600168 assert len(git_rev_lines) >= 1, lines
Greg Edelston5df44f12022-09-27 11:30:14 -0600169 git_rev_line = git_rev_lines[0]
170 assert git_rev_line.count(':') == 1, git_rev_line
171 githash = git_rev_line.strip().split(':')[1]
172 assert all(c in string.hexdigits for c in githash), githash
173 return GitHash(githash)
174
175
176def determine_cipd_and_git_targets(instanceid: Optional[CipdInstance] = None
177 ) -> Tuple[CipdInstance, GitHash]:
178 """Find the target CIPD instance (if not provided) and Git hash."""
179 if instanceid:
180 git_target = cipd_version_to_githash(instanceid)
181 cipd_target = instanceid
182 else:
183 git_target = cipd_version_to_githash(CipdInstance('refs/heads/main'))
184 cipd_target = cipd_ref_to_instance_id(CipdRef(f'git_revision:{git_target}'))
185 return (cipd_target, git_target)
186
187
188def cipd_ref_to_instance_id(ref: CipdRef) -> CipdInstance:
189 """Find the instanceid associated with a recipes CIPD ref."""
190 p = subprocess.run(['cipd', 'resolve', '-version', ref, RECIPE_BUNDLE],
191 capture_output=True, text=True, check=True)
192 stdout = [line.strip() for line in p.stdout.split('\n') if line]
Greg Edelston81df4db2022-10-03 10:43:46 -0600193 instance_id = stdout[-1].split(':')[-1]
Greg Edelston5df44f12022-09-27 11:30:14 -0600194 assert len(instance_id.split()) == 1, instance_id
195 return CipdInstance(instance_id)
196
197
198def get_pending_changes(from_hash: GitHash, to_hash: GitHash,
199 verbose: bool = False) -> List[Commit]:
200 """Find all changes that will be released.
201
202 from_hash: The git hash immediately preceding the first in the changelist.
203 to_hash: The final git hash of the changelist.
204 verbose: If False, exclude trivial recipe rolls.
205 """
206 print('=== Checking for pending changes ===')
207 print('Here are the changes from the provided (or default main) environment:')
208 if verbose:
209 print(' - Verbose specified, printing all changes')
210 fmt = '%h %al %s' # %h=commit, %al=user, %s=summary
211 cmd = [
212 'git', 'log', '--graph', f'--pretty=format:{fmt}',
213 f'{from_hash}..{to_hash}'
214 ]
215 p = subprocess.run(cmd, capture_output=True, text=True, cwd=RECIPES_DIR,
216 check=True)
217 if p.stderr:
218 print(f'Error running cmd: {cmd}')
219 print(p.stderr)
220 sys.exit(1)
221 lines = [line.strip().lstrip('* ') for line in p.stdout.split('\n')]
222 changes = []
223 for line in lines:
224 if not line:
225 continue
226 commit_hash, commit_user, commit_message = line.split(' ', 2)
227 if RE_TRIVIAL_COMMIT.match(commit_message) and not verbose:
228 continue
229 changes.append(Commit(commit_hash, commit_user, commit_message))
230 return changes
231
232
233def report_pending_changes(pending_changes: List[Commit]):
234 """Pretty-print info about all the pending changes."""
235 for pending_change in pending_changes:
236 print(f'* {pending_change.color_str()}')
237 print()
238
239
240def check_staging_builders(ignore_failures: bool = False):
241 """Check for failures in staging builders. Quit early if any problems."""
242 print('=== Check staging status ===')
243 baddies = []
George Engelbrechtb50e7662023-01-24 11:09:51 -0700244 print(
245 'Looking for 5 consecutive successes in staging, showing only failures...'
246 )
Greg Edelston5df44f12022-09-27 11:30:14 -0600247 for name in STAGING_CHECKS:
248 builder = f'chromeos/staging/{name}'
249 if has_builder_had_non_success(builder):
250 baddies.append(name)
251 if baddies:
252 if ignore_failures:
253 print('Ignoring failures, as requested.')
254 else:
255 print('Please address the failures in the above builders.')
256 print('When you\'re certain staging is OK, you may use -s to continue.')
257 sys.exit(1)
258 print()
259
260
261def has_builder_had_non_success(builder: str) -> bool:
262 """Check whether a single builder has had any recent non-successes."""
George Engelbrechtb50e7662023-01-24 11:09:51 -0700263 cmd = ['bb', 'ls', '-status', 'ended', '-n', '5', '-json', builder]
Greg Edelston5df44f12022-09-27 11:30:14 -0600264 p = subprocess.run(cmd, capture_output=True, text=True, check=True)
265 unique_statuses = []
266 for line in p.stdout.split('\n'):
267 if not line:
268 continue
269 status = json.loads(line)['status']
270 if status in ('STARTED', 'SCHEDULED'):
271 continue
272 if status not in unique_statuses:
273 unique_statuses.append(status)
George Engelbrechtb50e7662023-01-24 11:09:51 -0700274 if unique_statuses != ['SUCCESS']:
275 print(f'Non-success: {builder} --> {", ".join(unique_statuses)}')
276 return True
277 return False
Greg Edelston5df44f12022-09-27 11:30:14 -0600278
279
280def quit_early_if_no_pending_changes(pending_changes: List[Commit]):
281 """If there are no pending changes, exit gracefully."""
282 if not pending_changes:
283 print('No changes pending. Exiting early.')
284 sys.exit(0)
285
286
287def prompt_about_setting_git_target(git_target: GitHash):
288 """Ask the user whether it's OK to change the git target. If not, exit."""
289 if input(f'Set prod to git @ {git_target}? (y/N): ').upper() != 'Y':
290 sys.exit(0)
291
292
293def update_cipd_refs(cipd_target: CipdInstance, dry_run: bool = False):
294 """Set the prod ref and timestamped ref to the given cipd instance."""
295 prod_ref = CipdRef('prod')
296 timestamped_ref = CipdRef(f'release_{get_timestamp("%Y/%m/%d-%H")}')
297 for ref in (prod_ref, timestamped_ref):
298 cmd = [
299 'cipd', 'set-ref', RECIPE_BUNDLE, f'-version={cipd_target}',
300 f'-ref={ref}'
301 ]
302 if dry_run:
303 print('Not actually running the following command:')
304 print('\t ', ' '.join(cmd))
305 else:
306 subprocess.run(cmd, check=True)
307
308
309def get_timestamp(fmt: str = ''):
310 """Get a current timestamp in California time.
311
312 Use Bash's `date` because Python standard lib is shockingly bad at timezones
313 prior to Py3.9.
314 """
315 env = {'TZ': 'America/Los_Angeles'}
316 cmd = ['date']
317 if fmt:
318 cmd.append(f'+{fmt}')
319 p = subprocess.run(cmd, capture_output=True, text=True, env=env, check=True)
Greg Edelston08321462022-10-04 16:01:58 -0600320 return p.stdout.strip()
Greg Edelston5df44f12022-09-27 11:30:14 -0600321
322
323def print_email_link(pending_changes: List[Commit]):
324 """Show the user an email link to announce the new change."""
325 print()
326 print('Please click this link and send an email to chromeos-infra-releases!')
327 print()
328 print(get_email_link(pending_changes))
329
330
331def get_email_link(pending_changes: List[Commit]) -> str:
332 """Create an email link to announce the new change."""
333 email_subject = f'Recipes Release - {get_timestamp()}'
334 email_message = '\n'.join([
335 'We\'ve deployed Recipes to prod!',
336 '',
337 'Here is a summary of the changes:',
338 '',
339 '\n'.join(
340 change.plain_str(with_bullet=True) for change in pending_changes),
341 ])
342 url_params = urllib.parse.urlencode({
343 'view': 'cm',
344 'fs': 1,
345 'bcc': 'chromeos-infra-releases@google.com',
346 'to': 'chromeos-continuous-integration-team@google.com',
347 'su': email_subject,
348 'body': email_message,
349 })
350 url = f'https://mail.google.com/mail?{url_params}'
351 return url
352
353
354if __name__ == '__main__':
355 main(sys.argv[1:])