Skip to content

Commit 5c05fb6

Browse files
committed
[actions] cache container images, verified against digests looked up live
Each container job now looks up the image's index, manifest, and config digests from the registry, keys an `actions/cache` entry on the config digest, and only uses a cached image if its ID matches one of those digests; otherwise it is discarded and the image is pulled (with retries) instead. A poisoned or stale cache entry can therefore never be used, and a cache hit needs only a couple of small manifest requests, so this scales regardless of pull rate limits.
1 parent 37be73f commit 5c05fb6

4 files changed

Lines changed: 170 additions & 26 deletions

File tree

‎.github/scripts/docker-image.sh‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/bin/sh
2+
3+
# Cache a container image as a tarball, keyed by digests looked up live from the registry: a cached
4+
# image is only used if it is exactly what the registry serves now, so a poisoned cache entry is never used.
5+
#
6+
# usage:
7+
# sh .github/scripts/docker-image.sh resolve <image>
8+
# prints `key=<config digest>` and `digests=<index, manifest, and config digests>`, for $GITHUB_OUTPUT
9+
# DIGESTS='<digests>' sh .github/scripts/docker-image.sh ensure <image> <tarball>
10+
# loads <tarball> if it holds one of $DIGESTS; otherwise pulls <image>, checks it, and saves it to <tarball>
11+
12+
set -eu
13+
14+
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json'
15+
16+
sha256_of() {
17+
if command -v sha256sum >/dev/null 2>&1; then
18+
sha256sum "$1"
19+
else
20+
shasum -a 256 "$1"
21+
fi | cut -d ' ' -f 1
22+
}
23+
24+
# fetches a manifest into a file; digests are computed from its exact bytes
25+
registry_get() {
26+
curl -fsSL --retry 5 --retry-all-errors -H "Accept: ${ACCEPT}" -o "$2" "$1"
27+
}
28+
29+
resolve() {
30+
IMAGE="$1"
31+
HOST="${IMAGE%%/*}"
32+
REST="${IMAGE#*/}"
33+
REPO="${REST%:*}"
34+
TAG="${REST##*:}"
35+
OS="$(docker version --format '{{.Server.Os}}')"
36+
ARCH="$(docker version --format '{{.Server.Arch}}')"
37+
38+
WORK="$(mktemp -d)"
39+
trap 'rm -rf "${WORK}"' EXIT
40+
41+
registry_get "https://${HOST}/v2/${REPO}/manifests/${TAG}" "${WORK}/index.json"
42+
INDEX_DIGEST="sha256:$(sha256_of "${WORK}/index.json")"
43+
44+
if jq -e '.manifests' "${WORK}/index.json" >/dev/null; then
45+
MANIFEST_DIGEST="$(jq -r --arg os "${OS}" --arg arch "${ARCH}" '[.manifests[] | select(.platform.os == $os and .platform.architecture == $arch)][0].digest // empty' "${WORK}/index.json")"
46+
if [ -z "${MANIFEST_DIGEST}" ]; then
47+
echo "${IMAGE} has no ${OS}/${ARCH} image" >&2
48+
exit 1
49+
fi
50+
registry_get "https://${HOST}/v2/${REPO}/manifests/${MANIFEST_DIGEST}" "${WORK}/manifest.json"
51+
if [ "sha256:$(sha256_of "${WORK}/manifest.json")" != "${MANIFEST_DIGEST}" ]; then
52+
echo "the ${OS}/${ARCH} manifest of ${IMAGE} does not match its digest" >&2
53+
exit 1
54+
fi
55+
else
56+
MANIFEST_DIGEST="${INDEX_DIGEST}"
57+
cp "${WORK}/index.json" "${WORK}/manifest.json"
58+
fi
59+
60+
CONFIG_DIGEST="$(jq -r '.config.digest' "${WORK}/manifest.json")"
61+
echo "key=${CONFIG_DIGEST}"
62+
echo "digests=${INDEX_DIGEST} ${MANIFEST_DIGEST} ${CONFIG_DIGEST}"
63+
}
64+
65+
# an image's ID is its config digest (or, with the containerd image store, its manifest or index digest)
66+
image_matches() {
67+
ID="$(docker image inspect --format '{{.Id}}' "$1" 2>/dev/null)" || return 1
68+
case " ${DIGESTS} " in
69+
*" ${ID} "*) return 0 ;;
70+
esac
71+
return 1
72+
}
73+
74+
ensure() {
75+
IMAGE="$1"
76+
TARBALL="$2"
77+
78+
if [ -f "${TARBALL}" ]; then
79+
if docker load -i "${TARBALL}" && image_matches "${IMAGE}"; then
80+
echo "loaded ${IMAGE} from the cache"
81+
return 0
82+
fi
83+
echo "::warning::the cached ${IMAGE} does not match the registry's digests; pulling it instead"
84+
docker image rm -f "${IMAGE}" >/dev/null 2>&1 || true
85+
rm -f "${TARBALL}"
86+
fi
87+
88+
ATTEMPT=1
89+
until docker pull "${IMAGE}"; do
90+
if [ "${ATTEMPT}" -ge 5 ]; then
91+
echo "docker pull ${IMAGE} failed after ${ATTEMPT} attempts" >&2
92+
return 1
93+
fi
94+
echo "docker pull failed, attempt ${ATTEMPT}/5"
95+
sleep $((ATTEMPT * 5))
96+
ATTEMPT=$((ATTEMPT + 1))
97+
done
98+
99+
# the tag can move between `resolve` and the pull; then this run uses the image, but does not cache it
100+
if image_matches "${IMAGE}"; then
101+
docker save -o "${TARBALL}" "${IMAGE}"
102+
else
103+
echo "::warning::the pulled ${IMAGE} no longer matches the digests looked up earlier; not caching it"
104+
fi
105+
}
106+
107+
case "${1-}" in
108+
resolve) resolve "${2-}" ;;
109+
ensure) ensure "${2-}" "${3-}" ;;
110+
*)
111+
echo 'usage: docker-image.sh resolve <image> | ensure <image> <tarball>' >&2
112+
exit 2
113+
;;
114+
esac

