From 733455770682978492ea588a4c55b596f5c5f1a6 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 12 Aug 2026 23:30:08 +0100 Subject: [PATCH] feat(updater): detect newer release lines, not just newer builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The digest compare only ever asks about the tag already pinned, so it answers "has my tag been rebuilt?" and can never answer "does a newer version exist?". An app on v0.16 reports up to date forever while 0.17 ships. That is the gap between an app that updates and an app that is current, and it silently affects every pinned app. Adds tag enumeration for VERSIONED tags only (rolling tags already move on their own): list the repo's tags, keep those sharing the current tag's SHAPE, and pick the numerically greatest. Shape matching is the whole safety story — v0.16 -> v#.# so it can never "upgrade" you onto v0.16-alpine, 31-fpm-alpine onto 31-apache, or a date tag onto a semver one. Comparison is component-wise numeric, so 0.10 > 0.9 and 1.0 > 0.99 (a string sort gets both wrong), with 10# forcing base ten so an upstream "08" cannot be read as octal. 15 unit tests cover it. Docker Hub only, deliberately: all three pinned apps live there, it needs no auth, and the generic OCI tags/list wants a per-registry token dance. Other registries stay quiet rather than guess. Throttled inside the existing registry window and cached between windows so it cannot flicker. Surfaced as INFORMATION, never an action: no button applies it, because a version move can carry a data migration. `update_available` and the "up to date" badge keep their exact meaning; the new state sits beside them and points at the Version field. Against the live registry: stalwart v0.16 is current, nextcloud is on 31-fpm-alpine with 34-fpm-alpine out, mastodon on v4.2.0 with v4.6.5 out. Co-Authored-By: Claude Opus 5 --- .../apps/overview/js/overview-manager.js | 9 +- .../components/updater/js/updater-page.js | 10 +- .../generators/updater/webui_updater_scan.sh | 91 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) 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