Files
computer-playbook/tests/lint/ansible/services/test_version_keys.py
kevinveenbirkenbachandClaude Opus 5.5 4c955d080f feat(update): fail a declared update source whose pin is not a semver
The version-source resolver orders upstream versions by semver. A pin that
is not one, such as `latest` or a branch name, has no depth, so no candidate
ever matches it and the declaration never moves anything. Nothing reported
that: the watched-pins lint only looks at values that already are a semver.

test_version_keys now fails on an `update:` block whose pinned key does not
hold a semver. A monitored addon the resolver does not read is held to the
same rule as soon as it pins a `version`, because the catalog check skips a
non-semver pin just as silently. An addon without a `version` keeps tracking
the app default and is left alone.

The Stalwart README no longer asks for `webui_version` to be bumped by hand
next to the image tag. Its `update:` block names the upstream tags, the
server itself follows `releases/latest` of the bundle, and the resolver
plans v1.0.5 to v1.0.11 for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:48:22 +02:00

91 lines
3.4 KiB
Python

"""A version only lives behind a key that says it is one.
``version`` is the tag of the entity's own image, ``<what>_version`` any other
upstream version it pins, and ``ref`` the git ref of the repository updater.
A semver behind ``release``, ``tag`` or a name invented once is invisible to
the three updaters and to everyone reading the file, which is how pins go
stale unnoticed.
``update:`` blocks are checked too: a block naming a key the entity does not
carry, a type the resolver does not know, or a pin that is not a semver moves
nothing and says nothing. The updaters order upstream versions by semver, so a
moving tag behind an ``update:`` block, or as the ``version`` of a monitored
addon, fails.
Keys that hold a dotted number without naming a version are listed in
``_NOT_VERSIONS``: the container's ``cpus`` and ``bond`` shares and the
``upstream_host`` address.
Per-line opt-out: ``# nocheck: version-key`` above the pin, with a reason.
"""
from __future__ import annotations
import re
import unittest
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import is_semver
from utils.update.source import invalid_declarations
from . import PROJECT_ROOT
_RULE = "version-key"
_VALID_KEY = re.compile(r"^(version|[a-z0-9_]+_version|ref)$")
_NOT_VERSIONS = frozenset({"cpus", "bond", "upstream_host"})
def _findings() -> list[str]:
findings: list[str] = []
for config_path in sorted(
(PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_SERVICES}")
):
role = config_path.parts[-3]
lines = read_text(str(config_path)).splitlines()
for entity, config in (load_yaml(str(config_path)) or {}).items():
if not isinstance(config, dict):
continue
for key, value in config.items():
name, text = str(key), str(value)
if name in _NOT_VERSIONS or _VALID_KEY.match(name):
continue
if "." not in text or not is_semver(text):
continue
number = next(
(
index
for index, line in enumerate(lines, start=1)
if re.match(rf"^\s+{re.escape(name)}\s*:", line)
),
0,
)
if number and is_suppressed_at(lines, number, _RULE):
continue
findings.append(f"- {role}/{entity}.{name} = {text}")
return sorted(set(findings))
class TestVersionKeys(unittest.TestCase):
def test_a_version_sits_behind_version_or_its_suffix(self) -> None:
findings = _findings()
self.assertEqual(
findings,
[],
"These values look like a version but their key does not say so. "
"Name the key `version` for the entity's own image tag, "
"`<what>_version` for any other upstream version, `<what>_tag` for "
f"a locally built image, or mark it `# nocheck: {_RULE}` with a "
"reason:\n" + "\n".join(findings),
)
def test_every_declared_update_source_can_move_a_semver_pin(self) -> None:
problems = invalid_declarations(PROJECT_ROOT)
self.assertEqual(problems, [], "\n".join(problems))
if __name__ == "__main__":
unittest.main()