From 4c955d080f5d38182bea24b06fb577c1036eef80 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 5 Oct 2026 12:31:16 +0200 Subject: [PATCH] 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) --- cli/contributing/update/README.md | 4 +++ roles/web-app-stalwart/README.md | 5 +-- .../ansible/services/test_version_keys.py | 7 ++-- tests/unit/python/utils/update/test_source.py | 36 +++++++++++++++++++ utils/update/source.py | 35 ++++++++++++++---- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/cli/contributing/update/README.md b/cli/contributing/update/README.md index d6c0605940..06a929bdbc 100644 --- a/cli/contributing/update/README.md +++ b/cli/contributing/update/README.md @@ -31,6 +31,10 @@ pins declares a list of such blocks. Types: on Docker Hub), `strip: true` removes it again so a pin without the upstream's `v` stays without it. +The pinned value is a semver such as `v1.0.5`, `26.04.4.2.1` or `2.4.0p32`. A +moving tag behind an `update:` block, or as the `version` of a monitored addon, +fails `tests/lint/ansible/services/test_version_keys.py`. + An addon pins its version at the root of `meta/addons/.yml` and declares the same fields in its `update:` block, beside `monitored`, `catalog` and `upstream_id`: diff --git a/roles/web-app-stalwart/README.md b/roles/web-app-stalwart/README.md index 51524b0c4d..83d455f38a 100644 --- a/roles/web-app-stalwart/README.md +++ b/roles/web-app-stalwart/README.md @@ -182,8 +182,9 @@ account (or the Keycloak SSO token when SSO is enabled). it from GitHub on first boot, which fails behind restricted egress; the controller downloads it into a host-level cache (matrix rounds purge the instance dir, and per-round downloads hit GitHub rate limits) and bind-mounts - it read-only. `webui_version` in `meta/services.yml` MUST be bumped together - with the Stalwart `version`. + it read-only. The `update:` block next to `webui_version` in + `meta/services.yml` names the upstream tags, so the version-source updater + bumps the bundle on its own. - **The embedded PostgreSQL pins the plain `postgres` image** — the platform's postgis default defeats baudolo's substring database detection and would degrade backups to torn live-file snapshots. `backup.project_hard_restart` diff --git a/tests/lint/ansible/services/test_version_keys.py b/tests/lint/ansible/services/test_version_keys.py index 5c86c9ac8b..6c5b4b972e 100644 --- a/tests/lint/ansible/services/test_version_keys.py +++ b/tests/lint/ansible/services/test_version_keys.py @@ -7,7 +7,10 @@ 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, or a type the resolver does not know, resolves nothing and says nothing. +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 @@ -78,7 +81,7 @@ class TestVersionKeys(unittest.TestCase): "reason:\n" + "\n".join(findings), ) - def test_every_declared_update_source_resolves_a_key_it_knows(self) -> None: + def test_every_declared_update_source_can_move_a_semver_pin(self) -> None: problems = invalid_declarations(PROJECT_ROOT) self.assertEqual(problems, [], "\n".join(problems)) diff --git a/tests/unit/python/utils/update/test_source.py b/tests/unit/python/utils/update/test_source.py index ea546543c3..a4043f28e5 100644 --- a/tests/unit/python/utils/update/test_source.py +++ b/tests/unit/python/utils/update/test_source.py @@ -220,5 +220,41 @@ class TestAddons(unittest.TestCase): ) +class TestSemverPins(unittest.TestCase): + def test_an_update_block_on_a_moving_tag_is_reported(self) -> None: + root = _repo(SERVICES.replace("app_version: 2.0.0", "app_version: latest")) + + self.assertEqual( + module.invalid_declarations(root), + [ + ( + "web-app-example/app.app_version: 'latest' is not a semver, so " + "no upstream version orders above it and the pin never moves" + ), + ], + ) + + def test_a_monitored_addon_on_a_moving_tag_is_reported(self) -> None: + root = _addon_repo( + { + "moving": ADDONS["catalogued"].replace('"1.0.0"', '"master"'), + "pinned": ADDONS["catalogued"], + "unpinned": ADDONS["catalogued"].replace('version: "1.0.0"\n', ""), + "unmonitored": ADDONS["unmonitored"].replace('"1.0.0"', '"master"'), + } + ) + + self.assertEqual( + module.invalid_declarations(root), + [ + ( + "web-app-example/addons/moving.version: 'master' is not a " + "semver, so no upstream version orders above it and the pin " + "never moves" + ), + ], + ) + + if __name__ == "__main__": unittest.main() diff --git a/utils/update/source.py b/utils/update/source.py index 92fbd7561d..a79956477c 100644 --- a/utils/update/source.py +++ b/utils/update/source.py @@ -42,6 +42,7 @@ from utils.cache.yaml import load_yaml from utils.roles.mapping import ROLE_FILE_META_SERVICES from utils.update.addons import GITHUB_RELEASES_CATALOG, iter_addon_files from utils.update.base import ( + is_semver, latest_semver, version_depth, version_flavor, @@ -297,6 +298,21 @@ def addon_sources(spec: Any) -> list[dict[str, Any]]: return [] +def _monitored_pin(spec: Any) -> str: + """Return the version a monitored addon pins, or an empty string.""" + declared = spec.get(UPDATE_KEY) if isinstance(spec, dict) else None + if not isinstance(declared, dict) or not declared.get("monitored"): + return "" + return str(spec.get(DEFAULT_KEY) or "").strip() + + +def _not_semver(label: str, pinned: Any) -> str: + return ( + f"{label}: '{pinned}' is not a semver, so no upstream version orders " + "above it and the pin never moves" + ) + + def _declaration_problems( label: str, config: dict[str, Any], source: Any, owner: str ) -> list[str]: @@ -314,6 +330,8 @@ def _declaration_problems( key = str(source.get("key", DEFAULT_KEY)) if key not in config: problems.append(f"{label}: update.key '{key}' names no key of the {owner}") + elif not is_semver(str(config[key])): + problems.append(_not_semver(f"{label}.{key}", config[key])) if source.get("type") not in TYPES: problems.append( f"{label}.{key}: unknown update.type " @@ -331,8 +349,10 @@ def _declaration_problems( def invalid_declarations(repo_root: Path) -> list[str]: """Return one message per ``update:`` block that cannot be resolved. - A block naming a key the entity does not carry, or a type the resolver - does not know, would otherwise pin nothing and say nothing. + 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 would otherwise move nothing and + say nothing. A monitored addon the resolver does not read is held to the + same semver rule. Args: repo_root: repository root. @@ -353,10 +373,13 @@ def invalid_declarations(repo_root: Path) -> list[str]: ) for role, addon_path in iter_addon_files(repo_root / "roles"): spec = load_yaml(str(addon_path)) - for source in addon_sources(spec): - problems += _declaration_problems( - f"{role}/addons/{addon_path.stem}", spec, source, "addon" - ) + label = f"{role}/addons/{addon_path.stem}" + sources = addon_sources(spec) + for source in sources: + problems += _declaration_problems(label, spec, source, "addon") + pinned = "" if sources else _monitored_pin(spec) + if pinned and not is_semver(pinned): + problems.append(_not_semver(f"{label}.{DEFAULT_KEY}", pinned)) return problems