diff --git a/.changeset/patch-fix-cli-proxy-latest-tag-alias.md b/.changeset/patch-fix-cli-proxy-latest-tag-alias.md new file mode 100644 index 00000000000..c09907a737a --- /dev/null +++ b/.changeset/patch-fix-cli-proxy-latest-tag-alias.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix fleet-wide smoke-test outage caused by a Docker image tag mismatch: `download_docker_images.sh` now aliases every pulled image under the mutable `:latest` tag in addition to its version-pinned tag, so `docker compose up -d --pull never` can resolve `ghcr.io/github/gh-aw-firewall/cli-proxy` (and other AWF images) regardless of which tag they are referenced by. diff --git a/actions/setup/sh/download_docker_images.sh b/actions/setup/sh/download_docker_images.sh index 48213a0c522..5d06e692dab 100755 --- a/actions/setup/sh/download_docker_images.sh +++ b/actions/setup/sh/download_docker_images.sh @@ -15,6 +15,13 @@ set +o histexpand # When images include a digest pin (e.g. image:tag@sha256:abc), the script # ensures the tag alias (image:tag) is created after pulling so that tools # referencing images by tag (such as AWF with --skip-pull) can find them. +# +# The script also aliases the image as "image:latest" so that any downstream +# tool that references the image via the mutable ":latest" tag (regardless of +# which versioned tag was actually pulled) can still resolve it locally under +# --pull-never/--skip-pull semantics. This guards against tag mismatches +# between the version-pinned tag written here and a ":latest" reference used +# elsewhere (see gh-aw#50681). set -euo pipefail @@ -30,14 +37,37 @@ docker_pull_with_retry() { if timeout 5m docker pull --quiet "$image" 2>&1; then echo "Successfully pulled $image" - # When pulling with a digest pin (image:tag@sha256:...), Docker may not - # create the tag alias automatically. Ensure the tag exists so that - # downstream tools (e.g. AWF --skip-pull) can find the image by tag. + # When pulling with a digest pin, Docker may not create a digest-free + # alias automatically. Preserve the original base reference and add back + # its implicit ":latest" or explicit tag alias so downstream tools can + # resolve the image locally under --pull-never/--skip-pull semantics. + local tag_ref="$image" + local base_ref="$image" if [[ "$image" == *"@sha256:"* ]]; then - local tag_ref="${image%%@sha256:*}" - if [[ "$tag_ref" == *":"* ]]; then - echo "Tagging digest-pinned image as $tag_ref" - docker tag "$image" "$tag_ref" + base_ref="${image%%@sha256:*}" + tag_ref="$base_ref" + if [[ "$base_ref" == *":"* ]]; then + echo "Tagging digest-pinned image as $base_ref" + docker tag "$image" "$base_ref" + else + local latest_ref="${base_ref}:latest" + echo "Tagging digest-pinned image as $latest_ref" + docker tag "$image" "$latest_ref" + tag_ref="$latest_ref" + fi + fi + + # Only AWF images need a mutable ":latest" alias for local compose + # stacks, and only when the requested reference was version-pinned. This + # avoids races when unrelated repositories are pulled concurrently with + # multiple distinct tags. + if [[ "$base_ref" == ghcr.io/github/gh-aw-* && "$tag_ref" == *":"* ]]; then + local repo_ref="${tag_ref%%:*}" + local tag_part="${tag_ref##*:}" + if [[ "$tag_part" != "latest" ]]; then + local latest_ref="${repo_ref}:latest" + echo "Aliasing $tag_ref as $latest_ref" + docker tag "$tag_ref" "$latest_ref" fi fi diff --git a/actions/setup/sh/download_docker_images_test.sh b/actions/setup/sh/download_docker_images_test.sh index 1416e370b25..f9eb1946877 100755 --- a/actions/setup/sh/download_docker_images_test.sh +++ b/actions/setup/sh/download_docker_images_test.sh @@ -50,10 +50,66 @@ else fi echo "" -# Test 3: Already cached images (should be fast) -echo -e "${YELLOW}Test 3: Already cached images${NC}" +# Test 3: AWF digest-pinned images restore local aliases without retagging unrelated latest refs +echo -e "${YELLOW}Test 3: Digest-pinned AWF alias handling${NC}" +WORKDIR=$(mktemp -d) +cleanup_mock_docker() { + rm -rf "$WORKDIR" +} +cat > "$WORKDIR/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$DOCKER_LOG" +case "$1" in + pull) + exit 0 + ;; + tag) + exit 0 + ;; + *) + echo "unexpected docker command: $*" >&2 + exit 1 + ;; +esac +MOCK +chmod +x "$WORKDIR/docker" +DOCKER_LOG="$WORKDIR/docker.log" +export DOCKER_LOG +NODE_DIGEST="$(printf 'a%.0s' {1..64})" +CLI_PROXY_DIGEST="$(printf 'b%.0s' {1..64})" +if PATH="$WORKDIR:$PATH" bash "$DOWNLOAD_SCRIPT" \ + "ghcr.io/github/gh-aw-node@sha256:${NODE_DIGEST}" \ + "ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:${CLI_PROXY_DIGEST}" \ + alpine:3.17 > /tmp/test3.log 2>&1; then + if grep -q 'tag ghcr.io/github/gh-aw-node@sha256:' "$DOCKER_LOG" \ + && grep -q 'ghcr.io/github/gh-aw-node:latest' "$DOCKER_LOG" \ + && grep -q 'tag ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44@sha256:' "$DOCKER_LOG" \ + && grep -q 'ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.44 ghcr.io/github/gh-aw-firewall/cli-proxy:latest' "$DOCKER_LOG"; then + echo -e "${GREEN}✓ PASS${NC}: AWF digest aliases restored correctly" + else + echo -e "${RED}✗ FAIL${NC}: Missing expected AWF alias commands" + cat "$DOCKER_LOG" + exit 1 + fi + if grep -q 'alpine:3.17 alpine:latest' "$DOCKER_LOG"; then + echo -e "${RED}✗ FAIL${NC}: Unrelated image was incorrectly aliased to latest" + cat "$DOCKER_LOG" + exit 1 + else + echo -e "${GREEN}✓ PASS${NC}: Unrelated repositories were not aliased to latest" + fi +else + echo -e "${RED}✗ FAIL${NC}: Digest alias test failed" + cat /tmp/test3.log + exit 1 +fi +echo "" + +# Test 4: Already cached images (should be fast) +echo -e "${YELLOW}Test 4: Already cached images${NC}" START_TIME=$(date +%s) -if bash "$DOWNLOAD_SCRIPT" alpine:3.19 alpine:3.18 > /tmp/test3.log 2>&1; then +if bash "$DOWNLOAD_SCRIPT" alpine:3.19 alpine:3.18 > /tmp/test4.log 2>&1; then END_TIME=$(date +%s) DURATION=$((END_TIME - START_TIME)) echo -e "${GREEN}✓ PASS${NC}: Cached images download succeeded (${DURATION}s)" @@ -65,20 +121,20 @@ if bash "$DOWNLOAD_SCRIPT" alpine:3.19 alpine:3.18 > /tmp/test3.log 2>&1; then fi else echo -e "${RED}✗ FAIL${NC}: Cached images download failed" - cat /tmp/test3.log + cat /tmp/test4.log exit 1 fi echo "" -# Test 4: Invalid image (should fail gracefully) -echo -e "${YELLOW}Test 4: Invalid image (expected to fail)${NC}" -if bash "$DOWNLOAD_SCRIPT" "nonexistent-registry.invalid/fake-image:v999" > /tmp/test4.log 2>&1; then +# Test 5: Invalid image (should fail gracefully) +echo -e "${YELLOW}Test 5: Invalid image (expected to fail)${NC}" +if bash "$DOWNLOAD_SCRIPT" "nonexistent-registry.invalid/fake-image:v999" > /tmp/test5.log 2>&1; then echo -e "${RED}✗ FAIL${NC}: Should have failed for invalid image" exit 1 else echo -e "${GREEN}✓ PASS${NC}: Failed as expected for invalid image" # Check for expected error message - if grep -q "Failed to download" /tmp/test4.log; then + if grep -q "Failed to download" /tmp/test5.log; then echo -e "${GREEN}✓ PASS${NC}: Error message present" else echo -e "${YELLOW}⚠ WARNING${NC}: Expected error message format not found" @@ -86,9 +142,9 @@ else fi echo "" -# Test 5: Empty arguments (should handle gracefully) -echo -e "${YELLOW}Test 5: No images provided${NC}" -if bash "$DOWNLOAD_SCRIPT" > /tmp/test5.log 2>&1; then +# Test 6: Empty arguments (should handle gracefully) +echo -e "${YELLOW}Test 6: No images provided${NC}" +if bash "$DOWNLOAD_SCRIPT" > /tmp/test6.log 2>&1; then echo -e "${GREEN}✓ PASS${NC}: Handled empty arguments gracefully" else # This might fail which is also acceptable behavior @@ -102,3 +158,4 @@ echo "==========================================" # Cleanup rm -f /tmp/test*.log +cleanup_mock_docker