Fix gateway advertising 'unknown' version in production installs
CI / Gateway plugin tests (push) Successful in 5m56s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m29s

hermes plugins install <repo>#gateway-plugin ships ONLY the
gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so the
repo-root VERSION file is not present there and plugin_version()
fell back to 'unknown' — the app then showed a false
'App and gateway versions differ' warning.

- version.py: resolution chain repo-root VERSION (dev/symlink
  install) -> plugin.yaml version field (production install) ->
  'unknown'; stdlib-only parse, base= param for tests
- plugin.yaml: bump stale version 0.1.0 -> 0.1.2 (matches VERSION)
- scripts/check_version_sync.sh + pre-commit hook: fail commits
  where plugin.yaml drifts from the repo-root VERSION
- tests: regression test simulating the production layout
This commit is contained in:
ARIA committed 2026-08-27 09:33:31 +02:00
1 parent ea375fd88c
commit 0f5b5a16ab
5 files changed
+147 -15

No files matched your search

+6
View File
@@ -13,3 +13,9 @@ repos:
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true 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
+5 -1
View File
@@ -1,7 +1,11 @@
name: iris-platform name: iris-platform
label: Iris label: Iris
kind: platform 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: > description: >
Native Android / Desktop client gateway adapter for Hermes Agent. Native Android / Desktop client gateway adapter for Hermes Agent.
Runs an HTTP server (optional TLS) inside the gateway; the app connects Runs an HTTP server (optional TLS) inside the gateway; the app connects
+55 -14
View File
@@ -1,26 +1,67 @@
"""Version discovery -- the repo-root ``VERSION`` file is the single source """Version discovery.
of truth for the release version ("everything from here on out is vX.Y.Z"
= bump ``VERSION`` and commit).
The plugin lives at ``<repo>/gateway-plugin`` (installed into The repo-root ``VERSION`` file is the single source of truth for the release
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION`` version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and
file is one directory up. The value is advertised to the app in commit). It is advertised to the app in ``hello.ack``
``hello.ack`` (``server_caps.app_version``) so the app can show which (``server_caps.app_version``) so the app can show which gateway version it is
gateway version it is talking to. talking to.
Resolution order (first hit wins):
1. ``<repo>/VERSION`` — dev checkout / symlink install: the plugin lives at
``<repo>/gateway-plugin``, so the ``VERSION`` file is one directory up.
2. ``version:`` in the plugin's own ``plugin.yaml`` — production install:
``hermes plugins install <repo>#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 from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
_FALLBACK = "unknown" _FALLBACK = "unknown"
_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)")
def plugin_version() -> str:
"""The release version from ``<repo>/VERSION``, or ``"unknown"``.""" def _read_version_file(path: Path) -> str:
candidate = Path(__file__).resolve().parent.parent / "VERSION"
try: try:
version = candidate.read_text().strip() return path.read_text().strip()
except OSError: except OSError:
return _FALLBACK return ""
return version or _FALLBACK
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
+41
View File
@@ -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 <repo>#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
+40
View File
@@ -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" 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 <repo>#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 @pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter): async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the """Teardown broadcasts ``status{state=restarting}`` before closing the