From 3477f9a281acc2dc257a5cc08f2f6511a5790ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:03:56 +0000 Subject: [PATCH] v1.1.0: headless CLI, update notifications, code signing, tests - Add headless `collect` subcommand for scheduled/automated runs (Windows Task Scheduler), with password via flag/env/prompt - Add non-intrusive "new version available" notice in the web UI (/api/update-check); no auto-update, no telemetry, opt-out via env - Add pytest suite (version compare, collector mapping, excel builder) and a CI workflow that runs on push/PR and gates the release build - Support optional Authenticode signing via Azure Trusted Signing in the release workflow (off by default until secrets are configured) - Bump version to 1.1.0; update CHANGELOG and README --- .github/workflows/build-release.yml | 40 ++++++++ .github/workflows/ci.yml | 32 +++++++ CHANGELOG.md | 25 +++++ README.md | 54 +++++++++++ app.py | 142 +++++++++++++++++++++++++++- build.bat | 1 + requirements-dev.txt | 2 + static/app.js | 19 ++++ static/style.css | 19 ++++ templates/index.html | 3 + tests/__init__.py | 0 tests/test_collector.py | 107 +++++++++++++++++++++ tests/test_excel_builder.py | 47 +++++++++ tests/test_version_utils.py | 35 +++++++ version_utils.py | 61 ++++++++++++ 15 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_collector.py create mode 100644 tests/test_excel_builder.py create mode 100644 tests/test_version_utils.py create mode 100644 version_utils.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 183a07b..bda1482 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -14,7 +14,27 @@ permissions: contents: write jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: pytest -q + build-windows: + needs: tests runs-on: windows-latest steps: @@ -44,6 +64,26 @@ jobs: --collect-submodules waitress ` app.py + # Optional Authenticode signing via Azure Trusted Signing. + # No-ops until the repo variable AZURE_SIGNING_ENABLED is set to "true" + # and the corresponding secrets/variables are configured. Signing here + # (before zipping/hashing) ensures the published checksums cover the + # signed binary, and removes the Windows SmartScreen warning on first run. + - name: Sign .exe (Azure Trusted Signing) + if: vars.AZURE_SIGNING_ENABLED == 'true' + uses: azure/trusted-signing-action@v0.5.9 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_SIGNING_PROFILE }} + files-folder: ${{ github.workspace }}\dist\FCInventoryTool + files-folder-filter: exe + files-folder-recurse: true + file-digest: SHA256 + - name: Get version from tag id: version shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..134da94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +# Run the test suite on every push and pull request so regressions are caught +# before a release tag is ever cut. + +on: + push: + branches: + - "**" + tags-ignore: + - "v*.*.*" + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md index fa36bb1..2b69568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-06-12 + +### Added +- **Headless CLI mode**: `FCInventoryTool.exe collect --host --username [--port 7443] [--out file.xlsx]` + runs a single collection and writes the Excel file without opening the web UI. + Ideal for scheduled snapshots via Windows Task Scheduler. Password is supplied + via `--password`, the `FC_INVENTORY_PASSWORD` environment variable, or an + interactive prompt (never echoed into command-line history when using env/prompt). +- **Update notifications**: the web UI checks GitHub for a newer release and shows + a non-intrusive "Version X is available" link in the footer. Still no + auto-update and no telemetry — it only reads the public releases API and can be + turned off entirely with `FC_INVENTORY_DISABLE_UPDATE_CHECK`. +- `/api/update-check` endpoint backing the notice (degrades silently when offline). +- `web` and `collect` subcommands plus `--version` on the executable. +- Test suite (`pytest`) covering version comparison, collector field mapping, and + the Excel builder; runs in CI on every push and pull request, and gates releases. +- `requirements-dev.txt` for the developer/test toolchain. + +### Changed +- The release workflow now runs the test suite before building, and supports + **optional Authenticode code signing** through Azure Trusted Signing. Signing is + off by default and activates only when `AZURE_SIGNING_ENABLED` and the related + secrets are configured, so the build keeps working without it. Published SHA-256 + checksums now cover the signed binary. + ## [1.0.0] - 2026-04-10 First production-ready release. diff --git a/README.md b/README.md index ab803a4..1ea257a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,31 @@ A web-based inventory collector for Huawei FusionCompute, similar to RVTools for The Excel file is also saved next to `FCInventoryTool.exe` for safekeeping. +### Option C: Headless / command-line mode (automation) + +Run a one-off collection without the web UI — useful for scheduled snapshots +(e.g. Windows Task Scheduler): + +```cmd +FCInventoryTool.exe collect --host 192.168.1.100 --username admin --port 7443 --out C:\reports\fc.xlsx +``` + +The password is read from (in order): the `--password` flag, the +`FC_INVENTORY_PASSWORD` environment variable, or an interactive prompt. Prefer +the environment variable so the password never lands in command history or the +Task Scheduler argument list: + +```cmd +set FC_INVENTORY_PASSWORD=yourpassword +FCInventoryTool.exe collect --host 192.168.1.100 --username admin +``` + +If `--out` is omitted the file is written to `FC_Inventory_.xlsx` in +the current directory. The full output path is printed on success, and the +process exits non-zero on failure so it plays nicely with scripts. + +Running `FCInventoryTool.exe` with no arguments (or `web`) launches the UI as before. + ### Option B: Run from source (developers) ```bash @@ -88,6 +113,33 @@ build.bat Output goes to `dist\FCInventoryTool\`. Distribute the entire folder (~80 MB). +### Running the tests + +```bash +pip install -r requirements-dev.txt +pytest -q +``` + +The suite runs automatically in CI on every push and pull request, and gates +the release build. + +### Code signing (optional) + +The official release workflow can Authenticode-sign `FCInventoryTool.exe` via +[Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/), +which removes the Windows SmartScreen "unknown publisher" warning on first run. +Signing is **off by default** and stays off until configured, so forks and +local builds keep working unchanged. + +To enable it on your fork, set the repository **variable** +`AZURE_SIGNING_ENABLED` to `true` and provide: + +- Secrets: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` +- Variables: `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_PROFILE` + +Signing happens before the zip and checksums are produced, so the published +SHA-256 values cover the signed binary. + --- ## Configuration @@ -98,6 +150,8 @@ Environment variables (optional): |---|---|---| | `FC_INVENTORY_BIND` | `127.0.0.1` | Bind address. Set to `0.0.0.0` to expose on LAN (see Security below) | | `FC_INVENTORY_PORT` | `5000` | Local web UI port | +| `FC_INVENTORY_PASSWORD` | _(unset)_ | Password for headless `collect` mode (avoids putting it on the command line) | +| `FC_INVENTORY_DISABLE_UPDATE_CHECK` | _(unset)_ | Set to any value to disable the "new version available" check in the web UI | Example: ```cmd diff --git a/app.py b/app.py index ed0b4af..7cfefda 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,8 @@ import os import sys import socket +import argparse +import getpass import logging import logging.handlers import threading @@ -16,8 +18,9 @@ from collector import InventoryCollector from excel_builder import build_excel +from version_utils import GITHUB_REPO, get_latest_release, is_newer -__version__ = "1.0.0" +__version__ = "1.1.0" # Configure logging - console + file LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s - %(message)s" @@ -167,6 +170,33 @@ def version(): return jsonify({"version": __version__}) +@app.route("/api/update-check") +def update_check(): + """ + Check GitHub for a newer release. Returns whether an update is available + so the web UI can show a non-intrusive notice. Never auto-updates, never + sends any data about the user. Disable with FC_INVENTORY_DISABLE_UPDATE_CHECK. + """ + if os.environ.get("FC_INVENTORY_DISABLE_UPDATE_CHECK"): + return jsonify({"enabled": False, "current": __version__}) + + try: + latest = get_latest_release(GITHUB_REPO) + tag = latest["tag"] + available = bool(tag) and is_newer(tag, __version__) + return jsonify({ + "enabled": True, + "current": __version__, + "latest": tag.lstrip("vV"), + "update_available": available, + "url": latest["url"], + }) + except Exception as e: + # Offline / rate-limited / no releases yet: degrade silently. + logging.info(f"Update check skipped: {e}") + return jsonify({"enabled": True, "current": __version__, "error": str(e)}) + + @app.route("/changelog") def changelog_page(): return render_template("changelog.html", version=__version__) @@ -234,11 +264,12 @@ def _print_banner(host, port): print(banner, flush=True) -def main(): +def run_web(bind=None, port=None): + """Launch the local web UI (default mode).""" # Bind to localhost by default for security. # Set FC_INVENTORY_BIND=0.0.0.0 to expose on the LAN (use with caution). - host = os.environ.get("FC_INVENTORY_BIND", "127.0.0.1") - port = int(os.environ.get("FC_INVENTORY_PORT", "5000")) + host = bind or os.environ.get("FC_INVENTORY_BIND", "127.0.0.1") + port = port or int(os.environ.get("FC_INVENTORY_PORT", "5000")) _print_banner(host, port) @@ -259,5 +290,108 @@ def main(): app.run(host=host, port=port, debug=False, use_reloader=False) +def run_headless(args): + """ + Run a single collection without the web UI and write an Excel file, then + exit. Intended for automation (e.g. Windows Task Scheduler). Returns a + process exit code. + """ + password = args.password or os.environ.get("FC_INVENTORY_PASSWORD") + if not password: + try: + password = getpass.getpass("FusionCompute password: ") + except Exception: + password = "" + if not password: + print( + "ERROR: password required. Pass --password, set the " + "FC_INVENTORY_PASSWORD environment variable, or run interactively.", + file=sys.stderr, + ) + return 2 + + collector = InventoryCollector(args.host, args.username, password, port=args.port) + logging.info( + f"Headless collection from {args.host}:{args.port} as {args.username}" + ) + + try: + data = collector.collect_all() + except KeyboardInterrupt: + logging.warning("Interrupted by user") + return 130 + except Exception as e: + logging.error(f"Collection failed: {e}") + return 1 + + out = args.out + if not out: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out = os.path.join(os.getcwd(), f"FC_Inventory_{timestamp}.xlsx") + out = os.path.abspath(out) + + out_dir = os.path.dirname(out) + if out_dir and not os.path.isdir(out_dir): + os.makedirs(out_dir, exist_ok=True) + + build_excel(data, out) + logging.info(f"Excel saved to: {out}") + # Print the path on its own line so scripts can capture it. + print(out) + return 0 + + +def _build_arg_parser(): + parser = argparse.ArgumentParser( + prog="FCInventoryTool", + description="FusionCompute inventory collector (RVTools-style).", + ) + parser.add_argument( + "--version", action="version", + version=f"FC Inventory Tool {__version__}", + ) + sub = parser.add_subparsers(dest="command") + + web_p = sub.add_parser("web", help="Launch the web UI (this is the default).") + web_p.add_argument( + "--host", default=None, + help="Bind address for the web UI (overrides FC_INVENTORY_BIND).", + ) + web_p.add_argument( + "--port", type=int, default=None, + help="Web UI port (overrides FC_INVENTORY_PORT, default 5000).", + ) + + col_p = sub.add_parser( + "collect", help="Run one headless collection to an .xlsx file and exit.", + ) + col_p.add_argument("--host", required=True, help="FusionCompute VRM host or IP.") + col_p.add_argument("--username", required=True, help="Login username.") + col_p.add_argument( + "--password", default=None, + help="Login password. Omit to use FC_INVENTORY_PASSWORD or be prompted.", + ) + col_p.add_argument( + "--port", type=int, default=7443, + help="FusionCompute API port (default 7443).", + ) + col_p.add_argument( + "--out", default=None, + help="Output .xlsx path (default: ./FC_Inventory_.xlsx).", + ) + return parser + + +def main(): + parser = _build_arg_parser() + args = parser.parse_args() + + if args.command == "collect": + sys.exit(run_headless(args)) + + # Default (no subcommand) or explicit "web": launch the UI. + run_web(getattr(args, "host", None), getattr(args, "port", None)) + + if __name__ == "__main__": main() diff --git a/build.bat b/build.bat index 4cd4892..38d2f5d 100644 --- a/build.bat +++ b/build.bat @@ -20,6 +20,7 @@ pyinstaller --noconfirm --onedir --name FCInventoryTool ^ --add-data "static;static" ^ --add-data "CHANGELOG.md;." ^ --add-data "LICENSE;." ^ + --add-data "README.md;." ^ --hidden-import waitress ^ --collect-submodules waitress ^ app.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..044d133 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8,<9 diff --git a/static/app.js b/static/app.js index 30c1085..6d4c948 100644 --- a/static/app.js +++ b/static/app.js @@ -40,6 +40,9 @@ window.addEventListener("DOMContentLoaded", function () { }) .catch(() => {}); + // Non-intrusive "new version available" notice (no auto-update) + checkForUpdate(); + // Attach Enter key listener to all form inputs document.querySelectorAll("#form-section input").forEach(function (input) { input.addEventListener("keydown", handleKeyDown); @@ -53,6 +56,22 @@ window.addEventListener("DOMContentLoaded", function () { } }); +// ── Update notification ───────────────────────────────── +function checkForUpdate() { + fetch("/api/update-check") + .then(r => r.json()) + .then(d => { + if (!d || !d.update_available || !d.latest) return; + const notice = document.getElementById("update-notice"); + const link = document.getElementById("update-link"); + if (!notice || !link) return; + link.textContent = "⬆ Version " + d.latest + " is available — download"; + if (d.url) link.href = d.url; + notice.style.display = "block"; + }) + .catch(() => {}); +} + // ── Real Collection ───────────────────────────────────── function startCollection() { const host = document.getElementById("host").value.trim(); diff --git a/static/style.css b/static/style.css index 57834fb..5393135 100644 --- a/static/style.css +++ b/static/style.css @@ -301,3 +301,22 @@ footer p { color: #bdc3c7; font-size: 12px; } + +.update-notice { + margin-top: 6px; +} + +.update-notice a { + display: inline-block; + color: #27ae60; + font-weight: 600; + text-decoration: none; + padding: 4px 10px; + border: 1px solid #27ae60; + border-radius: 4px; +} + +.update-notice a:hover { + background: #27ae60; + color: #fff; +} diff --git a/templates/index.html b/templates/index.html index df2c56f..a972703 100644 --- a/templates/index.html +++ b/templates/index.html @@ -70,6 +70,9 @@

