#!/usr/bin/env python3
#
# Copyright 2018-2025 Elyra Authors
#
# Licensed 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 elyra
import elyra._version
import git
import io
import os
import re
import shutil
import subprocess
import sys

from datetime import datetime
from pathlib import Path
from types import SimpleNamespace

config: SimpleNamespace

VERSION_REG_EX = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.(?P<pre_release>[a-z]+)(?P<build>\d+))?"

DEFAULT_GIT_ORG = "elyra-ai"
DEFAULT_GIT_BRANCH = "main"
DEFAULT_BUILD_DIR = "build/release"


class DependencyException(Exception):
    """Error if dependency is missing"""


class MissingReleaseArtifactException(Exception):
    """Error if an artifact being released is not available"""


class UpdateVersionException(Exception):
    """Error if the old version is invalid or cannot be found, or if there's a duplicate version"""


def check_run(args, cwd=os.getcwd(), capture_output=True, env=None, shell=False) -> subprocess.CompletedProcess:
    """Run a command and return the response"""
    try:
        return subprocess.run(args, cwd=cwd, capture_output=capture_output, check=True, env=env, shell=shell)
    except subprocess.CalledProcessError as ex:
        # Safely extract error message
        if ex.stderr is None:
            error_msg = "No error output available"
        elif isinstance(ex.stderr, bytes):
            error_msg = ex.stderr.decode("utf-8", errors="replace")
        else:
            error_msg = str(ex.stderr)

        raise RuntimeError(f"Error executing process: {error_msg}") from ex


def check_output(args, cwd=os.getcwd(), env=None, shell=False) -> str:
    """Run a command and return its output"""
    response = check_run(args, cwd, capture_output=True, env=env, shell=shell)
    return response.stdout.decode("utf-8").replace("\n", "")


def dependency_exists(command) -> bool:
    """Returns true if a command exists on the system"""
    try:
        check_run(["which", command])
    except RuntimeError:
        return False

    return True


def sed(file: str, pattern: str, replace: str) -> None:
    """Perform regex substitution on a given file using Python instead of shell commands"""
    try:
        # Validate file path
        if not os.path.exists(file):
            raise RuntimeError(f"File does not exist: {file}")

        # Read file content
        with open(file, "r", encoding="utf-8") as f:
            content = f.read()

        # Perform regex substitution with MULTILINE flag for ^ and $ anchors
        modified_content = re.sub(pattern, replace, content, flags=re.MULTILINE)

        # Write back only if content changed
        if modified_content != content:
            with open(file, "w", encoding="utf-8") as f:
                f.write(modified_content)
            print(f"Updated {file}: {pattern} -> {replace}")
        else:
            print(f"No changes needed in {file} for pattern: {pattern}")

    except Exception as ex:
        raise RuntimeError(f"Error processing file {file}: {str(ex)}") from ex


def sed_remove(file: str, pattern: str) -> None:
    """Remove lines matching pattern from a file using Python instead of shell commands"""
    try:
        # Validate file path
        if not os.path.exists(file):
            # Silently return if file doesn't exist (common case for install.json)
            return

        # Read file content
        with open(file, "r", encoding="utf-8") as f:
            lines = f.readlines()

        # Filter out lines matching pattern
        filtered_lines = [line for line in lines if not re.search(pattern, line)]

        # Write back only if content changed
        if len(filtered_lines) != len(lines):
            with open(file, "w", encoding="utf-8") as f:
                f.writelines(filtered_lines)

    except Exception as ex:
        raise RuntimeError(f"Error processing file {file}: {str(ex)}") from ex


def validate_dependencies() -> None:
    """Error if a dependency is missing or invalid"""
    if not dependency_exists("git"):
        raise DependencyException("Please install git https://git-scm.com/downloads")
    if not dependency_exists("node"):
        raise DependencyException("Please install Node.js 22 https://nodejs.org/")
    if not dependency_exists("yarn"):
        raise DependencyException("Please install yarn https://classic.yarnpkg.com/")
    if not dependency_exists("twine"):
        raise DependencyException("Please install twine https://twine.readthedocs.io/en/latest/#installation")


def validate_environment() -> None:
    """Validate environment configurations are valid"""
    pass


