mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
* fix(server): compare against latest release tag, not branch HEAD `is_up_to_date()` previously compared the device's baked-in `GIT_HASH` to the master branch's HEAD SHA, with a GHCR-image-digest fallback. That answered "is your commit at master tip?" instead of "is there a newer release?", so devices on the last release artefact flipped to "out of date" the moment master moved one commit forward, and any GitHub API hiccup silently flipped the indicator back to "up to date". Rewrite around `/repos/Screenly/Anthias/releases/latest`: - Cache the `tag_name` for 24h; honour the existing 5-minute error backoff key. - Compare with `packaging.version.Version` so CalVer ordering is numeric (`2026.10.0 > 2026.5.0`). - On API failure or malformed remote tag, return the last successfully-computed verdict (persisted in Redis on every fresh check); fall back to `False` if there's no cached verdict — never optimistic. - Suppress the indicator on dev/branch builds whose local CalVer doesn't parse. - Drop `fetch_remote_hash`, `remote_branch_available`, `is_running_latest_published_image` and the GHCR helpers. Closes #2819 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: ruff-format new github module + tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(server): scope verdict cache by local release, back off on malformed 200s Two follow-ups to the release-tag rewrite: - Scope the last-verdict cache key by the installed CalVer (`is-up-to-date:last-verdict:<release>`). After an upgrade during a GitHub outage, the previous version's verdict no longer applies; an unscoped cache could keep the indicator wrong until GitHub came back. Falling back to the no-cache path (False) for an upgraded release is the safer choice the issue's "first-run pessimistic" rule already prescribed. - Trip the existing 5-minute backoff on malformed 200 responses (bad JSON, missing/non-string `tag_name`). Without this, an upstream payload regression would re-fire the GitHub call on every page render until the body was fixed. Also pin `packaging==26.1` directly in the server group instead of relying on its transitive arrival via celery → kombu. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(server): don't cache an unparseable release tag Per Copilot review: `_fetch_latest_release_tag()` previously cached any non-empty `tag_name` for 24h before validating parseability. A hand-edited release returning `nightly` (or similar) would pin `is_up_to_date()` to the fallback verdict for the full TTL even after upstream corrected the tag. Validate `_parse_version(tag) is not None` before writing the cache; treat an unparseable tag as a malformed-body error and trip the existing 5-minute backoff. Once the backoff clears, the next request re-fetches and picks up the corrected tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
381 lines
12 KiB
Python
381 lines
12 KiB
Python
import logging
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from requests import exceptions as requests_exceptions
|
|
|
|
from anthias_server.lib import github
|
|
|
|
logging.disable(logging.CRITICAL)
|
|
|
|
|
|
@pytest.fixture
|
|
def redis_data() -> dict[str, str]:
|
|
return {}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_redis(redis_data: dict[str, str]) -> MagicMock:
|
|
fake = MagicMock()
|
|
fake.get.side_effect = redis_data.get
|
|
fake.set.side_effect = lambda key, value: redis_data.__setitem__(
|
|
key, value
|
|
)
|
|
fake.expire.side_effect = lambda _key, _ttl: None
|
|
return fake
|
|
|
|
|
|
@pytest.fixture
|
|
def github_env(fake_redis: MagicMock) -> Iterator[None]:
|
|
with mock.patch.object(github, 'r', fake_redis):
|
|
yield
|
|
|
|
|
|
def _resp(status_code: int = 200, json_data: Any = None) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
if json_data is not None:
|
|
resp.json.return_value = json_data
|
|
if status_code >= 400:
|
|
|
|
def _raise() -> None:
|
|
raise requests_exceptions.HTTPError(
|
|
f'{status_code} error', response=resp
|
|
)
|
|
|
|
resp.raise_for_status.side_effect = _raise
|
|
else:
|
|
resp.raise_for_status.return_value = None
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fetch_latest_release_tag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fetch_latest_release_tag_cache_hit(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
redis_data[github.LATEST_RELEASE_TAG_KEY] = 'v2026.5.0'
|
|
with mock.patch.object(github, 'requests_get') as mock_get:
|
|
assert github._fetch_latest_release_tag() == 'v2026.5.0'
|
|
mock_get.assert_not_called()
|
|
|
|
|
|
def test_fetch_latest_release_tag_backoff_skips_fetch(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
redis_data['github-api-error'] = 'something'
|
|
with mock.patch.object(github, 'requests_get') as mock_get:
|
|
assert github._fetch_latest_release_tag() is None
|
|
mock_get.assert_not_called()
|
|
|
|
|
|
def test_fetch_latest_release_tag_happy_path(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
resp = _resp(200, json_data={'tag_name': 'v2026.6.0', 'name': 'Release'})
|
|
with mock.patch.object(
|
|
github, 'requests_get', return_value=resp
|
|
) as mock_get:
|
|
assert github._fetch_latest_release_tag() == 'v2026.6.0'
|
|
assert redis_data[github.LATEST_RELEASE_TAG_KEY] == 'v2026.6.0'
|
|
url = mock_get.call_args.args[0]
|
|
assert url.endswith('/repos/Screenly/Anthias/releases/latest')
|
|
|
|
|
|
def test_fetch_latest_release_tag_request_exception(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
with mock.patch.object(
|
|
github,
|
|
'requests_get',
|
|
side_effect=requests_exceptions.ConnectionError(),
|
|
):
|
|
assert github._fetch_latest_release_tag() is None
|
|
assert 'github-api-error' in redis_data
|
|
|
|
|
|
def test_fetch_latest_release_tag_5xx_triggers_backoff(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
with mock.patch.object(github, 'requests_get', return_value=_resp(500)):
|
|
assert github._fetch_latest_release_tag() is None
|
|
assert 'github-api-error' in redis_data
|
|
|
|
|
|
def test_fetch_latest_release_tag_invalid_json(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
resp = _resp(200)
|
|
resp.json.side_effect = ValueError('bad json')
|
|
with mock.patch.object(github, 'requests_get', return_value=resp):
|
|
assert github._fetch_latest_release_tag() is None
|
|
# Malformed bodies arm the same backoff as transport failures so
|
|
# the next page render doesn't re-fetch immediately.
|
|
assert 'github-api-error' in redis_data
|
|
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
|
|
|
|
|
|
def test_fetch_latest_release_tag_missing_tag_name(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
resp = _resp(200, json_data={'name': 'Release without tag_name'})
|
|
with mock.patch.object(github, 'requests_get', return_value=resp):
|
|
assert github._fetch_latest_release_tag() is None
|
|
assert 'github-api-error' in redis_data
|
|
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
|
|
|
|
|
|
def test_fetch_latest_release_tag_non_string_tag_name(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
resp = _resp(200, json_data={'tag_name': 12345})
|
|
with mock.patch.object(github, 'requests_get', return_value=resp):
|
|
assert github._fetch_latest_release_tag() is None
|
|
assert 'github-api-error' in redis_data
|
|
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
|
|
|
|
|
|
def test_fetch_latest_release_tag_unparseable_tag_name(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
"""An upstream tag like ``nightly`` must not be cached: with a
|
|
24h TTL, caching it would pin is_up_to_date() to the fallback
|
|
verdict for a day even after upstream corrects the tag. Trip the
|
|
5-minute backoff instead so the next attempt re-fetches once
|
|
upstream is fixed."""
|
|
resp = _resp(200, json_data={'tag_name': 'nightly'})
|
|
with mock.patch.object(github, 'requests_get', return_value=resp):
|
|
assert github._fetch_latest_release_tag() is None
|
|
assert 'github-api-error' in redis_data
|
|
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_version
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_version_strips_leading_v() -> None:
|
|
a = github._parse_version('v2026.5.0')
|
|
b = github._parse_version('2026.5.0')
|
|
assert a is not None and b is not None
|
|
assert a == b
|
|
|
|
|
|
def test_parse_version_invalid_returns_none() -> None:
|
|
assert github._parse_version('') is None
|
|
assert github._parse_version('not-a-version') is None
|
|
|
|
|
|
def test_parse_version_calver_ordering_is_numeric() -> None:
|
|
"""Catches the bug from the issue: string compare would put 10
|
|
before 5; packaging.version must compare numerically."""
|
|
assert github._parse_version('2026.10.0') > github._parse_version( # type: ignore[operator]
|
|
'2026.5.0'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_up_to_date
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_up_to_date_matching_versions_returns_true(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value='v2026.5.0'
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
assert redis_data[github._verdict_cache_key('2026.5.0')] == '1'
|
|
|
|
|
|
def test_is_up_to_date_local_behind_returns_false(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value='v2026.6.0'
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is False
|
|
assert redis_data[github._verdict_cache_key('2026.5.0')] == '0'
|
|
|
|
|
|
def test_is_up_to_date_local_ahead_returns_true(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
"""A local dev bump (e.g. master tip past the last release tag)
|
|
is still 'up to date' — the indicator is about being behind, not
|
|
matching exactly."""
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.10.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value='v2026.5.0'
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
assert redis_data[github._verdict_cache_key('2026.10.0')] == '1'
|
|
|
|
|
|
def test_is_up_to_date_unparseable_local_suppresses_indicator(
|
|
github_env: None,
|
|
) -> None:
|
|
"""Dev builds without a parseable CalVer get no comparison and no
|
|
pill. The remote fetch isn't even attempted in that case."""
|
|
with (
|
|
mock.patch.object(github, 'get_anthias_release', return_value=''),
|
|
mock.patch.object(github, '_fetch_latest_release_tag') as fetch_mock,
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
fetch_mock.assert_not_called()
|
|
|
|
|
|
def test_is_up_to_date_unparseable_local_string_suppresses_indicator(
|
|
github_env: None,
|
|
) -> None:
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='dev-snapshot'
|
|
),
|
|
mock.patch.object(github, '_fetch_latest_release_tag') as fetch_mock,
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
fetch_mock.assert_not_called()
|
|
|
|
|
|
def test_is_up_to_date_github_error_with_cached_verdict_uses_it(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
redis_data[github._verdict_cache_key('2026.5.0')] = '1'
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value=None
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
|
|
redis_data[github._verdict_cache_key('2026.5.0')] = '0'
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value=None
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is False
|
|
|
|
|
|
def test_is_up_to_date_verdict_cache_does_not_leak_across_releases(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
"""An upgrade during a GitHub outage must not reuse the previous
|
|
version's verdict — that verdict was computed against the OLD
|
|
installed release, so it can be stale either way after an
|
|
upgrade. Without a cached verdict for the new release, fall back
|
|
to False so the indicator state catches up to reality on the next
|
|
successful check."""
|
|
redis_data[github._verdict_cache_key('2026.5.0')] = '0'
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.6.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value=None
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is False
|
|
|
|
|
|
def test_is_up_to_date_github_error_no_cache_returns_false(
|
|
github_env: None,
|
|
) -> None:
|
|
"""First-run fail-pessimistic: don't claim 'up to date' when we
|
|
have never successfully checked."""
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github, '_fetch_latest_release_tag', return_value=None
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is False
|
|
|
|
|
|
def test_is_up_to_date_malformed_remote_tag_falls_back(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
redis_data[github._verdict_cache_key('2026.5.0')] = '1'
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github,
|
|
'_fetch_latest_release_tag',
|
|
return_value='not-a-version',
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is True
|
|
|
|
|
|
def test_is_up_to_date_malformed_remote_tag_no_cache_returns_false(
|
|
github_env: None,
|
|
) -> None:
|
|
with (
|
|
mock.patch.object(
|
|
github, 'get_anthias_release', return_value='2026.5.0'
|
|
),
|
|
mock.patch.object(
|
|
github,
|
|
'_fetch_latest_release_tag',
|
|
return_value='not-a-version',
|
|
),
|
|
):
|
|
assert github.is_up_to_date() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# handle_github_error
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_handle_github_error_with_response(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
inner_resp = MagicMock()
|
|
inner_resp.content = b'rate limited'
|
|
exc = requests_exceptions.HTTPError(response=inner_resp)
|
|
github.handle_github_error(exc, 'test-action')
|
|
assert redis_data['github-api-error'] == 'test-action'
|
|
|
|
|
|
def test_handle_github_error_without_response(
|
|
github_env: None, redis_data: dict[str, str]
|
|
) -> None:
|
|
exc = requests_exceptions.ConnectionError()
|
|
exc.response = None
|
|
github.handle_github_error(exc, 'no-resp-action')
|
|
assert redis_data['github-api-error'] == 'no-resp-action'
|