ci: use native ARM machines in docker build#7481
Conversation
There was a problem hiding this comment.
Pull request overview
Updates the Docker image publishing workflow to build amd64 and arm64 images on native runners (removing QEMU) and then publish multi-arch manifests.
Changes:
- Split the previous single Docker build job into
build-amd64andbuild-arm64with arch-specific tags. - Add separate tag generation for debug/assertions variants per architecture.
- Introduce a
combine-manifestsjob to create and push multi-arch manifests from the arch-suffixed images.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| combine-manifests: | ||
| needs: [build-amd64, build-arm64] | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Log in to GitHub Docker Registry | ||
| uses: docker/login-action@v4 | ||
| with: | ||
| registry: ghcr.io | ||
| username: ${{ github.actor }} | ||
| password: ${{ secrets.GITHUB_TOKEN }} | ||
|
|
||
| - name: Create and push manifests | ||
| run: | |
There was a problem hiding this comment.
combine-manifests calls docker buildx imagetools create but never initializes Buildx in this job. On some runners this can fail (no builder instance / buildx plugin not configured). Add a docker/setup-buildx-action step (similar to the build jobs) before running docker buildx imagetools ... to make the manifest creation more reliable.
| tags: | | ||
| type=semver,pattern={{version}},value=${{ inputs.ref }},suffix=-amd64 | ||
| type=semver,pattern={{major}}.{{minor}},value=${{ inputs.ref }},suffix=-amd64 | ||
| type=ref,event=tag,suffix=-amd64 | ||
| type=ref,event=branch,enable=${{ inputs.ref == '' }},suffix=-amd64 |
There was a problem hiding this comment.
The job uses a matrix over docker-base-image (debian/alpine), but the generated/pushed image tags do not include the base image. This means the debian and alpine matrix runs will push to the same tags (last write wins), and the later combine-manifests job will also fail because it expects tags like ...:<version>-amd64-<baseimage>. Include ${{ matrix.docker-base-image }} in the metadata-generated tags/suffixes (for normal/debug/assertions) so each matrix variant publishes distinct tags that match the manifest-combine naming scheme.
| # Determine tags based on trigger type | ||
| if [ "${{ inputs.ref }}" != "" ]; then | ||
| # workflow_dispatch with explicit ref | ||
| REF="${{ inputs.ref }}" | ||
| else | ||
| # push:tags trigger | ||
| REF="${{ github.ref }}" | ||
| fi | ||
|
|
||
| # Parse semver from ref (v26.4.0 -> 26.4.0, 26.4) | ||
| if [[ "$REF" =~ ^refs/tags/v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then | ||
| VERSION="${BASH_REMATCH[1]}" | ||
| MAJOR_MINOR="${VERSION%.*}" | ||
| fi | ||
|
|
||
| for BASE_IMAGE in debian alpine; do | ||
| echo "Creating manifests for $BASE_IMAGE..." | ||
|
|
||
| # Normal image manifests | ||
| if [ ! -z "$VERSION" ]; then | ||
| docker buildx imagetools create -t "$REGISTRY:$VERSION-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-arm64-$BASE_IMAGE" | ||
|
|
||
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-arm64-$BASE_IMAGE" | ||
| fi | ||
|
|
||
| # Debug image manifests | ||
| if [ ! -z "$VERSION" ]; then | ||
| docker buildx imagetools create -t "$REGISTRY:$VERSION-debug-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-debug-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-debug-arm64-$BASE_IMAGE" | ||
|
|
||
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-debug-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-debug-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-debug-arm64-$BASE_IMAGE" | ||
| fi | ||
|
|
||
| # Assertions image manifests | ||
| if [ ! -z "$VERSION" ]; then | ||
| docker buildx imagetools create -t "$REGISTRY:$VERSION-assertions-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-assertions-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$VERSION-assertions-arm64-$BASE_IMAGE" | ||
|
|
||
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-assertions-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-assertions-amd64-$BASE_IMAGE" \ | ||
| "$REGISTRY:$MAJOR_MINOR-assertions-arm64-$BASE_IMAGE" | ||
| fi |
There was a problem hiding this comment.
combine-manifests only creates manifests when REF matches ^refs/tags/v?(x.y.z) and then only for the derived VERSION / MAJOR_MINOR tags. This skips common cases: (1) workflow_dispatch input is likely vX.Y.Z (no refs/tags/), leaving VERSION empty and creating no manifests; (2) the build jobs also emit type=ref,event=tag tags (e.g. vX.Y.Z-...), but no manifests are created for those, so those tags would remain arch-specific. Consider deriving the tag(s) from ${GITHUB_REF_NAME} / inputs.ref robustly and creating manifests for all tags that are actually pushed.
| # Determine tags based on trigger type | |
| if [ "${{ inputs.ref }}" != "" ]; then | |
| # workflow_dispatch with explicit ref | |
| REF="${{ inputs.ref }}" | |
| else | |
| # push:tags trigger | |
| REF="${{ github.ref }}" | |
| fi | |
| # Parse semver from ref (v26.4.0 -> 26.4.0, 26.4) | |
| if [[ "$REF" =~ ^refs/tags/v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then | |
| VERSION="${BASH_REMATCH[1]}" | |
| MAJOR_MINOR="${VERSION%.*}" | |
| fi | |
| for BASE_IMAGE in debian alpine; do | |
| echo "Creating manifests for $BASE_IMAGE..." | |
| # Normal image manifests | |
| if [ ! -z "$VERSION" ]; then | |
| docker buildx imagetools create -t "$REGISTRY:$VERSION-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-arm64-$BASE_IMAGE" | |
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-arm64-$BASE_IMAGE" | |
| fi | |
| # Debug image manifests | |
| if [ ! -z "$VERSION" ]; then | |
| docker buildx imagetools create -t "$REGISTRY:$VERSION-debug-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-debug-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-debug-arm64-$BASE_IMAGE" | |
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-debug-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-debug-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-debug-arm64-$BASE_IMAGE" | |
| fi | |
| # Assertions image manifests | |
| if [ ! -z "$VERSION" ]; then | |
| docker buildx imagetools create -t "$REGISTRY:$VERSION-assertions-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-assertions-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$VERSION-assertions-arm64-$BASE_IMAGE" | |
| docker buildx imagetools create -t "$REGISTRY:$MAJOR_MINOR-assertions-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-assertions-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:$MAJOR_MINOR-assertions-arm64-$BASE_IMAGE" | |
| fi | |
| # Determine tag name based on trigger type | |
| if [ "${{ inputs.ref }}" != "" ]; then | |
| # workflow_dispatch with explicit ref/tag | |
| RAW_REF="${{ inputs.ref }}" | |
| elif [ "${{ github.ref_name }}" != "" ]; then | |
| # push:tags trigger | |
| RAW_REF="${{ github.ref_name }}" | |
| else | |
| RAW_REF="${{ github.ref }}" | |
| fi | |
| # Normalize refs/tags/v26.4.0 -> v26.4.0 | |
| REF_NAME="${RAW_REF#refs/tags/}" | |
| if [ -z "$REF_NAME" ]; then | |
| echo "Unable to determine tag name from ref: $RAW_REF" >&2 | |
| exit 1 | |
| fi | |
| # Always create a manifest for the exact pushed tag, and also | |
| # keep the existing semver aliases for plain release tags. | |
| TAGS=("$REF_NAME") | |
| if [[ "$REF_NAME" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then | |
| VERSION="${BASH_REMATCH[1]}" | |
| MAJOR_MINOR="${VERSION%.*}" | |
| TAGS+=("$VERSION" "$MAJOR_MINOR") | |
| fi | |
| # Deduplicate tags while preserving order | |
| MANIFEST_TAGS=() | |
| declare -A SEEN_TAGS=() | |
| for TAG in "${TAGS[@]}"; do | |
| if [ -n "$TAG" ] && [ -z "${SEEN_TAGS[$TAG]}" ]; then | |
| MANIFEST_TAGS+=("$TAG") | |
| SEEN_TAGS[$TAG]=1 | |
| fi | |
| done | |
| for BASE_IMAGE in debian alpine; do | |
| echo "Creating manifests for $BASE_IMAGE..." | |
| for IMAGE_SUFFIX in "" "-debug" "-assertions"; do | |
| for TAG in "${MANIFEST_TAGS[@]}"; do | |
| docker buildx imagetools create -t "$REGISTRY:${TAG}${IMAGE_SUFFIX}-$BASE_IMAGE" \ | |
| "$REGISTRY:${TAG}${IMAGE_SUFFIX}-amd64-$BASE_IMAGE" \ | |
| "$REGISTRY:${TAG}${IMAGE_SUFFIX}-arm64-$BASE_IMAGE" | |
| done | |
| done |
- Added ${{ matrix.docker-base-image }} to all metadata tag suffixes in both build-amd64 and
build-arm64 jobs
- Tags now properly distinguish between debian and alpine variants (e.g., -amd64-debian,
-debug-amd64-alpine)
✅ Issue 2: Buildx Initialization
- Added docker/setup-buildx-action step at the beginning of the combine-manifests job before
running docker buildx imagetools create
✅ Issue 3: Robust Manifest Tag Generation
- Improved tag extraction to handle both workflow_dispatch (direct tag names) and push:tags
(refs/tags/vX.Y.Z) triggers
- Uses github.ref_name as fallback for tags without refs/tags/ prefix
- Creates manifests for all pushed tags (exact match + semver aliases)
- Deduplicates tags while preserving order
- Properly creates manifests for all variants (normal, debug, assertions) across both base
images
The workflow is now more reliable and creates proper multi-arch manifests for all published
image variants.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| tags: | | ||
| type=semver,pattern={{version}},value=${{ inputs.ref }},suffix=-arm64-${{ matrix.docker-base-image }} | ||
| type=semver,pattern={{major}}.{{minor}},value=${{ inputs.ref }},suffix=-arm64-${{ matrix.docker-base-image }} | ||
| type=ref,event=tag,suffix=-arm64-${{ matrix.docker-base-image }} | ||
| type=ref,event=branch,enable=${{ inputs.ref == '' }},suffix=-arm64-${{ matrix.docker-base-image }} |
There was a problem hiding this comment.
Same issue as above: inputs.ref will be empty on tag pushes, so these type=semver rules may not emit the non-v semver tags that the manifest-combine step expects. Using inputs.ref || github.ref_name (or dropping value= for non-dispatch builds) will keep the produced tags aligned with what combine-manifests publishes.
| for BASE_IMAGE in debian alpine; do | ||
| echo "Creating manifests for $BASE_IMAGE..." | ||
|
|
There was a problem hiding this comment.
combine-manifests hard-codes for BASE_IMAGE in debian alpine; do, which duplicates the matrix values from the build jobs. If the matrix changes (e.g., add/remove a base image), this loop can silently go out of sync and start failing or skipping manifests; consider defining the base-image list once (workflow env) and referencing it in both places, or making combine-manifests a matrix job over the same values.
| strategy: | ||
| matrix: | ||
| docker-base-image: ["debian", "alpine"] | ||
| runs-on: ubuntu-latest-arm64 |
There was a problem hiding this comment.
runs-on: ubuntu-latest-arm64 is not a standard GitHub-hosted runner label (the usual ARM64 labels are versioned, e.g. ubuntu-24.04-arm64). As written, this job will fail to schedule on GitHub-hosted runners unless you have a custom runner label with this exact name; consider switching to a supported ubuntu-XX.XX-arm64 label (or documenting that this expects a custom runner label).
| runs-on: ubuntu-latest-arm64 | |
| runs-on: ubuntu-24.04-arm64 |
| tags: | | ||
| type=semver,pattern={{version}},value=${{ inputs.ref }},suffix=-amd64-${{ matrix.docker-base-image }} | ||
| type=semver,pattern={{major}}.{{minor}},value=${{ inputs.ref }},suffix=-amd64-${{ matrix.docker-base-image }} | ||
| type=ref,event=tag,suffix=-amd64-${{ matrix.docker-base-image }} | ||
| type=ref,event=branch,enable=${{ inputs.ref == '' }},suffix=-amd64-${{ matrix.docker-base-image }} |
There was a problem hiding this comment.
On push tag events, inputs.ref will be empty, so the type=semver entries here may not generate the plain semver tags (e.g. 26.4.0, 26.4) that combine-manifests later tries to publish. Consider using a fallback like inputs.ref || github.ref_name (and normalizing refs/tags/... if needed) so semver tags are generated consistently for both workflow_dispatch and tag pushes.
good riddance QEMU