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