Add tag existence validation to self-update system

- Add tag verification in schedule_update before accepting update requests - Check if selected tag exists on target branch via get_available_tags - Add tagExistenceWarning computed property to display branch-specific errors - Add tagExistenceChecked state flag to track validation status - Implement onTagBlur handler to validate tag on input blur - Add frontend validation in scheduleUpdate before API submission - Change tag dropdown clicks from @

frdel committed Mar 24, 2026 at 21:09 UTC 78ea2040a9db2de313eb32e832fa63ede79c4af9
4 files changed +127 -3
helpers/self_update.py
+14
@@ -315,6 +315,20 @@ def schedule_update(
315 if not _is_selector_supported_tag(normalized_tag):
316 raise ValueError("Release tag must be v1.0 or newer.")
317
318 + available_tags, tag_lookup_error = get_available_tags(
319 + normalized_branch,
320 + repo_dir=repository,
321 + query=normalized_tag,
322 + )
323 + if tag_lookup_error:
324 + raise RuntimeError(
325 + f"Failed to verify release tag {normalized_tag} on branch {normalized_branch}: {tag_lookup_error}"
326 + )
327 + if normalized_tag not in available_tags:
328 + raise ValueError(
329 + f"Version {normalized_tag} does not exist on branch {normalized_branch}."
330 + )
331 +
332 normalized_policy = backup_conflict_policy.strip().lower()
333 if normalized_policy not in BACKUP_CONFLICT_POLICIES:
334 raise ValueError(
tests/test_self_update_tag_filter.py
+52
@@ -2,6 +2,8 @@ import sys
2 import types
3 from pathlib import Path
4
5 +import pytest
6 +
7
8 PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 if str(PROJECT_ROOT) not in sys.path:
@@ -49,3 +51,53 @@ def test_self_update_frontend_filters_old_tag_suggestions():
51 assert "response.tags.filter((tag) => this.isSupportedSuggestionTag(tag))" in content
52 assert "Release tag must use the format vX.Y." in content
53 assert "Release tag must be v1.0 or newer." in content
54 + assert "this.info?.defaults?.branch ||" in content
55 + assert "Version ${tag} does not exist on branch" in content
56 + assert "this.selectedTagExistsOnBranch" in content
57 +
58 +
59 +def test_self_update_modal_validates_exact_tag_on_blur():
60 + modal_path = (
61 + PROJECT_ROOT
62 + / "webui"
63 + / "components"
64 + / "settings"
65 + / "external"
66 + / "self-update-modal.html"
67 + )
68 + content = modal_path.read_text(encoding="utf-8")
69 +
70 + assert '@blur="$store.selfUpdateStore.onTagBlur()"' in content
71 + assert '@mousedown.prevent="$store.selfUpdateStore.selectTag(tag)"' in content
72 + assert "$store.selfUpdateStore.tagExistenceWarning" in content
73 +
74 +
75 +def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_path):
76 + monkeypatch.setattr(
77 + self_update,
78 + "get_repo_version_info",
79 + lambda _repo: {
80 + "branch": "development",
81 + "describe": "v1.0",
82 + "short_tag": "v1.0",
83 + "commit": "abc123",
84 + "short_commit": "abc123",
85 + },
86 + )
87 + monkeypatch.setattr(
88 + self_update,
89 + "get_available_tags",
90 + lambda branch, *, repo_dir=None, query="": (["v1.0"], ""),
91 + )
92 + monkeypatch.setattr(self_update, "_write_yaml", lambda path, payload: None)
93 +
94 + with pytest.raises(ValueError, match=r"Version v1\.1 does not exist on branch development\."):
95 + self_update.schedule_update(
96 + branch="development",
97 + tag="v1.1",
98 + backup_usr=True,
99 + backup_path="",
100 + backup_name="",
101 + backup_conflict_policy="rename",
102 + repo_dir=tmp_path,
103 + )
webui/components/settings/external/self-update-modal.html
+7 -2
@@ -142,6 +142,7 @@
142 x-model="$store.selfUpdateStore.form.tag"
143 @input="$store.selfUpdateStore.onTagInput()"
144 @focus="$store.selfUpdateStore.openTagDropdown()"
145 + @blur="$store.selfUpdateStore.onTagBlur()"
146 @keydown.enter.prevent="$store.selfUpdateStore.searchTags()"
147 placeholder="v1.0"
148 :disabled="$store.selfUpdateStore.isBusy"
@@ -168,7 +169,7 @@
169 <template x-for="tag in $store.selfUpdateStore.groupedTagSuggestions.matched" :key="`match-${tag}`">
170 <div
171 class="self-update-tag-item"
171 - @click="$store.selfUpdateStore.selectTag(tag)"
172 + @mousedown.prevent="$store.selfUpdateStore.selectTag(tag)"
173 x-text="tag"
174 ></div>
175 </template>
@@ -181,7 +182,7 @@
182 <template x-for="tag in $store.selfUpdateStore.groupedTagSuggestions.rest" :key="`rest-${tag}`">
183 <div
184 class="self-update-tag-item"
184 - @click="$store.selfUpdateStore.selectTag(tag)"
185 + @mousedown.prevent="$store.selfUpdateStore.selectTag(tag)"
186 x-text="tag"
187 ></div>
188 </template>
@@ -193,6 +194,10 @@
194 <span x-text="$store.selfUpdateStore.versionCompatibilityWarning"></span>
195 </div>
196
197 + <div class="self-update-warning-banner" x-show="$store.selfUpdateStore.tagExistenceWarning">
198 + <span x-text="$store.selfUpdateStore.tagExistenceWarning"></span>
199 + </div>
200 +
201 <div class="field">
202 <div class="field-label">
203 <div class="field-title">Back up <code>/a0/usr</code> first</div>
webui/components/settings/external/self-update-store.js
+54 -1
@@ -18,6 +18,7 @@ const model = {
18 info: null,
19 tagSuggestions: [],
20 tagDropdownOpen: false,
21 + tagExistenceChecked: false,
22 restartStatusText: "",
23 restartDetailText: "",
24 form: {
@@ -66,6 +67,15 @@ const model = {
67 return { matched, rest };
68 },
69
70 + get trimmedTag() {
71 + return (this.form.tag || "").trim();
72 + },
73 +
74 + get selectedTagExistsOnBranch() {
75 + const tag = this.trimmedTag;
76 + return Boolean(tag) && this.tagSuggestions.includes(tag);
77 + },
78 +
79 get versionCompatibilityWarning() {
80 const current = this.parseCompatibilityTag(this.currentVersion);
81 const target = this.parseCompatibilityTag(this.form.tag);
@@ -79,11 +89,31 @@ const model = {
89 return "";
90 },
91
92 + get tagExistenceWarning() {
93 + const tag = this.trimmedTag;
94 + if (
95 + !this.tagExistenceChecked ||
96 + this.tagsLoading ||
97 + this.tagsError ||
98 + !tag ||
99 + !this.parseSelectorTag(tag) ||
100 + !this.isSupportedSelectorTag(tag)
101 + ) {
102 + return "";
103 + }
104 + if (!this.selectedTagExistsOnBranch) {
105 + return `Version ${tag} does not exist on branch ${this.form.branch || "main"}.`;
106 + }
107 + return "";
108 + },
109 +
110 get canScheduleUpdate() {
111 return (
112 this.isSupported &&
113 !this.isBusy &&
114 + !this.tagsLoading &&
115 this.isSupportedSelectorTag(this.form.tag) &&
116 + this.selectedTagExistsOnBranch &&
117 !this.versionCompatibilityWarning
118 );
119 },
@@ -101,6 +131,7 @@ const model = {
131 this.restarting = false;
132 this.tagsLoading = false;
133 this.tagDropdownOpen = false;
134 + this.tagExistenceChecked = false;
135 this.restartStatusText = "";
136 this.restartDetailText = "";
137 this.removeProgressOverlay();
@@ -268,6 +299,7 @@ const model = {
299 }
300 this.info = response;
301 this.applyFormState(response.pending || response.defaults || {});
302 + this.tagExistenceChecked = Boolean((this.form.tag || "").trim());
303 await this.searchTags({ openResults: false });
304 } catch (error) {
305 console.error("Failed to load self-update info:", error);
@@ -278,7 +310,11 @@ const model = {
310 },
311
312 applyFormState(source) {
281 - this.form.branch = source?.branch || "main";
313 + this.form.branch =
314 + this.info?.defaults?.branch ||
315 + this.currentBranch ||
316 + source?.branch ||
317 + "main";
318 this.form.tag =
319 typeof source?.tag === "string" ? source.tag : this.currentVersion;
320 this.form.backup_usr =
@@ -299,13 +335,20 @@ const model = {
335
336 async onBranchChanged() {
337 this.openTagDropdown();
338 + this.tagExistenceChecked = Boolean(this.trimmedTag);
339 await this.searchTags();
340 },
341
342 onTagInput() {
343 + this.tagExistenceChecked = false;
344 this.openTagDropdown();
345 },
346
347 + async onTagBlur() {
348 + this.tagExistenceChecked = Boolean(this.trimmedTag);
349 + await this.searchTags({ openResults: false });
350 + },
351 +
352 async searchTags({ openResults = true } = {}) {
353 const requestId = ++this._tagRequestId;
354 this.tagsLoading = true;
@@ -344,6 +387,7 @@ const model = {
387
388 selectTag(tag) {
389 this.form.tag = tag;
390 + this.tagExistenceChecked = true;
391 this.tagDropdownOpen = false;
392 },
393
@@ -400,6 +444,15 @@ const model = {
444 return;
445 }
446
447 + if (!this.selectedTagExistsOnBranch) {
448 + this.tagExistenceChecked = true;
449 + await this.searchTags({ openResults: false });
450 + if (!this.selectedTagExistsOnBranch) {
451 + this.error = `Version ${this.trimmedTag} does not exist on branch ${this.form.branch || "main"}.`;
452 + return;
453 + }
454 + }
455 +
456 this.saving = true;
457 this.error = "";
458 try {