The unmaintained warning runs on one field — when upstream last rebuilt the image — and off-Hub apps had no value for it. Hub answers in a single call; the OCI API does not expose it at all, so an app on ghcr.io, quay.io or lscr.io simply could not be assessed for staleness, which is the one signal a user cannot work out for themselves. It is in the image, just further down: manifest -> (if a multi-arch index) a platform manifest -> config blob, whose "created" is the build time. Three requests instead of Hub's one, once per registry window, and only for the apps Hub cannot answer for — which is why Hub keeps its cheap path rather than being routed through this. Index and single-arch manifests are distinguished explicitly rather than by position: in an index the first digest is a CHILD manifest, in an image manifest it is the config itself, so reading "the first digest" would silently fetch the wrong blob for one of the two shapes. Live: stoat 2026-08-08, bookstack 2026-08-17, speedtest 2026-08-16, invidious 2026-08-05 — all previously null. Hub unchanged, navidrome still answered by the single-call path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
15 KiB
Bash
309 lines
15 KiB
Bash
#!/bin/bash
|
|
|
|
# Version ladder — the rung list for a stepped upgrade.
|
|
# ---------------------------------------------------------------------------
|
|
# Some apps refuse to skip a version. Nextcloud says so outright ("Updates
|
|
# between multiple major versions and downgrades are unsupported") and simply
|
|
# will not start; databases behave the same way about their data directory.
|
|
# For those, going 31 -> 34 is not one update, it is three, each with its own
|
|
# migration that must finish before the next begins.
|
|
#
|
|
# This file answers only one question: WHICH VERSIONS, IN WHICH ORDER. It does
|
|
# no I/O beyond listing tags and never touches an app — so it is exhaustively
|
|
# testable, which matters because every later safety guarantee is built on it
|
|
# being right. Applying the rungs (snapshot, pull, verify, abort) is the
|
|
# engine's job, not this one's.
|
|
#
|
|
# Rules it enforces:
|
|
# * same SHAPE only 31-fpm-alpine never ladders onto 31-apache
|
|
# * strictly ascending never a downgrade, never a repeat
|
|
# * no gaps every published rung between here and there
|
|
# * stops at the target or at the newest rung if no target is given
|
|
|
|
# Sort key for a tag: each numeric run zero-padded to 6 digits, so a plain
|
|
# lexical sort orders correctly. Without this, "9" sorts after "10" and the
|
|
# ladder would be built in the wrong order — the one bug in here that could
|
|
# actually drive an app backwards through a migration.
|
|
updaterTagSortKey() {
|
|
printf '%s' "$1" | grep -oE '[0-9]+' | awk '{ printf "%06d.", $0 }'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic OCI registry access, for everything that is not Docker Hub.
|
|
#
|
|
# Hub keeps its own path below: hub.docker.com/v2/repositories is a SEPARATE
|
|
# rate-limit pool, whereas registry-1.docker.io manifest reads count against the
|
|
# anonymous PULL limit (100/hour). A ladder probes a tag per rung, so routing
|
|
# Hub through the OCI API would spend the budget the updater needs for pulls.
|
|
#
|
|
# Everything else speaks the standard Distribution API and needs only the token
|
|
# dance: ask, read the WWW-Authenticate challenge, fetch a token from the realm
|
|
# it names, retry. That is all "we cannot probe ghcr.io" ever amounted to —
|
|
# ghcr.io, lscr.io (which points its realm at ghcr.io) and quay.io all answer
|
|
# anonymously for public images. Until this existed those apps were reported as
|
|
# up to date having never been looked at, which is the wrong kind of quiet.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Split an image ref into registry + repository. Sets _oci_reg / _oci_repo.
|
|
# A first segment containing a dot or a colon, or "localhost", is a host;
|
|
# anything else is Hub, where a bare name means the library/ namespace.
|
|
_updaterOciSplit() {
|
|
local ref="${1%%:*}"
|
|
ref="${ref#docker.io/}"; ref="${ref#index.docker.io/}"
|
|
case "$ref" in
|
|
localhost/*|*.*/*|*:*/*) _oci_reg="${ref%%/*}"; _oci_repo="${ref#*/}" ;;
|
|
*/*) _oci_reg="registry-1.docker.io"; _oci_repo="$ref" ;;
|
|
*) _oci_reg="registry-1.docker.io"; _oci_repo="library/$ref" ;;
|
|
esac
|
|
}
|
|
|
|
# Bearer token for <registry> <repo>, or nothing when the registry does not
|
|
# challenge (quay.io serves public repos unauthenticated).
|
|
_updaterOciToken() {
|
|
local reg="$1" repo="$2" ch realm service scope
|
|
ch="$(curl -sS -o /dev/null -D - --connect-timeout 5 --max-time 12 \
|
|
"https://${reg}/v2/${repo}/tags/list?n=1" 2>/dev/null \
|
|
| tr -d '\r' | grep -i '^www-authenticate:' | head -1)"
|
|
[ -n "$ch" ] || return 0
|
|
realm="$(printf '%s' "$ch" | grep -o 'realm="[^"]*"' | head -1 | cut -d'"' -f2)"
|
|
service="$(printf '%s' "$ch" | grep -o 'service="[^"]*"' | head -1 | cut -d'"' -f2)"
|
|
scope="$(printf '%s' "$ch" | grep -o 'scope="[^"]*"' | head -1 | cut -d'"' -f2)"
|
|
[ -n "$realm" ] || return 0
|
|
[ -n "$scope" ] || scope="repository:${repo}:pull"
|
|
curl -fsS --connect-timeout 5 --max-time 12 \
|
|
"${realm}?service=${service}&scope=${scope}" 2>/dev/null \
|
|
| grep -oE '"(access_)?token"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
|
|
| sed -E 's/.*"([^"]+)"$/\1/'
|
|
}
|
|
|
|
# Does <tag> exist in <image-ref>? A manifest HEAD, so nothing is downloaded.
|
|
updaterOciTagExists() {
|
|
local ref="$1" tag="$2"
|
|
[ -n "$tag" ] || return 1
|
|
command -v curl >/dev/null 2>&1 || return 1
|
|
_updaterOciSplit "$ref"
|
|
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
|
|
local code
|
|
code="$(curl -sS -o /dev/null -w '%{http_code}' -I --connect-timeout 5 --max-time 12 \
|
|
${tok:+-H "Authorization: Bearer $tok"} \
|
|
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
|
|
"https://${_oci_reg}/v2/${_oci_repo}/manifests/${tag}" 2>/dev/null)"
|
|
[ "$code" = "200" ]
|
|
}
|
|
|
|
# Tags for <image-ref>, one per line. NOTE the ordering differs from Hub's:
|
|
# /v2/.../tags/list is lexical, not newest-first, and large repos cap the page —
|
|
# so a listing can legitimately miss the newest release. That is survivable
|
|
# because newer-version discovery falls back to probing, which has no window.
|
|
updaterOciTagList() {
|
|
command -v curl >/dev/null 2>&1 || return 0
|
|
_updaterOciSplit "$1"
|
|
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
|
|
curl -fsSL --connect-timeout 5 --max-time 20 \
|
|
${tok:+-H "Authorization: Bearer $tok"} \
|
|
"https://${_oci_reg}/v2/${_oci_repo}/tags/list?n=1000" 2>/dev/null \
|
|
| sed -E 's/.*"tags"[[:space:]]*:[[:space:]]*\[//; s/\].*//' \
|
|
| tr ',' '\n' | grep -oE '"[^"]*"' | tr -d '"' | grep -v '^$'
|
|
}
|
|
|
|
# When was the image behind <image-ref>:<tag> built? ISO8601, empty if unknown.
|
|
#
|
|
# Hub answers this in one call (updaterTagLastUpdated); the OCI API does not
|
|
# expose it at all, so it has to be walked out of the image itself:
|
|
# manifest -> (if a multi-arch index) a platform manifest -> config blob
|
|
# and the config blob's "created" is the build time. Three requests, once per
|
|
# registry window, and only for the apps Hub cannot answer for.
|
|
#
|
|
# Worth the walk because this single field is what the unmaintained warning
|
|
# runs on. Without it an off-Hub app cannot be assessed at all, and "upstream
|
|
# stopped rebuilding this a year ago" is exactly the thing a user cannot work
|
|
# out for themselves.
|
|
updaterOciCreated() {
|
|
command -v curl >/dev/null 2>&1 || return 0
|
|
local ref="${1%%@*}" tag="$2"
|
|
[ -n "$tag" ] || tag="${ref##*:}"
|
|
_updaterOciSplit "$ref"
|
|
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
|
|
local -a acc=(
|
|
-H 'Accept: application/vnd.oci.image.index.v1+json'
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json'
|
|
-H 'Accept: application/vnd.oci.image.manifest.v1+json'
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json'
|
|
)
|
|
local base="https://${_oci_reg}/v2/${_oci_repo}"
|
|
local man
|
|
man="$(curl -fsS --connect-timeout 5 --max-time 15 ${tok:+-H "Authorization: Bearer $tok"} \
|
|
"${acc[@]}" "${base}/manifests/${tag}" 2>/dev/null | tr -d ' \n')"
|
|
[ -n "$man" ] || return 0
|
|
|
|
# A multi-arch index lists child manifests; descend into the first. A single
|
|
# manifest has no "manifests" key and already carries the config reference —
|
|
# the distinction matters because in an index the first digest is a CHILD,
|
|
# while in an image manifest it is the config itself.
|
|
if printf '%s' "$man" | grep -q '"manifests":\['; then
|
|
local child
|
|
child="$(printf '%s' "$man" | grep -oE '"digest":"sha256:[0-9a-f]{64}"' | head -1 | grep -oE 'sha256:[0-9a-f]{64}')"
|
|
[ -n "$child" ] || return 0
|
|
man="$(curl -fsS --connect-timeout 5 --max-time 15 ${tok:+-H "Authorization: Bearer $tok"} \
|
|
"${acc[@]}" "${base}/manifests/${child}" 2>/dev/null | tr -d ' \n')"
|
|
[ -n "$man" ] || return 0
|
|
fi
|
|
|
|
local cfg
|
|
cfg="$(printf '%s' "$man" | sed -E 's/.*"config":\{//; s/\}.*//' | grep -oE 'sha256:[0-9a-f]{64}' | head -1)"
|
|
[ -n "$cfg" ] || return 0
|
|
curl -fsSL --connect-timeout 5 --max-time 20 ${tok:+-H "Authorization: Bearer $tok"} \
|
|
"${base}/blobs/${cfg}" 2>/dev/null \
|
|
| grep -oE '"created":"[^"]*"' | head -1 | cut -d'"' -f4
|
|
}
|
|
|
|
# Does this exact tag exist? One cheap lookup, and the ONLY reliable way to ask.
|
|
# Listing cannot answer it: Docker Hub pages at 100 and orders by recency, so an
|
|
# older intermediate rung falls off the end — mastodon's v4.3 exists but is
|
|
# absent from the newest-100, and a ladder built from that listing skipped it.
|
|
# Skipping a rung is the exact failure this whole file exists to prevent, so the
|
|
# ladder is built by PROBING each candidate, never by enumerating.
|
|
updaterTagExists() {
|
|
local repo="${1%%:*}" tag="$2"
|
|
repo="${repo#docker.io/}"; repo="${repo#index.docker.io/}" # docker.io/ IS Hub
|
|
# Non-Hub: the standard OCI API rather than a shrug. This is what makes
|
|
# ghcr.io / lscr.io / quay.io apps laddered and version-checked at all.
|
|
case "$repo" in *.*/*|localhost/*) updaterOciTagExists "$1" "$tag"; return $? ;; esac
|
|
case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac
|
|
command -v curl >/dev/null 2>&1 || return 1
|
|
local code
|
|
code="$(curl -fsS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 12 \
|
|
"https://hub.docker.com/v2/repositories/${repo}/tags/${tag}" 2>/dev/null)"
|
|
[ "$code" = "200" ]
|
|
}
|
|
|
|
# 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 digits change), which is what lets
|
|
# callers 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 immediately-next PUBLISHED version above $1 in repo $2, or "" if there is
|
|
# none. THE step primitive: everything else here is built on it being right.
|
|
#
|
|
# It exists because bumping only the last component cannot cross a component
|
|
# boundary. v1.158.0 -> v1.158.1 -> v1.158.2 … never arrives at v1.159.0, so a
|
|
# ladder built that way gave up on the single most common versioning scheme
|
|
# there is, and Synapse — which publishes v1.159.0 and no v1.158.1 at all —
|
|
# could not be climbed one rung.
|
|
#
|
|
# So consider a bump of EVERY component (major, minor, patch), keep only the
|
|
# candidates that actually exist upstream, and take the SMALLEST of those. That
|
|
# is the next release by definition, whether it lands in the patch position or
|
|
# crosses into a new major. Candidates that change the tag's shape are dropped,
|
|
# so 31-fpm-alpine never becomes 31-apache. Costs one lookup per component.
|
|
updaterNextRung() {
|
|
local cur="$1" repo="$2"
|
|
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="" i cand
|
|
for ((i=0; i<ncomp; i++)); do
|
|
cand="$(updaterTagBumpAt "$cur" "$i")"
|
|
[ "$(updaterTagShape "$cand")" = "$shape" ] || continue
|
|
updaterTagExists "$repo" "$cand" || continue
|
|
if [ -z "$best" ] || updaterTagGreater "$best" "$cand"; then best="$cand"; fi
|
|
done
|
|
printf '%s' "$best"
|
|
}
|
|
|
|
# Bump the LAST numeric component of a tag by one: v4.2 -> v4.3, 31-fpm-alpine
|
|
# -> 32-fpm-alpine, v0.16 -> v0.17.
|
|
updaterTagIncrement() {
|
|
local tag="$1"
|
|
# Greedy leading group takes everything up to the LAST digit run, so the
|
|
# split is prefix / number / suffix: "31-fpm-alpine" -> ""/"31"/"-fpm-alpine",
|
|
# "v4.2" -> "v4."/"2"/"". 10# keeps "08" decimal rather than octal.
|
|
if [[ "$tag" =~ ^(.*[^0-9])?([0-9]+)([^0-9]*)$ ]]; then
|
|
printf '%s%d%s' "${BASH_REMATCH[1]}" "$((10#${BASH_REMATCH[2]} + 1))" "${BASH_REMATCH[3]}"
|
|
else
|
|
printf '%s' "$tag"
|
|
fi
|
|
}
|
|
|
|
# updaterVersionLadder <current-tag> <repo> [target-tag]
|
|
# Prints the rungs to climb, one per line, ascending, EXCLUDING the current
|
|
# version and INCLUDING the target.
|
|
#
|
|
# Built by probing consecutive increments, so a rung missing from any listing
|
|
# can never be missed. A version upstream genuinely skipped (no v4.3 at all) is
|
|
# stepped over, but ONLY because the probe said it does not exist.
|
|
#
|
|
# FAILS LOUDLY (returns 1, prints nothing) if it cannot construct a continuous
|
|
# path to the target — e.g. the target is across a boundary simple incrementing
|
|
# cannot reach. Refusing to guess is the point: a wrong ladder means a skipped
|
|
# migration, and "I cannot compute this safely, do it by hand" is the only
|
|
# honest answer in that case.
|
|
updaterVersionLadder() {
|
|
local cur="$1" repo="$2" target="${3:-}"
|
|
[ -n "$cur" ] && [ -n "$repo" ] || return 0
|
|
local shape; shape="$(updaterTagShape "$cur")"
|
|
|
|
# A rolling tag has no ladder — it moves on its own. Guarded here as well as
|
|
# at the call site: this function must never be why an app moves.
|
|
[ "$(updaterClassifyTag "$cur")" = "rolling" ] && return 0
|
|
|
|
# Discover the target (newest same-shape tag) when not told one. Listing is
|
|
# fine for THIS — being one rung short is harmless, whereas a gap is not.
|
|
[ -n "$target" ] || target="$(updaterNewerVersionTag "$cur" "$repo")"
|
|
[ -n "$target" ] || return 0 # already current
|
|
[ "$(updaterTagShape "$target")" = "$shape" ] || return 0
|
|
updaterTagGreater "$target" "$cur" || return 0 # never downgrade
|
|
|
|
local -a rungs=()
|
|
local probe="$cur" i next
|
|
for ((i=0; i<64; i++)); do # bounded: no runaway
|
|
# Step to the next version that EXISTS, rather than incrementing blindly
|
|
# and testing. Same guarantee as before — every rung is probed, so a
|
|
# release missing from any listing can still never be skipped — but it
|
|
# can now cross a component boundary, which blind incrementing could
|
|
# not: v1.158.0 -> v1.159.0 was unreachable and the whole ladder failed
|
|
# closed on it.
|
|
next="$(updaterNextRung "$probe" "$repo")"
|
|
[ -n "$next" ] || break # nothing further published
|
|
updaterTagGreater "$next" "$target" && break # overshot
|
|
rungs+=("$next")
|
|
probe="$next"
|
|
[ "$probe" = "$target" ] && break
|
|
done
|
|
|
|
# The last rung MUST be the target. Anything else means the path is
|
|
# incomplete and applying it would land the app somewhere unintended.
|
|
if (( ${#rungs[@]} == 0 )) || [ "${rungs[-1]}" != "$target" ]; then
|
|
return 1
|
|
fi
|
|
printf '%s\n' "${rungs[@]}"
|
|
}
|
|
|
|
# Human summary of a ladder, for the confirmation the user sees before any of
|
|
# it runs: "31-fpm-alpine → 32-fpm-alpine → 33-fpm-alpine → 34-fpm-alpine (3 steps)".
|
|
updaterLadderSummary() {
|
|
local cur="$1"; shift
|
|
local -a rungs=("$@")
|
|
(( ${#rungs[@]} == 0 )) && { printf 'already current (%s)' "$cur"; return 0; }
|
|
local out="$cur" r
|
|
for r in "${rungs[@]}"; do out+=" → $r"; done
|
|
printf '%s (%d step%s)' "$out" "${#rungs[@]}" "$( (( ${#rungs[@]} == 1 )) || echo s )"
|
|
}
|