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 {