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,