From cfad806b2c7981ede92918d285f9c2799ef1643b Mon Sep 17 00:00:00 2001 From: Stig Dreyer Date: Sat, 18 Jul 2026 07:57:54 +1200 Subject: [PATCH 1/3] 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 } From 08215f7baf91319486ee4b16098810ac7b4fc46d Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Tue, 20 Oct 2026 12:13:35 +0300 Subject: [PATCH 2/3] fix: skip images whose tag fetch fails partway A failed GHCR token request or page left fetch_tags returning 0 with a partial, oldest-first tag list, which proposed downgrades. Docker Hub pagination hit the 403 that anonymous requests get past 1000 results and skipped grafana and traefik, so it is back to one page. The current tag now takes part in picking the latest, so no list proposes an older tag. Co-Authored-By: Claude Opus 5.5 --- scripts/check-image-updates.sh | 42 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/scripts/check-image-updates.sh b/scripts/check-image-updates.sh index 94cf501..23c9cd3 100755 --- a/scripts/check-image-updates.sh +++ b/scripts/check-image-updates.sh @@ -64,46 +64,38 @@ print('^' + pat + r'$') " "$1" } -# 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 all tags from Docker Hub or GHCR. Fails if any request fails: the +# caller runs it inside `if`, where errexit is off, and a partial list would +# be taken as complete. fetch_tags() { local image=$1 if [[ $image == ghcr.io/* ]]; then local repo=${image#ghcr.io/} 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 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. + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") || return 1 + # GHCR returns at most 1000 tags per page whatever n asks for, in + # lexicographic order, so the newest versions are on the last page. local path="/v2/${repo}/tags/list?n=10000" local headers + headers=$(mktemp) 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',[])]" + | python3 -c "import sys,json;[print(t) for t in json.load(sys.stdin).get('tags',[])]" \ + || { rm -f "$headers"; return 1; } path=$(tr -d '\r' < "$headers" | grep -i '^link:' \ | sed -nE 's/.*<([^>]*)>; *rel="next".*/\1/p') - rm -f "$headers" done + rm -f "$headers" else local path=$image [[ $image != */* ]] && path="library/$image" - 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 + # One page suffices: last_updated ordering puts the newest tags first, and + # anonymous requests are refused past 1000 results. + 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',[])]" fi } @@ -135,7 +127,9 @@ while IFS='|' read -r file service image current_tag; do continue fi - latest=$(echo "$matching" | sort -V | tail -1) + # The current tag takes part, so a tag list missing the newest versions + # yields no change rather than a downgrade. + latest=$(printf '%s\n%s\n' "$matching" "$current_tag" | sort -V | tail -1) echo " Latest: $latest" if [ "$latest" != "$current_tag" ]; then From 4200918269d9519d7776455cac01cf58c46f523f Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Tue, 20 Oct 2026 12:13:35 +0300 Subject: [PATCH 3/3] test: cover check-image-updates tag fetching A fake curl serves GHCR and Docker Hub responses, so the tests cover Link pagination, a failed page, a failed token request and the no-downgrade rule without network access. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- tests/check-image-updates.test.sh | 79 +++++++++++++++++++++++++++++++ tests/lib/fake-curl | 29 ++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100755 tests/check-image-updates.test.sh create mode 100755 tests/lib/fake-curl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b892ca0..613604c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Shellcheck - run: shellcheck -x scripts/*.sh tests/*.sh tests/lib/*.sh tests/lib/fake-gh + run: shellcheck -x scripts/*.sh tests/*.sh tests/lib/*.sh tests/lib/fake-gh tests/lib/fake-curl - name: Run script tests run: | diff --git a/tests/check-image-updates.test.sh b/tests/check-image-updates.test.sh new file mode 100755 index 0000000..48f4c91 --- /dev/null +++ b/tests/check-image-updates.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Tests for scripts/check-image-updates.sh against a fake registry. +set -euo pipefail + +# shellcheck source=tests/lib/step.sh +source "$(dirname "$0")/lib/step.sh" +SCRIPT="$ROOT/scripts/check-image-updates.sh" + +TAB=$'\t' +GHCR_TOKEN="https://ghcr.io/token?scope=repository:acme/app:pull${TAB}0${TAB}{\"token\":\"t\"}${TAB}" +GHCR_PAGE1="https://ghcr.io/v2/acme/app/tags/list?n=10000" +GHCR_PAGE2="https://ghcr.io/v2/acme/app/tags/list?last=2023.2.0&n=10000" +GHCR_NEXT="; rel=\"next\"" +HUB_PAGE1="https://hub.docker.com/v2/repositories/acme/tool/tags?page_size=100&ordering=last_updated" + +# check_image — runs the script on a compose file +# holding that image; leaves the resulting image line in IMAGE_LINE. +check_image() { + in_temp_dir + printf 'services:\n app:\n image: %s\n' "$1" > docker-compose.yml + shift + CURL_ROUTES=$(mktemp) + printf '%s\n' "$@" > "$CURL_ROUTES" + export CURL_ROUTES + COMPOSE_PATTERN=docker-compose.yml run_step "$SCRIPT" || { + cat "$STDOUT_FILE" + FAILURES=$((FAILURES + 1)) + } + IMAGE_LINE=$(grep 'image:' docker-compose.yml | sed 's/ *image: //') +} + +test_ghcr_follows_link_to_second_page() { + check_image ghcr.io/acme/app:2023.1.0 "$GHCR_TOKEN" \ + "$GHCR_PAGE1${TAB}0${TAB}{\"tags\":[\"2023.1.0\",\"2023.2.0\"]}${TAB}$GHCR_NEXT" \ + "$GHCR_PAGE2${TAB}0${TAB}{\"tags\":[\"2026.1.0\"]}${TAB}" + check "tag from the second GHCR page wins" ghcr.io/acme/app:2026.1.0 "$IMAGE_LINE" +} + +test_ghcr_single_page() { + check_image ghcr.io/acme/app:2023.1.0 "$GHCR_TOKEN" \ + "$GHCR_PAGE1${TAB}0${TAB}{\"tags\":[\"2023.1.0\",\"2023.2.0\"]}${TAB}" + check "single GHCR page without Link" ghcr.io/acme/app:2023.2.0 "$IMAGE_LINE" +} + +test_ghcr_failed_page_skips_image() { + check_image ghcr.io/acme/app:2023.1.0 "$GHCR_TOKEN" \ + "$GHCR_PAGE1${TAB}0${TAB}{\"tags\":[\"2023.1.0\",\"2023.2.0\"]}${TAB}$GHCR_NEXT" \ + "$GHCR_PAGE2${TAB}22${TAB}${TAB}" + check "failed second page leaves the image alone" ghcr.io/acme/app:2023.1.0 "$IMAGE_LINE" + check "failed second page is reported" yes \ + "$(grep -q 'Failed to fetch tags' "$STDOUT_FILE" && echo yes || echo no)" +} + +test_ghcr_failed_token_is_reported() { + check_image ghcr.io/acme/app:2023.1.0 + check "failed token request is reported" yes \ + "$(grep -q 'Failed to fetch tags' "$STDOUT_FILE" && echo yes || echo no)" +} + +test_never_proposes_an_older_tag() { + check_image ghcr.io/acme/app:2026.1.0 "$GHCR_TOKEN" \ + "$GHCR_PAGE1${TAB}0${TAB}{\"tags\":[\"2023.1.0\",\"2023.2.0\"]}${TAB}" + check "older tags only leave the image alone" ghcr.io/acme/app:2026.1.0 "$IMAGE_LINE" +} + +test_docker_hub_reads_newest_page_only() { + check_image acme/tool:1.0.0 \ + "$HUB_PAGE1${TAB}0${TAB}{\"results\":[{\"name\":\"1.2.0\"},{\"name\":\"1.0.0\"}],\"next\":\"$HUB_PAGE1&page=2\"}${TAB}" + check "Docker Hub newest tag from the first page" acme/tool:1.2.0 "$IMAGE_LINE" +} + +FAKEBIN=$(mktemp -d) +ln -s "$ROOT/tests/lib/fake-curl" "$FAKEBIN/curl" +PATH="$FAKEBIN:$PATH" +for t in $(declare -F | awk '{print $3}' | grep '^test_'); do + "$t" +done + +[ "$FAILURES" -eq 0 ] diff --git a/tests/lib/fake-curl b/tests/lib/fake-curl new file mode 100755 index 0000000..c27d995 --- /dev/null +++ b/tests/lib/fake-curl @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Fake curl for tests. Answers URLs listed in $CURL_ROUTES, one per line as +# TAB TAB TAB . +# Writes the response headers to the -D file; an unlisted URL fails like a 404. +headers="" +url="" +while [ $# -gt 0 ]; do + case $1 in + -D) headers=$2; shift 2 ;; + -H) shift 2 ;; + -*) shift ;; + *) url=$1; shift ;; + esac +done + +while IFS=$'\t' read -r route code body link; do + [ "$route" = "$url" ] || continue + if [ -n "$headers" ]; then + { + printf 'HTTP/2 200\r\n' + [ -n "$link" ] && printf 'link: %s\r\n' "$link" + printf '\r\n' + } > "$headers" + fi + [ "$code" = 0 ] || exit "$code" + printf '%s\n' "$body" + exit 0 +done < "$CURL_ROUTES" +exit 22