Simplify version tag format from vX.Y.Z to vX.Y, enforce v1.0 minimum
- Change version tag format from `v{epoch}.{major}.{minor}.{rest}` to `v{major}.{minor}` - Raise minimum selector version from v0.9.9 to v1.0 - Update tag parsing to require exactly two version segments - Add numeric sorting for version tags (e.g., v1.10 > v1.9) - Update major version compatibility check to compare first number only - Improve validation messages: "vX.Y.Z" → "vX.Y", add "v1.0 or newer" requirement - Clear default
frdel committed
Mar 24, 2026 at 20:59 UTC
ded0f480c817ee8089d8f149a39e60aa3274cd18
5 files changed
+72
-44
docs/guides/self-update.md
+7
-5
@@ -37,18 +37,20 @@ The WebUI fetches repository version tags for the selected branch and lets you e
37
38
Agent Zero version tags follow this format:
39
40
-`v{epoch}.{major}.{minor}.{rest}`
40
+`v{major}.{minor}`
41
42
Examples:
43
44
-- `v0.9.9.2`
45
-- `v1.0.3.0`
44
+- `v1.0`
45
+- `v1.1`
46
+
47
+Tags below `v1.0` are ignored by the selector and rejected by the self-update request validator.
48
49
## Major version limitation
50
49
-Self-update is intentionally limited to changes within the same `epoch.major` line.
51
+Self-update is intentionally limited to changes within the same major line.
52
51
-If the requested version changes the `epoch` or `major` part of the version, the UI blocks the update and shows a warning. Those upgrades require downloading a new Docker image because they can include operating system level changes or other breaking changes outside the repository checkout.
53
+If the requested version changes the first version number, the UI blocks the update and shows a warning. Those upgrades require downloading a new Docker image because they can include operating system level changes or other breaking changes outside the repository checkout.
54
55
## Safety notes
56
helpers/self_update.py
+12
-9
@@ -19,7 +19,7 @@ BRANCH_OPTIONS = [
19
]
20
SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
21
BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
22
-MIN_SELECTOR_VERSION = (0, 9, 9)
22
+MIN_SELECTOR_VERSION = (1, 0)
23
24
UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
25
STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
@@ -207,14 +207,13 @@ def _get_branch_merged_tags(
207
return set()
208
209
210
-def _parse_selector_version(tag: str) -> tuple[int, int, int] | None:
211
- match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:\..+)?", tag.strip())
210
+def _parse_selector_version(tag: str) -> tuple[int, int] | None:
211
+ match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip())
212
if not match:
213
return None
214
return (
215
int(match.group(1)),
216
int(match.group(2)),
217
- int(match.group(3)),
217
)
218
219
@@ -227,6 +226,10 @@ def _filter_selector_supported_tags(tags: list[str]) -> list[str]:
226
return [tag for tag in tags if _is_selector_supported_tag(tag)]
227
228
229
+def _sort_selector_supported_tags(tags: list[str]) -> list[str]:
230
+ return sorted(tags, key=lambda tag: _parse_selector_version(tag) or (-1, -1), reverse=True)
231
+
232
+
233
def is_valid_selector_tag(tag: str) -> bool:
234
return _parse_selector_version(tag) is not None
235
@@ -247,7 +250,7 @@ def get_available_tags(
250
if merged_tags:
251
tags = [tag for tag in tags if tag in merged_tags]
252
250
- tags = _filter_selector_supported_tags(tags)
253
+ tags = _sort_selector_supported_tags(_filter_selector_supported_tags(tags))
254
255
normalized_query = query.strip().lower()
256
if normalized_query:
@@ -278,7 +281,7 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
281
},
282
"defaults": {
283
"branch": default_branch,
281
- "tag": current_version,
284
+ "tag": current_version if _is_selector_supported_tag(current_version) else "",
285
"backup_usr": True,
286
"backup_path": str(get_default_backup_dir(repository)),
287
"backup_name": build_default_backup_name(current_version, current_version),
@@ -308,9 +311,9 @@ def schedule_update(
311
if not normalized_tag:
312
raise ValueError("A release tag is required.")
313
if not is_valid_selector_tag(normalized_tag):
311
- raise ValueError(
312
- "Release tag must use the format vX.Y.Z with optional extra segments such as .W or .W-suffix."
313
- )
314
+ raise ValueError("Release tag must use the format vX.Y.")
315
+ if not _is_selector_supported_tag(normalized_tag):
316
+ raise ValueError("Release tag must be v1.0 or newer.")
317
318
normalized_policy = backup_conflict_policy.strip().lower()
319
if normalized_policy not in BACKUP_CONFLICT_POLICIES:
tests/test_self_update_tag_filter.py
+23
-11
@@ -12,15 +12,26 @@ sys.modules["giturlparse"] = types.SimpleNamespace(parse=lambda *args, **kwargs:
12
from helpers import self_update
13
14
15
-def test_self_update_minimum_selector_tag_is_enforced():
16
- assert self_update.is_valid_selector_tag("v0.9.9")
17
- assert self_update._is_selector_supported_tag("v0.9.9.0")
18
- assert self_update._is_selector_supported_tag("v0.10.0.0")
19
- assert self_update.is_valid_selector_tag("v0.9.12.5-pre")
20
- assert not self_update.is_valid_selector_tag("0.9.9")
21
- assert not self_update.is_valid_selector_tag("v0.9")
22
- assert not self_update._is_selector_supported_tag("v0.9.8.9")
23
- assert not self_update._is_selector_supported_tag("v0.7.0.0")
15
+def test_self_update_selector_tags_use_two_segments_and_v1_floor():
16
+ assert self_update.is_valid_selector_tag("v1.0")
17
+ assert self_update.is_valid_selector_tag("v12.34")
18
+ assert self_update.is_valid_selector_tag("v0.9")
19
+ assert self_update._is_selector_supported_tag("v1.0")
20
+ assert self_update._is_selector_supported_tag("v2.3")
21
+ assert not self_update.is_valid_selector_tag("1.0")
22
+ assert not self_update.is_valid_selector_tag("v1")
23
+ assert not self_update.is_valid_selector_tag("v1.0.0")
24
+ assert not self_update.is_valid_selector_tag("v1.0.0.1")
25
+ assert not self_update._is_selector_supported_tag("v0.9")
26
+ assert not self_update._is_selector_supported_tag("v0.99")
27
+
28
+
29
+def test_self_update_selector_tags_are_sorted_numerically():
30
+ assert self_update._sort_selector_supported_tags(["v1.9", "v2.0", "v1.10"]) == [
31
+ "v2.0",
32
+ "v1.10",
33
+ "v1.9",
34
+ ]
35
36
37
def test_self_update_frontend_filters_old_tag_suggestions():
@@ -34,6 +45,7 @@ def test_self_update_frontend_filters_old_tag_suggestions():
45
)
46
content = store_path.read_text(encoding="utf-8")
47
37
- assert "const MIN_SELECTOR_VERSION = [0, 9, 9];" in content
48
+ assert "const MIN_SELECTOR_VERSION = [1, 0];" in content
49
assert "response.tags.filter((tag) => this.isSupportedSuggestionTag(tag))" in content
39
- assert "if (!this.parseSelectorTag(this.form.tag)) {" in content
50
+ assert "Release tag must use the format vX.Y." in content
51
+ assert "Release tag must be v1.0 or newer." in content
webui/components/settings/external/self-update-modal.html
+2
-2
@@ -127,7 +127,7 @@
127
<div class="field-description">
128
Enter a version tag from the
129
<a href="https://github.com/agent0ai/agent-zero" target="_blank" rel="noreferrer">Agent Zero repository</a>.
130
- Start typing to filter, or use the search button to fetch versions from the selected branch. Exact text entry is still allowed, including downgrades.
130
+ Start typing to filter, or use the search button to fetch versions from the selected branch. Only <code>vX.Y</code> tags at <code>v1.0</code> or newer are supported. Exact text entry is still allowed, including downgrades.
131
</div>
132
<template x-if="$store.selfUpdateStore.tagsError">
133
<div class="field-description">
@@ -143,7 +143,7 @@
143
@input="$store.selfUpdateStore.onTagInput()"
144
@focus="$store.selfUpdateStore.openTagDropdown()"
145
@keydown.enter.prevent="$store.selfUpdateStore.searchTags()"
146
- placeholder="v0.9.0"
146
+ placeholder="v1.0"
147
:disabled="$store.selfUpdateStore.isBusy"
148
style="padding-right: 32px;"
149
/>
webui/components/settings/external/self-update-store.js
+28
-17
@@ -6,7 +6,7 @@ const HEALTH_POLL_INTERVAL_MS = 2000;
6
const HEALTH_WAIT_BUFFER_MS = 30000;
7
const SELF_UPDATE_RETURN_URL_KEY = "a0:self-update:return-url";
8
const SELF_UPDATE_OVERLAY_ID = "self-update-progress-overlay";
9
-const MIN_SELECTOR_VERSION = [0, 9, 9];
9
+const MIN_SELECTOR_VERSION = [1, 0];
10
11
const model = {
12
loading: false,
@@ -67,12 +67,12 @@ const model = {
67
},
68
69
get versionCompatibilityWarning() {
70
- const current = this.parseVersionTag(this.currentVersion);
71
- const target = this.parseVersionTag(this.form.tag);
70
+ const current = this.parseCompatibilityTag(this.currentVersion);
71
+ const target = this.parseCompatibilityTag(this.form.tag);
72
if (!current || !target) return "";
73
- if (current.epoch !== target.epoch || current.major !== target.major) {
73
+ if (current.major !== target.major) {
74
return (
75
- "Updating across major versions requires downloading a new Docker image, " +
75
+ "Changing the first version number requires downloading a new Docker image, " +
76
"because those releases can include operating system level changes or other breaking changes."
77
);
78
}
@@ -80,7 +80,12 @@ const model = {
80
},
81
82
get canScheduleUpdate() {
83
- return this.isSupported && !this.isBusy && !this.versionCompatibilityWarning;
83
+ return (
84
+ this.isSupported &&
85
+ !this.isBusy &&
86
+ this.isSupportedSelectorTag(this.form.tag) &&
87
+ !this.versionCompatibilityWarning
88
+ );
89
},
90
91
async init() {
@@ -274,7 +279,8 @@ const model = {
279
280
applyFormState(source) {
281
this.form.branch = source?.branch || "main";
277
- this.form.tag = source?.tag || this.currentVersion;
282
+ this.form.tag =
283
+ typeof source?.tag === "string" ? source.tag : this.currentVersion;
284
this.form.backup_usr =
285
typeof source?.backup_usr === "boolean" ? source.backup_usr : true;
286
this.form.backup_path = source?.backup_path || "";
@@ -342,16 +348,19 @@ const model = {
348
},
349
350
parseSelectorTag(value) {
345
- const match = /^v(\d+)\.(\d+)\.(\d+)(?:\..+)?$/.exec((value || "").trim());
351
+ const match = /^v(\d+)\.(\d+)$/.exec((value || "").trim());
352
if (!match) return null;
353
return [
354
Number.parseInt(match[1], 10),
355
Number.parseInt(match[2], 10),
350
- Number.parseInt(match[3], 10),
356
];
357
},
358
359
isSupportedSuggestionTag(value) {
360
+ return this.isSupportedSelectorTag(value);
361
+ },
362
+
363
+ isSupportedSelectorTag(value) {
364
const parsed = this.parseSelectorTag(value);
365
if (!parsed) return false;
366
for (let i = 0; i < MIN_SELECTOR_VERSION.length; i += 1) {
@@ -361,14 +370,12 @@ const model = {
370
return true;
371
},
372
364
- parseVersionTag(value) {
365
- const match = /^v(\d+)\.(\d+)\.(\d+)(?:\.(.+))?$/.exec((value || "").trim());
373
+ parseCompatibilityTag(value) {
374
+ const match = /^v(\d+)\.(\d+)(?:\..+)?$/.exec((value || "").trim());
375
if (!match) return null;
376
return {
368
- epoch: Number.parseInt(match[1], 10),
369
- major: Number.parseInt(match[2], 10),
370
- minor: Number.parseInt(match[3], 10),
371
- rest: match[4] || "",
377
+ major: Number.parseInt(match[1], 10),
378
+ minor: Number.parseInt(match[2], 10),
379
};
380
},
381
@@ -384,8 +391,12 @@ const model = {
391
}
392
393
if (!this.parseSelectorTag(this.form.tag)) {
387
- this.error =
388
- "Release tag must use the format vX.Y.Z with optional extra segments such as .W or .W-suffix.";
394
+ this.error = "Release tag must use the format vX.Y.";
395
+ return;
396
+ }
397
+
398
+ if (!this.isSupportedSelectorTag(this.form.tag)) {
399
+ this.error = "Release tag must be v1.0 or newer.";
400
return;
401
}
402