feat(update): let the ref updater see the addon declarations

collect_entries walked roles/*/meta/services.yml only, so a repository and ref
declared in an addon file was invisible to it. That mattered once 84a4b04f0
made meta/addons the one place a Discourse plugin is pinned: the pins would
have left the updater's view entirely. The other updater cannot stand in,
utils/update/docker.py reaches version: through iter_role_images and skips any
service without an image:.

e317f08ac9 built the addon contract with its own catalog-driven SPOT in
utils/update/addons.py and left repository.py untouched without saying why, so
this widens the ref path rather than replacing the catalog one; an addon can
be maintained by either.

The per-file work moves into _entries_of_file so services.yml and every
meta/addons/*.yml share it. Addon entries carry an ("addons", <stem>) prefix
on their entity path, which keeps them distinguishable from a service entry
holding the same ref. The line index, the semver filter and the per-file
"# nocheck: repository-version" suppression apply unchanged.

Two tests cover it: an addon ref is collected beside a service ref with its
own path and config_path, and a suppressed addon ref yields nothing. Against
this repository the extension currently collects nothing, because every addon
ref is a branch and is_semver rejects those; the tests are what prove the walk
rather than the absence of output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-14 16:44:57 +02:00
1 parent 84a4b04f05
commit e4141cb826
2 files changed
+101 -31

No files matched your search

@@ -9,6 +9,7 @@ from utils.update.repository import (
RepositoryRefEntry,
RepositoryRefUpdate,
apply_updates,
collect_entries,
suppressed_ref_lines,
update_config_refs,
walk_repo_ref_pairs,
@@ -216,5 +217,50 @@ class TestApplyUpdates(unittest.TestCase):
self.assertEqual(read_text(str(config_b)), " ref: v2.1.0\n")
class TestCollectEntriesCoversAddons(unittest.TestCase):
def _role(self, root: Path) -> Path:
role = root / "roles" / "web-app-example"
(role / "meta" / "addons").mkdir(parents=True)
return role
def test_addon_ref_is_collected_beside_the_service_ref(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / "meta" / "services.yml").write_text(
"app:\n"
" repository: https://example.test/app.git\n"
" ref: v1.0.0\n",
encoding="utf-8",
)
(role / "meta" / "addons" / "plug.yml").write_text(
"config:\n"
" repository: https://example.test/plug.git\n"
" ref: v2.0.0\n",
encoding="utf-8",
)
by_ref = {entry.ref: entry for entry in collect_entries(root)}
self.assertEqual(set(by_ref), {"v1.0.0", "v2.0.0"})
addon = by_ref["v2.0.0"]
self.assertEqual(addon.entity_path[:2], ("addons", "plug"))
self.assertEqual(addon.config_path.name, "plug.yml")
def test_addon_ref_honours_its_own_suppression_marker(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / "meta" / "addons" / "plug.yml").write_text(
"config:\n"
" repository: https://example.test/plug.git\n"
" # nocheck: repository-version\n"
" ref: v2.0.0\n",
encoding="utf-8",
)
self.assertEqual(collect_entries(root), [])
if __name__ == "__main__":
unittest.main()
+55 -31
View File
@@ -30,7 +30,7 @@ from typing import TYPE_CHECKING
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.roles.mapping import ROLE_DIR_META_ADDONS, ROLE_FILE_META_SERVICES
from utils.update.base import (
is_semver,
latest_semver,
@@ -148,43 +148,67 @@ def suppressed_ref_lines(config_path: Path) -> set[int]:
return suppressed
def _entries_of_file(
role_name: str, config_path: Path, entity_prefix: tuple[str, ...]
) -> list[RepositoryRefEntry]:
"""Return the unsuppressed semver ``(repository, ref)`` entries of one
declaration file.
Args:
role_name: role the file belongs to, carried into each entry.
config_path: YAML file to read.
entity_prefix: path segments prepended to the entity path, so an
addon's entries stay distinguishable from a service's.
"""
data = load_yaml_any(str(config_path), default_if_missing=None)
if not isinstance(data, dict):
return []
ref_line_index: dict[str, list[int]] = {}
for line_no, ref_value in _ref_lines(read_text(str(config_path)).splitlines()):
ref_line_index.setdefault(ref_value, []).append(line_no)
suppressed = suppressed_ref_lines(config_path)
entries: list[RepositoryRefEntry] = []
for entity_path, repo, ref in walk_repo_ref_pairs(data, entity_prefix):
if not is_semver(ref):
continue
candidates = ref_line_index.get(ref) or []
if not candidates:
continue
line_no = candidates.pop(0)
if line_no in suppressed:
continue
entries.append(
RepositoryRefEntry(
role=role_name,
entity_path=entity_path,
repository=repo,
ref=ref,
config_path=config_path,
line=line_no,
)
)
return entries
def collect_entries(repo_root: Path) -> list[RepositoryRefEntry]:
"""Return every semver-checkable ``(repository, ref)`` declaration
under ``roles/*/meta/services.yml`` whose ref is not suppressed."""
under ``roles/*/meta/services.yml`` and ``roles/*/meta/addons/*.yml``
whose ref is not suppressed."""
roles_root = repo_root / "roles"
entries: list[RepositoryRefEntry] = []
for role_dir in sorted(p for p in roles_root.iterdir() if p.is_dir()):
services_path = role_dir / ROLE_FILE_META_SERVICES
if not services_path.is_file():
continue
data = load_yaml_any(str(services_path), default_if_missing=None)
if not isinstance(data, dict):
continue
if services_path.is_file():
entries += _entries_of_file(role_dir.name, services_path, ())
raw = read_text(str(services_path))
ref_line_index: dict[str, list[int]] = {}
for line_no, ref_value in _ref_lines(raw.splitlines()):
ref_line_index.setdefault(ref_value, []).append(line_no)
suppressed = suppressed_ref_lines(services_path)
for entity_path, repo, ref in walk_repo_ref_pairs(data, ()):
if not is_semver(ref):
continue
candidates = ref_line_index.get(ref) or []
if not candidates:
continue
line_no = candidates.pop(0)
if line_no in suppressed:
continue
entries.append(
RepositoryRefEntry(
role=role_dir.name,
entity_path=entity_path,
repository=repo,
ref=ref,
config_path=services_path,
line=line_no,
)
addons_dir = role_dir / ROLE_DIR_META_ADDONS
if not addons_dir.is_dir():
continue
for addon_path in sorted(addons_dir.glob("*.yml")):
entries += _entries_of_file(
role_dir.name, addon_path, ("addons", addon_path.stem)
)
return entries