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