From d1a5d3b8328099128b23a10434876e5a99f8caf1 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Tue, 16 Jun 2026 10:05:37 +0200 Subject: [PATCH] updates: allow confirmed unversioned repo updates --- docs/updates.md | 6 +++++ src/services/update-index.js | 39 ++++++++++++++++++++++++++++----- src/web/views/admin-updates.ejs | 17 ++++++++++---- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index b57340c..fbaa576 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -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: diff --git a/src/services/update-index.js b/src/services/update-index.js index 48fb532..d46e4e9 100644 --- a/src/services/update-index.js +++ b/src/services/update-index.js @@ -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 }; diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index bff7277..eaaa1cb 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -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}`; + }; %>
@@ -87,6 +94,7 @@

<%= core.version_description %>

<% if (core.warnings?.length) { %>
Warnings
    <% core.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.unversioned_update) { %>
Manual confirmation required

This core update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> <% if (core.dangers?.length) { %>
Dangers
    <% core.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.requirements?.length) { %>
Requirements
    <% core.requirements.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (core.migration_notes) { %>
Migration notes

<%= core.migration_notes %>

<% } %> @@ -107,9 +115,9 @@ -
+ " data-confirm-text="<%= applyConfirmText(core, "Core update") %>" data-confirm-label="<%= applyLabel(core) %>"> - +
<% if (core.snapshot.available) { %>
@@ -167,6 +175,7 @@

<%= plugin.version_description %>

<% if (plugin.warnings?.length) { %>
Warnings
    <% plugin.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.unversioned_update) { %>
Manual confirmation required

This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.

<% } %> <% if (plugin.dangers?.length) { %>
Dangers
    <% plugin.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> <% if (plugin.migration_notes) { %>
Migration notes

<%= plugin.migration_notes %>

<% } %>

Changelog to target

@@ -184,9 +193,9 @@
-
+ " data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>"> - +
<% if (plugin.snapshot.available) { %>