diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69a7968..b6655f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,4 +52,7 @@ repos: hooks: - id: mypy files: ^src/ - additional_dependencies: [types-requests, types-PyYAML] + additional_dependencies: + - types-requests + - types-PyYAML + - types-setuptools diff --git a/README.md b/README.md index a9b3dad..7241e3a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![GitHub stars](https://img.shields.io/github/stars/saadmk11/github-actions-version-updater?color=success&style=flat-square)](https://github.com/saadmk11/github-actions-version-updater/stargazers) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/saadmk11/github-actions-version-updater/Changelog%20CI?label=Changelog%20CI&style=flat-square) -**GitHub Actions Version Updater** is GitHub Action that is used to **Update All GitHub Actions** in a Repository +**GitHub Actions Version Updater** is a GitHub Action that is used to **Update All GitHub Actions** in a Repository and create a **pull request** with the updates (if enabled). It is an automated dependency updater similar to GitHub's **Dependabot** but for GitHub Actions. @@ -16,12 +16,20 @@ It is an automated dependency updater similar to GitHub's **Dependabot** but for in a repository and **checks for updates** for each of the action used in those workflows. * If an update is found and if that action is **not ignored** then the workflows are updated - with the **latest release** of the action being used. + with the **new version** of the action being used. * If at least one workflow file is updated then a new branch is created with the changes and pushed to GitHub. (If enabled) * Finally, a pull request is created with the newly created branch. (If enabled) +### Supported Version Fetch Sources: + +- **`release-tag`** (default): Uses **specific version tag** from **the latest release** to update a GitHub Action. (e.g. `actions/checkout@v1.2.3`) +- **`release-commit-sha`**: Uses **the latest release** tag **commit SHA** to update a GitHub Action. (e.g. `actions/checkout@c18e2a1b1a95d0c5c63af210857e8718a479f56f`) +- **`default-branch-sha`**: Uses **default branch** (e.g: `main`, `master`) **latest commit SHA** to update a GitHub Action. (e.g. `actions/checkout@c18e2a1b1a95d0c5c63af210857e8718a479f56f`) + +You can use `update_version_with` input option to select one of them. (e.g. `update_version_with: 'default-branch-sha'`) + ### Usage: We recommend running this action on a [`schedule`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#schedule) @@ -54,25 +62,30 @@ jobs: - name: Run GitHub Actions Version Updater uses: saadmk11/github-actions-version-updater@v0.5.6 with: - # Optional, This will be used to configure git + # [Required] Access token with `workflow` scope. + token: ${{ secrets.WORKFLOW_SECRET }} + # [Optional] This will be used to configure git # defaults to `github-actions[bot]` if not provided committer_username: 'test' committer_email: 'test@test.com' - # Optional, allows customizing the commit message and pull request title - # Both default to 'Update GitHub Action Versions' + # [Optional] Allows customizing the commit message + # defaults to 'Update GitHub Action Versions' commit_message: 'Commit Message' + # [Optional] Allows customizing the pull request title + # defaults to 'Update GitHub Action Versions' pull_request_title: 'Pull Request Title' - # Access token with `workflow` scope is required - token: ${{ secrets.WORKFLOW_SECRET }} - # Do not update these actions (Optional) + # [Optional] Do not update actions specified in this input # You need to add JSON array inside a string. e.g: '["actions/checkout@v2", "actions/cache@v2"]' # Or, add comma separated values. e.g: 'actions/checkout@v2, actions/cache@v2' ignore: 'actions/checkout@v2, actions/cache@v2' - # Optional, if set to 'true', the action will only check for updates and + # [Optional] If set to 'true', the action will only check for updates and # exit with a non-zero exit code if an update is found and update the build summary with the diff # otherwise it will create a pull request with the changes - # defaults to 'false' - skip_pull_request: 'true' + # options: 'false' (default), 'true' + skip_pull_request: 'false' + # [Optional] Use The Latest Release Tag/Commit SHA or Default Branch Commit SHA to update the actions + # options: "release-tag" (default), "release-commit-sha", "default-branch-sha"' + update_version_with: 'release-tag' ``` ### Important Note: diff --git a/action.yaml b/action.yaml index 42b86ac..783b0f3 100644 --- a/action.yaml +++ b/action.yaml @@ -32,6 +32,10 @@ inputs: description: 'Skip Pull Request creation' required: false default: 'false' + update_version_with: + description: 'Use Latest Release Tag/Commit SHA or Default Branch Commit SHA to update. options: "release-tag" (default), "release-commit-sha", "default-branch-sha"' + required: false + default: 'release-tag' runs: using: 'docker' image: 'Dockerfile' diff --git a/src/config.py b/src/config.py index 8346d0f..7bad5e5 100644 --- a/src/config.py +++ b/src/config.py @@ -4,6 +4,16 @@ import github_action_utils as gha_utils # type: ignore +LATEST_RELEASE_TAG = "release-tag" +LATEST_RELEASE_COMMIT_SHA = "release-commit-sha" +DEFAULT_BRANCH_COMMIT_SHA = "default-branch-sha" + +UPDATE_VERSION_WITH_LIST = [ + LATEST_RELEASE_TAG, + LATEST_RELEASE_COMMIT_SHA, + DEFAULT_BRANCH_COMMIT_SHA, +] + class ActionEnvironment(NamedTuple): repository: str @@ -29,6 +39,7 @@ class Configuration(NamedTuple): pull_request_title: str = "Update GitHub Action Versions" commit_message: str = "Update GitHub Action Versions" ignore_actions: set[str] = set() + update_version_with: str = LATEST_RELEASE_TAG @property def git_commit_author(self) -> str: @@ -58,6 +69,7 @@ def get_user_config(cls, env: Mapping[str, str | None]) -> dict[str, str | None] "pull_request_title": env.get("INPUT_PULL_REQUEST_TITLE"), "commit_message": env.get("INPUT_COMMIT_MESSAGE"), "ignore_actions": env.get("INPUT_IGNORE"), + "update_version_with": env.get("INPUT_UPDATE_VERSION_WITH"), } return user_config @@ -89,13 +101,26 @@ def clean_ignore_actions(value: Any) -> set[str] | None: f"expected JSON array of strings but got `{value}`" ) raise SystemExit(1) - elif isinstance(value, str): + elif value and isinstance(value, str): return {s.strip() for s in value.split(",")} else: return None @staticmethod - def clean_skip_pull_request(value: Any) -> bool: + def clean_skip_pull_request(value: Any) -> bool | None: if value in [1, "1", True, "true", "True"]: return True - return False + return None + + @staticmethod + def clean_update_version_with(value: Any) -> str | None: + if value and value not in UPDATE_VERSION_WITH_LIST: + gha_utils.error( + "Invalid input for `update_version_with` field, " + f"expected one of {UPDATE_VERSION_WITH_LIST} but got `{value}`" + ) + raise SystemExit(1) + elif value: + return value + else: + return None diff --git a/src/main.py b/src/main.py index 8fc69de..153c8bd 100644 --- a/src/main.py +++ b/src/main.py @@ -2,13 +2,20 @@ import pprint import time from collections.abc import Generator +from functools import cache from typing import Any import github_action_utils as gha_utils # type: ignore import requests import yaml +from pkg_resources import parse_version -from .config import ActionEnvironment, Configuration +from .config import ( + LATEST_RELEASE_COMMIT_SHA, + LATEST_RELEASE_TAG, + ActionEnvironment, + Configuration, +) from .run_git import ( configure_git_author, create_new_git_branch, @@ -36,7 +43,7 @@ def __init__(self, env: ActionEnvironment, user_config: Configuration): def run(self) -> None: """Entrypoint to the GitHub Action""" - workflow_paths = self.get_workflow_paths() + workflow_paths = self._get_workflow_paths() pull_request_body_lines = set() if not workflow_paths: @@ -54,64 +61,68 @@ def run(self) -> None: for workflow_path in workflow_paths: workflow_updated = False - try: - with open(workflow_path, "r+") as file, gha_utils.group( - f'Checking "{workflow_path}" for updates' - ): - file_data = file.read() - updated_workflow_data = file_data - - data = yaml.load(file_data, Loader=yaml.FullLoader) - all_action_set = set(self.get_all_actions(data)) - # Remove ignored actions - all_action_set.difference_update(ignore_actions) - - for action in all_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) + with open(workflow_path, "r+") as file, gha_utils.group( + f'Checking "{workflow_path}" for updates' + ): + file_data = file.read() + updated_workflow_data = file_data + + try: + workflow_data = yaml.load(file_data, Loader=yaml.FullLoader) + except yaml.YAMLError as exc: + gha_utils.error( + f"Error while parsing YAML from '{workflow_path}' file. " + f"Reason: {exc}" + ) + continue + + all_actions = set(self._get_all_actions(workflow_data)) + # Remove ignored actions + all_actions.difference_update(ignore_actions) + + for action in all_actions: + try: + action_repository, current_version = action.split("@") + except ValueError: + gha_utils.warning( + f'Action "{action}" is in a wrong format, ' + "We only support community actions currently" + ) + continue - if not latest_release: - continue + new_version, new_version_data = self._get_new_version( + action_repository + ) - updated_action = ( - f'{action_repository}@{latest_release["tag_name"]}' + if not new_version: + gha_utils.warning( + f"Could not find any new version for {action}. Skipping..." ) + continue - if action != updated_action: - gha_utils.echo( - f'Found new version for "{action_repository}"' - ) - pull_request_body_lines.add( - self.generate_pull_request_body_line( - action_repository, latest_release - ) - ) - gha_utils.echo( - f'Updating "{action}" with "{updated_action}"' - ) - updated_workflow_data = updated_workflow_data.replace( - action, updated_action - ) - workflow_updated = True - else: - gha_utils.echo( - f'No updates found for "{action_repository}"' + updated_action = f"{action_repository}@{new_version}" + + if action != updated_action: + gha_utils.echo(f'Found new version for "{action_repository}"') + pull_request_body_lines.add( + self._generate_pull_request_body_line( + action_repository, new_version_data ) + ) + gha_utils.echo( + f'Updating "{action}" with "{updated_action}"...' + ) + updated_workflow_data = updated_workflow_data.replace( + action, updated_action + ) + workflow_updated = True + else: + gha_utils.echo(f'No updates found for "{action_repository}"') - if workflow_updated: - file.seek(0) - file.write(updated_workflow_data) - file.truncate() - except Exception: - gha_utils.echo(f'Skipping "{workflow_path}"') + if workflow_updated: + file.seek(0) + file.write(updated_workflow_data) + file.truncate() if git_has_changes(): # Use timestamp to ensure uniqueness of the new branch @@ -140,51 +151,166 @@ def run(self) -> None: add_git_diff_to_job_summary() gha_utils.error( "Updates found but skipping pull request. " - "Checkout build summary for details." + "Checkout build summary for update details." ) raise SystemExit(1) 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] + def _generate_pull_request_body_line( + self, action_repository: str, version_data: 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" - ) + start = f"* **[{action_repository}]({self.github_url}{action_repository})**" - def get_latest_release(self, action_repository: str) -> dict[str, str]: + if self.user_config.update_version_with == LATEST_RELEASE_TAG: + return ( + f"{start} published a new release " + f"**[{version_data['tag_name']}]({version_data['html_url']})** " + f"on {version_data['published_at']}\n" + ) + elif self.user_config.update_version_with == LATEST_RELEASE_COMMIT_SHA: + return ( + f"{start} added a new " + f"**[commit]({version_data['commit_url']})** to " + f"**[{version_data['tag_name']}]({version_data['html_url']})** Tag " + f"on {version_data['commit_date']}\n" + ) + else: + return ( + f"{start} added a new " + f"**[commit]({version_data['commit_url']})** to " + f"**[{version_data['branch_name']}]({version_data['branch_url']})** " + f"branch on {version_data['commit_date']}\n" + ) + + def _get_latest_version_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" + url = f"{self.github_api_url}/repos/{action_repository}/releases?per_page=50" 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"], + if response_data: + # Sort through the releases (default 30 latest release) returned + # by GitHub API and find the latest version release + sorted_data = sorted( + response_data, key=lambda r: parse_version(r["tag_name"]) + )[-1] + return { + "published_at": sorted_data["published_at"], + "html_url": sorted_data["html_url"], + "tag_name": sorted_data["tag_name"], + } + + gha_utils.warning( + f"Could not find any release for " + f'"{action_repository}", status code: {response.status_code}' + ) + return {} + + def _get_commit_data( + self, action_repository: str, tag_or_branch_name: str + ) -> dict[str, str]: + """Get the commit Data for Tag or Branch using GitHub API""" + url = ( + f"{self.github_api_url}/repos" + f"/{action_repository}/commits?sha={tag_or_branch_name}" + ) + + response = requests.get( + url, headers=get_request_headers(self.user_config.github_token) + ) + + if response.status_code == 200: + response_data = response.json()[0] + + return { + "commit_sha": response_data["sha"], + "commit_url": response_data["html_url"], + "commit_date": response_data["commit"]["author"]["date"], } + + gha_utils.warning( + f"Could not find commit data for tag/branch {tag_or_branch_name} on " + f'"{action_repository}", status code: {response.status_code}' + ) + return {} + + def _get_default_branch_name(self, action_repository: str) -> str | None: + """Get the Action Repository's Default Branch Name using GitHub API""" + url = f"{self.github_api_url}/repos/{action_repository}" + + response = requests.get( + url, headers=get_request_headers(self.user_config.github_token) + ) + + if response.status_code == 200: + return response.json()["default_branch"] + + gha_utils.warning( + f"Could not find default branch for " + f'"{action_repository}", status code: {response.status_code}' + ) + return None + + # flake8: noqa: B019 + @cache + def _get_new_version( + self, action_repository: str + ) -> tuple[str | None, dict[str, str]]: + """Get the new version for the action""" + gha_utils.echo(f'Checking "{action_repository}" for updates...') + + if self.user_config.update_version_with == LATEST_RELEASE_TAG: + latest_release_data = self._get_latest_version_release(action_repository) + return latest_release_data.get("tag_name"), latest_release_data + + elif self.user_config.update_version_with == LATEST_RELEASE_COMMIT_SHA: + latest_release_data = self._get_latest_version_release(action_repository) + + if not latest_release_data: + return None, {} + + tag_commit_data = self._get_commit_data( + action_repository, latest_release_data["tag_name"] + ) + + if not tag_commit_data: + return None, {} + + return tag_commit_data["commit_sha"], { + **latest_release_data, + **tag_commit_data, + } + 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}' + default_branch_name = self._get_default_branch_name(action_repository) + + if not default_branch_name: + return None, {} + + branch_commit_data = self._get_commit_data( + action_repository, default_branch_name ) - return data + if not branch_commit_data: + return None, {} + + return branch_commit_data["commit_sha"], { + "branch_name": default_branch_name, + "branch_url": ( + f"{self.github_url}{action_repository}" + f"/tree/{default_branch_name}" + ), + **branch_commit_data, + } - def get_workflow_paths(self) -> list[str]: + 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" @@ -201,18 +327,18 @@ def get_workflow_paths(self) -> list[str]: ) raise SystemExit(1) - def get_all_actions(self, data: Any) -> Generator[str, None, None]: + def _get_all_actions(self, data: Any) -> Generator[str, None, None]: """Recursively get all action names from workflow data""" if isinstance(data, dict): for key, value in data.items(): if key == self.workflow_action_key: yield value elif isinstance(value, dict) or isinstance(value, list): - yield from self.get_all_actions(value) + yield from self._get_all_actions(value) elif isinstance(data, list): for element in data: - yield from self.get_all_actions(element) + yield from self._get_all_actions(element) if __name__ == "__main__":