diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e3fcdd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.github/ +.gitignore +README.md +CHANGELOG.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..69a7968 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +default_language_version: + python: python3.10 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-case-conflict + - id: check-merge-conflict + +- repo: https://github.com/asottile/pyupgrade + rev: v3.1.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + +- repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + +- repo: https://github.com/asottile/blacken-docs + rev: v1.12.1 + hooks: + - id: blacken-docs + additional_dependencies: + - black==22.6.0 + +- repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + args: ["--profile", "black"] + +- repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + args: + - "--max-line-length=88" + - "--min-python-version=3.10" + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-tidy-imports + - flake8-typing-imports + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.982 + hooks: + - id: mypy + files: ^src/ + additional_dependencies: [types-requests, types-PyYAML] diff --git a/Dockerfile b/Dockerfile index 90ff38b..3a9d07e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8 +FROM python:3.10-slim-buster LABEL "com.github.actions.name"="GitHub Actions Version Updater" LABEL "com.github.actions.description"="GitHub Actions Version Updater updates GitHub Action versions in a repository and creates a pull request with the changes." @@ -9,11 +9,21 @@ LABEL "repository"="https://github.com/saadmk11/github-actions-version-updater" LABEL "homepage"="https://github.com/saadmk11/github-actions-version-updater" LABEL "maintainer"="saadmk11" -COPY requirements.txt /requirements.txt +RUN apt-get update \ + && apt-get install \ + -y \ + --no-install-recommends \ + --no-install-suggests \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* -RUN pip install -r requirements.txt +COPY ./requirements.txt . -COPY main.py /main.py +RUN pip install --no-cache-dir -r requirements.txt -RUN ["chmod", "+x", "/main.py"] -ENTRYPOINT ["python", "/main.py"] +COPY . ./app + +ENV PYTHONPATH "${PYTHONPATH}:/app" + +CMD ["python", "-m", "src.main"] diff --git a/main.py b/main.py deleted file mode 100644 index e4c59b4..0000000 --- a/main.py +++ /dev/null @@ -1,339 +0,0 @@ -import json -import os -import subprocess -import time -from functools import cached_property - -import requests -import yaml - - -class GitHubActionsVersionUpdater: - """Main class that checks for updates and creates pull request""" - - github_api_url = 'https://api.github.com' - github_url = 'https://github.com/' - action_label = 'uses' - - def __init__(self, repository, base_branch, token, commit_message=None, pr_title=None, ignore_actions=None): - self.repository = repository - self.base_branch = base_branch - self.token = token - self.commit_message = commit_message or 'Update GitHub Action Versions' - self.pr_title = pr_title or 'Update GitHub Action Versions' - self.ignore_actions = self.get_ignored_actions(ignore_actions) - self.workflow_updated = False - - @staticmethod - def get_ignored_actions(json_string): - """Validate json string and return a set of actions""" - try: - ignore = json.loads(json_string) - - if ( - isinstance(ignore, list) and - all(isinstance(item, str) for item in ignore) - ): - return set(ignore) - else: - print_message( - 'Input "ignore" must be a JSON array of strings', - message_type='error' - ) - except Exception: - print_message( - ( - 'Invalid input format for "ignore", ' - 'expected JSON array of strings' - ), - message_type='error' - ) - return set() - - @cached_property - def get_request_headers(self): - """Get headers for GitHub API request""" - headers = { - 'Accept': 'application/vnd.github.v3+json' - } - # if the user adds `token` add it to API Request - # required for `private` repositories and creating pull requests - if self.token: - headers.update({ - 'authorization': 'Bearer {token}'.format(token=self.token) - }) - - return headers - - def run(self): - """Entrypoint to the GitHub Action""" - workflow_paths = self.get_workflow_paths() - pull_request_body = set() - - if not workflow_paths: - print_message( - ( - f'No Workflow found in "{self.repository}". ' - f'Skipping GitHub Actions Version Update' - ), - message_type='warning' - ) - return - - if self.ignore_actions: - print_message(f'Actions "{self.ignore_actions}" will be skipped') - - for workflow_path in workflow_paths: - try: - with open(workflow_path, 'r+') as file: - print_message( - f'Checking "{workflow_path}" for updates', - message_type='group' - ) - - file_data = file.read() - updated_config = file_data - - data = yaml.load(file_data, Loader=yaml.FullLoader) - old_action_set = set(self.get_all_actions(data)) - # Remove ignored actions - old_action_set.difference_update(self.ignore_actions) - - for action in old_action_set: - try: - action_repository, version = action.split('@') - except Exception: - print_message( - ( - f'Action "{action}" seems to be in a wrong format, ' - 'We currently support only community actions' - ), - message_type='warning' - ) - continue - - latest_release = self.get_latest_release(action_repository) - - if not latest_release: - continue - - updated_action = ( - f'{action_repository}@{latest_release["tag_name"]}' - ) - - if action != updated_action: - print_message( - f'Found new version for "{action_repository}"' - ) - pull_request_body.add( - self.generate_pull_request_body_line( - action_repository, latest_release - ) - ) - print_message( - f'Updating "{action}" with "{updated_action}"' - ) - updated_config = updated_config.replace( - action, updated_action - ) - file.seek(0) - file.write(updated_config) - file.truncate() - self.workflow_updated = True - else: - print_message( - f'No updates found for "{action_repository}"' - ) - - print_message('', message_type='endgroup') - - except Exception: - print_message(f'Skipping "{workflow_path}"') - - if self.workflow_updated: - new_branch = self.create_new_branch() - - current_branch = subprocess.check_output( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'] - ) - - if new_branch in str(current_branch): - print_message('Create Pull Request', message_type='group') - - pull_request_body_str = ( - '### GitHub Actions Version Updates\n' + - ''.join(pull_request_body) - ) - self.create_pull_request(new_branch, pull_request_body_str) - - print_message('', message_type='endgroup') - else: - print_message('Everything is up-to-date! \U0001F389 \U0001F389') - - def create_new_branch(self): - """Create and push a new branch with the changes""" - print_message('Create New Branch', message_type='group') - - # Use timestamp to ensure uniqueness of the new branch - new_branch = f'gh-actions-update-{int(time.time())}' - - subprocess.run( - ['git', 'checkout', self.base_branch] - ) - subprocess.run( - ['git', 'checkout', '-b', new_branch] - ) - subprocess.run(['git', 'add', '.']) - subprocess.run( - ['git', 'commit', '-m', self.commit_message] - ) - - subprocess.run(['git', 'push', '-u', 'origin', new_branch]) - - print_message('', message_type='endgroup') - - return new_branch - - def create_pull_request(self, branch_name, body): - """Create pull request on GitHub""" - url = f'{self.github_api_url}/repos/{self.repository}/pulls' - payload = { - 'title': self.pr_title, - 'head': branch_name, - 'base': self.base_branch, - 'body': body, - } - - response = requests.post( - url, json=payload, headers=self.get_request_headers - ) - - if response.status_code == 201: - html_url = response.json()['html_url'] - print_message(f'Pull request opened at {html_url} \U0001F389') - else: - msg = ( - f'Could not create a pull request on ' - f'{self.repository}, status code: {response.status_code}' - ) - print_message(msg, message_type='warning') - - def generate_pull_request_body_line(self, action_repository, latest_release): - """Generate pull request body line for pull request body""" - return ( - f"* **[{action_repository}]({self.github_url + action_repository})** " - "published a new release " - f"[{latest_release['tag_name']}]({latest_release['html_url']}) " - f"on {latest_release['published_at']}\n" - ) - - def get_latest_release(self, action_repository): - """Get latest release using GitHub API """ - url = f'{self.github_api_url}/repos/{action_repository}/releases/latest' - - response = requests.get(url, headers=self.get_request_headers) - data = {} - - if response.status_code == 200: - response_data = response.json() - - data = { - 'published_at': response_data['published_at'], - 'html_url': response_data['html_url'], - 'tag_name': response_data['tag_name'], - 'body': response_data['body'] - } - else: - # if there is no previous release API will return 404 Not Found - msg = ( - f'Could not find any release for ' - f'"{action_repository}", status code: {response.status_code}' - ) - print_message(msg, message_type='warning') - - return data - - def get_workflow_paths(self): - """Get all workflows of the repository using GitHub API """ - url = f'{self.github_api_url}/repos/{self.repository}/actions/workflows' - - response = requests.get(url, headers=self.get_request_headers) - data = [] - - if response.status_code == 200: - response_data = response.json() - - for workflow in response_data['workflows']: - data.append(workflow['path']) - else: - msg = ( - f'An error occurred while getting workflows for' - f'{self.repository}, status code: {response.status_code}' - ) - print_message(msg, message_type='error') - - return data - - def get_all_actions(self, config): - """Recursively get all action names from config""" - if isinstance(config, dict): - for key, value in config.items(): - if key == self.action_label: - yield value - elif isinstance(value, dict) or isinstance(value, list): - for item in self.get_all_actions(value): - yield item - - elif isinstance(config, list): - for element in config: - for item in self.get_all_actions(element): - yield item - - -def print_message(message, message_type=None): - """Helper function to print colorful outputs in GitHub Actions shell""" - # docs: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions - if not message_type: - return subprocess.run(['echo', f'{message}']) - - if message_type == 'endgroup': - return subprocess.run(['echo', '::endgroup::']) - - return subprocess.run(['echo', f'::{message_type}::{message}']) - - -if __name__ == '__main__': - # Default environment variable from GitHub - # https://docs.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables - repository = os.environ['GITHUB_REPOSITORY'] - base_branch = os.environ['GITHUB_REF'] - # Token provided from the workflow - token = os.environ.get('INPUT_TOKEN') - # Committer username and email address - username = os.environ['INPUT_COMMITTER_USERNAME'] - email = os.environ['INPUT_COMMITTER_EMAIL'] - # Actions that should not be updated - ignore = os.environ['INPUT_IGNORE'] - # Commit message - commit_message = os.environ['INPUT_COMMIT_MESSAGE'] - # Pull Request Title - pr_title = os.environ['INPUT_PULL_REQUEST_TITLE'] - - # Group: Configure Git - print_message('Configure Git', message_type='group') - - subprocess.run(['git', 'config', 'user.name', username]) - subprocess.run(['git', 'config', 'user.email', email]) - - print_message('', message_type='endgroup') - - # Group: Run Update GitHub Actions - print_message('Update GitHub Actions', message_type='group') - - # Initialize GitHubActionsVersionUpdater - actions_version_updater = GitHubActionsVersionUpdater( - repository, base_branch, token, commit_message=commit_message, pr_title=pr_title, ignore_actions=ignore - ) - actions_version_updater.run() - - print_message('', message_type='endgroup') diff --git a/requirements.txt b/requirements.txt index 0ec7f43..4c2cbb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -PyYAML==5.4.1 -requests==2.25.1 +PyYAML~=6.0.0 +requests~=2.28.1 +github-action-utils~=1.0.2 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..deede37 --- /dev/null +++ b/src/config.py @@ -0,0 +1,93 @@ +import json +from collections.abc import Mapping +from typing import Any, NamedTuple + +import github_action_utils as gha_utils # type: ignore + + +class ActionEnvironment(NamedTuple): + repository: str + base_branch: str + event_name: str + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> "ActionEnvironment": + return cls( + repository=env["GITHUB_REPOSITORY"], + base_branch=env["GITHUB_REF"], + event_name=env["GITHUB_EVENT_NAME"], + ) + + +class Configuration(NamedTuple): + """Configuration class for GitHub Actions Version Updater""" + + github_token: str | None = None + git_committer_username: str = "github-actions[bot]" + git_committer_email: str = "github-actions[bot]@users.noreply.github.com" + pull_request_title: str = "Update GitHub Action Versions" + commit_message: str = "Update GitHub Action Versions" + ignore_actions: set[str] = set() + + @property + def git_commit_author(self) -> str: + """git_commit_author option""" + return f"{self.git_committer_username} <{self.git_committer_email}>" + + @classmethod + def create(cls, env: Mapping[str, str | None]) -> "Configuration": + """ + Create a Configuration object from environment variables + """ + cleaned_user_config: dict[str, Any] = cls.clean_user_config( + cls.get_user_config(env) + ) + return cls(**cleaned_user_config) + + @classmethod + def get_user_config(cls, env: Mapping[str, str | None]) -> dict[str, str | None]: + """ + Read user provided input and return user configuration + """ + user_config: dict[str, str | None] = { + "github_token": env.get("INPUT_TOKEN"), + "git_committer_username": env.get("INPUT_COMMITTER_USERNAME"), + "git_committer_email": env.get("INPUT_COMMITTER_EMAIL"), + "pull_request_title": env.get("INPUT_PULL_REQUEST_TITLE"), + "commit_message": env.get("INPUT_COMMIT_MESSAGE"), + "ignore_actions": env.get("INPUT_IGNORE"), + } + return user_config + + @classmethod + def clean_user_config(cls, user_config: dict[str, str | None]) -> dict[str, Any]: + cleaned_user_config: dict[str, Any] = {} + + for key, value in user_config.items(): + if key in cls._fields: + cleaned_value = getattr(cls, f"clean_{key.lower()}", lambda x: x)(value) + + if cleaned_value is not None: + cleaned_user_config[key] = cleaned_value + + return cleaned_user_config + + @staticmethod + def clean_ignore_actions(value: Any) -> set[str] | None: + if isinstance(value, str) and value.startswith("[") and value.endswith("]"): + ignore_actions = json.loads(value) + + if isinstance(ignore_actions, list) and all( + isinstance(item, str) for item in ignore_actions + ): + return set(ignore_actions) + else: + gha_utils.error( + "Invalid input for `ignore` field, " + f"expected JSON array of strings but got `{value}`" + ) + raise SystemExit(1) + elif isinstance(value, str): + return {s.strip() for s in value.split(",")} + else: + return None diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..30085e6 --- /dev/null +++ b/src/main.py @@ -0,0 +1,217 @@ +import os +import time +from collections.abc import Generator +from typing import Any + +import github_action_utils as gha_utils # type: ignore +import requests +import yaml + +from .config import ActionEnvironment, Configuration +from .run_git import configure_git_author, create_new_git_branch, git_commit_changes +from .utils import create_pull_request, display_whats_new, get_request_headers + + +class GitHubActionsVersionUpdater: + """Main class that checks for updates and creates pull request""" + + github_api_url = "https://api.github.com" + github_url = "https://github.com/" + action_label = "uses" + + def __init__(self, env: ActionEnvironment, user_config: Configuration): + self.env = env + self.user_config = user_config + + def run(self) -> None: + """Entrypoint to the GitHub Action""" + workflow_paths = self.get_workflow_paths() + pull_request_body = set() + workflow_updated = False + + if not workflow_paths: + gha_utils.warning( + f'No Workflow found in "{self.env.repository}". ' + "Skipping GitHub Actions Version Update" + ) + raise SystemExit(0) + + ignore_actions = self.user_config.ignore_actions + + if ignore_actions: + gha_utils.echo(f'Actions "{ignore_actions}" will be skipped') + + for workflow_path in workflow_paths: + try: + with open(workflow_path, "r+") as file, gha_utils.group( + f'Checking "{workflow_path}" for updates' + ): + file_data = file.read() + updated_config = file_data + + data = yaml.load(file_data, Loader=yaml.FullLoader) + old_action_set = set(self.get_all_actions(data)) + # Remove ignored actions + old_action_set.difference_update(ignore_actions) + + for action in old_action_set: + try: + action_repository, version = action.split("@") + except ValueError: + gha_utils.warning( + f'Action "{action}" is in a wrong format, ' + "We only support community actions currently" + ) + continue + + latest_release = self.get_latest_release(action_repository) + + if not latest_release: + continue + + updated_action = ( + f'{action_repository}@{latest_release["tag_name"]}' + ) + + if action != updated_action: + gha_utils.notice( + f'Found new version for "{action_repository}"' + ) + pull_request_body.add( + self.generate_pull_request_body_line( + action_repository, latest_release + ) + ) + gha_utils.echo( + f'Updating "{action}" with "{updated_action}"' + ) + updated_config = updated_config.replace( + action, updated_action + ) + file.seek(0) + file.write(updated_config) + file.truncate() + workflow_updated = True + else: + gha_utils.notice( + f'No updates found for "{action_repository}"' + ) + except Exception: + gha_utils.echo(f'Skipping "{workflow_path}"') + + if workflow_updated: + # Use timestamp to ensure uniqueness of the new branch + new_branch_name = f"gh-actions-update-{int(time.time())}" + create_new_git_branch(self.env.base_branch, new_branch_name) + git_commit_changes( + self.user_config.commit_message, + self.user_config.git_commit_author, + new_branch_name, + ) + + pull_request_body_str = "### GitHub Actions Version Updates\n" + "".join( + pull_request_body + ) + create_pull_request( + self.user_config.pull_request_title, + self.env.repository, + self.env.base_branch, + new_branch_name, + pull_request_body_str, + self.user_config.github_token, + ) + gha_utils.append_job_summary(pull_request_body_str) + else: + gha_utils.notice("Everything is up-to-date! \U0001F389 \U0001F389") + + def generate_pull_request_body_line( + self, action_repository: str, latest_release: dict[str, str] + ) -> str: + """Generate pull request body line for pull request body""" + return ( + f"* **[{action_repository}]({self.github_url + action_repository})** " + "published a new release " + f"[{latest_release['tag_name']}]({latest_release['html_url']}) " + f"on {latest_release['published_at']}\n" + ) + + def get_latest_release(self, action_repository: str) -> dict[str, str]: + """Get the latest release using GitHub API""" + url = f"{self.github_api_url}/repos/{action_repository}/releases/latest" + + response = requests.get( + url, headers=get_request_headers(self.user_config.github_token) + ) + data = {} + + if response.status_code == 200: + response_data = response.json() + + data = { + "published_at": response_data["published_at"], + "html_url": response_data["html_url"], + "tag_name": response_data["tag_name"], + "body": response_data["body"], + } + else: + # if there is no previous release API will return 404 Not Found + gha_utils.warning( + f"Could not find any release for " + f'"{action_repository}", status code: {response.status_code}' + ) + + return data + + def get_workflow_paths(self) -> list[str]: + """Get all workflows of the repository using GitHub API""" + url = f"{self.github_api_url}/repos/{self.env.repository}/actions/workflows" + + response = requests.get( + url, headers=get_request_headers(self.user_config.github_token) + ) + + if response.status_code == 200: + return [workflow["path"] for workflow in response.json()["workflows"]] + + gha_utils.error( + f"An error occurred while getting workflows for" + f"{self.env.repository}, status code: {response.status_code}" + ) + raise SystemExit(1) + + def get_all_actions(self, data: Any) -> Generator[str, None, None]: + """Recursively get all action names from config""" + if isinstance(data, dict): + for key, value in data.items(): + if key == self.action_label: + yield value + elif isinstance(value, dict) or isinstance(value, list): + yield from self.get_all_actions(value) + + elif isinstance(data, list): + for element in data: + yield from self.get_all_actions(element) + + +if __name__ == "__main__": + with gha_utils.group("Parse Configuration"): + user_configuration = Configuration.create(os.environ) + action_environment = ActionEnvironment.from_env(os.environ) + + gha_utils.echo("Using Configuration:") + gha_utils.echo(user_configuration._asdict()) + + # Configure Git Author + configure_git_author( + user_configuration.git_committer_username, + user_configuration.git_committer_email, + ) + + with gha_utils.group("Run GitHub Actions Version Updater"): + actions_version_updater = GitHubActionsVersionUpdater( + action_environment, + user_configuration, + ) + actions_version_updater.run() + + display_whats_new() diff --git a/src/run_git.py b/src/run_git.py new file mode 100644 index 0000000..699b223 --- /dev/null +++ b/src/run_git.py @@ -0,0 +1,56 @@ +import subprocess + +import github_action_utils as gha_utils # type: ignore + + +def configure_git_author(username: str, email: str) -> None: + """ + Configure the git author. + """ + with gha_utils.group("Configure Git Author"): + gha_utils.notice(f"Setting Git Commit User to '{username}'.") + gha_utils.notice(f"Setting Git Commit email to '{email}'.") + + run_subprocess_command(["git", "config", "user.name", username]) + run_subprocess_command(["git", "config", "user.email", email]) + + +def create_new_git_branch(base_branch_name: str, new_branch_name: str) -> None: + """ + Create a new git branch from base branch. + """ + with gha_utils.group( + f"Create New Branch ({base_branch_name} -> {new_branch_name})" + ): + run_subprocess_command(["git", "checkout", base_branch_name]) + run_subprocess_command(["git", "checkout", "-b", new_branch_name]) + + +def git_commit_changes( + commit_message: str, commit_author: str, commit_branch_name: str +) -> None: + """ + Commit the changed files. + """ + with gha_utils.group("Commit Changes"): + gha_utils.echo("Git Status:") + run_subprocess_command(["git", "status"]) + + gha_utils.echo("Git Diff:") + run_subprocess_command(["git", "diff"]) + + run_subprocess_command(["git", "add", "."]) + run_subprocess_command( + ["git", "commit", f"--author={commit_author}", "-m", commit_message] + ) + run_subprocess_command(["git", "push", "-u", "origin", commit_branch_name]) + + +def run_subprocess_command(command: list[str]) -> None: + result = subprocess.run(command, capture_output=True, text=True) + + if result.returncode != 0: + gha_utils.error(result.stderr) + raise SystemExit(result.returncode) + + gha_utils.echo(result.stdout) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..b16a780 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,86 @@ +from functools import lru_cache + +import github_action_utils as gha_utils # type: ignore +import requests + + +@lru_cache +def get_request_headers(github_token: str | None = None) -> dict[str, str]: + """Get headers for GitHub API request""" + headers = {"Accept": "application/vnd.github.v3+json"} + + if github_token: + headers.update({"authorization": f"Bearer {github_token}"}) + + return headers + + +def create_pull_request( + pull_request_title: str, + repository_name: str, + base_branch_name: str, + head_branch_name: str, + body: str, + github_token: str | None = None, +) -> None: + """Create pull request on GitHub""" + with gha_utils.group("Create Pull Request"): + url = f"https://api.github.com/repos/{repository_name}/pulls" + payload = { + "title": pull_request_title, + "head": head_branch_name, + "base": base_branch_name, + "body": body, + } + + response = requests.post( + url, json=payload, headers=get_request_headers(github_token) + ) + + if response.status_code == 201: + html_url = response.json()["html_url"] + gha_utils.notice(f"Pull request opened at {html_url} \U0001F389") + else: + gha_utils.error( + f"Could not create a pull request on " + f"{repository_name}, status code: {response.status_code}" + ) + raise SystemExit(1) + + +def display_whats_new() -> None: + """ + Print what's new in GitHub Actions Version Updater Latest Version + """ + url = ( + "https://api.github.com/repos" + "/saadmk11/github-actions-version-updater" + "/releases/latest" + ) + response = requests.get(url) + + if response.status_code == 200: + response_data = response.json() + latest_release_tag = response_data["tag_name"] + latest_release_html_url = response_data["html_url"] + latest_release_body = response_data["body"] + + group_title = ( + "\U0001F389 What's New In " + f"GitHub Actions Version Updater {latest_release_tag} \U0001F389" + ) + + with gha_utils.group(group_title): + gha_utils.echo(latest_release_body) + gha_utils.echo( + f"Get More Information about '{latest_release_tag}' " + f"Here: {latest_release_html_url}" + ) + gha_utils.echo( + "\nTo use these features please upgrade to " + f"version '{latest_release_tag}' if you haven't already." + ) + gha_utils.echo( + "\nReport Bugs or Add Feature Requests Here: " + "https://github.com/saadmk11/github-actions-version-updater/issues" + )