updates: allow confirmed unversioned repo updates

This commit is contained in:
Franz Rolfsvaag 2026-06-16 10:05:37 +02:00
parent c611790370
commit d1a5d3b832
3 changed files with 53 additions and 9 deletions

View File

@ -22,6 +22,12 @@ install.
If the latest version is unsafe, the UI shows the safe target as the actionable
version and lists why the absolute latest is not selected.
Updates to or from an unversioned install or target remain available so an admin
can recover directly from the repository. They are treated as manual repo
updates: the action button stays enabled, but the UI shows warnings and the
confirmation modal states that version ordering, changelog range, and rollback
safety cannot be verified from metadata.
## Metadata
The updater looks for these files on the source branch:

View File

@ -4,6 +4,7 @@ const { spawnSync } = require("child_process");
const { getSetting } = require("./settings");
const { scanPluginDirectories, getPlugins } = require("./plugins");
const {
parseSemver,
compareSemver,
findSafeTarget,
collectChangelogRange,
@ -123,6 +124,10 @@ function manifestVersions(manifest, fallbackVersion) {
return normalizeVersions(entries);
}
function manifestRawVersion(manifest, fallbackVersion) {
return String(manifest?.version || fallbackVersion || "").trim();
}
function coreManifest(ref) {
return readJsonGitFile(ref, "update-manifest.json") ||
readJsonGitFile(ref, "lumi.manifest.json") ||
@ -154,10 +159,30 @@ function snapshotAvailability(kind, id = null) {
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) {
const versions = manifestVersions(manifest, manifest?.version);
const rawVersion = manifestRawVersion(manifest);
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
const currentIsVersioned = Boolean(parseSemver(currentVersion));
const targetIsVersioned = Boolean(parseSemver(targetVersion));
const unversionedUpdate = !currentIsVersioned || !targetIsVersioned;
const latest = latestEntry(versions);
const targetResult = findSafeTarget(currentVersion, versions);
const unversionedTarget = {
...(latest || manifest || {}),
version: targetVersion || "unversioned",
rollback_safe: false,
unversioned: true
};
const targetResult = unversionedUpdate
? {
target: unversionedTarget,
latest: latest || unversionedTarget,
blocked: false,
warning: "This update involves an unversioned source or target. It is available as a manual repo update, but version ordering, changelog range, and rollback safety cannot be verified."
}
: findSafeTarget(currentVersion, versions);
const target = targetResult.target;
const range = target ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) : [];
const range = target && !unversionedUpdate
? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions)
: changelog;
const warnings = [];
const dangers = [];
if (!changelog.length) warnings.push("Changelog metadata is missing.");
@ -169,9 +194,11 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
id: id || kind,
name: name || manifest?.name || id || "Lumi core",
current_version: currentVersion,
latest_available_version: latest?.version || currentVersion,
latest_available_version: latest?.version || rawVersion || currentVersion,
safe_target_version: target?.version || null,
update_available: Boolean(target && compareSemver(target.version, currentVersion) > 0),
update_available: Boolean(
target && (unversionedUpdate || compareSemver(target.version, currentVersion) > 0)
),
blocked: Boolean(targetResult.blocked),
blocked_reason: targetResult.reason || null,
source_branch: sourceBranch,
@ -184,10 +211,12 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown",
warnings,
dangers,
unversioned_update: unversionedUpdate,
requires_manual_confirmation: unversionedUpdate,
requirements: target?.requirements || manifest?.requirements || [],
migration_notes: target?.migration_notes || manifest?.migration_notes || "",
rollback_safe: target?.rollback_safe !== false,
major_crossing: target ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false,
major_crossing: target && !unversionedUpdate ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false,
snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id),
raw_target: target || null
};

View File

@ -10,6 +10,13 @@
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current";
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
const applyConfirmText = (item, label) => {
const base = `${label}: create a snapshot, write a recovery marker, apply ${item?.safe_target_version || "the selected repo target"}, and verify before finishing.`;
if (!item?.unversioned_update) return base;
const warnings = (item.warnings || []).join(" ");
return `${base} Warning: this update is to or from an unversioned install/target, so Lumi cannot verify version ordering, changelog range, or rollback safety. ${warnings}`;
};
%>
<section class="card">
@ -87,6 +94,7 @@
</div>
<p><%= core.version_description %></p>
<% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
<% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %>
@ -107,9 +115,9 @@
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check core</button>
</form>
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="Apply core update" data-confirm-text="Create a snapshot, write a recovery marker, apply the safe target, verify, then restart Lumi after a 5 second notice." data-confirm-label="Apply update">
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= core.unversioned_update ? "Apply unversioned core update" : "Apply core update" %>" data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>>Apply safe target</button>
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>><%= applyLabel(core) %></button>
</form>
<% if (core.snapshot.available) { %>
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert core snapshot" data-confirm-text="Revert only the previous core version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert core">
@ -167,6 +175,7 @@
</div>
<p><%= plugin.version_description %></p>
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
<h3>Changelog to target</h3>
@ -184,9 +193,9 @@
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check plugin</button>
</form>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="Apply plugin update" data-confirm-text="Create a plugin snapshot, write a recovery marker, apply the safe target, and verify this plugin." data-confirm-label="Apply plugin">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>>Apply safe target</button>
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= applyLabel(plugin) %></button>
</form>
<% if (plugin.snapshot.available) { %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin">