diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5951051 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[report] + +exclude_lines = + if __name__ == .__main__.: \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..e9e59a4 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,28 @@ +name: Lint +on: [push, pull_request] +jobs: + test_and_lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.12", "3.x" ] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Analyse with pylint + run: | + python3 -m pylint src --recursive=true --rcfile=.pylintrc + python3 -m pylint tests --recursive=true --rcfile=.pylintrc + + - name: Run Black formatter + uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/self_test.yaml b/.github/workflows/self_test.yaml new file mode 100644 index 0000000..cfc1bfc --- /dev/null +++ b/.github/workflows/self_test.yaml @@ -0,0 +1,33 @@ +name: Integration Test +on: [push, pull_request] +jobs: + self-test: + runs-on: ubuntu-latest + steps: + - name: Checkout Main to compare + uses: actions/checkout@v4 + with: + ref: 'main' + path: 'main' + + - uses: actions/checkout@v4 + with: + path: 'branch' + + - name: Self test + if: ${{ github.ref != 'refs/heads/main' }} + id: selftest + uses: khalford/check-version-action@main + with: + app_version_path: "version.txt" + docker_compose_path: "docker-compose.yml" + + - name: Log Success + if: ${{ env.app_updated == 'true' }} + run: | + echo "App version has been updated correctly!" + + - name: Log Success + if: ${{ env.compose_updated == 'true' }} + run: | + echo "Compose version has been updated correctly!" \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..bbd0cf2 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,34 @@ +name: Unit Test +on: [push, pull_request] +jobs: + test_and_lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.12", "3.x" ] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: | + python3 -m pytest tests + + - name: Run tests and collect coverage + run: | + python3 -m pytest tests --cov-report xml:coverage.xml --cov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{secrets.CODECOV_TOKEN}} + files: cloud-coverage.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..904c4c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +main_version +branch_version +src/__pycache__/* +tests/__pycache__/* \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..fad2aa6 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,13 @@ +[FORMAT] +# Black will enforce 88 chars on Python code +# this will enforce 120 chars on docs / comments +max-line-length=120 + +# Disable various warnings: + +# W0237 Disabled as it makes the code more readable +# R0801 Disabled as it's a small amount of duplication +disable=W0237, R0801 + +[MASTER] +init-hook='import sys; sys.path.append("src")' \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0456982 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3 +WORKDIR /app +COPY . . +RUN pip install -r requirements.txt +CMD ["python","/app/src/main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 459385a..35646b8 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ -# check-version-action \ No newline at end of file +# Check Version +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/khalford/check-version-action/self_test.yaml?label=Intergration%20Test) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/khalford/check-version-action/lint.yaml?label=Linting) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/khalford/check-version-action/test.yaml?label=Tests) +[![codecov](https://codecov.io/gh/khalford/check-version-action/graph/badge.svg?token=441S7FRP3I)](https://codecov.io/gh/khalford/check-version-action) + + +This action compares the application version number from your working branch to the main branch. + +You can also check that the **first** image version that appears in your `docker-compose.yaml` file will match the application version + +The comparison follows the PEP 440 Version Identification and Dependency Specification. + +More detailed information about the versions can be found [here](https://packaging.python.org/en/latest/specifications/version-specifiers/) + +# Usage + +## Notes: + +As of October 2024 GitHub actions using Docker Containers can only be run on GitHub runners using a Linux operating system.
+Read here for details: [Link to GitHub docs](https://docs.github.com/en/actions/sharing-automations/creating-actions/about-custom-actions#types-of-actions) + +The release tag is extracted and stored in `$GITHUB_ENV`, +you can access this in your workflow with `$ {{ env.release_tag }}` + + +```yaml +- name: Checkout main + uses: actions/checkout@v4 + with: + # Change to "master" if needed + ref: 'main' + # Do not change the path here + path: 'main' + +- name: Checkout current working branch + uses: actions/checkout@v4 + with: + # Do not change the path here + path: 'branch' + +- name: Compare versions + # Don't run on main otherwise it will compare main with main + if: ${{ github.ref != 'refs/heads/main' }} + id: version_comparison + uses: khalford/check-version-action@main + with: + # Path to version file from project root + app_version_path: "version.txt" + # Optional: To check if compose image version matches application version + docker_compose_path: "docker-compose.yaml" + +- name: Log App Success + if: ${{ env.app_updated == 'true' }} + run: | + echo "App version has been updated correctly!" + +# Optional: If using the docker compose check +- name: Log Compose Success + if: ${{ env.compose_updated == 'true' }} + run: | + echo "Compose version has been updated correctly!" +``` + + diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e65ec22 --- /dev/null +++ b/action.yml @@ -0,0 +1,19 @@ +name: 'Check Semver Version Number' +description: 'Check if the semver version number has changed from the main branch. Can also check if the docker compose file reflects the application version.' +inputs: + app_version_path: + description: 'Path to main app version file.' + required: true + default: './version.txt' + docker_compose_path: + description: 'Path to compose file.' + required: false +outputs: + app_updated: + description: 'If the app version was updated or not.' + compose_updated: + description: 'If the compose version was updated or not.' + +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9399d8a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,3 @@ +services: + self-test: + image: some/test:0.4.10 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3b93724 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +pythonpath = src +testpaths = tests +python_files = *.py +python_functions = test_* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04319ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +packaging +pylint +pytest +pytest-cov \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/base.py b/src/base.py new file mode 100644 index 0000000..84a79f1 --- /dev/null +++ b/src/base.py @@ -0,0 +1,36 @@ +"""This is the base module including helper/abstract classes and errors.""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Union +from packaging.version import Version + + +class VersionNotUpdated(Exception): + """The version number has not been updated or updated incorrectly.""" + + +class Base(ABC): + """The base abstract class to build features on.""" + + @abstractmethod + def run(self, path1: Path, path2: Path) -> bool: + """ + This method is the entry point to the feature. + It should take two paths and return the comparison result. + """ + + @staticmethod + @abstractmethod + def read_files(path1: Path, path2: Path) -> (str, str): + """This method should read the contents of the compared files and return the strings""" + + @staticmethod + @abstractmethod + def get_version(content: str) -> Version: + """This method should extract the version from the file and return it as a packaging Version object""" + + @staticmethod + @abstractmethod + def compare(version1: Version, version2: Version) -> Union[bool, VersionNotUpdated]: + """This method should compare the versions and return a bool status""" diff --git a/src/comparison.py b/src/comparison.py new file mode 100644 index 0000000..6cc1c3c --- /dev/null +++ b/src/comparison.py @@ -0,0 +1,127 @@ +"""The comparison module which is where the main check classes are.""" + +from pathlib import Path +from typing import Union, List, Type + +from packaging.version import Version +from base import Base, VersionNotUpdated + + +class CompareAppVersion(Base): + """This class compares the application versions""" + + def run(self, path1: Path, path2: Path) -> bool: + """ + Entry point to compare application versions. + :param path1: Path to main version + :param path2: Path to branch version + :return: true if success, error if fail + """ + main_content, branch_content = self.read_files(path1, path2) + main_ver = self.get_version(main_content) + branch_ver = self.get_version(branch_content) + comparison = self.compare(main_ver, branch_ver) + if comparison == VersionNotUpdated: + raise VersionNotUpdated( + f"The version in {('/'.join(str(path2).split('/')[4:]))[0:]} has not been updated correctly." + ) + return True + + @staticmethod + def read_files(path1: Path, path2: Path) -> (str, str): + """ + Read both version files and return the contents + :param path1: Path to main version + :param path2: Path to branched version + :return: main_ver, branch_ver + """ + with open(path1, "r", encoding="utf-8") as file1: + content1 = file1.read() + with open(path2, "r", encoding="utf-8") as file2: + content2 = file2.read() + return content1, content2 + + @staticmethod + def get_version(content: str) -> Version: + """ + This method returns the version from the file as an object + For application versions we expect nothing else in the file than the version. + :param content: Application version string + :return: Application version object + """ + return Version(content) + + @staticmethod + def compare(main: Version, branch: Version) -> Union[bool, Type[VersionNotUpdated]]: + """ + Returns if the branch version is larger than the main version + :param main: Version on main + :param branch: Version on branch + :return: If the version update is correct return true, else return error + """ + if branch > main: + return True + return VersionNotUpdated + + +class CompareComposeVersion(Base): + """This class compares the docker compose image version to the application version.""" + + def run(self, app: Path, compose: Path) -> bool: + """ + Entry point to compare docker compose and application versions. + :param app: Path to application version + :param compose: Path to compose image version + :return: true if success, error if fail + """ + app_content, compose_content = self.read_files(app, compose) + app_ver = Version(app_content) + compose_ver = self.get_version(compose_content) + comparison = self.compare(app_ver, compose_ver) + if comparison == VersionNotUpdated: + raise VersionNotUpdated( + f"The version in {('/'.join(str(compose).split('/')[4:]))[0:]}" + f"does not match {('/'.join(str(app).split('/')[4:]))[0:]}." + ) + return True + + @staticmethod + def read_files(app: Path, compose: Path) -> (str, List): + """ + Read both version files and return the contents + :param app: Path to app version + :param compose: Path to compose version + :return: main_ver, branch_ver + """ + with open(app, "r", encoding="utf-8") as file1: + content1 = file1.read() + with open(compose, "r", encoding="utf-8") as file2: + content2 = file2.readlines() + return content1, content2 + + @staticmethod + def get_version(content: List[str]) -> Version: + """ + This method returns the version from the file as an object + For compose versions we have to do some data handling. + :param content: Compose version string + :return: Compose version object + """ + version_str = "" + for line in content: + if "image" in line: + version_str = line.strip("\n").split(":")[-1] + break + return Version(version_str) + + @staticmethod + def compare(app: Version, compose: Version) -> Union[bool, Type[VersionNotUpdated]]: + """ + Returns if the application version and docker compose version are equal. + :param app: App version + :param compose: Compose version + :return: If the version update is correct return true, else return error + """ + if app == compose: + return True + return VersionNotUpdated diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..8286bb5 --- /dev/null +++ b/src/main.py @@ -0,0 +1,36 @@ +"""This module is the entry point for the Action.""" + +import os +from pathlib import Path +from comparison import CompareAppVersion, CompareComposeVersion + + +def main(): + """ + The entry point function for the action. + Here we get environment variables then set environment variables when finished. + """ + app_path = Path(os.environ.get("INPUT_APP_VERSION_PATH")) + compose_path = os.environ.get("INPUT_DOCKER_COMPOSE_PATH") + root_path = Path(os.environ.get("GITHUB_WORKSPACE")) + main_path = root_path / "main" + branch_path = root_path / "branch" + with open(branch_path / app_path, "r", encoding="utf-8") as release_file: + release_version = release_file.read().strip("\n") + + CompareAppVersion().run(main_path / app_path, branch_path / app_path) + if compose_path: + compose_path = Path(compose_path) + CompareComposeVersion().run(branch_path / app_path, branch_path / compose_path) + + github_env = os.getenv("GITHUB_ENV") + with open(github_env, "a", encoding="utf-8") as env: + # We can assume either/both of these values returned true otherwise they would have errored + env.write("app_updated=true\n") + if compose_path: + env.write("compose_updated=true") + env.write(f"release_tag={release_version}") + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_compare_app_version.py b/tests/test_compare_app_version.py new file mode 100644 index 0000000..02d8459 --- /dev/null +++ b/tests/test_compare_app_version.py @@ -0,0 +1,67 @@ +"""Tests for comparison.CompareAppVersion""" + +from unittest.mock import patch, mock_open +from pathlib import Path +from packaging.version import Version +import pytest +from comparison import CompareAppVersion, VersionNotUpdated + + +@pytest.fixture(name="instance", scope="function") +def instance_fixture(): + """Provide a fixture instance for the tests""" + return CompareAppVersion() + + +@patch("comparison.CompareAppVersion.compare") +@patch("comparison.CompareAppVersion.get_version") +@patch("comparison.CompareAppVersion.read_files") +def test_run(mock_read, mock_get_version, mock_compare, instance): + """Test the run method makes correct calls.""" + mock_path1 = Path("mock1") + mock_path2 = Path("mock2") + mock_read.return_value = ("1.0.0", "1.0.1") + mock_get_version.side_effect = [Version("1.0.0"), Version("1.0.0")] + mock_compare.return_value = True + res = instance.run(mock_path1, mock_path2) + mock_read.assert_called_once_with(mock_path1, mock_path2) + mock_get_version.assert_any_call("1.0.0") + mock_get_version.assert_any_call("1.0.1") + mock_compare.assert_called_once_with(Version("1.0.0"), Version("1.0.0")) + assert res + + +@patch("comparison.CompareAppVersion.compare") +@patch("comparison.CompareAppVersion.get_version") +@patch("comparison.CompareAppVersion.read_files") +def test_run_fails(mock_read, _, mock_compare, instance): + """Test the run method fails.""" + mock_read.return_value = ("mock1", "mock2") + mock_compare.side_effect = VersionNotUpdated() + with pytest.raises(VersionNotUpdated): + instance.run(Path("mock1"), Path("mock2")) + + +def test_read_files(instance): + """Test the read files method returns a tuple""" + with patch("builtins.open", mock_open(read_data="1.0.0")): + res = instance.read_files(Path("mock1"), Path("mock2")) + assert res == ("1.0.0", "1.0.0") + + +def test_get_version(instance): + """Test a version object is returned""" + res = instance.get_version("1.0.0") + assert isinstance(res, Version) + + +def test_compare_pass(instance): + """Test that the compare returns true for a valid comparison""" + res = instance.compare(Version("1.0.0"), Version("1.0.1")) + assert res != VersionNotUpdated + + +def test_compare_fails(instance): + """Test that the compare returns an error for an invalid comparison""" + res = instance.compare(Version("1.0.1"), Version("1.0.0")) + assert res == VersionNotUpdated diff --git a/tests/test_compare_compose_version.py b/tests/test_compare_compose_version.py new file mode 100644 index 0000000..2874506 --- /dev/null +++ b/tests/test_compare_compose_version.py @@ -0,0 +1,66 @@ +"""Tests for comparison.CompareComposeVersion""" + +from unittest.mock import patch, mock_open +from pathlib import Path +import pytest +from packaging.version import Version +from comparison import CompareComposeVersion, VersionNotUpdated + + +@pytest.fixture(name="instance", scope="function") +def instance_fixture(): + """Provide a fixture instance for the tests""" + return CompareComposeVersion() + + +@patch("comparison.CompareComposeVersion.compare") +@patch("comparison.CompareComposeVersion.get_version") +@patch("comparison.CompareComposeVersion.read_files") +def test_run(mock_read, mock_get_version, mock_compare, instance): + """Test the run method makes correct calls.""" + mock_path1 = Path("mock1") + mock_path2 = Path("mock2") + mock_read.return_value = ("1.0.0", "1.0.0") + mock_get_version.side_effect = [Version("1.0.0"), Version("1.0.0")] + mock_compare.return_value = True + res = instance.run(mock_path1, mock_path2) + mock_read.assert_called_once_with(mock_path1, mock_path2) + mock_get_version.assert_any_call("1.0.0") + mock_compare.assert_called_once_with(Version("1.0.0"), Version("1.0.0")) + assert res + + +@patch("comparison.CompareComposeVersion.compare") +@patch("comparison.CompareComposeVersion.get_version") +@patch("comparison.CompareComposeVersion.read_files") +def test_run_fails(mock_read, _, mock_compare, instance): + """Test the run method fails.""" + mock_read.return_value = ("1.0.0", "1.0.1") + mock_compare.side_effect = VersionNotUpdated() + with pytest.raises(VersionNotUpdated): + instance.run(Path("mock1"), Path("mock2")) + + +def test_read_files(instance): + """Test the read files method returns a tuple""" + with patch("builtins.open", mock_open(read_data="1.0.0")): + res = instance.read_files(Path("mock1"), Path("mock2")) + assert res == ("1.0.0", ["1.0.0"]) + + +def test_get_version(instance): + """Test a version object is returned""" + res = instance.get_version(["image: some/image:1.0.0\n"]) + assert isinstance(res, Version) + + +def test_compare_pass(instance): + """Test that the compare returns true for a valid comparison""" + res = instance.compare(Version("1.0.0"), Version("1.0.0")) + assert res != VersionNotUpdated + + +def test_compare_fails(instance): + """Test that the compare returns an error for an invalid comparison""" + res = instance.compare(Version("1.0.1"), Version("1.0.0")) + assert res == VersionNotUpdated diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..7aec16b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,26 @@ +"""Tests for main""" + +from unittest.mock import patch, mock_open +from pathlib import Path +from main import main + + +@patch("main.CompareComposeVersion") +@patch("main.CompareAppVersion") +@patch("main.os") +def test_main(mock_os, mock_compare_app, mock_compare_compose): + """Test the main method runs correctly.""" + mock_os.environ.get.side_effect = [Path("app"), Path("compose"), Path("workspace")] + with patch("builtins.open", mock_open(read_data="1.0.0")): + main() + mock_os.environ.get.assert_any_call("INPUT_APP_VERSION_PATH") + mock_os.environ.get.assert_any_call("INPUT_DOCKER_COMPOSE_PATH") + mock_os.environ.get.assert_any_call("GITHUB_WORKSPACE") + mock_branch_path = Path("workspace") / "branch" + mock_main_path = Path("workspace") / "main" + mock_compare_app.return_value.run.assert_called_once_with( + mock_main_path / "app", mock_branch_path / "app" + ) + mock_compare_compose.return_value.run.assert_called_once_with( + mock_branch_path / "app", mock_branch_path / "compose" + ) diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..a2094f3 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.4.10 \ No newline at end of file