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)