| # |
| # 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. |
| # |
| |
| """ |
| Utility for creating release candidates and promoting release candidates to a final release. |
| |
| Usage: release.py [subcommand] |
| |
| release.py stage |
| |
| Builds and stages an RC for a release. |
| |
| The utility is interactive; you will be prompted for basic release information and guided through the process. |
| |
| This utility assumes you already have local a kafka git folder and that you |
| have added remotes corresponding to both: |
| (i) the github apache kafka mirror and |
| (ii) the apache kafka git repo. |
| |
| release.py stage-docs [kafka-site-path] |
| |
| Builds the documentation and stages it into an instance of the Kafka website repository. |
| |
| This is meant to automate the integration between the main Kafka website repository (https://github.com/apache/kafka-site) |
| and the versioned documentation maintained in the main Kafka repository. This is useful both for local testing and |
| development of docs (follow the instructions here: https://cwiki.apache.org/confluence/display/KAFKA/Setup+Kafka+Website+on+Local+Apache+Server) |
| as well as for committers to deploy docs (run this script, then validate, commit, and push to kafka-site). |
| |
| With no arguments this script assumes you have the Kafka repository and kafka-site repository checked out side-by-side, but |
| you can specify a full path to the kafka-site repository if this is not the case. |
| |
| release.py release-email |
| |
| Generates the email content/template for sending release announcement email. |
| |
| """ |
| |
| import datetime |
| import os |
| import re |
| import subprocess |
| import sys |
| import time |
| |
| from runtime import ( |
| append_fail_hook, |
| cmd, |
| confirm, |
| confirm_or_fail, |
| execute, |
| fail, |
| prompt, |
| repo_dir, |
| ) |
| import git |
| import gpg |
| import notes |
| import preferences |
| import svn |
| import templates |
| import textfiles |
| |
| from svn import SVN_DEV_URL |
| |
| |
| def get_jdk(version): |
| """ |
| Get settings for the specified JDK version. |
| """ |
| msg = f"Enter the path for JAVA_HOME for a JDK{version} compiler (blank to use default JAVA_HOME): " |
| key = f"jdk{version}" |
| jdk_java_home = preferences.get(key, lambda: prompt(msg)) |
| jdk_env = dict(os.environ) |
| if jdk_java_home.strip(): jdk_env["JAVA_HOME"] = jdk_java_home |
| else: jdk_java_home = jdk_env["JAVA_HOME"] |
| java_version = execute(f"{jdk_java_home}/bin/java -version", env=jdk_env) |
| if (version == 8 and "1.8.0" not in java_version) or \ |
| (f"{version}.0" not in java_version and '"{version}"' not in java_version): |
| preferences.unset(key) |
| fail(f"JDK {version} is required") |
| return jdk_env |
| |
| |
| def docs_version(version): |
| """ |
| Detects the major/minor version and converts it to the format used for docs on the website, e.g. gets 0.10.2.0-SNAPSHOT |
| from gradle.properties and converts it to 0102 |
| """ |
| version_parts = version.strip().split(".") |
| # 1.0+ will only have 3 version components as opposed to pre-1.0 that had 4 |
| major_minor = version_parts[0:3] if version_parts[0] == "0" else version_parts[0:2] |
| return ''.join(major_minor) |
| |
| |
| def detect_docs_release_version(version): |
| """ |
| Detects the version from gradle.properties and converts it to a release version number that should be valid for the |
| current release branch. For example, 0.10.2.0-SNAPSHOT would remain 0.10.2.0-SNAPSHOT (because no release has been |
| made on that branch yet); 0.10.2.1-SNAPSHOT would be converted to 0.10.2.0 because 0.10.2.1 is still in development |
| but 0.10.2.0 should have already been released. Regular version numbers (e.g. as encountered on a release branch) |
| will remain the same. |
| """ |
| version_parts = version.strip().split(".") |
| if "-SNAPSHOT" in version_parts[-1]: |
| bugfix = int(version_parts[-1].split("-")[0]) |
| if bugfix > 0: |
| version_parts[-1] = str(bugfix - 1) |
| return ".".join(version_parts) |
| |
| |
| def command_stage_docs(): |
| kafka_site_repo_path = sys.argv[2] if len(sys.argv) > 2 else os.path.join(repo_dir, "..", "kafka-site") |
| if not os.path.exists(kafka_site_repo_path) or not os.path.exists(os.path.join(kafka_site_repo_path, "powered-by.html")): |
| fail("{kafka_site_repo_path} doesn't exist or does not appear to be the kafka-site repository") |
| |
| jdk21_env = get_jdk(21) |
| |
| # We explicitly override the version of the project that we normally get from gradle.properties since we want to be |
| # able to run this from a release branch where we made some updates, but the build would show an incorrect SNAPSHOT |
| # version due to already having bumped the bugfix version number. |
| gradle_version_override = detect_docs_release_version(project_version) |
| |
| cmd("Building docs", f"./gradlew -Pversion={gradle_version_override} clean siteDocsTar aggregatedJavadoc", cwd=repo_dir, env=jdk21_env) |
| |
| docs_tar = os.path.join(repo_dir, "core", "build", "distributions", f"kafka_2.13-{gradle_version_override}-site-docs.tgz") |
| |
| versioned_docs_path = os.path.join(kafka_site_repo_path, docs_version(project_version)) |
| if not os.path.exists(versioned_docs_path): |
| os.mkdir(versioned_docs_path, 0o755) |
| |
| # The contents of the docs jar are site-docs/<docs dir>. We need to get rid of the site-docs prefix and dump everything |
| # inside it into the docs version subdirectory in the kafka-site repo |
| cmd("Extracting site-docs", f"tar xf {docs_tar} --strip-components 1", cwd=versioned_docs_path) |
| |
| javadocs_src_dir = os.path.join(repo_dir, "build", "docs", "javadoc") |
| |
| cmd("Copying javadocs", f"cp -R {javadocs_src_dir} {versioned_docs_path}") |
| |
| sys.exit(0) |
| |
| |
| def validate_release_version_parts(version): |
| try: |
| version_parts = version.split(".") |
| if len(version_parts) != 3: |
| fail("Invalid release version, should have 3 version number components") |
| # Validate each part is a number |
| [int(x) for x in version_parts] |
| except ValueError: |
| fail("Invalid release version, should be a dotted version number") |
| |
| |
| def get_release_version_parts(version): |
| validate_release_version_parts(version) |
| return version.split(".") |
| |
| |
| def validate_release_num(version): |
| if version not in git.tags(): |
| fail("The specified version is not a valid release version number") |
| validate_release_version_parts(version) |
| |
| |
| def command_release_announcement_email(): |
| release_tag_pattern = re.compile("^[0-9]+\\.[0-9]+\\.[0-9]+$") |
| release_tags = sorted([t for t in git.tags() if re.match(release_tag_pattern, t)]) |
| release_version_num = release_tags[-1] |
| if not confirm(f"Is the current release {release_version_num}?"): |
| release_version_num = prompt("What is the current release version:") |
| validate_release_num(release_version_num) |
| previous_release_version_num = release_tags[-2] |
| if not confirm(f"Is the previous release {previous_release_version_num}?"): |
| previous_release_version_num = prompt("What is the previous release version:") |
| validate_release_num(previous_release_version_num) |
| if release_version_num < previous_release_version_num : |
| fail("Current release version number can't be less than previous release version number") |
| contributors = git.contributors(previous_release_version_num, release_version_num) |
| release_announcement_email = templates.release_announcement_email(release_version_num, contributors) |
| print(templates.release_announcement_email_instructions(release_announcement_email)) |
| sys.exit(0) |
| |
| |
| project_version = textfiles.prop(os.path.join(repo_dir, "gradle.properties"), 'version') |
| release_version = project_version.replace('-SNAPSHOT', '') |
| release_version_parts = get_release_version_parts(release_version) |
| dev_branch = '.'.join(release_version_parts[:2]) |
| docs_release_version = docs_version(release_version) |
| |
| # Dispatch to subcommand |
| subcommand = sys.argv[1] if len(sys.argv) > 1 else None |
| if subcommand == 'stage-docs': |
| command_stage_docs() |
| elif subcommand == 'release-email': |
| command_release_announcement_email() |
| elif not (subcommand is None or subcommand == 'stage'): |
| fail(f"Unknown subcommand: {subcommand}") |
| # else -> default subcommand stage |
| |
| |
| ## Default 'stage' subcommand implementation isn't isolated to its own function yet for historical reasons |
| |
| |
| def verify_gpg_key(): |
| if not gpg.key_exists(gpg_key_id): |
| fail(f"GPG key {gpg_key_id} not found") |
| if not gpg.valid_passphrase(gpg_key_id, gpg_passphrase): |
| fail(f"GPG passphrase not valid for key {gpg_key_id}") |
| |
| |
| preferences.once("verify_requirements", lambda: confirm_or_fail(templates.requirements_instructions(preferences.FILE, preferences.as_json()))) |
| global_gradle_props = os.path.expanduser("~/.gradle/gradle.properties") |
| gpg_key_id = textfiles.prop(global_gradle_props, "signing.keyId") |
| gpg_passphrase = textfiles.prop(global_gradle_props, "signing.password") |
| gpg_key_pass_id = gpg.key_pass_id(gpg_key_id, gpg_passphrase) |
| preferences.once(f"verify_gpg_key_{gpg_key_pass_id}", verify_gpg_key) |
| |
| apache_id = preferences.get('apache_id', lambda: prompt("Please enter your apache-id: ")) |
| jdk21_env = get_jdk(21) |
| |
| |
| def verify_prerequisites(): |
| print("Begin to check if you have met all the pre-requisites for the release process") |
| def prereq(name, soft_check): |
| try: |
| result = soft_check() |
| if not result: |
| fail(f"Pre-requisite not met: {name}") |
| else: |
| print(f"Pre-requisite met: {name}") |
| except Exception as e: |
| fail(f"Pre-requisite not met: {name}. Error: {e}") |
| prereq('Apache Maven CLI (mvn) in PATH', lambda: "Apache Maven" in execute("mvn -v")) |
| prereq("svn CLI in PATH", lambda: "svn" in execute("svn --version")) |
| prereq("Verifying that you have no unstaged git changes", lambda: git.has_unstaged_changes()) |
| prereq("Verifying that you have no staged git changes", lambda: git.has_staged_changes()) |
| return True |
| |
| |
| preferences.once(f"verify_prerequisites", verify_prerequisites) |
| |
| # Validate that the release doesn't already exist |
| git.fetch_tags() |
| if release_version in git.tags(): |
| fail(f"Version {release_version} has already been tagged and released.") |
| |
| rc = prompt(f"Release version {release_version} candidate number: ") |
| if not rc: |
| fail("Need a release candidate number.") |
| try: |
| int(rc) |
| except ValueError: |
| fail(f"Invalid release candidate number: {rc}") |
| rc_tag = release_version + '-rc' + rc |
| |
| starting_branch = git.current_branch() |
| def delete_gitrefs(): |
| try: |
| git.reset_hard_head() |
| git.switch_branch(starting_branch) |
| git.delete_branch(release_version) |
| git.delete_tag(rc_tag) |
| except subprocess.CalledProcessError: |
| print("Failed when trying to clean up git references added by this script. You may need to clean up branches/tags yourself before retrying.") |
| print("Expected git branch: " + release_version) |
| print("Expected git tag: " + rc_tag) |
| |
| git.create_branch(release_version, f"{git.push_remote_name}/{dev_branch}") |
| append_fail_hook("Delete gitrefs", delete_gitrefs) |
| print("Updating version numbers") |
| textfiles.replace(f"{repo_dir}/gradle.properties", "version", f"version={release_version}") |
| textfiles.replace(f"{repo_dir}/tests/kafkatest/__init__.py", "__version__", f"__version__ = '{release_version}'") |
| print("Updating streams quickstart pom") |
| textfiles.replace(f"{repo_dir}/streams/quickstart/pom.xml", "-SNAPSHOT", "", regex=True) |
| print("Updating streams quickstart java pom") |
| textfiles.replace(f"{repo_dir}/streams/quickstart/java/pom.xml", "-SNAPSHOT", "", regex=True) |
| print("Updating streams quickstart archetype pom") |
| textfiles.replace(f"{repo_dir}/streams/quickstart/java/src/main/resources/archetype-resources/pom.xml", "-SNAPSHOT", "", regex=True) |
| print("Updating ducktape version.py") |
| textfiles.replace(f"{repo_dir}/tests/kafkatest/version.py", "^DEV_VERSION =.*", |
| f"DEV_VERSION = KafkaVersion(\"{release_version}-SNAPSHOT\")", regex=True) |
| print("Updating docs templateData.js") |
| textfiles.replace(f"{repo_dir}/docs/js/templateData.js", "-SNAPSHOT", "", regex=True) |
| git.commit(f"Bump version to {release_version}") |
| git.create_tag(rc_tag) |
| git.switch_branch(starting_branch) |
| git.merge_ref(rc_tag) |
| |
| # Note that we don't use tempfile here because mkdtemp causes problems with being able to determine the absolute path to a file. |
| # Instead we rely on a fixed path |
| work_dir = os.path.join(repo_dir, ".release_work_dir") |
| clean_up_work_dir = lambda: cmd("Cleaning up work directory", f"rm -rf {work_dir}") |
| if os.path.exists(work_dir): |
| clean_up_work_dir() |
| os.makedirs(work_dir) |
| append_fail_hook("Clean up work dir", clean_up_work_dir) |
| print("Temporary build working directory:", work_dir) |
| kafka_dir = os.path.join(work_dir, 'kafka') |
| artifact_name = "kafka-" + rc_tag |
| cmd("Creating staging area for release artifacts", "mkdir " + artifact_name, cwd=work_dir) |
| artifacts_dir = os.path.join(work_dir, artifact_name) |
| git.clone(repo_dir, 'kafka', cwd=work_dir) |
| git.create_branch(release_version, rc_tag, cwd=kafka_dir) |
| current_year = datetime.datetime.now().year |
| cmd("Verifying the correct year in NOTICE", f"grep {current_year} NOTICE", cwd=kafka_dir) |
| svn.checkout_svn_dev(work_dir) |
| |
| print("Generating release notes") |
| try: |
| html = notes.generate(release_version) |
| release_notes_path = os.path.join(artifacts_dir, "RELEASE_NOTES.html") |
| textfiles.write(release_notes_path, html) |
| except Exception as e: |
| fail(f"Failed to generate release notes: {e}") |
| |
| |
| git.targz(rc_tag, f"kafka-{release_version}-src/", f"{artifacts_dir}/kafka-{release_version}-src.tgz") |
| cmd("Building artifacts", "./gradlew clean && ./gradlew releaseTarGz -PscalaVersion=2.13", cwd=kafka_dir, env=jdk21_env, shell=True) |
| cmd("Copying artifacts", f"cp {kafka_dir}/core/build/distributions/* {artifacts_dir}", shell=True) |
| cmd("Building docs", "./gradlew clean aggregatedJavadoc", cwd=kafka_dir, env=jdk21_env) |
| cmd("Copying docs", f"cp -R {kafka_dir}/build/docs/javadoc {artifacts_dir}") |
| |
| for filename in os.listdir(artifacts_dir): |
| full_path = os.path.join(artifacts_dir, filename) |
| if not os.path.isfile(full_path): |
| continue |
| sig_full_path = full_path + ".asc" |
| gpg.sign(gpg_key_id, gpg_passphrase, full_path, sig_full_path) |
| gpg.verify(full_path, sig_full_path) |
| # Note that for verification, we need to make sure only the filename is used with --print-md because the command line |
| # argument for the file is included in the output and verification uses a simple diff that will break if an absolute path |
| # is used. |
| dir, fname = os.path.split(full_path) |
| cmd(f"Generating MD5 for {full_path}", f"gpg --print-md md5 {fname} > {fname}.md5 ", shell=True, cwd=dir) |
| cmd(f"Generating SHA1 for {full_path}", f"gpg --print-md sha1 {fname} > {fname}.sha1 ", shell=True, cwd=dir) |
| cmd(f"Generating SHA512 for {full_path}", f"gpg --print-md sha512 {fname} > {fname}.sha512", shell=True, cwd=dir) |
| |
| cmd("Listing artifacts to be uploaded:", f"ls -R {artifacts_dir}") |
| cmd("Zipping artifacts", f"tar -czf {artifact_name}.tar.gz {artifact_name}", cwd=work_dir) |
| |
| confirm_or_fail(f"Going to check in artifacts to svn under {SVN_DEV_URL}/{rc_tag}. OK?") |
| svn.commit_artifacts(rc_tag, artifacts_dir, work_dir) |
| |
| confirm_or_fail("Going to build and upload mvn artifacts based on these settings:\n" + textfiles.read(global_gradle_props) + '\nOK?') |
| cmd("Building and uploading archives", "./gradlew publish -PscalaVersion=2.13", cwd=kafka_dir, env=jdk21_env, shell=True) |
| cmd("Building and uploading archives", "mvn deploy -Pgpg-signing", cwd=os.path.join(kafka_dir, "streams/quickstart"), env=jdk21_env, shell=True) |
| |
| # TODO: Many of these suggested validation steps could be automated |
| # and would help pre-validate a lot of the stuff voters test |
| print(templates.sanity_check_instructions(release_version, rc_tag)) |
| confirm_or_fail("Have you sufficiently verified the release artifacts?") |
| |
| # TODO: Can we close the staging repository via a REST API since we |
| # already need to collect credentials for this repo? |
| print(templates.deploy_instructions()) |
| confirm_or_fail("Have you successfully deployed the artifacts?") |
| confirm_or_fail(f"Ok to push RC tag {rc_tag}?") |
| git.push_ref(rc_tag) |
| git.push_ref(starting_branch) |
| |
| # Move back to starting branch and clean out the temporary release branch (e.g. 1.0.0) we used to generate everything |
| git.reset_hard_head() |
| git.switch_branch(starting_branch) |
| git.delete_branch(release_version) |
| |
| rc_vote_email_text = templates.rc_vote_email_text(release_version, rc, rc_tag, dev_branch, docs_release_version) |
| print(templates.rc_email_instructions(rc_vote_email_text)) |