def update_version_to_release() -> None:
    global config

    old_version = config.old_version
    old_npm_version = config.old_npm_version
    new_version = config.new_version
    new_npm_version = config.new_npm_version

    try:
        # Update backend version
        sed(
            _source(".bumpversion.cfg"),
            rf"^current_version\s*=\s*{re.escape(old_version)}",
            f"current_version = {new_version}",
        )
        sed(
            _source("elyra/_version.py"),
            rf'^__version__\s*=\s*"{re.escape(old_version)}"',
            f'__version__ = "{new_version}"',
        )
        sed(_source("README.md"), rf"elyra {re.escape(old_version)}", f"elyra {new_version}")
        sed(
            _source("docs/source/getting_started/installation.md"),
            rf"elyra {re.escape(old_version)}",
            f"elyra {new_version}",
        )

        # Update docker related tags
        sed(_source("Makefile"), r"^TAG:=dev", f"TAG:={new_version}")
        sed(_source("README.md"), r"elyra:dev ", f"elyra:{new_version} ")
        sed(_source("etc/docker/kubeflow/README.md"), r"kf-notebook:dev", f"kf-notebook:{new_version}")
        sed(_source("docs/source/getting_started/installation.md"), r"elyra:dev ", f"elyra:{new_version} ")
        sed(_source("docs/source/recipes/configure-airflow-as-a-runtime.md"), r"main", f"{config.tag}")
        sed(_source("docs/source/recipes/deploying-elyra-in-a-jupyterhub-environment.md"), r"dev", f"{new_version}")
        sed(_source("docs/source/recipes/using-elyra-with-kubeflow-notebook-server.md"), r"main", f"{new_version}")

        # Update UI component versions
        sed(_source("README.md"), rf"v{re.escape(old_npm_version)}", f"v{new_version}")
        sed(
            _source("docs/source/getting_started/installation.md"), rf"v{re.escape(old_npm_version)}", f"v{new_version}"
        )

        sed(
            _source("packages/theme/src/index.ts"),
            re.escape("https://elyra.readthedocs.io/en/latest/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
        )

        sed(
            _source("packages/theme/src/index.ts"),
            re.escape("https://github.com/elyra-ai/elyra/releases/latest/"),
            rf"https://github.com/elyra-ai/elyra/releases/v{new_version}/",
        )

        sed(
            _source("packages/theme/src/index.ts"),
            r"What's new in latest",
            rf"What's new in v{new_version}",
        )

        sed(
            _source("elyra/cli/pipeline_app.py"),
            re.escape("https://elyra.readthedocs.io/en/latest/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
        )

        # Update documentation version for elyra-metadata cli help
        sed(
            _source("elyra/metadata/metadata_app_utils.py"),
            re.escape("https://elyra.readthedocs.io/en/latest/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
        )

        sed(
            _source("packages/pipeline-editor/src/EmptyPipelineContent.tsx"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("packages/pipeline-editor/src/PipelineEditorWidget.tsx"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        # update documentation references in schema definitions
        # located in elyra/metadata/schemas/
        sed(
            _source("elyra/metadata/schemas/url-catalog.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/local-directory-catalog.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/local-file-catalog.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/airflow.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/kfp.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/code-snippet.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        sed(
            _source("elyra/metadata/schemas/runtime-image.json"),
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
        )

        # Update documentation references in documentation
        sed(
            _source("docs/source/user_guide/jupyterlab-interface.md"),
            re.escape("https://elyra.readthedocs.io/en/latest/"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
        )

        # Update GitHub references in documentation
        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            r"elyra-ai/elyra/main/etc/kfp/pip.conf",
            rf"elyra-ai/elyra/v{new_version}/etc/kfp/pip.conf",
        )

        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            r"elyra-ai/elyra/main/elyra/kfp/bootstrapper.py",
            rf"elyra-ai/elyra/v{new_version}/elyra/kfp/bootstrapper.py",
        )

        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            r"elyra-ai/elyra/main/elyra/airflow/bootstrapper.py",
            rf"elyra-ai/elyra/v{new_version}/elyra/airflow/bootstrapper.py",
        )

        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            r"elyra-ai/elyra/main/etc/generic/requirements-elyra.txt",
            rf"elyra-ai/elyra/v{new_version}/etc/generic/requirements-elyra.txt",
        )

        # UI packages
        sed(
            _source("lerna.json"),
            rf"{old_npm_version}",
            rf"{new_npm_version}",
        )
        sed(
            _source("package.json"),
            rf"{old_npm_version}",
            rf"{new_npm_version}",
        )

        packages_dir = os.path.join(config.source_dir, "packages")
        if os.path.exists(packages_dir) and os.path.isdir(packages_dir):
            for dir in os.listdir(packages_dir):
                dir_path = os.path.join(packages_dir, dir)
                if os.path.isdir(dir_path):
                    file_path = os.path.join(dir_path, "package.json")
                    if os.path.exists(file_path):
                        sed(file_path, re.escape(old_npm_version), new_npm_version)
                    file_path = os.path.join(dir_path, "install.json")
                    if os.path.exists(file_path):
                        sed_remove(file_path, rf"packageManager")

    except Exception as ex:
        raise UpdateVersionException from ex


def update_version_to_dev() -> None:
    global config

    new_version = config.new_version
    dev_version = config.dev_version
    new_npm_version = config.new_npm_version
    dev_npm_version = config.dev_npm_version

    try:
        # Update backend version
        sed(
            _source(".bumpversion.cfg"),
            rf"^current_version\s*=\s*{re.escape(new_version)}",
            f"current_version = {dev_version}",
        )
        sed(
            _source("elyra/_version.py"),
            rf'^__version__\s*=\s*"{re.escape(new_version)}"',
            f'__version__ = "{dev_version}"',
        )
        sed(_source("README.md"), rf"elyra {re.escape(new_version)}", f"elyra {dev_version}")
        sed(
            _source("docs/source/getting_started/installation.md"),
            rf"elyra {re.escape(new_version)}",
            f"elyra {dev_version}",
        )

        # Update docker related tags
        sed(_source("Makefile"), rf"^TAG:={new_version}", "TAG:=dev")
        sed(_source("README.md"), rf"elyra:{new_version} ", "elyra:dev ")
        sed(_source("etc/docker/kubeflow/README.md"), rf"kf-notebook:{new_version}", "kf-notebook:dev")
        sed(_source("docs/source/getting_started/installation.md"), rf"elyra:{new_version} ", "elyra:dev ")
        sed(_source("docs/source/recipes/configure-airflow-as-a-runtime.md"), rf"{config.tag}", "main")
        sed(_source("docs/source/recipes/deploying-elyra-in-a-jupyterhub-environment.md"), rf"{new_version}", "dev")
        sed(_source("docs/source/recipes/using-elyra-with-kubeflow-notebook-server.md"), rf"{new_version}", "main")

        # Update UI component versions
        sed(_source("README.md"), rf"extension v{new_version}", f"extension v{dev_npm_version}")
        sed(
            _source("docs/source/getting_started/installation.md"),
            rf"extension v{new_version}",
            f"extension v{dev_npm_version}",
        )

        sed(
            _source("packages/theme/src/index.ts"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
            re.escape("https://elyra.readthedocs.io/en/latest/"),
        )

        sed(
            _source("packages/theme/src/index.ts"),
            rf"https://github.com/elyra-ai/elyra/releases/v{new_version}/",
            re.escape("https://github.com/elyra-ai/elyra/releases/latest/"),
        )

        sed(
            _source("packages/theme/src/index.ts"),
            rf"What's new in v{new_version}",
            rf"What's new in latest",
        )

        # Update documentation references in documentation
        sed(
            _source("docs/source/user_guide/jupyterlab-interface.md"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
            re.escape("https://elyra.readthedocs.io/en/latest/"),
        )

        sed(
            _source("elyra/cli/pipeline_app.py"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
            re.escape("https://elyra.readthedocs.io/en/latest/"),
        )

        # Update documentation version for elyra-metadata cli help
        sed(
            _source("elyra/metadata/metadata_app_utils.py"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/",
            re.escape("https://elyra.readthedocs.io/en/latest/"),
        )

        sed(
            _source("packages/pipeline-editor/src/EmptyPipelineContent.tsx"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("packages/pipeline-editor/src/PipelineEditorWidget.tsx"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        # Update GitHub references in documentation
        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            rf"elyra-ai/elyra/v{new_version}/etc/kfp/pip.conf",
            r"elyra-ai/elyra/main/etc/kfp/pip.conf",
        )
        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            rf"elyra-ai/elyra/v{new_version}/elyra/kfp/bootstrapper.py",
            r"elyra-ai/elyra/main/elyra/kfp/bootstrapper.py",
        )
        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            rf"elyra-ai/elyra/v{new_version}/elyra/airflow/bootstrapper.py",
            r"elyra-ai/elyra/main/elyra/airflow/bootstrapper.py",
        )
        sed(
            _source("docs/source/recipes/running-elyra-in-air-gapped-environment.md"),
            rf"elyra-ai/elyra/v{new_version}/etc/generic/requirements-elyra.txt",
            r"elyra-ai/elyra/main/etc/generic/requirements-elyra.txt",
        )

        # update documentation references in schema definitions
        # located in elyra/metadata/schemas/
        sed(
            _source("elyra/metadata/schemas/url-catalog.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/local-directory-catalog.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/local-file-catalog.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/airflow.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/kfp.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/code-snippet.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("elyra/metadata/schemas/runtime-image.json"),
            rf"https://elyra.readthedocs.io/en/v{new_version}/user_guide/",
            re.escape("https://elyra.readthedocs.io/en/latest/user_guide/"),
        )

        sed(
            _source("lerna.json"),
            rf"{new_npm_version}",
            rf"{dev_npm_version}",
        )

        sed(
            _source("package.json"),
            rf"{new_npm_version}",
            rf"{dev_npm_version}",
        )

        packages_dir = os.path.join(config.source_dir, "packages")
        if os.path.exists(packages_dir) and os.path.isdir(packages_dir):
            for dir in os.listdir(packages_dir):
                dir_path = os.path.join(packages_dir, dir)
                if os.path.isdir(dir_path):
                    file_path = os.path.join(dir_path, "package.json")
                    if os.path.exists(file_path):
                        sed(file_path, re.escape(new_npm_version), dev_npm_version)

    except Exception as ex:
        raise UpdateVersionException from ex


def _source(file: str) -> str:
    """Get absolute path for a file within the source directory with path validation"""
    global config

    # Validate input
    if not file or ".." in file or file.startswith("/"):
        raise ValueError(f"Invalid file path: {file}")

    # Create absolute path
    source_path = os.path.abspath(config.source_dir)
    file_path = os.path.abspath(os.path.join(source_path, file))

    # Ensure the resolved path is within source directory (prevent path traversal)
    if not file_path.startswith(source_path + os.sep) and file_path != source_path:
        raise ValueError(f"Path traversal attempt detected: {file}")

    return file_path


def checkout_code() -> None:
    global config

    print("-----------------------------------------------------------------")
    print("-------------------- Retrieving source code ---------------------")
    print("-----------------------------------------------------------------")

    print(f"Cloning repository: {config.git_url}")
    if os.path.exists(config.work_dir):
        print(f"Removing working directory: {config.work_dir}")
        # Validate work_dir path to prevent accidental deletion
        work_dir_abs = os.path.abspath(config.work_dir)
        if not work_dir_abs.endswith(("/build/release", "\\build\\release")) or len(work_dir_abs.split(os.sep)) < 3:
            raise ValueError(f"Unsafe work directory path: {work_dir_abs}")
        shutil.rmtree(config.work_dir)
    print(f"Creating working directory: {config.work_dir}")
    os.makedirs(config.work_dir)
    print(f"Cloning : {config.git_url} to {config.work_dir}")
    check_run(["git", "clone", config.git_url, "-b", config.git_branch], cwd=config.work_dir)
    check_run(["git", "config", "user.name", config.git_user_name], cwd=config.source_dir)
    check_run(["git", "config", "user.email", config.git_user_email], cwd=config.source_dir)

    print("")


def build_release():
    global config

    print("-----------------------------------------------------------------")
    print("----------------------- Building Release ------------------------")
    print("-----------------------------------------------------------------")

    # Build wheels and source packages
    print(f">>> Building release in {config.source_dir}")
    check_run(["make", "release"], cwd=config.source_dir, capture_output=False)

    if not config.pre_release:
        # Build container images from tagged release
        check_run(["git", "checkout", f"tags/v{config.new_version}"], cwd=config.source_dir, capture_output=False)
        check_run(["make", "container-images"], cwd=config.source_dir, capture_output=False)
        check_run(["git", "checkout", "main"], cwd=config.source_dir, capture_output=False)

    print("")


def build_server():
    global config

    print("-----------------------------------------------------------------")
    print("------------------------ Building Server ------------------------")
    print("-----------------------------------------------------------------")

    # update project name
    sed(_source("pyproject.toml"), r'name="elyra"', 'name="elyra-server"')
    sed(
        _source("pyproject.toml"),
        r'description="Elyra provides AI Centric extensions to JupyterLab"',
        'description="The elyra-server package provides common core libraries and functions that are required by '
        "Elyra's individual extensions. Note: Installing this package alone will not enable the use of Elyra. "
        "Please install the 'elyra' package instead. e.g. pip install elyra[all]\"",
    )

    # build server wheel
    check_run(["make", "build-server"], cwd=config.source_dir, capture_output=False)

    # revert project name
    check_run(["git", "reset", "--hard"], cwd=config.source_dir, capture_output=False)

    print("")


def show_release_artifacts():
    global config
    dist_dir = os.path.join(config.source_dir, "dist")

    print("-----------------------------------------------------------------")
    print("------------------------ Release Files --------------------------")
    print("-----------------------------------------------------------------")

    print("")
    print(f"Location \t {dist_dir}")
    print("")
    check_run(["ls", "-la", dist_dir], capture_output=False)
    print("")


def update_yarn_lock():
    global config

    print("-----------------------------------------------------------------")
    print("----------------------- Update Yarn lock ------------------------")
    print("-----------------------------------------------------------------")

    # Build wheels and source packages
    print(f">>> Updating yarn lock file in {config.source_dir}")
    check_run(["yarn", "install"], cwd=config.source_dir, capture_output=False)

    print("")


def copy_extension_dir(extension: str, work_dir: str) -> None:
    global config

    extension = extension.replace("-", "_")
    extension_package_source_dir = os.path.join(config.source_dir, f"labextensions/elyra_{extension}")
    extension_package_dest_dir = os.path.join(work_dir, f"labextensions/elyra_{extension}")
    print(f">>> Copying extension package from {extension_package_source_dir} to {extension_package_dest_dir}")
    os.makedirs(os.path.dirname(extension_package_dest_dir), exist_ok=True)
    shutil.copytree(extension_package_source_dir, extension_package_dest_dir)


def generate_changelog() -> None:
    global config

    print("-----------------------------------------------------------------")
    print("--------------------- Preparing Changelog -----------------------")
    print("-----------------------------------------------------------------")
    print("")

    changelog_path = os.path.join(config.source_dir, "docs/source/getting_started/changelog.md")
    changelog_backup_path = os.path.join(config.source_dir, "docs/source/getting_started/changelog.bak")
    if os.path.exists(changelog_backup_path):
        os.remove(changelog_backup_path)
    shutil.copy(changelog_path, changelog_backup_path)

    repo = git.Repo(config.source_dir)

    # define static header
    header_lines = [
        "# Changelog\n",
        "\n",
        "A summary of new feature highlights is located on the [GitHub release page](https://github.com/elyra-ai/elyra/releases).\n",
        "\n",
    ]

    # Start generating the release header on top of the changelog
    with io.open(changelog_path, "r+") as changelog:
        # add static header
        for line in header_lines:
            changelog.write(line)
        # add release section
        changelog.write(f'## Release {config.new_version} - {datetime.now().strftime("%m/%d/%Y")}\n')
        changelog.write("\n")

        start = 0
        page_size = 10
        continue_paginating = True
        while continue_paginating:
            # paginate the list of commits until it finds the begining of release changes
            # which is denominated by a commit titled 'Prepare for next development iteration'
            commits = list(repo.iter_commits(max_count=page_size, skip=start))
            start += page_size
            for commit in commits:
                # for each commit, get it's title and prepare a changelog
                # entry linking to the related pull request
                commit_title = commit.message.splitlines()[0]
                # commit_hash = commit.hexsha
                # print(f'>>> {commit_hash} - {commit_title}')
                if commit_title != "Prepare for next development iteration":
                    pr_string = ""
                    pr = re.findall(r"\(#(.*?)\)", commit_title)
                    if pr:
                        commit_title = re.sub(r"\(#(.*?)\)", "", commit_title).strip()
                        pr_string = f" - [#{pr[0]}](https://github.com/elyra-ai/elyra/pull/{pr[0]})"
                    changelog_entry = f"- {commit_title}{pr_string}\n"
                    changelog.write(changelog_entry)
                else:
                    # here it found the first commit of the release
                    # changelog for the release is done
                    # exit the loop
                    continue_paginating = False
                    break

        # copy the remaining changelog at the bottom of the new content
        with io.open(changelog_backup_path) as old_changelog:
            # ignore existing static header
            line = old_changelog.readline()
            while line and line.startswith("## Release") is False:
                line = old_changelog.readline()

            changelog.write("\n")

            while line:
                changelog.write(line)
                line = old_changelog.readline()


def prepare_extensions_release() -> None:
    global config

    print("-----------------------------------------------------------------")
    print("--------------- Preparing Individual Extensions -----------------")
    print("-----------------------------------------------------------------")

    extensions = {
        "elyra-code-snippet-extension": SimpleNamespace(
            packages=["code-snippet-extension", "metadata-extension", "theme-extension"],
            description=f"The Code Snippet editor extension adds support for reusable code fragments, "
            f"making programming in JupyterLab more efficient by reducing repetitive work. "
            f"See https://elyra.readthedocs.io/en/v{config.new_version}/user_guide/code-snippets.html",
        ),
        "elyra-pipeline-editor-extension": SimpleNamespace(
            packages=["pipeline-editor-extension", "metadata-extension", "theme-extension"],
            description=f"The Visual Editor Pipeline extension is used to build AI pipelines from notebooks, "
            f"Python scripts and R scripts, simplifying the conversion of multiple notebooks "
            f"or script files into batch jobs or workflows."
            f"See https://elyra.readthedocs.io/en/v{config.new_version}/user_guide/pipelines.html",
        ),
        "elyra-python-editor-extension": SimpleNamespace(
            packages=["python-editor-extension", "metadata-extension", "theme-extension", "script-debugger-extension"],
            description=f"The Python Script editor extension contains support for Python files, "
            f"which can take advantage of the Hybrid Runtime Support enabling users to "
            f"locally edit, execute and debug .py scripts against local or cloud-based resources."
            f"See https://elyra.readthedocs.io/en/v{config.new_version}/user_guide/enhanced-script-support.html",
        ),
        "elyra-r-editor-extension": SimpleNamespace(
            packages=["r-editor-extension", "metadata-extension", "theme-extension", "script-debugger-extension"],
            description=f"The R Script editor extension contains support for R files, which can take "
            f"advantage of the Hybrid Runtime Support enabling users to locally edit .R scripts "
            f"and execute them against local or cloud-based resources."
            f"See https://elyra.readthedocs.io/en/v{config.new_version}/user_guide/enhanced-script-support.html",
        ),
        "elyra-scala-editor-extension": SimpleNamespace(
            packages=["scala-editor-extension", "metadata-extension", "theme-extension", "script-debugger-extension"],
            description=f"The Scala Language editor extension contains support for Scala files, which can take "
            f"advantage of the Hybrid Runtime Support enabling users to locally edit .scala files "
            f"and execute them against local or cloud-based resources."
            f"See https://elyra.readthedocs.io/en/v{config.new_version}/user_guide/enhanced-script-support.html",
        ),
    }

    for extension in extensions:
        extension_source_dir = os.path.join(config.work_dir, extension)
        print(f"Preparing extension : {extension} at {extension_source_dir}")
        # copy extension package template to working directory
        if os.path.exists(extension_source_dir):
            print(f"Removing working directory: {extension_source_dir}")
            # Validate extension_source_dir path to prevent accidental deletion
            ext_dir_abs = os.path.abspath(extension_source_dir)
            work_dir_abs = os.path.abspath(config.work_dir)
            if not ext_dir_abs.startswith(work_dir_abs + os.sep):
