main
py 937 lines 29.3 KB
Raw
1 from __future__ import annotations
2
3 import os
4 import re
5 import subprocess
6 import tempfile
7 import time
8 from datetime import datetime
9 from pathlib import Path
10 from typing import Any, Literal, TypedDict
11
12 from helpers import git, yaml
13 from helpers.localization import Localization
14
15
16 OFFICIAL_REPO_AUTHOR = "agent0ai"
17 OFFICIAL_REPO_NAME = "agent-zero"
18 BRANCH_OPTIONS = [
19 {"value": "main", "label": "main"},
20 {"value": "ready", "label": "ready"},
21 {"value": "testing", "label": "testing"},
22 {"value": "development", "label": "development"},
23 ]
24 SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
25 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
26 MIN_SELECTOR_VERSION = (1, 0)
27 REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS = 60.0
28 REMOTE_BRANCH_LIST_CACHE_TTL_SECONDS = 60.0
29
30 UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
31 STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
32 LOG_FILE_PATH = Path("/exe/a0-self-update.log")
33 DURABLE_EXE_DIR = UPDATE_FILE_PATH.parent
34
35 _remote_branch_tag_cache: dict[str, tuple[float, set[str]]] = {}
36 _remote_branch_head_cache: dict[str, tuple[float, dict[str, str]]] = {}
37 _remote_branch_list_cache: tuple[float, list[str]] | None = None
38
39
40 class PendingUpdateConfig(TypedDict):
41 branch: str
42 tag: str
43 source_version: str
44 source_describe: str
45 source_commit: str
46 requested_at: str
47 backup_usr: bool
48 backup_path: str
49 backup_name: str
50 backup_conflict_policy: Literal["rename", "overwrite", "fail"]
51
52
53 class UpdateStatus(TypedDict, total=False):
54 status: str
55 message: str
56 branch: str
57 tag: str
58 source_version: str
59 source_commit: str
60 current_version: str
61 requested_at: str
62 started_at: str
63 finished_at: str
64 backup_zip_path: str
65 log_file_path: str
66 update_file_path: str
67 rollback_applied: bool
68 error: str
69
70
71 class SelectorTagOption(TypedDict):
72 value: str
73 label: str
74
75
76 def _now_iso() -> str:
77 return Localization.get().now_iso()
78
79
80 def get_update_file_path() -> Path:
81 return UPDATE_FILE_PATH
82
83
84 def get_status_file_path() -> Path:
85 return STATUS_FILE_PATH
86
87
88 def get_log_file_path() -> Path:
89 return LOG_FILE_PATH
90
91
92 def get_durable_exe_dir() -> Path:
93 return DURABLE_EXE_DIR
94
95
96 def get_durable_self_update_manager_path() -> Path:
97 return get_durable_exe_dir() / "self_update_manager.py"
98
99
100 def _load_yaml(path: Path) -> dict[str, Any] | None:
101 if not path.exists():
102 return None
103 loaded = yaml.loads(path.read_text(encoding="utf-8"))
104 return loaded if isinstance(loaded, dict) else None
105
106
107 def _write_yaml(path: Path, payload: dict[str, Any]) -> None:
108 path.parent.mkdir(parents=True, exist_ok=True)
109 path.write_text(yaml.dumps(payload), encoding="utf-8")
110
111
112 def load_pending_update() -> PendingUpdateConfig | None:
113 loaded = _load_yaml(get_update_file_path())
114 return loaded if loaded is not None else None
115
116
117 def load_last_status() -> UpdateStatus | None:
118 loaded = _load_yaml(get_status_file_path())
119 return loaded if loaded is not None else None
120
121
122 def get_log_text() -> str:
123 path = get_log_file_path()
124 if not path.exists():
125 return ""
126 return path.read_text(encoding="utf-8")
127
128
129 def get_default_backup_dir(repo_dir: str | Path | None = None) -> Path:
130 return Path("/root/update-backups")
131
132
133 def get_repo_dir(repo_dir: str | Path | None = None) -> Path:
134 if repo_dir is not None:
135 return Path(repo_dir).resolve()
136 return Path(__file__).resolve().parents[1]
137
138
139 def get_repo_self_update_manager_path(
140 repo_dir: str | Path | None = None,
141 ) -> Path:
142 return get_repo_dir(repo_dir) / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
143
144
145 def _get_official_remote_url() -> str:
146 return f"https://github.com/{OFFICIAL_REPO_AUTHOR}/{OFFICIAL_REPO_NAME}.git"
147
148
149 def _run_git_raw(*args: str) -> str:
150 completed = subprocess.run(
151 ["git", *args],
152 check=True,
153 text=True,
154 capture_output=True,
155 env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
156 )
157 return completed.stdout.strip()
158
159
160 def _run_git(repo_dir: str | Path, *args: str) -> str:
161 completed = subprocess.run(
162 ["git", "-C", str(get_repo_dir(repo_dir)), *args],
163 check=True,
164 text=True,
165 capture_output=True,
166 env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
167 )
168 return completed.stdout.strip()
169
170
171 def _normalize_describe_to_version(describe: str) -> str:
172 match = re.fullmatch(r"(.+)-\d+-g[0-9a-f]+", describe)
173 if match:
174 return match.group(1)
175 return describe
176
177
178 def _split_describe_version(describe: str) -> tuple[str, int]:
179 normalized = describe.strip()
180 match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized)
181 if not match:
182 return normalized, 0
183 return match.group(1), int(match.group(2))
184
185
186 def _is_latest_selector_tag(tag: str) -> bool:
187 return tag.strip().lower() == "latest"
188
189
190 def _get_tag_release_time_in_repo(
191 repo_dir: str | Path,
192 tag: str,
193 ) -> str:
194 normalized_tag = tag.strip()
195 if not normalized_tag:
196 return ""
197 try:
198 timestamp = _run_git(repo_dir, "log", "-1", "--format=%ct", normalized_tag)
199 if not timestamp:
200 return ""
201 return datetime.fromtimestamp(
202 int(timestamp),
203 tz=Localization.get().get_tzinfo(),
204 ).strftime("%Y-%m-%d %H:%M:%S %Z")
205 except Exception:
206 return ""
207
208
209 def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
210 repository = get_repo_dir(repo_dir)
211 describe = _run_git(repository, "describe", "--tags", "--always")
212 commit = _run_git(repository, "rev-parse", "HEAD")
213 short_tag = _normalize_describe_to_version(describe)
214 try:
215 branch = _run_git(repository, "branch", "--show-current")
216 except Exception:
217 branch = ""
218 return {
219 "branch": branch,
220 "describe": describe,
221 "short_tag": short_tag,
222 "display_version": _format_branch_head_version(branch, describe),
223 "commit": commit,
224 "short_commit": commit[:7],
225 "released_at": _get_tag_release_time_in_repo(repository, short_tag),
226 }
227
228
229 def _sanitize_filename(name: str, default_name: str) -> str:
230 raw = (name or "").strip()
231 if not raw:
232 raw = default_name
233 raw = Path(raw).name
234 raw = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-") or default_name
235 if not raw.lower().endswith(".zip"):
236 raw = f"{raw}.zip"
237 return raw
238
239
240 def _slugify_version(text: str) -> str:
241 cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip()).strip("-")
242 return cleaned or "unknown"
243
244
245 def build_default_backup_name(
246 current_version: str,
247 target_tag: str | None = None,
248 ) -> str:
249 timestamp = Localization.get().now().strftime("%Y%m%d-%H%M%S")
250 return f"usr-{timestamp}.zip"
251
252
253 def _resolve_backup_path(
254 backup_path: str,
255 repo_dir: str | Path | None = None,
256 ) -> Path:
257 raw = (backup_path or "").strip()
258 if not raw:
259 return get_default_backup_dir(repo_dir)
260 path = Path(raw)
261 if not path.is_absolute():
262 path = get_repo_dir(repo_dir) / path
263 return path.resolve()
264
265
266 def _is_excluded_self_update_branch(branch: str) -> bool:
267 normalized = branch.strip().lower()
268 return (
269 not normalized
270 or normalized == "head"
271 or normalized.startswith("pr/")
272 or normalized.startswith("pr-")
273 or normalized.startswith("pull/")
274 )
275
276
277 def _sort_branch_names(branches: list[str]) -> list[str]:
278 unique_branches: list[str] = []
279 seen: set[str] = set()
280 for branch in branches:
281 normalized = branch.strip().lower()
282 if _is_excluded_self_update_branch(normalized) or normalized in seen:
283 continue
284 seen.add(normalized)
285 unique_branches.append(normalized)
286 return sorted(unique_branches, key=lambda branch: (branch != "main", branch))
287
288
289 def _get_remote_branch_names() -> list[str]:
290 global _remote_branch_list_cache
291
292 now = time.monotonic()
293 if (
294 _remote_branch_list_cache
295 and now - _remote_branch_list_cache[0] <= REMOTE_BRANCH_LIST_CACHE_TTL_SECONDS
296 ):
297 return list(_remote_branch_list_cache[1])
298
299 output = _run_git_raw("ls-remote", "--heads", _get_official_remote_url())
300 branches: list[str] = []
301 prefix = "refs/heads/"
302 for line in output.splitlines():
303 parts = line.strip().split()
304 if len(parts) != 2:
305 continue
306 ref_name = parts[1]
307 if not ref_name.startswith(prefix):
308 continue
309 branches.append(ref_name[len(prefix):])
310
311 sorted_branches = _sort_branch_names(branches)
312 _remote_branch_list_cache = (now, sorted_branches)
313 return list(sorted_branches)
314
315
316 def _get_local_origin_branch_names(
317 repo_dir: str | Path | None = None,
318 ) -> list[str]:
319 repository = get_repo_dir(repo_dir)
320 try:
321 output = _run_git(
322 repository,
323 "for-each-ref",
324 "--format=%(refname:short)",
325 "refs/remotes/origin",
326 )
327 except Exception:
328 return []
329
330 branches: list[str] = []
331 prefix = "origin/"
332 for line in output.splitlines():
333 ref_name = line.strip()
334 if not ref_name.startswith(prefix):
335 continue
336 branches.append(ref_name[len(prefix):])
337 return _sort_branch_names(branches)
338
339
340 def get_available_branch_values(
341 repo_dir: str | Path | None = None,
342 ) -> list[str]:
343 try:
344 remote_branches = _get_remote_branch_names()
345 if remote_branches:
346 return remote_branches
347 except Exception:
348 pass
349
350 local_origin_branches = _get_local_origin_branch_names(repo_dir=repo_dir)
351 if local_origin_branches:
352 return local_origin_branches
353
354 return _sort_branch_names([option["value"] for option in BRANCH_OPTIONS])
355
356
357 def get_available_branches(
358 repo_dir: str | Path | None = None,
359 ) -> list[dict[str, str]]:
360 return [
361 {"value": branch, "label": branch}
362 for branch in get_available_branch_values(repo_dir=repo_dir)
363 ]
364
365
366 def durable_self_update_supports_latest(
367 repo_dir: str | Path | None = None,
368 ) -> bool:
369 candidate_paths = [
370 get_durable_self_update_manager_path(),
371 get_repo_self_update_manager_path(repo_dir=repo_dir),
372 ]
373 for path in candidate_paths:
374 if not path.exists():
375 continue
376 try:
377 content = path.read_text(encoding="utf-8")
378 except OSError:
379 continue
380 return (
381 'LATEST_SELECTOR_TAG = "latest"' in content
382 and "def resolve_requested_target(" in content
383 )
384 return False
385
386
387 def _get_branch_reference_names(branch: str) -> list[str]:
388 normalized_branch = branch.strip().lower()
389 if _is_excluded_self_update_branch(normalized_branch):
390 return []
391 return [f"origin/{normalized_branch}", normalized_branch]
392
393
394 def _get_remote_branch_merged_tags(branch: str) -> set[str]:
395 normalized_branch = branch.strip().lower()
396 if _is_excluded_self_update_branch(normalized_branch):
397 return set()
398
399 cached = _remote_branch_tag_cache.get(normalized_branch)
400 now = time.monotonic()
401 if cached and now - cached[0] <= REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS:
402 return set(cached[1])
403
404 with tempfile.TemporaryDirectory(prefix="a0-self-update-tags-") as temp_dir:
405 repository = Path(temp_dir)
406 _run_git(repository, "init", "--bare")
407 _run_git(
408 repository,
409 "fetch",
410 "--quiet",
411 "--prune",
412 "--filter=blob:none",
413 "--tags",
414 _get_official_remote_url(),
415 f"refs/heads/{normalized_branch}:refs/remotes/origin/{normalized_branch}",
416 )
417 output = _run_git(repository, "tag", "--merged", f"refs/remotes/origin/{normalized_branch}")
418 merged_tags = {line.strip() for line in output.splitlines() if line.strip()}
419
420 _remote_branch_tag_cache[normalized_branch] = (now, merged_tags)
421 return set(merged_tags)
422
423
424 def _get_remote_branch_head_info(branch: str) -> dict[str, str]:
425 normalized_branch = branch.strip().lower()
426 if _is_excluded_self_update_branch(normalized_branch):
427 return {"describe": "", "short_tag": "", "commit": "", "released_at": ""}
428
429 cached = _remote_branch_head_cache.get(normalized_branch)
430 now = time.monotonic()
431 if cached and now - cached[0] <= REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS:
432 return dict(cached[1])
433
434 with tempfile.TemporaryDirectory(prefix="a0-self-update-head-") as temp_dir:
435 repository = Path(temp_dir)
436 _run_git(repository, "init", "--bare")
437 _run_git(
438 repository,
439 "fetch",
440 "--quiet",
441 "--prune",
442 "--filter=blob:none",
443 "--tags",
444 _get_official_remote_url(),
445 f"refs/heads/{normalized_branch}:refs/remotes/origin/{normalized_branch}",
446 )
447 remote_ref = f"refs/remotes/origin/{normalized_branch}"
448 describe = _run_git(repository, "describe", "--tags", "--always", remote_ref)
449 commit = _run_git(repository, "rev-parse", remote_ref)
450 short_tag = _normalize_describe_to_version(describe)
451 released_at = _get_tag_release_time_in_repo(repository, short_tag)
452
453 payload = {
454 "describe": describe,
455 "short_tag": short_tag,
456 "commit": commit,
457 "released_at": released_at,
458 }
459 _remote_branch_head_cache[normalized_branch] = (now, payload)
460 return dict(payload)
461
462
463 def _get_local_branch_merged_tags(
464 branch: str,
465 repo_dir: str | Path | None = None,
466 ) -> set[str]:
467 repository = get_repo_dir(repo_dir)
468 for ref in _get_branch_reference_names(branch):
469 try:
470 _run_git(repository, "rev-parse", "--verify", ref)
471 output = _run_git(repository, "tag", "--merged", ref)
472 return {line.strip() for line in output.splitlines() if line.strip()}
473 except Exception:
474 continue
475 return set()
476
477
478 def _get_local_branch_head_info(
479 branch: str,
480 repo_dir: str | Path | None = None,
481 ) -> dict[str, str]:
482 repository = get_repo_dir(repo_dir)
483 for ref in _get_branch_reference_names(branch):
484 try:
485 _run_git(repository, "rev-parse", "--verify", ref)
486 describe = _run_git(repository, "describe", "--tags", "--always", ref)
487 commit = _run_git(repository, "rev-parse", ref)
488 return {
489 "describe": describe,
490 "short_tag": _normalize_describe_to_version(describe),
491 "commit": commit,
492 "released_at": _get_tag_release_time_in_repo(
493 repository,
494 _normalize_describe_to_version(describe),
495 ),
496 }
497 except Exception:
498 continue
499 return {"describe": "", "short_tag": "", "commit": "", "released_at": ""}
500
501
502 def _get_branch_merged_tags(
503 branch: str,
504 repo_dir: str | Path | None = None,
505 ) -> set[str]:
506 try:
507 remote_tags = _get_remote_branch_merged_tags(branch)
508 if remote_tags:
509 return remote_tags
510 except Exception:
511 pass
512 return _get_local_branch_merged_tags(branch, repo_dir=repo_dir)
513
514
515 def _get_branch_head_info(
516 branch: str,
517 repo_dir: str | Path | None = None,
518 ) -> dict[str, str]:
519 try:
520 remote_info = _get_remote_branch_head_info(branch)
521 if remote_info.get("commit"):
522 return remote_info
523 except Exception:
524 pass
525 return _get_local_branch_head_info(branch, repo_dir=repo_dir)
526
527
528 def _parse_selector_version(tag: str) -> tuple[int, int] | None:
529 match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip())
530 if not match:
531 return None
532 return (
533 int(match.group(1)),
534 int(match.group(2)),
535 )
536
537
538 def _is_selector_supported_tag(tag: str) -> bool:
539 parsed = _parse_selector_version(tag)
540 return parsed is not None and parsed >= MIN_SELECTOR_VERSION
541
542
543 def _filter_selector_supported_tags(tags: list[str]) -> list[str]:
544 return [tag for tag in tags if _is_selector_supported_tag(tag)]
545
546
547 def _sort_selector_supported_tags(tags: list[str]) -> list[str]:
548 return sorted(tags, key=lambda tag: _parse_selector_version(tag) or (-1, -1), reverse=True)
549
550
551 def is_valid_selector_tag(tag: str) -> bool:
552 return _parse_selector_version(tag) is not None
553
554
555 def _parse_major_version(tag: str) -> int | None:
556 match = re.fullmatch(r"v(\d+)(?:[.-].*)?", tag.strip())
557 if not match:
558 return None
559 return int(match.group(1))
560
561
562 def _format_latest_selector_label(branch: str, describe: str) -> str:
563 short_tag, commits_since_tag = _split_describe_version(describe)
564 if not short_tag:
565 return "latest"
566 if branch.strip().lower() == "main" or commits_since_tag <= 0:
567 return f"latest ({short_tag})"
568 return f"latest ({short_tag}+{commits_since_tag})"
569
570
571 def _format_latest_release_label(tag: str) -> str:
572 normalized = tag.strip()
573 if not normalized:
574 return "latest"
575 return f"latest ({normalized})"
576
577
578 def _format_branch_head_version(branch: str, describe: str) -> str:
579 short_tag, commits_since_tag = _split_describe_version(describe)
580 if not short_tag:
581 return ""
582 if branch.strip().lower() == "main" or commits_since_tag <= 0:
583 return short_tag
584 return f"{short_tag}+{commits_since_tag}"
585
586
587 def _get_release_tag_info(
588 branch: str,
589 tag: str,
590 *,
591 repo_dir: str | Path | None = None,
592 ) -> dict[str, Any]:
593 repository = get_repo_dir(repo_dir)
594 commit = ""
595 try:
596 commit = _run_git(repository, "rev-parse", f"refs/tags/{tag}^{{commit}}")
597 except Exception:
598 commit = ""
599 return {
600 "branch": branch.strip().lower(),
601 "supported": True,
602 "describe": tag,
603 "short_tag": tag,
604 "display_version": tag,
605 "commit": commit,
606 "short_commit": commit[:7] if commit else "",
607 "released_at": _get_tag_release_time_in_repo(repository, tag),
608 }
609
610
611 def get_current_major_main_latest_info(
612 current_version: str,
613 *,
614 repo_dir: str | Path | None = None,
615 ) -> dict[str, Any]:
616 repository = get_repo_dir(repo_dir)
617 available_branches = set(get_available_branch_values(repo_dir=repository))
618 if "main" not in available_branches:
619 return {
620 "branch": "main",
621 "supported": False,
622 "describe": "",
623 "short_tag": "",
624 "display_version": "",
625 "commit": "",
626 "short_commit": "",
627 "released_at": "",
628 }
629
630 current_major = _parse_major_version(current_version)
631 if current_major is None:
632 return get_current_branch_latest_info("main", repo_dir=repository)
633
634 tags, error = get_available_tags("main", repo_dir=repository)
635 if error:
636 return get_current_branch_latest_info("main", repo_dir=repository)
637
638 latest_same_major_tag = next(
639 (tag for tag in tags if _parse_major_version(tag) == current_major),
640 "",
641 )
642 if not latest_same_major_tag:
643 return {
644 "branch": "main",
645 "supported": True,
646 "describe": "",
647 "short_tag": "",
648 "display_version": "",
649 "commit": "",
650 "short_commit": "",
651 "released_at": "",
652 }
653
654 branch_head_info = _get_branch_head_info("main", repo_dir=repository)
655 head_describe = branch_head_info.get("describe", "")
656 head_short_tag = branch_head_info.get("short_tag", "")
657 _, commits_since_tag = _split_describe_version(head_describe)
658 if head_short_tag == latest_same_major_tag and commits_since_tag <= 0:
659 commit = branch_head_info.get("commit", "")
660 return {
661 "branch": "main",
662 "supported": True,
663 "describe": head_describe,
664 "short_tag": head_short_tag,
665 "display_version": _format_branch_head_version("main", head_describe),
666 "commit": commit,
667 "short_commit": commit[:7] if commit else "",
668 "released_at": branch_head_info.get("released_at", ""),
669 }
670
671 return _get_release_tag_info("main", latest_same_major_tag, repo_dir=repository)
672
673
674 def get_current_branch_latest_info(
675 current_branch: str,
676 *,
677 repo_dir: str | Path | None = None,
678 ) -> dict[str, Any]:
679 repository = get_repo_dir(repo_dir)
680 normalized_branch = current_branch.strip().lower()
681 available_branches = set(get_available_branch_values(repo_dir=repository))
682 if normalized_branch not in available_branches:
683 return {
684 "branch": current_branch.strip(),
685 "supported": False,
686 "describe": "",
687 "short_tag": "",
688 "display_version": "",
689 "commit": "",
690 "short_commit": "",
691 "released_at": "",
692 }
693
694 branch_head_info = _get_branch_head_info(normalized_branch, repo_dir=repository)
695 commit = branch_head_info.get("commit", "")
696 return {
697 "branch": normalized_branch,
698 "supported": True,
699 "describe": branch_head_info.get("describe", ""),
700 "short_tag": branch_head_info.get("short_tag", ""),
701 "display_version": _format_branch_head_version(
702 normalized_branch,
703 branch_head_info.get("describe", ""),
704 ),
705 "commit": commit,
706 "short_commit": commit[:7] if commit else "",
707 "released_at": branch_head_info.get("released_at", ""),
708 }
709
710
711 def get_available_tags(
712 branch: str | None = None,
713 *,
714 repo_dir: str | Path | None = None,
715 query: str = "",
716 ) -> tuple[list[str], str]:
717 result = git.get_remote_releases(OFFICIAL_REPO_AUTHOR, OFFICIAL_REPO_NAME)
718 if result.error:
719 return [], result.error
720 tags = [release.tag for release in result.releases]
721
722 if branch:
723 merged_tags = _get_branch_merged_tags(branch, repo_dir=repo_dir)
724 if merged_tags:
725 tags = [tag for tag in tags if tag in merged_tags]
726
727 tags = _sort_selector_supported_tags(_filter_selector_supported_tags(tags))
728
729 normalized_query = query.strip().lower()
730 if normalized_query:
731 tags = [tag for tag in tags if normalized_query in tag.lower()]
732
733 return tags, ""
734
735
736 def get_selector_tag_options(
737 branch: str | None = None,
738 *,
739 repo_dir: str | Path | None = None,
740 current_version: str | None = None,
741 ) -> tuple[list[SelectorTagOption], list[int], str]:
742 repository = get_repo_dir(repo_dir)
743 tags, error = get_available_tags(branch, repo_dir=repository)
744 if error:
745 return [], [], error
746 supports_latest = durable_self_update_supports_latest(repo_dir=repository)
747
748 current_major = _parse_major_version(
749 current_version or get_repo_version_info(repository)["short_tag"]
750 )
751 if current_major is None:
752 return [{"value": tag, "label": tag} for tag in tags], [], ""
753
754 branch_head_info = _get_branch_head_info(branch or "", repo_dir=repository)
755 branch_head_tag = branch_head_info.get("short_tag", "")
756 branch_head_major = _parse_major_version(branch_head_tag)
757
758 same_major_tags: list[SelectorTagOption] = []
759 higher_major_versions: set[int] = set()
760 for tag in tags:
761 tag_major = _parse_major_version(tag)
762 if tag_major is None:
763 continue
764 if tag_major == current_major:
765 same_major_tags.append({"value": tag, "label": tag})
766 elif tag_major > current_major:
767 higher_major_versions.add(tag_major)
768
769 if branch_head_major is not None and branch_head_major > current_major:
770 higher_major_versions.add(branch_head_major)
771
772 normalized_branch = (branch or "").strip().lower()
773
774 if supports_latest and normalized_branch == "main" and same_major_tags:
775 same_major_tags.insert(
776 0,
777 {
778 "value": "latest",
779 "label": _format_latest_release_label(same_major_tags[0]["value"]),
780 },
781 )
782 elif (
783 supports_latest
784 and branch_head_major == current_major
785 and _is_selector_supported_tag(branch_head_tag)
786 ):
787 same_major_tags.insert(
788 0,
789 {
790 "value": "latest",
791 "label": _format_latest_selector_label(
792 branch or "",
793 branch_head_info.get("describe", ""),
794 ),
795 },
796 )
797
798 return same_major_tags, sorted(higher_major_versions), ""
799
800
801 def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
802 repository = get_repo_dir(repo_dir)
803 version_info = get_repo_version_info(repository)
804 current_version = version_info["short_tag"]
805 current_branch = version_info.get("branch", "").strip().lower()
806 available_branches = get_available_branches(repo_dir=repository)
807 available_branch_values = [branch["value"] for branch in available_branches]
808 if current_branch in available_branch_values:
809 default_branch = current_branch
810 elif "main" in available_branch_values:
811 default_branch = "main"
812 elif available_branch_values:
813 default_branch = available_branch_values[0]
814 else:
815 default_branch = "main"
816 tag_options, higher_major_versions, tags_error = get_selector_tag_options(
817 default_branch,
818 repo_dir=repository,
819 current_version=current_version,
820 )
821 if "main" in available_branch_values:
822 _, major_upgrade_versions, _ = get_selector_tag_options(
823 "main",
824 repo_dir=repository,
825 current_version=current_version,
826 )
827 else:
828 major_upgrade_versions = []
829 return {
830 "repo_dir": str(repository),
831 "current": version_info,
832 "main_branch_latest": get_current_major_main_latest_info(
833 current_version,
834 repo_dir=repository,
835 ),
836 "current_branch_latest": get_current_branch_latest_info(
837 current_branch,
838 repo_dir=repository,
839 ),
840 "pending": load_pending_update(),
841 "last_status": load_last_status(),
842 "branches": available_branches,
843 "available_tags": [option["value"] for option in tag_options],
844 "available_tag_options": tag_options,
845 "available_tags_error": tags_error,
846 "available_higher_major_versions": higher_major_versions,
847 "major_upgrade_versions": major_upgrade_versions,
848 "paths": {
849 "update_file": str(get_update_file_path()),
850 "status_file": str(get_status_file_path()),
851 "log_file": str(get_log_file_path()),
852 },
853 "defaults": {
854 "branch": default_branch,
855 "tag": current_version if _is_selector_supported_tag(current_version) else "",
856 "backup_usr": True,
857 "backup_path": str(get_default_backup_dir(repository)),
858 "backup_name": build_default_backup_name(current_version, current_version),
859 "backup_conflict_policy": "rename",
860 },
861 }
862
863
864 def schedule_update(
865 *,
866 branch: str,
867 tag: str,
868 backup_usr: bool,
869 backup_path: str,
870 backup_name: str,
871 backup_conflict_policy: str,
872 repo_dir: str | Path | None = None,
873 ) -> PendingUpdateConfig:
874 repository = get_repo_dir(repo_dir)
875 version_info = get_repo_version_info(repository)
876
877 normalized_branch = branch.strip().lower()
878 available_branch_values = set(get_available_branch_values(repo_dir=repository))
879 if normalized_branch not in available_branch_values:
880 raise ValueError("Branch must be one of the available remote branches.")
881
882 normalized_tag = tag.strip()
883 if not normalized_tag:
884 raise ValueError("A release tag is required.")
885 if _is_latest_selector_tag(normalized_tag):
886 if not durable_self_update_supports_latest(repo_dir=repository):
887 raise ValueError(
888 "This Docker image's durable updater does not support the latest selector. "
889 "Choose a concrete version or update the Docker image."
890 )
891 normalized_tag = "latest"
892 elif not is_valid_selector_tag(normalized_tag):
893 raise ValueError("Release tag must use the format vX.Y.")
894 elif not _is_selector_supported_tag(normalized_tag):
895 raise ValueError("Release tag must be v1.0 or newer.")
896
897 selector_tag_options, _, tag_lookup_error = get_selector_tag_options(
898 normalized_branch,
899 repo_dir=repository,
900 current_version=version_info["short_tag"],
901 )
902 if tag_lookup_error:
903 raise RuntimeError(
904 f"Failed to verify release tag {normalized_tag} on branch {normalized_branch}: {tag_lookup_error}"
905 )
906 if normalized_tag not in {option["value"] for option in selector_tag_options}:
907 raise ValueError(
908 f"Version {normalized_tag} does not exist on branch {normalized_branch}."
909 )
910
911 normalized_policy = backup_conflict_policy.strip().lower()
912 if normalized_policy not in BACKUP_CONFLICT_POLICIES:
913 raise ValueError(
914 "Backup conflict policy must be one of: rename, overwrite, fail."
915 )
916
917 resolved_backup_path = _resolve_backup_path(backup_path, repository)
918 resolved_backup_name = _sanitize_filename(
919 backup_name,
920 build_default_backup_name(version_info["short_tag"], normalized_tag),
921 )
922
923 payload: PendingUpdateConfig = {
924 "branch": normalized_branch, # type: ignore[assignment]
925 "tag": normalized_tag,
926 "source_version": version_info["short_tag"],
927 "source_describe": version_info["describe"],
928 "source_commit": version_info["commit"],
929 "requested_at": _now_iso(),
930 "backup_usr": bool(backup_usr),
931 "backup_path": str(resolved_backup_path),
932 "backup_name": resolved_backup_name,
933 "backup_conflict_policy": normalized_policy, # type: ignore[assignment]
934 }
935
936 _write_yaml(get_update_file_path(), payload)
937 return payload