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
+ )