You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ho...@apache.org on 2021/04/14 20:34:23 UTC

[solr-operator] branch main updated: Create a Release Wizard for the Solr Operator (#249)

This is an automated email from the ASF dual-hosted git repository.

houston pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 12b6382  Create a Release Wizard for the Solr Operator (#249)
12b6382 is described below

commit 12b6382649146e2201ace1b7b4b1c3fcf2130bff
Author: Houston Putman <ho...@apache.org>
AuthorDate: Wed Apr 14 16:34:14 2021 -0400

    Create a Release Wizard for the Solr Operator (#249)
---
 .gitignore                                 |    1 +
 docs/release-instructions.md               |   42 +-
 hack/install_dependencies.sh               |    2 +-
 hack/release/artifacts/build_helm.sh       |   11 +-
 hack/release/artifacts/create_artifacts.sh |   13 +-
 hack/release/smoke_test/verify_all.sh      |   17 +-
 hack/release/version/change_suffix.sh      |    5 +-
 hack/release/version/propagate_version.sh  |    5 +-
 hack/release/version/update_version.sh     |    5 +-
 hack/release/wizard/poll-mirrors.py        |  165 +++
 hack/release/wizard/releaseWizard.py       | 1975 ++++++++++++++++++++++++++++
 hack/release/wizard/releaseWizard.yaml     | 1440 ++++++++++++++++++++
 hack/release/wizard/requirements.txt       |    7 +
 hack/release/wizard/scriptutil.py          |  190 +++
 14 files changed, 3824 insertions(+), 54 deletions(-)

diff --git a/.gitignore b/.gitignore
index d8162d7..10bbcfc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ generated-check
 
 # Python for the release wizard
 venv
+__pycache__
diff --git a/docs/release-instructions.md b/docs/release-instructions.md
index 5cfeaec..88b9996 100644
--- a/docs/release-instructions.md
+++ b/docs/release-instructions.md
@@ -3,10 +3,7 @@
 This page details the steps for releasing new versions of the Solr Operator.
 
 - [Versioning](#versioning)
-  - [Backwards Compatibility](#backwards-compatibility)
-- [Create the Upgrade Commit](#create-the-upgrade-commit)
-- [Create a release PR and merge into `master`](#create-a-release-pr-and-merge-into-master)
-- [Tag and publish the release](#tag-and-publish-the-release)
+- [Use the Release Wizard](#release-wizard)
  
 ### Versioning
 
@@ -18,40 +15,13 @@ For example `v0.2.5` or `v1.3.4`.
 Certain systems except versions that do not start wth `v`, such as Helm.
 However the tooling has been created to automatically make these changes when necessary, so always include the prefixed `v` when following these instructions.
 
-#### Backwards Compatibility
+### Release Wizard
 
-All patch versions of the same major & minor version should be backwards compatabile.
-Non-backwards compatible changes will be allowed while the Solr Operator is still in a beta state.
- 
-### Create the upgrade commit
-
-The last commit of a release version of the Solr Operator should be made via the following command.
+Run the release wizard from the root of the repo on the branch that you want to make the release from.
 
 ```bash
-$ VERSION=<version> make release
+./hack/release/wizard/releaseWizard.py
 ```
 
-This will do the following steps:
-
-1. Set the variables of the Helm chart to be the new version.
-1. Build the CRDs and copy them into the Helm chart.
-1. Package up the helm charts and index them in `docs/charts/index.yaml`.
-1. Create all artifacts that should be included in the Github Release, and place them in the `/release-artifacts` directory.
-1. Commits all necessary changes for the release.
-
-### Create a release PR and merge into `master`
-
-Now you need to merge the release commit into master.
-You can push it to your fork and create a PR against the `master` branch.
-If the Travis tests pass, "Squash and Merge" it into master.
-
-### Tag and publish the release
-
-In order to create a release, you can do it entirely through the Github UI.
-Go to the releases tab, and click "Draft a new Release".
-
-Follow the formatting of previous releases, showing the highlights of changes in that version nicluding links to relevant PRs.
-
-Before publishing, make sure to attach all of the artifacts from the `release-artifacts` directory that were made when running the `make release` command earlier in the guide.
-
-Once you publish the release, Travis should re-run and deploy the docker containers to docker hub.
\ No newline at end of file
+Make sure to install all necessary programs and follow all steps.
+If there is any confusion, it is best to reach out on slack or the mailing lists before continuing.
\ No newline at end of file
diff --git a/hack/install_dependencies.sh b/hack/install_dependencies.sh
index c7d73a0..b92e671 100755
--- a/hack/install_dependencies.sh
+++ b/hack/install_dependencies.sh
@@ -49,7 +49,7 @@ if ! (which controller-gen); then
 	go install "sigs.k8s.io/controller-tools/cmd/controller-gen@${controller_gen_version}"
   echo "Installed controller-gen at $(which controller-gen), version: $(controller-gen --version)"
 elif ! (controller-gen --version | grep "Version: ${controller_gen_version}"); then
-	rm "$(shell which controller-gen)"
+	rm "$(which controller-gen)"
 	go install "sigs.k8s.io/controller-tools/cmd/controller-gen@${controller_gen_version}"
   echo "Installed controller-gen at $(which controller-gen), version: $(controller-gen --version)"
 else
diff --git a/hack/release/artifacts/build_helm.sh b/hack/release/artifacts/build_helm.sh
index bba321d..b49283a 100755
--- a/hack/release/artifacts/build_helm.sh
+++ b/hack/release/artifacts/build_helm.sh
@@ -75,21 +75,22 @@ helm dependency build helm/solr-operator
 
 SIGNING_INFO=()
 CREATED_SECURE_RING=false
+SECURE_RING_FILE=~/.gnupg/secring.gpg
 if [[ -n "${APACHE_ID:-}" ]]; then
   # First generate the temporary secret key ring
-  if [[ ! -f "/.gnupg/secring.gpg" ]]; then
-    gpg --export-secret-keys >~/.gnupg/secring.gpg
+  if [[ ! -f "${SECURE_RING_FILE}" ]]; then
+    gpg --export-secret-keys >"${SECURE_RING_FILE}"
     CREATED_SECURE_RING=true
   fi
 
-  SIGNING_INFO=(--sign --key "${APACHE_ID}@apache.org" --keyring ~/.gnupg/secring.gpg)
+  SIGNING_INFO=(--sign --key "${APACHE_ID}@apache.org" --keyring "${SECURE_RING_FILE}")
 fi
 
 helm package -u helm/* --app-version "${VERSION}" --version "${VERSION#v}" -d "${HELM_RELEASE_DIR}" "${SIGNING_INFO[@]}"
 
-if [[ ${CREATED_SECURE_RING} ]]; then
+if [[ "${CREATED_SECURE_RING}" = true ]]; then
   # Remove the temporary secret key ring
-  rm ~/.gnupg/secring.gpg
+  rm "${SECURE_RING_FILE}"
 fi
 
 helm repo index "${HELM_RELEASE_DIR}"
diff --git a/hack/release/artifacts/create_artifacts.sh b/hack/release/artifacts/create_artifacts.sh
index aef96fd..ba82907 100755
--- a/hack/release/artifacts/create_artifacts.sh
+++ b/hack/release/artifacts/create_artifacts.sh
@@ -84,11 +84,11 @@ echo "Setting up Solr Operator ${VERSION} release artifacts at '${ARTIFACTS_DIR}
 (
   cd "${ARTIFACTS_DIR}"
 
-  for artifact_directory in $(find * -type d); do
+  for artifact_directory in $(find '*' -type d); do
     (
       cd "${artifact_directory}"
 
-      for artifact in $(find * -type f ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
+      for artifact in $(find '*' -type f -maxdepth 1 ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
         if [ ! -f "${artifact}.asc" ]; then
           gpg "${GPG_USER[@]}" -ab "${artifact}"
         fi
@@ -98,4 +98,13 @@ echo "Setting up Solr Operator ${VERSION} release artifacts at '${ARTIFACTS_DIR}
       done
     )
   done
+
+  for artifact in $(find '*' -type f -maxdepth 1 ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
+    if [ ! -f "${artifact}.asc" ]; then
+      gpg "${GPG_USER[@]}" -ab "${artifact}"
+    fi
+    if [ ! -f "${artifact}.sha512" ]; then
+      sha512sum -b "${artifact}" > "${artifact}.sha512"
+    fi
+  done
 )
diff --git a/hack/release/smoke_test/verify_all.sh b/hack/release/smoke_test/verify_all.sh
index 7d85fde..edcdbef 100755
--- a/hack/release/smoke_test/verify_all.sh
+++ b/hack/release/smoke_test/verify_all.sh
@@ -68,7 +68,7 @@ if ! (echo "${LOCATION}" | grep -E "http://"); then
 fi
 
 echo "Import Solr Keys"
-curl -sL0 "https://dist.apache.org/repos/dist/release/solr/KEYS" | gpg2 --import --quiet
+curl -sL0 "https://dist.apache.org/repos/dist/release/solr/KEYS" | gpg --import --quiet
 
 # First generate the temporary public key ring
 gpg --export >~/.gnupg/pubring.gpg
@@ -91,14 +91,15 @@ echo "Download all artifacts and verify signatures"
     cp -r "${LOCATION}/"* .
   fi
 
-  for artifact_directory in $(find * -type d); do
+  for artifact_directory in $(find '*' -type d); do
     (
       cd "${artifact_directory}"
 
-      for artifact in $(find * -type f ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
+      for artifact in $(find '*' -type f -maxdepth 1 ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
+        echo "Veryifying: ${artifact_directory}/${artifact}"
         sha512sum -c "${artifact}.sha512" \
           || { echo "Invalid sha512 for ${artifact}. Aborting!"; exit 1; }
-        gpg2 --verify "${artifact}.asc" "${artifact}" \
+        gpg --verify "${artifact}.asc" "${artifact}" \
           || { echo "Invalid signature for ${artifact}. Aborting!"; exit 1; }
       done
 
@@ -108,6 +109,14 @@ echo "Download all artifacts and verify signatures"
       fi
     )
   done
+
+  for artifact in $(find '*' -type f -maxdepth 1 ! \( -name '*.asc' -o -name '*.sha512' -o -name '*.prov' \) ); do
+    echo "Veryifying: ${artifact}"
+    sha512sum -c "${artifact}.sha512" \
+      || { echo "Invalid sha512 for ${artifact}. Aborting!"; exit 1; }
+    gpg --verify "${artifact}.asc" "${artifact}" \
+      || { echo "Invalid signature for ${artifact}. Aborting!"; exit 1; }
+  done
 )
 
 # Delete temporary source download directory
diff --git a/hack/release/version/change_suffix.sh b/hack/release/version/change_suffix.sh
index eccd5bf..2601d5f 100755
--- a/hack/release/version/change_suffix.sh
+++ b/hack/release/version/change_suffix.sh
@@ -62,5 +62,6 @@ fi
 echo "Updating the version suffix for the project to: ${VERSION_SUFFIX}"
 
 # Version file
-awk -i inplace '$1 == "VersionSuffix"{$4 = "\"'"${VERSION_SUFFIX}"'\""} 1' version/version.go && \
-  go fmt version/version.go
+awk '$1 == "VersionSuffix"{$4 = "\"'"${VERSION_SUFFIX}"'\""} 1' version/version.go > version/version.tmp.go
+go fmt version/version.tmp.go
+mv version/version.tmp.go version/version.go
diff --git a/hack/release/version/propagate_version.sh b/hack/release/version/propagate_version.sh
index 01fe035..28a9944 100755
--- a/hack/release/version/propagate_version.sh
+++ b/hack/release/version/propagate_version.sh
@@ -56,8 +56,9 @@ fi
 echo "Updating the version throughout the repo to: ${VERSION}"
 
 # Update default solr-operator version and the helm chart versions.
-awk -i inplace '$1 == "repository:" { tag = ($2 == "apache/solr-operator") }
-tag && $1 == "tag:"{$1 = "  " $1; $2 = "'"${VERSION}"'"} 1' helm/solr-operator/values.yaml
+awk '$1 == "repository:" { tag = ($2 == "apache/solr-operator") }
+tag && $1 == "tag:"{$1 = "  " $1; $2 = "'"${VERSION}"'"} 1' helm/solr-operator/values.yaml > helm/solr-operator/values.yaml.tmp
+mv helm/solr-operator/values.yaml.tmp helm/solr-operator/values.yaml
 
 # Update Helm Chart.yaml
 IS_PRE_RELEASE="false"
diff --git a/hack/release/version/update_version.sh b/hack/release/version/update_version.sh
index 68d83db..a110c59 100755
--- a/hack/release/version/update_version.sh
+++ b/hack/release/version/update_version.sh
@@ -56,5 +56,6 @@ fi
 echo "Updating the latest version throughout the repo to: ${VERSION}"
 
 # Version file
-awk -i inplace '$1 == "Version"{$4 = "\"'"${VERSION}"'\""} 1' version/version.go && \
-  go fmt version/version.go
+awk '$1 == "Version"{$4 = "\"'"${VERSION}"'\""} 1' version/version.go > version/version.tmp.go
+go fmt version/version.tmp.go
+mv version/version.tmp.go version/version.go
diff --git a/hack/release/wizard/poll-mirrors.py b/hack/release/wizard/poll-mirrors.py
new file mode 100755
index 0000000..606217f
--- /dev/null
+++ b/hack/release/wizard/poll-mirrors.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+#
+# vim: softtabstop=2 shiftwidth=2 expandtab
+#
+# Python port of poll-mirrors.pl
+#
+# This script is designed to poll download sites after posting a release
+# and print out notice as each becomes available.  The RM can use this
+# script to delay the release announcement until the release can be
+# downloaded.
+#
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import argparse
+import datetime
+import ftplib
+import re
+import sys
+import time
+
+from urllib.parse import urlparse
+from multiprocessing import Pool
+import http.client as http
+
+
+def p(s):
+  sys.stdout.write(s)
+  sys.stdout.flush()
+
+
+def mirror_contains_file(url):
+  url = urlparse(url)
+
+  if url.scheme == 'https':
+    return https_file_exists(url)
+  elif url.scheme == 'http':
+    return http_file_exists(url)
+  elif url.scheme == 'ftp':
+    return ftp_file_exists(url)
+
+
+def http_file_exists(url):
+  exists = False
+
+  try:
+    conn = http.HTTPConnection(url.netloc)
+    conn.request('HEAD', url.path)
+    response = conn.getresponse()
+
+    exists = response.status == 200
+  except:
+    pass
+
+  return exists
+
+def https_file_exists(url):
+  exists = False
+
+  try:
+    conn = http.HTTPSConnection(url.netloc)
+    conn.request('HEAD', url.path)
+    response = conn.getresponse()
+    exists = response.status == 200
+  except:
+    pass
+
+  return exists
+
+def ftp_file_exists(url):
+  listing = []
+  try:
+    conn = ftplib.FTP(url.netloc)
+    conn.login()
+    listing = conn.nlst(url.path)
+    conn.quit()
+  except Exception as e:
+    pass
+
+  return len(listing) > 0
+
+
+def check_mirror(url):
+  if mirror_contains_file(url):
+    p('.')
+    return None
+  else:
+    p('\nFAIL: ' + url + '\n' if args.details else 'X')
+    return url
+
+
+desc = 'Periodically checks that all Lucene/Solr mirrors contain either a copy of a release or a specified path'
+parser = argparse.ArgumentParser(description=desc)
+parser.add_argument('-version', '-v', help='Solr Operator version to check')
+parser.add_argument('-path', '-p', help='instead of a versioned release, check for some/explicit/path')
+parser.add_argument('-interval', '-i', help='seconds to wait before re-querying mirrors', type=int, default=300)
+parser.add_argument('-details', '-d', help='print missing mirror URLs', action='store_true', default=False)
+parser.add_argument('-once', '-o', help='run only once', action='store_true', default=False)
+args = parser.parse_args()
+
+if (args.version is None and args.path is None) \
+    or (args.version is not None and args.path is not None):
+  p('You must specify either -version or -path but not both!\n')
+  sys.exit(1)
+
+try:
+  conn = http.HTTPConnection('www.apache.org')
+  conn.request('GET', '/mirrors/')
+  response = conn.getresponse()
+  html = response.read()
+except Exception as e:
+  p('Unable to fetch the Apache mirrors list!\n')
+  sys.exit(1)
+
+mirror_path = args.path if args.path is not None else 'solr/solr-operator/{}/solr-operator-{}.tgz.sha512'.format(args.version, args.version)
+
+pending_mirrors = []
+for match in re.finditer('<TR>(.*?)</TR>', str(html), re.MULTILINE | re.IGNORECASE | re.DOTALL):
+  row = match.group(1)
+  if not '<TD>ok</TD>' in row:
+    # skip bad mirrors
+    continue
+
+  match = re.search('<A\s+HREF\s*=\s*"([^"]+)"\s*>', row, re.MULTILINE | re.IGNORECASE)
+  if match:
+    pending_mirrors.append(match.group(1) + mirror_path)
+
+total_mirrors = len(pending_mirrors)
+
+label = args.version if args.version is not None else args.path
+while True:
+  p('\n{:%Y-%m-%d %H:%M:%S}'.format(datetime.datetime.now()))
+  p('\nPolling {} Apache Mirrors'.format(len(pending_mirrors)))
+  p('...\n')
+
+  start = time.time()
+  with Pool(processes=5) as pool:
+    pending_mirrors = list(filter(lambda x: x is not None, pool.map(check_mirror, pending_mirrors)))
+  stop = time.time()
+  remaining = args.interval - (stop - start)
+
+  available_mirrors = total_mirrors - len(pending_mirrors)
+
+  p('\n{} is downloadable from {}/{} Apache Mirrors ({:.2f}%)\n'
+    .format(label, available_mirrors, total_mirrors, available_mirrors * 100 / total_mirrors))
+  if len(pending_mirrors) == 0 or args.once == True:
+    break
+
+  if remaining > 0:
+    p('Sleeping for {:d} seconds...\n'.format(int(remaining + 0.5)))
+    time.sleep(remaining)
+
diff --git a/hack/release/wizard/releaseWizard.py b/hack/release/wizard/releaseWizard.py
new file mode 100755
index 0000000..6fe29e0
--- /dev/null
+++ b/hack/release/wizard/releaseWizard.py
@@ -0,0 +1,1975 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This script is the Release Manager's best friend, ensuring all details of a release are handled correctly.
+# It will walk you through the steps of the release process, asking for decisions or input along the way.
+# CAUTION: You still need to use your head! Please read the HELP section in the main menu.
+#
+# Requirements:
+#   Install requirements with this command:
+#   pip3 install -r requirements.txt
+#
+# Usage:
+#   releaseWizard.py [-h] [--dry-run] [--root PATH]
+#
+#   optional arguments:
+#   -h, --help   show this help message and exit
+#   --dry-run    Do not execute any commands, but echo them instead. Display
+#   extra debug info
+#   --root PATH  Specify different root folder than ~/.solr-operator-releases
+
+import argparse
+import copy
+import fcntl
+import json
+import os
+import platform
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+import textwrap
+import time
+import urllib
+from collections import OrderedDict
+from datetime import datetime
+from datetime import timedelta
+
+try:
+    import holidays
+    import yaml
+    from ics import Calendar, Event
+    from jinja2 import Environment
+except:
+    print("You lack some of the module dependencies to run this script.")
+    print("Please run 'pip3 install -r requirements.txt' and try again.")
+    sys.exit(1)
+
+import scriptutil
+from consolemenu import ConsoleMenu
+from consolemenu.items import FunctionItem, SubmenuItem, ExitItem
+from consolemenu.screen import Screen
+from scriptutil import BranchType, Version, download, run
+
+# SolrOperator-to-GoLang version mapping
+go_versions = {"0.3": "1.16"}
+editor = None
+
+# Edit this to add other global jinja2 variables or filters
+def expand_jinja(text, vars=None):
+    global_vars = OrderedDict({
+        'script_version': state.script_version,
+        'release_version': state.release_version,
+        'release_version_underscore': state.release_version.replace('.', '_'),
+        'release_date': state.get_release_date(),
+        'config_path': state.config_path,
+        'rc_number': state.rc_number,
+        'script_branch': state.script_branch,
+        'release_folder': state.get_release_folder(),
+        'git_checkout_folder': state.get_git_checkout_folder(),
+        'git_website_folder': state.get_website_git_folder(),
+        'dist_url_base': 'https://dist.apache.org/repos/dist/dev/solr/solr-operator',
+        'dist_file_path': state.get_dist_folder(),
+        'rc_folder': state.get_rc_folder(),
+        'base_branch': state.get_base_branch_name(),
+        'release_branch': state.release_branch,
+        'stable_branch': state.get_stable_branch_name(),
+        'minor_branch': state.get_minor_branch_name(),
+        'release_type': state.release_type,
+        'is_feature_release': state.release_type in ['minor', 'major'],
+        'release_version_major': state.release_version_major,
+        'release_version_minor': state.release_version_minor,
+        'release_version_bugfix': state.release_version_bugfix,
+        'release_version_refguide': state.get_refguide_release() ,
+        'state': state,
+        'gpg_key': state.get_gpg_key(),
+        'epoch': unix_time_millis(datetime.utcnow()),
+        'get_next_version': state.get_next_version(),
+        'current_git_rev': state.get_current_git_rev(),
+        'keys_downloaded': keys_downloaded(),
+        'editor': get_editor(),
+        'rename_cmd': 'ren' if is_windows() else 'mv',
+        'vote_close_72h': vote_close_72h_date().strftime("%Y-%m-%d %H:00 UTC"),
+        'vote_close_72h_epoch': unix_time_millis(vote_close_72h_date()),
+        'vote_close_72h_holidays': vote_close_72h_holidays(),
+        'solr_operator_news_file': solr_operator_news_file,
+        'load_lines': load_lines,
+        'latest_version': state.get_latest_version(),
+        'latest_lts_version': state.get_latest_lts_version(),
+        'main_version': state.get_main_version(),
+        'mirrored_versions': state.get_mirrored_versions(),
+        'mirrored_versions_to_delete': state.get_mirrored_versions_to_delete(),
+        'home': os.path.expanduser("~")
+    })
+    global_vars.update(state.get_todo_states())
+    if vars:
+        global_vars.update(vars)
+
+    filled = replace_templates(text)
+
+    try:
+        env = Environment(lstrip_blocks=True, keep_trailing_newline=False, trim_blocks=True)
+        env.filters['path_join'] = lambda paths: os.path.join(*paths)
+        env.filters['expanduser'] = lambda path: os.path.expanduser(path)
+        env.filters['formatdate'] = lambda date: (datetime.strftime(date, "%-d %B %Y") if date else "<date>" )
+        template = env.from_string(str(filled), globals=global_vars)
+        filled = template.render()
+    except Exception as e:
+        print("Exception while rendering jinja template %s: %s" % (str(filled)[:10], e))
+    return filled
+
+
+def replace_templates(text):
+    tpl_lines = []
+    for line in text.splitlines():
+        if line.startswith("(( template="):
+            match = re.search(r"^\(\( template=(.+?) \)\)", line)
+            name = match.group(1)
+            tpl_lines.append(replace_templates(templates[name].strip()))
+        else:
+            tpl_lines.append(line)
+    return "\n".join(tpl_lines)
+
+def getScriptVersion():
+    return scriptutil.find_current_version()
+
+
+def get_editor():
+    global editor
+    if editor is None:
+      if 'EDITOR' in os.environ:
+          if os.environ['EDITOR'] in ['vi', 'vim', 'nano', 'pico', 'emacs']:
+              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'])
+          editor = os.environ['EDITOR']
+      elif is_windows():
+          editor = 'notepad.exe'
+      elif is_mac():
+          editor = 'open -a TextEdit'
+      else:
+          sys.exit("On Linux you have to set EDITOR variable to a command that will start an editor in its own window")
+    return editor
+
+
+def check_prerequisites(todo=None):
+    if sys.version_info < (3, 4):
+        sys.exit("Script requires Python v3.4 or later")
+    try:
+        gpg_ver = run("gpg --version").splitlines()[0]
+    except:
+        sys.exit("You will need gpg installed")
+    if not 'GPG_TTY' in os.environ:
+        print("WARNING: GPG_TTY environment variable is not set, GPG signing may not work correctly (try 'export GPG_TTY=$(tty)'")
+    try:
+        asciidoc_ver = run("asciidoctor -V").splitlines()[0]
+    except:
+        asciidoc_ver = ""
+        print("WARNING: In order to export asciidoc version to HTML, you will need asciidoctor installed")
+    try:
+        git_ver = run("git --version").splitlines()[0]
+    except:
+        sys.exit("You will need git installed")
+    if not 'EDITOR' in os.environ:
+        print("WARNING: Environment variable $EDITOR not set, using %s" % get_editor())
+
+    if todo:
+        print("%s\n%s\n%s\n" % (gpg_ver, asciidoc_ver, git_ver))
+    return True
+
+
+epoch = datetime.utcfromtimestamp(0)
+
+
+def unix_time_millis(dt):
+    return int((dt - epoch).total_seconds() * 1000.0)
+
+
+def bootstrap_todos(todo_list):
+    # Establish links from commands to to_do for finding todo vars
+    for tg in todo_list:
+        if dry_run:
+            print("Group %s" % tg.id)
+        for td in tg.get_todos():
+            if dry_run:
+                print("  Todo %s" % td.id)
+            cmds = td.commands
+            if cmds:
+                if dry_run:
+                    print("  Commands")
+                cmds.todo_id = td.id
+                for cmd in cmds.commands:
+                    if dry_run:
+                        print("    Command %s" % cmd.cmd)
+                    cmd.todo_id = td.id
+
+    print("Loaded TODO definitions from releaseWizard.yaml")
+    return todo_list
+
+
+def maybe_remove_rc_from_svn():
+    todo = state.get_todo_by_id('import_svn')
+    if todo and todo.is_done():
+        print("import_svn done")
+        Commands(state.get_git_checkout_folder(),
+                 """Looks like you uploaded artifacts for {{ build_rc.git_rev | default("<git_rev>", True) }} to svn which needs to be removed.""",
+                 [Command(
+                 """svn -m "Remove cancelled Solr Operator {{ release_version }} RC{{ rc_number }}" rm {{ dist_url }}""",
+                 logfile="svn_rm.log",
+                 tee=True,
+                 vars={
+                     'dist_folder': """solr-operator-{{ release_version }}-RC{{ rc_number }}-rev{{ build_rc.git_rev | default("<git_rev>", True) }}""",
+                     'dist_url': "{{ dist_url_base }}/{{ dist_folder }}"
+                 }
+             )],
+                 enable_execute=True, confirm_each_command=False).run()
+
+
+# To be able to hide fields when dumping Yaml
+class SecretYamlObject(yaml.YAMLObject):
+    hidden_fields = []
+    @classmethod
+    def to_yaml(cls,dumper,data):
+        print("Dumping object %s" % type(data))
+
+        new_data = copy.deepcopy(data)
+        for item in cls.hidden_fields:
+            if item in new_data.__dict__:
+                del new_data.__dict__[item]
+        for item in data.__dict__:
+            if item in new_data.__dict__ and new_data.__dict__[item] is None:
+                del new_data.__dict__[item]
+        return dumper.represent_yaml_object(cls.yaml_tag, new_data, cls,
+                                            flow_style=cls.yaml_flow_style)
+
+
+def str_presenter(dumper, data):
+    if len(data.split('\n')) > 1:  # check for multiline string
+        return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
+    return dumper.represent_scalar('tag:yaml.org,2002:str', data)
+
+
+class ReleaseState:
+    def __init__(self, config_path, release_version, script_version):
+        self.script_version = script_version
+        self.config_path = config_path
+        self.todo_groups = None
+        self.todos = None
+        self.latest_version = None
+        self.previous_rcs = {}
+        self.rc_number = 1
+        self.start_date = unix_time_millis(datetime.utcnow())
+        self.script_branch = run("git rev-parse --abbrev-ref HEAD").strip()
+        self.mirrored_versions = None
+        try:
+            self.script_branch_type = scriptutil.find_branch_type()
+        except:
+            print("WARNING: This script shold (ideally) run from the release branch, not a feature branch (%s)" % self.script_branch)
+            self.script_branch_type = 'feature'
+        self.set_release_version(release_version)
+
+    def set_release_version(self, version):
+        self.validate_release_version(self.script_branch_type, self.script_branch, version)
+        self.release_version = version
+        v = Version.parse(version)
+        self.release_version_major = v.major
+        self.release_version_minor = v.minor
+        self.release_version_bugfix = v.bugfix
+        self.release_branch = "release-%s.%s" % (v.major, v.minor)
+        if v.is_major_release():
+            self.release_type = 'major'
+        elif v.is_minor_release():
+            self.release_type = 'minor'
+        else:
+            self.release_type = 'bugfix'
+
+    def is_released(self):
+        return self.get_todo_by_id('announce_solr_operator').is_done()
+
+    def get_gpg_key(self):
+        gpg_task = self.get_todo_by_id('gpg')
+        if gpg_task.is_done():
+            return gpg_task.get_state()['gpg_key']
+        else:
+            return None
+
+    def get_release_date(self):
+        publish_task = self.get_todo_by_id('publish_docker_image')
+        if publish_task.is_done():
+            return unix_to_datetime(publish_task.get_state()['done_date'])
+        else:
+            return None
+
+    def get_release_date_iso(self):
+        release_date = self.get_release_date()
+        if release_date is None:
+            return "yyyy-mm-dd"
+        else:
+            return release_date.isoformat()[:10]
+
+    def get_latest_version(self):
+        if self.latest_version is None:
+            #TODO: Remove when first release is made
+            #versions = self.get_mirrored_versions()
+            #latest = versions[0]
+            versions = []
+            latest = "v0.2.8"
+            for ver in versions:
+                if Version.parse(ver).gt(Version.parse(latest)):
+                    latest = ver
+            self.latest_version = latest
+            self.save()
+        return state.latest_version
+
+    def get_mirrored_versions(self):
+        if state.mirrored_versions is None:
+            releases_str = load("https://projects.apache.org/json/foundation/releases.json", "utf-8")
+            releases = json.loads(releases_str)
+            state.mirrored_versions = []
+            if 'solr-operator' in releases.keys():
+                releases = releases['solr-operator']
+                state.mirrored_versions = [ r for r in list(map(lambda y: y[7:], filter(lambda x: x.startswith('solr-operator-'), list(releases.keys())))) ]
+        return state.mirrored_versions
+
+    def get_mirrored_versions_to_delete(self):
+        versions = self.get_mirrored_versions()
+        to_keep = versions
+        if state.release_type == 'major':
+          to_keep = [self.release_version, self.get_latest_version()]
+        if state.release_type == 'minor':
+          to_keep = [self.release_version, self.get_latest_lts_version()]
+        if state.release_type == 'bugfix':
+          if Version.parse(state.release_version).major == Version.parse(state.get_latest_version()).major:
+            to_keep = [self.release_version, self.get_latest_lts_version()]
+          elif Version.parse(state.release_version).major == Version.parse(state.get_latest_lts_version()).major:
+            to_keep = [self.get_latest_version(), self.release_version]
+          else:
+            raise Exception("Release version %s must have same major version as current minor or lts release")
+        return [ver for ver in versions if ver not in to_keep]
+
+    def get_main_version(self):
+        v = Version.parse(self.get_latest_version())
+        return "%s.%s.%s" % (v.major + 1, 0, 0)
+
+    def get_latest_lts_version(self):
+        return None
+        versions = self.get_mirrored_versions()
+        latest = self.get_latest_version()
+        lts_prefix = "%s." % (Version.parse(latest).major - 1)
+        lts_versions = list(filter(lambda x: x.startswith(lts_prefix), versions))
+        latest_lts = lts_versions[0]
+        for ver in lts_versions:
+            if Version.parse(ver).gt(Version.parse(latest_lts)):
+                latest_lts = ver
+        return latest_lts
+
+    def validate_release_version(self, branch_type, branch, release_version):
+        ver = Version.parse(release_version)
+        # print("release_version=%s, ver=%s" % (release_version, ver))
+        if branch_type == BranchType.release:
+            if not branch.startswith('branch_'):
+                sys.exit("Incompatible branch and branch_type")
+            if not ver.is_bugfix_release():
+                sys.exit("You can only release bugfix releases from an existing release branch")
+        elif branch_type == BranchType.stable:
+            if not branch.startswith('branch_') and branch.endswith('x'):
+                sys.exit("Incompatible branch and branch_type")
+            if not ver.is_minor_release():
+                sys.exit("You can only release minor releases from an existing stable branch")
+        elif branch_type == BranchType.unstable:
+            if not branch == 'main':
+                sys.exit("Incompatible branch and branch_type")
+            if not ver.is_major_release():
+                sys.exit("You can only release a new major version from main branch")
+        if not getScriptVersion() == release_version:
+            print("WARNING: Expected release version %s when on branch %s, but got %s" % (
+                getScriptVersion(), branch, release_version))
+
+    def get_base_branch_name(self):
+        v = Version.parse(self.release_version)
+        if v.is_major_release() :
+            return 'main'
+        elif v.is_minor_release():
+            return self.get_stable_branch_name()
+        elif v.major == Version.parse(self.get_latest_version()).major:
+            return self.get_minor_branch_name()
+        else:
+            return self.release_branch
+
+    def clear_rc(self):
+        if ask_yes_no("Are you sure? This will clear and restart RC%s" % self.rc_number):
+            maybe_remove_rc_from_svn()
+            dict = {}
+            for g in list(filter(lambda x: x.in_rc_loop(), self.todo_groups)):
+                for t in g.get_todos():
+                    t.clear()
+            print("Cleared RC TODO state")
+            try:
+                shutil.rmtree(self.get_rc_folder())
+                print("Cleared folder %s" % self.get_rc_folder())
+            except Exception as e:
+                print("WARN: Failed to clear %s, please do it manually with higher privileges" % self.get_rc_folder())
+            self.save()
+
+    def new_rc(self):
+        if ask_yes_no("Are you sure? This will abort current RC"):
+            maybe_remove_rc_from_svn()
+            dict = {}
+            for g in list(filter(lambda x: x.in_rc_loop(), self.todo_groups)):
+                for t in g.get_todos():
+                    if t.applies(self.release_type):
+                        dict[t.id] = copy.deepcopy(t.state)
+                        t.clear()
+            self.previous_rcs["RC%d" % self.rc_number] = dict
+            self.rc_number += 1
+            self.save()
+
+    def to_dict(self):
+        tmp_todos = {}
+        for todo_id in self.todos:
+            t = self.todos[todo_id]
+            tmp_todos[todo_id] = copy.deepcopy(t.state)
+        dict = {
+            'script_version': self.script_version,
+            'release_version': self.release_version,
+            'start_date': self.start_date,
+            'rc_number': self.rc_number,
+            'script_branch': self.script_branch,
+            'todos': tmp_todos,
+            'previous_rcs': self.previous_rcs
+        }
+        if self.latest_version:
+            dict['latest_version'] = self.latest_version
+        return dict
+
+    def restore_from_dict(self, dict):
+        self.script_version = dict['script_version']
+        assert dict['release_version'] == self.release_version
+        if 'start_date' in dict:
+            self.start_date = dict['start_date']
+        if 'latest_version' in dict:
+            self.latest_version = dict['latest_version']
+        else:
+            self.latest_version = None
+        self.rc_number = dict['rc_number']
+        self.script_branch = dict['script_branch']
+        self.previous_rcs = copy.deepcopy(dict['previous_rcs'])
+        for todo_id in dict['todos']:
+            if todo_id in self.todos:
+                t = self.todos[todo_id]
+                for k in dict['todos'][todo_id]:
+                    t.state[k] = dict['todos'][todo_id][k]
+            else:
+                print("Warning: Could not restore state for %s, Todo definition not found" % todo_id)
+
+    def load(self):
+        if os.path.exists(os.path.join(self.config_path, self.release_version, 'state.yaml')):
+            state_file = os.path.join(self.config_path, self.release_version, 'state.yaml')
+            with open(state_file, 'r') as fp:
+                try:
+                    dict = yaml.load(fp, Loader=yaml.Loader)
+                    self.restore_from_dict(dict)
+                    print("Loaded state from %s" % state_file)
+                except Exception as e:
+                    print("Failed to load state from %s: %s" % (state_file, e))
+
+    def save(self):
+        print("Saving")
+        if not os.path.exists(os.path.join(self.config_path, self.release_version)):
+            print("Creating folder %s" % os.path.join(self.config_path, self.release_version))
+            os.makedirs(os.path.join(self.config_path, self.release_version))
+
+        with open(os.path.join(self.config_path, self.release_version, 'state.yaml'), 'w') as fp:
+            yaml.dump(self.to_dict(), fp, sort_keys=False, default_flow_style=False)
+
+    def clear(self):
+        self.previous_rcs = {}
+        self.rc_number = 1
+        for t_id in self.todos:
+            t = self.todos[t_id]
+            t.state = {}
+        self.save()
+
+    def get_rc_number(self):
+        return self.rc_number
+
+    def get_current_git_rev(self):
+        try:
+            return run("git rev-parse HEAD", cwd=self.get_git_checkout_folder()).strip()
+        except:
+            return "<git-rev>"
+
+    def get_group_by_id(self, id):
+        lst = list(filter(lambda x: x.id == id, self.todo_groups))
+        if len(lst) == 1:
+            return lst[0]
+        else:
+            return None
+
+    def get_todo_by_id(self, id):
+        lst = list(filter(lambda x: x.id == id, self.todos.values()))
+        if len(lst) == 1:
+            return lst[0]
+        else:
+            return None
+
+    def get_todo_state_by_id(self, id):
+        lst = list(filter(lambda x: x.id == id, self.todos.values()))
+        if len(lst) == 1:
+            return lst[0].state
+        else:
+            return {}
+
+    def get_release_folder(self):
+        folder = os.path.join(self.config_path, self.release_version)
+        if not os.path.exists(folder):
+            print("Creating folder %s" % folder)
+            os.makedirs(folder)
+        return folder
+
+    def get_rc_folder(self):
+        folder = os.path.join(self.get_release_folder(), "RC%d" % self.rc_number)
+        if not os.path.exists(folder):
+            print("Creating folder %s" % folder)
+            os.makedirs(folder)
+        return folder
+
+    def get_dist_folder(self):
+        folder = os.path.join(self.get_rc_folder(), "release-artifacts")
+        return folder
+
+    def get_git_checkout_folder(self):
+        folder = os.path.join(self.get_release_folder(), "solr-operator")
+        return folder
+
+    def get_website_git_folder(self):
+        folder = os.path.join(self.get_release_folder(), "solr-site")
+        return folder
+
+    def get_minor_branch_name(self):
+        latest = state.get_latest_version()
+        if latest is not None:
+          v = Version.parse(latest)
+          return "release-%s.%s" % (v.major, v.minor)
+        else:
+            raise Exception("Cannot find latest version")
+
+    def get_stable_branch_name(self):
+        if self.release_version_major == 0:
+            #TODO: Change this when main reflects 1.0.0-prerelease
+            #v = Version.parse(self.release_version)
+            return "main"
+        elif self.release_type == 'major':
+            v = Version.parse(self.get_main_version())
+        else:
+            v = Version.parse(self.get_latest_version())
+        return "release-%s" % v.major
+
+    def get_next_version(self):
+        if self.release_type == 'major':
+            return "v%s.0.0" % (self.release_version_major + 1)
+        if self.release_type == 'minor':
+            return "v%s.%s.0" % (self.release_version_major, self.release_version_minor + 1)
+        if self.release_type == 'bugfix':
+            return "v%s.%s.%s" % (self.release_version_major, self.release_version_minor, self.release_version_bugfix + 1)
+
+    def get_refguide_release(self):
+        return "%s_%s" % (self.release_version_major, self.release_version_minor)
+
+    def get_todo_states(self):
+        states = {}
+        if self.todos:
+            for todo_id in self.todos:
+                t = self.todos[todo_id]
+                states[todo_id] = copy.deepcopy(t.state)
+        return states
+
+    def init_todos(self, groups):
+        self.todo_groups = groups
+        self.todos = {}
+        for g in self.todo_groups:
+            for t in g.get_todos():
+                self.todos[t.id] = t
+
+
+class TodoGroup(SecretYamlObject):
+    yaml_tag = u'!TodoGroup'
+    hidden_fields = []
+    def __init__(self, id, title, description, todos, is_in_rc_loop=None, depends=None):
+        self.id = id
+        self.title = title
+        self.description = description
+        self.depends = depends
+        self.is_in_rc_loop = is_in_rc_loop
+        self.todos = todos
+
+    @classmethod
+    def from_yaml(cls, loader, node):
+        fields = loader.construct_mapping(node, deep = True)
+        return TodoGroup(**fields)
+
+    def num_done(self):
+        return sum(1 for x in self.todos if x.is_done() > 0)
+
+    def num_applies(self):
+        count = sum(1 for x in self.todos if x.applies(state.release_type))
+        # print("num_applies=%s" % count)
+        return count
+
+    def is_done(self):
+        # print("Done=%s, applies=%s" % (self.num_done(), self.num_applies()))
+        return self.num_done() >= self.num_applies()
+
+    def get_title(self):
+        # print("get_title: %s" % self.is_done())
+        prefix = ""
+        if self.is_done():
+            prefix = "✓ "
+        return "%s%s (%d/%d)" % (prefix, self.title, self.num_done(), self.num_applies())
+
+    def get_submenu(self):
+        menu = UpdatableConsoleMenu(title=self.title, subtitle=self.get_subtitle, prologue_text=self.get_description(),
+                           screen=MyScreen())
+        menu.exit_item = CustomExitItem("Return")
+        for todo in self.get_todos():
+            if todo.applies(state.release_type):
+                menu.append_item(todo.get_menu_item())
+        return menu
+
+    def get_menu_item(self):
+        item = UpdatableSubmenuItem(self.get_title, self.get_submenu())
+        return item
+
+    def get_todos(self):
+        return self.todos
+
+    def in_rc_loop(self):
+        return self.is_in_rc_loop is True
+
+    def get_description(self):
+        desc = self.description
+        if desc:
+            return expand_jinja(desc)
+        else:
+            return None
+
+    def get_subtitle(self):
+        if self.depends:
+            ret_str = ""
+            for dep in ensure_list(self.depends):
+                g = state.get_group_by_id(dep)
+                if not g:
+                    g = state.get_todo_by_id(dep)
+                if g and not g.is_done():
+                    ret_str += "NOTE: Please first complete '%s'\n" % g.title
+                    return ret_str.strip()
+        return None
+
+
+class Todo(SecretYamlObject):
+    yaml_tag = u'!Todo'
+    hidden_fields = ['state']
+    def __init__(self, id, title, description=None, post_description=None, done=None, types=None, links=None,
+                 commands=None, user_input=None, depends=None, vars=None, asciidoc=None, persist_vars=None,
+                 function=None):
+        self.id = id
+        self.title = title
+        self.description = description
+        self.asciidoc = asciidoc
+        self.types = types
+        self.depends = depends
+        self.vars = vars
+        self.persist_vars = persist_vars
+        self.function = function
+        self.user_input = user_input
+        self.commands = commands
+        self.post_description = post_description
+        self.links = links
+        self.state = {}
+
+        self.set_done(done)
+        if self.types:
+            self.types = ensure_list(self.types)
+            for t in self.types:
+                if not t in ['minor', 'major', 'bugfix']:
+                    sys.exit("Wrong Todo config for '%s'. Type needs to be either 'minor', 'major' or 'bugfix'" % self.id)
+        if commands:
+            self.commands.todo_id = self.id
+            for c in commands.commands:
+                c.todo_id = self.id
+
+    @classmethod
+    def from_yaml(cls, loader, node):
+        fields = loader.construct_mapping(node, deep = True)
+        return Todo(**fields)
+
+    def get_vars(self):
+        myvars = {}
+        if self.vars:
+            for k in self.vars:
+                val = self.vars[k]
+                if callable(val):
+                    myvars[k] = expand_jinja(val(), vars=myvars)
+                else:
+                    myvars[k] = expand_jinja(val, vars=myvars)
+        return myvars
+
+    def set_done(self, is_done):
+        if is_done:
+            self.state['done_date'] = unix_time_millis(datetime.utcnow())
+            if self.persist_vars:
+                for k in self.persist_vars:
+                    self.state[k] = self.get_vars()[k]
+        else:
+            self.state.clear()
+        self.state['done'] = is_done
+
+    def applies(self, type):
+        if self.types:
+            return type in self.types
+        return True
+
+    def is_done(self):
+        return 'done' in self.state and self.state['done'] is True
+
+    def get_title(self):
+        prefix = ""
+        if self.is_done():
+            prefix = "✓ "
+        return expand_jinja("%s%s" % (prefix, self.title), self.get_vars_and_state())
+
+    def display_and_confirm(self):
+        try:
+            if self.depends:
+                ret_str = ""
+                for dep in ensure_list(self.depends):
+                    g = state.get_group_by_id(dep)
+                    if not g:
+                        g = state.get_todo_by_id(dep)
+                    if not g.is_done():
+                        print("This step depends on '%s'. Please complete that first\n" % g.title)
+                        return
+            desc = self.get_description()
+            if desc:
+                print("%s" % desc)
+            try:
+                if self.function and not self.is_done():
+                    if not eval(self.function)(self):
+                        return
+            except Exception as e:
+                print("Function call to %s for todo %s failed: %s" % (self.function, self.id, e))
+                raise e
+            if self.user_input and not self.is_done():
+                ui_list = ensure_list(self.user_input)
+                for ui in ui_list:
+                    ui.run(self.state)
+                print()
+            if self.links:
+                print("\nLinks:\n")
+                for link in self.links:
+                    print("- %s" % expand_jinja(link, self.get_vars_and_state()))
+                print()
+            cmds = self.get_commands()
+            if cmds:
+                if not self.is_done():
+                    if not cmds.logs_prefix:
+                        cmds.logs_prefix = self.id
+                    cmds.run()
+                else:
+                    print("This step is already completed. You have to first set it to 'not completed' in order to execute commands again.")
+                print()
+            if self.post_description:
+                print("%s" % self.get_post_description())
+            todostate = self.get_state()
+            if self.is_done() and len(todostate) > 2:
+                print("Variables registered\n")
+                for k in todostate:
+                    if k == 'done' or k == 'done_date':
+                        continue
+                    print("* %s = %s" % (k, todostate[k]))
+                print()
+            completed = ask_yes_no("Mark task '%s' as completed?" % self.get_title())
+            self.set_done(completed)
+            state.save()
+        except Exception as e:
+            print("ERROR while executing todo %s (%s)" % (self.get_title(), e))
+
+    def get_menu_item(self):
+        return UpdatableFunctionItem(self.get_title, self.display_and_confirm)
+
+    def clone(self):
+        clone = Todo(self.id, self.title, description=self.description)
+        clone.state = copy.deepcopy(self.state)
+        return clone
+
+    def clear(self):
+        self.state.clear()
+        self.set_done(False)
+
+    def get_state(self):
+        return self.state
+
+    def get_description(self):
+        desc = self.description
+        if desc:
+            return expand_jinja(desc, vars=self.get_vars_and_state())
+        else:
+            return None
+
+    def get_post_description(self):
+        if self.post_description:
+            return expand_jinja(self.post_description, vars=self.get_vars_and_state())
+        else:
+            return None
+
+    def get_commands(self):
+        cmds = self.commands
+        return cmds
+
+    def get_asciidoc(self):
+        if self.asciidoc:
+            return expand_jinja(self.asciidoc, vars=self.get_vars_and_state())
+        else:
+            return None
+
+    def get_vars_and_state(self):
+        d = self.get_vars().copy()
+        d.update(self.get_state())
+        return d
+
+
+def get_release_version():
+    v = str(input("Which version are you releasing? (vX.Y.Z) "))
+    try:
+        version = Version.parse(v)
+    except:
+        print("Not a valid version %s" % v)
+        return get_release_version()
+
+    return str(version)
+
+
+def get_subtitle():
+    applying_groups = list(filter(lambda x: x.num_applies() > 0, state.todo_groups))
+    done_groups = sum(1 for x in applying_groups if x.is_done())
+    return "Please complete the below checklist (Complete: %s/%s)" % (done_groups, len(applying_groups))
+
+
+def get_todo_menuitem_title():
+    return "Go to checklist (RC%d)" % (state.rc_number)
+
+
+def get_releasing_text():
+    return "Releasing the Solr Operator %s RC%d" % (state.release_version, state.rc_number)
+
+
+def get_start_new_rc_menu_title():
+    return "Abort RC%d and start a new RC%d" % (state.rc_number, state.rc_number + 1)
+
+
+def start_new_rc():
+    state.new_rc()
+    print("Started RC%d" % state.rc_number)
+
+
+def reset_state():
+    global state
+    if ask_yes_no("Are you sure? This will erase all current progress"):
+        maybe_remove_rc_from_svn()
+        shutil.rmtree(os.path.join(state.config_path, state.release_version))
+        state.clear()
+
+
+def template(name, vars=None):
+    return expand_jinja(templates[name], vars=vars)
+
+
+def help():
+    print(template('help'))
+    pause()
+
+
+def ensure_list(o):
+    if o is None:
+        return []
+    if not isinstance(o, list):
+        return [o]
+    else:
+        return o
+
+
+def open_file(filename):
+    print("Opening file %s" % filename)
+    if platform.system().startswith("Win"):
+        run("start %s" % filename)
+    else:
+        run("open %s" % filename)
+
+
+def expand_multiline(cmd_txt, indent=0):
+    return re.sub(r'  +', " %s\n    %s" % (Commands.cmd_continuation_char, " "*indent), cmd_txt)
+
+
+def unix_to_datetime(unix_stamp):
+    return datetime.utcfromtimestamp(unix_stamp / 1000)
+
+
+def generate_asciidoc():
+    base_filename = os.path.join(state.get_release_folder(),
+                                 "solr_operator_release_%s"
+                                 % (state.release_version.replace("\.", "_")))
+
+    filename_adoc = "%s.adoc" % base_filename
+    filename_html = "%s.html" % base_filename
+    fh = open(filename_adoc, "w")
+
+    fh.write("= Solr Operator Release %s\n\n" % state.release_version)
+    fh.write("(_Generated by releaseWizard.py %s at %s_)\n\n"
+             % (getScriptVersion(), datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")))
+    fh.write(":numbered:\n\n")
+    fh.write("%s\n\n" % template('help'))
+    for group in state.todo_groups:
+        if group.num_applies() == 0:
+            continue
+        fh.write("== %s\n\n" % group.get_title())
+        fh.write("%s\n\n" % group.get_description())
+        for todo in group.get_todos():
+            if not todo.applies(state.release_type):
+                continue
+            fh.write("=== %s\n\n" % todo.get_title())
+            if todo.is_done():
+                fh.write("_Completed %s_\n\n" % unix_to_datetime(todo.state['done_date']).strftime(
+                    "%Y-%m-%d %H:%M UTC"))
+            if todo.get_asciidoc():
+                fh.write("%s\n\n" % todo.get_asciidoc())
+            else:
+                desc = todo.get_description()
+                if desc:
+                    fh.write("%s\n\n" % desc)
+            state_copy = copy.deepcopy(todo.state)
+            state_copy.pop('done', None)
+            state_copy.pop('done_date', None)
+            if len(state_copy) > 0 or todo.user_input is not None:
+                fh.write(".Variables collected in this step\n")
+                fh.write("|===\n")
+                fh.write("|Variable |Value\n")
+                mykeys = set()
+                for e in ensure_list(todo.user_input):
+                    mykeys.add(e.name)
+                for e in state_copy.keys():
+                    mykeys.add(e)
+                for key in mykeys:
+                    val = "(not set)"
+                    if key in state_copy:
+                        val = state_copy[key]
+                    fh.write("\n|%s\n|%s\n" % (key, val))
+                fh.write("|===\n\n")
+            cmds = todo.get_commands()
+            if cmds:
+                if cmds.commands_text:
+                    fh.write("%s\n\n" % cmds.get_commands_text())
+                fh.write("[source,sh]\n----\n")
+                if cmds.env:
+                    for key in cmds.env:
+                        val = cmds.env[key]
+                        if is_windows():
+                            fh.write("SET %s=%s\n" % (key, val))
+                        else:
+                            fh.write("export %s=%s\n" % (key, val))
+                fh.write(abbreviate_homedir("cd %s\n" % cmds.get_root_folder()))
+                cmds2 = ensure_list(cmds.commands)
+                for c in cmds2:
+                    for line in c.display_cmd():
+                        fh.write("%s\n" % line)
+                fh.write("----\n\n")
+            if todo.post_description and not todo.get_asciidoc():
+                fh.write("\n%s\n\n" % todo.get_post_description())
+            if todo.links:
+                fh.write("Links:\n\n")
+                for l in todo.links:
+                    fh.write("* %s\n" % expand_jinja(l))
+                fh.write("\n")
+
+    fh.close()
+    print("Wrote file %s" % os.path.join(state.get_release_folder(), filename_adoc))
+    print("Running command 'asciidoctor %s'" % filename_adoc)
+    run_follow("asciidoctor %s" % filename_adoc)
+    if os.path.exists(filename_html):
+        open_file(filename_html)
+    else:
+        print("Failed generating HTML version, please install asciidoctor")
+    pause()
+
+
+def load_rc():
+    solr_operator_rc = os.path.expanduser("~/.solr_operator_rc")
+    try:
+        with open(solr_operator_rc, 'r') as fp:
+            return json.load(fp)
+    except:
+        return None
+
+
+def store_rc(release_root, release_version=None):
+    solr_operator_rc = os.path.expanduser("~/.solr_operator_rc")
+    dict = {}
+    dict['root'] = release_root
+    if release_version:
+        dict['release_version'] = release_version
+    with open(solr_operator_rc, "w") as fp:
+        json.dump(dict, fp, indent=2)
+
+
+def release_other_version():
+    if not state.is_released():
+        maybe_remove_rc_from_svn()
+    store_rc(state.config_path, None)
+    print("Please restart the wizard")
+    sys.exit(0)
+
+def file_to_string(filename):
+    with open(filename, encoding='utf8') as f:
+        return f.read().strip()
+
+def download_keys():
+    download('KEYS', "https://archive.apache.org/dist/solr/KEYS", state.config_path)
+
+def keys_downloaded():
+    return os.path.exists(os.path.join(state.config_path, "KEYS"))
+
+
+def dump_yaml():
+    file = open(os.path.join(script_path, "releaseWizard.yaml"), "w")
+    yaml.add_representer(str, str_presenter)
+    yaml.Dumper.ignore_aliases = lambda *args : True
+    dump_obj = {'templates': templates,
+                'groups': state.todo_groups}
+    yaml.dump(dump_obj, width=180, stream=file, sort_keys=False, default_flow_style=False)
+
+
+def parse_config():
+    description = 'Script to guide a RM through the whole release process'
+    parser = argparse.ArgumentParser(description=description, epilog="Go push that release!",
+                                     formatter_class=argparse.RawDescriptionHelpFormatter)
+    parser.add_argument('--dry-run', dest='dry', action='store_true', default=False,
+                        help='Do not execute any commands, but echo them instead. Display extra debug info')
+    parser.add_argument('--init', action='store_true', default=False,
+                        help='Re-initialize root and version')
+    config = parser.parse_args()
+
+    return config
+
+
+def load(urlString, encoding="utf-8"):
+    try:
+        content = urllib.request.urlopen(urlString).read().decode(encoding)
+    except Exception as e:
+        print('Retrying download of url %s after exception: %s' % (urlString, e))
+        content = urllib.request.urlopen(urlString).read().decode(encoding)
+    return content
+
+
+def configure_pgp(gpg_todo):
+    print("Based on your Apache ID we'll lookup your key online\n"
+          "and through this complete the 'gpg' prerequisite task.\n")
+    gpg_state = gpg_todo.get_state()
+    id = str(input("Please enter your Apache id: (ENTER=skip) "))
+    if id.strip() == '':
+        return False
+    all_keys = load('https://home.apache.org/keys/group/solr.asc')
+    lines = all_keys.splitlines()
+    keyid_linenum = None
+    for idx, line in enumerate(lines):
+        if line == 'ASF ID: %s' % id:
+            keyid_linenum = idx+1
+            break
+    if keyid_linenum:
+        keyid_line = lines[keyid_linenum]
+        assert keyid_line.startswith('LDAP PGP key: ')
+        gpg_id = keyid_line[14:].replace(" ", "")[-8:]
+        print("Found gpg key id %s on file at Apache (https://home.apache.org/keys/group/solr.asc)" % gpg_id)
+    else:
+        print(textwrap.dedent("""\
+            Could not find your GPG key from Apache servers.
+            Please make sure you have registered your key ID in
+            id.apache.org, see links for more info."""))
+        gpg_id = str(input("Enter your key ID manually, 8 last characters (ENTER=skip): "))
+        if gpg_id.strip() == '':
+            return False
+        elif len(gpg_id) != 8:
+            print("gpg id must be the last 8 characters of your key id")
+        gpg_id = gpg_id.upper()
+    try:
+        res = run("gpg --list-secret-keys %s" % gpg_id)
+        print("Found key %s on your private gpg keychain" % gpg_id)
+        # Check rsa and key length >= 4096
+        match = re.search(r'^sec +((rsa|dsa)(\d{4})) ', res)
+        type = "(unknown)"
+        length = -1
+        if match:
+            type = match.group(2)
+            length = int(match.group(3))
+        else:
+            match = re.search(r'^sec +((\d{4})([DR])/.*?) ', res)
+            if match:
+                type = 'rsa' if match.group(3) == 'R' else 'dsa'
+                length = int(match.group(2))
+            else:
+                print("Could not determine type and key size for your key")
+                print("%s" % res)
+                if not ask_yes_no("Is your key of type RSA and size >= 2048 (ideally 4096)? "):
+                    print("Sorry, please generate a new key, add to KEYS and register with id.apache.org")
+                    return False
+        if not type == 'rsa':
+            print("We strongly recommend RSA type key, your is '%s'. Consider generating a new key." % type.upper())
+        if length < 2048:
+            print("Your key has key length of %s. Cannot use < 2048, please generate a new key before doing the release" % length)
+            return False
+        if length < 4096:
+            print("Your key length is < 4096, Please generate a stronger key.")
+            print("Alternatively, follow instructions in http://www.apache.org/dev/release-signing.html#note")
+            if not ask_yes_no("Have you configured your gpg to avoid SHA-1?"):
+                print("Please either generate a strong key or reconfigure your client")
+                return False
+        print("Validated that your key is of type RSA and has a length >= 2048 (%s)" % length)
+    except:
+        print(textwrap.dedent("""\
+            Key not found on your private gpg keychain. In order to sign the release you'll
+            need to fix this, then try again"""))
+        return False
+    try:
+        lines = run("gpg --check-signatures %s" % gpg_id).splitlines()
+        sigs = 0
+        apache_sigs = 0
+        for line in lines:
+            if line.startswith("sig") and not gpg_id in line:
+                sigs += 1
+                if '@apache.org' in line:
+                    apache_sigs += 1
+        print("Your key has %s signatures, of which %s are by committers (@apache.org address)" % (sigs, apache_sigs))
+        if apache_sigs < 1:
+            print(textwrap.dedent("""\
+                Your key is not signed by any other committer. 
+                Please review http://www.apache.org/dev/openpgp.html#apache-wot
+                and make sure to get your key signed until next time.
+                You may want to run 'gpg --refresh-keys' to refresh your keychain."""))
+        uses_apacheid = is_code_signing_key = False
+        for line in lines:
+            if line.startswith("uid") and "%s@apache" % id in line:
+                uses_apacheid = True
+                if 'CODE SIGNING KEY' in line.upper():
+                    is_code_signing_key = True
+        if not uses_apacheid:
+            print("WARNING: Your key should use your apache-id email address, see http://www.apache.org/dev/release-signing.html#user-id")
+        if not is_code_signing_key:
+            print("WARNING: You code signing key should be labeled 'CODE SIGNING KEY', see http://www.apache.org/dev/release-signing.html#key-comment")
+    except Exception as e:
+        print("Could not check signatures of your key: %s" % e)
+
+    download_keys()
+    keys_text = file_to_string(os.path.join(state.config_path, "KEYS"))
+    if gpg_id in keys_text or "%s %s" % (gpg_id[:4], gpg_id[-4:]) in keys_text:
+        print("Found your key ID in official KEYS file. KEYS file is not cached locally.")
+    else:
+        print(textwrap.dedent("""\
+            Could not find your key ID in official KEYS file.
+            Please make sure it is added to https://dist.apache.org/repos/dist/release/solr/KEYS
+            and committed to svn. Then re-try this initialization"""))
+        if not ask_yes_no("Do you want to continue without fixing KEYS file? (not recommended) "):
+            return False
+
+    gpg_state['apache_id'] = id
+    gpg_state['gpg_key'] = gpg_id
+    return True
+
+
+def pause(fun=None):
+    if fun:
+        fun()
+    input("\nPress ENTER to continue...")
+
+
+# Custom classes for ConsoleMenu, to make menu texts dynamic
+# Needed until https://github.com/aegirhall/console-menu/pull/25 is released
+# See https://pypi.org/project/console-menu/ for other docs
+
+class UpdatableConsoleMenu(ConsoleMenu):
+
+    def __repr__(self):
+        return "%s: %s. %d items" % (self.get_title(), self.get_subtitle(), len(self.items))
+
+    def draw(self):
+        """
+        Refreshes the screen and redraws the menu. Should be called whenever something changes that needs to be redrawn.
+        """
+        self.screen.printf(self.formatter.format(title=self.get_title(), subtitle=self.get_subtitle(), items=self.items,
+                                                 prologue_text=self.get_prologue_text(), epilogue_text=self.get_epilogue_text()))
+
+    # Getters to get text in case method reference
+    def get_title(self):
+        return self.title() if callable(self.title) else self.title
+
+    def get_subtitle(self):
+        return self.subtitle() if callable(self.subtitle) else self.subtitle
+
+    def get_prologue_text(self):
+        return self.prologue_text() if callable(self.prologue_text) else self.prologue_text
+
+    def get_epilogue_text(self):
+        return self.epilogue_text() if callable(self.epilogue_text) else self.epilogue_text
+
+
+class UpdatableSubmenuItem(SubmenuItem):
+    def __init__(self, text, submenu, menu=None, should_exit=False):
+        """
+        :ivar ConsoleMenu self.submenu: The submenu to be opened when this item is selected
+        """
+        super(SubmenuItem, self).__init__(text=text, menu=menu, should_exit=should_exit)
+
+        self.submenu = submenu
+        if menu:
+            self.get_submenu().parent = menu
+
+    def show(self, index):
+        return "%2d - %s" % (index + 1, self.get_text())
+
+    # Getters to get text in case method reference
+    def get_text(self):
+        return self.text() if callable(self.text) else self.text
+
+    def set_menu(self, menu):
+        """
+        Sets the menu of this item.
+        Should be used instead of directly accessing the menu attribute for this class.
+
+        :param ConsoleMenu menu: the menu
+        """
+        self.menu = menu
+        self.get_submenu().parent = menu
+
+    def action(self):
+        """
+        This class overrides this method
+        """
+        self.get_submenu().start()
+
+    def clean_up(self):
+        """
+        This class overrides this method
+        """
+        self.get_submenu().join()
+        self.menu.clear_screen()
+        self.menu.resume()
+
+    def get_return(self):
+        """
+        :return: The returned value in the submenu
+        """
+        return self.get_submenu().returned_value
+
+    def get_submenu(self):
+        """
+        We unwrap the submenu variable in case it is a reference to a method that returns a submenu
+        """
+        return self.submenu if not callable(self.submenu) else self.submenu()
+
+
+class UpdatableFunctionItem(FunctionItem):
+    def show(self, index):
+        return "%2d - %s" % (index + 1, self.get_text())
+
+    # Getters to get text in case method reference
+    def get_text(self):
+        return self.text() if callable(self.text) else self.text
+
+
+class MyScreen(Screen):
+    def clear(self):
+        return
+
+
+class CustomExitItem(ExitItem):
+    def show(self, index):
+        return super(ExitItem, self).show(index)
+
+    def get_return(self):
+        return ""
+
+
+def main():
+    global state
+    global dry_run
+    global templates
+
+    print("Solr Operator releaseWizard %s" % getScriptVersion())
+    c = parse_config()
+
+    if c.dry:
+        print("Entering dry-run mode where all commands will be echoed instead of executed")
+        dry_run = True
+
+    release_root = os.path.expanduser("~/.solr-operator-releases")
+    if not load_rc() or c.init:
+        print("Initializing")
+        dir_ok = False
+        root = str(input("Choose root folder: [~/.solr-operator-releases] "))
+        if os.path.exists(root) and (not os.path.isdir(root) or not os.access(root, os.W_OK)):
+            sys.exit("Root %s exists but is not a directory or is not writable" % root)
+        if not root == '':
+            if root.startswith("~/"):
+                release_root = os.path.expanduser(root)
+            else:
+                release_root = os.path.abspath(root)
+        if not os.path.exists(release_root):
+            try:
+                print("Creating release root %s" % release_root)
+                os.makedirs(release_root)
+            except Exception as e:
+                sys.exit("Error while creating %s: %s" % (release_root, e))
+        release_version = get_release_version()
+    else:
+        conf = load_rc()
+        release_root = conf['root']
+        if 'release_version' in conf:
+            release_version = conf['release_version']
+        else:
+            release_version = get_release_version()
+    store_rc(release_root, release_version)
+
+    check_prerequisites()
+
+    try:
+        y = yaml.load(open(os.path.join(script_path, "releaseWizard.yaml"), "r"), Loader=yaml.Loader)
+        templates = y.get('templates')
+        todo_list = y.get('groups')
+        state = ReleaseState(release_root, release_version, getScriptVersion())
+        state.init_todos(bootstrap_todos(todo_list))
+        state.load()
+    except Exception as e:
+        sys.exit("Failed initializing. %s" % e)
+
+    state.save()
+
+    global solr_operator_news_file
+    solr_operator_news_file = os.path.join(state.get_website_git_folder(), 'content', 'solr', 'operator', 'news',
+      "%s-%s-available.md" % (state.get_release_date_iso(), state.release_version.replace(".", "-")))
+    website_folder = state.get_website_git_folder()
+
+    main_menu = UpdatableConsoleMenu(title="Solr Operator ReleaseWizard",
+                            subtitle=get_releasing_text,
+                            prologue_text="Welcome to the release wizard. From here you can manage the process including creating new RCs. "
+                                          "All changes are persisted, so you can exit any time and continue later. Make sure to read the Help section.",
+                            epilogue_text="® 2020 The Solr Operator project. Licensed under the Apache License 2.0\nScript version %s)" % getScriptVersion(),
+                            screen=MyScreen())
+
+    todo_menu = UpdatableConsoleMenu(title=get_releasing_text,
+                            subtitle=get_subtitle,
+                            prologue_text=None,
+                            epilogue_text=None,
+                            screen=MyScreen())
+    todo_menu.exit_item = CustomExitItem("Return")
+
+    for todo_group in state.todo_groups:
+        if todo_group.num_applies() >= 0:
+            menu_item = todo_group.get_menu_item()
+            menu_item.set_menu(todo_menu)
+            todo_menu.append_item(menu_item)
+
+    main_menu.append_item(UpdatableSubmenuItem(get_todo_menuitem_title, todo_menu, menu=main_menu))
+    main_menu.append_item(UpdatableFunctionItem(get_start_new_rc_menu_title, start_new_rc))
+    main_menu.append_item(UpdatableFunctionItem('Clear and restart current RC', state.clear_rc))
+    main_menu.append_item(UpdatableFunctionItem("Clear all state, restart the %s release" % state.release_version, reset_state))
+    main_menu.append_item(UpdatableFunctionItem('Start release for a different version', release_other_version))
+    main_menu.append_item(UpdatableFunctionItem('Generate Asciidoc guide for this release', generate_asciidoc))
+    # main_menu.append_item(UpdatableFunctionItem('Dump YAML', dump_yaml))
+    main_menu.append_item(UpdatableFunctionItem('Help', help))
+
+    main_menu.show()
+
+
+sys.path.append(os.path.dirname(__file__))
+current_git_root = os.path.abspath(
+    os.path.join(os.path.abspath(os.path.dirname(__file__)), os.path.pardir, os.path.pardir))
+
+dry_run = False
+
+major_minor = ['major', 'minor']
+script_path = os.path.dirname(os.path.realpath(__file__))
+os.chdir(script_path)
+
+
+def git_checkout_folder():
+    return state.get_git_checkout_folder()
+
+
+def tail_file(file, lines):
+    bufsize = 8192
+    fsize = os.stat(file).st_size
+    with open(file) as f:
+        if bufsize >= fsize:
+            bufsize = fsize
+        idx = 0
+        while True:
+            idx += 1
+            seek_pos = fsize - bufsize * idx
+            if seek_pos < 0:
+                seek_pos = 0
+            f.seek(seek_pos)
+            data = []
+            data.extend(f.readlines())
+            if len(data) >= lines or f.tell() == 0 or seek_pos == 0:
+                if not seek_pos == 0:
+                    print("Tailing last %d lines of file %s" % (lines, file))
+                print(''.join(data[-lines:]))
+                break
+
+
+def run_with_log_tail(command, cwd, logfile=None, tail_lines=10, tee=False, live=False, shell=None, env=None):
+    fh = sys.stdout
+    if logfile:
+        logdir = os.path.dirname(logfile)
+        if not os.path.exists(logdir):
+            os.makedirs(logdir)
+        fh = open(logfile, 'w')
+    rc = run_follow(command, cwd, fh=fh, tee=tee, live=live, shell=shell, env=env)
+    if logfile:
+        fh.close()
+        if not tee and tail_lines and tail_lines > 0:
+            tail_file(logfile, tail_lines)
+    return rc
+
+
+def ask_yes_no(text):
+    answer = None
+    while answer not in ['y', 'n']:
+        answer = str(input("\nQ: %s (y/n): " % text))
+    print("\n")
+    return answer == 'y'
+
+
+def abbreviate_line(line, width):
+    line = line.rstrip()
+    if len(line) > width:
+        line = "%s.....%s" % (line[:(width / 2 - 5)], line[-(width / 2):])
+    else:
+        line = "%s%s" % (line, " " * (width - len(line) + 2))
+    return line
+
+
+def print_line_cr(line, linenum, stdout=True, tee=False):
+    if not tee:
+        if not stdout:
+            print("[line %s] %s" % (linenum, abbreviate_line(line, 80)), end='\r')
+    else:
+        if line.endswith("\r"):
+            print(line.rstrip(), end='\r')
+        else:
+            print(line.rstrip())
+
+
+def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=None, env=None):
+    doShell = '&&' in command or '&' in command or shell is not None
+    if not doShell and not isinstance(command, list):
+        command = shlex.split(command)
+    process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd,
+                               universal_newlines=True, bufsize=0, close_fds=True, shell=doShell, env=env)
+    lines_written = 0
+
+    fl = fcntl.fcntl(process.stdout, fcntl.F_GETFL)
+    fcntl.fcntl(process.stdout, fcntl.F_SETFL, fl | os.O_NONBLOCK)
+
+    flerr = fcntl.fcntl(process.stderr, fcntl.F_GETFL)
+    fcntl.fcntl(process.stderr, fcntl.F_SETFL, flerr | os.O_NONBLOCK)
+
+    endstdout = endstderr = False
+    errlines = []
+    while not (endstderr and endstdout):
+        lines_before = lines_written
+        if not endstdout:
+            try:
+                if live:
+                    chars = process.stdout.read()
+                    if chars == '' and process.poll() is not None:
+                        endstdout = True
+                    else:
+                        fh.write(chars)
+                        fh.flush()
+                        if '\n' in chars:
+                            lines_written += 1
+                else:
+                    line = process.stdout.readline()
+                    if line == '' and process.poll() is not None:
+                        endstdout = True
+                    else:
+                        fh.write("%s\n" % line.rstrip())
+                        fh.flush()
+                        lines_written += 1
+                        print_line_cr(line, lines_written, stdout=(fh == sys.stdout), tee=tee)
+
+            except Exception as ioe:
+                pass
+        if not endstderr:
+            try:
+                if live:
+                    chars = process.stderr.read()
+                    if chars == '' and process.poll() is not None:
+                        endstderr = True
+                    else:
+                        fh.write(chars)
+                        fh.flush()
+                        if '\n' in chars:
+                            lines_written += 1
+                else:
+                    line = process.stderr.readline()
+                    if line == '' and process.poll() is not None:
+                        endstderr = True
+                    else:
+                        errlines.append("%s\n" % line.rstrip())
+                        lines_written += 1
+                        print_line_cr(line, lines_written, stdout=(fh == sys.stdout), tee=tee)
+            except Exception as e:
+                pass
+
+        if not lines_written > lines_before:
+            # if no output then sleep a bit before checking again
+            time.sleep(0.1)
+
+    print(" " * 80)
+    rc = process.poll()
+    if len(errlines) > 0:
+        for line in errlines:
+            fh.write("%s\n" % line.rstrip())
+            fh.flush()
+    return rc
+
+
+def is_windows():
+    return platform.system().startswith("Win")
+
+def is_mac():
+    return platform.system().startswith("Darwin")
+
+def is_linux():
+    return platform.system().startswith("Linux")
+
+class Commands(SecretYamlObject):
+    yaml_tag = u'!Commands'
+    hidden_fields = ['todo_id']
+    cmd_continuation_char = "^" if is_windows() else "\\"
+    def __init__(self, root_folder, commands_text=None, commands=None, logs_prefix=None, run_text=None, enable_execute=None,
+                 confirm_each_command=None, env=None, vars=None, todo_id=None, remove_files=None):
+        self.root_folder = root_folder
+        self.commands_text = commands_text
+        self.vars = vars
+        self.env = env
+        self.run_text = run_text
+        self.remove_files = remove_files
+        self.todo_id = todo_id
+        self.logs_prefix = logs_prefix
+        self.enable_execute = enable_execute
+        self.confirm_each_command = confirm_each_command
+        self.commands = commands
+        for c in self.commands:
+            c.todo_id = todo_id
+
+    @classmethod
+    def from_yaml(cls, loader, node):
+        fields = loader.construct_mapping(node, deep = True)
+        return Commands(**fields)
+
+    def run(self):
+        root = self.get_root_folder()
+
+        if self.commands_text:
+            print(self.get_commands_text())
+        if self.env:
+            for key in self.env:
+                val = self.jinjaify(self.env[key])
+                os.environ[key] = val
+                if is_windows():
+                    print("\n  SET %s=%s" % (key, val))
+                else:
+                    print("\n  export %s=%s" % (key, val))
+        print(abbreviate_homedir("\n  cd %s" % root))
+        commands = ensure_list(self.commands)
+        for cmd in commands:
+            for line in cmd.display_cmd():
+                print("  %s" % line)
+        print()
+        if not self.enable_execute is False:
+            if self.run_text:
+                print("\n%s\n" % self.get_run_text())
+            if len(commands) > 1:
+                if not self.confirm_each_command is False:
+                    print("You will get prompted before running each individual command.")
+                else:
+                    print(
+                        "You will not be prompted for each command but will see the ouput of each. If one command fails the execution will stop.")
+            success = True
+            if ask_yes_no("Do you want me to run these commands now?"):
+                if self.remove_files:
+                    for _f in ensure_list(self.get_remove_files()):
+                        f = os.path.join(root, _f)
+                        if os.path.exists(f):
+                            filefolder = "File" if os.path.isfile(f) else "Folder"
+                            if ask_yes_no("%s %s already exists. Shall I remove it now?" % (filefolder, f)) and not dry_run:
+                                if os.path.isdir(f):
+                                    shutil.rmtree(f)
+                                else:
+                                    os.remove(f)
+                index = 0
+                log_folder = self.logs_prefix if len(commands) > 1 else None
+                for cmd in commands:
+                    index += 1
+                    if len(commands) > 1:
+                        log_prefix = "%02d_" % index
+                    else:
+                        log_prefix = self.logs_prefix if self.logs_prefix else ''
+                    if not log_prefix[-1:] == '_':
+                        log_prefix += "_"
+                    cwd = root
+                    if cmd.cwd:
+                        cwd = os.path.join(root, cmd.cwd)
+                    folder_prefix = ''
+                    if cmd.cwd:
+                        folder_prefix = cmd.cwd + "_"
+                    if self.confirm_each_command is False or len(commands) == 1 or ask_yes_no("Shall I run '%s' in folder '%s'" % (cmd, cwd)):
+                        if self.confirm_each_command is False:
+                            print("------------\nRunning '%s' in folder '%s'" % (cmd, cwd))
+                        logfilename = cmd.logfile
+                        logfile = None
+                        cmd_to_run = "%s%s" % ("echo Dry run, command is: " if dry_run else "", cmd.get_cmd())
+                        if cmd.redirect:
+                            try:
+                                out = run(cmd_to_run, cwd=cwd)
+                                mode = 'a' if cmd.redirect_append is True else 'w'
+                                with open(os.path.join(root, cwd, cmd.get_redirect()), mode) as outfile:
+                                    outfile.write(out)
+                                    outfile.flush()
+                                print("Wrote %s bytes to redirect file %s" % (len(out), cmd.get_redirect()))
+                            except Exception as e:
+                                print("Command %s failed: %s" % (cmd_to_run, e))
+                                success = False
+                                break
+                        else:
+                            if not cmd.stdout:
+                                if not log_folder:
+                                    log_folder = os.path.join(state.get_rc_folder(), "logs")
+                                elif not os.path.isabs(log_folder):
+                                    log_folder = os.path.join(state.get_rc_folder(), "logs", log_folder)
+                                if not logfilename:
+                                    logfilename = "%s.log" % re.sub(r"\W", "_", cmd.get_cmd())
+                                logfile = os.path.join(log_folder, "%s%s%s" % (log_prefix, folder_prefix, logfilename))
+                                if cmd.tee:
+                                    print("Output of command will be printed (logfile=%s)" % logfile)
+                                elif cmd.live:
+                                    print("Output will be shown live byte by byte")
+                                    logfile = None
+                                else:
+                                    print("Wait until command completes... Full log in %s\n" % logfile)
+                                if cmd.comment:
+                                    print("# %s\n" % cmd.get_comment())
+                            start_time = time.time()
+                            additional_env = None
+                            user_env = cmd.get_env()
+                            if user_env is not None:
+                                additional_env = os.environ.copy()
+                                for k in user_env:
+                                    additional_env[k] = user_env[k]
+                            returncode = run_with_log_tail(cmd_to_run, cwd, logfile=logfile, tee=cmd.tee, tail_lines=25,
+                                                           live=cmd.live, shell=cmd.shell, env=additional_env)
+                            elapsed = time.time() - start_time
+                            if not returncode == 0:
+                                if cmd.should_fail:
+                                    print("Command failed, which was expected")
+                                    success = True
+                                else:
+                                    print("WARN: Command %s returned with error" % cmd.get_cmd())
+                                    success = False
+                                    break
+                            else:
+                                if cmd.should_fail and not dry_run:
+                                    print("Expected command to fail, but it succeeded.")
+                                    success = False
+                                    break
+                                else:
+                                    if elapsed > 30:
+                                        print("Command completed in %s seconds" % elapsed)
+            if not success:
+                print("WARNING: One or more commands failed, you may want to check the logs")
+            return success
+
+    def get_root_folder(self):
+        return self.jinjaify(self.root_folder)
+
+    def get_commands_text(self):
+        return self.jinjaify(self.commands_text)
+
+    def get_run_text(self):
+        return self.jinjaify(self.run_text)
+
+    def get_remove_files(self):
+        return self.jinjaify(self.remove_files)
+
+    def get_vars(self):
+        myvars = {}
+        if self.vars:
+            for k in self.vars:
+                val = self.vars[k]
+                if callable(val):
+                    myvars[k] = expand_jinja(val(), vars=myvars)
+                else:
+                    myvars[k] = expand_jinja(val, vars=myvars)
+        return myvars
+
+    def jinjaify(self, data, join=False):
+        if not data:
+            return None
+        v = self.get_vars()
+        if self.todo_id:
+            v.update(state.get_todo_by_id(self.todo_id).get_vars())
+        if isinstance(data, list):
+            if join:
+                return expand_jinja(" ".join(data), v)
+            else:
+                res = []
+                for rf in data:
+                    res.append(expand_jinja(rf, v))
+                return res
+        else:
+            return expand_jinja(data, v)
+
+
+def abbreviate_homedir(line):
+    if is_windows():
+        if 'HOME' in os.environ:
+            return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1%HOME%", line)
+        elif 'USERPROFILE' in os.environ:
+            return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1%USERPROFILE%", line)
+    else:
+        return re.sub(r'([^/]|\b)%s' % os.path.expanduser('~'), "\\1~", line)
+
+
+class Command(SecretYamlObject):
+    yaml_tag = u'!Command'
+    hidden_fields = ['todo_id']
+    def __init__(self, cmd, cwd=None, stdout=None, logfile=None, tee=None, live=None, comment=None, vars=None,
+                 todo_id=None, should_fail=None, redirect=None, redirect_append=None, shell=None, env=None):
+        self.cmd = cmd
+        self.cwd = cwd
+        self.comment = comment
+        self.logfile = logfile
+        self.vars = vars
+        self.env = env
+        self.tee = tee
+        self.live = live
+        self.stdout = stdout
+        self.should_fail = should_fail
+        self.shell = shell
+        self.todo_id = todo_id
+        self.redirect_append = redirect_append
+        self.redirect = redirect
+        if tee and stdout:
+            self.stdout = None
+            print("Command %s specifies 'tee' and 'stdout', using only 'tee'" % self.cmd)
+        if live and stdout:
+            self.stdout = None
+            print("Command %s specifies 'live' and 'stdout', using only 'live'" % self.cmd)
+        if live and tee:
+            self.tee = None
+            print("Command %s specifies 'tee' and 'live', using only 'live'" % self.cmd)
+        if redirect and (tee or stdout or live):
+            self.tee = self.stdout = self.live = None
+            print("Command %s specifies 'redirect' and other out options at the same time. Using redirect only" % self.cmd)
+
+    @classmethod
+    def from_yaml(cls, loader, node):
+        fields = loader.construct_mapping(node, deep = True)
+        return Command(**fields)
+
+    def get_comment(self):
+        return self.jinjaify(self.comment)
+
+    def get_redirect(self):
+        return self.jinjaify(self.redirect)
+
+    def get_cmd(self):
+        return self.jinjaify(self.cmd, join=True)
+
+    def get_vars(self):
+        myvars = {}
+        if self.vars:
+            for k in self.vars:
+                val = self.vars[k]
+                if callable(val):
+                    myvars[k] = expand_jinja(val(), vars=myvars)
+                else:
+                    myvars[k] = expand_jinja(val, vars=myvars)
+        return myvars
+
+    def get_env(self):
+        return self.jinjaify(self.env) if self.env is not None else None
+
+    def __str__(self):
+        return self.get_cmd()
+
+    def jinjaify(self, data, join=False):
+        v = self.get_vars()
+        if self.todo_id:
+            v.update(state.get_todo_by_id(self.todo_id).get_vars())
+        if isinstance(data, list):
+            if join:
+                return expand_jinja(" ".join(data), v)
+            else:
+                res = []
+                for rf in data:
+                    res.append(expand_jinja(rf, v))
+                return res
+        if isinstance(data, dict):
+            res = {}
+            for k in data:
+                val = data[k]
+                if callable(val):
+                    res[k] = expand_jinja(val(), vars=v)
+                else:
+                    res[k] = expand_jinja(val, vars=v)
+            return res
+        else:
+            return expand_jinja(data, v)
+
+    def display_cmd(self):
+        lines = []
+        pre = post = ''
+        if self.comment:
+            if is_windows():
+                lines.append("REM %s" % self.get_comment())
+            else:
+                lines.append("# %s" % self.get_comment())
+        if self.cwd:
+            lines.append("pushd %s" % self.cwd)
+        env = self.get_env()
+        if env is not None:
+            for k in env:
+                lines.append("%s=%s \\" % (k, env[k]))
+        redir = "" if self.redirect is None else " %s %s" % (">" if self.redirect_append is None else ">>" , self.get_redirect())
+        line = "%s%s" % (expand_multiline(self.get_cmd(), indent=2), redir)
+        # Print ~ or %HOME% rather than the full expanded homedir path
+        line = abbreviate_homedir(line)
+        lines.append(line)
+        if self.cwd:
+            lines.append("popd")
+        return lines
+
+class UserInput(SecretYamlObject):
+    yaml_tag = u'!UserInput'
+
+    def __init__(self, name, prompt, type=None):
+        self.type = type
+        self.prompt = prompt
+        self.name = name
+
+    @classmethod
+    def from_yaml(cls, loader, node):
+        fields = loader.construct_mapping(node, deep = True)
+        return UserInput(**fields)
+
+    def run(self, dict=None):
+        correct = False
+        while not correct:
+            try:
+                result = str(input("%s : " % self.prompt))
+                if self.type and self.type == 'int':
+                    result = int(result)
+                correct = True
+            except Exception as e:
+                print("Incorrect input: %s, try again" % e)
+                continue
+            if dict:
+                dict[self.name] = result
+            return result
+
+
+def create_ical(todo):
+    if ask_yes_no("Do you want to add a Calendar reminder for the close vote time?"):
+        c = Calendar()
+        e = Event()
+        e.name = "Solr Operator %s vote ends" % state.release_version
+        e.begin = vote_close_72h_date()
+        e.description = "Remember to sum up votes and continue release :)"
+        c.events.add(e)
+        ics_file = os.path.join(state.get_rc_folder(), 'vote_end.ics')
+        with open(ics_file, 'w') as my_file:
+            my_file.writelines(c)
+        open_file(ics_file)
+    return True
+
+
+today = datetime.utcnow().date()
+sundays = {(today + timedelta(days=x)): 'Sunday' for x in range(10) if (today + timedelta(days=x)).weekday() == 6}
+y = datetime.utcnow().year
+years = [y, y+1]
+non_working = holidays.CA(years=years) + holidays.US(years=years) + holidays.England(years=years) \
+              + holidays.DE(years=years) + holidays.NO(years=years) + holidays.IND(years=years) + holidays.RU(years=years)
+
+
+def vote_close_72h_date():
+    # Voting open at least 72 hours according to ASF policy
+    return datetime.utcnow() + timedelta(hours=73)
+
+
+def vote_close_72h_holidays():
+    days = 0
+    day_offset = -1
+    holidays = []
+    # Warn RM about major holidays coming up that should perhaps extend the voting deadline
+    # Warning will be given for Sunday or a public holiday observed by 3 or more [CA, US, EN, DE, NO, IND, RU]
+    while days < 3:
+        day_offset += 1
+        d = today + timedelta(days=day_offset)
+        if not (d in sundays or (d in non_working and len(non_working[d]) >= 2)):
+            days += 1
+        else:
+            if d in sundays:
+                holidays.append("%s (Sunday)" % d)
+            else:
+                holidays.append("%s (%s)" % (d, non_working[d]))
+    return holidays if len(holidays) > 0 else None
+
+
+def prepare_announce_solr_operator(todo):
+    if not os.path.exists(solr_operator_news_file):
+        solr_operator_text = expand_jinja("(( template=announce_solr_operator ))")
+        with open(solr_operator_news_file, 'w') as fp:
+            fp.write(solr_operator_text)
+        # print("Wrote Solr Operator announce draft to %s" % solr_operator_news_file)
+    else:
+        print("Draft already exist, not re-generating")
+    return True
+
+
+def load_lines(file, from_line=0):
+    if os.path.exists(file):
+        with open(file, 'r') as fp:
+            return fp.readlines()[from_line:]
+    else:
+        return ["<Please paste the announcement text here>\n"]
+
+
+if __name__ == '__main__':
+    try:
+        main()
+    except KeyboardInterrupt:
+        print('Keyboard interrupt...exiting')
diff --git a/hack/release/wizard/releaseWizard.yaml b/hack/release/wizard/releaseWizard.yaml
new file mode 100644
index 0000000..1de710d
--- /dev/null
+++ b/hack/release/wizard/releaseWizard.yaml
@@ -0,0 +1,1440 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# =====================================================================
+# This file contains the definition TO-DO steps and commands to run for the
+# releaseWizard.py script. It also contains Jinja2 templates for use in those
+# definitions. See documentation for "groups" below the templates.
+# Edit this file with an editor with YAML support, such as Sublime Text
+# for syntax highlighting and context sensitive tag suggestion
+# =====================================================================
+#
+# Templates may be included in any text by starting a line with this syntax:
+# (( template=my_template_name ))
+# Templates may contain other templates for easy re-use of snippets
+# And of course all Jinja2 syntax for inclusion of variables etc is supported.
+# See http://jinja.pocoo.org/docs/2.10/templates/ for details
+# To add new global variables or functions/filters, edit releaseWizard.py
+#
+templates:
+  help: |
+    Welcome to the role as Release Manager for the Solr Operator, and the releaseWizard!
+
+    The Release Wizard aims to walk you through the whole release process step by step,
+    helping you to to run the right commands in the right order, generating
+    e-mail templates for you with the correct texts, versions, paths etc, obeying
+    the voting rules and much more. It also serves as a documentation of all the
+    steps, with timestamps, preserving log files from each command etc.
+
+    As you complete each step the tool will ask you if the task is complete, making
+    it easy for you to know what is done and what is left to do. If you need to
+    re-spin a Release Candidata (RC) the Wizard will also help.
+
+    In the first TODO step in the checklist you will be asked to read up on the
+    Apache release policy and other relevant documents before you start the release.
+
+    NOTE: Even if we have great tooling and some degree of automation, there are
+          still many manual steps and it is also important that the RM validates
+          and QAs the process, validating that the right commands are run, and that
+          the output from scripts are correct before proceeding.
+  vote_logic: |
+    {% set passed = plus_binding >= 3 and minus < plus_binding %}
+    {% set too_few = plus_binding < 3 %}
+    {% set veto = plus_binding < minus %}
+    {% set reason = 'too few binding votes' if too_few else 'too many negative votes' if veto else 'unknown' %}    
+  vote_macro: |
+    {% macro end_vote_result(plus_binding, plus_other, zero, minus) -%}
+    (( template=vote_logic ))
+    .Mail template {% if passed %}successful{% else %}failed{% endif %} vote
+    ----
+    To: dev@solr.apache.org
+    Subject: [{% if passed %}RESULT{% else %}FAILED{% endif %}] [VOTE] Release the Solr Operator {{ release_version }} RC{{ rc_number }}
+
+    It's been >72h since the vote was initiated and the result is:
+
+    +1  {{ plus_binding + plus_other }}  ({{ plus_binding }} binding)
+     0  {{ zero }}
+    -1  {{ minus }}
+
+    {% if not passed %}
+    Reason for fail is {{ reason }}.
+    
+    {% endif %}
+    This vote has {% if passed %}PASSED{% else %}FAILED{% endif %} 
+    ----
+    {%- endmacro %}
+  announce_solr_operator: |
+    Title: Apache Solr Operator™ {{ release_version }} available
+    category: solr/operator/news
+    URL:
+    save_as:
+
+    The Solr PMC is pleased to announce the release of the Apache Solr Operator {{ release_version }}.
+
+    The Apache Solr Operator is a safe and easy way of managing a Solr ecosystem in Kubernetes.
+
+    This release contains numerous bug fixes, optimizations, and improvements, some of which are highlighted below. The release is available for immediate download at:
+
+      <https://spolr.apache.org/operator/downloads.html>
+
+  announce_solr_operator_mail: |
+    The template below can be used to announce the Solr Operator release to the
+    internal mailing lists.
+
+    .Mail template
+    ----
+    To: dev@solr.apache.org, users@solr.apache.org
+    Subject: [ANNOUNCE] Apache Solr Operator {{ release_version }} released
+
+    (( template=announce_solr_operator_mail_body ))
+    ----
+  announce_solr_operator_sign_mail: |
+    The template below can be used to announce the Solr Operator release to the
+    `announce@apache.org` mailing list. The mail *should be signed with PGP.*
+    and sent *from your `@apache.org` account*.
+
+    .Mail template
+    ----
+    From: {{ gpg.apache_id }}@apache.org
+    To: announce@apache.org
+    Subject: [ANNOUNCE] Apache Solr Operator {{ release_version }} released
+
+    (( template=announce_solr_operator_mail_body ))
+    ----
+  announce_solr_operator_mail_body: |
+    {% for line in load_lines(solr_news_file, 4) -%}
+    {{ line }}
+    {%- endfor %}
+
+
+    Note: The Apache Software Foundation uses an extensive mirroring network for
+    distributing releases. It is possible that the mirror you are using may not have
+    replicated the release yet. If that is the case, please try another mirror.
+    This also applies to Maven access.
+# TODOs belong to groups for easy navigation in menus. Todo objects may contain asciidoc
+# descriptions, a number of commands to execute, some links to display, user input to gather
+# etc. Here is the documentation of each type of object. For further details, please consult
+# the corresponding Python object in releaseWizard.py, as these are parsed 1:1 from yaml.
+#
+# - !TodoGroup
+#   id: unique_id
+#   title: Title which will appear in menu
+#   description: Longer description that will appear in sub-menu
+#   depends: ['group1_id', 'group2_id'] # Explicit dependencies for groups
+#   is_in_rc_loop: Tells that a group is thrown away on RC re-psin (default=False)
+#   todos:    # Array of !Todo objects beloning to the group
+#     !Todo
+#     id: todo_id
+#     title: Short title that will appear in menu and elsewhere
+#     description: |
+#         The main description being printed when selecing the todo item. Here
+#         you should introduce the task in more detail. You can use {{ jinja_var }} to
+#         reference variables. See `releaseWizard.py` for list of global vars supported.
+#         You can reference state saved from earlier TODO items using syntax
+#           {{ todi_id.var_name }}
+#         with `var_name` being either fetched from user_input or persist_vars
+#     depends:  # One or more dependencies which will bar execution
+#     - todo_id1
+#     - todo_id2
+#     vars:  # Dictionary of jinja2 variables local to this TODO, e.g.
+#       logfile_path: "{{ [rc_folder, 'logs'] | path_join }}"
+#       # Vars can contain global jinja vars or local vars earlier defined (ordered dict)
+#     persist_vars: ['var_name', 'var_name'] # List of variables to persist in TODO state
+#     asciidoc: |
+#         Some `asciidoc` text to be included in asciidoc guide 
+#         *instead of* description/post_description
+#     function: my_python_function  # Will call the named function for complex tasks
+#     commands: !Commands   # A !Commands object holding commands to execute for this todo
+#       root_folder: '{{ git_checkout_folder }}' # path to where commands will run
+#       commands_text: Introduction text to be displayed just before the commands 
+#       enable_execute: true # Set to false to never offer to run commands automatically
+#       confirm_each_command: true # Set to false to run all commands without prompting
+#       remove_files: ['file1', 'folder2'] # List of files or folders that must be gone
+#       logs_prefix: prefix # Lets you prefix logs file names with this string
+#       commands:   # List of !Commands to execute
+#       - !Command  # One single command
+#         cmd: "ls {{ folder_to_ls }}"  # A single command. May reference jinja vars
+#              # Double spaces in a cmd will trigger multi-line display with continuation char \
+#         cwd: relative_path # Where to run command, relative to root_folder
+#         comment: # Will display a # or REM comment above the command in printouts
+#         vars: {} # Possible to define local vars for this command only
+#         logfile: my.og # Overrides log file name which may grow very long :)
+#         tee: false       # If true, sends output to console and file
+#         stdout: false    # if true, sends output only to console, not log file
+#         live: false      # If true, sends output live byte-by-byte to console
+#         redirect: file.txt      # Send output to file. Use instead of >
+#         redirect_append: false  # Will cause output to be appended, like >>
+#         shell: false     $ Set to true to use built-in shell commands
+#     user_input: # An array of !UserInput objects for requesting input from user
+#     - !UserInput
+#       prompt: Please enter your gpg key ID, e.g. 0D8D0B93
+#       name: gpg_id  # This will be stored in todo state and can be referenced as {{ todo_id.name }}
+#       type: int  # if no type is given, a string is stored. Supported types are 'int'
+#     post_description: |
+#       Some `asciidoc` text (with jinja template support) 
+#       to be printed *after* commands and user_input is done.
+#     links:
+#     - http://example.com/list/of/links?to&be&displayed
+groups:
+- !TodoGroup
+  id: prerequisites
+  title: Prerequisites
+  description: |
+    Releasing software requires thorough understanding of the process and careful execution,
+    as it is easy to make mistakes. It also requires an environtment and tools such as gpg
+    correctly setup. This section makes sure you're in good shape for the job!
+  todos:
+  - !Todo
+    id: read_up
+    title: Read up on the release process
+    description: |-
+      As a Release Manager (RM) you should be familiar with Apache's release policy,
+      voting rules, create a PGP/GPG key for use with signing and more. Please familiarise
+      yourself with the resources listed below.
+    links:
+    - http://www.apache.org/dev/release-publishing.html
+    - http://www.apache.org/legal/release-policy.html
+    - http://www.apache.org/dev/release-signing.html
+  - !Todo
+    id: tools
+    title: Necessary tools are installed
+    description: |
+      You will need these tools:
+
+      * Python v3.4 or later, with dependencies listed in requirements.txt
+      * Go 1.16
+      * gpg
+      * git
+      * svn
+      * asciidoctor (to generate HTML version)
+      * gnu tar (install separately if on OSX)
+      * docker
+      * yq
+      * helm v3
+      * kubectl
+
+      You should also set the $EDITOR environment variable, else we'll fallback to
+      `vi` on Linux and `notepad.exe` on Windows, and you don't want that :)
+    function: check_prerequisites
+    links:
+    - https://gnupg.org/download/index.html
+    - https://asciidoctor.org
+  - !Todo
+    id: gpg
+    title: GPG key id is configured
+    description: |-
+      To sign the release you need to provide your GPG key ID. This must be
+      the same key ID that you have registered in your Apache account.
+      The ID is the last 8 bytes of your key fingerprint, e.g. 0D8D0B93.
+
+      * Make sure your gpg key is 4096 bits key or larger
+      * Upload your key to the MIT key server, pgp.mit.edu
+      * Put you GPG key's fingerprint in the `OpenPGP Public Key Primary Fingerprint`
+        field in your id.apache.org profile
+      * The tests will complain if your GPG key has not been signed by another Solr
+        committer. This makes you a part of the GPG "web of trust" (WoT). Ask a committer
+        that you know personally to sign your key for you, providing them with the
+        fingerprint for the key.
+    function: configure_pgp
+    links:
+    - http://www.apache.org/dev/release-signing.html
+    - http://www.apache.org/dev/openpgp.html#apache-wot
+    - https://id.apache.org
+    - https://dist.apache.org/repos/dist/release/solr/KEYS
+- !TodoGroup
+  id: preparation
+  title: Prepare for the release
+  description: Work with the community to decide when the release will happen and what work must be completed before it can happen
+  todos:
+  - !Todo
+    id: decide_github_issues
+    title: Select Github issues to be included
+    description: Set the appropriate "Milestone" in Github for the issues that should be included in the release.
+  - !Todo
+    id: decide_branch_date
+    title: Decide the date for branching
+    types:
+    - major
+    - minor
+    user_input: !UserInput
+      prompt: Enter date (YYYY-MM-DD)
+      name: branch_date
+  - !Todo
+    id: decide_freeze_length
+    title: Decide the lenght of feature freeze
+    types:
+    - major
+    - minor
+    user_input: !UserInput
+      prompt: Enter end date of feature freeze (YYYY-MM-DD)
+      name: feature_freeze_date
+- !TodoGroup
+  id: branching_versions
+  title: Create branch (if needed) and update versions
+  description: Here you'll do all the branching and version updates needed to prepare for the new release version
+  todos:
+  - !Todo
+    id: clean_git_checkout
+    title: Do a clean git clone to do the release from
+    description: This eliminates the risk of a dirty checkout
+    commands: !Commands
+      root_folder: '{{ release_folder }}'
+      commands_text: Run these commands to make a fresh clone in the release folder
+      remove_files:
+      - '{{ git_checkout_folder }}'
+      commands:
+      - !Command
+        cmd: git clone --progress https://gitbox.apache.org/repos/asf/solr-operator.git solr-operator
+        logfile: git_clone.log
+  - !Todo
+    id: checkout_base_branch
+    title: Checkout branch {{ base_branch }}
+    depends: clean_git_checkout
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: |-
+        From the base branch {{ base_branch }} we'll run lint checks.
+        Fix any problems that are found by pushing fixes to the branch
+        and then running this task again. You can likely fix any errors with `make prepare`.
+        This task will always do `git pull`
+        before `make check` so it will catch changes to your branch :)
+      confirm_each_command: false
+      commands:
+        - !Command
+          cmd: git checkout {{ base_branch }}
+          stdout: true
+        - !Command
+          cmd: git clean -df && git checkout -- .
+          comment: Make sure checkout is clean and up to date
+          logfile: git_clean.log
+          tee: true
+        - !Command
+          cmd: git pull --ff-only
+          stdout: true
+  - !Todo
+    id: install_dependencies
+    title: Install dependencies for this branch of the Solr Operator
+    depends: checkout_base_branch
+    description: This makes sure that your versions of the necessary build tools align with what is expected for this branch.
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: |
+        Run this command to install the necessary dependencies for building the operator.
+        You will likely be asked to provide your password during the installation.
+      commands:
+        - !Command
+          cmd: make install-dependencies
+          logfile: install-dependencies.log
+  - !Todo
+    id: make_lint
+    title: Run make lint and fix issues
+    depends:
+      - clean_git_checkout
+      - install_dependencies
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: |-
+        From the base branch {{ base_branch }} we'll run lint checks.
+        Fix any problems that are found by pushing fixes to the branch
+        and then running this task again. You can likely fix any errors with `make prepare`.
+        This task will always do `git pull`
+        before `make check` so it will catch changes to your branch :)
+      confirm_each_command: false
+      commands:
+      - !Command
+        cmd: git pull --ff-only
+        stdout: true
+      - !Command
+        cmd: "make lint"
+  - !Todo
+    id: create_stable_branch
+    title: Create a new stable branch, off from main
+    description: In our case we'll create {{ stable_branch }}
+    types:
+    - major
+    depends: clean_git_checkout
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run these commands to create a stable branch
+      commands:
+      - !Command
+        cmd: git checkout main
+        tee: true
+      - !Command
+        cmd: git pull --ff-only
+        tee: true
+      - !Command
+        cmd: git ls-remote --exit-code --heads origin {{ stable_branch }}
+        stdout: true
+        should_fail: true
+        comment: We expect error code 2 since {{ stable_branch }} does not already exist
+      - !Command
+        cmd: git checkout -b {{ stable_branch }}
+        tee: true
+      - !Command
+        cmd: git push origin {{ stable_branch }}
+        tee: true
+  - !Todo
+    id: create_minor_branch
+    title: Create a new minor branch off the stable branch
+    description: In our case we'll create {{ release_branch }}
+    types:
+    - major
+    - minor
+    depends: clean_git_checkout
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run these commands to create a release branch
+      commands:
+      - !Command
+        cmd: git checkout {{ stable_branch }}
+        tee: true
+      - !Command
+        cmd: git pull --ff-only
+        tee: true
+      - !Command
+        cmd: git ls-remote --exit-code --heads origin {{ release_branch }}
+        stdout: true
+        should_fail: true
+        comment: This command should fail with exit code 2 to verify branch {{ release_branch }} does not already exist
+      - !Command
+        cmd: git checkout -b {{ release_branch }}
+        tee: true
+      - !Command
+        cmd: git push origin {{ release_branch }}
+        tee: true
+  - !Todo
+    id: add_version_major
+    title: Add a new major version on main branch
+    types:
+    - major
+    depends:
+      - clean_git_checkout
+      - create_minor_branch
+    vars:
+      next_version: "v{{ release_version_major + 1 }}.0.0"
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run these commands to add the new major version {{ next_version }} to the main branch
+      commands:
+      - !Command
+        cmd: git checkout main
+        tee: true
+      - !Command
+        cmd: ./hack/release/version/update_version.sh -v {{ next_version }}
+        tee: true
+      - !Command
+        cmd: ./hack/release/version/propagate_version.sh
+        tee: true
+      - !Command
+        comment: Make sure the edits done by propagating the version are ok, then push
+        cmd: git add -u .  && git commit -m "Add next major version {{ next_version }}"  && git push
+        logfile: commit-stable.log
+  - !Todo
+    id: add_version_minor
+    title: Add a new minor version on stable branch
+    types:
+    - major
+    - minor
+    depends: clean_git_checkout
+    vars:
+      next_version: "v{{ release_version_major }}.{{ release_version_minor + 1 }}.0"
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run these commands to add the new minor version {{ next_version }} to the stable branch
+      commands:
+      - !Command
+        cmd: git checkout {{ stable_branch }}
+        tee: true
+      - !Command
+        cmd: ./hack/release/version/update_version.sh -v {{ next_version }}
+        tee: true
+      - !Command
+        cmd: ./hack/release/version/propagate_version.sh
+        tee: true
+      - !Command
+        comment: Make sure the edits done by propagating the version are ok, then push
+        cmd: git add -u .  && git commit -m "Add next minor version {{ next_version }}"  && git push
+        logfile: commit-stable.log
+#  - !Todo
+#    id: jenkins_builds
+#    title: Add Jenkins task for the release branch
+#    description: '...so that builds run for the new branch. Consult the JenkinsReleaseBuilds page.'
+#    types:
+#    - major
+#    - minor
+#    links:
+#    - https://wiki.apache.org/lucene-java/JenkinsReleaseBuilds
+  - !Todo
+    id: inform_devs
+    title: Inform Devs of the new Release Branch
+    description: |-
+      Send a note to dev@ to inform the committers that the branch
+      has been created and the feature freeze phase has started.
+
+      This is an e-mail template you can use as a basis for
+      announcing the new branch and feature freeze.
+
+      .Mail template
+      ----
+      To: dev@solr.apache.org
+      Subject: New branch and feature freeze for the Solr Operator {{ release_version }}
+
+      NOTICE:
+
+      Branch {{ release_branch }} has been cut and versions updated to v{{ release_version_major }}.{{ release_version_minor + 1 }} on the stable branch.
+
+      Please observe the normal rules:
+
+      * No new features may be committed to the branch.
+      * Documentation patches, build patches and serious bug fixes may be
+        committed to the branch. However, you should submit all issues/PRs you
+        want to commit to Github first to give others the chance to review
+        and possibly vote against the PR. Keep in mind that it is our
+        main intention to keep the branch as stable as possible.
+      * All patches that are intended for the branch should first be committed
+        to the unstable branch, merged into the stable branch, and then into
+        the current release branch.
+      * Normal unstable and stable branch development may continue as usual.
+        However, if you plan to commit a big change to the unstable branch
+        while the branch feature freeze is in effect, think twice: can't the
+        addition wait a couple more days? Merges of bug fixes into the branch
+        may become more difficult.
+      * Only Github issues with Milestone {{ release_version }} and priority "Blocker" will delay
+        a release candidate build.
+      ----
+    types:
+    - major
+    - minor
+  - !Todo
+    id: inform_devs_bugfix
+    title: Inform Devs about the planned release
+    description: |-
+      Send a note to dev@ to inform the committers about the rules for committing to the branch.
+
+      This is an e-mail template you can use as a basis for
+      announcing the rules for committing to the release branch
+
+      .Mail template
+      ----
+      To: dev@solr.apache.org
+      Subject: Bugfix release Solr Operator {{ release_version }}
+
+      NOTICE:
+
+      I am now preparing for a Solr Operator bugfix release from branch {{ release_branch }}
+
+      Please observe the normal rules for committing to this branch:
+
+      * Before committing to the branch, reply to this thread and argue
+        why the fix needs backporting and how long it will take.
+      * All issues accepted for backporting should be marked with Milestone {{ release_version }}
+        in Github, and issues that should delay the release must be marked as Blocker
+      * All patches that are intended for the branch should first be committed
+        to the unstable branch, merged into the stable branch, and then into
+        the current release branch.
+      * Only Github issues with Milestone {{ release_version }} and priority "Blocker" will delay
+        a release candidate build.
+      ----
+    types:
+    - bugfix
+  - !Todo
+    id: draft_release_notes
+    title: Get a draft of the release notes in place
+    description: |-
+      These are typically edited on the Wiki
+
+      Clone a page for a previous version as a starting point for your release notes.
+      Edit the git history and Github Release into a more concise format for public consumption.
+      Ask on dev@ for input. Ideally the timing of this request mostly coincides with the
+      release branch creation. It's a good idea to remind the devs of this later in the release too.
+
+      NOTE: Do not add every single Github issue, but distill the Release note into important changes!
+    links:
+    - https://cwiki.apache.org/confluence/display/SOLR/Solr+Operator+Release+Notes
+    - https://github.com/apache/solr-operator/milestones/{{ release_version }}
+    - https://github.com/apache/solr-operator/commits/{{ base_branch }}
+  - !Todo
+    id: new_github_milestone_versions
+    title: Add a new milestone in Github for the next release
+    # FOR-MAJOR-RELEASE: change minor -> major below.
+    description: |-
+      Go to the Github Milestones page and add the new version:
+
+      {% if release_type == 'minor' %}
+      - Change name of version `main ({{ release_version }})` into `{{ release_version }}`
+      - Create a new (unreleased) version `main ({{ get_next_version }})`
+      {% else %}
+      - Create a new (unreleased) version `{{ get_next_version }}`
+      {% endif %}
+    types:
+    - major
+    - minor
+    links:
+    - https://github.com/apache/solr-operator/milestones
+- !TodoGroup
+  id: artifacts
+  title: Build the release artifacts
+  description: |-
+    If after the last day of the feature freeze phase no blocking issues are
+    in Github with "Milestone" {{ release_version }}, then it's time to build the
+    release artifacts, run the smoke tester and stage the RC in svn
+  depends:
+  - test
+  - prerequisites
+  is_in_rc_loop: true
+  todos:
+  - !Todo
+    id: run_tests
+    title: Run tests & lint
+    depends: clean_git_checkout
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands:
+      - !Command
+        cmd: git checkout {{ release_branch }}
+        stdout: true
+      - !Command
+        cmd: "make check"
+    post_description: Check that the task passed. If it failed, commit fixes for the failures before proceeding.
+  - !Todo
+    id: helm_change_log
+    title: Add changelog to the helm chart
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands:
+        - !Command
+          cmd: git checkout {{ release_branch }}
+          tee: true
+        - !Command
+          cmd: git clean -df && git checkout -- .
+          comment: Make sure checkout is clean and up to date
+          logfile: git_clean.log
+          tee: true
+        - !Command
+          cmd: make clean
+          stdout: true
+        - !Command
+          cmd: "{{ editor }} helm/solr-operator/Chart.yaml"
+          comment: |
+            Add changelog at annotations.'artifacthub.io/changes', in helm/solr-operator/Chart.yaml.
+            This will replace the "Change 1" and "Change 2" lines.
+            You can only input the changelog as a single-layer bulleted list.
+          tee: true
+        - !Command
+          cmd: git commit -am "Solr Operator {{ release_version }} Changelog"
+          logfile: commit.log
+          stdout: true
+        - !Command
+          cmd: git push origin {{ release_branch }}
+          logfile: git_push_changelog.log
+          tee: true
+  - !Todo
+    id: build_rc
+    title: Build the release candidate and issue final commit
+    depends:
+      - run_tests
+      - helm_change_log
+      - gpg
+    vars:
+      logfile: '{{ [rc_folder, ''logs'', ''buildAndPushRelease.log''] | path_join }}'
+      local_keys: '{% if keys_downloaded %} --local-keys "{{ [config_path, ''KEYS''] | path_join }}"{% endif %}'
+      # Note, the following vars will be recorded in todo state AFTER completion of commands
+      git_rev: '{{ current_git_rev }}'
+      rc_folder: 'solr-operator-{{ release_version }}-RC{{ rc_number }}-rev{{ current_git_rev | default("<git_rev>", True) }}'
+      git_sha: '{{ current_git_rev | default("<git_sha>", True) | truncate(7,true,"") }}'
+    persist_vars:
+    - git_rev
+    - rc_folder
+    - git_sha
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: |-
+        In this step we will build the RC artifacts.
+      confirm_each_command: false
+      commands:
+      - !Command
+        cmd: git checkout {{ release_branch }}
+        tee: true
+      - !Command
+        cmd: git clean -df && git checkout -- .
+        comment: Make sure checkout is clean and up to date
+        logfile: git_clean.log
+        tee: true
+      - !Command
+        cmd: git pull --ff-only
+        tee: true
+      - !Command
+        cmd: make clean
+        tee: true
+      - !Command
+        cmd: ./hack/release/version/change_suffix.sh
+        comment: Remove the prerelase suffix from the version.
+        stdout: true
+      - !Command
+        cmd: ./hack/release/version/propagate_version.sh
+        comment: Propagate the new version throughout the repo.
+        logfile: propagate_version.log
+        tee: true
+      - !Command
+        cmd: git commit -am "Solr Operator {{ release_version }} Release"
+        comment: "We are only committing with the new version, we will not push to the release branch until after the vote has succeeded."
+        logfile: commit.log
+        stdout: true
+      - !Command
+        vars:
+          rc_folder: 'solr-operator-{{ release_version }}-RC{{ rc_number }}-rev{{ current_git_rev | default("<git_rev>", True) }}'
+        env:
+          TAG: "{{ release_version }}-rc{{ rc_number }}"
+          ARTIFACTS_DIR: "{{ [dist_file_path, rc_folder] | path_join }}"
+          APACHE_ID: "{{ gpg.apache_id | default('<apache_id>', True) }}"
+          GPG_KEY: "{{ gpg_key | default('<gpg_key_id>', True) }}"
+        cmd: make build-release-artifacts
+        comment: "NOTE: Remember to type your GPG pass-phrase at the prompt!"
+        logfile: build_rc.log
+        tee: true
+  - !Todo
+    id: smoke_tester
+    title: Run the smoke tester
+    depends: build_rc
+    vars:
+      dist_path: '{{ [dist_file_path, (build_rc.rc_folder | default("<rc_folder>", True))] | path_join }}'
+      docker_image: 'apache/solr-operator:{{ release_version }}-rc{{ rc_number }}'
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Here we'll smoke test the release by 'downloading' the artifacts, running the tests, validating GPG signatures etc.
+      commands:
+      - !Command
+        cmd: ./hack/release/smoke_test/smoke_test.sh -v "{{ release_version }}" -s "{{ build_rc.git_sha | default("<git_sha>", True) }}" -i "{{ docker_image }}" -l "{{ dist_path }}"
+        logfile: smoketest.log
+        tee: true
+        stdout: true
+  - !Todo
+    id: upload_rc_docker_image
+    title: Upload RC Docker image
+    depends: smoke_tester
+    vars:
+      docker_image: 'apache/solr-operator:{{ release_version }}-rc{{ rc_number }}'
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Upload the docker image to DockerHub
+      commands:
+        - !Command
+          cmd: docker push "{{ docker_image }}"
+          logfile: rc_docker_upload.log
+  - !Todo
+    id: import_svn
+    title: Import artifacts into SVN
+    description: |
+      Here we'll import the artifacts into Subversion.
+    depends: smoke_tester
+    vars:
+      dist_path: '{{ [dist_file_path, (build_rc.rc_folder | default("<rc_folder>", True))] | path_join }}'
+      dist_url: https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Have your Apache credentials handy, you'll be prompted for your password
+      commands:
+      - !Command
+        cmd: svn -m "Solr Operator {{ release_version }} RC{{ rc_number }}" import  {{ dist_path }}  {{ dist_url }}
+        logfile: import_svn.log
+        tee: true
+  - !Todo
+    id: verify_staged
+    title: Verify staged artifacts
+    description: |
+      A lightweight smoke testing which downloads the artifacts from stage 
+      area and checks hash and signatures, but does not re-run all tests.
+    depends: import_svn
+    vars:
+      dist_url: https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}
+      docker_image: 'apache/solr-operator:{{ release_version }}-rc{{ rc_number }}'
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Here we'll verify that the staged artifacts are downloadable and hash/signatures match.
+      commands:
+      - !Command
+        cmd: ./hack/release/smoke_test/smoke_test.sh -p -v "{{ release_version }}" -s "{{ build_rc.git_sha | default("<git_sha>", True) }}" -i "{{ docker_image }}" -l "{{ dist_url }}"
+        logfile: smoketest_staged.log
+- !TodoGroup
+  id: voting
+  title: Hold the vote and sum up the results
+  description: These are the steps necessary for the voting process. Read up on the link first!
+  is_in_rc_loop: true
+  todos:
+  - !Todo
+    id: initiate_vote
+    title: Initiate the vote
+    depends: verify_staged
+    description: |
+      If the smoke test passes against the staged artifacts, send an email to the dev mailing list announcing the release candidate.
+
+      .Mail template
+      ----
+      To: dev@solr.apache.org
+      Subject: [VOTE] Release the Solr Operator {{ release_version }} RC{{ rc_number }}
+
+      Please vote for release candidate {{ rc_number }} for the Solr Operator {{ release_version }}
+
+      The artifacts can be downloaded from:
+      https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}
+
+      The artifacts are layed out in the following way:
+        * solr-operator-{{ release_version }}.tgz - Contains the source release
+        * crds/ - Contains the CRD files
+        * helm/ - Contains the Helm release packages
+
+      The RC Docker image can be found at:
+        apache/solr-operator:{{ release_version }}-rc{{ rc_number }}
+
+      The RC Helm repo can be added with:
+        helm repo add solr-operator-rc https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}/helm-charts
+
+      Install the RC Solr Operator and CRDs with:
+        kubectl create -f https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}/crds/all-with-dependencies.yaml
+        helm install --verify solr-operator solr-operator-rc/solr-operator --set image.tag={{ release_version }}-rc{{ rc_number }}
+
+      You can run the smoke tester directly with this command:
+
+      ./hack/release/smoke_test/smoke_test.sh -p -v "{{ release_version }}" -s "{{ build_rc.git_sha | default("<git_sha>", True) }}" -i "apache/solr-operator:{{ release_version }}-rc{{ rc_number }}" \
+          -l 'https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}'
+
+      Make sure you have the following installed before running the smoke test:
+        - Docker
+        - Go 1.16
+        - Kubectl
+        - GnuPG
+        - Helm
+        - yq
+        - jq
+
+      The vote will be open for at least 72 hours i.e. until {{ vote_close }}.
+
+      [ ] +1  approve
+      [ ] +0  no opinion
+      [ ] -1  disapprove (and reason why)
+
+      Here is my +1
+      ----
+
+      {% if vote_close_72h_holidays %}
+      [IMPORTANT]
+      ====
+      The voting period contains one or more holidays. Please consider extending the vote deadline.
+
+      {% for holiday in vote_close_72h_holidays %}* {{ holiday }}
+      {% endfor %}
+      ====
+      {%- endif %}
+    vars:
+      vote_close: '{{ vote_close_72h }}'
+      vote_close_epoch: '{{ vote_close_72h_epoch }}'
+    persist_vars:
+    - vote_close
+    - vote_close_epoch
+    function: create_ical
+    links:
+    - https://www.apache.org/foundation/voting.html
+  - !Todo
+    id: end_vote
+    title: End vote
+    depends: initiate_vote
+    description: |
+      At the end of the voting deadline, count the votes and send RESULT message to the mailing list.
+
+      {% set vote_close_epoch = initiate_vote.vote_close_epoch | int %}
+      {% if epoch < vote_close_epoch %}
+      WARNING: The planned voting deadline {{ initiate_vote.vote_close }} has not yet passed
+      {% else %}
+      The planned 72h voting deadline {{ initiate_vote.vote_close }} has passed.
+      {% endif %}
+    asciidoc: |
+      (( template=vote_macro ))
+      Note down how many votes were cast, summing as:
+
+      * Binding PMC-member +1 votes
+      * Non-binding +1 votes
+      * Neutral +/-0 votes
+      * Negative -1 votes
+
+      You need 3 binding +1 votes and more +1 than -1 votes for the release to happen.
+      A release cannot be vetoed, see more in provided links.
+
+      Here are some mail templates for successful and failed vote results with sample numbers:
+
+      {{ end_vote_result(3,1,0,2) }}
+
+      {{ end_vote_result(3,1,0,4) }}
+
+      {{ end_vote_result(2,9,0,0) }}
+    user_input:
+    - !UserInput
+      type: int
+      prompt: Number of binding +1 votes (PMC members)
+      name: plus_binding
+    - !UserInput
+      type: int
+      prompt: Number of other +1 votes
+      name: plus_other
+    - !UserInput
+      type: int
+      prompt: Number of 0 votes
+      name: zero
+    - !UserInput
+      type: int
+      prompt: Number of -1 votes
+      name: minus
+    post_description: |
+        (( template=vote_logic ))
+        (( template=vote_macro ))
+        {% if passed -%}
+        Congratulations! The vote has passed.
+
+        {% if minus > 0 %}
+        However, there were negative votes. A release cannot be vetoed, and as long as
+        there are more positive than negative votes you can techically release
+        the software. However, please review the negative votes and consider
+        a re-spin.
+        
+        {% endif %}
+        {%- endif %}
+        {{ end_vote_result(plus_binding,plus_other,zero,minus) }}
+    links:
+    - https://www.apache.org/foundation/voting.html
+- !TodoGroup
+  id: publish
+  title: Publishing to the ASF Mirrors
+  description: Once the vote has passed, the release may be published to the ASF Mirrors and to Maven Central.
+  todos:
+  - !Todo
+    id: tag_release
+    title: Tag the release
+    description: Tag the release from the same revision from which the passing release candidate's was built
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: This will tag the release in git
+      logs_prefix: tag_release
+      commands:
+      - !Command
+        cmd: git tag -a {{ release_version }}  -m "Solr Operator {{ release_version }} release"  {{ build_rc.git_rev | default("<git_rev>", True) }}
+        logfile: git_tag.log
+        tee: true
+      - !Command
+        cmd: git push origin {{ release_version }}
+        logfile: git_push_tag.log
+        tee: true
+  - !Todo
+    id: increment_release_version
+    title: Add the next version on release branch
+    description: Add the next version after the just-released version on the release branch
+    depends: tag_release
+    vars:
+      next_version: "v{{ release_version_major }}.{{ release_version_minor }}.{{ release_version_bugfix + 1 }}"
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run these commands to add the new bugfix version {{ next_version }} to the release branch
+      commands:
+        - !Command
+          cmd: git checkout {{ release_branch }}
+          tee: true
+        - !Command
+          cmd: ./hack/release/version/update_version.sh -v {{ next_version }}
+          tee: true
+        - !Command
+          cmd: ./hack/release/version/change_suffix.sh -s "prerelease"
+          tee: true
+        - !Command
+          cmd: ./hack/release/version/propagate_version.sh
+          tee: true
+        - !Command
+          cmd: ./hack/release/version/remove_version_specific_info.sh
+          tee: true
+        - !Command
+          cmd: git diff
+          logfile: diff.log
+          comment: Check the git diff before committing. Do any edits if necessary
+          tee: true
+        - !Command
+          cmd: git add -u .  && git commit -m "Add next patch version {{ next_version }}"
+          logfile: commit-release-next-version.log
+        - !Command
+          cmd: git push {{ release_branch }}
+          comment: |
+            This will push both the release commit as well as the commit to increase version.
+            The release commit will correspond to the previously pushed tag.
+          logfile: push-release-next-version.log
+  - !Todo
+    id: svn_release_mv
+    title: Move release artifacts from staging to release
+    vars:
+      dist_stage_url: https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}
+      dist_release_url: https://dist.apache.org/repos/dist/release/solr/solr-operator/{{ release_version }}
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands_text: This will move the new release artifacts from staging repo to the release repo
+      commands:
+      - !Command
+        cmd: svn move -m "Move Solr Operator {{ release_version }} RC{{ rc_number }} to release repo"  {{ dist_stage_url }}  {{ dist_release_url }}
+        logfile: svn_mv_solr_operator.log
+        tee: true
+  - !Todo
+    id: publish_helm_charts
+    title: Publish the staged Helm charts
+    depends: svn_release_mv
+    vars:
+      dist_release_url: https://dist.apache.org/repos/dist/release/solr/solr-operator/{{ release_version }}
+      official_helm_charts_url: https://nightlies.apache.org/solr/release/helm-charts
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands_text: This will upload the staged Helm chart to the release location in nightlies.apache.org
+      commands:
+        - !Command
+          cmd: ./hack/release/upload/upload_helm.sh -g "{{ gpg_key | default("<gpg_key_id>", True) }}" -a "{{ gpg.apache_id | default("<apache_id>", True) }}" -c "{{ official_helm_charts_url }}" -r "{{ dist_release_url }}"
+          logfile: upload_helm.log
+          tee: true
+  - !Todo
+    id: publish_crds
+    title: Publish the staged CRDs
+    depends: svn_release_mv
+    vars:
+      dist_release_url: https://dist.apache.org/repos/dist/release/solr/solr-operator/{{ release_version }}
+      crds_release_url: https://nightlies.apache.org/solr/release/operator/crds
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands_text: This will upload the staged CRDs to the release location in nightlies.apache.org
+      commands:
+        - !Command
+          cmd: ./hack/release/upload/upload_crds.sh  -a "{{ gpg.apache_id | default('<apache_id>', True) }}" -c "{{ crds_release_url }}" -r "{{ dist_release_url }}" -v "{{ release_version }}"
+          logfile: upload_crds.log
+          tee: true
+  - !Todo
+    id: cleanup_svn_rc_folder
+    title: Cleanup RC folder in SVN
+    depends:
+      - svn_release_mv
+    vars:
+      dist_stage_url: https://dist.apache.org/repos/dist/dev/solr/solr-operator/{{ build_rc.rc_folder | default("<rc_folder>", True) }}
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands_text: This will remove the release artifacts from staging repo
+      commands:
+        - !Command
+          cmd: svn rm -m "Clean up the RC folder for {{ release_version }} RC{{ rc_number }}"  {{ dist_stage_url }}
+          logfile: svn_rm_containing.log
+          comment: Clean up containing folder on the staging repo
+          tee: true
+  - !Todo
+    id: publish_docker_image
+    title: Publish the staged Docker image
+    vars:
+      rc_tag: '{{ release_version }}-rc{{ rc_number }}'
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      confirm_each_command: false
+      commands_text: This will pull the RC Docker image, tag it as a release, and push the release image to DockerHub.
+      commands:
+        - !Command
+          cmd: docker pull "apache/solr-operator:{{ rc_tag }}"
+          logfile: docker_pull.log
+          tee: true
+        - !Command
+          cmd: docker tag "apache/solr-operator:{{ rc_tag }}" "apache/solr-operator:{{ release_version }}"
+          tee: true
+        - !Command
+          cmd: docker push "apache/solr-operator:{{ release_version }}"
+          logfile: docker_push.log
+          tee: true
+  - !Todo
+    id: check_mirroring
+    title: Check state of mirroring so far
+    description: Mark this as complete once a good spread is confirmed
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Run this script to check the number and percentage of mirrors that have the release
+      commands:
+      - !Command
+        cmd: python3 -u hack/release/wizard/poll-mirrors.py -version {{ release_version }} -o
+        live: true
+- !TodoGroup
+  id: website
+  title: Update the website
+  description: |
+    For every release, we publish docs on the website, we need to update the
+    download pages etc. The website is hosted in https://github.com/apache/solr-site
+#    but the Javadocs and Solr Reference Guide are pushed to SVN and then included
+#    in the main site through links.
+  todos:
+#  - !Todo
+#    id: website_docs
+#    title: Publish docs, changes and javadocs
+#    description: |
+#      Ensure your refrigerator has at least 2 beers - the svn import operation can take a while,
+#      depending on your upload bandwidth. We'll publish this directly to the production tree.
+#      At the end of the task, the two links below shall work.
+#    links:
+#    - http://solr.apache.org/operator/{{ version }}
+#    vars:
+#      version: "{{ release_version_major }}_{{ release_version_minor }}_{{ release_version_bugfix }}"
+#    commands: !Commands
+#      root_folder: '{{ git_checkout_folder }}'
+#      commands_text: Build the documentation and add it to SVN production tree
+#      commands:
+#      - !Command
+#        cmd: git fetch && git checkout {{ release_version }}
+#        comment: Checkout the release branch
+#        logfile: checkout-release-tag.log
+#        tee: true
+#      - !Command
+#        cmd: "{{ gradle_cmd }} documentation -Dversion.release={{ release_version }}"
+#        comment: Build documentation
+#      - !Command
+#        cmd: svn -m "Add docs, changes and javadocs for Solr {{ release_version }}"  import {{ git_checkout_folder }}/solr/build/docs  https://svn.apache.org/repos/infra/websites/production/lucene/content/solr/{{ version }}
+#        logfile: add-docs-solr.log
+#        comment: Add docs for Solr
+  - !Todo
+    id: website_git_clone
+    title: Do a clean git clone of the website repo
+    description: This is where we'll commit later updates for the website.
+    commands: !Commands
+      root_folder: '{{ release_folder }}'
+      commands_text: Run this command to clone the website git repo
+      remove_files:
+        - '{{ git_website_folder }}'
+      commands:
+        - !Command
+          cmd: git clone --progress https://gitbox.apache.org/repos/asf/solr-site.git solr-site
+          logfile: website_git_clone.log
+  - !Todo
+    id: website_update_versions
+    title: Update website versions
+    depends: website_git_clone
+    description: |
+      We need to update the website so that the download pages list the new release.
+
+      Fortunately the only thing you need to change is a few variables in `pelicanconf.py`.
+      If you release a current latest release, change the `SOLR_OPERATOR_LATEST_RELEASE` and `SOLR_OPERATOR_LATEST_RELEASE_DATE`
+      variables.
+      If you relese a bugfix release for previous version, then change the `SOLR_OPERATOR_PREVIOUS_MAJOR_RELEASE` variable.
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands_text: Edit pelicanconf.py to update version numbers
+      commands:
+        - !Command
+          cmd: "{{ editor }} pelicanconf.py"
+          comment: Edit the pelicanconf.file. Make sure to include the "v" prefix in the version.
+          stdout: true
+        - !Command
+          cmd: git commit -am "Update version variables for release {{ release_version }}"
+          logfile: commit.log
+          stdout: true
+    post_description: |
+      You will push and verify all changes in a later step
+  - !Todo
+    id: prepare_announce
+    title: Author the Solr Operator release news
+    depends: website_git_clone
+    description: |
+      Edit a news text for the Solr Operator website. This text will be the basis for the release announcement email later.
+      This step will open an editor with a template. You will need to copy/paste the final release announcement text
+      from the Wiki page and format it as Markdown.
+    function: prepare_announce
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands_text: |
+        Copy the Solr Operator announcement from https://cwiki.apache.org/confluence/display/SOLR/Solr+Operator+Release+Notes
+        You have to exit the editor after edit to continue.
+      commands:
+      - !Command
+        cmd: "{{ editor }} {{ solr_operator_news_file }}"
+        comment: Edit the for Solr Operator announcement news
+        stdout: true
+      - !Command
+        cmd: git add . && git commit -m "Adding news for Solr Operator release {{ release_version }}"
+        logfile: commit.log
+        stdout: true
+    post_description: |
+      You will push and verify all changes in a later step
+  - !Todo
+    id: website_update_doap
+    title: Update the Solr Opertator DOAP file
+    depends:
+      - website_update_versions
+      - prepare_announce
+    description: Update the Solr Operator DOAP RDF file to reflect the new version.
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands_text: Edit DOAP file
+      commands:
+        - !Command
+          cmd: "{{ editor }} content/doap/solr-operator.rdf"
+          comment: Edit Solr Operator DOAP, add version {{ release_version }}
+          stdout: true
+        - !Command
+          cmd: git add content/doap/solr-operator.rdf  && git commit -m "DOAP changes for Solr Operator release {{ release_version }}"
+          logfile: commit.log
+          stdout: true
+  - !Todo
+    id: update_other
+    title: Update rest of webpage
+    depends: website_update_doap
+    description: |
+      Update the rest of the web page. Please review all files in the checkout
+      and consider if any need change based on what changes there are in the
+      release you are doing. Things to consider:
+
+      * System requirements
+      * Quickstart and tutorial?
+
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands_text: |
+        We'll open an editor on the root folder of the site checkout
+        You have to exit the editor after edit to continue.
+      commands:
+        - !Command
+          cmd: "{{ editor }} ."
+          comment: Open an editor on the root folder
+          stdout: true
+        - !Command
+          cmd: git commit -am "Other website changes for Solr Operator release {{ release_version }}"
+          comment: Commit the other changes
+          logfile: commit.log
+          stdout: true
+  - !Todo
+    id: stage_website
+    title: Stage the website changes
+    depends:
+      - prepare_announce
+      - website_update_doap
+      - website_update_versions
+    description: |
+      Push the website changes to 'main' branch, and check the staging site.
+      You will get a chance to preview the diff of all changes before you push.
+      If you need to do changes, do the changes (e.g. by re-running previous step 'Update rest of webpage')
+      and commit your changes. Then re-run this step and push when everything is OK.
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands_text: |
+        Verify that changes look good, and then publish.
+        You have to exit the editor after review to continue.
+      commands:
+      - !Command
+        cmd: git checkout main && git status
+        stdout: true
+      - !Command
+        cmd: git diff
+        redirect: "{{ [release_folder, 'website.diff'] | path_join }}"
+        comment: Make a diff of all edits. Will open in next step
+      - !Command
+        cmd: "{{ editor }} {{ [release_folder, 'website.diff'] | path_join }}"
+        comment: View the diff of the website changes. Abort if you need to do changes.
+        stdout: true
+      - !Command
+        cmd: git push origin
+        comment: Push all changes
+        logfile: push-website.log
+    post_description: |
+      Wait a few minutes for the build to happen. You can follow the site build at https://ci2.apache.org/#/builders/3
+      and view the staged site at https://solr.staged.apache.org/operator
+      Verify that correct links and versions are mentioned in download pages, download buttons etc.
+      If you find anything wrong, then commit and push any changes and check again.
+
+      Next step is to merge the changes to branch 'production' in order to publish the site.
+      Make sure that you do "Merge with Commit", do NOT use a Squash Merge.
+    links:
+    - https://ci2.apache.org/#/builders/3
+    - https://solr.staged.apache.org/operator
+  - !Todo
+    id: publish_website
+    title: Publish the website changes
+    depends: stage_website
+    description: |
+      Push the website changes to 'production' branch. This will build and publish the live site on
+      https://solr.apache.org/operator
+    commands: !Commands
+      root_folder: '{{ git_website_folder }}'
+      commands:
+        - !Command
+          cmd: git checkout production && git pull --ff-only
+          stdout: true
+        - !Command
+          cmd: git merge main
+          stdout: true
+        - !Command
+          cmd: git push origin
+          comment: Push all changes to production branch
+          logfile: push-website.log
+    post_description: |
+      Wait a few minutes for the build to happen. You can follow the site build at https://ci2.apache.org/#/builders/3
+
+      Verify on https://solr.apache.org/operator that the site is OK.
+    links:
+      - https://ci2.apache.org/#/builders/3
+      - https://solr.apache.org
+      - https://solr.apache.org/operator
+- !TodoGroup
+  id: announce
+  title: Announce the release
+  description: |
+    For feature releases, your announcement should describe the main features included 
+    in the release. *Send the announce as Plain-text email, not HTML.*
+
+    This step will generate email templates based on the news files you edited earler for the website.
+    Do any last-minute necessary edits to the text as you copy it over to the email.
+  todos:
+  - !Todo
+    id: announce_solr_operator
+    title: Announce the Solr Operator release (@s.a.o)
+    description: |
+      (( template=announce_solr_operator_mail ))
+  - !Todo
+    id: setup_pgp_mail
+    title: Setup your mail client for PGP
+    description: |
+      The announce mail to `announce@apache.org` should be cryptographically signed.
+      Make sure you have a PGP enabled email client with your apache key installed.
+      There are plugins for popular email programs, as well as browser plugins for webmail.
+      See links for help on how to setup your email client for PGP.
+
+      If you prefer to sign the announcements manually rather than using a plugin,
+      you can do so from the command line and then copy the output into your mail program.
+
+      gpg --output - --clearsign solr_operator_announce.txt
+    links:
+    - https://www.openpgp.org/software/
+    - https://ssd.eff.org/en/module/how-use-pgp-mac-os-x
+    - https://ssd.eff.org/en/module/how-use-pgp-linux
+    - https://ssd.eff.org/en/module/how-use-pgp-windows
+    - https://www.openpgp.org/software/mailvelope/
+  - !Todo
+    id: announce_solr_operator_sig
+    title: Announce the Solr Operator release (announce@a.o)
+    description: |
+      (( template=announce_solr_operator_sign_mail ))
+#  - !Todo
+#    id: add_to_wikipedia
+#    title: Add the new version to Wikipedia
+#    description: |
+#      Go to Wikipedia and edit the page to include the new release.
+#      Major versions should have a small new paragraph under 'History'.
+#      If you know other languages than English, edit those as well.
+#    links:
+#    - https://en.wikipedia.org/wiki/Apache_Solr
+  - !Todo
+    id: add_to_apache_reporter
+    title: Add the new version to the Apache Release Reporter
+    description: |
+      Go to the Apache Release Reporter and add a release for the Solr Operator.
+      Fill in the same date that you used for the release in previous steps.
+      Use a product name prefix for the version, as this is not the main release of the Solr PMC.
+      The version you input should be:
+        solr-operator-{{ release_version }}
+    links:
+      - https://reporter.apache.org/addrelease.html?solr
+- !TodoGroup
+  id: post_release
+  title: Tasks to do after release.
+  description: There are many more tasks to do now that the new version is out there, so hang in there for a few more hours.
+  todos:
+  - !Todo
+    id: github_issue_unfinished
+    title: Remove all unfinished Issues and PRs from the Github Milestone
+    description: |-
+      Go to Github and make sure that all of the Issues listed under the Milestone are either closed, or removed from the Milestone.
+
+      . Go to https://github.com/apache/solr-operator/milestones/{{ release_version }}
+      . If there are any Issues/PRs that are still open, then go to them and either:
+        . Close the Issue if it is actually completed
+        . Remove the Milestone from the Issue/PR if it is still not complete
+    links:
+    - https://github.com/apache/solr-operator/milestones/{{ release_version }}
+  - !Todo
+    id: github_milestone_close
+    title: Mark milestone as closed in Github
+    depends: github_issue_unfinished
+    description: |-
+      Go to the Github "Manage Milestones" Administration pages.
+
+      . Find version {{ release_version }}, click "edit" under the progress bar
+      . Set the "Due Date" to the release date of this version
+      . Click "Close milestone"
+
+    links:
+      - https://github.com/apache/solr-operator/milestones?state=open
+#  - !Todo
+#    id: jira_clear_security
+#    title: Clear Security Level of Public Solr JIRA Issues
+#    description: |-
+#      ASF JIRA has a deficiency in which issues that have a security level of "Public" are nonetheless not searchable.
+#      As a maintenance task, we'll clear the security flag for all public Solr JIRAs, even if it is not a task directly
+#      related to the release:
+#
+#      . Open in browser: https://issues.apache.org/jira/issues/?jql=project+=+SOLR+AND+level+=+%22Public%22
+#      . In the `Tools` menu, start a bulk change, select all issues and click `Next`
+#      . Select operation="Edit issues" and click `Next`
+#      . Click the checkbox next to `Change security level` and choose `None` in the dropdown.
+#      . On the bottom of the form, uncheck the box that says `Send mail for this update`
+#      . Click `Next`, review the changes and click `Confirm`
+#    links:
+#    - https://issues.apache.org/jira/issues/?jql=project+=+SOLR+AND+level+=+%22Public%22
+  - !Todo
+    id: new_github_milestone_version_bugfix
+    title: Add a new milestone in Github for the next release
+    description: |-
+      Go to the Github Milestones page and add the new version:
+
+      - Create a new (unreleased) version `{{ get_next_version }}`
+    types:
+      - bugfix
+    links:
+      - https://github.com/apache/solr-operator/milestones
+  - !Todo
+    id: stop_mirroring
+    title: Stop mirroring old releases
+    description: |
+      Shortly after new releases are first mirrored, they are automatically copied to the archives. 
+      Only the latest point release from each active branch should be kept under the Solr PMC
+      svnpubsub areas `dist/releases/solr/solr-operator`. Older releases can be
+      safely deleted, since they are already backed up in the archives.
+
+      Currenlty these versions are in the mirrors:
+
+      *{{ mirrored_versions|join(', ') }}*
+
+      The commands below will remove old versions automatically. If this suggestion is wrong,
+      please do *not* execute the commands automatically, but edit the command and run manually.
+      Versions to be deleted from the mirrors are:
+
+      *{{ mirrored_versions_to_delete|join(', ') }}*
+
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: |
+        Run these commands to delete proposed versions from mirrors.
+
+        WARNING: Validate that the proposal is correct!
+      commands:
+      - !Command
+        cmd: |
+          svn rm -m "Stop mirroring old Solr Operator releases"{% for ver in mirrored_versions_to_delete %}  https://dist.apache.org/repos/dist/release/solr/solr-operator/{{ ver }}{% endfor %}
+        logfile: svn-rm-solr-operator.log
diff --git a/hack/release/wizard/requirements.txt b/hack/release/wizard/requirements.txt
new file mode 100644
index 0000000..956cf9d
--- /dev/null
+++ b/hack/release/wizard/requirements.txt
@@ -0,0 +1,7 @@
+six>=1.11.0
+Jinja2>=2.10.1
+PyYAML>=5.1
+holidays>=0.9.10
+ics>=0.4
+console-menu>=0.5.1
+PyGithub
\ No newline at end of file
diff --git a/hack/release/wizard/scriptutil.py b/hack/release/wizard/scriptutil.py
new file mode 100644
index 0000000..85ab718
--- /dev/null
+++ b/hack/release/wizard/scriptutil.py
@@ -0,0 +1,190 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import argparse
+import re
+import subprocess
+import sys
+import os
+from enum import Enum
+import time
+import urllib.request, urllib.error, urllib.parse
+import urllib.parse
+
+class Version(object):
+  def __init__(self, major, minor, bugfix, prerelease):
+    self.major = major
+    self.minor = minor
+    self.bugfix = bugfix
+    self.prerelease = prerelease
+    self.previous_dot_matcher = self.make_previous_matcher()
+    self.dot_no_v = '%d.%d.%d' % (self.major, self.minor, self.bugfix)
+    self.dot = 'v%s' % (self.dot_no_v)
+    self.constant = 'SOLR_OPERATOR_%d_%d_%d' % (self.major, self.minor, self.bugfix)
+
+  @classmethod
+  def parse(cls, value):
+    match = re.search(r'(?:v)?(\d+)\.(\d+).(\d+)(.1|.2)?', value)
+    if match is None:
+      raise argparse.ArgumentTypeError('Version argument must be of format vX.Y.Z(.1|.2)?')
+    parts = [int(v) for v in match.groups()[:-1]]
+    parts.append({ None: 0, '.1': 1, '.2': 2 }[match.groups()[-1]])
+    return Version(*parts)
+
+  def __str__(self):
+    return self.dot
+
+  def make_previous_matcher(self, prefix='', suffix='', sep='\\.'):
+    if self.is_bugfix_release():
+      pattern = '%s%s%s%s%d' % (self.major, sep, self.minor, sep, self.bugfix - 1)
+    elif self.is_minor_release():
+      pattern = '%s%s%d%s\\d+' % (self.major, sep, self.minor - 1, sep)
+    else:
+      pattern = '%d%s\\d+%s\\d+' % (self.major - 1, sep, sep)
+
+    return re.compile(prefix + '(' + pattern + ')' + suffix)
+
+  def is_bugfix_release(self):
+    return self.bugfix != 0
+
+  def is_minor_release(self):
+    return self.bugfix == 0 and self.minor != 0
+
+  def is_major_release(self):
+    return self.bugfix == 0 and self.minor == 0
+
+  def no_major_release(self):
+    return self.major == 0
+
+  def on_or_after(self, other):
+    return (self.major > other.major or self.major == other.major and
+           (self.minor > other.minor or self.minor == other.minor and
+           (self.bugfix > other.bugfix or self.bugfix == other.bugfix and
+           self.prerelease >= other.prerelease)))
+
+  def gt(self, other):
+    return (self.major > other.major or
+           (self.major == other.major and self.minor > other.minor) or
+           (self.major == other.major and self.minor == other.minor and self.bugfix > other.bugfix))
+
+  def is_back_compat_with(self, other):
+    if not self.on_or_after(other):
+      raise Exception('Back compat check disallowed for newer version: %s < %s' % (self, other))
+    return other.major + 1 >= self.major
+
+def run(cmd, cwd=None):
+  try:
+    output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT, cwd=cwd)
+  except subprocess.CalledProcessError as e:
+    print(e.output.decode('utf-8'))
+    raise e
+  return output.decode('utf-8') 
+
+def update_file(filename, line_re, edit):
+  infile = open(filename, 'r')
+  buffer = [] 
+  
+  changed = False
+  for line in infile:
+    if not changed:
+      match = line_re.search(line)
+      if match:
+        changed = edit(buffer, match, line)
+        if changed is None:
+          return False
+        continue
+    buffer.append(line)
+  if not changed:
+    raise Exception('Could not find %s in %s' % (line_re, filename))
+  with open(filename, 'w') as f:
+    f.write(''.join(buffer))
+  return True
+
+
+# branch types are "release", "stable" and "unstable"
+class BranchType(Enum):
+  unstable = 1
+  stable   = 2
+  release  = 3
+
+def find_branch_type():
+  output = subprocess.check_output('git status', shell=True)
+  for line in output.split(b'\n'):
+    if line.startswith(b'On branch '):
+      branchName = line.split(b' ')[-1]
+      break
+  else:
+    raise Exception('git status missing branch name')
+
+  if branchName == b'main':
+    return BranchType.unstable
+  if re.match(r'branch_(\d+)x', branchName.decode('UTF-8')):
+    return BranchType.stable
+  if re.match(r'branch_(\d+)_(\d+)', branchName.decode('UTF-8')):
+    return BranchType.release
+  raise Exception('Cannot run %s on feature branch' % sys.argv[0].rsplit('/', 1)[-1])
+
+
+def download(name, urlString, tmpDir, quiet=False, force_clean=True):
+  if not quiet:
+      print("Downloading %s" % urlString)
+  startTime = time.time()
+  fileName = '%s/%s' % (tmpDir, name)
+  if not force_clean and os.path.exists(fileName):
+    if not quiet and fileName.find('.asc') == -1:
+      print('    already done: %.1f MB' % (os.path.getsize(fileName)/1024./1024.))
+    return
+  try:
+    attemptDownload(urlString, fileName)
+  except Exception as e:
+    print('Retrying download of url %s after exception: %s' % (urlString, e))
+    try:
+      attemptDownload(urlString, fileName)
+    except Exception as e:
+      raise RuntimeError('failed to download url "%s"' % urlString) from e
+  if not quiet and fileName.find('.asc') == -1:
+    t = time.time()-startTime
+    sizeMB = os.path.getsize(fileName)/1024./1024.
+    print('    %.1f MB in %.2f sec (%.1f MB/sec)' % (sizeMB, t, sizeMB/t))
+
+
+def attemptDownload(urlString, fileName):
+  fIn = urllib.request.urlopen(urlString)
+  fOut = open(fileName, 'wb')
+  success = False
+  try:
+    while True:
+      s = fIn.read(65536)
+      if s == b'':
+        break
+      fOut.write(s)
+    fOut.close()
+    fIn.close()
+    success = True
+  finally:
+    fIn.close()
+    fOut.close()
+    if not success:
+      os.remove(fileName)
+
+version_prop_re = re.compile(r'Version\s*string\s*=\s*([\'"])(.*)\1')
+def find_current_version():
+  script_path = os.path.dirname(os.path.realpath(__file__))
+  top_level_dir = os.path.join(os.path.abspath("%s/" % script_path), os.path.pardir, os.path.pardir, os.path.pardir)
+  return version_prop_re.search(open('%s/version/version.go' % top_level_dir).read()).group(2).strip()
+
+if __name__ == '__main__':
+  print('This is only a support module, it cannot be run')
+  sys.exit(1)