From 92b71c702c19e500c6dff3da37e09fac03efd7b3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 9 Sep 2026 22:44:11 -0400 Subject: [PATCH] fix(plugins): compare update versions by ordering Different text is not necessarily a newer version. Use the same parsed comparison for approved feeds and PyPI. Parse each pair once. Keep invalid versions unknown and explain them in the result. The cadence test shows that an invalid feed cannot request an automatic update. Git SHA and catalog-pin comparisons are unchanged. Verification: 55 tests passed across checks, cadence, catalog and transaction paths. Network replies were injected at existing seams. No remote plugin was changed. Lint passed. --- hermes_cli/plugins_updates.py | 24 +++++- tests/hermes_cli/test_plugin_version_order.py | 85 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_plugin_version_order.py diff --git a/hermes_cli/plugins_updates.py b/hermes_cli/plugins_updates.py index 238f675713..aeebb570aa 100644 --- a/hermes_cli/plugins_updates.py +++ b/hermes_cli/plugins_updates.py @@ -22,6 +22,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Optional +from packaging.version import InvalidVersion, Version + from hermes_cli.plugins_provenance import ( Provenance, ProvenanceClass, @@ -34,6 +36,14 @@ _MAX_FEED_BYTES = 1 * 1024 * 1024 _FULL_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{40}") +def _version_is_newer(latest: str, current: str) -> Optional[bool]: + """Invalid versions are unknown, not evidence of an update.""" + try: + return Version(latest) > Version(current) + except InvalidVersion: + return None + + @dataclass class CheckResult: name: str @@ -196,7 +206,12 @@ def check_provenanced( # version, so `current` reports the installed version, not the # recorded revision sha. result.current = installed_version - result.update_available = result.latest != installed_version + result.update_available = _version_is_newer(result.latest, installed_version) + if result.update_available is None: + result.reason = ( + f"cannot compare feed version {result.latest!r} with " + f"installed version {installed_version!r}" + ) return result # ── 3. no update_url anywhere + git row → ls-remote ──────────── @@ -308,13 +323,18 @@ def check_pip_plugins( ) ) continue + update_available = _version_is_newer(latest, current) results.append( CheckResult( name=ep.name, klass="pip", current=current, latest=latest, - update_available=latest != current, + update_available=update_available, + reason=( + f"cannot compare PyPI version {latest!r} with installed {current!r}" + if update_available is None else "" + ), ) ) return results diff --git a/tests/hermes_cli/test_plugin_version_order.py b/tests/hermes_cli/test_plugin_version_order.py new file mode 100644 index 0000000000..cd76f51495 --- /dev/null +++ b/tests/hermes_cli/test_plugin_version_order.py @@ -0,0 +1,85 @@ +"""Plugin update consumers share strict version ordering and unknown results.""" +import json +from types import SimpleNamespace + +import pytest +import yaml + +from hermes_cli.plugins_updates import run_checks + + +def _installed_feed_plugin(plugins): + plugin = plugins / 'feed-plugin' + (plugin / '.git').mkdir(parents=True) + feed_url = 'https://example.invalid/plugin.yml' + metadata = plugins / '.install-metadata.json' + metadata.write_text(json.dumps({'feed-plugin': { + 'source': 'https://example.invalid/plugin.git', 'revision': 'a' * 40, + 'update_url': feed_url, 'pinned': False, + }}), encoding='utf-8') + return plugin, metadata, feed_url + + +@pytest.mark.parametrize('current, offered, expected', [ + ('2.0', '1.0', False), + ('1.0', '1.0.0', False), + ('1.0rc1', '1.0', True), + ('1.0', '1.0rc1', False), + ('1.0', '2.0', True), + ('1.0', 'garbage!', None), + ('garbage!', '1.0', None), +]) +def test_feed_and_pip_checks_use_the_same_version_order(tmp_path, current, offered, expected): + plugins = tmp_path / 'plugins' + plugin, metadata, feed_url = _installed_feed_plugin(plugins) + manifest = plugin / 'plugin.yaml' + manifest.write_text(yaml.safe_dump({'name': plugin.name, 'version': current, + 'update_url': feed_url}), encoding='utf-8') + before = {file: file.read_bytes() for file in (manifest, metadata)} + fetched = [] + results = run_checks( + plugins, + fetch=lambda url: fetched.append(url) or yaml.safe_dump({'version': offered}), + ls_remote=lambda source: pytest.fail('a saved feed must not fall back to Git'), + pip_entry_points=[SimpleNamespace(name='pip-plugin', dist_name='plugin-dist')], + pip_installed_version=lambda name: current, + pip_pypi_latest=lambda name: offered, + ) + assert fetched == [feed_url] + assert {result.name for result in results} == {'feed-plugin', 'pip-plugin'} + for result in results: + row = result.to_json() + assert row['current'] == current and row['latest'] == offered + assert row['update_available'] is expected + assert ('cannot compare' in row['reason']) is (expected is None) + assert {file: file.read_bytes() for file in before} == before + + +def test_cadence_never_applies_an_unparseable_version(tmp_path, monkeypatch): + from hermes_cli.plugins_cadence import run_scheduled_check + from pm import receipt + + home = tmp_path / 'home' + monkeypatch.setenv('HERMES_HOME', str(home)) + monkeypatch.setenv('HERMES_RUNTIME_DIR', str(tmp_path / 'tools')) + plugins = home / 'plugins' + plugin, metadata, feed_url = _installed_feed_plugin(plugins) + (plugin / 'plugin.yaml').write_text( + yaml.safe_dump({'name': plugin.name, 'version': '1.0', 'update_url': feed_url}), encoding='utf-8') + before = metadata.read_bytes() + applied = [] + results = run_scheduled_check( + plugins_dir=plugins, + run_checks_fn=lambda directory: run_checks( + directory, include_pip=False, + fetch=lambda url: 'version: "not-a-version"\n', + ls_remote=lambda source: pytest.fail('feed version failure is not a Git update'), + ), + config_get=lambda section, key: True if key == 'auto_apply' else 24, + apply_updates_fn=applied.append, + ) + assert len(results) == 1 and results[0].update_available is None + assert 'cannot compare' in results[0].reason + assert applied == [] + assert receipt.latest()['outcome'] == 'ok' + assert metadata.read_bytes() == before