From 1488e02eab56186efbddf122d9220387ff86aaa9 Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali Date: Sun, 12 Jul 2026 13:23:11 +0100 Subject: [PATCH 1/8] Initial change --- .github/scripts/antora_updater.py | 252 +++++++++++++++++ .github/scripts/antora_utils.py | 176 ++++++++++++ .github/scripts/test_antora_updater.py | 343 +++++++++++++++++++++++ .github/scripts/test_antora_utils.py | 177 ++++++++++++ .github/workflows/package.yml | 101 +++++++ .github/workflows/promote.yml | 115 ++++++++ .github/workflows/release-pr-builder.yml | 34 +++ .gitignore | 8 + 8 files changed, 1206 insertions(+) create mode 100644 .github/scripts/antora_updater.py create mode 100644 .github/scripts/antora_utils.py create mode 100644 .github/scripts/test_antora_updater.py create mode 100644 .github/scripts/test_antora_utils.py create mode 100644 .github/workflows/package.yml create mode 100644 .github/workflows/promote.yml create mode 100644 .github/workflows/release-pr-builder.yml diff --git a/.github/scripts/antora_updater.py b/.github/scripts/antora_updater.py new file mode 100644 index 000000000..6272be828 --- /dev/null +++ b/.github/scripts/antora_updater.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +import logging +from typing import Any +from ruamel.yaml import YAML +from packaging.version import parse +import antora_utils as utils + +ANTORA_FILE: str = "docs/antora.yml" + +logger: logging.Logger = utils.setup_logger(__name__) + +def get_beta_suffix(version: str) -> str: + + parsed = parse(version) + if parsed.pre and parsed.pre[0] == 'b': + return f"BETA-{parsed.pre[1]}" + return "" + +def resolve_versions( + target_version:str, + rel_major_minor:str, + master_major_minor:str, + is_beta_release:bool, + is_rel_major_minor:bool, + is_main:bool, + data:Any +) -> utils.AntoraVersions: + + antora_versions = utils.AntoraVersions() + attrs = data['asciidoc']['attributes'] + + if is_main: + master_snapshot = f"{master_major_minor}-SNAPSHOT" + antora_versions.version = f"{master_major_minor}-snapshot" + antora_versions.display_version = master_snapshot + antora_versions.minor_version = master_snapshot + antora_versions.attr_version = master_snapshot + antora_versions.os_version = target_version + antora_versions.ee_version = target_version + antora_versions.full_version = target_version + elif is_beta_release: + local_clean = target_version.upper().replace("-SNAPSHOT", "") + beta_suffix = get_beta_suffix(local_clean) + mm = rel_major_minor + antora_versions.version = f"{mm}-{beta_suffix.lower()}" + antora_versions.display_version = f"{mm}-{beta_suffix}" + antora_versions.full_version = target_version + antora_versions.minor_version = f"{mm}-{beta_suffix}" + antora_versions.attr_version = f"{mm}-{beta_suffix}" + antora_versions.os_version = attrs.get('os-version') + antora_versions.ee_version = target_version + antora_versions.pop_snapshot = False + else: + # Patch + antora_versions.version = rel_major_minor + antora_versions.display_version = rel_major_minor + antora_versions.minor_version = rel_major_minor + antora_versions.attr_version = rel_major_minor + antora_versions.full_version = target_version + antora_versions.ee_version = target_version + + if is_rel_major_minor: + antora_versions.pop_prerelease = True + antora_versions.pop_snapshot = True + + if not is_rel_major_minor: + antora_versions.os_version = attrs.get('os-version') + else: + antora_versions.os_version = target_version + + return antora_versions + +def process_antora( + target_version:str, + rel_major_minor:str, + master_major_minor:str, + is_beta_release:bool, + is_rel_major_minor:bool, + is_main:bool +) -> None: + + yaml: YAML = YAML() + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.width = 4096 + + with open(ANTORA_FILE, 'r+') as f: + data = yaml.load(f) + attrs = data['asciidoc']['attributes'] + + antora_versions = resolve_versions( + target_version=target_version, + rel_major_minor=rel_major_minor, + master_major_minor=master_major_minor, + is_beta_release=is_beta_release, + is_rel_major_minor=is_rel_major_minor, + is_main=is_main, + data=data + ) + + data['version'] = antora_versions.version + data['display_version'] = antora_versions.display_version + attrs['full-version'] = antora_versions.full_version + attrs['os-version'] = antora_versions.os_version + attrs['ee-version'] = antora_versions.ee_version + + if 'minor-version' in attrs or is_main: + attrs['minor-version'] = antora_versions.minor_version + + if 'version' in attrs or is_main: + attrs['version'] = antora_versions.attr_version + + if antora_versions.pop_prerelease: + data.pop('prerelease', None) + + if antora_versions.pop_snapshot: + attrs.pop('snapshot', None) + + f.seek(0) + yaml.dump(data, f) + f.truncate() + +def update_release( + release_ver:str, + rel_major_minor:str, + master_major_minor:str, + is_beta_release:bool, + is_rel_major_minor:bool, + is_patch_release:bool +) -> None: + + # For PATCH release, checkout v/branch directly. When release is MAJOR/MINOR or BETA, + # use release branch instead, and v/branch is created from release branch during `promote` + # phase. This is necessary to prevent premature docs 'live' publishing via v/branch (website + # auto publishes from v/branch) + if is_patch_release: + target_base = f"v/{rel_major_minor}" + else: + target_base = release_ver + + update_branch: str = utils.checkout_branch("antora", target_base) + + process_antora( + target_version=release_ver, + rel_major_minor=rel_major_minor, + master_major_minor=master_major_minor, + is_beta_release=is_beta_release, + is_rel_major_minor=is_rel_major_minor, + is_main=False + ) + + utils.commit_changes(target_base, release_ver, [ANTORA_FILE], update_branch) + utils.create_github_pr(target_base, update_branch, release_ver) + +def update_main( + master_version:str, + rel_major_minor:str, + master_major_minor:str, + is_rel_major_minor:bool +) -> None: + + target_base: str = "main" + update_branch: str = utils.checkout_branch("antora", target_base) + + process_antora( + target_version=master_version, + rel_major_minor=rel_major_minor, + master_major_minor=master_major_minor, + is_beta_release=False, + is_rel_major_minor=is_rel_major_minor, + is_main=True + ) + + utils.commit_changes(target_base, master_version, [ANTORA_FILE], update_branch) + utils.create_github_pr(target_base, update_branch, master_version) + +def update( + release_ver:str, + rel_major_minor:str, + master_version:str, + master_major_minor:str, + is_latest_stable_release:str, + is_beta_release:str, + is_rel_major_minor:str, + is_patch_release:str +) -> None: + + is_patch: bool = is_patch_release == "true" + is_beta: bool = is_beta_release == "true" + is_rel_mm: bool = is_rel_major_minor == "true" + is_latest_stable: bool = is_latest_stable_release == "true" + + if is_rel_mm and is_latest_stable: + update_main( + master_version=master_version, + rel_major_minor=rel_major_minor, + master_major_minor=master_major_minor, + is_rel_major_minor=is_rel_mm + ) + + update_release( + release_ver=release_ver, + rel_major_minor=rel_major_minor, + master_major_minor=master_major_minor, + is_beta_release=is_beta, + is_rel_major_minor=is_rel_mm, + is_patch_release=is_patch + ) + +def merge_pull_requests( + is_rel_major_minor:str, + is_patch_release:str, + release_version:str, + master_version:str, + rel_major_minor:str +) -> None: + + is_maj_min: bool = is_rel_major_minor == "true" + is_patch: bool = is_patch_release == "true" + + if is_maj_min: + utils.merge_github_pr("main", master_version) + + if is_patch: + base_branch = f"v/{rel_major_minor}" + else: + base_branch = release_version + + utils.merge_github_pr(base_branch, release_version) + +def create_v_branch( + release_version:str, + rel_major_minor:str, + is_beta_release:str, + is_patch_release:str +) -> None: + + is_patch: bool = is_patch_release == "true" + is_beta: bool = is_beta_release == "true" + + # Patch `v/branch` should already exist so skip + if is_patch: + return + + v_branch_name = f"v/{rel_major_minor}" + + if is_beta: + beta_suffix = get_beta_suffix(release_version) + v_branch_name = f"v/{rel_major_minor}-{beta_suffix}" + + utils.git_checkout_remote(v_branch_name, release_version) + utils.git_push_remote(v_branch_name) diff --git a/.github/scripts/antora_utils.py b/.github/scripts/antora_utils.py new file mode 100644 index 000000000..fded07d76 --- /dev/null +++ b/.github/scripts/antora_utils.py @@ -0,0 +1,176 @@ +import os +import sys +import json +import logging +import subprocess +import inspect +from datetime import datetime +from typing import Any, Optional + +class AntoraVersions: + def __init__(self) -> None: + self.version: str = "" + self.display_version: str = "" + self.full_version: str = "" + self.os_version: Optional[str] = None + self.ee_version: Optional[str] = None + self.minor_version: Optional[str] = None + self.attr_version: Optional[str] = None + self.pop_prerelease: bool = False + self.pop_snapshot: bool = False + +def run_command( + command:list +) -> str: + + logger.debug(f"Executing command: {' '.join(command)}") + try: + result = subprocess.run(command, capture_output=True, text=True, check=True) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + error_message = e.stderr.strip() if e.stderr else str(e) + raise RuntimeError(f"Command failed: {command}\nError: {error_message}") + +def get_pr_title( + base_branch:str, + version:str +) -> str: + + return f"Update branch {base_branch} to {version}" + +def git_checkout_remote( + local_branch:str, + remote_branch:str +) -> None: + + run_command([ + "git", "fetch", + "origin", remote_branch + ]) + run_command([ + "git", "checkout", + "-b", local_branch, + f"origin/{remote_branch}" + ]) + +def checkout_branch( + prefix:str, + branch:str +) -> str: + + timestamp = datetime.now().strftime("%d%m%Y%H%M%S") + update_branch = f"update_{prefix}_{branch}_{timestamp}" + git_checkout_remote(update_branch, branch) + return update_branch + +def git_push_remote( + branch_name:str +) -> None: + + run_command([ + "git", "push", + "origin", + branch_name + ]) + +def commit_changes( + base_branch:str, + version:str, + file_paths:list, + active_branch:str +) -> None: + + run_command([ + "git", "add" + ] + file_paths) + run_command([ + "git", "commit", + "--message", f"Update branch {base_branch} to {version}" + ]) + git_push_remote(active_branch) + +def create_github_pr( + base_branch:str, + head_branch:str, + version:str +) -> None: + + title = get_pr_title(base_branch, version) + server_url = os.environ["GITHUB_SERVER_URL"] + repository = os.environ["GITHUB_REPOSITORY"] + run_id = os.environ["GITHUB_RUN_ID"] + github_run_url = f"{server_url}/{repository}/actions/runs/{run_id}" + body = f"Triggered by GitHub Action Run: {github_run_url}" + run_command([ + "gh", "pr", "create", + "--title", title, + "--body", body, + "--base", base_branch, + "--head", head_branch + ]) + +def merge_github_pr( + base_branch: str, + version: str, + fail_on_missing: bool = True +) -> None: + + target_title = get_pr_title(base_branch, version) + pr_list_output = run_command([ + "gh", "search", "prs", + "--state", "open", + "--base", base_branch, + "--match", "title", f'"{target_title}"', + "--json", "number,title" + ]) + + try: + prs = json.loads(pr_list_output) + except Exception as e: + raise RuntimeError(f"Failed to parse JSON: {e}") + + exact_matches = [pr for pr in prs if pr.get("title") == target_title] + + if not exact_matches: + if fail_on_missing: + raise RuntimeError(f"PR not found: '{target_title}' (Base: {base_branch})") + else: + logger.warning(f"PR not found for '{target_title}'. Skipping merge execution safely.") + return + elif len(exact_matches) > 1: + pr_numbers = [pr["number"] for pr in exact_matches] + raise RuntimeError(f"Conflict: Multiple open PRs found with title '{target_title}'. PRs: {pr_numbers}") + + matching_pr = exact_matches[0] + pr_number = matching_pr["number"] + + try: + run_command([ + "gh", "pr", "merge", str(pr_number), + "--squash", + "--admin", + "--delete-branch" + ]) + except Exception as e: + raise RuntimeError(f"Failed to merge PR #{pr_number}: {e}") + +def setup_logger( + name:str=__name__ +) -> logging.Logger: + + if os.environ.get("RUNNER_DEBUG") == "1": + current_level = logging.DEBUG + else: + current_level = logging.INFO + + logging.basicConfig( + level=current_level, + format="\033[36m[%(levelname)s]\033[0m %(message)s", + handlers=[logging.StreamHandler(sys.stdout)] + ) + return logging.getLogger(name) + +""" +Logger instance for this module +""" +logger = setup_logger() diff --git a/.github/scripts/test_antora_updater.py b/.github/scripts/test_antora_updater.py new file mode 100644 index 000000000..a20f57ee7 --- /dev/null +++ b/.github/scripts/test_antora_updater.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +import os +import sys +import unittest +from unittest.mock import patch, call +from ruamel.yaml import YAML + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import antora_updater as antora + +class DynamicFileSimulator: + """ + Virtual filesystem router that isolates sequential file access. + Prevents standard mock_open data cross-contamination when update_main + and update_release run in the same process. + """ + def __init__(self, initial_content: str): + self.content = initial_content + self.history = [] + + def open_stream(self, file_path: str, mode: str): + return VirtualFileContext(self, mode) + +class VirtualFileContext: + """ + Simulates text/binary file stream behaviors. + Delivers EOF empty strings on consecutive reads to prevent infinite YAML + loops, transparently decodes raw bytes, and commits writes on block exit. + Supports seek and truncate operations for r+ block mode scopes. + """ + def __init__(self, factory: DynamicFileSimulator, mode: str): + self.factory = factory + self.mode = mode + self.read_done = False + self.local_buffer = [] + + def read(self, *args, **kwargs) -> str: + if ("r" in self.mode or "+" in self.mode) and not self.read_done: + self.read_done = True + return self.factory.content + return "" + + def write(self, data) -> int: + text_chunk = data.decode("utf-8") if isinstance(data, bytes) else data + self.local_buffer.append(text_chunk) + return len(data) + + def seek(self, position: int, *args, **kwargs) -> None: + if position == 0: + self.local_buffer = [] + + def truncate(self, *args, **kwargs) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if ("w" in self.mode or "+" in self.mode) and self.local_buffer: + completed_doc = "".join(self.local_buffer) + self.factory.content = completed_doc + self.factory.history.append(completed_doc) + + +class TestAntoraUpdater(unittest.TestCase): + + def setUp(self) -> None: + self.yaml: YAML = YAML() + self.yaml.preserve_quotes = True + + def get_main_template(self) -> str: + return """name: hazelcast +title: Hazelcast Platform +version: '5.8-snapshot' +display_version: '5.8-SNAPSHOT' +prerelease: true +snapshot: true +asciidoc: + attributes: + full-version: '5.8.0-SNAPSHOT' + os-version: '5.8.0-SNAPSHOT' + ee-version: '5.8.0-SNAPSHOT' + hz-repo-swagger-branch: 'master' + jet-version: '4.5.4' + minor-version: '5.8-SNAPSHOT' + version: '5.8-SNAPSHOT' + experimental: true + snapshot: true + page-toclevels: 1 +nav: + - modules/ROOT/nav.adoc +""" + + def get_release_patch_template(self) -> str: + return """name: hazelcast +title: Hazelcast Platform +version: '5.8' +display_version: '5.8' +asciidoc: + attributes: + full-version: '5.8.0' + os-version: '5.8.0' + ee-version: '5.8.0' + hz-repo-swagger-branch: 'master' + jet-version: '4.5.4' + minor-version: '5.8' + version: '5.8' + experimental: true + page-toclevels: 1 +nav: + - modules/ROOT/nav.adoc +""" + + def assert_untouched_properties(self, data: dict) -> None: + attrs = data["asciidoc"]["attributes"] + self.assertEqual(attrs["hz-repo-swagger-branch"], "master") + self.assertEqual(attrs["jet-version"], "4.5.4") + self.assertTrue(attrs["experimental"]) + + @patch("builtins.open") + @patch("antora_utils.checkout_branch") + @patch("antora_utils.commit_changes") + @patch("antora_utils.create_github_pr") + def test_scenario_1_major_minor_main_flow(self, mock_pr, mock_commit, mock_checkout, mock_open, *args) -> None: + simulator = DynamicFileSimulator(self.get_main_template()) + mock_open.side_effect = simulator.open_stream + mock_checkout.return_value = "update_mock_branch_123" + + antora.update( + release_ver="5.8.0", + rel_major_minor="5.9", + master_version="5.9.0-SNAPSHOT", + master_major_minor="5.9", + is_latest_stable_release="true", + is_beta_release="false", + is_rel_major_minor="true", + is_patch_release="false" + ) + + self.assertEqual(len(simulator.history), 2) + mock_checkout.assert_any_call("antora", "main") + mock_checkout.assert_any_call("antora", "5.8.0") + + main_data = self.yaml.load(simulator.history[0]) + release_data = self.yaml.load(simulator.history[1]) + + self.assertEqual(main_data["asciidoc"]["attributes"]["full-version"], "5.9.0-SNAPSHOT") + self.assertEqual(main_data["asciidoc"]["attributes"]["snapshot"], True) + self.assert_untouched_properties(main_data) + + self.assertEqual(release_data["asciidoc"]["attributes"]["full-version"], "5.8.0") + self.assertNotIn("snapshot", release_data["asciidoc"]["attributes"]) + self.assert_untouched_properties(release_data) + + @patch("builtins.open") + @patch("antora_utils.checkout_branch") + @patch("antora_utils.commit_changes") + @patch("antora_utils.create_github_pr") + def test_scenario_2_major_minor_release_flow(self, mock_pr, mock_commit, mock_checkout, mock_open, *args) -> None: + simulator = DynamicFileSimulator(self.get_main_template()) + mock_open.side_effect = simulator.open_stream + mock_checkout.return_value = "update_mock_branch_123" + + antora.update( + release_ver="5.8.0", + rel_major_minor="5.9", + master_version="5.9.0-SNAPSHOT", + master_major_minor="5.9", + is_latest_stable_release="true", + is_beta_release="false", + is_rel_major_minor="true", + is_patch_release="false" + ) + + self.assertEqual(len(simulator.history), 2) + main_data = self.yaml.load(simulator.history[0]) + release_data = self.yaml.load(simulator.history[1]) + + self.assertEqual(main_data["asciidoc"]["attributes"]["full-version"], "5.9.0-SNAPSHOT") + self.assertEqual(release_data["asciidoc"]["attributes"]["full-version"], "5.8.0") + self.assertNotIn("snapshot", release_data["asciidoc"]["attributes"]) + self.assert_untouched_properties(release_data) + + @patch("builtins.open") + @patch("antora_utils.checkout_branch") + @patch("antora_utils.commit_changes") + @patch("antora_utils.create_github_pr") + def test_scenario_3_beta_prerelease_flow(self, mock_pr, mock_commit, mock_checkout, mock_open, *args) -> None: + simulator = DynamicFileSimulator(self.get_main_template()) + mock_open.side_effect = simulator.open_stream + mock_checkout.return_value = "update_mock_branch_123" + + antora.update( + release_ver="5.8.0-BETA-1", + rel_major_minor="5.8", + master_version="5.8.0-SNAPSHOT", + master_major_minor="5.8", + is_latest_stable_release="false", + is_beta_release="true", + is_rel_major_minor="true", + is_patch_release="false" + ) + + mock_checkout.assert_called_once_with("antora", "5.8.0-BETA-1") + self.assertEqual(len(simulator.history), 1) + beta_data = self.yaml.load(simulator.history[-1]) + self.assertEqual(beta_data["version"], "5.8-beta-1") + self.assertEqual(beta_data["display_version"], "5.8-BETA-1") + self.assertEqual(beta_data["asciidoc"]["attributes"]["full-version"], "5.8.0-BETA-1") + self.assertEqual(beta_data["asciidoc"]["attributes"]["os-version"], "5.8.0-SNAPSHOT") + self.assertEqual(beta_data["asciidoc"]["attributes"]["ee-version"], "5.8.0-BETA-1") + self.assertEqual(beta_data["asciidoc"]["attributes"]["minor-version"], "5.8-BETA-1") + self.assertEqual(beta_data["asciidoc"]["attributes"]["version"], "5.8-BETA-1") + self.assert_untouched_properties(beta_data) + + @patch("builtins.open") + @patch("antora_utils.checkout_branch") + @patch("antora_utils.commit_changes") + @patch("antora_utils.create_github_pr") + def test_scenario_4_patch_latest_flow(self, mock_pr, mock_commit, mock_checkout, mock_open, *args) -> None: + simulator = DynamicFileSimulator(self.get_release_patch_template()) + mock_open.side_effect = simulator.open_stream + mock_checkout.return_value = "update_mock_branch_123" + + antora.update( + release_ver="5.8.1", + rel_major_minor="5.8", + master_version="5.9.0-SNAPSHOT", + master_major_minor="5.9", + is_latest_stable_release="true", + is_beta_release="false", + is_rel_major_minor="false", + is_patch_release="true" + ) + + mock_checkout.assert_called_once_with("antora", "v/5.8") + self.assertEqual(len(simulator.history), 1) + patch_data = self.yaml.load(simulator.history[-1]) + self.assertEqual(patch_data["asciidoc"]["attributes"]["full-version"], "5.8.1") + self.assertEqual(patch_data["asciidoc"]["attributes"]["os-version"], "5.8.0") + self.assert_untouched_properties(patch_data) + + @patch("builtins.open") + @patch("antora_utils.checkout_branch") + @patch("antora_utils.commit_changes") + @patch("antora_utils.create_github_pr") + def test_scenario_5_patch_not_latest_flow(self, mock_pr, mock_commit, mock_checkout, mock_open, *args) -> None: + simulator = DynamicFileSimulator(self.get_release_patch_template()) + mock_open.side_effect = simulator.open_stream + mock_checkout.return_value = "update_mock_branch_123" + + antora.update( + release_ver="5.8.1", + rel_major_minor="5.8", + master_version="5.9.0-SNAPSHOT", + master_major_minor="5.9", + is_latest_stable_release="false", + is_beta_release="false", + is_rel_major_minor="false", + is_patch_release="true" + ) + + mock_checkout.assert_called_once_with("antora", "v/5.8") + self.assertEqual(len(simulator.history), 1) + patch_data = self.yaml.load(simulator.history[-1]) + self.assertEqual(patch_data["asciidoc"]["attributes"]["full-version"], "5.8.1") + self.assertEqual(patch_data["asciidoc"]["attributes"]["os-version"], "5.8.0") + self.assert_untouched_properties(patch_data) + + @patch("antora_utils.merge_github_pr") + def test_merge_pull_requests_major_minor(self, mock_merge) -> None: + antora.merge_pull_requests( + is_rel_major_minor="true", + is_patch_release="false", + release_version="5.8.0", + master_version="5.9.0-SNAPSHOT", + rel_major_minor="5.8" + ) + mock_merge.assert_has_calls([ + call("main", "5.9.0-SNAPSHOT"), + call("5.8.0", "5.8.0") + ]) + + @patch("antora_utils.merge_github_pr") + def test_merge_pull_requests_beta(self, mock_merge) -> None: + antora.merge_pull_requests( + is_rel_major_minor="false", + is_patch_release="false", + release_version="5.8.0-BETA-1", + master_version="5.8.0-SNAPSHOT", + rel_major_minor="5.8" + ) + mock_merge.assert_called_once_with("5.8.0-BETA-1", "5.8.0-BETA-1") + + @patch("antora_utils.merge_github_pr") + def test_merge_pull_requests_patch(self, mock_merge) -> None: + antora.merge_pull_requests( + is_rel_major_minor="false", + is_patch_release="true", + release_version="5.8.1", + master_version="5.9.0-SNAPSHOT", + rel_major_minor="5.8" + ) + mock_merge.assert_called_once_with("v/5.8", "5.8.1") + + @patch("antora_utils.git_push_remote") + @patch("antora_utils.git_checkout_remote") + def test_create_v_branch_standard(self, mock_checkout, mock_push) -> None: + antora.create_v_branch( + release_version="5.8.0", + rel_major_minor="5.8", + is_beta_release="false", + is_patch_release="false" + ) + mock_checkout.assert_called_once_with("v/5.8", "5.8.0") + mock_push.assert_called_once_with("v/5.8") + + @patch("antora_utils.git_push_remote") + @patch("antora_utils.git_checkout_remote") + def test_create_v_branch_beta(self, mock_checkout, mock_push) -> None: + antora.create_v_branch( + release_version="5.8.0-BETA-1", + rel_major_minor="5.8", + is_beta_release="true", + is_patch_release="false" + ) + mock_checkout.assert_called_once_with("v/5.8-BETA-1", "5.8.0-BETA-1") + mock_push.assert_called_once_with("v/5.8-BETA-1") + + @patch("antora_utils.git_push_remote") + @patch("antora_utils.git_checkout_remote") + def test_create_v_branch_patch(self, mock_checkout, mock_push) -> None: + antora.create_v_branch( + release_version="5.8.1", + rel_major_minor="5.8", + is_beta_release="false", + is_patch_release="true" + ) + mock_checkout.assert_not_called() + mock_push.assert_not_called() + +if __name__ == "__main__": + unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/.github/scripts/test_antora_utils.py b/.github/scripts/test_antora_utils.py new file mode 100644 index 000000000..6e39427f3 --- /dev/null +++ b/.github/scripts/test_antora_utils.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +import unittest +from unittest.mock import patch, MagicMock, call +import os +import json +import subprocess +import logging + +import antora_utils + +class TestAntoraUtils(unittest.TestCase): + + def test_get_pr_title(self) -> None: + title = antora_utils.get_pr_title("main", "5.8.0") + self.assertEqual(title, "Update branch main to 5.8.0") + + @patch("antora_utils.subprocess.run") + def test_run_command_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = MagicMock(stdout=" mock output \n", returncode=0) + output = antora_utils.run_command(["git", "status"]) + self.assertEqual(output, "mock output") + mock_run.assert_called_once_with(["git", "status"], capture_output=True, text=True, check=True) + + @patch("antora_utils.subprocess.run") + def test_run_command_failure(self, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.CalledProcessError(1, ["git", "status"], stderr="mock error") + with self.assertRaises(RuntimeError): + antora_utils.run_command(["git", "status"]) + + @patch("antora_utils.run_command") + def test_git_checkout_remote(self, mock_run_command: MagicMock) -> None: + antora_utils.git_checkout_remote("local-feature", "remote-base") + expected_calls = [ + call([ + "git", "fetch", + "origin", "remote-base" + ]), + call([ + "git", "checkout", + "-b", "local-feature", + "origin/remote-base" + ]) + ] + mock_run_command.assert_has_calls(expected_calls) + + @patch("antora_utils.git_checkout_remote") + @patch("antora_utils.datetime") + def test_checkout_branch(self, mock_datetime: MagicMock, mock_checkout_remote: MagicMock) -> None: + mock_datetime.now.return_value = MagicMock(strftime=MagicMock(return_value="29062026105000")) + + branch_name = antora_utils.checkout_branch("release", "5.8.0") + + self.assertEqual(branch_name, "update_release_5.8.0_29062026105000") + mock_checkout_remote.assert_called_once_with("update_release_5.8.0_29062026105000", "5.8.0") + + @patch("antora_utils.run_command") + def test_git_push_remote(self, mock_run_command: MagicMock) -> None: + antora_utils.git_push_remote("feature-branch") + mock_run_command.assert_called_once_with([ + "git", "push", + "origin", + "feature-branch" + ]) + + @patch("antora_utils.git_push_remote") + @patch("antora_utils.run_command") + def test_commit_changes(self, mock_run_command: MagicMock, mock_push_remote: MagicMock) -> None: + antora_utils.commit_changes("main", "5.8.0", ["docs/antora.yml"], "update_feature_branch") + + expected_calls = [ + call([ + "git", "add", + "docs/antora.yml" + ]), + call([ + "git", "commit", + "--message", "Update branch main to 5.8.0" + ]) + ] + mock_run_command.assert_has_calls(expected_calls) + mock_push_remote.assert_called_once_with("update_feature_branch") + + @patch.dict(os.environ, { + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "hz-devops-test/hz-docs", + "GITHUB_RUN_ID": "12345" + }) + @patch("antora_utils.run_command") + def test_create_github_pr(self, mock_run_command: MagicMock) -> None: + antora_utils.create_github_pr("main", "feature-branch", "5.8.0") + + mock_run_command.assert_called_once_with([ + "gh", "pr", "create", + "--title", "Update branch main to 5.8.0", + "--body", "Triggered by GitHub Action Run: https://github.com/hz-devops-test/hz-docs/actions/runs/12345", + "--base", "main", + "--head", "feature-branch" + ]) + + @patch("antora_utils.run_command") + def test_merge_github_pr_success(self, mock_run_command: MagicMock) -> None: + mock_prs_json = json.dumps([{"number": 42, "title": "Update branch main to 5.8.0"}]) + mock_run_command.side_effect = [mock_prs_json, ""] + + antora_utils.merge_github_pr("main", "5.8.0") + + mock_run_command.assert_has_calls([ + call([ + "gh", "search", "prs", + "--state", "open", + "--base", "main", + "--match", "title", f'"Update branch main to 5.8.0"', + "--json", "number,title" + ]), + call([ + "gh", "pr", "merge", "42", + "--squash", + "--admin", + "--delete-branch" + ]) + ]) + + @patch("antora_utils.run_command") + def test_merge_github_pr_not_found(self, mock_run_command: MagicMock) -> None: + mock_run_command.return_value = json.dumps([]) + + with self.assertRaises(RuntimeError) as context: + antora_utils.merge_github_pr("main", "5.8.0") + + self.assertIn("PR not found", str(context.exception)) + + @patch("antora_utils.logger") + @patch("antora_utils.run_command") + def test_merge_github_pr_not_found_no_fail(self, mock_run_command: MagicMock, mock_logger: MagicMock) -> None: + mock_run_command.return_value = json.dumps([]) + + antora_utils.merge_github_pr("main", "5.8.0", fail_on_missing=False) + + mock_logger.warning.assert_called_once() + self.assertEqual(mock_run_command.call_count, 1) + + @patch("antora_utils.run_command") + def test_merge_github_pr_conflict_multiple(self, mock_run_command: MagicMock) -> None: + mock_prs_json = json.dumps([ + {"number": 42, "title": "Update branch main to 5.8.0"}, + {"number": 43, "title": "Update branch main to 5.8.0"} + ]) + mock_run_command.return_value = mock_prs_json + + with self.assertRaises(RuntimeError) as context: + antora_utils.merge_github_pr("main", "5.8.0") + + self.assertIn("Conflict: Multiple open PRs found", str(context.exception)) + + @patch("logging.basicConfig") + def test_setup_logger(self, mock_basic_config) -> None: + import importlib + import antora_utils + + scenarios = [ + {"env_val": "0", "expected_level": logging.INFO, "level_name": "INFO"}, + {"env_val": "1", "expected_level": logging.DEBUG, "level_name": "DEBUG"} + ] + + for case in scenarios: + with self.subTest(level=case["level_name"]): + mock_basic_config.reset_mock() + + with patch.dict(os.environ, {"RUNNER_DEBUG": case["env_val"]}): + importlib.reload(antora_utils) + mock_basic_config.assert_called_once() + kwargs = mock_basic_config.call_args[1] + self.assertEqual(kwargs["level"], case["expected_level"]) + self.assertIn("[%(levelname)s]", kwargs["format"]) + +if __name__ == "__main__": + unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 000000000..54e50a1c4 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,101 @@ +name: Package Release +run-name: ${{ github.workflow }} (`${{ inputs.VERSION }}`@${{ inputs.ENVIRONMENT }}) + +on: + workflow_dispatch: + inputs: + VERSION: + description: 'The version to build (e.g. `5.4.1`)' + required: true + RELEASE_TYPE: + description: 'What should be built' + required: true + type: choice + options: + - ALL + - OSS + - EE + ENVIRONMENT: + description: 'Environment to use' + required: true + type: environment + distinct_id: + description: 'Required exclusively for automated execution to track status of workflow execution' + required: false + +jobs: + prepare: + runs-on: ubuntu-slim + steps: + - name: Logging distinct ID (${{ inputs.distinct_id }}) + run: echo "${DISTINCT_ID}" + env: + DISTINCT_ID: ${{ inputs.distinct_id }} + + package: + # we need to use GH runners for now as there's a GH issue with triggering jobs on ubicloud in a private sandbox fork + runs-on: ${{ github.event.repository.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + needs: prepare + environment: + name: ${{ inputs.ENVIRONMENT }} + deployment: false + permissions: + id-token: write + contents: write + steps: + - name: Checkout `release` branch + uses: actions/checkout@v7 + with: + path: ${{ inputs.VERSION }} + + - name: Checkout `main` automation scripts + uses: actions/checkout@v7 + with: + path: src-scripts + + - name: Get release info + id: get-rel-info + uses: hazelcast/mono-actions/release-info@DI-719-migrate-antora-1 + with: + release-version: ${{ inputs.VERSION }} + jfrog-files-repo: ${{ vars.JFROG_PREPROD_FILES_REPO }} + github-token: ${{ secrets.GH_TOKEN }} + aws-role-to-assume: ${{ vars.AWS_HAZELCAST_OIDC_GITHUB_ACTIONS_ROLE_ARN }} + + - name: Install ruamel.yaml + run: python3 -m pip install ruamel.yaml packaging + + - name: Configure git + working-directory: ${{ inputs.VERSION }} + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + - name: Update antora.yml with Pull Requests + shell: python + working-directory: ${{ inputs.VERSION }} + run: | + from os import getenv + import antora_updater as antora + + antora.update( + release_ver=getenv("RELEASE_VERSION"), + rel_major_minor=getenv("REL_MAJOR_MINOR"), + master_version=getenv("MASTER_VERSION"), + master_major_minor=getenv("MASTER_MAJOR_MINOR"), + is_latest_stable_release=getenv("IS_LATEST_STABLE_RELEASE"), + is_beta_release=getenv("IS_BETA_RELEASE"), + is_rel_major_minor=getenv("IS_REL_MAJOR_MINOR"), + is_patch_release=getenv("IS_PATCH_RELEASE") + ) + env: + RELEASE_VERSION: ${{ inputs.VERSION }} + REL_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.rel-major-minor }} + MASTER_VERSION: ${{ steps.get-rel-info.outputs.master-version }} + MASTER_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.master-major-minor }} + IS_LATEST_STABLE_RELEASE: ${{ steps.get-rel-info.outputs.is-latest-stable-release }} + IS_BETA_RELEASE: ${{ steps.get-rel-info.outputs.is-beta-release }} + IS_REL_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.is-rel-major-minor }} + IS_PATCH_RELEASE: ${{ steps.get-rel-info.outputs.is-patch-release }} + PYTHONPATH: ${{ github.workspace }}/src-scripts/.github/scripts + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 000000000..0b3212078 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,115 @@ +name: Promote Release +run-name: ${{ github.workflow }} (`${{ inputs.VERSION }}`@${{ inputs.ENVIRONMENT }}) + +on: + # Called from release orchestrator + workflow_dispatch: + inputs: + VERSION: + description: 'The version to promote (e.g. `5.4.1`)' + required: true + RELEASE_TYPE: + description: 'What should be promoted' + required: true + type: choice + options: + - ALL + - OSS + - EE + ENVIRONMENT: + description: 'Environment to use' + required: true + type: environment + distinct_id: + description: 'Required exclusively for automated execution to track status of workflow execution' + required: false + +jobs: + prepare: + # we need to use GH runners for now as there's a GH issue with triggering jobs on ubicloud in a private sandbox fork ff + runs-on: ubuntu-slim + steps: + # https://github.com + - name: Logging distinct ID (${{ inputs.distinct_id }}) + run: echo "${DISTINCT_ID}" + env: + DISTINCT_ID: ${{ inputs.distinct_id }} + + promote: + # we need to use GH runners for now as there's a GH issue with triggering jobs on ubicloud in a private sandbox fork + runs-on: ${{ github.event.repository.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + needs: prepare + environment: + name: ${{ inputs.ENVIRONMENT }} + deployment: false + permissions: + contents: write + id-token: write + pull-requests: write + env: + RELEASE_VERSION: ${{ inputs.VERSION }} + steps: + - name: Checkout `release` branch + uses: actions/checkout@v7 + with: + path: ${{ env.RELEASE_VERSION }} + + - name: Checkout `main` automation scripts + uses: actions/checkout@v7 + with: + path: src-scripts + + - name: Get release info + id: get-rel-info + uses: hazelcast/mono-actions/release-info@DI-719-migrate-antora-1 + with: + release-version: ${{ env.RELEASE_VERSION }} + jfrog-files-repo: ${{ vars.JFROG_PREPROD_FILES_REPO }} + github-token: ${{ secrets.GH_TOKEN }} + aws-role-to-assume: ${{ vars.AWS_HAZELCAST_OIDC_GITHUB_ACTIONS_ROLE_ARN }} + + - name: Install ruamel.yaml + run: python3 -m pip install ruamel.yaml packaging + + - name: Merge Antora Pull Requests + working-directory: ${{ env.RELEASE_VERSION }} + shell: python + run: | + from os import getenv + import antora_updater as updater + + updater.merge_pull_requests( + is_rel_major_minor=getenv("IS_REL_MAJOR_MINOR"), + release_version=getenv("RELEASE_VERSION"), + master_version=getenv("MASTER_VERSION"), + rel_major_minor=getenv("REL_MAJOR_MINOR"), + is_patch_release=getenv("IS_PATCH_RELEASE") + ) + env: + MASTER_VERSION: ${{ steps.get-rel-info.outputs.master-version }} + REL_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.rel-major-minor }} + IS_REL_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.is-rel-major-minor }} + IS_PATCH_RELEASE: ${{ steps.get-rel-info.outputs.is-patch-release }} + PYTHONPATH: ${{ github.workspace }}/src-scripts/.github/scripts + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Create v/branch from release branch + shell: python + working-directory: ${{ env.RELEASE_VERSION }} + run: | + from os import getenv + import antora_updater as updater + + updater.create_v_branch( + release_version=getenv("RELEASE_VERSION"), + rel_major_minor=getenv("REL_MAJOR_MINOR"), + is_beta_release=getenv("IS_BETA_RELEASE"), + is_patch_release=getenv("IS_PATCH_RELEASE") + ) + env: + REL_MAJOR_MINOR: ${{ steps.get-rel-info.outputs.rel-major-minor }} + IS_BETA_RELEASE: ${{ steps.get-rel-info.outputs.is-beta-release }} + IS_PATCH_RELEASE: ${{ steps.get-rel-info.outputs.is-patch-release }} + PYTHONPATH: ${{ github.workspace }}/src-scripts/.github/scripts + GH_TOKEN: ${{ secrets.GH_TOKEN }} + diff --git a/.github/workflows/release-pr-builder.yml b/.github/workflows/release-pr-builder.yml new file mode 100644 index 000000000..727c47863 --- /dev/null +++ b/.github/workflows/release-pr-builder.yml @@ -0,0 +1,34 @@ +name: Run release unit tests + +on: + pull_request: + branches: + - main + types: [opened, synchronize] + paths: + - '.github/scripts/**' + - '.github/workflows/package-release.yml' + - '.github/workflows/promote-release.yml' + workflow_dispatch: + +jobs: + test: + # Ignore automated release PR branches + if: ${{ !startsWith(github.head_ref, 'update_antora_') }} + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install ruamel.yaml + run: python3 -m pip install ruamel.yaml packaging + + - name: Run unit tests + run: | + python3 -m unittest discover -s .github/scripts -p "test_*.py" diff --git a/.gitignore b/.gitignore index 57007745b..e76f43ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ /target/ .classpath global-antora-playbook.yml + +# Python +__pycache__/ +*.py[cod] +*$py.class +.env +.pytest_cache/ +.unittest_cache/ From b54d4a7aec6fc335c7da9944fff7f359b0f3b8ef Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali Date: Mon, 13 Jul 2026 15:28:34 +0100 Subject: [PATCH 2/8] Add comments for used 'ruamel.yaml' fields --- .github/scripts/antora_updater.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/scripts/antora_updater.py b/.github/scripts/antora_updater.py index 6272be828..18c5164b5 100644 --- a/.github/scripts/antora_updater.py +++ b/.github/scripts/antora_updater.py @@ -79,9 +79,14 @@ def process_antora( is_main:bool ) -> None: + # Using https://yaml.dev/doc/ruamel.yaml/ yaml: YAML = YAML() + # Preserve original single/double quotess yaml.preserve_quotes = True + # Preserve indentations as per current `antora.yml` layout + # See https://yaml.dev/doc/ruamel.yaml/detail/#Indentation_of_block_sequences yaml.indent(mapping=2, sequence=4, offset=2) + # Extend line wrapping limit to prevent premature line breaks yaml.width = 4096 with open(ANTORA_FILE, 'r+') as f: From 6ee49171c501db0ccbf1553800fb2b27e3f5a826 Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali <12186256+nishaatr@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:24:30 +0100 Subject: [PATCH 3/8] Update .github/workflows/promote.yml Co-authored-by: Jack Green --- .github/workflows/promote.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 0b3212078..4eb44283f 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -112,4 +112,3 @@ jobs: IS_PATCH_RELEASE: ${{ steps.get-rel-info.outputs.is-patch-release }} PYTHONPATH: ${{ github.workspace }}/src-scripts/.github/scripts GH_TOKEN: ${{ secrets.GH_TOKEN }} - From fecc24557a38b34b789f78b563951998d42606ba Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali <12186256+nishaatr@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:24:40 +0100 Subject: [PATCH 4/8] Update .github/workflows/promote.yml Co-authored-by: Jack Green --- .github/workflows/promote.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 4eb44283f..2eb796c24 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -26,7 +26,6 @@ on: jobs: prepare: - # we need to use GH runners for now as there's a GH issue with triggering jobs on ubicloud in a private sandbox fork ff runs-on: ubuntu-slim steps: # https://github.com From 3f0a4a82f3cdbe79f4ab87552923d3e73872e4eb Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali <12186256+nishaatr@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:28:19 +0100 Subject: [PATCH 5/8] Update .github/workflows/release-pr-builder.yml Co-authored-by: Jack Green --- .github/workflows/release-pr-builder.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release-pr-builder.yml b/.github/workflows/release-pr-builder.yml index 727c47863..4138487dc 100644 --- a/.github/workflows/release-pr-builder.yml +++ b/.github/workflows/release-pr-builder.yml @@ -4,7 +4,6 @@ on: pull_request: branches: - main - types: [opened, synchronize] paths: - '.github/scripts/**' - '.github/workflows/package-release.yml' From 38b3d7a85800185a70354dc474742eb8db9b71c2 Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali <12186256+nishaatr@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:32:18 +0100 Subject: [PATCH 6/8] Update .github/workflows/package.yml Co-authored-by: Jack Green --- .github/workflows/package.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 54e50a1c4..7ec6c8c70 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -33,8 +33,7 @@ jobs: DISTINCT_ID: ${{ inputs.distinct_id }} package: - # we need to use GH runners for now as there's a GH issue with triggering jobs on ubicloud in a private sandbox fork - runs-on: ${{ github.event.repository.fork && 'ubuntu-latest' || 'ubicloud-standard-2' }} + runs-on: ubuntu-latest needs: prepare environment: name: ${{ inputs.ENVIRONMENT }} From 22aa1bcb4276808c38a8206b4366039e3fe35c2a Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali Date: Mon, 20 Jul 2026 16:20:43 +0100 Subject: [PATCH 7/8] No need to install Python as already installed in GH runner --- .github/workflows/release-pr-builder.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/release-pr-builder.yml b/.github/workflows/release-pr-builder.yml index 4138487dc..594815c26 100644 --- a/.github/workflows/release-pr-builder.yml +++ b/.github/workflows/release-pr-builder.yml @@ -20,11 +20,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install ruamel.yaml run: python3 -m pip install ruamel.yaml packaging From 8aeaf6e44289c1dd5d63e8ee8388a124114d52e2 Mon Sep 17 00:00:00 2001 From: Nishaat Rajabali Date: Mon, 20 Jul 2026 17:16:52 +0100 Subject: [PATCH 8/8] Add v/* to PR builder --- .github/workflows/release-pr-builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr-builder.yml b/.github/workflows/release-pr-builder.yml index 594815c26..a76c99a45 100644 --- a/.github/workflows/release-pr-builder.yml +++ b/.github/workflows/release-pr-builder.yml @@ -3,7 +3,7 @@ name: Run release unit tests on: pull_request: branches: - - main + branches: [main, 'v/*'] paths: - '.github/scripts/**' - '.github/workflows/package-release.yml'