Fix gateway advertising 'unknown' version in production installs
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:
1 parent
ea375fd88c
commit
0f5b5a16ab
5 files changed
+147
-15
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
Executable
+41
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user