Collection Failed

diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_collector.py b/tests/test_collector.py new file mode 100644 index 0000000..4506cf2 --- /dev/null +++ b/tests/test_collector.py @@ -0,0 +1,107 @@ +"""Tests for collector helpers and sheet builders (no network required).""" + +from collector import ( + InventoryCollector, + _power_state, + _try_paths, + _get_path, + _prettify_key, +) + + +def _make_collector(): + # __init__ creates an FCClient but performs no network calls until login(). + return InventoryCollector("host", "user", "pass", port=7443) + + +def test_power_state_mapping(): + assert _power_state("running") == "ON" + assert _power_state("stopped") == "OFF" + assert _power_state("shutOff") == "OFF" + assert _power_state("migrating") == "migrating" + assert _power_state("") == "" + + +def test_get_path_nested(): + d = {"vmConfig": {"cpu": {"quantity": 4}}} + assert _get_path(d, "vmConfig.cpu.quantity") == 4 + assert _get_path(d, "vmConfig.cpu.missing") is None + assert _get_path(d, "nope.here") is None + + +def test_try_paths_returns_first_non_empty(): + d = {"a": "", "b": "value"} + assert _try_paths(d, ["a", "b"]) == "value" + assert _try_paths(d, ["missing"]) == "" + + +def test_prettify_key(): + assert _prettify_key("vmConfig.cpu.quantity") == "Cpu - Quantity" + + +def test_build_vinfo_merges_detail_disks_and_nics(): + c = _make_collector() + vm = { + "name": "vm1", + "urn": "urn:vm:1", + "status": "running", + "uuid": "uuid-1", + "clusterUrn": "urn:cl:1", + "locationUrn": "urn:host:1", + } + c.vms = [vm] + c.vm_details = { + "urn:vm:1": { + "vmConfig": { + "cpu": {"quantity": 4, "coresPerSocket": 2}, + "memory": {"quantityMB": 8192}, + }, + "osOptions": {"osType": "Linux"}, + } + } + c.vm_disks = {"urn:vm:1": [{"quantityGB": 40}, {"quantityGB": 60}]} + c.vm_nics = {"urn:vm:1": [{"ip": "10.0.0.1"}, {"ip": ""}]} + c.host_map = {"urn:host:1": "esx-01"} + c.cluster_map = {"urn:cl:1": "Cluster-A"} + + rows = c._build_vinfo() + assert len(rows) == 1 + row = rows[0] + assert row["VM Name"] == "vm1" + assert row["Power State"] == "ON" + assert row["CPUs"] == 4 + assert row["Memory (MB)"] == 8192 + assert row["Total Disk (GB)"] == 100 + assert row["NICs"] == 2 + assert row["IP Addresses"] == "10.0.0.1" + assert row["Host"] == "esx-01" + assert row["Cluster"] == "Cluster-A" + assert row["Guest OS"] == "Linux" + + +def test_build_vdisk_one_row_per_disk(): + c = _make_collector() + c.vms = [{"name": "vm1", "urn": "urn:vm:1", "status": "stopped"}] + c.vm_disks = { + "urn:vm:1": [ + {"name": "disk0", "quantityGB": 40, "datastoreUrn": "urn:ds:1"}, + {"name": "disk1", "quantityGB": 60, "datastoreUrn": "urn:ds:1"}, + ] + } + c.datastore_map = {"urn:ds:1": "DS-A"} + + rows = c._build_vdisk() + assert len(rows) == 2 + assert rows[0]["Power"] == "OFF" + assert rows[0]["Datastore"] == "DS-A" + assert rows[1]["Capacity (GB)"] == 60 + + +def test_build_all_sheets_keys(): + c = _make_collector() + sheets = c._build_all_sheets() + expected = { + "vSummary", "vInfo", "vCPU", "vMemory", "vDisk", + "vNetwork", "vHost", "vCluster", "vDatastore", "vSwitch", + } + assert set(sheets.keys()) == expected diff --git a/tests/test_excel_builder.py b/tests/test_excel_builder.py new file mode 100644 index 0000000..a3fef51 --- /dev/null +++ b/tests/test_excel_builder.py @@ -0,0 +1,47 @@ +"""Tests for the Excel workbook builder.""" + +from openpyxl import load_workbook + +from excel_builder import build_excel, SHEET_ORDER + + +def test_build_excel_creates_all_sheets(tmp_path): + data = { + "vInfo": [ + {"VM Name": "a", "CPUs": 2}, + {"VM Name": "b", "CPUs": 4, "Extra": "x"}, + ] + } + out = tmp_path / "out.xlsx" + build_excel(data, str(out)) + assert out.exists() + + wb = load_workbook(out) + # Every sheet in the fixed order must exist, even when empty. + assert wb.sheetnames == SHEET_ORDER + + +def test_build_excel_header_is_union_of_keys(tmp_path): + data = { + "vInfo": [ + {"VM Name": "a", "CPUs": 2}, + {"VM Name": "b", "CPUs": 4, "Extra": "x"}, + ] + } + out = tmp_path / "out.xlsx" + build_excel(data, str(out)) + + ws = load_workbook(out)["vInfo"] + headers = [c.value for c in ws[1]] + assert headers == ["VM Name", "CPUs", "Extra"] + # Second data row carries the extra column; first row leaves it blank. + rows = list(ws.iter_rows(min_row=2, values_only=True)) + assert rows[0] == ("a", 2, None) or rows[0] == ("a", 2, "") + assert rows[1][2] == "x" + + +def test_build_excel_empty_sheet_has_placeholder(tmp_path): + out = tmp_path / "out.xlsx" + build_excel({}, str(out)) + ws = load_workbook(out)["vInfo"] + assert ws["A1"].value == "No data collected" diff --git a/tests/test_version_utils.py b/tests/test_version_utils.py new file mode 100644 index 0000000..e2d1e6a --- /dev/null +++ b/tests/test_version_utils.py @@ -0,0 +1,35 @@ +"""Tests for version parsing/comparison helpers.""" + +from version_utils import parse_version, is_newer + + +def test_parse_version_basic(): + assert parse_version("1.2.3") == (1, 2, 3) + assert parse_version("v1.2.3") == (1, 2, 3) + + +def test_parse_version_pads_missing_parts(): + assert parse_version("1.1") == (1, 1, 0) + assert parse_version("2") == (2, 0, 0) + + +def test_parse_version_ignores_prerelease_suffix(): + assert parse_version("2.0.0-rc.1") == (2, 0, 0) + assert parse_version("1.4.0+build7") == (1, 4, 0) + + +def test_parse_version_handles_garbage(): + assert parse_version("") == (0, 0, 0) + assert parse_version(None) == (0, 0, 0) + + +def test_is_newer(): + assert is_newer("1.1.0", "1.0.0") is True + assert is_newer("v1.0.1", "1.0.0") is True + assert is_newer("2.0.0", "1.9.9") is True + + +def test_is_not_newer_for_same_or_older(): + assert is_newer("1.0.0", "1.0.0") is False + assert is_newer("1.0.0", "1.1.0") is False + assert is_newer("1.0.0", "v1.0.0") is False diff --git a/version_utils.py b/version_utils.py new file mode 100644 index 0000000..6d58af8 --- /dev/null +++ b/version_utils.py @@ -0,0 +1,61 @@ +""" +Version utilities. + +Pure helpers for parsing/comparing semantic versions, plus a thin wrapper to +query the latest published GitHub release. Kept dependency-light and free of +side effects so it can be unit-tested without network access. +""" + +import re + +import requests + +# Public GitHub repository used for the "new version available" notice. +GITHUB_REPO = "sukritphiboon/fc-inventory" + + +def parse_version(value): + """ + Parse a version string into a comparable (major, minor, patch) tuple. + + Accepts a leading 'v' and ignores any pre-release/build suffix: + 'v1.2.3' -> (1, 2, 3) + '1.1' -> (1, 1, 0) + '2.0.0-rc.1' -> (2, 0, 0) + """ + if not value: + return (0, 0, 0) + core = re.split(r"[-+]", value.strip().lstrip("vV"))[0] + nums = [] + for part in core.split("."): + match = re.match(r"\d+", part) + nums.append(int(match.group()) if match else 0) + while len(nums) < 3: + nums.append(0) + return tuple(nums[:3]) + + +def is_newer(latest, current): + """Return True if `latest` is a strictly newer version than `current`.""" + return parse_version(latest) > parse_version(current) + + +def get_latest_release(repo=GITHUB_REPO, timeout=4): + """ + Fetch the latest published release for `repo` from the GitHub API. + + Returns a dict with 'tag' and 'url'. Raises on network/HTTP errors so the + caller can decide how to degrade gracefully. + """ + url = f"https://api.github.com/repos/{repo}/releases/latest" + resp = requests.get( + url, + timeout=timeout, + headers={"Accept": "application/vnd.github+json"}, + ) + resp.raise_for_status() + data = resp.json() + return { + "tag": data.get("tag_name", "") or "", + "url": data.get("html_url", "") or "", + }