diff --git a/README.md b/README.md index a7b1771..759429a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A GH action for validating version tag sequences and ensuring compliance with ve ## Motivation This action is designed to help maintainers and contributors ensure that version tags are sequenced correctly and comply with versioning standards. It can be used to prevent common issues such as: +- Duplicate version tags on input - Missing version tags - Incorrect version sequences - Non-standard version formats @@ -41,21 +42,6 @@ This action is designed to help maintainers and contributors ensure that version - **Description**: The version tag to check for in the repository. Example: `v0.1.0`. - **Required**: Yes -### `branch` -- **Description**: The branch to check for the version tag. Example: `master`. -- **Required**: Yes - -### `fails-on-error` -- **Description**: Whether the action should fail if an error occurs. -- **Required**: No -- **Default**: `true` - -## Outputs - -### `valid` -- **Description**: Whether the version tag is valid. -- **Value**: `true` or `false` - ## Usage ### Adding the Action to Your Workflow @@ -73,10 +59,8 @@ See the default action step definition: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - github-repository: "{ org }/{ repo }" + github-repository: "{ org }/{ repo }" # e.g. ${{ github.repository }} version-tag: "v0.1.0" - branch: "master" - fails-on-error: "false" ``` ### Supported Version Tags Formats @@ -215,22 +199,10 @@ fi # Set necessary environment variables export INPUT_GITHUB_TOKEN="$GITHUB_TOKEN" export INPUT_VERSION_TAG="v1.2.3" -export INPUT_BRANCH="main" -export INPUT_FAILS_ON_ERROR="true" export INPUT_GITHUB_REPOSITORY="AbsaOSS/generate-release-notes" -export GITHUB_OUTPUT="output.txt" # File to capture outputs - -# Remove existing output file if it exists -if [ -f "$GITHUB_OUTPUT" ]; then - rm "$GITHUB_OUTPUT" -fi # Run the main script python main.py - -# Display the outputs -echo "Action Outputs:" -cat "$GITHUB_OUTPUT" ``` ## Contribution Guidelines diff --git a/action.yml b/action.yml index 6910bae..0aa5c01 100644 --- a/action.yml +++ b/action.yml @@ -23,18 +23,6 @@ inputs: version-tag: description: 'The version tag sequence to validate. Example: "v1.2.3".' required: true - branch: - description: 'The branch to check for the latest version tag. Example: "master".' - required: true - fails-on-error: - description: 'Set to "true" to fail the action if validation errors are found.' - required: false - default: 'true' - -outputs: - valid: - description: 'Indicates whether the version tag sequence is valid ("true" or "false").' - value: ${{ steps.version-tag-check.outputs.valid }} branding: icon: 'book' @@ -72,8 +60,6 @@ runs: INPUT_GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} INPUT_GITHUB_REPOSITORY: ${{ inputs.github-repository }} INPUT_VERSION_TAG: ${{ inputs.version-tag }} - INPUT_BRANCH: ${{ inputs.branch }} - INPUT_FAILS_ON_ERROR: ${{ inputs.fails-on-error }} run: | source .venv/bin/activate python ${{ github.action_path }}/main.py diff --git a/tests/test_version_tag_check_action.py b/tests/test_version_tag_check_action.py index b4588bc..4ba9335 100644 --- a/tests/test_version_tag_check_action.py +++ b/tests/test_version_tag_check_action.py @@ -19,6 +19,7 @@ import os import pytest +from version_tag_check.version import Version from version_tag_check.version_tag_check_action import VersionTagCheckAction @@ -28,7 +29,6 @@ ("INPUT_GITHUB_TOKEN", "Failure: GITHUB_TOKEN is not set correctly."), ("INPUT_GITHUB_REPOSITORY", "Failure: GITHUB_REPOSITORY is not set correctly."), ("INPUT_VERSION_TAG", "Failure: VERSION_TAG is not set correctly."), - ("INPUT_BRANCH", "Failure: BRANCH is not set correctly."), ]) def test_validate_inputs_missing_variables(monkeypatch, caplog, missing_var, error_message): # Set all required environment variables @@ -36,7 +36,6 @@ def test_validate_inputs_missing_variables(monkeypatch, caplog, missing_var, err "INPUT_GITHUB_TOKEN": "fake_token", "INPUT_GITHUB_REPOSITORY": "owner/repo", "INPUT_VERSION_TAG": "v1.0.0", - "INPUT_BRANCH": "main", } env_vars.pop(missing_var) # Remove the variable to test if missing_var in os.environ.keys(): @@ -60,153 +59,83 @@ def test_validate_inputs_missing_variables(monkeypatch, caplog, missing_var, err # run -def test_run_successful(mocker, tmp_path): +@pytest.mark.parametrize( + "version_tag, existing_tags", + [ + ("v0.0.1", []), # New version with no existing tags + ("v0.1.0", []), # New version with no existing tags + ("v1.0.0", []), # New version with no existing tags + ("v0.0.2", [Version("v0.0.1")]), # Patch increment + ("v1.2.0", [Version("v1.1.0")]), # Minor increment + ("v2.0.0", [Version("v1.9.9")]), # Major increment + ("v2.1.0", [Version("v2.0.5"), Version("v2.0.0")]), # New Release serie + ("v1.5.2", [Version("v2.0.0"), Version("v1.5.1")]), # Backport increment + ], +) +def test_run_successful(mocker, version_tag, existing_tags): # Set environment variables env_vars = { "INPUT_GITHUB_TOKEN": "fake_token", - "INPUT_VERSION_TAG": "v1.0.1", - "INPUT_BRANCH": "main", - "INPUT_FAILS_ON_ERROR": "true", + "INPUT_VERSION_TAG": version_tag, "INPUT_GITHUB_REPOSITORY": "owner/repo", } - # Update os.environ with the test environment variables os.environ.update(env_vars) - if os.path.exists("output.txt"): - os.remove("output.txt") # Mock sys.exit to prevent the test from exiting mock_exit = mocker.patch("sys.exit") - # Mock the Version class - mock_version_class = mocker.patch("version_tag_check.version_tag_check_action.Version") - mock_version_instance = mock_version_class.return_value - mock_version_instance.is_valid_format.return_value = True - # Mock the GitHubRepository class mock_repository_class = mocker.patch("version_tag_check.version_tag_check_action.GitHubRepository") mock_repository_instance = mock_repository_class.return_value - mock_repository_instance.get_all_tags.return_value = [] - - # Mock the NewVersionValidator class - mock_validator_class = mocker.patch("version_tag_check.version_tag_check_action.NewVersionValidator") - mock_validator_instance = mock_validator_class.return_value - mock_validator_instance.is_valid_increment.return_value = True - - # Mock the output writing method - mock_output = mocker.patch("version_tag_check.version_tag_check_action.VersionTagCheckAction.write_output") + mock_repository_instance.get_all_tags.return_value = existing_tags # Run the action action = VersionTagCheckAction() action.run() - mock_output.assert_called_once_with("true") mock_exit.assert_called_once_with(0) -def test_run_invalid_version_format(mocker, tmp_path, caplog): + +@pytest.mark.parametrize( + "version_tag, existing_tags, is_valid_format, is_valid_increment, expected_exit_code, error_message", + [ + ("invalid_version", [], False, True, 1, "Tag does not match the required format"), # Invalid format + ("invalid_version", [Version("v1.0.0")], False, True, 1, "Tag does not match the required format"), # Invalid format + ("v1.0.3", [Version("v1.0.1")], True, False, 1, "New tag v1.0.3 is not one patch higher than the latest tag v1.0.1."), # Invalid increment + ("v1.0.0", [Version("v1.0.0")], True, False, 1, "The tag already exists in repository"), # Existing tag + ("v1.4.1", [Version("v2.0.0"), Version("v1.4.2")], True, False, 1, "New tag v1.4.1 is not one patch higher than the latest tag v1.4.2."), # Invalid backport increment + ("1.0.0", [], False, True, 1, "Tag does not match the required format"), # Invalid format and increment + ("v3.0.1", [Version("v2.9.9"), Version("v1.0.0")], True, False, 1, "New tag v3.0.1 is not a valid major bump. Latest version: v2.9.9."), # Invalid version gap + ], +) +def test_run_unsuccessful(mocker, caplog, version_tag, existing_tags, is_valid_format, is_valid_increment, expected_exit_code, error_message): # Set environment variables env_vars = { "INPUT_GITHUB_TOKEN": "fake_token", - "INPUT_VERSION_TAG": "invalid_version", - "INPUT_BRANCH": "main", - "INPUT_FAILS_ON_ERROR": "true", + "INPUT_VERSION_TAG": version_tag, "INPUT_GITHUB_REPOSITORY": "owner/repo", } os.environ.update(env_vars) - if os.path.exists("output.txt"): - os.remove("output.txt") - # Mock sys.exit + # Mock sys.exit to raise a SystemExit for assertion def mock_exit(code): raise SystemExit(code) mocker.patch("sys.exit", mock_exit) - # Mock the Version class used in VersionTagCheckAction - mock_version_class = mocker.patch("version_tag_check.version_tag_check_action.Version") - mock_version_instance = mock_version_class.return_value - mock_version_instance.is_valid_format.return_value = False # Simulate invalid format - - # Mock the output writing method - mock_output = mocker.patch("version_tag_check.version_tag_check_action.VersionTagCheckAction.write_output") - - # Run the action - caplog.set_level(logging.ERROR) - action = VersionTagCheckAction() - with pytest.raises(SystemExit) as e: - action.run() - - # Assert that sys.exit was called with exit code 1 - assert e.value.code == 1 - - mock_output.assert_called_once_with("false") - assert 'Tag does not match the required format' in caplog.text - -def test_run_invalid_version_increment(mocker, tmp_path): - # Set environment variables - env_vars = { - "INPUT_GITHUB_TOKEN": "fake_token", - "INPUT_VERSION_TAG": "v1.0.2", - "INPUT_BRANCH": "main", - "INPUT_FAILS_ON_ERROR": "true", - "INPUT_GITHUB_REPOSITORY": "owner/repo", - } - os.environ.update(env_vars) - if os.path.exists("output.txt"): - os.remove("output.txt") - - # Mock sys.exit - mock_exit = mocker.patch("sys.exit") - - # Mock the Version class - mock_version_class = mocker.patch("version_tag_check.version_tag_check_action.Version") - mock_version_instance = mock_version_class.return_value - mock_version_instance.is_valid_format.return_value = True - # Mock the GitHubRepository class mock_repository_class = mocker.patch("version_tag_check.version_tag_check_action.GitHubRepository") mock_repository_instance = mock_repository_class.return_value - mock_repository_instance.get_all_tags.return_value = [] # Simulate existing versions if needed - - # Mock the NewVersionValidator class to return False for is_valid_increment - mock_validator_class = mocker.patch("version_tag_check.version_tag_check_action.NewVersionValidator") - mock_validator_instance = mock_validator_class.return_value - mock_validator_instance.is_valid_increment.return_value = False - - # Mock the output writing method - mock_output = mocker.patch("version_tag_check.version_tag_check_action.VersionTagCheckAction.write_output") + mock_repository_instance.get_all_tags.return_value = existing_tags # Run the action + caplog.set_level(logging.ERROR) action = VersionTagCheckAction() - action.run() - - mock_output.assert_called_once_with("false") - mock_exit.assert_called_once_with(1) - - -# handle_failure - -def test_handle_failure_fails_on_error_false(mocker): - # Set environment variables with 'INPUT_FAILS_ON_ERROR' set to 'false' - env_vars = { - "INPUT_GITHUB_TOKEN": "fake_token", - "INPUT_VERSION_TAG": "v1.0.0", - "INPUT_BRANCH": "main", - "INPUT_FAILS_ON_ERROR": "false", # Set to 'false' to test else branch - "INPUT_GITHUB_REPOSITORY": "owner/repo", - } - mocker.patch.dict(os.environ, env_vars) - - # Mock sys.exit to raise SystemExit exception - def mock_exit(code): - raise SystemExit(code) - mocker.patch("sys.exit", mock_exit) - - # Instantiate the action - action = VersionTagCheckAction() - - # Call handle_failure and expect SystemExit with pytest.raises(SystemExit) as e: - action.handle_failure() + action.run() + + # Assert sys.exit was called with the correct code + assert e.value.code == expected_exit_code - # Assert that sys.exit was called with exit code 0 - assert e.value.code == 0 + # Assert error message in logs + assert error_message in caplog.text diff --git a/version_tag_check/github_repository.py b/version_tag_check/github_repository.py index 5d54a68..57ba321 100644 --- a/version_tag_check/github_repository.py +++ b/version_tag_check/github_repository.py @@ -45,6 +45,7 @@ def __init__(self, owner: str, repo: str, token: str) -> None: self.owner = owner self.repo = repo self.token = token + self.headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github.v3+json"} def get_all_tags(self) -> list[Version]: diff --git a/version_tag_check/version.py b/version_tag_check/version.py index 6c263e8..12c3cf6 100644 --- a/version_tag_check/version.py +++ b/version_tag_check/version.py @@ -32,7 +32,7 @@ class Version: Class to represent a version and compare it to other versions. """ - VERSION_REGEX = r"^v?(\d+)\.(\d+)\.(\d+)$" + VERSION_REGEX = r"^v(\d+)\.(\d+)\.(\d+)$" def __init__(self, version_str: str, version_regex: str = VERSION_REGEX) -> None: """ diff --git a/version_tag_check/version_tag_check_action.py b/version_tag_check/version_tag_check_action.py index 626d76f..bf434c5 100644 --- a/version_tag_check/version_tag_check_action.py +++ b/version_tag_check/version_tag_check_action.py @@ -41,8 +41,6 @@ def __init__(self) -> None: """ self.github_token: str = os.environ.get("INPUT_GITHUB_TOKEN", default="") self.version_tag_str: str = os.environ.get("INPUT_VERSION_TAG", default="") - self.branch: str = os.environ.get("INPUT_BRANCH", default="") - self.fails_on_error: bool = os.environ.get("INPUT_FAILS_ON_ERROR", "true").lower() == "true" self.github_repository: str = os.environ.get("INPUT_GITHUB_REPOSITORY", default="") self.__validate_inputs() @@ -58,42 +56,21 @@ def run(self) -> None: new_version = Version(self.version_tag_str) if not new_version.is_valid_format(): logger.error('Tag does not match the required format "v[0-9]+.[0-9]+.[0-9]+"') - self.handle_failure() + sys.exit(1) repository: GitHubRepository = GitHubRepository(self.owner, self.repo, self.github_token) existing_versions: list[Version] = repository.get_all_tags() + if new_version in existing_versions: + logger.error("The tag already exists in repository.") + sys.exit(1) + validator = NewVersionValidator(new_version, existing_versions) if validator.is_valid_increment(): - self.write_output("true") logger.info("New tag is valid.") sys.exit(0) else: - logger.error("New tag is not valid.") - self.handle_failure() - - def write_output(self, valid_value) -> None: - """ - Write the output to the file specified by the GITHUB_OUTPUT environment variable. - - @param valid_value: The value to write to the output file. - @return: None - """ - output_file = os.environ.get("GITHUB_OUTPUT", default="output.txt") - with open(output_file, "a", encoding="utf-8") as fh: - print(f"valid={valid_value}", file=fh) - - def handle_failure(self) -> None: - """ - Handle the failure of the action. - - @return: None - """ - self.write_output("false") - if self.fails_on_error: sys.exit(1) - else: - sys.exit(0) def __validate_inputs(self) -> None: """ @@ -112,7 +89,3 @@ def __validate_inputs(self) -> None: if len(self.version_tag_str) == 0: logger.error("Failure: VERSION_TAG is not set correctly.") sys.exit(1) - - if len(self.branch) == 0: - logger.error("Failure: BRANCH is not set correctly.") - sys.exit(1) diff --git a/version_tag_check/version_validator.py b/version_tag_check/version_validator.py index df5977e..b29f1ba 100644 --- a/version_tag_check/version_validator.py +++ b/version_tag_check/version_validator.py @@ -52,6 +52,20 @@ def __get_latest_version(self) -> Optional[Version]: return None return max(self.__existing_versions) + def __get_filtered_versions(self, major: int, minor: Optional[int] = None) -> list[Version]: + """ + Filter the existing versions based on major and optionally minor versions. + + @param major: The major version to filter by + @param minor: The minor version to filter by (optional) + @return: A list of versions matching the criteria + """ + return [ + version + for version in self.__existing_versions + if version.major == major and (minor is None or version.minor == minor) + ] + def is_valid_increment(self) -> bool: """ Check if the new version is a valid increment from the latest version. @@ -59,20 +73,35 @@ def is_valid_increment(self) -> bool: @return: True if the new version is a valid increment, False otherwise """ latest_version: Optional[Version] = self.__get_latest_version() + logger.debug("Validator: Latest version: %s", latest_version) if not latest_version: # Any version is valid if no previous versions exist + logger.info("No previous versions exist. New version is valid.") return True - lv: Optional[Version] = latest_version nv: Version = self.__new_version - if nv.major == lv.major: - if nv.minor == lv.minor: - return nv.patch == lv.patch + 1 - if nv.minor == lv.minor + 1: - return nv.patch == 0 - elif nv.major == lv.major + 1: - return nv.minor == 0 and nv.patch == 0 + # Filter versions matching the major and minor version of the new version + filtered_versions = self.__get_filtered_versions(nv.major, nv.minor) + if filtered_versions: + latest_filtered_version = max(filtered_versions) + logger.debug("Validator: Latest filtered version: %s", latest_filtered_version) + + # Validate against the latest filtered version + if nv.major == latest_filtered_version.major and nv.minor == latest_filtered_version.minor: + if nv.patch == latest_filtered_version.patch + 1: + return True + logger.error("New tag %s is not one patch higher than the latest tag %s.", nv, latest_filtered_version) + + # Check if this is a valid minor or major bump + if nv.major == latest_version.major: + if nv.minor == latest_version.minor + 1: + if nv.patch == 0: + return True + logger.error("New tag %s is not a valid minor bump. Latest version: %s.", nv, latest_version) + elif nv.major == latest_version.major + 1: + if nv.minor == 0 and nv.patch == 0: + return True + logger.error("New tag %s is not a valid major bump. Latest version: %s.", nv, latest_version) - logger.error("New tag %s is not one version higher than the latest tag %s.", self.__new_version, latest_version) return False