Files
computer-playbook/tests/unit/python/utils/update/test_repository.py
T
kevinveenbirkenbachandClaude Opus 5.5 cdcb638deb feat(update): move a pinned commit SHA to its repository's default branch
The repository-ref updater only checked semver refs and skipped commit SHAs, so a role pinned to a commit never received a bump. Discourse pins its launcher and plugins to commits on feature/updater (297f3f4a67) to stop a rebuild on every upstream push, and those pins must keep moving after the merge.

A full 40-hex ref next to a repository, in meta/services.yml or an addon's config, is now compared with the tip of the repository's default branch (git ls-remote <repository> HEAD, one query per repository and run) and rewritten through the same path as a semver ref, so it lands in the usual update/repository-refs PR. Short SHAs and branch names stay skipped, '# nocheck: repository-version' still freezes a pin, and a failed lookup leaves the pin alone. test_version_pins_are_watched counts such pins as watched, and test_semver_pinning no longer warns about them.

The jeff pin of svc-ai-s1 drops its unread 'semver-ref' marker, and bridgy-fed and ltengine drop the repository-version markers that were written to silence the non-semver warning, so all three follow their upstream from now on, as does taiga's oidc plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:28:49 +02:00

580 lines
20 KiB
Python

from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_DIR_META_ADDONS, ROLE_FILE_META_SERVICES
from utils.update import repository as module
from utils.update.repository import (
RepositoryRefEntry,
RepositoryRefUpdate,
apply_updates,
collect_entries,
find_outdated_updates,
git_ls_remote_head,
is_commit_sha,
suppressed_ref_lines,
update_config_refs,
walk_repo_ref_pairs,
)
class TestWalkRepoRefPairs(unittest.TestCase):
def test_finds_top_level_entity(self) -> None:
data = {
"bookwyrm": {
"repository": "https://github.com/bookwyrm-social/bookwyrm.git",
"ref": "v0.8.5",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(
pairs,
[
(
("bookwyrm",),
"https://github.com/bookwyrm-social/bookwyrm.git",
"v0.8.5",
),
],
)
def test_descends_into_nested_plugin_map(self) -> None:
data = {
"discourse": {
"plugins": {
"discourse-ldap-auth": {
"repository": "https://github.com/jonmbake/discourse-ldap-auth.git",
"ref": "master",
},
"discourse-prometheus": {
"repository": "https://github.com/discourse/discourse-prometheus.git",
"ref": "v1.0.0",
},
},
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
entity_paths = {pair[0] for pair in pairs}
self.assertIn(("discourse", "plugins", "discourse-ldap-auth"), entity_paths)
self.assertIn(("discourse", "plugins", "discourse-prometheus"), entity_paths)
def test_skips_when_only_repository_or_only_ref(self) -> None:
data = {
"only-repo": {"repository": "https://example.invalid/x.git"},
"only-ref": {"ref": "v1.0.0"},
"both": {
"repository": "https://example.invalid/y.git",
"ref": "v2.0.0",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0][0], ("both",))
def test_walks_lists(self) -> None:
data = {
"repos": [
{
"repository": "https://example.invalid/a.git",
"ref": "v1",
},
{
"repository": "https://example.invalid/b.git",
"ref": "v2",
},
],
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 2)
self.assertEqual(pairs[0][0], ("repos", "[0]"))
self.assertEqual(pairs[1][0], ("repos", "[1]"))
class TestUpdateConfigRefs(unittest.TestCase):
def test_rewrites_target_line_and_preserves_others(self) -> None:
original = """plugin-a:
repository: https://example.invalid/a.git
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {3: "v1.1.0"})
self.assertTrue(changed)
updated = read_text(str(config_path))
self.assertIn(" ref: v1.1.0\n", updated)
self.assertIn(" ref: v2.0.0\n", updated)
def test_preserves_quoting_and_trailing_comment(self) -> None:
original = ' ref: "v0.7.5" # pin\n'
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v0.8.5"})
self.assertTrue(changed)
self.assertEqual(
read_text(str(config_path)),
' ref: "v0.8.5" # pin\n',
)
def test_no_op_when_target_value_already_matches(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v1.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
def test_skips_out_of_range_line_numbers(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {99: "v2.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
class TestSuppressedRefLines(unittest.TestCase):
def test_recognises_line_above_marker(self) -> None:
content = """plugin-a:
repository: https://example.invalid/a.git
# nocheck: repository-version
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(content, encoding="utf-8")
suppressed = suppressed_ref_lines(config_path)
self.assertEqual(suppressed, {4})
class TestApplyUpdates(unittest.TestCase):
def test_groups_updates_per_config_file(self) -> None:
file_a_content = " ref: v1.0.0\n"
file_b_content = " ref: v2.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_a = Path(tmpdir) / "a" / "services.yml"
config_b = Path(tmpdir) / "b" / "services.yml"
config_a.parent.mkdir()
config_b.parent.mkdir()
config_a.write_text(file_a_content, encoding="utf-8")
config_b.write_text(file_b_content, encoding="utf-8")
updates = [
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-a",
entity_path=("plugin-a",),
repository="https://example.invalid/a.git",
ref="v1.0.0",
config_path=config_a,
line=1,
),
latest="v1.1.0",
),
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-b",
entity_path=("plugin-b",),
repository="https://example.invalid/b.git",
ref="v2.0.0",
config_path=config_b,
line=1,
),
latest="v2.1.0",
),
]
changed = apply_updates(updates)
self.assertEqual(set(changed), {config_a, config_b})
self.assertEqual(read_text(str(config_a)), " ref: v1.1.0\n")
self.assertEqual(read_text(str(config_b)), " ref: v2.1.0\n")
SOURCED_FIRST = (
"sourced:\n"
" repository: https://example.test/a.git\n"
" ref: v1.0.0\n"
" update:\n"
" key: ref\n"
" type: http_regex\n"
" url: https://example.test/tags\n"
" pattern: 'a (v[0-9.]+)'\n"
"plain:\n"
" repository: https://example.test/b.git\n"
" ref: v1.0.0\n"
)
class TestSourcedRef(unittest.TestCase):
def test_a_sourced_ref_is_skipped_and_keeps_its_own_line(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config = root / "roles" / "web-app-example" / ROLE_FILE_META_SERVICES
config.parent.mkdir(parents=True)
config.write_text(SOURCED_FIRST, encoding="utf-8")
entries = collect_entries(root)
self.assertEqual(
[(entry.entity_path, entry.line) for entry in entries],
[(("plain",), 11)],
)
apply_updates([RepositoryRefUpdate(entry=entries[0], latest="v1.1.0")])
written = config.read_text() # nocheck: cache-read just rewritten here
lines = written.splitlines()
self.assertEqual(lines[2], " ref: v1.0.0")
self.assertEqual(lines[10], " ref: v1.1.0")
class TestCollectEntriesCoversAddons(unittest.TestCase):
def _role(self, root: Path) -> Path:
role = root / "roles" / "web-app-example"
(role / ROLE_DIR_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 / ROLE_FILE_META_SERVICES).write_text(
"app:\n repository: https://example.test/app.git\n ref: v1.0.0\n",
encoding="utf-8",
)
(role / ROLE_DIR_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 / ROLE_DIR_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), [])
def test_refs_of_an_eol_role_are_not_collected(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / ROLE_FILE_META_SERVICES).write_text(
"example:\n"
" lifecycle: eol\n"
" repository: https://example.test/app.git\n"
" ref: v1.0.0\n",
encoding="utf-8",
)
(role / ROLE_DIR_META_ADDONS / "plug.yml").write_text(
"config:\n repository: https://example.test/plug.git\n ref: v2.0.0\n",
encoding="utf-8",
)
self.assertEqual(collect_entries(root), [])
OLD_SHA = "c623b753" * 5
TIP_SHA = "0f4e2a9c" * 5
SHA_REPO = "https://example.test/oidc.git"
TAG_REPO = "https://example.test/app.git"
class _LsRemote:
"""Stand-in for ``subprocess.run`` answering ``git ls-remote``.
Args:
heads: default-branch tip per repository URL.
tags: tag names per repository URL.
returncode: exit status every call reports.
"""
def __init__(
self,
heads: dict[str, str] | None = None,
tags: dict[str, list[str]] | None = None,
returncode: int = 0,
) -> None:
self.heads = heads or {}
self.tags = tags or {}
self.returncode = returncode
self.calls: list[tuple[str, ...]] = []
def __call__(self, argv: list[str], **_kwargs) -> subprocess.CompletedProcess:
args = tuple(argv[2:])
self.calls.append(args)
if args[0] == "--tags":
stdout = "".join(
f"{'1' * 40}\trefs/tags/{tag}\n" for tag in self.tags.get(args[1], [])
)
else:
head = self.heads.get(args[0])
stdout = f"{head}\tHEAD\n" if head else ""
return subprocess.CompletedProcess(argv, self.returncode, stdout, "")
def _write_role(
root: Path, services: str, addons: dict[str, str] | None = None
) -> Path:
role = root / "roles" / "web-app-example"
(role / ROLE_DIR_META_ADDONS).mkdir(parents=True)
config = role / ROLE_FILE_META_SERVICES
config.write_text(services, encoding="utf-8")
for stem, body in (addons or {}).items():
(role / ROLE_DIR_META_ADDONS / f"{stem}.yml").write_text(body, encoding="utf-8")
return config
def _find(root: Path, ls_remote: _LsRemote) -> list[RepositoryRefUpdate]:
with (
mock.patch.object(module, "resolve_max_fetch_workers", return_value=2),
mock.patch.object(module.subprocess, "run", side_effect=ls_remote),
):
return find_outdated_updates(root)
SHA_PINNED = (
"sso:\n"
" oidc:\n"
f" repository: {SHA_REPO}\n"
f' ref: "{OLD_SHA}" # pinned commit\n'
)
ADDON_SHAPED = (
"---\n"
"enabled: true\n"
"mechanism: plugin\n"
"source: upstream\n"
"update:\n"
" monitored: true\n"
" catalog: discourse-meta\n"
" upstream_id: docker_manager\n"
"config:\n"
f" repository: {SHA_REPO}\n"
f" ref: {OLD_SHA}\n"
)
class TestIsCommitSha(unittest.TestCase):
def test_only_a_full_lowercase_sha_counts(self) -> None:
self.assertTrue(is_commit_sha(OLD_SHA))
self.assertFalse(is_commit_sha(OLD_SHA[:7]))
self.assertFalse(is_commit_sha(OLD_SHA + "0"))
self.assertFalse(is_commit_sha("main"))
self.assertFalse(is_commit_sha("v1.0.0"))
class TestGitLsRemoteHead(unittest.TestCase):
def test_returns_the_head_commit(self) -> None:
ls_remote = _LsRemote(heads={SHA_REPO: TIP_SHA})
with mock.patch.object(module.subprocess, "run", side_effect=ls_remote):
self.assertEqual(git_ls_remote_head(SHA_REPO), TIP_SHA)
self.assertEqual(ls_remote.calls, [(SHA_REPO, "HEAD")])
def test_a_failed_lookup_returns_none(self) -> None:
for failure in (
_LsRemote(heads={SHA_REPO: TIP_SHA}, returncode=128),
subprocess.TimeoutExpired(["git"], 30),
FileNotFoundError("git"),
):
with (
self.subTest(failure=type(failure).__name__),
mock.patch.object(module.subprocess, "run", side_effect=failure),
):
self.assertIsNone(git_ls_remote_head(SHA_REPO))
def test_an_empty_answer_returns_none(self) -> None:
with mock.patch.object(module.subprocess, "run", side_effect=_LsRemote()):
self.assertIsNone(git_ls_remote_head(SHA_REPO))
class TestCommitShaPins(unittest.TestCase):
def test_an_outdated_sha_is_rewritten_to_the_tip(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
config = _write_role(root, SHA_PINNED)
updates = _find(root, _LsRemote(heads={SHA_REPO: TIP_SHA}))
self.assertEqual(
[(u.entry.entity_path, u.entry.ref, u.latest) for u in updates],
[(("sso", "oidc"), OLD_SHA, TIP_SHA)],
)
self.assertEqual(apply_updates(updates), [config])
written = config.read_text() # nocheck: cache-read just rewritten here
self.assertEqual(
written, SHA_PINNED.replace(f'"{OLD_SHA}"', f'"{TIP_SHA}"')
)
def test_a_current_sha_is_left_alone(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(root, SHA_PINNED)
self.assertEqual(_find(root, _LsRemote(heads={SHA_REPO: OLD_SHA})), [])
def test_short_shas_and_branches_are_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root,
"short:\n"
" repository: https://example.test/short.git\n"
f" ref: {OLD_SHA[:7]}\n"
"branch:\n"
" repository: https://example.test/branch.git\n"
" ref: main\n",
)
ls_remote = _LsRemote()
self.assertEqual(collect_entries(root), [])
self.assertEqual(_find(root, ls_remote), [])
self.assertEqual(ls_remote.calls, [])
def test_a_suppressed_sha_is_not_collected(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root,
"app:\n"
f" repository: {SHA_REPO}\n"
" # nocheck: repository-version\n"
f" ref: {OLD_SHA}\n",
)
self.assertEqual(collect_entries(root), [])
def test_a_sha_that_names_its_own_source_is_not_collected(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root,
"app:\n"
f" repository: {SHA_REPO}\n"
f" ref: {OLD_SHA}\n"
" update:\n"
" key: ref\n"
" type: script\n"
" path: files/ref.sh\n",
)
self.assertEqual(collect_entries(root), [])
def test_a_failed_lookup_leaves_every_pin_alone(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root,
SHA_PINNED + f"app:\n repository: {TAG_REPO}\n ref: v1.0.0\n",
)
ls_remote = _LsRemote(
heads={SHA_REPO: TIP_SHA},
tags={TAG_REPO: ["v1.0.0", "v1.2.0"]},
returncode=128,
)
self.assertEqual(_find(root, ls_remote), [])
self.assertEqual(len(ls_remote.calls), 2)
def test_each_repository_is_queried_once_per_lookup_kind(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root,
SHA_PINNED + f"app:\n repository: {TAG_REPO}\n ref: v1.0.0\n",
addons={"docker_manager": ADDON_SHAPED},
)
ls_remote = _LsRemote(
heads={SHA_REPO: TIP_SHA},
tags={TAG_REPO: ["v1.0.0", "v1.2.0"]},
)
updates = _find(root, ls_remote)
self.assertEqual(
sorted(ls_remote.calls),
[("--tags", TAG_REPO), (SHA_REPO, "HEAD")],
)
self.assertEqual(
sorted((u.entry.entity_path, u.latest) for u in updates),
[
(("addons", "docker_manager", "config"), TIP_SHA),
(("app",), "v1.2.0"),
(("sso", "oidc"), TIP_SHA),
],
)
class TestAddonConfigShaPin(unittest.TestCase):
def test_the_addon_config_ref_is_walked_and_rewritten(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
_write_role(
root, "app:\n image: nginx\n", {"docker_manager": ADDON_SHAPED}
)
addon = root / "roles" / "web-app-example" / ROLE_DIR_META_ADDONS
addon /= "docker_manager.yml"
entries = collect_entries(root)
self.assertEqual(
[(e.entity_path, e.ref, e.line) for e in entries],
[(("addons", "docker_manager", "config"), OLD_SHA, 11)],
)
updates = _find(root, _LsRemote(heads={SHA_REPO: TIP_SHA}))
self.assertEqual(apply_updates(updates), [addon])
written = addon.read_text() # nocheck: cache-read just rewritten here
self.assertEqual(written, ADDON_SHAPED.replace(OLD_SHA, TIP_SHA))
if __name__ == "__main__":
unittest.main()