diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a10ae32..58c7b11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,4 +12,10 @@ repos: entry: scripts/guard_hermes_agent.sh --staged language: system pass_filenames: false - always_run: true \ No newline at end of file + always_run: true + - id: check-version-sync + name: check gateway-plugin/plugin.yaml version == repo-root VERSION + entry: scripts/check_version_sync.sh + language: system + pass_filenames: false + always_run: true diff --git a/gateway-plugin/plugin.yaml b/gateway-plugin/plugin.yaml index 6f5b1f7..2829016 100644 --- a/gateway-plugin/plugin.yaml +++ b/gateway-plugin/plugin.yaml @@ -1,7 +1,11 @@ name: iris-platform label: Iris kind: platform -version: 0.1.0 +# MUST match the repo-root VERSION file (checked by +# scripts/check_version_sync.sh on commit). This field is the version the +# gateway advertises in production installs, where only this plugin dir is +# shipped (see version.py). +version: 0.1.2 description: > Native Android / Desktop client gateway adapter for Hermes Agent. Runs an HTTP server (optional TLS) inside the gateway; the app connects diff --git a/gateway-plugin/version.py b/gateway-plugin/version.py index 9091049..6238fc1 100644 --- a/gateway-plugin/version.py +++ b/gateway-plugin/version.py @@ -1,26 +1,67 @@ -"""Version discovery -- the repo-root ``VERSION`` file is the single source -of truth for the release version ("everything from here on out is vX.Y.Z" -= bump ``VERSION`` and commit). +"""Version discovery. -The plugin lives at ``/gateway-plugin`` (installed into -``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION`` -file is one directory up. The value is advertised to the app in -``hello.ack`` (``server_caps.app_version``) so the app can show which -gateway version it is talking to. +The repo-root ``VERSION`` file is the single source of truth for the release +version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and +commit). It is advertised to the app in ``hello.ack`` +(``server_caps.app_version``) so the app can show which gateway version it is +talking to. + +Resolution order (first hit wins): + +1. ``/VERSION`` — dev checkout / symlink install: the plugin lives at + ``/gateway-plugin``, so the ``VERSION`` file is one directory up. +2. ``version:`` in the plugin's own ``plugin.yaml`` — production install: + ``hermes plugins install #gateway-plugin`` moves ONLY the + ``gateway-plugin/`` subdirectory into ``~/.hermes/plugins/iris``, so the + repo-root ``VERSION`` is not present there. ``plugin.yaml`` ships with the + plugin dir; a pre-commit hook (``scripts/check_version_sync.sh``) keeps its + ``version:`` field in sync with the repo-root ``VERSION``. +3. ``"unknown"``. """ from __future__ import annotations +import re from pathlib import Path _FALLBACK = "unknown" +_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)") -def plugin_version() -> str: - """The release version from ``/VERSION``, or ``"unknown"``.""" - candidate = Path(__file__).resolve().parent.parent / "VERSION" + +def _read_version_file(path: Path) -> str: try: - version = candidate.read_text().strip() + return path.read_text().strip() except OSError: - return _FALLBACK - return version or _FALLBACK + return "" + + +def _plugin_yaml_version(plugin_dir: Path) -> str: + """The top-level ``version:`` field of ``plugin.yaml`` (stdlib-only parse).""" + try: + text = (plugin_dir / "plugin.yaml").read_text() + except OSError: + return "" + for line in text.splitlines(): + m = _VERSION_RE.match(line) + if m: + return m.group(1) + return "" + + +def plugin_version(base: Path | None = None) -> str: + """The release version, or ``"unknown"`` if it cannot be found. + + ``base`` overrides the plugin directory (tests); by default it is the + directory containing this file. + """ + plugin_dir = base if base is not None else Path(__file__).resolve().parent + # 1. Repo-root VERSION (dev checkout / symlink install). + version = _read_version_file(plugin_dir.parent / "VERSION") + if version: + return version + # 2. plugin.yaml (production install ships only the plugin dir). + version = _plugin_yaml_version(plugin_dir) + if version: + return version + return _FALLBACK diff --git a/scripts/check_version_sync.sh b/scripts/check_version_sync.sh new file mode 100755 index 0000000..582cc0d --- /dev/null +++ b/scripts/check_version_sync.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Fail the commit if gateway-plugin/plugin.yaml's `version:` field drifts +# from the repo-root VERSION file (the single source of truth). +# +# Why: `hermes plugins install #gateway-plugin` ships ONLY the +# gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so in production +# the gateway advertises the version from plugin.yaml (see +# gateway-plugin/version.py). If the two drift, the app shows a false +# "versions differ" warning. +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +version_file="$repo_root/VERSION" +plugin_yaml="$repo_root/gateway-plugin/plugin.yaml" + +[ -f "$version_file" ] || { + echo "check_version_sync: missing $version_file" >&2 + exit 1 +} +[ -f "$plugin_yaml" ] || { + echo "check_version_sync: missing $plugin_yaml" >&2 + exit 1 +} + +root_version="$(tr -d '[:space:]' <"$version_file")" +# Mirrors gateway-plugin/version.py's _VERSION_RE: optional single OR double +# quote, at least one captured character. +yaml_version="$(sed -n "s/^version:[[:space:]]*[\"']\{0,1\}\([^\"'[:space:]]\{1,\}\).*/\1/p" "$plugin_yaml" | head -n1)" + +if [ -z "$yaml_version" ]; then + echo "check_version_sync: no top-level 'version:' field in gateway-plugin/plugin.yaml" >&2 + exit 1 +fi + +if [ "$root_version" != "$yaml_version" ]; then + echo "check_version_sync: version drift" >&2 + echo " VERSION (repo root) = $root_version" >&2 + echo " gateway-plugin/plugin.yaml = $yaml_version" >&2 + echo "Bump both to the same value (VERSION is the source of truth)." >&2 + exit 1 +fi diff --git a/tests/test_android.py b/tests/test_android.py index 546c03e..dcde3a5 100644 --- a/tests/test_android.py +++ b/tests/test_android.py @@ -486,6 +486,46 @@ async def test_hello_ack_advertises_app_version_and_sse_header_is_stored( assert device2["caps"].get("app_version") == "9.9.9" +def test_plugin_version_falls_back_to_plugin_yaml_in_production_layout(plugin, tmp_path): + """Production install (``hermes plugins install #gateway-plugin``) + ships ONLY the ``gateway-plugin/`` subdirectory into + ``~/.hermes/plugins/iris`` — the repo-root ``VERSION`` file is not + present there. The advertised version must then come from + ``plugin.yaml``, not "unknown" (regression: the app showed + 'Gateway vunknown' against a production gateway).""" + v = plugin.version + + # Dev checkout / symlink install: repo-root VERSION is the source of truth. + root_version = Path(v.__file__).resolve().parent.parent / "VERSION" + root_value = root_version.read_text().strip() if root_version.is_file() else "" + if root_value: + assert v.plugin_version() == root_value + + # Simulate the production layout: a plugin dir with a plugin.yaml but no + # VERSION file one level up. + prod_dir = tmp_path / "plugins" / "iris" + prod_dir.mkdir(parents=True) + (prod_dir / "plugin.yaml").write_text( + (Path(v.__file__).parent / "plugin.yaml").read_text() + ) + advertised = v.plugin_version(base=prod_dir) + assert advertised != "unknown" + # The pre-commit hook (scripts/check_version_sync.sh) keeps plugin.yaml in + # sync with the repo-root VERSION, so the production fallback must + # advertise the same real value. + if root_value: + assert advertised == root_value + + # A VERSION file one level up still wins when present. + (tmp_path / "plugins" / "VERSION").write_text("9.9.9\n") + assert v.plugin_version(base=prod_dir) == "9.9.9" + + # Nothing at all -> "unknown". + empty = tmp_path / "empty" + empty.mkdir() + assert v.plugin_version(base=empty) == "unknown" + + @pytest.mark.asyncio async def test_disconnect_broadcasts_status_restarting(adapter): """Teardown broadcasts ``status{state=restarting}`` before closing the