‎.github/workflows/tests-alpine.yml‎

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,22 @@ jobs:
6767
node-version: 'lts/*'
6868
skip-ls-check: true
6969
- run: npm ls urchin
70+
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
71+
# anonymous pull rate limits, and cache the image; a cached image is only used
72+
# if it matches the digests the registry serves now
73+
- name: 'Resolve the alpine:${{ matrix.alpine }} image digests'
74+
id: image
75+
run: sh .github/scripts/docker-image.sh resolve mirror.gcr.io/library/alpine:${{ matrix.alpine }} >> "$GITHUB_OUTPUT"
76+
- uses: actions/cache@v6
77+
with:
78+
path: ${{ runner.temp }}/docker-image.tar
79+
key: docker-image-${{ runner.arch }}-${{ steps.image.outputs.key }}
80+
- name: 'Load or pull the alpine:${{ matrix.alpine }} image'
81+
env:
82+
DIGESTS: ${{ steps.image.outputs.digests }}
83+
run: sh .github/scripts/docker-image.sh ensure mirror.gcr.io/library/alpine:${{ matrix.alpine }} "${RUNNER_TEMP}/docker-image.tar"
7084
- name: 'Run fast tests on Alpine ${{ matrix.alpine }} (${{ matrix.arch }})'
7185
run: |
72-
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
73-
# anonymous pull rate limits; retry to tolerate transient registry failures
74-
for i in 1 2 3 4 5; do
75-
docker pull mirror.gcr.io/library/alpine:${{ matrix.alpine }} && break
76-
echo "docker pull failed, attempt $i/5"; sleep $((i * 5))
77-
done
7886
docker run --rm \
7987
-v "${{ github.workspace }}:/workspace" \
8088
-w /workspace \
@@ -151,14 +159,22 @@ jobs:
151159
- uses: actions/checkout@v6
152160
with:
153161
submodules: false
162+
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
163+
# anonymous pull rate limits, and cache the image; a cached image is only used
164+
# if it matches the digests the registry serves now
165+
- name: 'Resolve the alpine:${{ matrix.alpine }} image digests'
166+
id: image
167+
run: sh .github/scripts/docker-image.sh resolve mirror.gcr.io/library/alpine:${{ matrix.alpine }} >> "$GITHUB_OUTPUT"
168+
- uses: actions/cache@v6
169+
with:
170+
path: ${{ runner.temp }}/docker-image.tar
171+
key: docker-image-${{ runner.arch }}-${{ steps.image.outputs.key }}
172+
- name: 'Load or pull the alpine:${{ matrix.alpine }} image'
173+
env:
174+
DIGESTS: ${{ steps.image.outputs.digests }}
175+
run: sh .github/scripts/docker-image.sh ensure mirror.gcr.io/library/alpine:${{ matrix.alpine }} "${RUNNER_TEMP}/docker-image.tar"
154176
- name: 'Install node ${{ matrix.node }} from a musl binary on Alpine ${{ matrix.alpine }}'
155177
run: |
156-
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
157-
# anonymous pull rate limits; retry to tolerate transient registry failures
158-
for i in 1 2 3 4 5; do
159-
docker pull mirror.gcr.io/library/alpine:${{ matrix.alpine }} && break
160-
echo "docker pull failed, attempt $i/5"; sleep $((i * 5))
161-
done
162178
docker run --rm \
163179
-v "${{ github.workspace }}:/workspace" \
164180
-w /workspace \

