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.
This commit is contained in:
ethernet
2026-09-09 22:44:11 -04:00
parent 20c3d2180e
commit 92b71c702c
2 changed files with 107 additions and 2 deletions

View File

@@ -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

View File

@@ -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