fix(updater): find newer versions the tag listing cannot see

Two holes that together left a versioned app reporting "up to date"
while a newer release was published.

Newer-version discovery enumerated a repo's newest 100 tags. Projects
that push a tag per commit drown their own releases in that window —
matrixdotorg/synapse's newest 100 hold five version tags, about ten
days of history. Once the release we need is older than the window it
is simply absent, and the app reports current forever. The failure is
silent and lands hardest on the apps furthest behind. Discovery now
falls back to PROBING exact tags, most-significant component first,
which has no window at all. Listing still runs first, so the common
case stays at one call; probing is bounded at 40 lookups. Same
reasoning the version ladder already uses, for the same reason.

Registry lookups were also throttled purely per-run, so an app
installed just after a window carried an empty available_digest until
the next one — up to CFG_UPDATER_REGISTRY_INTERVAL (6h) later. Empty
means update_available=false, which the UI renders as "up to date", so
a new app claimed to be current on no evidence. Seen live: seven apps
installed the evening after a 19:31 window all sat at
update_available=false, one of them two releases behind. Apps with no
prior registry answer are now looked up regardless of the throttle —
once each, and interval 0 still means manual-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-19 00:15:45 +01:00
parent 741edfdeb1
commit f747083115

View File

@ -187,9 +187,9 @@ updaterTagGreater() {
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() {
# The newest same-shape, numerically-greater tag found by ENUMERATING the newest
# 100 tags. One cheap call, and right whenever the release we want is recent.
updaterNewerVersionByList() {
local cur="$1" repo="$2"
local shape; shape="$(updaterTagShape "$cur")"
local best="" t
@ -202,6 +202,82 @@ updaterNewerVersionTag() {
printf '%s' "$best"
}
# Bump the Nth (0-based) numeric component of a tag by one and zero every
# component after it, so a bump means what a version bump means:
# "v1.158.2", 1 -> "v1.159.0" "31-fpm-alpine", 0 -> "32-fpm-alpine"
# Shape is preserved by construction (only the digits change), which is what
# lets the caller compare shapes to reject nonsense candidates.
updaterTagBumpAt() {
printf '%s' "$1" | awk -v idx="$2" '{
out=""; n=0; s=$0
while (match(s, /[0-9]+/)) {
pre = substr(s, 1, RSTART-1)
num = substr(s, RSTART, RLENGTH) + 0
s = substr(s, RSTART+RLENGTH)
if (n == idx) num = num + 1; else if (n > idx) num = 0
out = out pre num
n++
}
print out s
}'
}
# The newest tag reachable from $1 by PROBING for exact tags, most-significant
# component first, climbing each as far as it goes.
#
# This exists because enumeration has a blind spot that gets worse the more
# actively a project publishes. The listing returns the newest 100 tags, and
# repos that push a tag per commit drown their own releases in them —
# matrixdotorg/synapse's newest 100 hold just 5 version tags, roughly ten days
# of history. Once the release we need is older than that window it is simply
# absent, updaterNewerVersionByList returns nothing, and the app reports "up to
# date" forever. The failure is silent and it lands exactly on the apps that are
# furthest behind, which are the ones that most need to be told.
#
# Probing has no window: an exact tag lookup either exists or it does not. This
# is the same reasoning (and the same endpoint) as the version ladder, which
# refuses to build a rung list from a listing for precisely this reason — see
# cli_updater_ladder.sh. Bounded at 40 lookups so a strange tag scheme cannot
# turn one app's scan into a crawl.
updaterNewerVersionByProbe() {
local cur="$1" repo="$2"
declare -F updaterTagExists >/dev/null 2>&1 || return 0 # ladder unavailable
local shape; shape="$(updaterTagShape "$cur")"
local ncomp; ncomp="$(printf '%s' "$cur" | grep -oE '[0-9]+' | wc -l | tr -d ' ')"
[ "${ncomp:-0}" -gt 0 ] 2>/dev/null || return 0
local best="$cur" probes=0 i cand
for ((i=0; i<ncomp; i++)); do
# Climb this component until it runs out, THEN move to a finer one. Going
# most-significant first means v2.0.0 is preferred over v1.159.0, and
# never re-probing a component that already missed keeps the call count
# proportional to how far behind we actually are.
while (( probes < 40 )); do
cand="$(updaterTagBumpAt "$best" "$i")"
[ "$(updaterTagShape "$cand")" = "$shape" ] || break
probes=$((probes + 1))
updaterTagExists "$repo" "$cand" || break
best="$cand"
done
done
[ "$best" != "$cur" ] && printf '%s' "$best"
return 0
}
# 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.
#
# Listing first because it is one call and answers most repos outright; probing
# only when it comes back empty, which is both the "genuinely current" case
# (a few wasted lookups, no answer either way) and the "release fell out of the
# listing window" case that enumeration alone gets silently wrong.
updaterNewerVersionTag() {
local cur="$1" repo="$2"
local best; best="$(updaterNewerVersionByList "$cur" "$repo")"
[ -n "$best" ] && { printf '%s' "$best"; return 0; }
updaterNewerVersionByProbe "$cur" "$repo"
}
# Human-readable version for display: OCI label → tag (versioned) → tag·shortdigest.
updaterDisplayVersion() {
local oci="$1" channel="$2" vtype="$3" digest="$4"
@ -270,9 +346,29 @@ webuiUpdaterScan() {
# local running identity of the anchor image
local li dig oci; li="$(updaterInspectLocal "$anchor")"; dig="${li%%|*}"; oci="${li#*|}"
# Has this app EVER had a registry answer? A just-installed app has no
# previous available_digest to reuse, so between windows it would carry
# an empty one forward — and empty means update_available=false, which
# the UI renders as "up to date". An app installed minutes after a
# window would therefore claim to be current, on no evidence, until the
# next one up to CFG_UPDATER_REGISTRY_INTERVAL (default 6h) later.
# Observed live: seven apps installed the evening after a 19:31 window
# all sat at update_available=false with an empty digest, one of them
# two releases behind. Look those up regardless of the throttle — it is
# the difference between "checked and current" and "never checked", and
# it costs one lookup per app exactly once. Interval 0 still means
# manual-only, so an explicit "never check on its own" is honoured.
local app_registry="$do_registry"
if [ "$app_registry" = "0" ] && [ "$reg_interval" != "0" ] && [ "$have_jq" = "1" ]; then
local _seen=""
[ -f "$prev_json" ] && _seen="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.available_digest)//""' "$prev_json" 2>/dev/null)"
[ "$_seen" = "null" ] && _seen=""
[ -z "$_seen" ] && app_registry=1 # never answered (new app, or first scan)
fi
# available (registry) digest for the anchor's channel
local avail_dig=""
if [ "$do_registry" = "1" ]; then
if [ "$app_registry" = "1" ]; then
avail_dig="$(updaterRegistryDigest "$(updaterRepoTag "$anchor")")"
fi
# reuse the prior value when we didn't (or couldn't) reach the registry
@ -287,7 +383,7 @@ webuiUpdaterScan() {
# rest of the registry work: look it up live inside the window, otherwise
# carry the previous answer forward so the field never blinks off.
local img_updated=""
if [ "$do_registry" = "1" ]; then
if [ "$app_registry" = "1" ]; then
img_updated="$(updaterTagLastUpdated "$(updaterRepoTag "$anchor")" "$channel")"
fi
if [ -z "$img_updated" ] && [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then
@ -301,7 +397,7 @@ webuiUpdaterScan() {
# field does not flicker off on a throttled scan.
local newer_ver=""
if [ "$vtype" = "versioned" ]; then
if [ "$do_registry" = "1" ]; then
if [ "$app_registry" = "1" ]; then
newer_ver="$(updaterNewerVersionTag "$channel" "$(updaterRepoTag "$anchor")")"
newer_ver="${newer_ver%%:*}"
elif [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then