From 2b4ecc7812afe1e25c27deac2e3b6fee83112c03 Mon Sep 17 00:00:00 2001 From: zhanghao Date: Wed, 28 Oct 2026 11:08:47 +0800 Subject: [PATCH 1/2] Publish the image for linux/arm64 as well as linux/amd64 The release built amd64 only, because an arm64 leg of this Rust build under QEMU was too slow to be worth it. Build each architecture natively instead: amd64 on ubuntu-latest, arm64 on the ubuntu-24.04-arm runner. Each job pushes its image by digest and a merge job tags a manifest list over both, so the version and latest tags stay a single multi-arch image. Cross-compiling was the other option, but aws-lc-sys (rustls' crypto provider) needs an aarch64 C toolchain and cmake in the builder; native runners keep the Dockerfile unchanged. Pull requests now run both builds with nothing pushed, and every build is smoke-tested on its own runner: the image architecture matches the target, the binary prints its version, and config.example.yaml passes --config-check. --- .github/workflows/release.yml | 141 ++++++++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9e4658..8384d5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,19 @@ -# Public release: a version tag builds the image and pushes it to Docker Hub. +# Public release: a version tag builds the image for linux/amd64 and linux/arm64 +# and pushes it to Docker Hub as one multi-arch tag. # # Every base image defaults to a public image, so no build-arg overrides are # passed here. # -# amd64 only: this is a Rust build, and an arm64 leg under QEMU costs - # far more than a second architecture is worth here. Revisit if - # anyone actually needs arm64. +# Each architecture builds natively on its own runner (ubuntu-24.04-arm for +# arm64): this is a Rust build with C dependencies (aws-lc-sys), which under QEMU +# takes far longer than the build itself is worth, and cross-compiling would need +# an aarch64 C toolchain in the Dockerfile. Each job pushes its image by digest; +# the merge job then puts the version (and latest) tags on a manifest list over +# both digests. +# +# Pull requests run the same two builds with nothing pushed, so an arm64 breakage +# shows up before a tag does. Every build is smoke-tested on its own runner: the +# binary prints its version and accepts config.example.yaml. # # Credentials come from the organisation secrets DOCKERHUB_USERNAME and # DOCKERHUB_TOKEN. @@ -15,24 +23,118 @@ on: push: tags: - 'v[0-9]*' + pull_request: permissions: {} +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: IMAGE_0: 4pdosc/cache_aware_router jobs: image: - name: Build and push - runs-on: ubuntu-latest + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} permissions: contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + env: + PLATFORM: ${{ matrix.platform }} + PUSH: ${{ github.event_name == 'push' }} steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Set up Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + # Loaded into the runner's docker so the smoke test can run it; the push + # below reuses this build's layers. + - name: Build cache_aware_router + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile + platforms: ${{ matrix.platform }} + load: true + tags: cache-aware-router:smoke + + - name: Smoke test + run: | + set -eu + arch=$(docker image inspect cache-aware-router:smoke --format '{{.Architecture}}') + echo "runner $(uname -m), image ${arch}" + if [ "$arch" != "${PLATFORM#linux/}" ]; then + echo "built ${arch}, wanted ${PLATFORM}" >&2 + exit 1 + fi + docker run --rm --entrypoint ./cache-aware-router cache-aware-router:smoke --version + docker run --rm --entrypoint ./cache-aware-router cache-aware-router:smoke \ + -c config.example.yaml --config-check + + - name: Log in to Docker Hub + if: env.PUSH == 'true' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push by digest + id: push + if: env.PUSH == 'true' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE_0 }},push-by-digest=true,name-canonical=true,push=true + + - name: Export the digest + if: env.PUSH == 'true' + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -eu + mkdir -p "${RUNNER_TEMP}/digests" + touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" + echo "PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" + + - name: Upload the digest + if: env.PUSH == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: digests-${{ env.PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Tag the multi-arch image + if: github.event_name == 'push' + needs: image + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Download the digests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + # latest is what someone gets when they ask for no version in particular, # so a prerelease tag (0.1.0-rc1) builds without taking it. - name: Work out the tags to push @@ -53,9 +155,6 @@ jobs: echo "T_EOF" } >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - - name: Set up Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -65,11 +164,19 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push cache_aware_router - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.tags.outputs.t0 }} + - name: Create the manifest list and push the tags + working-directory: ${{ runner.temp }}/digests + env: + TAGS: ${{ steps.tags.outputs.t0 }} + run: | + set -eu + n=$(ls | wc -l) + if [ "$n" -ne 2 ]; then + echo "expected 2 digests (amd64, arm64), got ${n}" >&2 + exit 1 + fi + args="" + for t in $TAGS; do args="$args -t $t"; done + # shellcheck disable=SC2046,SC2086 + docker buildx imagetools create $args $(printf "${IMAGE_0}@sha256:%s " *) + for t in $TAGS; do docker buildx imagetools inspect "$t"; done From dad7e830f4d392a55432021be41ed044963abce6 Mon Sep 17 00:00:00 2001 From: zhanghao Date: Wed, 28 Oct 2026 11:12:15 +0800 Subject: [PATCH 2/2] Check the binary's ELF header in the smoke test The image's architecture field is only what the image claims. An amd64 binary inside an arm64 image still runs on a runner with binfmt emulation, so --version alone would not catch it; read the ELF machine of the binary copied out of the image and require it to match. --- .github/workflows/release.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8384d5e..0900aa0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,8 @@ # # Pull requests run the same two builds with nothing pushed, so an arm64 breakage # shows up before a tag does. Every build is smoke-tested on its own runner: the -# binary prints its version and accepts config.example.yaml. +# binary's ELF header matches the platform, it prints its version and it accepts +# config.example.yaml. # # Credentials come from the organisation secrets DOCKERHUB_USERNAME and # DOCKERHUB_TOKEN. @@ -80,6 +81,19 @@ jobs: echo "built ${arch}, wanted ${PLATFORM}" >&2 exit 1 fi + # The image's architecture field only says what the image claims to be. Read the + # binary's ELF header too: an amd64 binary inside an arm64 image would still run + # on a runner with binfmt emulation installed. + cid=$(docker create cache-aware-router:smoke) + docker cp "${cid}:/workspace/cache-aware-router" "${RUNNER_TEMP}/car" + docker rm "$cid" >/dev/null + file "${RUNNER_TEMP}/car" + machine=$(readelf -h "${RUNNER_TEMP}/car" | sed -n 's/^ *Machine: *//p') + echo "ELF machine: ${machine}" + case "${PLATFORM}:${machine}" in + linux/amd64:*X86-64*|linux/arm64:AArch64) ;; + *) echo "binary is ${machine}, wanted ${PLATFORM}" >&2; exit 1 ;; + esac docker run --rm --entrypoint ./cache-aware-router cache-aware-router:smoke --version docker run --rm --entrypoint ./cache-aware-router cache-aware-router:smoke \ -c config.example.yaml --config-check