feat(updater): build dates for off-Hub images, from the config blob

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>
This commit is contained in:
librelad 2026-08-20 03:11:29 +01:00
parent 0a6ea95b08
commit 9c8f0782e1
3 changed files with 69 additions and 1 deletions

View File

@ -108,6 +108,57 @@ updaterOciTagList() {
| 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

View File

@ -1062,6 +1062,7 @@ declare -gA LP_FN_MAP=(
[updaterNewerVersionByProbe]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterNewerVersionTag]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterNextRung]="cli/commands/updater/cli_updater_ladder.sh"
[updaterOciCreated]="cli/commands/updater/cli_updater_ladder.sh"
[_updaterOciSplit]="cli/commands/updater/cli_updater_ladder.sh"
[updaterOciTagExists]="cli/commands/updater/cli_updater_ladder.sh"
[updaterOciTagList]="cli/commands/updater/cli_updater_ladder.sh"
@ -2239,6 +2240,7 @@ declare -gA LP_FN_ROOT=(
[updaterNewerVersionByProbe]="scripts"
[updaterNewerVersionTag]="scripts"
[updaterNextRung]="scripts"
[updaterOciCreated]="scripts"
[_updaterOciSplit]="scripts"
[updaterOciTagExists]="scripts"
[updaterOciTagList]="scripts"
@ -3452,6 +3454,7 @@ updaterNewerVersionByList() { unset -f updaterNewerVersionByList; __lpAutoload "
updaterNewerVersionByProbe() { unset -f updaterNewerVersionByProbe; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterNewerVersionByProbe "$@"; }
updaterNewerVersionTag() { unset -f updaterNewerVersionTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterNewerVersionTag "$@"; }
updaterNextRung() { unset -f updaterNextRung; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterNextRung "$@"; }
updaterOciCreated() { unset -f updaterOciCreated; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterOciCreated "$@"; }
_updaterOciSplit() { unset -f _updaterOciSplit; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; _updaterOciSplit "$@"; }
updaterOciTagExists() { unset -f updaterOciTagExists; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterOciTagExists "$@"; }
updaterOciTagList() { unset -f updaterOciTagList; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterOciTagList "$@"; }

View File

@ -190,7 +190,21 @@ updaterRegistryTags() {
updaterTagLastUpdated() {
local repo="${1%%:*}" tag="${2:-latest}"
repo="${repo#docker.io/}"; repo="${repo#index.docker.io/}"
case "$repo" in *.*/*|localhost/*) return 0 ;; esac # non-Hub: unknown, stay quiet
# Non-Hub: no equivalent endpoint exists, so dig the build time out of the
# image's own config blob instead of returning "unknown". Costs three
# requests against one for Hub, which is why Hub keeps its cheap path — but
# without it an off-Hub app can never be assessed for staleness at all, and
# the unmaintained warning is the one signal a user cannot derive alone.
case "$repo" in
*.*/*|localhost/*)
if ! declare -F updaterOciCreated >/dev/null 2>&1; then
[ -f "$install_scripts_dir/cli/commands/updater/cli_updater_ladder.sh" ] \
&& source "$install_scripts_dir/cli/commands/updater/cli_updater_ladder.sh" 2>/dev/null
fi
declare -F updaterOciCreated >/dev/null 2>&1 && updaterOciCreated "$1" "$tag"
return 0
;;
esac
case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac
command -v curl >/dev/null 2>&1 || return 0
curl -fsS --connect-timeout 5 --max-time 12 \