5353 setup :
5454 runs-on : ubuntu-latest
5555 outputs :
56- # build job's matrix: cross product of selected directories and platforms
56+ # build job's matrix: flat include list, one entry per (directory, arch)
5757 build_matrix : ${{ steps.select.outputs.build_matrix }}
5858 # merge job's matrix: selected directories only (one manifest list per directory)
5959 merge_matrix : ${{ steps.select.outputs.merge_matrix }}
6262 steps :
6363 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
6464
65+ # Needed for `docker buildx imagetools inspect` to read the Alpine base and
66+ # published-tag manifests when detecting architectures.
67+ - name : Set up Docker Buildx
68+ uses : docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
69+
6570 - name : Detect changed directories
6671 uses : dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
6772 id : filter
@@ -93,35 +98,94 @@ jobs:
9398 {"directory": "2.1", "version": "2.1.0", "tags": "2.1 2.1-alpine 2 2-alpine latest"}
9499 ]
95100 run : |
96- SELECTED='[]'
101+ # Each platform builds on the closest runner architecture to minimize QEMU
102+ # emulation: amd64 (and 386, which x86-64 runs in hardware) on amd64, and the
103+ # arm variants on ARM hosts (arm64v8 natively; armv7/armv6 via aarch32 emulation).
104+ # The remaining arches (ppc64le, s390x, riscv64) have no native runner and are
105+ # fully emulated.
106+ runner_for() {
107+ case "$1" in
108+ linux/arm*) echo "ubuntu-24.04-arm" ;;
109+ *) echo "ubuntu-latest" ;;
110+ esac
111+ }
112+
113+ # Slug: strip leading "linux/", drop remaining slashes (linux/arm/v7 -> armv7).
114+ slug_for() {
115+ local p="${1#linux/}"
116+ echo "${p//\//}"
117+ }
118+
119+ # Print one "os/arch[/variant]" line per real platform in an image's manifest,
120+ # dropping attestation entries (platform.os == "unknown"). Non-zero if the
121+ # image ref cannot be inspected (e.g. the tag does not exist).
122+ platforms_of() {
123+ docker buildx imagetools inspect --raw "$1" 2>/dev/null \
124+ | jq -r '.manifests[]?
125+ | select(.platform.os != null and .platform.os != "unknown")
126+ | .platform
127+ | .os + "/" + .architecture
128+ + (if (.variant // "") == "" then "" else "/" + .variant end)'
129+ }
130+
131+ # Apply containerd's default-variant rule so a base image's "arm64/v8" compares
132+ # equal to a buildx-pushed image's normalized "arm64". Without this, arm64 would
133+ # always look missing and every directory would rebuild forever.
134+ canonical() {
135+ sed -E 's#^linux/arm64/v8$#linux/arm64#'
136+ }
137+
138+ BUILD='[]'
139+ MERGE='[]'
97140 while read -r ENTRY; do
98141 DIRECTORY=$(jq -r '.directory' <<< "${ENTRY}")
99142 VERSION=$(jq -r '.version' <<< "${ENTRY}")
143+
144+ # Expected arches come from the directory's pinned Alpine base image.
145+ BASE_REF=$(grep -iE '^FROM alpine:' "${DIRECTORY}/Dockerfile" | tail -1 | awk '{print $2}')
146+ EXPECTED=$(platforms_of "${BASE_REF}" | sort -u)
147+ if [[ -z "${EXPECTED}" ]]; then
148+ echo "::error::Could not determine architectures for ${DIRECTORY} from ${BASE_REF}"
149+ exit 1
150+ fi
151+
100152 if jq -e --arg d "${DIRECTORY}" 'index($d) != null' <<< "${CHANGED}" > /dev/null; then
101153 echo "${DIRECTORY}: files changed, building"
102- elif ! curl -fsS "https://hub.docker.com/v2/repositories/${REPO_NAME}/tags/${VERSION}" &> /dev/null; then
103- echo "${DIRECTORY}: ${REPO_NAME}:${VERSION} not yet published, building"
104154 else
105- echo "${DIRECTORY}: unchanged and already published, skipping"
106- continue
155+ PUBLISHED=$(platforms_of "${REPO_NAME}:${VERSION}" | canonical | sort -u || true)
156+ if [[ -z "${PUBLISHED}" ]]; then
157+ echo "${DIRECTORY}: ${REPO_NAME}:${VERSION} not yet published, building"
158+ else
159+ MISSING=$(comm -23 <(printf '%s\n' "${EXPECTED}" | canonical) <(printf '%s\n' "${PUBLISHED}"))
160+ if [[ -n "${MISSING}" ]]; then
161+ echo "${DIRECTORY}: ${REPO_NAME}:${VERSION} building; architectures missing:"
162+ printf '%s\n' "${MISSING}" | sed 's/^/ /'
163+ else
164+ echo "${DIRECTORY}: unchanged and all arches published, skipping"
165+ continue
166+ fi
167+ fi
107168 fi
108- SELECTED=$(jq -c ". + [${ENTRY}]" <<< "${SELECTED}")
169+
170+ # Selected: record the directory for merge, and one build entry per arch.
171+ MERGE=$(jq -c ". + [${ENTRY}]" <<< "${MERGE}")
172+ while read -r PLATFORM; do
173+ [[ -z "${PLATFORM}" ]] && continue
174+ BUILD=$(jq -c \
175+ --arg directory "${DIRECTORY}" \
176+ --arg version "${VERSION}" \
177+ --arg platform "${PLATFORM}" \
178+ --arg runner "$(runner_for "${PLATFORM}")" \
179+ --arg slug "$(slug_for "${PLATFORM}")" \
180+ '. + [{directory: $directory, version: $version, platform: $platform, runner: $runner, slug: $slug}]' \
181+ <<< "${BUILD}")
182+ done <<< "${EXPECTED}"
109183 done < <(jq -c '.[]' <<< "${CANDIDATES}")
110184
111- # Each platform builds on the closest runner architecture to minimize QEMU
112- # emulation: amd64 natively on amd64, and the arm variants on ARM hosts
113- # (arm64v8 natively; armv7/armv6 via aarch32 emulation).
114- PLATFORMS='[
115- {"name": "linux/amd64", "slug": "amd64", "runner": "ubuntu-latest"},
116- {"name": "linux/arm64/v8", "slug": "arm64v8", "runner": "ubuntu-24.04-arm"},
117- {"name": "linux/arm/v7", "slug": "armv7", "runner": "ubuntu-24.04-arm"},
118- {"name": "linux/arm/v6", "slug": "armv6", "runner": "ubuntu-24.04-arm"}
119- ]'
120-
121- echo "Selected directories: ${SELECTED}"
122- echo "merge_matrix={\"include\":${SELECTED}}" >> "${GITHUB_OUTPUT}"
123- echo "build_matrix={\"entry\":${SELECTED},\"platform\":$(jq -c . <<< "${PLATFORMS}")}" >> "${GITHUB_OUTPUT}"
124- if [[ "${SELECTED}" == "[]" ]]; then
185+ echo "Selected build matrix: ${BUILD}"
186+ echo "build_matrix={\"include\":${BUILD}}" >> "${GITHUB_OUTPUT}"
187+ echo "merge_matrix={\"include\":${MERGE}}" >> "${GITHUB_OUTPUT}"
188+ if [[ "${BUILD}" == "[]" ]]; then
125189 echo "has_builds=false" >> "${GITHUB_OUTPUT}"
126190 else
127191 echo "has_builds=true" >> "${GITHUB_OUTPUT}"
@@ -135,16 +199,16 @@ jobs:
135199 - lint
136200 - setup
137201 if : needs.setup.outputs.has_builds == 'true'
138- runs-on : ${{ matrix.platform. runner }}
202+ runs-on : ${{ matrix.runner }}
139203 strategy :
140204 fail-fast : false
141205 matrix : ${{ fromJSON(needs.setup.outputs.build_matrix) }}
142206 env :
143- DIRECTORY : ${{ matrix.entry. directory }}
144- VERSION : ${{ matrix.entry. version }}
145- PLATFORM : ${{ matrix.platform.name }}
207+ DIRECTORY : ${{ matrix.directory }}
208+ VERSION : ${{ matrix.version }}
209+ PLATFORM : ${{ matrix.platform }}
146210 # Cache scope keeps each directory+platform's layers separate
147- CACHE_SCOPE : ${{ matrix.entry. directory }}-${{ matrix.platform .slug }}
211+ CACHE_SCOPE : ${{ matrix.directory }}-${{ matrix.slug }}
148212 steps :
149213 - uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
150214
@@ -203,13 +267,13 @@ jobs:
203267 if : github.ref == 'refs/heads/master'
204268 run : |
205269 mkdir -p digests
206- echo "${{ steps.push.outputs.digest }}" > "digests/${{ matrix.platform. slug }}"
270+ echo "${{ steps.push.outputs.digest }}" > "digests/${{ matrix.slug }}"
207271
208272 - name : Upload digest
209273 if : github.ref == 'refs/heads/master'
210274 uses : actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
211275 with :
212- name : digests-${{ matrix.entry. directory }}-${{ matrix.platform .slug }}
276+ name : digests-${{ matrix.directory }}-${{ matrix.slug }}
213277 path : digests/*
214278 if-no-files-found : error
215279
0 commit comments