From cfad806b2c7981ede92918d285f9c2799ef1643b Mon Sep 17 00:00:00 2001 From: Stig Dreyer Date: Sat, 18 Jul 2026 07:57:54 +1200 Subject: [PATCH] fix: follow GHCR pagination when fetching tags, not just the first page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHCR silently caps tags/list responses below whatever `n` is requested (observed: n=10000 request, 1000 tags actually returned, with a Link: rel="next" header pointing at the rest) — and fetch_tags() never followed it, so any image with more tags than that cap silently lost everything past the first page. Tags come back in lexicographic order, so the truncation isn't random: it's a full page of the earliest-sorting tags, then a hard stop. For ghcr.io/home-assistant/home-assistant (years of near-daily CalVer releases, thousands of tags), this meant "latest" resolved to 2023.3.6 while 2026.7.2 existed entirely unseen past the cutoff — an update-PR downgrading a live 2026.7.0 install by three years. Also hardens the Docker Hub path the same way (its JSON response's `next` field was likewise ignored) — same class of bug, not yet observed to bite in practice since that endpoint orders by last_updated rather than lexicographically, but the same silent- truncation risk applies to any image with enough tags. Verified against the real registries: home-assistant/home-assistant now correctly returns 2026.7.2 as latest (4327 tags fetched across pages, vs. 1000 before), and a single-page image (music-assistant/server) is unaffected. --- scripts/check-image-updates.sh | 39 ++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/scripts/check-image-updates.sh b/scripts/check-image-updates.sh index df8e619..216530b 100755 --- a/scripts/check-image-updates.sh +++ b/scripts/check-image-updates.sh @@ -64,7 +64,15 @@ print('^' + pat + r'$') " "$1" } -# Fetch all tags from Docker Hub or GHCR. +# Fetch all tags from Docker Hub or GHCR, following pagination fully — a +# tag-heavy image (e.g. home-assistant/home-assistant, years of near-daily +# CalVer releases) exceeds a single page, and the registry silently caps the +# page size below whatever we ask for rather than erroring. Missing pages +# means missing the actual latest tag: GHCR returns tags in lexicographic +# order, so a truncated first page still returns a full page of +# early-sorting tags and just silently drops everything after, which for +# home-assistant produced a "latest" from ~2023 while 2026.x releases existed +# unseen past the cutoff. fetch_tags() { local image=$1 if [[ $image == ghcr.io/* ]]; then @@ -72,17 +80,30 @@ fetch_tags() { local token token=$(curl -fsSL "https://ghcr.io/token?scope=repository:${repo}:pull" \ | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") - # n=10000: OCI tags/list defaults to ~100 in lex order which misses - # high-numbered versions (e.g., v1.51.0 sorts after v1.9.0) - curl -fsSL -H "Authorization: Bearer $token" \ - "https://ghcr.io/v2/${repo}/tags/list?n=10000" \ - | python3 -c "import sys,json;[print(t) for t in json.load(sys.stdin).get('tags',[])]" + # n=10000 is a request, not a guarantee: GHCR has been observed capping + # actual responses at 1000 regardless. Follow the Link: rel="next" + # header (RFC 8288, relative-path style) until it's absent. + local path="/v2/${repo}/tags/list?n=10000" + local headers + while [ -n "$path" ]; do + headers=$(mktemp) + curl -fsSL -D "$headers" -H "Authorization: Bearer $token" \ + "https://ghcr.io${path}" \ + | python3 -c "import sys,json;[print(t) for t in json.load(sys.stdin).get('tags',[])]" + path=$(tr -d '\r' < "$headers" | grep -i '^link:' \ + | sed -nE 's/.*<([^>]*)>; *rel="next".*/\1/p') + rm -f "$headers" + done else local path=$image [[ $image != */* ]] && path="library/$image" - curl -fsSL \ - "https://hub.docker.com/v2/repositories/${path}/tags?page_size=100&ordering=last_updated" \ - | python3 -c "import sys,json;[print(r['name']) for r in json.load(sys.stdin).get('results',[])]" + local url="https://hub.docker.com/v2/repositories/${path}/tags?page_size=100&ordering=last_updated" + while [ -n "$url" ] && [ "$url" != "null" ]; do + local response + response=$(curl -fsSL "$url") + echo "$response" | python3 -c "import sys,json;[print(r['name']) for r in json.load(sys.stdin).get('results',[])]" + url=$(echo "$response" | python3 -c "import sys,json;print(json.load(sys.stdin).get('next') or '')") + done fi }