1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3# Licensed to the Apache Software Foundation (ASF) under one or more 4# contributor license agreements. See the NOTICE file distributed with 5# this work for additional information regarding copyright ownership. 6# The ASF licenses this file to You under the Apache License, Version 2.0 7# (the "License"); you may not use this file except in compliance with 8# the License. You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17 18# This script is the Release Manager's best friend, ensuring all details of a release are handled correctly. 19# It will walk you through the steps of the release process, asking for decisions or input along the way. 20# CAUTION: You still need to use your head! Please read the HELP section in the main menu. 21# 22# Requirements: 23# Install requirements with this command: 24# pip3 install -r requirements.txt 25# 26# Usage: 27# releaseWizard.py [-h] [--dry-run] [--root PATH] 28# 29# optional arguments: 30# -h, --help show this help message and exit 31# --dry-run Do not execute any commands, but echo them instead. Display 32# extra debug info 33# --root PATH Specify different root folder than ~/.lucene-releases 34 35import argparse 36import copy 37import fcntl 38import json 39import os 40import platform 41import re 42import shlex 43import shutil 44import subprocess 45import sys 46import textwrap 47import time 48import urllib 49from collections import OrderedDict 50from datetime import datetime 51from datetime import timedelta 52 53try: 54 import holidays 55 import yaml 56 from ics import Calendar, Event 57 from jinja2 import Environment 58except: 59 print("You lack some of the module dependencies to run this script.") 60 print("Please run 'pip3 install -r requirements.txt' and try again.") 61 sys.exit(1) 62 63import scriptutil 64from consolemenu import ConsoleMenu 65from consolemenu.items import FunctionItem, SubmenuItem, ExitItem 66from consolemenu.screen import Screen 67from scriptutil import BranchType, Version, download, run 68 69# Lucene-to-Java version mapping 70java_versions = {6: 8, 7: 8, 8: 8, 9: 11, 10: 11} 71editor = None 72 73# Edit this to add other global jinja2 variables or filters 74def expand_jinja(text, vars=None): 75 global_vars = OrderedDict({ 76 'script_version': state.script_version, 77 'release_version': state.release_version, 78 'release_version_underscore': state.release_version.replace('.', '_'), 79 'release_date': state.get_release_date(), 80 'ivy2_folder': os.path.expanduser("~/.ivy2/"), 81 'config_path': state.config_path, 82 'rc_number': state.rc_number, 83 'script_branch': state.script_branch, 84 'release_folder': state.get_release_folder(), 85 'git_checkout_folder': state.get_git_checkout_folder(), 86 'git_website_folder': state.get_website_git_folder(), 87 'dist_url_base': 'https://dist.apache.org/repos/dist/dev/lucene', 88 'm2_repository_url': 'https://repository.apache.org/service/local/staging/deploy/maven2', 89 'dist_file_path': state.get_dist_folder(), 90 'rc_folder': state.get_rc_folder(), 91 'base_branch': state.get_base_branch_name(), 92 'release_branch': state.release_branch, 93 'stable_branch': state.get_stable_branch_name(), 94 'minor_branch': state.get_minor_branch_name(), 95 'release_type': state.release_type, 96 'is_feature_release': state.release_type in ['minor', 'major'], 97 'release_version_major': state.release_version_major, 98 'release_version_minor': state.release_version_minor, 99 'release_version_bugfix': state.release_version_bugfix, 100 'state': state, 101 'gpg_key' : state.get_gpg_key(), 102 'gradle_cmd' : 'gradlew.bat' if is_windows() else './gradlew', 103 'epoch': unix_time_millis(datetime.utcnow()), 104 'get_next_version': state.get_next_version(), 105 'current_git_rev': state.get_current_git_rev(), 106 'keys_downloaded': keys_downloaded(), 107 'editor': get_editor(), 108 'rename_cmd': 'ren' if is_windows() else 'mv', 109 'vote_close_72h': vote_close_72h_date().strftime("%Y-%m-%d %H:00 UTC"), 110 'vote_close_72h_epoch': unix_time_millis(vote_close_72h_date()), 111 'vote_close_72h_holidays': vote_close_72h_holidays(), 112 'lucene_news_file': lucene_news_file, 113 'load_lines': load_lines, 114 'set_java_home': set_java_home, 115 'latest_version': state.get_latest_version(), 116 'latest_lts_version': state.get_latest_lts_version(), 117 'main_version': state.get_main_version(), 118 'mirrored_versions': state.get_mirrored_versions(), 119 'mirrored_versions_to_delete': state.get_mirrored_versions_to_delete(), 120 'home': os.path.expanduser("~") 121 }) 122 global_vars.update(state.get_todo_states()) 123 if vars: 124 global_vars.update(vars) 125 126 filled = replace_templates(text) 127 128 try: 129 env = Environment(lstrip_blocks=True, keep_trailing_newline=False, trim_blocks=True) 130 env.filters['path_join'] = lambda paths: os.path.join(*paths) 131 env.filters['expanduser'] = lambda path: os.path.expanduser(path) 132 env.filters['formatdate'] = lambda date: (datetime.strftime(date, "%-d %B %Y") if date else "<date>" ) 133 template = env.from_string(str(filled), globals=global_vars) 134 filled = template.render() 135 except Exception as e: 136 print("Exception while rendering jinja template %s: %s" % (str(filled)[:10], e)) 137 return filled 138 139 140def replace_templates(text): 141 tpl_lines = [] 142 for line in text.splitlines(): 143 if line.startswith("(( template="): 144 match = re.search(r"^\(\( template=(.+?) \)\)", line) 145 name = match.group(1) 146 tpl_lines.append(replace_templates(templates[name].strip())) 147 else: 148 tpl_lines.append(line) 149 return "\n".join(tpl_lines) 150 151def getScriptVersion(): 152 return scriptutil.find_current_version() 153 154 155def get_editor(): 156 global editor 157 if editor is None: 158 if 'EDITOR' in os.environ: 159 if os.environ['EDITOR'] in ['vi', 'vim', 'nano', 'pico', 'emacs']: 160 print("WARNING: You have EDITOR set to %s, which will not work when launched from this tool. Please use an editor that launches a separate window/process" % os.environ['EDITOR']) 161 editor = os.environ['EDITOR'] 162 elif is_windows(): 163 editor = 'notepad.exe' 164 elif is_mac(): 165 editor = 'open -a TextEdit' 166 else: 167 sys.exit("On Linux you have to set EDITOR variable to a command that will start an editor in its own window") 168 return editor 169 170 171def check_prerequisites(todo=None): 172 if sys.version_info < (3, 4): 173 sys.exit("Script requires Python v3.4 or later") 174 try: 175 gpg_ver = run("gpg --version").splitlines()[0] 176 except: 177 sys.exit("You will need gpg installed") 178 if not 'GPG_TTY' in os.environ: 179 print("WARNING: GPG_TTY environment variable is not set, GPG signing may not work correctly (try 'export GPG_TTY=$(tty)'") 180 if not 'JAVA11_HOME' in os.environ: 181 sys.exit("Please set environment variables JAVA11_HOME") 182 try: 183 asciidoc_ver = run("asciidoctor -V").splitlines()[0] 184 except: 185 asciidoc_ver = "" 186 print("WARNING: In order to export asciidoc version to HTML, you will need asciidoctor installed") 187 try: 188 git_ver = run("git --version").splitlines()[0] 189 except: 190 sys.exit("You will need git installed") 191 if not 'EDITOR' in os.environ: 192 print("WARNING: Environment variable $EDITOR not set, using %s" % get_editor()) 193 194 if todo: 195 print("%s\n%s\n%s\n" % (gpg_ver, asciidoc_ver, git_ver)) 196 return True 197 198 199epoch = datetime.utcfromtimestamp(0) 200 201 202def unix_time_millis(dt): 203 return int((dt - epoch).total_seconds() * 1000.0) 204 205 206def bootstrap_todos(todo_list): 207 # Establish links from commands to to_do for finding todo vars 208 for tg in todo_list: 209 if dry_run: 210 print("Group %s" % tg.id) 211 for td in tg.get_todos(): 212 if dry_run: 213 print(" Todo %s" % td.id) 214 cmds = td.commands 215 if cmds: 216 if dry_run: 217 print(" Commands") 218 cmds.todo_id = td.id 219 for cmd in cmds.commands: 220 if dry_run: 221 print(" Command %s" % cmd.cmd) 222 cmd.todo_id = td.id 223 224 print("Loaded TODO definitions from releaseWizard.yaml") 225 return todo_list 226 227 228def maybe_remove_rc_from_svn(): 229 todo = state.get_todo_by_id('import_svn') 230 if todo and todo.is_done(): 231 print("import_svn done") 232 Commands(state.get_git_checkout_folder(), 233 """Looks like you uploaded artifacts for {{ build_rc.git_rev | default("<git_rev>", True) }} to svn which needs to be removed.""", 234 [Command( 235 """svn -m "Remove cancelled Lucene {{ release_version }} RC{{ rc_number }}" rm {{ dist_url }}""", 236 logfile="svn_rm.log", 237 tee=True, 238 vars={ 239 'dist_folder': """lucene-{{ release_version }}-RC{{ rc_number }}-rev{{ build_rc.git_rev | default("<git_rev>", True) }}""", 240 'dist_url': "{{ dist_url_base }}/{{ dist_folder }}" 241 } 242 )], 243 enable_execute=True, confirm_each_command=False).run() 244 245 246# To be able to hide fields when dumping Yaml 247class SecretYamlObject(yaml.YAMLObject): 248 hidden_fields = [] 249 @classmethod 250 def to_yaml(cls,dumper,data): 251 print("Dumping object %s" % type(data)) 252 253 new_data = copy.deepcopy(data) 254 for item in cls.hidden_fields: 255 if item in new_data.__dict__: 256 del new_data.__dict__[item] 257 for item in data.__dict__: 258 if item in new_data.__dict__ and new_data.__dict__[item] is None: 259 del new_data.__dict__[item] 260 return dumper.represent_yaml_object(cls.yaml_tag, new_data, cls, 261 flow_style=cls.yaml_flow_style) 262 263 264def str_presenter(dumper, data): 265 if len(data.split('\n')) > 1: # check for multiline string 266 return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') 267 return dumper.represent_scalar('tag:yaml.org,2002:str', data) 268 269 270class ReleaseState: 271 def __init__(self, config_path, release_version, script_version): 272 self.script_version = script_version 273 self.config_path = config_path 274 self.todo_groups = None 275 self.todos = None 276 self.latest_version = None 277 self.previous_rcs = {} 278 self.rc_number = 1 279 self.start_date = unix_time_millis(datetime.utcnow()) 280 self.script_branch = run("git rev-parse --abbrev-ref HEAD").strip() 281 self.mirrored_versions = None 282 try: 283 self.script_branch_type = scriptutil.find_branch_type() 284 except: 285 print("WARNING: This script shold (ideally) run from the release branch, not a feature branch (%s)" % self.script_branch) 286 self.script_branch_type = 'feature' 287 self.set_release_version(release_version) 288 289 def set_release_version(self, version): 290 self.validate_release_version(self.script_branch_type, self.script_branch, version) 291 self.release_version = version 292 v = Version.parse(version) 293 self.release_version_major = v.major 294 self.release_version_minor = v.minor 295 self.release_version_bugfix = v.bugfix 296 self.release_branch = "branch_%s_%s" % (v.major, v.minor) 297 if v.is_major_release(): 298 self.release_type = 'major' 299 elif v.is_minor_release(): 300 self.release_type = 'minor' 301 else: 302 self.release_type = 'bugfix' 303 304 def is_released(self): 305 return self.get_todo_by_id('announce_lucene').is_done() 306 307 def get_gpg_key(self): 308 gpg_task = self.get_todo_by_id('gpg') 309 if gpg_task.is_done(): 310 return gpg_task.get_state()['gpg_key'] 311 else: 312 return None 313 314 def get_release_date(self): 315 publish_task = self.get_todo_by_id('publish_maven') 316 if publish_task.is_done(): 317 return unix_to_datetime(publish_task.get_state()['done_date']) 318 else: 319 return None 320 321 def get_release_date_iso(self): 322 release_date = self.get_release_date() 323 if release_date is None: 324 return "yyyy-mm-dd" 325 else: 326 return release_date.isoformat()[:10] 327 328 def get_latest_version(self): 329 if self.latest_version is None: 330 versions = self.get_mirrored_versions() 331 latest = versions[0] 332 for ver in versions: 333 if Version.parse(ver).gt(Version.parse(latest)): 334 latest = ver 335 self.latest_version = latest 336 self.save() 337 return state.latest_version 338 339 def get_mirrored_versions(self): 340 if state.mirrored_versions is None: 341 releases_str = load("https://projects.apache.org/json/foundation/releases.json", "utf-8") 342 releases = json.loads(releases_str)['lucene'] 343 state.mirrored_versions = [ r for r in list(map(lambda y: y[7:], filter(lambda x: x.startswith('lucene-'), list(releases.keys())))) ] 344 return state.mirrored_versions 345 346 def get_mirrored_versions_to_delete(self): 347 versions = self.get_mirrored_versions() 348 to_keep = versions 349 if state.release_type == 'major': 350 to_keep = [self.release_version, self.get_latest_version()] 351 if state.release_type == 'minor': 352 to_keep = [self.release_version, self.get_latest_lts_version()] 353 if state.release_type == 'bugfix': 354 if Version.parse(state.release_version).major == Version.parse(state.get_latest_version()).major: 355 to_keep = [self.release_version, self.get_latest_lts_version()] 356 elif Version.parse(state.release_version).major == Version.parse(state.get_latest_lts_version()).major: 357 to_keep = [self.get_latest_version(), self.release_version] 358 else: 359 raise Exception("Release version %s must have same major version as current minor or lts release") 360 return [ver for ver in versions if ver not in to_keep] 361 362 def get_main_version(self): 363 v = Version.parse(self.get_latest_version()) 364 return "%s.%s.%s" % (v.major + 1, 0, 0) 365 366 def get_latest_lts_version(self): 367 versions = self.get_mirrored_versions() 368 latest = self.get_latest_version() 369 lts_prefix = "%s." % (Version.parse(latest).major - 1) 370 lts_versions = list(filter(lambda x: x.startswith(lts_prefix), versions)) 371 latest_lts = lts_versions[0] 372 for ver in lts_versions: 373 if Version.parse(ver).gt(Version.parse(latest_lts)): 374 latest_lts = ver 375 return latest_lts 376 377 def validate_release_version(self, branch_type, branch, release_version): 378 ver = Version.parse(release_version) 379 # print("release_version=%s, ver=%s" % (release_version, ver)) 380 if branch_type == BranchType.release: 381 if not branch.startswith('branch_'): 382 sys.exit("Incompatible branch and branch_type") 383 if not ver.is_bugfix_release(): 384 sys.exit("You can only release bugfix releases from an existing release branch") 385 elif branch_type == BranchType.stable: 386 if not branch.startswith('branch_') and branch.endswith('x'): 387 sys.exit("Incompatible branch and branch_type") 388 if not ver.is_minor_release(): 389 sys.exit("You can only release minor releases from an existing stable branch") 390 elif branch_type == BranchType.unstable: 391 if not branch == 'main': 392 sys.exit("Incompatible branch and branch_type") 393 if not ver.is_major_release(): 394 sys.exit("You can only release a new major version from main branch") 395 if not getScriptVersion() == release_version: 396 print("WARNING: Expected release version %s when on branch %s, but got %s" % ( 397 getScriptVersion(), branch, release_version)) 398 399 def get_base_branch_name(self): 400 v = Version.parse(self.release_version) 401 if v.is_major_release(): 402 return 'main' 403 elif v.is_minor_release(): 404 return self.get_stable_branch_name() 405 elif v.major == Version.parse(self.get_latest_version()).major: 406 return self.get_minor_branch_name() 407 else: 408 return self.release_branch 409 410 def clear_rc(self): 411 if ask_yes_no("Are you sure? This will clear and restart RC%s" % self.rc_number): 412 maybe_remove_rc_from_svn() 413 dict = {} 414 for g in list(filter(lambda x: x.in_rc_loop(), self.todo_groups)): 415 for t in g.get_todos(): 416 t.clear() 417 print("Cleared RC TODO state") 418 try: 419 shutil.rmtree(self.get_rc_folder()) 420 print("Cleared folder %s" % self.get_rc_folder()) 421 except Exception as e: 422 print("WARN: Failed to clear %s, please do it manually with higher privileges" % self.get_rc_folder()) 423 self.save() 424 425 def new_rc(self): 426 if ask_yes_no("Are you sure? This will abort current RC"): 427 maybe_remove_rc_from_svn() 428 dict = {} 429 for g in list(filter(lambda x: x.in_rc_loop(), self.todo_groups)): 430 for t in g.get_todos(): 431 if t.applies(self.release_type): 432 dict[t.id] = copy.deepcopy(t.state) 433 t.clear() 434 self.previous_rcs["RC%d" % self.rc_number] = dict 435 self.rc_number += 1 436 self.save() 437 438 def to_dict(self): 439 tmp_todos = {} 440 for todo_id in self.todos: 441 t = self.todos[todo_id] 442 tmp_todos[todo_id] = copy.deepcopy(t.state) 443 dict = { 444 'script_version': self.script_version, 445 'release_version': self.release_version, 446 'start_date': self.start_date, 447 'rc_number': self.rc_number, 448 'script_branch': self.script_branch, 449 'todos': tmp_todos, 450 'previous_rcs': self.previous_rcs 451 } 452 if self.latest_version: 453 dict['latest_version'] = self.latest_version 454 return dict 455 456 def restore_from_dict(self, dict): 457 self.script_version = dict['script_version'] 458 assert dict['release_version'] == self.release_version 459 if 'start_date' in dict: 460 self.start_date = dict['start_date'] 461 if 'latest_version' in dict: 462 self.latest_version = dict['latest_version'] 463 else: 464 self.latest_version = None 465 self.rc_number = dict['rc_number'] 466 self.script_branch = dict['script_branch'] 467 self.previous_rcs = copy.deepcopy(dict['previous_rcs']) 468 for todo_id in dict['todos']: 469 if todo_id in self.todos: 470 t = self.todos[todo_id] 471 for k in dict['todos'][todo_id]: 472 t.state[k] = dict['todos'][todo_id][k] 473 else: 474 print("Warning: Could not restore state for %s, Todo definition not found" % todo_id) 475 476 def load(self): 477 if os.path.exists(os.path.join(self.config_path, self.release_version, 'state.yaml')): 478 state_file = os.path.join(self.config_path, self.release_version, 'state.yaml') 479 with open(state_file, 'r') as fp: 480 try: 481 dict = yaml.load(fp, Loader=yaml.Loader) 482 self.restore_from_dict(dict) 483 print("Loaded state from %s" % state_file) 484 except Exception as e: 485 print("Failed to load state from %s: %s" % (state_file, e)) 486 487 def save(self): 488 print("Saving") 489 if not os.path.exists(os.path.join(self.config_path, self.release_version)): 490 print("Creating folder %s" % os.path.join(self.config_path, self.release_version)) 491 os.makedirs(os.path.join(self.config_path, self.release_version)) 492 493 with open(os.path.join(self.config_path, self.release_version, 'state.yaml'), 'w') as fp: 494 yaml.dump(self.to_dict(), fp, sort_keys=False, default_flow_style=False) 495 496 def clear(self): 497 self.previous_rcs = {} 498 self.rc_number = 1 499 for t_id in self.todos: 500 t = self.todos[t_id] 501 t.state = {} 502 self.save() 503 504 def get_rc_number(self): 505 return self.rc_number 506 507 def get_current_git_rev(self): 508 try: 509 return run("git rev-parse HEAD", cwd=self.get_git_checkout_folder()).strip() 510 except: 511 return "<git-rev>" 512 513 def get_group_by_id(self, id): 514 lst = list(filter(lambda x: x.id == id, self.todo_groups)) 515 if len(lst) == 1: 516 return lst[0] 517 else: 518 return None 519 520 def get_todo_by_id(self, id): 521 lst = list(filter(lambda x: x.id == id, self.todos.values())) 522 if len(lst) == 1: 523 return lst[0] 524 else: 525 return None 526 527 def get_todo_state_by_id(self, id): 528 lst = list(filter(lambda x: x.id == id, self.todos.values())) 529 if len(lst) == 1: 530 return lst[0].state 531 else: 532 return {} 533 534 def get_release_folder(self): 535 folder = os.path.join(self.config_path, self.release_version) 536 if not os.path.exists(folder): 537 print("Creating folder %s" % folder) 538 os.makedirs(folder) 539 return folder 540 541 def get_rc_folder(self): 542 folder = os.path.join(self.get_release_folder(), "RC%d" % self.rc_number) 543 if not os.path.exists(folder): 544 print("Creating folder %s" % folder) 545 os.makedirs(folder) 546 return folder 547 548 def get_dist_folder(self): 549 folder = os.path.join(self.get_rc_folder(), "dist") 550 return folder 551 552 def get_git_checkout_folder(self): 553 folder = os.path.join(self.get_release_folder(), "lucene") 554 return folder 555 556 def get_website_git_folder(self): 557 folder = os.path.join(self.get_release_folder(), "lucene-site") 558 return folder 559 560 def get_minor_branch_name(self): 561 latest = state.get_latest_version() 562 if latest is not None: 563 v = Version.parse(latest) 564 return "branch_%s_%s" % (v.major, v.minor) 565 else: 566 raise Exception("Cannot find latest version") 567 568 def get_stable_branch_name(self): 569 if self.release_type == 'major': 570 v = Version.parse(self.get_main_version()) 571 else: 572 v = Version.parse(self.get_latest_version()) 573 return "branch_%sx" % v.major 574 575 def get_next_version(self): 576 if self.release_type == 'major': 577 return "%s.0.0" % (self.release_version_major + 1) 578 if self.release_type == 'minor': 579 return "%s.%s.0" % (self.release_version_major, self.release_version_minor + 1) 580 if self.release_type == 'bugfix': 581 return "%s.%s.%s" % (self.release_version_major, self.release_version_minor, self.release_version_bugfix + 1) 582 583 def get_java_home(self): 584 return self.get_java_home_for_version(self.release_version) 585 586 def get_java_home_for_version(self, version): 587 v = Version.parse(version) 588 java_ver = java_versions[v.major] 589 java_home_var = "JAVA%s_HOME" % java_ver 590 if java_home_var in os.environ: 591 return os.environ.get(java_home_var) 592 else: 593 raise Exception("Script needs environment variable %s" % java_home_var ) 594 595 def get_java_cmd_for_version(self, version): 596 return os.path.join(self.get_java_home_for_version(version), "bin", "java") 597 598 def get_java_cmd(self): 599 return os.path.join(self.get_java_home(), "bin", "java") 600 601 def get_todo_states(self): 602 states = {} 603 if self.todos: 604 for todo_id in self.todos: 605 t = self.todos[todo_id] 606 states[todo_id] = copy.deepcopy(t.state) 607 return states 608 609 def init_todos(self, groups): 610 self.todo_groups = groups 611 self.todos = {} 612 for g in self.todo_groups: 613 for t in g.get_todos(): 614 self.todos[t.id] = t 615 616 617class TodoGroup(SecretYamlObject): 618 yaml_tag = u'!TodoGroup' 619 hidden_fields = [] 620 def __init__(self, id, title, description, todos, is_in_rc_loop=None, depends=None): 621 self.id = id 622 self.title = title 623 self.description = description 624 self.depends = depends 625 self.is_in_rc_loop = is_in_rc_loop 626 self.todos = todos 627 628 @classmethod 629 def from_yaml(cls, loader, node): 630 fields = loader.construct_mapping(node, deep = True) 631 return TodoGroup(**fields) 632 633 def num_done(self): 634 return sum(1 for x in self.todos if x.is_done() > 0) 635 636 def num_applies(self): 637 count = sum(1 for x in self.todos if x.applies(state.release_type)) 638 # print("num_applies=%s" % count) 639 return count 640 641 def is_done(self): 642 # print("Done=%s, applies=%s" % (self.num_done(), self.num_applies())) 643 return self.num_done() >= self.num_applies() 644 645 def get_title(self): 646 # print("get_title: %s" % self.is_done()) 647 prefix = "" 648 if self.is_done(): 649 prefix = "✓ " 650 return "%s%s (%d/%d)" % (prefix, self.title, self.num_done(), self.num_applies()) 651 652 def get_submenu(self): 653 menu = UpdatableConsoleMenu(title=self.title, subtitle=self.get_subtitle, prologue_text=self.get_description(), 654 screen=MyScreen()) 655 menu.exit_item = CustomExitItem("Return") 656 for todo in self.get_todos(): 657 if todo.applies(state.release_type): 658 menu.append_item(todo.get_menu_item()) 659 return menu 660 661 def get_menu_item(self): 662 item = UpdatableSubmenuItem(self.get_title, self.get_submenu()) 663 return item 664 665 def get_todos(self): 666 return self.todos 667 668 def in_rc_loop(self): 669 return self.is_in_rc_loop is True 670 671 def get_description(self): 672 desc = self.description 673 if desc: 674 return expand_jinja(desc) 675 else: 676 return None 677 678 def get_subtitle(self): 679 if self.depends: 680 ret_str = "" 681 for dep in ensure_list(self.depends): 682 g = state.get_group_by_id(dep) 683 if not g: 684 g = state.get_todo_by_id(dep) 685 if g and not g.is_done(): 686 ret_str += "NOTE: Please first complete '%s'\n" % g.title 687 return ret_str.strip() 688 return None 689 690 691class Todo(SecretYamlObject): 692 yaml_tag = u'!Todo' 693 hidden_fields = ['state'] 694 def __init__(self, id, title, description=None, post_description=None, done=None, types=None, links=None, 695 commands=None, user_input=None, depends=None, vars=None, asciidoc=None, persist_vars=None, 696 function=None): 697 self.id = id 698 self.title = title 699 self.description = description 700 self.asciidoc = asciidoc 701 self.types = types 702 self.depends = depends 703 self.vars = vars 704 self.persist_vars = persist_vars 705 self.function = function 706 self.user_input = user_input 707 self.commands = commands 708 self.post_description = post_description 709 self.links = links 710 self.state = {} 711 712 self.set_done(done) 713 if self.types: 714 self.types = ensure_list(self.types) 715 for t in self.types: 716 if not t in ['minor', 'major', 'bugfix']: 717 sys.exit("Wrong Todo config for '%s'. Type needs to be either 'minor', 'major' or 'bugfix'" % self.id) 718 if commands: 719 self.commands.todo_id = self.id 720 for c in commands.commands: 721 c.todo_id = self.id 722 723 @classmethod 724 def from_yaml(cls, loader, node): 725 fields = loader.construct_mapping(node, deep = True) 726 return Todo(**fields) 727 728 def get_vars(self): 729 myvars = {} 730 if self.vars: 731 for k in self.vars: 732 val = self.vars[k] 733 if callable(val): 734 myvars[k] = expand_jinja(val(), vars=myvars) 735 else: 736 myvars[k] = expand_jinja(val, vars=myvars) 737 return myvars 738 739 def set_done(self, is_done): 740 if is_done: 741 self.state['done_date'] = unix_time_millis(datetime.utcnow()) 742 if self.persist_vars: 743 for k in self.persist_vars: 744 self.state[k] = self.get_vars()[k] 745 else: 746 self.state.clear() 747 self.state['done'] = is_done 748 749 def applies(self, type): 750 if self.types: 751 return type in self.types 752 return True 753 754 def is_done(self): 755 return 'done' in self.state and self.state['done'] is True 756 757 def get_title(self): 758 prefix = "" 759 if self.is_done(): 760 prefix = "✓ " 761 return expand_jinja("%s%s" % (prefix, self.title), self.get_vars_and_state()) 762 763 def display_and_confirm(self): 764 try: 765 if self.depends: 766 ret_str = "" 767 for dep in ensure_list(self.depends): 768 g = state.get_group_by_id(dep) 769 if not g: 770 g = state.get_todo_by_id(dep) 771 if not g.is_done(): 772 print("This step depends on '%s'. Please complete that first\n" % g.title) 773 return 774 desc = self.get_description() 775 if desc: 776 print("%s" % desc) 777 try: 778 if self.function and not self.is_done(): 779 if not eval(self.function)(self): 780 return 781 except Exception as e: 782 print("Function call to %s for todo %s failed: %s" % (self.function, self.id, e)) 783 raise e 784 if self.user_input and not self.is_done(): 785 ui_list = ensure_list(self.user_input) 786 for ui in ui_list: 787 ui.run(self.state) 788 print() 789 if self.links: 790 print("\nLinks:\n") 791 for link in self.links: 792 print("- %s" % expand_jinja(link, self.get_vars_and_state())) 793 print() 794 cmds = self.get_commands() 795 if cmds: 796 if not self.is_done(): 797 if not cmds.logs_prefix: 798 cmds.logs_prefix = self.id 799 cmds.run() 800 else: 801 print("This step is already completed. You have to first set it to 'not completed' in order to execute commands again.") 802 print() 803 if self.post_description: 804 print("%s" % self.get_post_description()) 805 todostate = self.get_state() 806 if self.is_done() and len(todostate) > 2: 807 print("Variables registered\n") 808 for k in todostate: 809 if k == 'done' or k == 'done_date': 810 continue 811 print("* %s = %s" % (k, todostate[k])) 812 print() 813 completed = ask_yes_no("Mark task '%s' as completed?" % self.get_title()) 814 self.set_done(completed) 815 state.save() 816 except Exception as e: 817 print("ERROR while executing todo %s (%s)" % (self.get_title(), e)) 818 819 def get_menu_item(self): 820 return UpdatableFunctionItem(self.get_title, self.display_and_confirm) 821 822 def clone(self): 823 clone = Todo(self.id, self.title, description=self.description) 824 clone.state = copy.deepcopy(self.state) 825 return clone 826 827 def clear(self): 828 self.state.clear() 829 self.set_done(False) 830 831 def get_state(self): 832 return self.state 833 834 def get_description(self): 835 desc = self.description 836 if desc: 837 return expand_jinja(desc, vars=self.get_vars_and_state()) 838 else: 839 return None 840 841 def get_post_description(self): 842 if self.post_description: 843 return expand_jinja(self.post_description, vars=self.get_vars_and_state()) 844 else: 845 return None 846 847 def get_commands(self): 848 cmds = self.commands 849 return cmds 850 851 def get_asciidoc(self): 852 if self.asciidoc: 853 return expand_jinja(self.asciidoc, vars=self.get_vars_and_state()) 854 else: 855 return None 856 857 def get_vars_and_state(self): 858 d = self.get_vars().copy() 859 d.update(self.get_state()) 860 return d 861 862 863def get_release_version(): 864 v = str(input("Which version are you releasing? (x.y.z) ")) 865 try: 866 version = Version.parse(v) 867 except: 868 print("Not a valid version %s" % v) 869 return get_release_version() 870 871 return str(version) 872 873 874def get_subtitle(): 875 applying_groups = list(filter(lambda x: x.num_applies() > 0, state.todo_groups)) 876 done_groups = sum(1 for x in applying_groups if x.is_done()) 877 return "Please complete the below checklist (Complete: %s/%s)" % (done_groups, len(applying_groups)) 878 879 880def get_todo_menuitem_title(): 881 return "Go to checklist (RC%d)" % (state.rc_number) 882 883 884def get_releasing_text(): 885 return "Releasing Lucene %s RC%d" % (state.release_version, state.rc_number) 886 887 888def get_start_new_rc_menu_title(): 889 return "Abort RC%d and start a new RC%d" % (state.rc_number, state.rc_number + 1) 890 891 892def start_new_rc(): 893 state.new_rc() 894 print("Started RC%d" % state.rc_number) 895 896 897def reset_state(): 898 global state 899 if ask_yes_no("Are you sure? This will erase all current progress"): 900 maybe_remove_rc_from_svn() 901 shutil.rmtree(os.path.join(state.config_path, state.release_version)) 902 state.clear() 903 904 905def template(name, vars=None): 906 return expand_jinja(templates[name], vars=vars) 907 908 909def help(): 910 print(template('help')) 911 pause() 912 913 914def ensure_list(o): 915 if o is None: 916 return [] 917 if not isinstance(o, list): 918 return [o] 919 else: 920 return o 921 922 923def open_file(filename): 924 print("Opening file %s" % filename) 925 if platform.system().startswith("Win"): 926 run("start %s" % filename) 927 else: 928 run("open %s" % filename) 929 930 931def expand_multiline(cmd_txt, indent=0): 932 return re.sub(r' +', " %s\n %s" % (Commands.cmd_continuation_char, " "*indent), cmd_txt) 933 934 935def unix_to_datetime(unix_stamp): 936 return datetime.utcfromtimestamp(unix_stamp / 1000) 937 938 939def generate_asciidoc(): 940 base_filename = os.path.join(state.get_release_folder(), 941 "lucene_release_%s" 942 % (state.release_version.replace("\.", "_"))) 943 944 filename_adoc = "%s.adoc" % base_filename 945 filename_html = "%s.html" % base_filename 946 fh = open(filename_adoc, "w") 947 948 fh.write("= Lucene Release %s\n\n" % state.release_version) 949 fh.write("(_Generated by releaseWizard.py v%s at %s_)\n\n" 950 % (getScriptVersion(), datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"))) 951 fh.write(":numbered:\n\n") 952 fh.write("%s\n\n" % template('help')) 953 for group in state.todo_groups: 954 if group.num_applies() == 0: 955 continue 956 fh.write("== %s\n\n" % group.get_title()) 957 fh.write("%s\n\n" % group.get_description()) 958 for todo in group.get_todos(): 959 if not todo.applies(state.release_type): 960 continue 961 fh.write("=== %s\n\n" % todo.get_title()) 962 if todo.is_done(): 963 fh.write("_Completed %s_\n\n" % unix_to_datetime(todo.state['done_date']).strftime( 964 "%Y-%m-%d %H:%M UTC")) 965 if todo.get_asciidoc(): 966 fh.write("%s\n\n" % todo.get_asciidoc()) 967 else: 968 desc = todo.get_description() 969 if desc: 970 fh.write("%s\n\n" % desc) 971 state_copy = copy.deepcopy(todo.state) 972 state_copy.pop('done', None) 973 state_copy.pop('done_date', None) 974 if len(state_copy) > 0 or todo.user_input is not None: 975 fh.write(".Variables collected in this step\n") 976 fh.write("|===\n") 977 fh.write("|Variable |Value\n") 978 mykeys = set() 979 for e in ensure_list(todo.user_input): 980 mykeys.add(e.name) 981 for e in state_copy.keys(): 982 mykeys.add(e) 983 for key in mykeys: 984 val = "(not set)" 985 if key in state_copy: 986 val = state_copy[key] 987 fh.write("\n|%s\n|%s\n" % (key, val)) 988 fh.write("|===\n\n") 989 cmds = todo.get_commands() 990 if cmds: 991 if cmds.commands_text: 992 fh.write("%s\n\n" % cmds.get_commands_text()) 993 fh.write("[source,sh]\n----\n") 994 if cmds.env: 995 for key in cmds.env: 996 val = cmds.env[key] 997 if is_windows(): 998 fh.write("SET %s=%s\n" % (key, val)) 999 else: 1000 fh.write("export %s=%s\n" % (key, val)) 1001 fh.write(abbreviate_homedir("cd %s\n" % cmds.get_root_folder())) 1002 cmds2 = ensure_list(cmds.commands) 1003 for c in cmds2: 1004 for line in c.display_cmd(): 1005 fh.write("%s\n" % line) 1006 fh.write("----\n\n") 1007 if todo.post_description and not todo.get_asciidoc(): 1008 fh.write("\n%s\n\n" % todo.get_post_description()) 1009 if todo.links: 1010 fh.write("Links:\n\n") 1011 for l in todo.links: 1012 fh.write("* %s\n" % expand_jinja(l)) 1013 fh.write("\n") 1014 1015 fh.close() 1016 print("Wrote file %s" % os.path.join(state.get_release_folder(), filename_adoc)) 1017 print("Running command 'asciidoctor %s'" % filename_adoc) 1018 run_follow("asciidoctor %s" % filename_adoc) 1019 if os.path.exists(filename_html): 1020 open_file(filename_html) 1021 else: 1022 print("Failed generating HTML version, please install asciidoctor") 1023 pause() 1024 1025 1026def load_rc(): 1027 lucenerc = os.path.expanduser("~/.lucenerc") 1028 try: 1029 with open(lucenerc, 'r') as fp: 1030 return json.load(fp) 1031 except: 1032 return None 1033 1034 1035def store_rc(release_root, release_version=None): 1036 lucenerc = os.path.expanduser("~/.lucenerc") 1037 dict = {} 1038 dict['root'] = release_root 1039 if release_version: 1040 dict['release_version'] = release_version 1041 with open(lucenerc, "w") as fp: 1042 json.dump(dict, fp, indent=2) 1043 1044 1045def release_other_version(): 1046 if not state.is_released(): 1047 maybe_remove_rc_from_svn() 1048 store_rc(state.config_path, None) 1049 print("Please restart the wizard") 1050 sys.exit(0) 1051 1052def file_to_string(filename): 1053 with open(filename, encoding='utf8') as f: 1054 return f.read().strip() 1055 1056def download_keys(): 1057 download('KEYS', "https://archive.apache.org/dist/lucene/KEYS", state.config_path) 1058 1059def keys_downloaded(): 1060 return os.path.exists(os.path.join(state.config_path, "KEYS")) 1061 1062 1063def dump_yaml(): 1064 file = open(os.path.join(script_path, "releaseWizard.yaml"), "w") 1065 yaml.add_representer(str, str_presenter) 1066 yaml.Dumper.ignore_aliases = lambda *args : True 1067 dump_obj = {'templates': templates, 1068 'groups': state.todo_groups} 1069 yaml.dump(dump_obj, width=180, stream=file, sort_keys=False, default_flow_style=False) 1070 1071 1072def parse_config(): 1073 description = 'Script to guide a RM through the whole release process' 1074 parser = argparse.ArgumentParser(description=description, epilog="Go push that release!", 1075 formatter_class=argparse.RawDescriptionHelpFormatter) 1076 parser.add_argument('--dry-run', dest='dry', action='store_true', default=False, 1077 help='Do not execute any commands, but echo them instead. Display extra debug info') 1078 parser.add_argument('--init', action='store_true', default=False, 1079 help='Re-initialize root and version') 1080 config = parser.parse_args() 1081 1082 return config 1083 1084 1085def load(urlString, encoding="utf-8"): 1086 try: 1087 content = urllib.request.urlopen(urlString).read().decode(encoding) 1088 except Exception as e: 1089 print('Retrying download of url %s after exception: %s' % (urlString, e)) 1090 content = urllib.request.urlopen(urlString).read().decode(encoding) 1091 return content 1092 1093 1094def configure_pgp(gpg_todo): 1095 print("Based on your Apache ID we'll lookup your key online\n" 1096 "and through this complete the 'gpg' prerequisite task.\n") 1097 gpg_state = gpg_todo.get_state() 1098 id = str(input("Please enter your Apache id: (ENTER=skip) ")) 1099 if id.strip() == '': 1100 return False 1101 key_url = "https://home.apache.org/keys/committer/%s.asc" % id.strip() 1102 committer_key = load(key_url) 1103 lines = committer_key.splitlines() 1104 keyid_linenum = None 1105 for idx, line in enumerate(lines): 1106 if line == 'ASF ID: %s' % id: 1107 keyid_linenum = idx+1 1108 break 1109 if keyid_linenum: 1110 keyid_line = lines[keyid_linenum] 1111 assert keyid_line.startswith('LDAP PGP key: ') 1112 gpg_id = keyid_line[14:].replace(" ", "")[-8:] 1113 print("Found gpg key id %s on file at Apache (%s)" % (gpg_id, key_url)) 1114 else: 1115 print(textwrap.dedent("""\ 1116 Could not find your GPG key from Apache servers. 1117 Please make sure you have registered your key ID in 1118 id.apache.org, see links for more info.""")) 1119 gpg_id = str(input("Enter your key ID manually, 8 last characters (ENTER=skip): ")) 1120 if gpg_id.strip() == '': 1121 return False 1122 elif len(gpg_id) != 8: 1123 print("gpg id must be the last 8 characters of your key id") 1124 gpg_id = gpg_id.upper() 1125 try: 1126 res = run("gpg --list-secret-keys %s" % gpg_id) 1127 print("Found key %s on your private gpg keychain" % gpg_id) 1128 # Check rsa and key length >= 4096 1129 match = re.search(r'^sec +((rsa|dsa)(\d{4})) ', res) 1130 type = "(unknown)" 1131 length = -1 1132 if match: 1133 type = match.group(2) 1134 length = int(match.group(3)) 1135 else: 1136 match = re.search(r'^sec +((\d{4})([DR])/.*?) ', res) 1137 if match: 1138 type = 'rsa' if match.group(3) == 'R' else 'dsa' 1139 length = int(match.group(2)) 1140 else: 1141 print("Could not determine type and key size for your key") 1142 print("%s" % res) 1143 if not ask_yes_no("Is your key of type RSA and size >= 2048 (ideally 4096)? "): 1144 print("Sorry, please generate a new key, add to KEYS and register with id.apache.org") 1145 return False 1146 if not type == 'rsa': 1147 print("We strongly recommend RSA type key, your is '%s'. Consider generating a new key." % type.upper()) 1148 if length < 2048: 1149 print("Your key has key length of %s. Cannot use < 2048, please generate a new key before doing the release" % length) 1150 return False 1151 if length < 4096: 1152 print("Your key length is < 4096, Please generate a stronger key.") 1153 print("Alternatively, follow instructions in http://www.apache.org/dev/release-signing.html#note") 1154 if not ask_yes_no("Have you configured your gpg to avoid SHA-1?"): 1155 print("Please either generate a strong key or reconfigure your client") 1156 return False 1157 print("Validated that your key is of type RSA and has a length >= 2048 (%s)" % length) 1158 except: 1159 print(textwrap.dedent("""\ 1160 Key not found on your private gpg keychain. In order to sign the release you'll 1161 need to fix this, then try again""")) 1162 return False 1163 try: 1164 lines = run("gpg --check-signatures %s" % gpg_id).splitlines() 1165 sigs = 0 1166 apache_sigs = 0 1167 for line in lines: 1168 if line.startswith("sig") and not gpg_id in line: 1169 sigs += 1 1170 if '@apache.org' in line: 1171 apache_sigs += 1 1172 print("Your key has %s signatures, of which %s are by committers (@apache.org address)" % (sigs, apache_sigs)) 1173 if apache_sigs < 1: 1174 print(textwrap.dedent("""\ 1175 Your key is not signed by any other committer. 1176 Please review http://www.apache.org/dev/openpgp.html#apache-wot 1177 and make sure to get your key signed until next time. 1178 You may want to run 'gpg --refresh-keys' to refresh your keychain.""")) 1179 uses_apacheid = is_code_signing_key = False 1180 for line in lines: 1181 if line.startswith("uid") and "%s@apache" % id in line: 1182 uses_apacheid = True 1183 if 'CODE SIGNING KEY' in line.upper(): 1184 is_code_signing_key = True 1185 if not uses_apacheid: 1186 print("WARNING: Your key should use your apache-id email address, see http://www.apache.org/dev/release-signing.html#user-id") 1187 if not is_code_signing_key: 1188 print("WARNING: You code signing key should be labeled 'CODE SIGNING KEY', see http://www.apache.org/dev/release-signing.html#key-comment") 1189 except Exception as e: 1190 print("Could not check signatures of your key: %s" % e) 1191 1192 download_keys() 1193 keys_text = file_to_string(os.path.join(state.config_path, "KEYS")) 1194 if gpg_id in keys_text or "%s %s" % (gpg_id[:4], gpg_id[-4:]) in keys_text: 1195 print("Found your key ID in official KEYS file. KEYS file is not cached locally.") 1196 else: 1197 print(textwrap.dedent("""\ 1198 Could not find your key ID in official KEYS file. 1199 Please make sure it is added to https://dist.apache.org/repos/dist/release/lucene/KEYS 1200 and committed to svn. Then re-try this initialization""")) 1201 if not ask_yes_no("Do you want to continue without fixing KEYS file? (not recommended) "): 1202 return False 1203 1204 gpg_state['apache_id'] = id 1205 gpg_state['gpg_key'] = gpg_id 1206 return True 1207 1208 1209def pause(fun=None): 1210 if fun: 1211 fun() 1212 input("\nPress ENTER to continue...") 1213 1214 1215# Custom classes for ConsoleMenu, to make menu texts dynamic 1216# Needed until https://github.com/aegirhall/console-menu/pull/25 is released 1217# See https://pypi.org/project/console-menu/ for other docs 1218 1219class UpdatableConsoleMenu(ConsoleMenu): 1220 1221 def __repr__(self): 1222 return "%s: %s. %d items" % (self.get_title(), self.get_subtitle(), len(self.items)) 1223 1224 def draw(self): 1225 """ 1226 Refreshes the screen and redraws the menu. Should be called whenever something changes that needs to be redrawn. 1227 """ 1228 self.screen.printf(self.formatter.format(title=self.get_title(), subtitle=self.get_subtitle(), items=self.items, 1229 prologue_text=self.get_prologue_text(), epilogue_text=self.get_epilogue_text())) 1230 1231 # Getters to get text in case method reference 1232 def get_title(self): 1233 return self.title() if callable(self.title) else self.title 1234 1235 def get_subtitle(self): 1236 return self.subtitle() if callable(self.subtitle) else self.subtitle 1237 1238 def get_prologue_text(self): 1239 return self.prologue_text() if callable(self.prologue_text) else self.prologue_text 1240 1241 def get_epilogue_text(self): 1242 return self.epilogue_text() if callable(self.epilogue_text) else self.epilogue_text 1243 1244 1245class UpdatableSubmenuItem(SubmenuItem): 1246 def __init__(self, text, submenu, menu=None, should_exit=False): 1247 """ 1248 :ivar ConsoleMenu self.submenu: The submenu to be opened when this item is selected 1249 """ 1250 super(SubmenuItem, self).__init__(text=text, menu=menu, should_exit=should_exit) 1251 1252 self.submenu = submenu 1253 if menu: 1254 self.get_submenu().parent = menu 1255 1256 def show(self, index): 1257 return "%2d - %s" % (index + 1, self.get_text()) 1258 1259 # Getters to get text in case method reference 1260 def get_text(self): 1261 return self.text() if callable(self.text) else self.text 1262 1263 def set_menu(self, menu): 1264 """ 1265 Sets the menu of this item. 1266 Should be used instead of directly accessing the menu attribute for this class. 1267 1268 :param ConsoleMenu menu: the menu 1269 """ 1270 self.menu = menu 1271 self.get_submenu().parent = menu 1272 1273 def action(self): 1274 """ 1275 This class overrides this method 1276 """ 1277 self.get_submenu().start() 1278 1279 def clean_up(self): 1280 """ 1281 This class overrides this method 1282 """ 1283 self.get_submenu().join() 1284 self.menu.clear_screen() 1285 self.menu.resume() 1286 1287 def get_return(self): 1288 """ 1289 :return: The returned value in the submenu 1290 """ 1291 return self.get_submenu().returned_value 1292 1293 def get_submenu(self): 1294 """ 1295 We unwrap the submenu variable in case it is a reference to a method that returns a submenu 1296 """ 1297 return self.submenu if not callable(self.submenu) else self.submenu() 1298 1299 1300class UpdatableFunctionItem(FunctionItem): 1301 def show(self, index): 1302 return "%2d - %s" % (index + 1, self.get_text()) 1303 1304 # Getters to get text in case method reference 1305 def get_text(self): 1306 return self.text() if callable(self.text) else self.text 1307 1308 1309class MyScreen(Screen): 1310 def clear(self): 1311 return 1312 1313 1314class CustomExitItem(ExitItem): 1315 def show(self, index): 1316 return super(ExitItem, self).show(index) 1317 1318 def get_return(self): 1319 return "" 1320 1321 1322def main(): 1323 global state 1324 global dry_run 1325 global templates 1326 1327 print("Lucene releaseWizard v%s" % getScriptVersion()) 1328 c = parse_config() 1329 1330 if c.dry: 1331 print("Entering dry-run mode where all commands will be echoed instead of executed") 1332 dry_run = True 1333 1334 release_root = os.path.expanduser("~/.lucene-releases") 1335 if not load_rc() or c.init: 1336 print("Initializing") 1337 dir_ok = False 1338 root = str(input("Choose root folder: [~/.lucene-releases] ")) 1339 if os.path.exists(root) and (not os.path.isdir(root) or not os.access(root, os.W_OK)): 1340 sys.exit("Root %s exists but is not a directory or is not writable" % root) 1341 if not root == '': 1342 if root.startswith("~/"): 1343 release_root = os.path.expanduser(root) 1344 else: 1345 release_root = os.path.abspath(root) 1346 if not os.path.exists(release_root): 1347 try: 1348 print("Creating release root %s" % release_root) 1349 os.makedirs(release_root) 1350 except Exception as e: 1351 sys.exit("Error while creating %s: %s" % (release_root, e)) 1352 release_version = get_release_version() 1353 else: 1354 conf = load_rc() 1355 release_root = conf['root'] 1356 if 'release_version' in conf: 1357 release_version = conf['release_version'] 1358 else: 1359 release_version = get_release_version() 1360 store_rc(release_root, release_version) 1361 1362 check_prerequisites() 1363 1364 try: 1365 y = yaml.load(open(os.path.join(script_path, "releaseWizard.yaml"), "r"), Loader=yaml.Loader) 1366 templates = y.get('templates') 1367 todo_list = y.get('groups') 1368 state = ReleaseState(release_root, release_version, getScriptVersion()) 1369 state.init_todos(bootstrap_todos(todo_list)) 1370 state.load() 1371 except Exception as e: 1372 sys.exit("Failed initializing. %s" % e) 1373 1374 state.save() 1375 1376 # Smoketester requires JAVA11_HOME to point to Java11 1377 os.environ['JAVA_HOME'] = state.get_java_home() 1378 os.environ['JAVACMD'] = state.get_java_cmd() 1379 1380 global lucene_news_file 1381 lucene_news_file = os.path.join(state.get_website_git_folder(), 'content', 'core', 'core_news', 1382 "%s-%s-available.md" % (state.get_release_date_iso(), state.release_version.replace(".", "-"))) 1383 website_folder = state.get_website_git_folder() 1384 1385 main_menu = UpdatableConsoleMenu(title="Lucene ReleaseWizard", 1386 subtitle=get_releasing_text, 1387 prologue_text="Welcome to the release wizard. From here you can manage the process including creating new RCs. " 1388 "All changes are persisted, so you can exit any time and continue later. Make sure to read the Help section.", 1389 epilogue_text="® 2021 The Lucene project. Licensed under the Apache License 2.0\nScript version v%s)" % getScriptVersion(), 1390 screen=MyScreen()) 1391 1392 todo_menu = UpdatableConsoleMenu(title=get_releasing_text, 1393 subtitle=get_subtitle, 1394 prologue_text=None, 1395 epilogue_text=None, 1396 screen=MyScreen()) 1397 todo_menu.exit_item = CustomExitItem("Return") 1398 1399 for todo_group in state.todo_groups: 1400 if todo_group.num_applies() >= 0: 1401 menu_item = todo_group.get_menu_item() 1402 menu_item.set_menu(todo_menu) 1403 todo_menu.append_item(menu_item) 1404 1405 main_menu.append_item(UpdatableSubmenuItem(get_todo_menuitem_title, todo_menu, menu=main_menu)) 1406 main_menu.append_item(UpdatableFunctionItem(get_start_new_rc_menu_title, start_new_rc)) 1407 main_menu.append_item(UpdatableFunctionItem('Clear and restart current RC', state.clear_rc)) 1408 main_menu.append_item(UpdatableFunctionItem("Clear all state, restart the %s release" % state.release_version, reset_state)) 1409 main_menu.append_item(UpdatableFunctionItem('Start release for a different version', release_other_version)) 1410 main_menu.append_item(UpdatableFunctionItem('Generate Asciidoc guide for this release', generate_asciidoc)) 1411 # main_menu.append_item(UpdatableFunctionItem('Dump YAML', dump_yaml)) 1412 main_menu.append_item(UpdatableFunctionItem('Help', help)) 1413 1414 main_menu.show() 1415 1416 1417sys.path.append(os.path.dirname(__file__)) 1418current_git_root = os.path.abspath( 1419 os.path.join(os.path.abspath(os.path.dirname(__file__)), os.path.pardir, os.path.pardir)) 1420 1421dry_run = False 1422 1423major_minor = ['major', 'minor'] 1424script_path = os.path.dirname(os.path.realpath(__file__)) 1425os.chdir(script_path) 1426 1427 1428def git_checkout_folder(): 1429 return state.get_git_checkout_folder() 1430 1431 1432def tail_file(file, lines): 1433 bufsize = 8192 1434 fsize = os.stat(file).st_size 1435 with open(file) as f: 1436 if bufsize >= fsize: 1437 bufsize = fsize 1438 idx = 0 1439 while True: 1440 idx += 1 1441 seek_pos = fsize - bufsize * idx 1442 if seek_pos < 0: 1443 seek_pos = 0 1444 f.seek(seek_pos) 1445 data = [] 1446 data.extend(f.readlines()) 1447 if len(data) >= lines or f.tell() == 0 or seek_pos == 0: 1448 if not seek_pos == 0: 1449 print("Tailing last %d lines of file %s" % (lines, file)) 1450 print(''.join(data[-lines:])) 1451 break 1452 1453 1454def run_with_log_tail(command, cwd, logfile=None, tail_lines=10, tee=False, live=False, shell=None): 1455 fh = sys.stdout 1456 if logfile: 1457 logdir = os.path.dirname(logfile) 1458 if not os.path.exists(logdir): 1459 os.makedirs(logdir) 1460 fh = open(logfile, 'w') 1461 rc = run_follow(command, cwd, fh=fh, tee=tee, live=live, shell=shell) 1462 if logfile: 1463 fh.close() 1464 if not tee and tail_lines and tail_lines > 0: 1465 tail_file(logfile, tail_lines) 1466 return rc 1467 1468 1469def ask_yes_no(text): 1470 answer = None 1471 while answer not in ['y', 'n']: 1472 answer = str(input("\nQ: %s (y/n): " % text)) 1473 print("\n") 1474 return answer == 'y' 1475 1476 1477def abbreviate_line(line, width): 1478 line = line.rstrip() 1479 if len(line) > width: 1480 line = "%s.....%s" % (line[:(width / 2 - 5)], line[-(width / 2):]) 1481 else: 1482 line = "%s%s" % (line, " " * (width - len(line) + 2)) 1483 return line 1484 1485 1486def print_line_cr(line, linenum, stdout=True, tee=False): 1487 if not tee: 1488 if not stdout: 1489 print("[line %s] %s" % (linenum, abbreviate_line(line, 80)), end='\r') 1490 else: 1491 if line.endswith("\r"): 1492 print(line.rstrip(), end='\r') 1493 else: 1494 print(line.rstrip()) 1495 1496 1497def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=None): 1498 doShell = '&&' in command or '&' in command or shell is not None 1499 if not doShell and not isinstance(command, list): 1500 command = shlex.split(command) 1501 process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, 1502 universal_newlines=True, bufsize=0, close_fds=True, shell=doShell) 1503 lines_written = 0 1504 1505 fl = fcntl.fcntl(process.stdout, fcntl.F_GETFL) 1506 fcntl.fcntl(process.stdout, fcntl.F_SETFL, fl | os.O_NONBLOCK) 1507 1508 flerr = fcntl.fcntl(process.stderr, fcntl.F_GETFL) 1509 fcntl.fcntl(process.stderr, fcntl.F_SETFL, flerr | os.O_NONBLOCK) 1510 1511 endstdout = endstderr = False 1512 errlines = [] 1513 while not (endstderr and endstdout): 1514 lines_before = lines_written 1515 if not endstdout: 1516 try: 1517 if live: 1518 chars = process.stdout.read() 1519 if chars == '' and process.poll() is not None: 1520 endstdout = True 1521 else: 1522 fh.write(chars) 1523 fh.flush() 1524 if '\n' in chars: 1525 lines_written += 1 1526 else: 1527 line = process.stdout.readline() 1528 if line == '' and process.poll() is not None: 1529 endstdout = True 1530 else: 1531 fh.write("%s\n" % line.rstrip()) 1532 fh.flush() 1533 lines_written += 1 1534 print_line_cr(line, lines_written, stdout=(fh == sys.stdout), tee=tee) 1535 1536 except Exception as ioe: 1537 pass 1538 if not endstderr: 1539 try: 1540 if live: 1541 chars = process.stderr.read() 1542 if chars == '' and process.poll() is not None: 1543 endstderr = True 1544 else: 1545 fh.write(chars) 1546 fh.flush() 1547 if '\n' in chars: 1548 lines_written += 1 1549 else: 1550 line = process.stderr.readline() 1551 if line == '' and process.poll() is not None: 1552 endstderr = True 1553 else: 1554 errlines.append("%s\n" % line.rstrip()) 1555 lines_written += 1 1556 print_line_cr(line, lines_written, stdout=(fh == sys.stdout), tee=tee) 1557 except Exception as e: 1558 pass 1559 1560 if not lines_written > lines_before: 1561 # if no output then sleep a bit before checking again 1562 time.sleep(0.1) 1563 1564 print(" " * 80) 1565 rc = process.poll() 1566 if len(errlines) > 0: 1567 for line in errlines: 1568 fh.write("%s\n" % line.rstrip()) 1569 fh.flush() 1570 return rc 1571 1572 1573def is_windows(): 1574 return platform.system().startswith("Win") 1575 1576def is_mac(): 1577 return platform.system().startswith("Darwin") 1578 1579def is_linux(): 1580 return platform.system().startswith("Linux") 1581 1582class Commands(SecretYamlObject): 1583 yaml_tag = u'!Commands' 1584 hidden_fields = ['todo_id'] 1585 cmd_continuation_char = "^" if is_windows() else "\\" 1586 def __init__(self, root_folder, commands_text=None, commands=None, logs_prefix=None, run_text=None, enable_execute=None, 1587 confirm_each_command=None, env=None, vars=None, todo_id=None, remove_files=None): 1588 self.root_folder = root_folder 1589 self.commands_text = commands_text 1590 self.vars = vars 1591 self.env = env 1592 self.run_text = run_text 1593 self.remove_files = remove_files 1594 self.todo_id = todo_id 1595 self.logs_prefix = logs_prefix 1596 self.enable_execute = enable_execute 1597 self.confirm_each_command = confirm_each_command 1598 self.commands = commands 1599 for c in self.commands: 1600 c.todo_id = todo_id 1601 1602 @classmethod 1603 def from_yaml(cls, loader, node): 1604 fields = loader.construct_mapping(node, deep = True) 1605 return Commands(**fields) 1606 1607 def run(self): 1608 root = self.get_root_folder() 1609 1610 if self.commands_text: 1611 print(self.get_commands_text()) 1612 if self.env: 1613 for key in self.env: 1614 val = self.jinjaify(self.env[key]) 1615 os.environ[key] = val 1616 if is_windows(): 1617 print("\n SET %s=%s" % (key, val)) 1618 else: 1619 print("\n export %s=%s" % (key, val)) 1620 print(abbreviate_homedir("\n cd %s" % root)) 1621 commands = ensure_list(self.commands) 1622 for cmd in commands: 1623 for line in cmd.display_cmd(): 1624 print(" %s" % line) 1625 print() 1626 if not self.enable_execute is False: 1627 if self.run_text: 1628 print("\n%s\n" % self.get_run_text()) 1629 if len(commands) > 1: 1630 if not self.confirm_each_command is False: 1631 print("You will get prompted before running each individual command.") 1632 else: 1633 print( 1634 "You will not be prompted for each command but will see the ouput of each. If one command fails the execution will stop.") 1635 success = True 1636 if ask_yes_no("Do you want me to run these commands now?"): 1637 if self.remove_files: 1638 for _f in ensure_list(self.get_remove_files()): 1639 f = os.path.join(root, _f) 1640 if os.path.exists(f): 1641 filefolder = "File" if os.path.isfile(f) else "Folder" 1642 if ask_yes_no("%s %s already exists. Shall I remove it now?" % (filefolder, f)) and not dry_run: 1643 if os.path.isdir(f): 1644 shutil.rmtree(f) 1645 else: 1646 os.remove(f) 1647 index = 0 1648 log_folder = self.logs_prefix if len(commands) > 1 else None 1649 for cmd in commands: 1650 index += 1 1651 if len(commands) > 1: 1652 log_prefix = "%02d_" % index 1653 else: 1654 log_prefix = self.logs_prefix if self.logs_prefix else '' 1655 if not log_prefix[-1:] == '_': 1656 log_prefix += "_" 1657 cwd = root 1658 if cmd.cwd: 1659 cwd = os.path.join(root, cmd.cwd) 1660 folder_prefix = '' 1661 if cmd.cwd: 1662 folder_prefix = cmd.cwd + "_" 1663 if self.confirm_each_command is False or len(commands) == 1 or ask_yes_no("Shall I run '%s' in folder '%s'" % (cmd, cwd)): 1664 if self.confirm_each_command is False: 1665 print("------------\nRunning '%s' in folder '%s'" % (cmd, cwd)) 1666 logfilename = cmd.logfile 1667 logfile = None 1668 cmd_to_run = "%s%s" % ("echo Dry run, command is: " if dry_run else "", cmd.get_cmd()) 1669 if cmd.redirect: 1670 try: 1671 out = run(cmd_to_run, cwd=cwd) 1672 mode = 'a' if cmd.redirect_append is True else 'w' 1673 with open(os.path.join(root, cwd, cmd.get_redirect()), mode) as outfile: 1674 outfile.write(out) 1675 outfile.flush() 1676 print("Wrote %s bytes to redirect file %s" % (len(out), cmd.get_redirect())) 1677 except Exception as e: 1678 print("Command %s failed: %s" % (cmd_to_run, e)) 1679 success = False 1680 break 1681 else: 1682 if not cmd.stdout: 1683 if not log_folder: 1684 log_folder = os.path.join(state.get_rc_folder(), "logs") 1685 elif not os.path.isabs(log_folder): 1686 log_folder = os.path.join(state.get_rc_folder(), "logs", log_folder) 1687 if not logfilename: 1688 logfilename = "%s.log" % re.sub(r"\W", "_", cmd.get_cmd()) 1689 logfile = os.path.join(log_folder, "%s%s%s" % (log_prefix, folder_prefix, logfilename)) 1690 if cmd.tee: 1691 print("Output of command will be printed (logfile=%s)" % logfile) 1692 elif cmd.live: 1693 print("Output will be shown live byte by byte") 1694 logfile = None 1695 else: 1696 print("Wait until command completes... Full log in %s\n" % logfile) 1697 if cmd.comment: 1698 print("# %s\n" % cmd.get_comment()) 1699 start_time = time.time() 1700 returncode = run_with_log_tail(cmd_to_run, cwd, logfile=logfile, tee=cmd.tee, tail_lines=25, 1701 live=cmd.live, shell=cmd.shell) 1702 elapsed = time.time() - start_time 1703 if not returncode == 0: 1704 if cmd.should_fail: 1705 print("Command failed, which was expected") 1706 success = True 1707 else: 1708 print("WARN: Command %s returned with error" % cmd.get_cmd()) 1709 success = False 1710 break 1711 else: 1712 if cmd.should_fail and not dry_run: 1713 print("Expected command to fail, but it succeeded.") 1714 success = False 1715 break 1716 else: 1717 if elapsed > 30: 1718 print("Command completed in %s seconds" % elapsed) 1719 if not success: 1720 print("WARNING: One or more commands failed, you may want to check the logs") 1721 return success 1722 1723 def get_root_folder(self): 1724 return self.jinjaify(self.root_folder) 1725 1726 def get_commands_text(self): 1727 return self.jinjaify(self.commands_text) 1728 1729 def get_run_text(self): 1730 return self.jinjaify(self.run_text) 1731 1732 def get_remove_files(self): 1733 return self.jinjaify(self.remove_files) 1734 1735 def get_vars(self): 1736 myvars = {} 1737 if self.vars: 1738 for k in self.vars: 1739 val = self.vars[k] 1740 if callable(val): 1741 myvars[k] = expand_jinja(val(), vars=myvars) 1742 else: 1743 myvars[k] = expand_jinja(val, vars=myvars) 1744 return myvars 1745 1746 def jinjaify(self, data, join=False): 1747 if not data: 1748 return None 1749 v = self.get_vars() 1750 if self.todo_id: 1751 v.update(state.get_todo_by_id(self.todo_id).get_vars()) 1752 if isinstance(data, list): 1753 if join: 1754 return expand_jinja(" ".join(data), v) 1755 else: 1756 res = [] 1757 for rf in data: 1758 res.append(expand_jinja(rf, v)) 1759 return res 1760 else: 1761 return expand_jinja(data, v) 1762 1763 1764def abbreviate_homedir(line): 1765 if is_windows(): 1766 if 'HOME' in os.environ: 1767 return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1%HOME%", line) 1768 elif 'USERPROFILE' in os.environ: 1769 return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1%USERPROFILE%", line) 1770 else: 1771 return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1~", line) 1772 1773 1774class Command(SecretYamlObject): 1775 yaml_tag = u'!Command' 1776 hidden_fields = ['todo_id'] 1777 def __init__(self, cmd, cwd=None, stdout=None, logfile=None, tee=None, live=None, comment=None, vars=None, 1778 todo_id=None, should_fail=None, redirect=None, redirect_append=None, shell=None): 1779 self.cmd = cmd 1780 self.cwd = cwd 1781 self.comment = comment 1782 self.logfile = logfile 1783 self.vars = vars 1784 self.tee = tee 1785 self.live = live 1786 self.stdout = stdout 1787 self.should_fail = should_fail 1788 self.shell = shell 1789 self.todo_id = todo_id 1790 self.redirect_append = redirect_append 1791 self.redirect = redirect 1792 if tee and stdout: 1793 self.stdout = None 1794 print("Command %s specifies 'tee' and 'stdout', using only 'tee'" % self.cmd) 1795 if live and stdout: 1796 self.stdout = None 1797 print("Command %s specifies 'live' and 'stdout', using only 'live'" % self.cmd) 1798 if live and tee: 1799 self.tee = None 1800 print("Command %s specifies 'tee' and 'live', using only 'live'" % self.cmd) 1801 if redirect and (tee or stdout or live): 1802 self.tee = self.stdout = self.live = None 1803 print("Command %s specifies 'redirect' and other out options at the same time. Using redirect only" % self.cmd) 1804 1805 @classmethod 1806 def from_yaml(cls, loader, node): 1807 fields = loader.construct_mapping(node, deep = True) 1808 return Command(**fields) 1809 1810 def get_comment(self): 1811 return self.jinjaify(self.comment) 1812 1813 def get_redirect(self): 1814 return self.jinjaify(self.redirect) 1815 1816 def get_cmd(self): 1817 return self.jinjaify(self.cmd, join=True) 1818 1819 def get_vars(self): 1820 myvars = {} 1821 if self.vars: 1822 for k in self.vars: 1823 val = self.vars[k] 1824 if callable(val): 1825 myvars[k] = expand_jinja(val(), vars=myvars) 1826 else: 1827 myvars[k] = expand_jinja(val, vars=myvars) 1828 return myvars 1829 1830 def __str__(self): 1831 return self.get_cmd() 1832 1833 def jinjaify(self, data, join=False): 1834 v = self.get_vars() 1835 if self.todo_id: 1836 v.update(state.get_todo_by_id(self.todo_id).get_vars()) 1837 if isinstance(data, list): 1838 if join: 1839 return expand_jinja(" ".join(data), v) 1840 else: 1841 res = [] 1842 for rf in data: 1843 res.append(expand_jinja(rf, v)) 1844 return res 1845 else: 1846 return expand_jinja(data, v) 1847 1848 def display_cmd(self): 1849 lines = [] 1850 pre = post = '' 1851 if self.comment: 1852 if is_windows(): 1853 lines.append("REM %s" % self.get_comment()) 1854 else: 1855 lines.append("# %s" % self.get_comment()) 1856 if self.cwd: 1857 lines.append("pushd %s" % self.cwd) 1858 redir = "" if self.redirect is None else " %s %s" % (">" if self.redirect_append is None else ">>" , self.get_redirect()) 1859 line = "%s%s" % (expand_multiline(self.get_cmd(), indent=2), redir) 1860 # Print ~ or %HOME% rather than the full expanded homedir path 1861 line = abbreviate_homedir(line) 1862 lines.append(line) 1863 if self.cwd: 1864 lines.append("popd") 1865 return lines 1866 1867class UserInput(SecretYamlObject): 1868 yaml_tag = u'!UserInput' 1869 1870 def __init__(self, name, prompt, type=None): 1871 self.type = type 1872 self.prompt = prompt 1873 self.name = name 1874 1875 @classmethod 1876 def from_yaml(cls, loader, node): 1877 fields = loader.construct_mapping(node, deep = True) 1878 return UserInput(**fields) 1879 1880 def run(self, dict=None): 1881 correct = False 1882 while not correct: 1883 try: 1884 result = str(input("%s : " % self.prompt)) 1885 if self.type and self.type == 'int': 1886 result = int(result) 1887 correct = True 1888 except Exception as e: 1889 print("Incorrect input: %s, try again" % e) 1890 continue 1891 if dict: 1892 dict[self.name] = result 1893 return result 1894 1895 1896def create_ical(todo): 1897 if ask_yes_no("Do you want to add a Calendar reminder for the close vote time?"): 1898 c = Calendar() 1899 e = Event() 1900 e.name = "Lucene %s vote ends" % state.release_version 1901 e.begin = vote_close_72h_date() 1902 e.description = "Remember to sum up votes and continue release :)" 1903 c.events.add(e) 1904 ics_file = os.path.join(state.get_rc_folder(), 'vote_end.ics') 1905 with open(ics_file, 'w') as my_file: 1906 my_file.writelines(c) 1907 open_file(ics_file) 1908 return True 1909 1910 1911today = datetime.utcnow().date() 1912sundays = {(today + timedelta(days=x)): 'Sunday' for x in range(10) if (today + timedelta(days=x)).weekday() == 6} 1913y = datetime.utcnow().year 1914years = [y, y+1] 1915non_working = holidays.CA(years=years) + holidays.US(years=years) + holidays.England(years=years) \ 1916 + holidays.DE(years=years) + holidays.NO(years=years) + holidays.IND(years=years) + holidays.RU(years=years) 1917 1918 1919def vote_close_72h_date(): 1920 # Voting open at least 72 hours according to ASF policy 1921 return datetime.utcnow() + timedelta(hours=73) 1922 1923 1924def vote_close_72h_holidays(): 1925 days = 0 1926 day_offset = -1 1927 holidays = [] 1928 # Warn RM about major holidays coming up that should perhaps extend the voting deadline 1929 # Warning will be given for Sunday or a public holiday observed by 3 or more [CA, US, EN, DE, NO, IND, RU] 1930 while days < 3: 1931 day_offset += 1 1932 d = today + timedelta(days=day_offset) 1933 if not (d in sundays or (d in non_working and len(non_working[d]) >= 2)): 1934 days += 1 1935 else: 1936 if d in sundays: 1937 holidays.append("%s (Sunday)" % d) 1938 else: 1939 holidays.append("%s (%s)" % (d, non_working[d])) 1940 return holidays if len(holidays) > 0 else None 1941 1942 1943def prepare_announce_lucene(todo): 1944 if not os.path.exists(lucene_news_file): 1945 lucene_text = expand_jinja("(( template=announce_lucene ))") 1946 with open(lucene_news_file, 'w') as fp: 1947 fp.write(lucene_text) 1948 # print("Wrote Lucene announce draft to %s" % lucene_news_file) 1949 else: 1950 print("Draft already exist, not re-generating") 1951 return True 1952 1953 1954def check_artifacts_available(todo): 1955 try: 1956 cdnUrl = expand_jinja("https://dlcdn.apache.org/lucene/java/{{ release_version }}/lucene-{{ release_version }}-src.tgz.asc") 1957 load(cdnUrl) 1958 print("Found %s" % cdnUrl) 1959 except Exception as e: 1960 print("Could not fetch %s (%s)" % (cdnUrl, e)) 1961 return False 1962 1963 try: 1964 mavenUrl = expand_jinja("https://repo1.maven.org/maven2/org/apache/lucene/lucene-core/{{ release_version }}/lucene-core-{{ release_version }}.pom.asc") 1965 load(mavenUrl) 1966 print("Found %s" % mavenUrl) 1967 except Exception as e: 1968 print("Could not fetch %s (%s)" % (mavenUrl, e)) 1969 return False 1970 1971 return True 1972 1973def set_java_home(version): 1974 os.environ['JAVA_HOME'] = state.get_java_home_for_version(version) 1975 os.environ['JAVACMD'] = state.get_java_cmd_for_version(version) 1976 1977 1978def load_lines(file, from_line=0): 1979 if os.path.exists(file): 1980 with open(file, 'r') as fp: 1981 return fp.readlines()[from_line:] 1982 else: 1983 return ["<Please paste the announcement text here>\n"] 1984 1985 1986if __name__ == '__main__': 1987 try: 1988 main() 1989 except KeyboardInterrupt: 1990 print('Keyboard interrupt...exiting') 1991