diff --git a/.github/workflows/integrations-tests.yml b/.github/workflows/integrations-tests.yml
new file mode 100644
index 0000000..512bfeb
--- /dev/null
+++ b/.github/workflows/integrations-tests.yml
@@ -0,0 +1,89 @@
+name: Integration Tests
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - main
+ schedule:
+ - cron: '0 9 * * *'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - uses: ./
+ name: Prometheus (YAML)
+ id: prometheus
+ with:
+ product: 'prometheus'
+ file_path: '__fixtures__/helm-values.yaml'
+ file_key: 'image.tag'
+ - uses: ./
+ name: Grafana (JSON)
+ id: grafana
+ with:
+ product: 'grafana'
+ file_path: '__fixtures__/helm-values.json'
+ file_key: 'image.tag'
+ file_format: 'json'
+ - uses: ./
+ name: Graylog (Regex)
+ id: graylog
+ with:
+ product: 'graylog'
+ file_path: '__fixtures__/services.xml'
+ regex: 'v([0-9]+\.[0-9]+\.[0-9]+)'
+ - uses: ./
+ name: AKS (Regex)
+ id: aks
+ with:
+ product: 'azure-kubernetes-service'
+ file_path: '__fixtures__/variables.tf'
+ regex: '(?<=default = \")([^\"]+)'
+
+ outputs:
+ prometheus_end_of_life: "${{ steps.prometheus.outputs.end_of_life }}"
+ prometheus_version: "${{ steps.prometheus.outputs.version }}"
+ grafana_end_of_life: "${{ steps.grafana.outputs.end_of_life }}"
+ grafana_version: "${{ steps.grafana.outputs.version }}"
+ graylog_end_of_life: "${{ steps.graylog.outputs.end_of_life }}"
+ graylog_version: "${{ steps.graylog.outputs.version }}"
+
+ assert:
+ runs-on: ubuntu-latest
+ needs: [test]
+ steps:
+ - name: Assert Prometheus EoL
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'true'
+ actual: "${{ needs.test.outputs.prometheus_end_of_life }}"
+ - name: Assert Prometheus extracted version
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'v2.55.1'
+ actual: "${{ needs.test.outputs.prometheus_version }}"
+ - name: Assert Grafana EoL
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'true'
+ actual: "${{ needs.test.outputs.grafana_end_of_life }}"
+ - name: Assert Grafana extracted version
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'v10.3.12'
+ actual: "${{ needs.test.outputs.grafana_version }}"
+ - name: Assert Graylog EoL
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'true'
+ actual: "${{ needs.test.outputs.graylog_end_of_life }}"
+ - name: Assert Graylog extracted version
+ uses: nick-fields/assert-action@v1
+ with:
+ expected: 'v5.2.12'
+ actual: "${{ needs.test.outputs.graylog_version }}"
diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml
new file mode 100644
index 0000000..fc42814
--- /dev/null
+++ b/.github/workflows/unit-tests.yaml
@@ -0,0 +1,32 @@
+name: Unit Tests
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ run-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: 3.12
+ cache: 'pip'
+ - name: Install dependencies
+ working-directory: ./src
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install -r requirements-test.txt
+ - name: Run pylint
+ working-directory: ./src
+ run: |
+ pylint $(git ls-files '*.py')
+ - name: Run unit tests
+ working-directory: ./src
+ run: |
+ pytest -vv
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..312306e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,175 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# PyPI configuration file
+.pypirc
+
+.DS_Store
+
+*.tmp
\ No newline at end of file
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000..408749a
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+@sindrel
\ No newline at end of file
diff --git a/README.md b/README.md
index 30220ff..2eedb88 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,201 @@
-# endoflife-github-action
-Source running versions of components from files in your repository. Get notified when they're no longer being maintained.
+
+
+
End-of-Life GitHub Action
+ Source running versions of components from files in your repository. Get notified when they're no longer being maintained.
+
+
+## Features
+
+- Fetches product cycle information from the [endoflife.date API](https://endoflife.date).
+- Supports version extraction from YAML, JSON, or plain text files.
+- Allows for failure conditions based on end-of-life status and days until end-of-life.
+- Outputs can be used in subsequent steps, e.g. for pushing notifications or raising alerts.
+
+## Inputs
+
+| Input | Required | Description |
+|----------------|----------|-----------------------------------------------------------------------------|
+| `product` | Yes | The product ID (see the URL on https://endoflife.date). |
+| `file_path` | No | Path to the file containing the version information. |
+| `file_key` | No | Key used to extract the version from a file if YAML or JSON (e.g. `image.tag`). |
+| `file_format` | No | Format of the file containing the version information. Default is `yaml`. |
+| `version` | No | If not extracting from a file, the version can be provided directly. |
+| `regex` | No | Regular expression to capture a group in any file. |
+| `fail_on_eol` | No | Whether to fail if the end-of-life date has passed. Default is `false`. |
+| `fail_days_left` | No | Fail the action if the end-of-life date is less than this number of days away. |
+
+## Outputs
+
+| Output | Description |
+|------------------|----------------------------------------------------------------------------|
+| `end_of_life` | Whether the end-of-life date has passed or not ('true', 'false'). |
+| `version` | The version extracted from file (if not provided). |
+| `days_until_eol` | The number of days until the end-of-life date (negative if passed). |
+| `text_summary` | A brief summary of the end-of-life status. |
+
+## Usage examples
+
+### Example A: Notify on Slack if there's 30 days left until end-of-life
+
+```yaml
+# .github/workflows/myworkflow.yml
+name: Notify on Slack
+on:
+ schedule:
+ - cron: '0 10 * * *'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - uses: sindrel/endoflife-github-action@latest
+ name: Check when Prometheus is end-of-life
+ id: endoflife
+ with:
+ product: 'prometheus'
+ file_path: 'helm/values.yaml'
+ file_key: 'image.tag'
+
+ - name: Notify when 30 days until end-of-life
+ if: steps.endoflife.outputs.days_until_eol == 30
+ uses: slackapi/slack-github-action@v2.0.0
+ with:
+ method: chat.postMessage
+ token: ${{ secrets.SLACK_BOT_TOKEN }}
+ payload: |
+ channel: ${{ secrets.SLACK_CHANNEL_ID }}
+ text: ":warning: ${{ steps.endoflife.outputs.text_summary }}"
+```
+
+##### This would produce a Slack notification:
+
+
+#### You can use the same pattern for similar use-cases, like:
+* Raising an incident in PagerDuty, or
+* Creating an issue for follow-up in GitHub or Jira
+
+### Example B: Fail the workflow if the end-of-life date has passed
+
+```yaml
+# .github/workflows/myworkflow.yml
+name: Fail if end-of-life
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ check-eol:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Fail if end-of-Life
+ uses: sindrel/endoflife-github-action@latest
+ with:
+ product: 'your-product-id'
+ file_path: 'path/to/your/version/file.yaml'
+ file_key: 'image.tag'
+ fail_on_eol: 'true'
+```
+This could be run on a schedule, or as part of a CI pipeline.
+
+
+## Extracting version from a YAML or JSON file
+
+The action supports simple extraction of versions from YAML or JSON formatted files.
+
+#### Key pattern
+
+The `file_key` input takes in the path to a value you wish to extract. For instance, if you have a Helm values file like this:
+
+```yaml
+# helm/values.yaml
+image:
+ repository: prom/prometheus
+ tag: v2.55.1
+```
+
+To extract the value that contains the version, specify the key as `image.tag`, meaning it's the `tag` value in the `image` stanza.
+
+Here’s an example of how to use it in a workflow (for JSON, set `file_format` to `json`):
+
+```yaml
+# .github/workflows/myworkflow.yml
+# ...
+ - name: Check end-of-Life for Prometheus
+ uses: sindrel/endoflife-github-action@latest
+ with:
+ product: 'prometheus'
+ file_path: 'helm/values.yaml'
+ file_key: 'image.tag'
+ file_format: 'yaml'
+```
+
+## Extracting the version from any file using a regular expression
+
+It's possible to extract a version from any plaintext file using the `regex` input.
+
+Let's say you have a XML document that contains a version you wish to extract:
+
+```xml
+
+
+ graylog
+ v5.2.12
+
+```
+
+To extract the version value, you can provide a regular expression. In this example any first match on a string that seems to be a semantic version prefixed with `v` is used: in this case `v5.2.12`. (Note that your use case might require something a bit more complex):
+
+```yaml
+# .github/workflows/myworkflow.yml
+# ...
+ - name: Check end-of-life for Graylog
+ uses: sindrel/endoflife-github-action@latest
+ with:
+ product: 'graylog'
+ file_path: 'path/to/your/file.xml'
+ regex: 'v([0-9]+\.[0-9]+\.[0-9]+)'
+```
+
+## Provide the version manually
+
+If you are using other methods to extract the version, or wish to hardcode it into your workflow, you can use the `version` input to provide the version manually.
+
+```yaml
+# .github/workflows/myworkflow.yml
+# ...
+ - name: Check end-of-life for a product
+ uses: sindrel/endoflife-github-action@latest
+ with:
+ product: 'the-product'
+ version: 'v1.2.3'
+```
+
+## Matching semantic versions
+
+If the version used is semantic (e.g. `v1.2.3`), the action will attempt to match cycles from patch to major. This means that you don't have to strip the patch version where the product's release cycles apply to major or minor versions.
+
+* If no direct match on `1.2.3`, it will attempt to match `1.2`
+* If no direct match on `1.2`, it will attempt to match `1`
+
+## Failure conditions
+
+The default behavior of the action is to **not fail** if a product cycle is end-of-life.
+
+* Set the `fail_on_eol` input to `true` to have the workflow fail if a product has passed it's end-of-life date.
+* To have the workflow fail as an early warning, use the `fail_days_left` input to specify how many days in advance you wish the workflow to fail.
+
+## License
+
+This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details.
+
+## Contributing
+
+Contributions are welcome! Please open an issue or submit a pull request for any enhancements or bug fixes.
\ No newline at end of file
diff --git a/__fixtures__/helm-values.json b/__fixtures__/helm-values.json
new file mode 100644
index 0000000..a47ab48
--- /dev/null
+++ b/__fixtures__/helm-values.json
@@ -0,0 +1,6 @@
+{
+ "image": {
+ "repository": "grafana/grafana",
+ "tag": "v10.3.12"
+ }
+}
\ No newline at end of file
diff --git a/__fixtures__/helm-values.yaml b/__fixtures__/helm-values.yaml
new file mode 100644
index 0000000..89dcdab
--- /dev/null
+++ b/__fixtures__/helm-values.yaml
@@ -0,0 +1,3 @@
+image:
+ repository: prom/prometheus
+ tag: v2.55.1
\ No newline at end of file
diff --git a/__fixtures__/services.xml b/__fixtures__/services.xml
new file mode 100644
index 0000000..a27b2d6
--- /dev/null
+++ b/__fixtures__/services.xml
@@ -0,0 +1,4 @@
+
+ graylog
+ v5.2.12
+
\ No newline at end of file
diff --git a/__fixtures__/variables.tf b/__fixtures__/variables.tf
new file mode 100644
index 0000000..48663a9
--- /dev/null
+++ b/__fixtures__/variables.tf
@@ -0,0 +1,4 @@
+variable "aks_version" {
+ type = string
+ default = "1.28"
+}
\ No newline at end of file
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..2fcf719
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,76 @@
+name: 'End-of-Life GitHub Action'
+description: 'Identifies the End-of-life (EoL) dates for products, using the endoflife.date API.'
+author: Sindre Lindstad (https://github.com/sindrel)
+branding:
+ icon: 'check-circle'
+ color: 'blue'
+inputs:
+ product:
+ required: true
+ description: "The product id (see the URL on https://endoflife.date)."
+ file_path:
+ required: false
+ description: "Path to the file containing the version information."
+ default: ""
+ file_key:
+ required: false
+ description: "Key used to extract the version from the file if YAML or JSON (e.g. 'image.tag')."
+ default: ""
+ file_format:
+ required: false
+ description: "Format of the file containing the version information."
+ default: "yaml"
+ version:
+ required: false
+ description: "If not using a file, the version can be provided directly."
+ default: ""
+ regex:
+ required: false
+ description: "Regular expression to capture a group in any file."
+ default: ""
+ fail_on_eol:
+ required: false
+ description: "Whether to fail if the end-of-life date has passed."
+ default: "false"
+ fail_days_left:
+ required: false
+ description: "Fail the action if the end-of-life date is less than this number of days away."
+outputs:
+ end_of_life:
+ description: "Whether the end-of-life date has passed or not."
+ value: "${{ steps.get-cycle.outputs.end_of_life }}"
+ version:
+ description: "The version extracted from file (if not provided)."
+ value: "${{ steps.get-cycle.outputs.version }}"
+ days_until_eol:
+ description: "The number of days until the end-of-life date (negative if passed)."
+ value: "${{ steps.get-cycle.outputs.days_until_eol }}"
+ text_summary:
+ description: "A brief summary of the end-of-life status."
+ value: "${{ steps.get-cycle.outputs.text_summary }}"
+runs:
+ using: "composite"
+ steps:
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ shell: "bash"
+ working-directory: ${{ github.action_path }}/src/
+ run: |
+ python -m pip install --upgrade pip && \
+ pip install -r requirements.txt
+ - id: get-cycle
+ name: Fetch cycle
+ shell: "bash"
+ working-directory: ./
+ run: |
+ python ${GITHUB_ACTION_PATH}/src/get_cycle.py \
+ --product '${{ inputs.product }}' \
+ --file_path '${{ inputs.file_path }}' \
+ --file_key '${{ inputs.file_key }}' \
+ --file_format '${{ inputs.file_format }}' \
+ --version '${{ inputs.version }}' \
+ --regex '${{ inputs.regex }}' \
+ --fail_on_eol '${{ inputs.fail_on_eol }}' \
+ --fail_days_left '${{ inputs.fail_days_left }}'
diff --git a/assets/eol-action-logo.png b/assets/eol-action-logo.png
new file mode 100644
index 0000000..e8d6a1c
Binary files /dev/null and b/assets/eol-action-logo.png differ
diff --git a/assets/slack-notification.png b/assets/slack-notification.png
new file mode 100644
index 0000000..4d0c0f6
Binary files /dev/null and b/assets/slack-notification.png differ
diff --git a/src/get_cycle.py b/src/get_cycle.py
new file mode 100644
index 0000000..1c762be
--- /dev/null
+++ b/src/get_cycle.py
@@ -0,0 +1,234 @@
+# pylint: disable=C0114,C0116,W0621,C0301
+
+import json
+import os
+import sys
+import argparse
+import re
+import http.client
+
+from datetime import datetime
+
+import yaml
+
+
+def get_product_cycle(args):
+ result = {}
+ version = None
+
+ if not args.version:
+ with open(file=args.file_path, mode='r', encoding='utf8') as file:
+ file_contents = file.read()
+ if args.file_format in ['yaml', 'json']:
+ print(f"Looking for version in file '{args.file_path}' using key '{args.file_key}'...")
+ version = _get_version_from_structured_file(args, file_contents)
+ elif args.file_format == 'text':
+ print(f"Looking for version in file '{args.file_path}' using regex '{args.regex}'...")
+ version = _extract_value_from_string(args.regex, file_contents)
+ else:
+ version = args.version
+
+ if not version:
+ print("No version was extracted or supplied.")
+ return result
+
+ print(f"Version found: {version}")
+
+ product_cycles = _get_product_details(args.product)
+
+ if version:
+ semantic = _parse_semantic_version(version)
+ cycle = _match_version_to_cycle(product_cycles, *semantic)
+ if cycle["eol"]:
+ eol_passed, eol_days_until = _check_eol_date(
+ cycle["eol"])
+ else:
+ eol_passed = False
+ eol_days_until = None
+
+ result = {
+ "product": args.product,
+ "version": version,
+ "cycle": cycle,
+ "end_of_life": eol_passed,
+ "days_until_eol": eol_days_until,
+ }
+
+ result['text_summary'] = _construct_summary(result)
+
+ return result
+
+
+def write_to_output_file(result, output_file):
+ outputs = f"""
+version={result['version']}
+end_of_life={str(result['end_of_life']).lower()}
+days_until_eol={result['days_until_eol']}
+text_summary={result['text_summary']}
+"""
+ with open(file=output_file, mode='a', encoding='utf8') as file:
+ file.write(outputs)
+
+
+def _check_eol_date(eol_date):
+ eol_datetime = datetime.strptime(eol_date, "%Y-%m-%d")
+ current_datetime = datetime.now()
+ days_until_eol = (eol_datetime - current_datetime).days
+ end_of_life = days_until_eol < 0
+
+ return end_of_life, days_until_eol
+
+
+def _parse_semantic_version(version):
+ parts = version.lstrip('v').split('.')
+ major = parts[0]
+ minor = parts[1] if len(parts) > 1 else None
+ patch = parts[2] if len(parts) > 2 else None
+
+ print(f"Split version {version} into major: {major}, minor: {minor}, patch: {patch}")
+ return major, minor, patch
+
+
+def _match_version_to_cycle(cycles, major, minor, patch):
+ for cycle in cycles:
+ if cycle["cycle"] in [
+ f"{major}.{minor}.{patch}",
+ f"{major}.{minor}",
+ f"{major}"
+ ]:
+ return cycle
+ return None
+
+
+def _get_product_details(product):
+ host = "endoflife.date"
+ path = f"/api/{product}.json"
+ conn = http.client.HTTPSConnection(host)
+ headers = {'Accept': "application/json"}
+ conn.request("GET", path, headers=headers)
+ res = conn.getresponse()
+ data = res.read()
+
+ if res.status != 200:
+ print(f"Unable to fetch product details from {host}{path}: {res.status}")
+ return None
+
+ json_contents, error = _json_decode(data.decode("utf-8"))
+ if error:
+ print(f"Unable to parse result from {host}: {error}")
+ return None
+
+ return json_contents
+
+
+def _get_version_from_structured_file(args, file_contents):
+ if args.file_format == "yaml":
+ yaml_file, error = _yaml_decode(file_contents)
+ if error:
+ print(f"Error parsing file: {error}")
+ return None
+
+ elif args.file_format == "json":
+ yaml_file, error = _json_decode(file_contents)
+ if error:
+ print(f"Error parsing file: {error}")
+ return None
+
+ else:
+ raise ValueError(f"Unsupported format: {args.file_format}")
+
+ keys = args.file_key.split('.')
+ value = yaml_file
+ for k in keys:
+ value = value.get(k, None)
+ if value is None:
+ break
+ return value
+
+
+def _yaml_decode(file_contents):
+ try:
+ return yaml.safe_load(file_contents), ""
+ except yaml.YAMLError as e:
+ msg = f"Error parsing YAML: {e}"
+ return None, msg
+
+
+def _json_decode(file_contents):
+ try:
+ return json.loads(file_contents), ""
+ except json.JSONDecodeError as e:
+ msg = f"Error parsing JSON: {e}"
+ return None, msg
+
+
+def _extract_value_from_string(regex, string):
+ # 'v([0-9]+\.[0-9]+\.[0-9]+)'
+ match = re.search(regex, string)
+ if match:
+ return match.group(0)
+ return None
+
+def _construct_summary(result):
+ if result['end_of_life']:
+ return f"{result['product']} {result['version']} is end-of-life."
+ if not result['days_until_eol']:
+ return f"{result['product']} {result['version']} has no end-of-life date set."
+ return f"{result['product']} {result['version']} has {result['days_until_eol']} days left until end-of-life."
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(
+ description='Get product cycle information from endoflife.date.')
+ parser.add_argument('--product', type=str, required=True,
+ help='Product name to fetch the cycle information.')
+ parser.add_argument('--file_path', type=str, required=False,
+ help='Path to the file containing the version information.')
+ parser.add_argument('--file_key', type=str, required=False,
+ help='Key to extract the version from the file (if yaml or json).')
+ parser.add_argument('--file_format', type=str, required=False,
+ choices=['yaml', 'json', 'text'], help='File format (yaml, json or text).')
+ parser.add_argument('--version', type=str, required=False,
+ help='A known product version.')
+ parser.add_argument('--regex', type=str, required=False,
+ help='A regular expression used for extracting the product version.')
+ parser.add_argument('--fail_on_eol', type=str, required=False,
+ help='Fail the workflow if the product is end-of-life.')
+ parser.add_argument('--fail_days_left', type=str, required=False,
+ help='Fail the workflow if less than x days until end-of-life.')
+
+ args = parser.parse_args()
+
+ if not args.file_path and not args.version:
+ sys.exit("Either file_path or version must be provided.")
+
+ if args.file_path and not (args.file_key or args.regex):
+ sys.exit(
+ "Either file_key or regex must be provided when extracting from file.")
+
+ if args.file_key and args.regex:
+ sys.exit("Only one of file_key or regex can be provided.")
+
+ if args.regex:
+ try:
+ re.compile(args.regex)
+ except re.error:
+ sys.exit(f"Invalid regular expression: '{args.regex}'")
+ args.file_format = "text"
+
+ if args.file_format not in ['yaml', 'json'] and not args.regex:
+ sys.exit("A regex must be provided when using text format.")
+
+ result = get_product_cycle(args)
+ if not result:
+ sys.exit("No result.")
+
+ print(result['text_summary'])
+
+ env_file = os.getenv('GITHUB_OUTPUT', 'output.tmp')
+ write_to_output_file(result, env_file)
+
+ if (args.fail_on_eol and args.fail_on_eol.lower() == "true") and result['end_of_life']:
+ sys.exit(result['text_summary'])
+
+ if args.fail_days_left and result['days_until_eol'] <= int(args.fail_days_left):
+ sys.exit(result['text_summary'])
diff --git a/src/requirements-test.txt b/src/requirements-test.txt
new file mode 100644
index 0000000..073afda
--- /dev/null
+++ b/src/requirements-test.txt
@@ -0,0 +1,2 @@
+pytest==7.0.1
+pylint==3.3.3
\ No newline at end of file
diff --git a/src/requirements.txt b/src/requirements.txt
new file mode 100644
index 0000000..d03d21f
--- /dev/null
+++ b/src/requirements.txt
@@ -0,0 +1 @@
+pyyaml==6.0.2
\ No newline at end of file
diff --git a/src/test_get_cycle.py b/src/test_get_cycle.py
new file mode 100644
index 0000000..dbdaea6
--- /dev/null
+++ b/src/test_get_cycle.py
@@ -0,0 +1,81 @@
+# pylint: disable=C0301,C0114,C0115,C0116,W0621
+
+import unittest
+from unittest.mock import patch, mock_open, MagicMock
+from datetime import datetime
+import json
+from get_cycle import write_to_output_file, _check_eol_date, _parse_semantic_version, _get_product_details, _get_version_from_structured_file, _extract_value_from_string, _construct_summary
+
+class TestGetCycle(unittest.TestCase):
+
+ def test_get_version_from_json_file(self):
+ args = MagicMock()
+ args.file_path = 'dummy_path'
+ args.file_format = 'json'
+ args.file_key = 'version'
+ version = _get_version_from_structured_file(args, '{"version": "1.2.3"}')
+ self.assertEqual(version, '1.2.3')
+
+ def test_get_version_from_yaml_file(self):
+ args = MagicMock()
+ args.file_path = 'dummy_path'
+ args.file_format = 'yaml'
+ args.file_key = 'version'
+ version = _get_version_from_structured_file(args, 'version: 1.2.3')
+ self.assertEqual(version, '1.2.3')
+
+ def test_parse_semantic_version(self):
+ version = 'v1.2.3'
+ major, minor, patch = _parse_semantic_version(version)
+ self.assertEqual(major, '1')
+ self.assertEqual(minor, '2')
+ self.assertEqual(patch, '3')
+
+ def test_check_eol_date(self):
+ eol_date = '2022-01-01'
+ with patch('get_cycle.datetime') as mock_datetime:
+ mock_datetime.now.return_value = datetime(2022, 1, 2)
+ mock_datetime.strptime.return_value = datetime(2022, 1, 1)
+ end_of_life, days_until_eol = _check_eol_date(eol_date)
+ self.assertTrue(end_of_life)
+ self.assertEqual(days_until_eol, -1)
+
+ @patch('http.client.HTTPSConnection')
+ def test_get_product_details(self, mock_https):
+ mock_conn = mock_https.return_value
+ mock_conn.getresponse.return_value.status = 200
+ mock_conn.getresponse.return_value.read.return_value = json.dumps([{"cycle": "1.2.3", "eol": "2022-01-01"}]).encode('utf-8')
+ product_details = _get_product_details('dummy_product')
+ self.assertEqual(product_details, [{"cycle": "1.2.3", "eol": "2022-01-01"}])
+
+ def test_extract_value_from_string(self):
+ regex = r'v([0-9]+\.[0-9]+\.[0-9]+)'
+ string = 'version: v1.2.3'
+ value = _extract_value_from_string(regex, string)
+ self.assertEqual(value, 'v1.2.3')
+
+ def test_construct_summary(self):
+ result = {
+ 'product': 'dummy_product',
+ 'version': '1.2.3',
+ 'end_of_life': True,
+ 'days_until_eol': -1
+ }
+ summary = _construct_summary(result)
+ self.assertEqual(summary, 'dummy_product 1.2.3 is end-of-life.')
+
+ @patch('builtins.open', new_callable=mock_open)
+ def test_write_to_output_file(self, mock_file):
+ result = {
+ 'version': '1.2.3',
+ 'end_of_life': True,
+ 'days_until_eol': -1,
+ 'text_summary': 'dummy_product 1.2.3 is end-of-life.'
+ }
+ write_to_output_file(result, 'dummy_output_file')
+ mock_file().write.assert_called_once_with(
+ "\nversion=1.2.3\nend_of_life=true\ndays_until_eol=-1\ntext_summary=dummy_product 1.2.3 is end-of-life.\n"
+ )
+
+if __name__ == '__main__':
+ unittest.main()