diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
index 4d479e4..392a526 100644
--- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
+++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js
@@ -599,13 +599,20 @@ class OverviewManager {
: (failed
? `auto failed`
: '');
+ // A newer RELEASE LINE exists (v0.16 → v0.17). Deliberately not an action:
+ // no Update button applies it, because moving between versions can carry a
+ // data migration. It is a nudge to go read release notes and change the
+ // Version field, which is why it renders as a quiet informational chip.
+ const newer = a.newer_version
+ ? `${esc(a.newer_version)} available`
+ : '';
const updBtn = a.update_available
? ``
: '';
return `
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js
index cc09f05..f7837ea 100644
--- a/containers/libreportal/frontend/components/updater/js/updater-page.js
+++ b/containers/libreportal/frontend/components/updater/js/updater-page.js
@@ -550,9 +550,17 @@ class UpdaterPage {
: (this.autoAttemptFailed(a)
? 'Set to automatic, but this build failed to apply and was rolled back — it won\'t be retried. Press Update to try again, or wait for the next build.'
: 'Set to automatic — new builds install on their own, after a recovery snapshot.');
+ // A newer release line, if one has been published. Separate from the
+ // update state above on purpose: "up to date" stays true — you ARE
+ // current on the version you track — while still saying a newer one
+ // exists and what to do about it.
+ const newerLine = a.newer_version
+ ? `
A newer release line is available: ${this.escape(a.newer_version)} (you track ${this.escape(a.channel || '—')}). Automatic updates keep you current within your line; to move, set Version on this app's Advanced config tab after checking the release notes.
`
+ : '';
versionSection = `
Version
${badge} ${cur}${avail ? ` →${avail}` : ''}
-
${policyLine}
`;
+
${policyLine}
+ ${newerLine}
`;
}
const cves = a.cves || [];
const appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app');
diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh
index e52851a..24c1054 100644
--- a/scripts/webui/data/generators/updater/webui_updater_scan.sh
+++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh
@@ -101,6 +101,80 @@ updaterRegistryDigest() {
| tr -d '\r' | grep -oE 'sha256:[0-9a-f]{64}' | head -1
}
+# ---------------------------------------------------------------------------
+# Newer-VERSION discovery (distinct from newer-BUILD detection above).
+#
+# The digest compare answers "has the tag I track been rebuilt?". It can never
+# answer "does a newer version exist?", because it only ever asks about the tag
+# already pinned — so an app on v0.16 reports "up to date" forever while 0.17
+# ships, and nothing says so. That is the difference between an app that
+# updates and an app that is current.
+#
+# Only meaningful for VERSIONED tags. Rolling tags (latest/stable) already move
+# on their own, so there is nothing to discover.
+# ---------------------------------------------------------------------------
+
+# Tag list for a repo. Docker Hub only, deliberately: every pinned app in the
+# catalogue lives there (nextcloud, stalwartlabs, tootsuite), the endpoint needs
+# no auth for public repos, and the generic OCI /v2/tags/list wants a per-
+# registry token dance. Anything else returns nothing and the feature simply
+# stays quiet for that app rather than guessing. 100 newest tags is plenty:
+# they are returned newest-first and we only care about ones ABOVE the current.
+updaterRegistryTags() {
+ local repo="${1%%:*}" # strip any :tag
+ case "$repo" in
+ *.*/*|localhost/*) return 0 ;; # a real registry host — not Hub
+ esac
+ case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac # official images
+ command -v curl >/dev/null 2>&1 || return 0
+ curl -fsSL --connect-timeout 5 --max-time 15 \
+ "https://hub.docker.com/v2/repositories/${repo}/tags?page_size=100&ordering=last_updated" 2>/dev/null \
+ | grep -oE '"name":"[^"]+"' | cut -d'"' -f4
+}
+
+# Split a tag into its SHAPE and its numbers, so only like-for-like is compared.
+# "v0.16" -> shape "v#.#" numbers "0 16"
+# "31-fpm-alpine" -> "#-fpm-alpine" "31"
+# The shape must match exactly, which is what stops v0.16 being "upgraded" to
+# v0.16-alpine, 31-fpm-alpine to 31-apache, or a date tag to a semver one.
+updaterTagShape() {
+ printf '%s' "$1" | sed -E 's/[0-9]+/#/g'
+}
+updaterTagNums() {
+ printf '%s' "$1" | grep -oE '[0-9]+' | tr '\n' ' '
+}
+
+# Is tag $1 numerically greater than tag $2? Component-wise, left to right.
+# Both must already share a shape, so the component counts line up.
+updaterTagGreater() {
+ local a b; a="$(updaterTagNums "$1")"; b="$(updaterTagNums "$2")"
+ local -a A=($a) B=($b)
+ local i n=${#A[@]}; [ ${#B[@]} -lt "$n" ] && n=${#B[@]}
+ for ((i=0; i y )) && return 0
+ (( x < y )) && return 1
+ done
+ return 1 # identical -> not newer
+}
+
+# The newest tag for $2 (repo) that shares $1's (current tag) shape and is
+# numerically greater. Empty when there is nothing newer — the common case.
+updaterNewerVersionTag() {
+ local cur="$1" repo="$2"
+ local shape; shape="$(updaterTagShape "$cur")"
+ local best="" t
+ while IFS= read -r t; do
+ [ -n "$t" ] || continue
+ [ "$(updaterTagShape "$t")" = "$shape" ] || continue
+ updaterTagGreater "$t" "$cur" || continue
+ if [ -z "$best" ] || updaterTagGreater "$t" "$best"; then best="$t"; fi
+ done < <(updaterRegistryTags "$repo")
+ printf '%s' "$best"
+}
+
# Human-readable version for display: OCI label → tag (versioned) → tag·shortdigest.
updaterDisplayVersion() {
local oci="$1" channel="$2" vtype="$3" digest="$4"
@@ -182,6 +256,21 @@ webuiUpdaterScan() {
local update_available=false
[ -n "$dig" ] && [ -n "$avail_dig" ] && [ "$dig" != "$avail_dig" ] && update_available=true
+ # Newer VERSION (a different tag), not just a newer build of this tag.
+ # Versioned tags only, and only inside the registry window — it is one
+ # more network call. Reuses the previous answer between windows so the
+ # field does not flicker off on a throttled scan.
+ local newer_ver=""
+ if [ "$vtype" = "versioned" ]; then
+ if [ "$do_registry" = "1" ]; then
+ newer_ver="$(updaterNewerVersionTag "$channel" "$(updaterRepoTag "$anchor")")"
+ newer_ver="${newer_ver%%:*}"
+ elif [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
+ newer_ver="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.newer_version)//""' "$prev_json" 2>/dev/null)"
+ [ "$newer_ver" = "null" ] && newer_ver=""
+ fi
+ fi
+
local cur_ver; cur_ver="$(updaterDisplayVersion "$oci" "$channel" "$vtype" "$dig")"
local avail_ver=""
if [ "$update_available" = "true" ]; then
@@ -238,6 +327,7 @@ webuiUpdaterScan() {
--arg available_digest "$avail_dig" --argjson update_available "$update_available" \
--arg last_checked "$now" --argjson services "$svcs" \
--arg update_type "$policy" --arg auto_attempted "$auto_attempted" \
+ --arg newer_version "$newer_ver" \
'{name:$name,displayName:$displayName,type:$type,channel:$channel,
current_image:$current_image,current_version:$current_version,current_digest:$current_digest,
available_image:$available_image,
@@ -245,6 +335,7 @@ webuiUpdaterScan() {
available_digest:$available_digest,update_available:$update_available,
update_type:$update_type,
auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end),
+ newer_version:(if $newer_version=="" then null else $newer_version end),
scanned:true,last_checked:$last_checked,services:$services}' \
>> "$objs" 2>/dev/null
else