xref: /Lucene/dev-tools/scripts/releaseWizard.py (revision 8c48475c4d8eb3296cc2da7adbad4d8634b03116)
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