Add CLI trigger script for self-update with major version validation and backup configuration
- Add trigger_self_update.sh to executable permissions in Dockerfile - Add trigger-update command mode to self_update_manager.py with argparse CLI - Add queue_update_request helper to write trigger file with normalized parameters - Add parse_selector_version, is_valid_selector_tag, is_supported_selector_tag helpers - Add get_latest_same_major_tag to resolve "latest" within current major version line - Add ensure
frdel committed
Mar 26, 2026 at 12:32 UTC
261c4d6138f10f262eaa3cd914cfeb0f4b222ac9
6 files changed
+462
-16
docker/run/Dockerfile
+1
-1
@@ -30,7 +30,7 @@ RUN bash /ins/post_install.sh $BRANCH
30
# Expose ports
31
EXPOSE 22 80 9000-9009
32
33
-RUN chmod +x /exe/initialize.sh /exe/run_A0.sh /exe/run_searxng.sh /exe/run_tunnel_api.sh
33
+RUN chmod +x /exe/initialize.sh /exe/run_A0.sh /exe/run_searxng.sh /exe/run_tunnel_api.sh /exe/trigger_self_update.sh
34
35
# initialize runtime and switch to supervisord
36
CMD ["/exe/initialize.sh", "$BRANCH"]
docker/run/fs/exe/self_update_manager.py
+248
-14
@@ -1,6 +1,7 @@
1
#!/usr/bin/env python3
2
from __future__ import annotations
3
4
+import argparse
5
import json
6
import os
7
import re
@@ -38,6 +39,10 @@ DEFAULT_HEALTH_TIMEOUT_SECONDS = int(
39
DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float(
40
os.environ.get("A0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2")
41
)
42
+DEFAULT_BACKUP_DIR = "/root/update-backups"
43
+DEFAULT_BACKUP_CONFLICT_POLICY = "rename"
44
+BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
45
+MIN_SELECTOR_VERSION = (1, 0)
46
LATEST_SELECTOR_TAG = "latest"
47
48
@@ -126,10 +131,120 @@ def split_describe_version(describe: str) -> tuple[str, int]:
131
return match.group(1), int(match.group(2))
132
133
134
+def parse_selector_version(tag: str) -> tuple[int, int] | None:
135
+ match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip())
136
+ if not match:
137
+ return None
138
+ return int(match.group(1)), int(match.group(2))
139
+
140
+
141
+def is_valid_selector_tag(tag: str) -> bool:
142
+ return parse_selector_version(tag) is not None
143
+
144
+
145
+def is_supported_selector_tag(tag: str) -> bool:
146
+ parsed = parse_selector_version(tag)
147
+ return parsed is not None and parsed >= MIN_SELECTOR_VERSION
148
+
149
+
150
+def sort_selector_supported_tags(tags: list[str]) -> list[str]:
151
+ return sorted(
152
+ tags,
153
+ key=lambda tag: parse_selector_version(tag) or (-1, -1),
154
+ reverse=True,
155
+ )
156
+
157
+
158
+def parse_major_version(tag: str) -> int | None:
159
+ match = re.fullmatch(r"v(\d+)(?:[.-].*)?", tag.strip())
160
+ if not match:
161
+ return None
162
+ return int(match.group(1))
163
+
164
+
165
def is_latest_selector_tag(tag: str) -> bool:
166
return tag.strip().lower() == LATEST_SELECTOR_TAG
167
168
169
+def build_default_backup_name() -> str:
170
+ timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
171
+ return f"usr-{timestamp}.zip"
172
+
173
+
174
+def normalize_requested_tag(tag: str) -> str:
175
+ normalized = (tag or "").strip()
176
+ if not normalized:
177
+ return LATEST_SELECTOR_TAG
178
+ if is_latest_selector_tag(normalized):
179
+ return LATEST_SELECTOR_TAG
180
+ if not is_valid_selector_tag(normalized):
181
+ raise ValueError("Release tag must use the format vX.Y.")
182
+ if not is_supported_selector_tag(normalized):
183
+ raise ValueError("Release tag must be v1.0 or newer.")
184
+ return normalized
185
+
186
+
187
+def normalize_backup_conflict_policy(conflict_policy: str) -> str:
188
+ normalized = (conflict_policy or DEFAULT_BACKUP_CONFLICT_POLICY).strip().lower()
189
+ if normalized not in BACKUP_CONFLICT_POLICIES:
190
+ raise ValueError("Backup conflict policy must be one of: rename, overwrite, fail.")
191
+ return normalized
192
+
193
+
194
+def get_latest_same_major_tag(
195
+ repo_dir: Path,
196
+ *,
197
+ branch_ref: str,
198
+ current_version: str,
199
+) -> str:
200
+ current_major = parse_major_version(current_version)
201
+ if current_major is None:
202
+ raise RuntimeError(
203
+ f"Could not determine the installed major version from {current_version}. "
204
+ "Use an explicit tag instead of latest."
205
+ )
206
+
207
+ output = git_output(repo_dir, "tag", "--merged", branch_ref)
208
+ same_major_tags = [
209
+ tag
210
+ for tag in (line.strip() for line in output.splitlines())
211
+ if is_supported_selector_tag(tag) and parse_major_version(tag) == current_major
212
+ ]
213
+ if not same_major_tags:
214
+ raise RuntimeError(
215
+ f"No v{current_major}.x release tags are reachable from branch "
216
+ f"{branch_ref.rsplit('/', 1)[-1]}."
217
+ )
218
+ return sort_selector_supported_tags(same_major_tags)[0]
219
+
220
+
221
+def ensure_latest_target_matches_current_major(
222
+ *,
223
+ branch: str,
224
+ current_version: str,
225
+ target_version: str,
226
+) -> None:
227
+ current_major = parse_major_version(current_version)
228
+ if current_major is None:
229
+ raise RuntimeError(
230
+ f"Could not determine the installed major version from {current_version}. "
231
+ "Use an explicit tag instead of latest."
232
+ )
233
+
234
+ target_major = parse_major_version(target_version)
235
+ if target_major is None or not is_supported_selector_tag(target_version):
236
+ raise RuntimeError(
237
+ f"Could not resolve latest on branch {branch} to a supported vX.Y release. "
238
+ "Use an explicit tag instead."
239
+ )
240
+
241
+ if target_major != current_major:
242
+ raise RuntimeError(
243
+ f"Latest on branch {branch} resolves to {target_version}, but the installed "
244
+ f"version is {current_version}. Use an explicit tag to change major versions."
245
+ )
246
+
247
+
248
def get_repo_version_info(repo_dir: Path) -> dict[str, str]:
249
describe = git_output(repo_dir, "describe", "--tags", "--always")
250
commit = git_output(repo_dir, "rev-parse", "HEAD")
@@ -435,6 +550,7 @@ def resolve_requested_target(
550
repo_dir: Path,
551
branch: str,
552
tag: str,
553
+ current_version: str,
554
logger: AttemptLogger,
555
) -> dict[str, str]:
556
normalized_tag = tag.strip()
@@ -451,16 +567,12 @@ def resolve_requested_target(
567
}
568
569
remote_branch_ref = fetch_branch_refs(repo_dir, branch, logger)
454
- head_describe = git_output(repo_dir, "describe", "--tags", "--always", remote_branch_ref)
455
- head_short_tag = normalize_describe_to_version(head_describe)
456
- head_commit = git_output(repo_dir, "rev-parse", remote_branch_ref)
457
-
570
if branch == "main":
459
- effective_tag, _ = split_describe_version(head_describe)
460
- if not effective_tag or effective_tag == head_commit[:7]:
461
- raise RuntimeError(
462
- "Could not resolve the latest tagged release on branch main."
463
- )
571
+ effective_tag = get_latest_same_major_tag(
572
+ repo_dir,
573
+ branch_ref=remote_branch_ref,
574
+ current_version=current_version,
575
+ )
576
logger.log(f"Resolved latest on main to tag {effective_tag}")
577
return {
578
"requested_tag": LATEST_SELECTOR_TAG,
@@ -471,6 +583,14 @@ def resolve_requested_target(
583
"target_description": f"latest tag {effective_tag}",
584
}
585
586
+ head_describe = git_output(repo_dir, "describe", "--tags", "--always", remote_branch_ref)
587
+ head_short_tag = normalize_describe_to_version(head_describe)
588
+ head_commit = git_output(repo_dir, "rev-parse", remote_branch_ref)
589
+ ensure_latest_target_matches_current_major(
590
+ branch=branch,
591
+ current_version=current_version,
592
+ target_version=head_short_tag,
593
+ )
594
logger.log(
595
f"Resolved latest on branch {branch} to commit {head_commit[:7]} ({head_describe})"
596
)
@@ -718,7 +838,13 @@ def execute_pending_update(
838
backup_zip_path = str(backup_destination)
839
backup_exclusions.append(backup_destination)
840
721
- resolved_target = resolve_requested_target(REPO_DIR, branch, tag, logger)
841
+ resolved_target = resolve_requested_target(
842
+ REPO_DIR,
843
+ branch,
844
+ tag,
845
+ source_info["short_tag"],
846
+ logger,
847
+ )
848
849
repository_changed = True
850
logger.log(
@@ -887,6 +1013,109 @@ def load_request_file() -> tuple[dict[str, Any] | None, str]:
1013
TRIGGER_FILE.unlink(missing_ok=True)
1014
1015
1016
+def queue_update_request(
1017
+ *,
1018
+ branch: str = "main",
1019
+ tag: str = LATEST_SELECTOR_TAG,
1020
+ backup_usr: bool = True,
1021
+ backup_path: str = DEFAULT_BACKUP_DIR,
1022
+ backup_name: str = "",
1023
+ backup_conflict_policy: str = DEFAULT_BACKUP_CONFLICT_POLICY,
1024
+) -> dict[str, Any]:
1025
+ source_info = get_repo_version_info(REPO_DIR)
1026
+ normalized_branch = (branch or "").strip().lower() or "main"
1027
+ normalized_tag = normalize_requested_tag(tag)
1028
+ normalized_policy = normalize_backup_conflict_policy(backup_conflict_policy)
1029
+ normalized_backup_path = (backup_path or "").strip() or DEFAULT_BACKUP_DIR
1030
+ normalized_backup_name = sanitize_filename(
1031
+ backup_name,
1032
+ build_default_backup_name(),
1033
+ )
1034
+
1035
+ payload = {
1036
+ "branch": normalized_branch,
1037
+ "tag": normalized_tag,
1038
+ "source_version": source_info["short_tag"],
1039
+ "source_describe": source_info["describe"],
1040
+ "source_commit": source_info["commit"],
1041
+ "requested_at": now_iso(),
1042
+ "backup_usr": bool(backup_usr),
1043
+ "backup_path": normalized_backup_path,
1044
+ "backup_name": normalized_backup_name,
1045
+ "backup_conflict_policy": normalized_policy,
1046
+ }
1047
+ write_yaml(TRIGGER_FILE, payload)
1048
+ return payload
1049
+
1050
+
1051
+def trigger_update_command(args: list[str]) -> int:
1052
+ parser = argparse.ArgumentParser(
1053
+ prog="trigger_self_update.sh",
1054
+ description="Queue an Agent Zero self-update for the next startup attempt.",
1055
+ )
1056
+ parser.add_argument(
1057
+ "branch",
1058
+ nargs="?",
1059
+ default="main",
1060
+ help="Target official branch. Default: main",
1061
+ )
1062
+ parser.add_argument(
1063
+ "tag",
1064
+ nargs="?",
1065
+ default=LATEST_SELECTOR_TAG,
1066
+ help='Target release tag such as v1.10 or "latest". Default: latest',
1067
+ )
1068
+ parser.add_argument(
1069
+ "--backup-dir",
1070
+ default=DEFAULT_BACKUP_DIR,
1071
+ help=f"Directory for the usr backup zip. Default: {DEFAULT_BACKUP_DIR}",
1072
+ )
1073
+ parser.add_argument(
1074
+ "--backup-name",
1075
+ default="",
1076
+ help="Backup zip filename. Default: autogenerated usr-YYYYMMDD-HHMMSS.zip",
1077
+ )
1078
+ parser.add_argument(
1079
+ "--backup-conflict-policy",
1080
+ default=DEFAULT_BACKUP_CONFLICT_POLICY,
1081
+ choices=sorted(BACKUP_CONFLICT_POLICIES),
1082
+ help="How to handle an existing backup zip. Default: rename",
1083
+ )
1084
+ parser.add_argument(
1085
+ "--no-backup",
1086
+ action="store_true",
1087
+ help="Skip creating a usr backup before the update.",
1088
+ )
1089
+ parsed = parser.parse_args(args)
1090
+
1091
+ try:
1092
+ payload = queue_update_request(
1093
+ branch=parsed.branch,
1094
+ tag=parsed.tag,
1095
+ backup_usr=not parsed.no_backup,
1096
+ backup_path=parsed.backup_dir,
1097
+ backup_name=parsed.backup_name,
1098
+ backup_conflict_policy=parsed.backup_conflict_policy,
1099
+ )
1100
+ except Exception as exc:
1101
+ print(f"Failed to queue self-update: {exc}", file=sys.stderr)
1102
+ return 1
1103
+
1104
+ print("Queued Agent Zero self-update for the next startup attempt.")
1105
+ print(f"Branch: {payload['branch']}")
1106
+ print(f"Version: {payload['tag']}")
1107
+ if payload["backup_usr"]:
1108
+ print(f"Backup dir: {payload['backup_path']}")
1109
+ print(f"Backup name: {payload['backup_name']}")
1110
+ print(f"Backup conflict policy: {payload['backup_conflict_policy']}")
1111
+ else:
1112
+ print("Backup: disabled")
1113
+ print(f"Trigger file: {TRIGGER_FILE}")
1114
+ print(f"Log file: {LOG_FILE}")
1115
+ print("Restart the container or Agent Zero process to apply it.")
1116
+ return 0
1117
+
1118
+
1119
def docker_run_ui() -> int:
1120
request_data, raw_text = load_request_file()
1121
logger = AttemptLogger(LOG_FILE)
@@ -949,10 +1178,15 @@ def docker_run_ui() -> int:
1178
1179
def main(argv: list[str] | None = None) -> int:
1180
args = list(argv if argv is not None else sys.argv[1:])
952
- if args and args[0] not in {"docker-run-ui"}:
953
- print(f"Unknown command: {args[0]}", file=sys.stderr)
954
- return 1
955
- return docker_run_ui()
1181
+ if not args or args[0] == "docker-run-ui":
1182
+ return docker_run_ui()
1183
+ if args[0] == "trigger-update":
1184
+ return trigger_update_command(args[1:])
1185
+ if args[0] in {"-h", "--help"}:
1186
+ print("Usage: self_update_manager.py [docker-run-ui | trigger-update ...]")
1187
+ return 0
1188
+ print(f"Unknown command: {args[0]}", file=sys.stderr)
1189
+ return 1
1190
1191
1192
if __name__ == "__main__":
docker/run/fs/exe/trigger_self_update.sh
new
+6
@@ -0,0 +1,6 @@
1
+#!/bin/sh
2
+set -eu
3
+
4
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+
6
+exec python3 "$SCRIPT_DIR/self_update_manager.py" trigger-update "$@"
docs/guides/troubleshooting.md
+38
@@ -55,6 +55,44 @@ Yes, by creating custom tools or using MCP servers. See [Extensions](../develope
55
**Usage**
56
57
- **Terminal commands not executing:** Ensure the Docker container is running and properly configured. Check SSH settings if applicable. Check if the Docker image is updated by removing it from Docker Desktop app, and subsequently pulling it again.
58
+- **Agent Zero stuck on the update screen or not starting after an update:** If the browser stays on the updating screen for multiple minutes, reload the current browser window first. If the UI still does not come back, restart the Docker container. If it still does not recover, queue another self-update for the next startup and inspect the updater log.
59
+
60
+From the host, find the container name:
61
+
62
+```bash
63
+docker ps
64
+```
65
+
66
+Open a shell inside the container:
67
+
68
+```bash
69
+docker exec -it <container> /bin/bash
70
+```
71
+
72
+Queue an update for the next startup attempt with the recovery script in `/exe`:
73
+
74
+```bash
75
+/exe/trigger_self_update.sh
76
+```
77
+
78
+That default command writes `/exe/a0-self-update.yaml` with `main` and `latest`, so the next startup tries the newest release in the current installed major version. You can also specify the branch, version, and backup settings:
79
+
80
+```bash
81
+/exe/trigger_self_update.sh ready latest
82
+/exe/trigger_self_update.sh main v1.10 --backup-dir /root/update-backups --backup-name usr-recovery.zip
83
+/exe/trigger_self_update.sh development latest --no-backup
84
+```
85
+
86
+You can run the same commands directly from the host without opening a shell:
87
+
88
+```bash
89
+docker exec -it <container> /exe/trigger_self_update.sh
90
+docker exec -it <container> /exe/trigger_self_update.sh ready latest
91
+docker exec -it <container> tail -n 200 /exe/a0-self-update.log
92
+docker exec -it <container> cat /exe/a0-self-update-status.yaml
93
+```
94
+
95
+The recovery command only schedules the update. Restart the container or let Agent Zero start again, then check `/exe/a0-self-update.log` and `/exe/a0-self-update-status.yaml` to see what happened.
96
97
* **Error Messages:** Pay close attention to the error messages displayed in the Web UI or terminal. They often provide valuable clues for diagnosing the issue. Refer to the specific error message in online searches or community forums for potential solutions.
98
helpers/self_update.py
+18
-1
@@ -538,6 +538,13 @@ def _format_latest_selector_label(branch: str, describe: str) -> str:
538
return f"latest ({short_tag}+{commits_since_tag})"
539
540
541
+def _format_latest_release_label(tag: str) -> str:
542
+ normalized = tag.strip()
543
+ if not normalized:
544
+ return "latest"
545
+ return f"latest ({normalized})"
546
+
547
+
548
def _format_branch_head_version(branch: str, describe: str) -> str:
549
short_tag, commits_since_tag = _split_describe_version(describe)
550
if not short_tag:
@@ -643,7 +650,17 @@ def get_selector_tag_options(
650
if branch_head_major is not None and branch_head_major > current_major:
651
higher_major_versions.add(branch_head_major)
652
646
- if (
653
+ normalized_branch = (branch or "").strip().lower()
654
+
655
+ if supports_latest and normalized_branch == "main" and same_major_tags:
656
+ same_major_tags.insert(
657
+ 0,
658
+ {
659
+ "value": "latest",
660
+ "label": _format_latest_release_label(same_major_tags[0]["value"]),
661
+ },
662
+ )
663
+ elif (
664
supports_latest
665
and branch_head_major == current_major
666
and _is_selector_supported_tag(branch_head_tag)
tests/test_self_update_tag_filter.py
+151
@@ -1,3 +1,4 @@
1
+import importlib.util
2
import sys
3
import types
4
from pathlib import Path
@@ -14,6 +15,17 @@ sys.modules["giturlparse"] = types.SimpleNamespace(parse=lambda *args, **kwargs:
15
from helpers import self_update
16
17
18
+def load_self_update_manager():
19
+ manager_path = (
20
+ PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
21
+ )
22
+ spec = importlib.util.spec_from_file_location("test_self_update_manager", manager_path)
23
+ assert spec is not None and spec.loader is not None
24
+ module = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
def test_self_update_selector_tags_use_two_segments_and_v1_floor():
30
assert self_update.is_valid_selector_tag("v1.0")
31
assert self_update.is_valid_selector_tag("v12.34")
@@ -201,6 +213,44 @@ def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
213
assert higher_major_versions == [2, 3]
214
215
216
+def test_self_update_selector_tag_options_keep_main_latest_within_current_major(monkeypatch):
217
+ monkeypatch.setattr(
218
+ self_update,
219
+ "get_available_tags",
220
+ lambda branch, *, repo_dir=None, query="": (
221
+ ["v2.0", "v1.4", "v1.2"],
222
+ "",
223
+ ),
224
+ )
225
+ monkeypatch.setattr(
226
+ self_update,
227
+ "_get_branch_head_info",
228
+ lambda branch, repo_dir=None: {
229
+ "describe": "v2.0",
230
+ "short_tag": "v2.0",
231
+ "commit": "def5678",
232
+ },
233
+ )
234
+ monkeypatch.setattr(
235
+ self_update,
236
+ "durable_self_update_supports_latest",
237
+ lambda repo_dir=None: True,
238
+ )
239
+
240
+ tag_options, higher_major_versions, error = self_update.get_selector_tag_options(
241
+ "main",
242
+ current_version="v1.2",
243
+ )
244
+
245
+ assert error == ""
246
+ assert tag_options == [
247
+ {"value": "latest", "label": "latest (v1.4)"},
248
+ {"value": "v1.4", "label": "v1.4"},
249
+ {"value": "v1.2", "label": "v1.2"},
250
+ ]
251
+ assert higher_major_versions == [2]
252
+
253
+
254
def test_self_update_selector_tag_options_hide_latest_when_durable_updater_lacks_support(monkeypatch):
255
monkeypatch.setattr(
256
self_update,
@@ -386,6 +436,23 @@ def test_self_update_modal_uses_standard_select_and_manual_backup():
436
assert "selectTag(tag)" not in content
437
438
439
+def test_self_update_recovery_script_and_docs_are_present():
440
+ script_path = PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "trigger_self_update.sh"
441
+ script_content = script_path.read_text(encoding="utf-8")
442
+ docs_path = PROJECT_ROOT / "docs" / "guides" / "troubleshooting.md"
443
+ docs_content = docs_path.read_text(encoding="utf-8")
444
+ dockerfile_path = PROJECT_ROOT / "docker" / "run" / "Dockerfile"
445
+ dockerfile_content = dockerfile_path.read_text(encoding="utf-8")
446
+
447
+ assert 'trigger-update "$@"' in script_content
448
+ assert "/exe/trigger_self_update.sh" in docs_content
449
+ assert "docker exec -it <container>" in docs_content
450
+ assert "/exe/a0-self-update.log" in docs_content
451
+ assert "reload the current browser window" in docs_content
452
+ assert "main` and `latest`" in docs_content
453
+ assert "/exe/trigger_self_update.sh" in dockerfile_content
454
+
455
+
456
def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_path):
457
monkeypatch.setattr(
458
self_update,
@@ -483,6 +550,90 @@ def test_self_update_schedule_accepts_latest_when_selector_exposes_it(monkeypatc
550
assert captured_payload["tag"] == "latest"
551
552
553
+def test_self_update_manager_queues_update_with_main_latest_defaults(monkeypatch):
554
+ manager = load_self_update_manager()
555
+ captured = {}
556
+ monkeypatch.setattr(
557
+ manager,
558
+ "get_repo_version_info",
559
+ lambda _repo: {
560
+ "branch": "main",
561
+ "describe": "v1.2",
562
+ "short_tag": "v1.2",
563
+ "commit": "abc1234",
564
+ },
565
+ )
566
+ monkeypatch.setattr(
567
+ manager,
568
+ "write_yaml",
569
+ lambda path, payload: captured.update({"path": path, "payload": payload}),
570
+ )
571
+
572
+ payload = manager.queue_update_request(branch="", tag="")
573
+
574
+ assert payload["branch"] == "main"
575
+ assert payload["tag"] == "latest"
576
+ assert payload["backup_usr"] is True
577
+ assert payload["backup_path"] == "/root/update-backups"
578
+ assert payload["backup_conflict_policy"] == "rename"
579
+ assert captured["path"] == manager.TRIGGER_FILE
580
+ assert captured["payload"]["tag"] == "latest"
581
+
582
+
583
+def test_self_update_manager_latest_on_main_uses_current_major_release(monkeypatch):
584
+ manager = load_self_update_manager()
585
+ monkeypatch.setattr(
586
+ manager,
587
+ "fetch_branch_refs",
588
+ lambda repo_dir, branch, logger: "refs/remotes/a0-self-update/main",
589
+ )
590
+ monkeypatch.setattr(
591
+ manager,
592
+ "git_output",
593
+ lambda repo_dir, *args: {
594
+ ("tag", "--merged", "refs/remotes/a0-self-update/main"): "v2.0\nv1.4\nv1.2\n",
595
+ ("rev-parse", "refs/tags/v1.4"): "deadbeef1234",
596
+ }[args],
597
+ )
598
+
599
+ resolved = manager.resolve_requested_target(
600
+ Path("/tmp/repo"),
601
+ "main",
602
+ "latest",
603
+ "v1.2",
604
+ manager.NullLogger(),
605
+ )
606
+
607
+ assert resolved["effective_tag"] == "v1.4"
608
+ assert resolved["expected_short_tag"] == "v1.4"
609
+
610
+
611
+def test_self_update_manager_latest_on_non_main_rejects_cross_major(monkeypatch):
612
+ manager = load_self_update_manager()
613
+ monkeypatch.setattr(
614
+ manager,
615
+ "fetch_branch_refs",
616
+ lambda repo_dir, branch, logger: "refs/remotes/a0-self-update/development",
617
+ )
618
+ monkeypatch.setattr(
619
+ manager,
620
+ "git_output",
621
+ lambda repo_dir, *args: {
622
+ ("describe", "--tags", "--always", "refs/remotes/a0-self-update/development"): "v2.0-3-gabc1234",
623
+ ("rev-parse", "refs/remotes/a0-self-update/development"): "abc123456789",
624
+ }[args],
625
+ )
626
+
627
+ with pytest.raises(RuntimeError, match=r"Use an explicit tag to change major versions"):
628
+ manager.resolve_requested_target(
629
+ Path("/tmp/repo"),
630
+ "development",
631
+ "latest",
632
+ "v1.2",
633
+ manager.NullLogger(),
634
+ )
635
+
636
+
637
def test_self_update_schedule_rejects_latest_when_durable_updater_lacks_support(monkeypatch, tmp_path):
638
monkeypatch.setattr(
639
self_update,