Replace file sync with capability detection for durable self-update manager

Replace sync_self_update_runtime_files with durable_self_update_supports_latest that checks whether the durable updater supports the "latest" selector by inspecting manager source code for LATEST_SELECTOR_TAG and resolve_requested_target. Check durable manager first, fall back to repo manager if missing. Block "latest" selection in schedule_update and hide it from get_selector_tag_options when durable updater lacks support.

frdel committed Mar 26, 2026 at 12:06 UTC e0dae52b7fa20601c3d59b940fae8aae2b94d662
2 files changed +162 -49
helpers/self_update.py
+35 -25
@@ -2,7 +2,6 @@ from __future__ import annotations
2
3 import os
4 import re
5 -import shutil
5 import subprocess
6 import tempfile
7 import time
@@ -93,6 +92,10 @@ def get_durable_exe_dir() -> Path:
92 return DURABLE_EXE_DIR
93
94
95 +def get_durable_self_update_manager_path() -> Path:
96 + return get_durable_exe_dir() / "self_update_manager.py"
97 +
98 +
99 def _load_yaml(path: Path) -> dict[str, Any] | None:
100 if not path.exists():
101 return None
@@ -132,10 +135,10 @@ def get_repo_dir(repo_dir: str | Path | None = None) -> Path:
135 return Path(__file__).resolve().parents[1]
136
137
135 -def get_self_update_runtime_source_dir(
138 +def get_repo_self_update_manager_path(
139 repo_dir: str | Path | None = None,
140 ) -> Path:
138 - return get_repo_dir(repo_dir) / "docker" / "run" / "fs" / "exe"
141 + return get_repo_dir(repo_dir) / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
142
143
144 def _get_official_remote_url() -> str:
@@ -337,27 +340,25 @@ def get_available_branches(
340 ]
341
342
340 -def sync_self_update_runtime_files(
343 +def durable_self_update_supports_latest(
344 repo_dir: str | Path | None = None,
342 -) -> list[str]:
343 - source_dir = get_self_update_runtime_source_dir(repo_dir)
344 - durable_dir = get_durable_exe_dir()
345 - synced_files: list[str] = []
346 - runtime_files = ["self_update_manager.py", "run_A0.sh"]
347 -
348 - for filename in runtime_files:
349 - source_path = source_dir / filename
350 - if not source_path.exists():
351 - raise FileNotFoundError(
352 - f"Required self-update runtime file is missing: {source_path}"
353 - )
354 - destination_path = durable_dir / filename
355 - destination_path.parent.mkdir(parents=True, exist_ok=True)
356 - shutil.copyfile(source_path, destination_path)
357 - shutil.copymode(source_path, destination_path)
358 - synced_files.append(str(destination_path))
359 -
360 - return synced_files
345 +) -> bool:
346 + candidate_paths = [
347 + get_durable_self_update_manager_path(),
348 + get_repo_self_update_manager_path(repo_dir=repo_dir),
349 + ]
350 + for path in candidate_paths:
351 + if not path.exists():
352 + continue
353 + try:
354 + content = path.read_text(encoding="utf-8")
355 + except OSError:
356 + continue
357 + return (
358 + 'LATEST_SELECTOR_TAG = "latest"' in content
359 + and "def resolve_requested_target(" in content
360 + )
361 + return False
362
363
364 def _get_branch_reference_names(branch: str) -> list[str]:
@@ -616,6 +617,7 @@ def get_selector_tag_options(
617 tags, error = get_available_tags(branch, repo_dir=repository)
618 if error:
619 return [], [], error
620 + supports_latest = durable_self_update_supports_latest(repo_dir=repository)
621
622 current_major = _parse_major_version(
623 current_version or get_repo_version_info(repository)["short_tag"]
@@ -641,7 +643,11 @@ def get_selector_tag_options(
643 if branch_head_major is not None and branch_head_major > current_major:
644 higher_major_versions.add(branch_head_major)
645
644 - if branch_head_major == current_major and _is_selector_supported_tag(branch_head_tag):
646 + if (
647 + supports_latest
648 + and branch_head_major == current_major
649 + and _is_selector_supported_tag(branch_head_tag)
650 + ):
651 same_major_tags.insert(
652 0,
653 {
@@ -728,6 +734,11 @@ def schedule_update(
734 if not normalized_tag:
735 raise ValueError("A release tag is required.")
736 if _is_latest_selector_tag(normalized_tag):
737 + if not durable_self_update_supports_latest(repo_dir=repository):
738 + raise ValueError(
739 + "This Docker image's durable updater does not support the latest selector. "
740 + "Choose a concrete version or update the Docker image."
741 + )
742 normalized_tag = "latest"
743 elif not is_valid_selector_tag(normalized_tag):
744 raise ValueError("Release tag must use the format vX.Y.")
@@ -773,6 +784,5 @@ def schedule_update(
784 "backup_conflict_policy": normalized_policy, # type: ignore[assignment]
785 }
786
776 - sync_self_update_runtime_files(repository)
787 _write_yaml(get_update_file_path(), payload)
788 return payload
tests/test_self_update_tag_filter.py
+127 -24
@@ -114,35 +114,53 @@ def test_self_update_available_branch_values_fallback_to_local_origin(monkeypatc
114 assert branches == ["main", "ready", "testing"]
115
116
117 -def test_self_update_runtime_files_are_synced_to_durable_exe(monkeypatch, tmp_path):
118 - source_dir = tmp_path / "source-exe"
119 - durable_dir = tmp_path / "durable-exe"
120 - source_dir.mkdir()
121 - durable_dir.mkdir()
122 - manager_source = source_dir / "self_update_manager.py"
123 - launcher_source = source_dir / "run_A0.sh"
124 - manager_source.write_text("# manager\n", encoding="utf-8")
125 - launcher_source.write_text("#!/bin/bash\necho hi\n", encoding="utf-8")
117 +def test_self_update_uses_durable_manager_capability_when_present(monkeypatch, tmp_path):
118 + durable_manager = tmp_path / "self_update_manager.py"
119 + repo_manager = tmp_path / "repo-self_update_manager.py"
120 + durable_manager.write_text("# old manager without latest\n", encoding="utf-8")
121 + repo_manager.write_text(
122 + 'LATEST_SELECTOR_TAG = "latest"\n'
123 + "def resolve_requested_target():\n"
124 + " pass\n",
125 + encoding="utf-8",
126 + )
127
128 monkeypatch.setattr(
129 self_update,
129 - "get_self_update_runtime_source_dir",
130 - lambda repo_dir=None: source_dir,
130 + "get_durable_self_update_manager_path",
131 + lambda: durable_manager,
132 )
133 monkeypatch.setattr(
134 self_update,
134 - "get_durable_exe_dir",
135 - lambda: durable_dir,
135 + "get_repo_self_update_manager_path",
136 + lambda repo_dir=None: repo_manager,
137 )
138
138 - synced_files = self_update.sync_self_update_runtime_files()
139 + assert self_update.durable_self_update_supports_latest() is False
140
140 - assert synced_files == [
141 - str(durable_dir / "self_update_manager.py"),
142 - str(durable_dir / "run_A0.sh"),
143 - ]
144 - assert (durable_dir / "self_update_manager.py").read_text(encoding="utf-8") == "# manager\n"
145 - assert (durable_dir / "run_A0.sh").read_text(encoding="utf-8") == "#!/bin/bash\necho hi\n"
141 +
142 +def test_self_update_falls_back_to_repo_manager_capability_when_durable_missing(monkeypatch, tmp_path):
143 + missing_durable = tmp_path / "missing-self_update_manager.py"
144 + repo_manager = tmp_path / "repo-self_update_manager.py"
145 + repo_manager.write_text(
146 + 'LATEST_SELECTOR_TAG = "latest"\n'
147 + "def resolve_requested_target():\n"
148 + " pass\n",
149 + encoding="utf-8",
150 + )
151 +
152 + monkeypatch.setattr(
153 + self_update,
154 + "get_durable_self_update_manager_path",
155 + lambda: missing_durable,
156 + )
157 + monkeypatch.setattr(
158 + self_update,
159 + "get_repo_self_update_manager_path",
160 + lambda repo_dir=None: repo_manager,
161 + )
162 +
163 + assert self_update.durable_self_update_supports_latest() is True
164
165
166 def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
@@ -163,6 +181,11 @@ def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
181 "commit": "abc1234",
182 },
183 )
184 + monkeypatch.setattr(
185 + self_update,
186 + "durable_self_update_supports_latest",
187 + lambda repo_dir=None: True,
188 + )
189
190 tag_options, higher_major_versions, error = self_update.get_selector_tag_options(
191 "main",
@@ -178,6 +201,43 @@ def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
201 assert higher_major_versions == [2, 3]
202
203
204 +def test_self_update_selector_tag_options_hide_latest_when_durable_updater_lacks_support(monkeypatch):
205 + monkeypatch.setattr(
206 + self_update,
207 + "get_available_tags",
208 + lambda branch, *, repo_dir=None, query="": (
209 + ["v1.4", "v1.2"],
210 + "",
211 + ),
212 + )
213 + monkeypatch.setattr(
214 + self_update,
215 + "_get_branch_head_info",
216 + lambda branch, repo_dir=None: {
217 + "describe": "v1.4-4-gabc1234",
218 + "short_tag": "v1.4",
219 + "commit": "abc1234",
220 + },
221 + )
222 + monkeypatch.setattr(
223 + self_update,
224 + "durable_self_update_supports_latest",
225 + lambda repo_dir=None: False,
226 + )
227 +
228 + tag_options, higher_major_versions, error = self_update.get_selector_tag_options(
229 + "development",
230 + current_version="v1.2",
231 + )
232 +
233 + assert error == ""
234 + assert tag_options == [
235 + {"value": "v1.4", "label": "v1.4"},
236 + {"value": "v1.2", "label": "v1.2"},
237 + ]
238 + assert higher_major_versions == []
239 +
240 +
241 def test_self_update_update_info_uses_current_branch_for_latest_version(monkeypatch):
242 monkeypatch.setattr(
243 self_update,
@@ -213,6 +273,11 @@ def test_self_update_update_info_uses_current_branch_for_latest_version(monkeypa
273 "",
274 ),
275 )
276 + monkeypatch.setattr(
277 + self_update,
278 + "durable_self_update_supports_latest",
279 + lambda repo_dir=None: True,
280 + )
281 monkeypatch.setattr(self_update, "load_pending_update", lambda: None)
282 monkeypatch.setattr(self_update, "load_last_status", lambda: None)
283 monkeypatch.setattr(
@@ -349,8 +414,8 @@ def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_pat
414 )
415 monkeypatch.setattr(
416 self_update,
352 - "sync_self_update_runtime_files",
353 - lambda repo_dir=None: [],
417 + "durable_self_update_supports_latest",
418 + lambda repo_dir=None: True,
419 )
420 monkeypatch.setattr(self_update, "_write_yaml", lambda path, payload: None)
421
@@ -395,8 +460,8 @@ def test_self_update_schedule_accepts_latest_when_selector_exposes_it(monkeypatc
460 )
461 monkeypatch.setattr(
462 self_update,
398 - "sync_self_update_runtime_files",
399 - lambda repo_dir=None: [],
463 + "durable_self_update_supports_latest",
464 + lambda repo_dir=None: True,
465 )
466 monkeypatch.setattr(
467 self_update,
@@ -416,3 +481,41 @@ def test_self_update_schedule_accepts_latest_when_selector_exposes_it(monkeypatc
481
482 assert payload["tag"] == "latest"
483 assert captured_payload["tag"] == "latest"
484 +
485 +
486 +def test_self_update_schedule_rejects_latest_when_durable_updater_lacks_support(monkeypatch, tmp_path):
487 + monkeypatch.setattr(
488 + self_update,
489 + "get_repo_version_info",
490 + lambda _repo: {
491 + "branch": "development",
492 + "describe": "v1.4-2-gabc1234",
493 + "short_tag": "v1.4",
494 + "commit": "abc1234",
495 + "short_commit": "abc1234",
496 + },
497 + )
498 + monkeypatch.setattr(
499 + self_update,
500 + "get_available_branch_values",
501 + lambda repo_dir=None: ["development", "ready", "testing", "main"],
502 + )
503 + monkeypatch.setattr(
504 + self_update,
505 + "durable_self_update_supports_latest",
506 + lambda repo_dir=None: False,
507 + )
508 +
509 + with pytest.raises(
510 + ValueError,
511 + match=r"durable updater does not support the latest selector",
512 + ):
513 + self_update.schedule_update(
514 + branch="development",
515 + tag="latest",
516 + backup_usr=True,
517 + backup_path="",
518 + backup_name="",
519 + backup_conflict_policy="rename",
520 + repo_dir=tmp_path,
521 + )