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:
@@ -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
|
||||
|
||||
85
tests/hermes_cli/test_plugin_version_order.py
Normal file
85
tests/hermes_cli/test_plugin_version_order.py
Normal 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
|
||||
Reference in New Issue
Block a user