Add latest selector option to self-update with branch head resolution for testing/development and newest tag resolution for main

- Add LATEST_SELECTOR_TAG constant and is_latest_selector_tag helper to identify "latest" selection - Add split_describe_version helper to parse git describe output into tag and commit count - Replace fetch_release_refs with resolve_requested_target that handles both specific tags and "latest" resolution - For main branch, resolve "latest" to newest reachable release tag - For testing/development branches

frdel committed Mar 26, 2026 at 10:44 UTC 192d6e2cae1a85c0a2e7a6ecf41c153b39f1b4c6
9 files changed +466 -58
api/self_update_tags.py
+4 -2
@@ -12,14 +12,15 @@ class SelfUpdateTags(ApiHandler):
12 resolved_branch = branch or default_branch
13
14 try:
15 - tags, higher_major_versions, error = self_update.get_selector_tag_options(
15 + tag_options, higher_major_versions, error = self_update.get_selector_tag_options(
16 resolved_branch,
17 )
18 return {
19 "success": True,
20 "supported": runtime.is_dockerized(),
21 "branch": resolved_branch,
22 - "tags": tags,
22 + "tags": [option["value"] for option in tag_options],
23 + "tag_options": tag_options,
24 "higher_major_versions": higher_major_versions,
25 "error": error,
26 }
@@ -29,6 +30,7 @@ class SelfUpdateTags(ApiHandler):
30 "supported": runtime.is_dockerized(),
31 "branch": resolved_branch,
32 "tags": [],
33 + "tag_options": [],
34 "higher_major_versions": [],
35 "error": str(e),
36 }
docker/run/fs/exe/self_update_manager.py
+114 -11
@@ -38,6 +38,7 @@ DEFAULT_HEALTH_TIMEOUT_SECONDS = int(
38 DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float(
39 os.environ.get("A0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2")
40 )
41 +LATEST_SELECTOR_TAG = "latest"
42
43
44 def now_iso() -> str:
@@ -117,6 +118,18 @@ def normalize_describe_to_version(describe: str) -> str:
118 return describe
119
120
121 +def split_describe_version(describe: str) -> tuple[str, int]:
122 + normalized = describe.strip()
123 + match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
124 + if not match:
125 + return normalized, 0
126 + return match.group(1), int(match.group(2))
127 +
128 +
129 +def is_latest_selector_tag(tag: str) -> bool:
130 + return tag.strip().lower() == LATEST_SELECTOR_TAG
131 +
132 +
133 def get_repo_version_info(repo_dir: Path) -> dict[str, str]:
134 describe = git_output(repo_dir, "describe", "--tags", "--always")
135 commit = git_output(repo_dir, "rev-parse", "HEAD")
@@ -397,15 +410,90 @@ def fetch_release_refs(repo_dir: Path, branch: str, tag: str, logger: AttemptLog
410 )
411
412
400 -def checkout_target_release(
413 +def fetch_branch_refs(repo_dir: Path, branch: str, logger: AttemptLogger) -> str:
414 + remote_branch_ref = f"refs/remotes/a0-self-update/{branch}"
415 + logger.log(f"Fetching branch {branch} and tags from {OFFICIAL_REPO_URL}")
416 + run_command(
417 + [
418 + "git",
419 + "-C",
420 + str(repo_dir),
421 + "fetch",
422 + "--force",
423 + "--tags",
424 + OFFICIAL_REPO_URL,
425 + f"+refs/heads/{branch}:{remote_branch_ref}",
426 + ],
427 + cwd=None,
428 + logger=logger,
429 + error_message=f"Failed to fetch branch {branch} from the official repository.",
430 + )
431 + return remote_branch_ref
432 +
433 +
434 +def resolve_requested_target(
435 repo_dir: Path,
436 branch: str,
437 tag: str,
438 logger: AttemptLogger,
439 +) -> dict[str, str]:
440 + normalized_tag = tag.strip()
441 +
442 + if not is_latest_selector_tag(normalized_tag):
443 + fetch_release_refs(repo_dir, branch, normalized_tag, logger)
444 + return {
445 + "requested_tag": normalized_tag,
446 + "effective_tag": normalized_tag,
447 + "target_ref": f"refs/tags/{normalized_tag}",
448 + "expected_short_tag": normalized_tag,
449 + "expected_commit": git_output(repo_dir, "rev-parse", f"refs/tags/{normalized_tag}"),
450 + "target_description": f"tag {normalized_tag}",
451 + }
452 +
453 + 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 +
458 + 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 + )
464 + logger.log(f"Resolved latest on main to tag {effective_tag}")
465 + return {
466 + "requested_tag": LATEST_SELECTOR_TAG,
467 + "effective_tag": effective_tag,
468 + "target_ref": f"refs/tags/{effective_tag}",
469 + "expected_short_tag": effective_tag,
470 + "expected_commit": git_output(repo_dir, "rev-parse", f"refs/tags/{effective_tag}"),
471 + "target_description": f"latest tag {effective_tag}",
472 + }
473 +
474 + logger.log(
475 + f"Resolved latest on branch {branch} to commit {head_commit[:7]} ({head_describe})"
476 + )
477 + return {
478 + "requested_tag": LATEST_SELECTOR_TAG,
479 + "effective_tag": head_short_tag,
480 + "target_ref": remote_branch_ref,
481 + "expected_short_tag": head_short_tag,
482 + "expected_commit": head_commit,
483 + "target_description": f"latest branch state {head_describe}",
484 + }
485 +
486 +
487 +def checkout_target_release(
488 + repo_dir: Path,
489 + branch: str,
490 + target_ref: str,
491 + target_description: str,
492 + logger: AttemptLogger,
493 *,
494 exclude_paths: list[Path] | None = None,
495 ) -> None:
408 - logger.log(f"Checking out branch {branch} at tag {tag}")
496 + logger.log(f"Checking out branch {branch} at {target_description}")
497 run_command(
498 [
499 "git",
@@ -414,11 +502,11 @@ def checkout_target_release(
502 "checkout",
503 "-B",
504 branch,
417 - f"refs/tags/{tag}",
505 + target_ref,
506 ],
507 cwd=None,
508 logger=logger,
421 - error_message=f"Failed to check out requested tag {tag} on branch {branch}.",
509 + error_message=f"Failed to check out requested {target_description} on branch {branch}.",
510 )
511 clean_repo_worktree(repo_dir, logger, exclude_paths=exclude_paths)
512
@@ -492,6 +580,7 @@ def wait_for_health(
580 timeout_seconds: int,
581 poll_interval_seconds: float,
582 expected_version: str | None = None,
583 + expected_commit: str | None = None,
584 logger: AttemptLogger,
585 ) -> tuple[bool, dict[str, Any] | str]:
586 deadline = time.monotonic() + timeout_seconds
@@ -514,7 +603,13 @@ def wait_for_health(
603 payload = json.loads(body) if body else {}
604 git_info = payload.get("gitinfo") or {}
605 current_version = (git_info.get("short_tag") or "").strip()
517 - if expected_version and current_version and current_version != expected_version:
606 + current_commit = (git_info.get("commit_hash") or "").strip()
607 + if expected_commit and current_commit and current_commit != expected_commit:
608 + last_error = (
609 + f"Health check responded, but commit {current_commit} does not match "
610 + f"expected {expected_commit}."
611 + )
612 + elif expected_version and current_version and current_version != expected_version:
613 last_error = (
614 f"Health check responded, but version {current_version} does not match "
615 f"expected {expected_version}."
@@ -602,6 +697,7 @@ def execute_pending_update(
697 branch = str(request_data.get("branch", "")).strip()
698 tag = str(request_data.get("tag", "")).strip()
699 backup_exclusions: list[Path] = []
700 + resolved_target: dict[str, str] | None = None
701
702 try:
703 if not branch:
@@ -622,7 +718,7 @@ def execute_pending_update(
718 backup_zip_path = str(backup_destination)
719 backup_exclusions.append(backup_destination)
720
625 - fetch_release_refs(REPO_DIR, branch, tag, logger)
721 + resolved_target = resolve_requested_target(REPO_DIR, branch, tag, logger)
722
723 repository_changed = True
724 logger.log(
@@ -632,16 +728,22 @@ def execute_pending_update(
728 checkout_target_release(
729 REPO_DIR,
730 branch,
635 - tag,
731 + resolved_target["target_ref"],
732 + resolved_target["target_description"],
733 logger,
734 exclude_paths=backup_exclusions,
735 )
736
737 current_info = get_repo_version_info(REPO_DIR)
641 - if current_info["short_tag"] != tag:
738 + if resolved_target.get("expected_commit") and current_info["commit"] != resolved_target["expected_commit"]:
739 + raise RuntimeError(
740 + "Git checkout completed but the repository commit does not match the requested target. "
741 + f"Expected {resolved_target['expected_commit']}, got {current_info['commit']}."
742 + )
743 + if resolved_target.get("expected_short_tag") and current_info["short_tag"] != resolved_target["expected_short_tag"]:
744 raise RuntimeError(
745 "Git checkout completed but the repository version does not match the requested tag. "
644 - f"Expected {tag}, got {current_info['short_tag']}."
746 + f"Expected {resolved_target['expected_short_tag']}, got {current_info['short_tag']}."
747 )
748
749 updated_process = launch_ui_process(REPO_DIR, logger)
@@ -650,13 +752,14 @@ def execute_pending_update(
752 health_url=DEFAULT_HEALTH_URL,
753 timeout_seconds=DEFAULT_HEALTH_TIMEOUT_SECONDS,
754 poll_interval_seconds=DEFAULT_HEALTH_POLL_INTERVAL_SECONDS,
653 - expected_version=tag,
755 + expected_version=resolved_target.get("expected_short_tag"),
756 + expected_commit=resolved_target.get("expected_commit"),
757 logger=logger,
758 )
759 if healthy:
760 record_result(
761 status="success",
659 - message=f"Updated Agent Zero to branch {branch}, tag {tag}.",
762 + message=f"Updated Agent Zero to branch {branch}, {resolved_target['target_description']}.",
763 request_data=request_data,
764 source_info=source_info,
765 current_version=current_info["short_tag"],
docs/guides/self-update.md
+8 -3
@@ -8,7 +8,7 @@ Agent Zero includes a Docker-oriented self-update flow for switching to a specif
8 2. Agent Zero restarts.
9 3. The durable updater in `/exe` reads the YAML request before starting the UI.
10 4. If requested, it creates a zip backup of `/a0/usr`.
11 -5. It fetches the requested branch and version tag from the official Agent Zero repository.
11 +5. It fetches the requested branch and update target from the official Agent Zero repository.
12 6. It updates `/a0` while preserving gitignored paths such as `/a0/usr`.
13 7. It starts Agent Zero again and waits for `/api/health` to become healthy.
14 8. If the UI does not become healthy within the allowed time, it restores the previous checkout and starts that version again.
@@ -33,9 +33,14 @@ The updater can create a zip backup of `/a0/usr` before replacing repository fil
33
34 ## Version selection
35
36 -The WebUI preloads repository version tags for the selected branch into a standard selector.
36 +The WebUI preloads repository version choices for the selected branch into a standard selector.
37
38 -Only tags from the current major release line are listed in the selector. If newer major lines are available on the selected branch, the UI shows an attention banner that links to the Docker update guide.
38 +Only versions from the current major release line are listed in the selector. If newer major lines are available on the selected branch, the UI shows an attention banner that links to the Docker update guide.
39 +
40 +The selector also includes `latest` when the selected branch is still on the current major line:
41 +
42 +- On `main`, `latest` resolves to the newest reachable release tag on `main`. It is displayed as `latest (vX.Y)`.
43 +- On `testing` and `development`, `latest` resolves to the current branch head. It is displayed as `latest (vX.Y+N)` when the branch head is `N` commits past the newest reachable tag, or `latest (vX.Y)` when it is exactly on a tag.
44
45 Agent Zero version tags follow this format:
46
helpers/git.py
+38 -8
@@ -5,6 +5,7 @@ from dataclasses import dataclass
5 import os
6 import subprocess
7 import base64
8 +import re
9 from urllib.parse import urlparse, urlunparse
10 from helpers import files
11
@@ -100,6 +101,33 @@ def _format_git_timestamp(timestamp: int) -> str:
101 return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
102
103
104 +def _split_describe_version(describe: str) -> tuple[str, int]:
105 + normalized = describe.strip()
106 + match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
107 + if not match:
108 + return normalized, 0
109 + return match.group(1), int(match.group(2))
110 +
111 +
112 +def _format_release_version(
113 + branch: str,
114 + short_tag: str,
115 + commits_since_tag: int,
116 + commit_hash: str,
117 +) -> str:
118 + version_prefix = branch[0].upper() if branch else "D"
119 + version_core = short_tag or commit_hash[:7]
120 +
121 + if (
122 + short_tag
123 + and commits_since_tag > 0
124 + and branch.strip().lower() != "main"
125 + ):
126 + version_core = f"{short_tag}+{commits_since_tag}"
127 +
128 + return f"{version_prefix} {version_core}"
129 +
130 +
131 def get_remote_releases(author: str, repo: str) -> GitRemoteReleasesResult:
132 try:
133 author = author.strip()
@@ -289,13 +317,10 @@ def get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo:
317 tag = ""
318 short_tag = ""
319 release_time = ""
320 + commits_since_tag = 0
321 try:
293 - tag = repo.git.describe(tags=True)
294 - tag_split = tag.split('-')
295 - if len(tag_split) >= 3:
296 - short_tag = "-".join(tag_split[:-1])
297 - else:
298 - short_tag = tag
322 + tag = repo.git.describe(tags=True, always=True)
323 + short_tag, commits_since_tag = _split_describe_version(tag)
324
325 tag_ref = next((t for t in repo.tags if t.name == short_tag), None)
326 if tag_ref:
@@ -305,9 +330,14 @@ def get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo:
330 tag = ""
331 short_tag = ""
332 release_time = ""
333 + commits_since_tag = 0
334
309 - version_prefix = branch[0].upper() if branch else "D"
310 - version = version_prefix + " " + (short_tag or commit.hexsha[:7])
335 + version = _format_release_version(
336 + branch,
337 + short_tag,
338 + commits_since_tag,
339 + commit.hexsha,
340 + )
341
342 return GitRepoReleaseInfo(
343 is_git_repo=True,
helpers/self_update.py
+129 -11
@@ -29,6 +29,7 @@ STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
29 LOG_FILE_PATH = Path("/exe/a0-self-update.log")
30
31 _remote_branch_tag_cache: dict[str, tuple[float, set[str]]] = {}
32 +_remote_branch_head_cache: dict[str, tuple[float, dict[str, str]]] = {}
33
34
35 class PendingUpdateConfig(TypedDict):
@@ -62,6 +63,11 @@ class UpdateStatus(TypedDict, total=False):
63 error: str
64
65
66 +class SelectorTagOption(TypedDict):
67 + value: str
68 + label: str
69 +
70 +
71 def _now_iso() -> str:
72 return datetime.now(UTC).isoformat().replace("+00:00", "Z")
73
@@ -139,6 +145,18 @@ def _normalize_describe_to_version(describe: str) -> str:
145 return describe
146
147
148 +def _split_describe_version(describe: str) -> tuple[str, int]:
149 + normalized = describe.strip()
150 + match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
151 + if not match:
152 + return normalized, 0
153 + return match.group(1), int(match.group(2))
154 +
155 +
156 +def _is_latest_selector_tag(tag: str) -> bool:
157 + return tag.strip().lower() == "latest"
158 +
159 +
160 def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
161 repository = get_repo_dir(repo_dir)
162 describe = _run_git(repository, "describe", "--tags", "--always")
@@ -230,6 +248,42 @@ def _get_remote_branch_merged_tags(branch: str) -> set[str]:
248 return set(merged_tags)
249
250
251 +def _get_remote_branch_head_info(branch: str) -> dict[str, str]:
252 + normalized_branch = branch.strip().lower()
253 + if normalized_branch not in SUPPORTED_BRANCHES:
254 + return {"describe": "", "short_tag": "", "commit": ""}
255 +
256 + cached = _remote_branch_head_cache.get(normalized_branch)
257 + now = time.monotonic()
258 + if cached and now - cached[0] <= REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS:
259 + return dict(cached[1])
260 +
261 + with tempfile.TemporaryDirectory(prefix="a0-self-update-head-") as temp_dir:
262 + repository = Path(temp_dir)
263 + _run_git(repository, "init", "--bare")
264 + _run_git(
265 + repository,
266 + "fetch",
267 + "--quiet",
268 + "--prune",
269 + "--filter=blob:none",
270 + "--tags",
271 + _get_official_remote_url(),
272 + f"refs/heads/{normalized_branch}:refs/remotes/origin/{normalized_branch}",
273 + )
274 + remote_ref = f"refs/remotes/origin/{normalized_branch}"
275 + describe = _run_git(repository, "describe", "--tags", "--always", remote_ref)
276 + commit = _run_git(repository, "rev-parse", remote_ref)
277 +
278 + payload = {
279 + "describe": describe,
280 + "short_tag": _normalize_describe_to_version(describe),
281 + "commit": commit,
282 + }
283 + _remote_branch_head_cache[normalized_branch] = (now, payload)
284 + return dict(payload)
285 +
286 +
287 def _get_local_branch_merged_tags(
288 branch: str,
289 repo_dir: str | Path | None = None,
@@ -245,6 +299,26 @@ def _get_local_branch_merged_tags(
299 return set()
300
301
302 +def _get_local_branch_head_info(
303 + branch: str,
304 + repo_dir: str | Path | None = None,
305 +) -> dict[str, str]:
306 + repository = get_repo_dir(repo_dir)
307 + for ref in _get_branch_reference_names(branch):
308 + try:
309 + _run_git(repository, "rev-parse", "--verify", ref)
310 + describe = _run_git(repository, "describe", "--tags", "--always", ref)
311 + commit = _run_git(repository, "rev-parse", ref)
312 + return {
313 + "describe": describe,
314 + "short_tag": _normalize_describe_to_version(describe),
315 + "commit": commit,
316 + }
317 + except Exception:
318 + continue
319 + return {"describe": "", "short_tag": "", "commit": ""}
320 +
321 +
322 def _get_branch_merged_tags(
323 branch: str,
324 repo_dir: str | Path | None = None,
@@ -258,6 +332,19 @@ def _get_branch_merged_tags(
332 return _get_local_branch_merged_tags(branch, repo_dir=repo_dir)
333
334
335 +def _get_branch_head_info(
336 + branch: str,
337 + repo_dir: str | Path | None = None,
338 +) -> dict[str, str]:
339 + try:
340 + remote_info = _get_remote_branch_head_info(branch)
341 + if remote_info.get("commit"):
342 + return remote_info
343 + except Exception:
344 + pass
345 + return _get_local_branch_head_info(branch, repo_dir=repo_dir)
346 +
347 +
348 def _parse_selector_version(tag: str) -> tuple[int, int] | None:
349 match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip())
350 if not match:
@@ -292,6 +379,15 @@ def _parse_major_version(tag: str) -> int | None:
379 return int(match.group(1))
380
381
382 +def _format_latest_selector_label(branch: str, describe: str) -> str:
383 + short_tag, commits_since_tag = _split_describe_version(describe)
384 + if not short_tag:
385 + return "latest"
386 + if branch.strip().lower() == "main" or commits_since_tag <= 0:
387 + return f"latest ({short_tag})"
388 + return f"latest ({short_tag}+{commits_since_tag})"
389 +
390 +
391 def get_available_tags(
392 branch: str | None = None,
393 *,
@@ -322,7 +418,7 @@ def get_selector_tag_options(
418 *,
419 repo_dir: str | Path | None = None,
420 current_version: str | None = None,
325 -) -> tuple[list[str], list[int], str]:
421 +) -> tuple[list[SelectorTagOption], list[int], str]:
422 repository = get_repo_dir(repo_dir)
423 tags, error = get_available_tags(branch, repo_dir=repository)
424 if error:
@@ -332,19 +428,38 @@ def get_selector_tag_options(
428 current_version or get_repo_version_info(repository)["short_tag"]
429 )
430 if current_major is None:
335 - return tags, [], ""
431 + return [{"value": tag, "label": tag} for tag in tags], [], ""
432
337 - same_major_tags: list[str] = []
433 + branch_head_info = _get_branch_head_info(branch or "", repo_dir=repository)
434 + branch_head_tag = branch_head_info.get("short_tag", "")
435 + branch_head_major = _parse_major_version(branch_head_tag)
436 +
437 + same_major_tags: list[SelectorTagOption] = []
438 higher_major_versions: set[int] = set()
439 for tag in tags:
440 tag_major = _parse_major_version(tag)
441 if tag_major is None:
442 continue
443 if tag_major == current_major:
344 - same_major_tags.append(tag)
444 + same_major_tags.append({"value": tag, "label": tag})
445 elif tag_major > current_major:
446 higher_major_versions.add(tag_major)
447
448 + if branch_head_major is not None and branch_head_major > current_major:
449 + higher_major_versions.add(branch_head_major)
450 +
451 + if branch_head_major == current_major and _is_selector_supported_tag(branch_head_tag):
452 + same_major_tags.insert(
453 + 0,
454 + {
455 + "value": "latest",
456 + "label": _format_latest_selector_label(
457 + branch or "",
458 + branch_head_info.get("describe", ""),
459 + ),
460 + },
461 + )
462 +
463 return same_major_tags, sorted(higher_major_versions), ""
464
465
@@ -354,7 +469,7 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
469 current_version = version_info["short_tag"]
470 current_branch = version_info.get("branch", "").strip().lower()
471 default_branch = current_branch if current_branch in SUPPORTED_BRANCHES else "main"
357 - tags, higher_major_versions, tags_error = get_selector_tag_options(
472 + tag_options, higher_major_versions, tags_error = get_selector_tag_options(
473 default_branch,
474 repo_dir=repository,
475 current_version=current_version,
@@ -365,7 +480,8 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
480 "pending": load_pending_update(),
481 "last_status": load_last_status(),
482 "branches": BRANCH_OPTIONS,
368 - "available_tags": tags,
483 + "available_tags": [option["value"] for option in tag_options],
484 + "available_tag_options": tag_options,
485 "available_tags_error": tags_error,
486 "available_higher_major_versions": higher_major_versions,
487 "paths": {
@@ -404,21 +520,23 @@ def schedule_update(
520 normalized_tag = tag.strip()
521 if not normalized_tag:
522 raise ValueError("A release tag is required.")
407 - if not is_valid_selector_tag(normalized_tag):
523 + if _is_latest_selector_tag(normalized_tag):
524 + normalized_tag = "latest"
525 + elif not is_valid_selector_tag(normalized_tag):
526 raise ValueError("Release tag must use the format vX.Y.")
409 - if not _is_selector_supported_tag(normalized_tag):
527 + elif not _is_selector_supported_tag(normalized_tag):
528 raise ValueError("Release tag must be v1.0 or newer.")
529
412 - available_tags, tag_lookup_error = get_available_tags(
530 + selector_tag_options, _, tag_lookup_error = get_selector_tag_options(
531 normalized_branch,
532 repo_dir=repository,
415 - query=normalized_tag,
533 + current_version=version_info["short_tag"],
534 )
535 if tag_lookup_error:
536 raise RuntimeError(
537 f"Failed to verify release tag {normalized_tag} on branch {normalized_branch}: {tag_lookup_error}"
538 )
421 - if normalized_tag not in available_tags:
539 + if normalized_tag not in {option["value"] for option in selector_tag_options}:
540 raise ValueError(
541 f"Version {normalized_tag} does not exist on branch {normalized_branch}."
542 )
tests/test_git_version_label.py new
+69
@@ -0,0 +1,69 @@
1 +import subprocess
2 +import sys
3 +import types
4 +from pathlib import Path
5 +
6 +
7 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
8 +if str(PROJECT_ROOT) not in sys.path:
9 + sys.path.insert(0, str(PROJECT_ROOT))
10 +
11 +sys.modules["giturlparse"] = types.SimpleNamespace(
12 + parse=lambda *args, **kwargs: types.SimpleNamespace(
13 + owner="",
14 + repo="",
15 + name="",
16 + valid=False,
17 + )
18 +)
19 +
20 +from helpers import git
21 +
22 +
23 +def run_git(repo_dir: Path, *args: str) -> str:
24 + completed = subprocess.run(
25 + ["git", "-C", str(repo_dir), *args],
26 + check=True,
27 + text=True,
28 + capture_output=True,
29 + )
30 + return completed.stdout.strip()
31 +
32 +
33 +def init_repo_with_tag(repo_dir: Path, branch: str) -> None:
34 + run_git(repo_dir, "init")
35 + run_git(repo_dir, "branch", "-m", branch)
36 + run_git(repo_dir, "config", "user.name", "Test User")
37 + run_git(repo_dir, "config", "user.email", "test@example.com")
38 + (repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8")
39 + run_git(repo_dir, "add", "tracked.txt")
40 + run_git(repo_dir, "commit", "-m", "initial")
41 + run_git(repo_dir, "tag", "v1.9")
42 +
43 +
44 +def add_commit(repo_dir: Path, content: str) -> None:
45 + (repo_dir / "tracked.txt").write_text(content, encoding="utf-8")
46 + run_git(repo_dir, "add", "tracked.txt")
47 + run_git(repo_dir, "commit", "-m", "update")
48 +
49 +
50 +def test_git_version_label_shows_commit_distance_on_development(tmp_path):
51 + init_repo_with_tag(tmp_path, "development")
52 + add_commit(tmp_path, "two\n")
53 +
54 + info = git.get_repo_release_info(str(tmp_path))
55 +
56 + assert info.release is not None
57 + assert info.release.short_tag == "v1.9"
58 + assert info.release.version == "D v1.9+1"
59 +
60 +
61 +def test_git_version_label_hides_commit_distance_on_main(tmp_path):
62 + init_repo_with_tag(tmp_path, "main")
63 + add_commit(tmp_path, "two\n")
64 +
65 + info = git.get_repo_release_info(str(tmp_path))
66 +
67 + assert info.release is not None
68 + assert info.release.short_tag == "v1.9"
69 + assert info.release.version == "M v1.9"
tests/test_self_update_tag_filter.py
+70 -6
@@ -75,14 +75,27 @@ def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
75 "",
76 ),
77 )
78 + monkeypatch.setattr(
79 + self_update,
80 + "_get_branch_head_info",
81 + lambda branch, repo_dir=None: {
82 + "describe": "v1.4-4-gabc1234",
83 + "short_tag": "v1.4",
84 + "commit": "abc1234",
85 + },
86 + )
87
79 - tags, higher_major_versions, error = self_update.get_selector_tag_options(
88 + tag_options, higher_major_versions, error = self_update.get_selector_tag_options(
89 "main",
90 current_version="v1.2",
91 )
92
93 assert error == ""
85 - assert tags == ["v1.4", "v1.2"]
94 + assert tag_options == [
95 + {"value": "latest", "label": "latest (v1.4)"},
96 + {"value": "v1.4", "label": "v1.4"},
97 + {"value": "v1.2", "label": "v1.2"},
98 + ]
99 assert higher_major_versions == [2, 3]
100
101
@@ -99,15 +112,18 @@ def test_self_update_frontend_uses_preloaded_select():
112
113 assert 'const SELF_UPDATE_MANUAL_BACKUP_MODAL_PATH = "settings/backup/backup_restore.html";' in content
114 assert "const MIN_SELECTOR_VERSION = [1, 0];" in content
102 - assert "availableTags: []" in content
115 + assert "availableTagOptions: []" in content
116 assert "higherMajorVersions: []" in content
117 assert "this.applyAvailableTags({" in content
105 - assert "response.available_tags" in content
118 + assert "response.available_tag_options" in content
119 assert "response.available_higher_major_versions" in content
120 + assert "response.tag_options" in content
121 assert "response.higher_major_versions" in content
122 assert "await this.fetchTags();" in content
123 assert "Release tag must use the format vX.Y." in content
124 assert "Release tag must be v1.0 or newer." in content
125 + assert "isLatestSelectorTag(value)" in content
126 + assert "this.isSelectableTag(this.form.tag)" in content
127 assert "this.info?.defaults?.branch ||" in content
128 assert "Version ${this.trimmedTag} does not exist on branch" in content
129 assert "this.selectedTagExistsOnBranch" in content
@@ -137,6 +153,8 @@ def test_self_update_modal_uses_standard_select_and_manual_backup():
153 assert 'x-model="$store.selfUpdateStore.form.tag"' in content
154 assert "$store.selfUpdateStore.versionSelectPlaceholder" in content
155 assert "$store.selfUpdateStore.higherMajorVersionMessage" in content
156 + assert "$store.selfUpdateStore.availableTagOptions" in content
157 + assert "tagOption.label" in content
158 assert "Docker update guide" in content
159 assert "https://www.agent-zero.ai/p/docs/get-started/" in content
160 assert "Manual backup" in content
@@ -159,8 +177,12 @@ def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_pat
177 )
178 monkeypatch.setattr(
179 self_update,
162 - "get_available_tags",
163 - lambda branch, *, repo_dir=None, query="": (["v1.0"], ""),
180 + "get_selector_tag_options",
181 + lambda branch, *, repo_dir=None, current_version=None: (
182 + [{"value": "v1.0", "label": "v1.0"}],
183 + [],
184 + "",
185 + ),
186 )
187 monkeypatch.setattr(self_update, "_write_yaml", lambda path, payload: None)
188
@@ -174,3 +196,45 @@ def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_pat
196 backup_conflict_policy="rename",
197 repo_dir=tmp_path,
198 )
199 +
200 +
201 +def test_self_update_schedule_accepts_latest_when_selector_exposes_it(monkeypatch, tmp_path):
202 + monkeypatch.setattr(
203 + self_update,
204 + "get_repo_version_info",
205 + lambda _repo: {
206 + "branch": "development",
207 + "describe": "v1.4-2-gabc1234",
208 + "short_tag": "v1.4",
209 + "commit": "abc1234",
210 + "short_commit": "abc1234",
211 + },
212 + )
213 + captured_payload = {}
214 + monkeypatch.setattr(
215 + self_update,
216 + "get_selector_tag_options",
217 + lambda branch, *, repo_dir=None, current_version=None: (
218 + [{"value": "latest", "label": "latest (v1.4+2)"}],
219 + [],
220 + "",
221 + ),
222 + )
223 + monkeypatch.setattr(
224 + self_update,
225 + "_write_yaml",
226 + lambda path, payload: captured_payload.update(payload),
227 + )
228 +
229 + payload = self_update.schedule_update(
230 + branch="development",
231 + tag="latest",
232 + backup_usr=True,
233 + backup_path="",
234 + backup_name="",
235 + backup_conflict_policy="rename",
236 + repo_dir=tmp_path,
237 + )
238 +
239 + assert payload["tag"] == "latest"
240 + assert captured_payload["tag"] == "latest"
webui/components/settings/external/self-update-modal.html
+8 -5
@@ -31,7 +31,7 @@
31 <p>
32 Agent Zero saves this request into
33 <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
34 - restarts once, applies the requested branch and release tag before the UI
34 + restarts once, applies the requested branch and version target before the UI
35 starts again, then reloads this page when <code>/api/health</code> is healthy.
36 </p>
37 <p>
@@ -125,9 +125,12 @@
125 <div class="field-label">
126 <div class="field-title">Version</div>
127 <div class="field-description">
128 - Choose a preloaded version tag from the
128 + Choose a preloaded version target from the
129 <a href="https://github.com/agent0ai/agent-zero" target="_blank" rel="noreferrer">Agent Zero repository</a>.
130 - Only tags from the current major release line are listed here. Newer major lines require a Docker image update first.
130 + Only versions from the current major release line are listed here. Newer major lines require a Docker image update first.
131 + </div>
132 + <div class="field-description">
133 + <code>latest</code> resolves to the newest tag on <code>main</code>, and to the current branch head on <code>testing</code> and <code>development</code>.
134 </div>
135 <template x-if="$store.selfUpdateStore.tagsError">
136 <div class="field-description">
@@ -154,8 +157,8 @@
157 :disabled="$store.selfUpdateStore.isBusy || $store.selfUpdateStore.tagsLoading || !$store.selfUpdateStore.hasAvailableTags"
158 >
159 <option value="" x-text="$store.selfUpdateStore.versionSelectPlaceholder"></option>
157 - <template x-for="tag in $store.selfUpdateStore.availableTags" :key="tag">
158 - <option :value="tag" x-text="tag"></option>
160 + <template x-for="tagOption in $store.selfUpdateStore.availableTagOptions" :key="tagOption.value">
161 + <option :value="tagOption.value" x-text="tagOption.label"></option>
162 </template>
163 </select>
164 </div>
webui/components/settings/external/self-update-store.js
+26 -12
@@ -18,7 +18,7 @@ const model = {
18 error: "",
19 tagsError: "",
20 info: null,
21 - availableTags: [],
21 + availableTagOptions: [],
22 higherMajorVersions: [],
23 restartStatusText: "",
24 restartDetailText: "",
@@ -54,7 +54,13 @@ const model = {
54 },
55
56 get hasAvailableTags() {
57 - return this.availableTags.length > 0;
57 + return this.availableTagOptions.length > 0;
58 + },
59 +
60 + get availableTags() {
61 + return this.availableTagOptions
62 + .map((option) => option?.value || "")
63 + .filter(Boolean);
64 },
65
66 get selectedTagExistsOnBranch() {
@@ -84,7 +90,7 @@ const model = {
90 !this.isBusy &&
91 !this.tagsLoading &&
92 this.hasAvailableTags &&
87 - this.isSupportedSelectorTag(this.form.tag) &&
93 + this.isSelectableTag(this.form.tag) &&
94 this.selectedTagExistsOnBranch
95 );
96 },
@@ -101,7 +107,7 @@ const model = {
107 this.saving = false;
108 this.restarting = false;
109 this.tagsLoading = false;
104 - this.availableTags = [];
110 + this.availableTagOptions = [];
111 this.higherMajorVersions = [];
112 this.restartStatusText = "";
113 this.restartDetailText = "";
@@ -251,7 +257,7 @@ const model = {
257 this.info = response;
258 this.applyFormState(response.pending || response.defaults || {});
259 this.applyAvailableTags({
254 - tags: response.available_tags,
260 + options: response.available_tag_options,
261 higherMajorVersions: response.available_higher_major_versions,
262 error: response.available_tags_error,
263 });
@@ -281,8 +287,8 @@ const model = {
287 source?.backup_conflict_policy || "rename";
288 },
289
284 - applyAvailableTags({ tags = [], higherMajorVersions = [], error = "" } = {}) {
285 - this.availableTags = Array.isArray(tags) ? tags : [];
290 + applyAvailableTags({ options = [], higherMajorVersions = [], error = "" } = {}) {
291 + this.availableTagOptions = Array.isArray(options) ? options : [];
292 this.higherMajorVersions = Array.isArray(higherMajorVersions)
293 ? higherMajorVersions
294 : [];
@@ -333,7 +339,7 @@ const model = {
339 return;
340 }
341 this.applyAvailableTags({
336 - tags: response.tags,
342 + options: response.tag_options,
343 higherMajorVersions: response.higher_major_versions,
344 error: response.error,
345 });
@@ -370,6 +376,14 @@ const model = {
376 return true;
377 },
378
379 + isLatestSelectorTag(value) {
380 + return (value || "").trim().toLowerCase() === "latest";
381 + },
382 +
383 + isSelectableTag(value) {
384 + return this.isLatestSelectorTag(value) || this.isSupportedSelectorTag(value);
385 + },
386 +
387 async scheduleUpdate() {
388 if (!this.form.branch?.trim()) {
389 this.error = "Choose a branch.";
@@ -381,12 +395,12 @@ const model = {
395 return;
396 }
397
384 - if (!this.parseSelectorTag(this.form.tag)) {
398 + if (!this.isLatestSelectorTag(this.form.tag) && !this.parseSelectorTag(this.form.tag)) {
399 this.error = "Release tag must use the format vX.Y.";
400 return;
401 }
402
389 - if (!this.isSupportedSelectorTag(this.form.tag)) {
403 + if (!this.isLatestSelectorTag(this.form.tag) && !this.isSupportedSelectorTag(this.form.tag)) {
404 this.error = "Release tag must be v1.0 or newer.";
405 return;
406 }
@@ -418,7 +432,7 @@ const model = {
432 this.info.pending = response.pending;
433 }
434 await notificationStore.frontendWarning(
421 - "Agent Zero is restarting to apply the requested branch and release tag.",
435 + "Agent Zero is restarting to apply the requested branch and version target.",
436 "Self Update",
437 10,
438 "self-update-restart",
@@ -440,7 +454,7 @@ const model = {
454 let observedBackendUnavailable = false;
455 this.setRestartState(
456 "Starting self-update",
443 - "The request was saved. Agent Zero is about to restart and apply the requested branch and tag."
457 + "The request was saved. Agent Zero is about to restart and apply the requested branch and version target."
458 );
459 this.ensureProgressOverlay();
460