‎.github/workflows/tests-installation-node.yml‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,22 @@ jobs:
6464
skip-ls-check: true
6565
- run: npm ls urchin
6666
- run: npx which urchin
67+
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
68+
# anonymous pull rate limits, and cache the image; a cached image is only used
69+
# if it matches the digests the registry serves now
70+
- name: Resolve the ubuntu:16.04 image digests
71+
id: image
72+
run: sh .github/scripts/docker-image.sh resolve mirror.gcr.io/library/ubuntu:16.04 >> "$GITHUB_OUTPUT"
73+
- uses: actions/cache@v6
74+
with:
75+
path: ${{ runner.temp }}/docker-image.tar
76+
key: docker-image-${{ runner.arch }}-${{ steps.image.outputs.key }}
77+
- name: Load or pull the ubuntu:16.04 image
78+
env:
79+
DIGESTS: ${{ steps.image.outputs.digests }}
80+
run: sh .github/scripts/docker-image.sh ensure mirror.gcr.io/library/ubuntu:16.04 "${RUNNER_TEMP}/docker-image.tar"
6781
- name: Run installation_node tests in container
6882
run: |
69-
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
70-
# anonymous pull rate limits; retry to tolerate transient registry failures
71-
for i in 1 2 3 4 5; do
72-
docker pull mirror.gcr.io/library/ubuntu:16.04 && break
73-
echo "docker pull failed, attempt $i/5"
74-
sleep $((i * 5))
75-
done
7683
docker run --rm \
7784
-v "${{ github.workspace }}:/workspace" \
7885
-w /workspace \

‎.github/workflows/tests-xenial.yml‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,22 @@ jobs:
6161
skip-ls-check: true
6262
- run: npm ls urchin
6363
- run: npx which urchin
64+
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
65+
# anonymous pull rate limits, and cache the image; a cached image is only used
66+
# if it matches the digests the registry serves now
67+
- name: Resolve the ubuntu:16.04 image digests
68+
id: image
69+
run: sh .github/scripts/docker-image.sh resolve mirror.gcr.io/library/ubuntu:16.04 >> "$GITHUB_OUTPUT"
70+
- uses: actions/cache@v6
71+
with:
72+
path: ${{ runner.temp }}/docker-image.tar
73+
key: docker-image-${{ runner.arch }}-${{ steps.image.outputs.key }}
74+
- name: Load or pull the ubuntu:16.04 image
75+
env:
76+
DIGESTS: ${{ steps.image.outputs.digests }}
77+
run: sh .github/scripts/docker-image.sh ensure mirror.gcr.io/library/ubuntu:16.04 "${RUNNER_TEMP}/docker-image.tar"
6478
- name: Run xenial tests in container
6579
run: |
66-
# Pull through Google's Docker Hub mirror, which is not subject to Docker Hub's
67-
# anonymous pull rate limits; retry to tolerate transient registry failures
68-
for i in 1 2 3 4 5; do
69-
docker pull mirror.gcr.io/library/ubuntu:16.04 && break
70-
echo "docker pull failed, attempt $i/5"
71-
sleep $((i * 5))
72-
done
7380
docker run --rm \
7481
-v "${{ github.workspace }}:/workspace" \
7582
-w /workspace \

0 commit comments

Comments
 (0)