Multi-arch builds with Github Action runners
GHA in the modern day…
So Github Actions are fairly powerful. While yaml can be a frustrating language to write in, we can get quite a bit done!
A while back, I recall reading a comment somewhere about how hard it was to do multi-arch builds in GHA. I’d figured it out at work, so I thought it might be useful!
The real trick is combining multiple built containers into a single image manifest in a repository. Docker’s buildx imagetools makes this possible.
The Code
We’ll use composite actions for most of the work here to make the actual github workflow more composable. And so users can pull individual portions of the build out if needed.
We’ll also need to make some GHA runner groups to ensure we’re actually running on the correct infrastructure for each individual build. In the example, we’ve created two runner groups tagged x86 and arm64.
Composite Build Action
Actually building a container in a somewhat standard way:
# yaml-language-server: $schema=https://json.schemastore.org/github-action.json
name: Build and push container to ECR
description: build and (optionally) push a docker container to ECR
inputs:
docker-repository:
required: true
default: ${{ github.repository }}
type: string
description: The name (after the ECR repository base) of the ECR repo we'll be pushing to
git-ref:
required: false
default: ${{ github.ref || github.head_ref || github.sha }}
type: string
git-sha:
required: false
default: ${{ github.sha }}
type: string
branch-name:
required: false
default: ${{ github.ref_name }}
type: string
tag-date:
required: false
default: true
type: string
description: Should we add a date-based tag for the uploaded image
push:
required: false
default: false
type: boolean
build-args:
required: false
default: ''
type: string
build-secrets:
required: false
default: ''
type: string
path:
required: false
default: '.'
type: string
description: The 'context' path, name is kept for historical reasons
file:
required: false
default: 'Dockerfile'
type: string
platform:
required: false
type: string
default: 'linux/amd64'
target:
required: false
type: string
default: ''
description: 'The target stage in the Dockerfile to build'
multi-arch:
required: false
type: string
default: false
additional-tags:
required: false
type: string
default: ''
description: 'Additional tags to apply to the image (one per line or comma-separated)'
provenance:
required: false
type: boolean
default: true
sbom:
required: false
type: boolean
default: true
aws-access-key:
required: true
aws-access-secret:
required: true
region:
required: false
type: string
default: us-west-2
outputs:
image-name:
value: '${{ steps.image-name.outputs.image-name }}'
image-path:
value: '${{ steps.image-name.outputs.image-path }}'
image-tag:
value: '${{ steps.tag.outputs.tag }}'
short-image-tag:
value: '${{ steps.image-name.outputs.image-tag }}'
additional-tags:
value: '${{ steps.tag.outputs.additional-tags }}'
runs:
using: composite
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.git-ref }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ inputs.aws-access-key }}
aws-secret-access-key: ${{ inputs.aws-access-secret }}
aws-region: ${{ inputs.region }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ inputs.platforms || inputs.platform }}
- name: concat build args
id: build-args
shell: bash
run: |
buildargs=$(echo "${{ inputs.build-args }}")
if [[ "${buildargs}" != *"BUILDKIT_SBOM_SCAN_CONTEXT"* ]]; then
buildargs+=$'\nBUILDKIT_SBOM_SCAN_CONTEXT=true'
fi
{
echo 'buildargs<<EOF'
echo "${buildargs}"
echo EOF
} >> "$GITHUB_OUTPUT"
buildsecrets=$(echo "${{ inputs.build-secrets }}")
{
echo 'buildsecrets<<EOF'
echo "${buildsecrets}"
echo EOF
} >> "$GITHUB_OUTPUT"
- name: generate build tag
id: tag
shell: bash
run: |
repo="${{ inputs.docker-repository }}"
image_tag=${{ inputs.git-sha }}
if [[ "${{ inputs.branch-name }}" != "${{ github.event.repository.default_branch }}" ]]; then
if [[ "${{ inputs.tag-date }}" == "true" ]]; then
image_tag+="-$(date +%Y%m%d)"
fi
image_tag+="-${{ inputs.branch-name }}"
fi
image_tag=$(echo "${image_tag}" | tr '/' '-')
image_path="${{ steps.login-ecr.outputs.registry }}/${repo}"
image_name="${{ steps.login-ecr.outputs.registry }}/${repo}:${image_tag}"
echo "image_name=${image_name}" >> "$GITHUB_OUTPUT"
echo "image_path=${image_path}" >> "$GITHUB_OUTPUT"
echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
architecture=$(echo "${{ inputs.platforms || inputs.platform }}" | cut -d"/" -f2)
if [[ "${{ inputs.multi-arch }}" == "true" ]]; then
cache="cache-${architecture}"
tag="${image_name}-${architecture}"
else
cache="cache"
tag="${image_name}"
if [[ "${{ github.ref }}" == "refs/heads/${{ github.event.repository.default_branch }}" ]]; then
tag+=$'\n'
tag+="${image_path}:latest"
fi
fi
# Process additional tags
additional_tags="${{ inputs.additional-tags }}"
if [[ -n "${additional_tags}" ]]; then
# Handle both comma-separated and newline-separated tags
parsed_additional_tags=$(echo "${additional_tags}" | sed 's/,/\n/g' | sed '/^[[:space:]]*$/d')
if [[ -n "${parsed_additional_tags}" ]]; then
for tag_item in ${parsed_additional_tags}; do
tag+=$'\n'
if [[ "${{ inputs.multi-arch }}" == "true" ]]; then
tag+="${image_path}:${tag_item}-${architecture}"
else
tag+=${image_path}:${tag_item}"
fi
done
fi
fi
echo "arch=${architecture}" >> "$GITHUB_OUTPUT"
echo "cache=${cache}" >> "$GITHUB_OUTPUT"
{
echo 'tag<<EOF'
echo "${tag}"
echo EOF
} >> "$GITHUB_OUTPUT"
{
echo 'additional-tags<<EOF'
echo "${additional_tags}"
echo EOF
} >> "$GITHUB_OUTPUT"
- name: Docker Metadata Action
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
with:
images: |
${{ steps.tag.outputs.image_path }}
labels: |
org.opencontainers.image.ref.name=${{ github.sha }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ github.sha }}
tags: |
${{ steps.tag.outputs.tag }}
# Do an actual image build/push from the branch
- name: Build and push branch tag
# v6.16.0
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1
with:
context: ${{ inputs.path }}
file: ${{ inputs.file }}
target: ${{ inputs.target }}
platforms: ${{ inputs.platforms || inputs.platform }}
build-args: |
${{ steps.build-args.outputs.buildargs }}
secrets: |
${{ steps.build-args.outputs.buildsecrets }}
tags: |
${{ steps.tag.outputs.tag }}
provenance: ${{ inputs.provenance }}
sbom: ${{ inputs.sbom }}
push: ${{ inputs.push }}
cache-to: type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${{ steps.tag.outputs.image_path }}:${{ steps.tag.outputs.cache }}
cache-from: type=registry,ref=${{ steps.tag.outputs.service-name }}:${{ steps.tag.outputs.cache }}
labels: |
${{ steps.meta.outputs.labels }}
Composite Action to Combine Multi-Arch Builds
name: Combine Multi-Arch Builds into a single image
description: Combine Multi-Arch Builds into a single manifest and push to ECR
inputs:
image-path:
required: true
type: string
image-name:
required: true
type: string
image-tag:
required: true
type: string
runs:
uses: composite
steps:
- name: collate builds
shell: bash
run: |
# create multi-arch build with actual tag
docker buildx imagetools create \
-t ${{ inputs.image-name }} \
${{ inputs.image-path }}:${{ inputs.image-tag }}-amd64 \
${{ inputs.image-path }}:${{ inputs.image-tag }}-arm64
# create multi-arch build with latest tag
docker buildx imagetools create \
-t ${{ inputs.image-path }}:latest \
${{ inputs.image-path }}:${{ inputs.image-tag }}-amd64 \
${{ inputs.image-path }}:${{ inputs.image-tag }}-arm64
Workflow to use the composite actions
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Build multi-arch
on:
workflow_call:
inputs:
target:
required: false
default: ""
type: string
use-repo:
required: false
default: false
type: boolean
ecr-repo:
required: false
default: ${{ github.repository }}
type: string
branch-name:
required: false
default: ${{ github.ref_name }}
type: string
path:
required: false
default: "."
type: string
registry-name:
required: false
default: null
type: string
region:
required: false
default: us-west-2
type: string
sbom:
type: boolean
required: false
default: true
provenance:
type: boolean
required: false
default: true
outputs:
image-name:
description: The name of the built image
value: ${{ jobs.combine-archs.outputs.image-name }}
image-path:
description: The path of the built image
value: ${{ jobs.combine-archs.outputs.image-path }}
image-tag:
description: The tag of the built image
value: ${{ jobs.combine-archs.outputs.image-tag }}
secrets:
aws-access-key:
required: true
aws-access-secret:
required: true
build-args:
required: false
build-secrets:
required: false
concurrency:
group: "${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}"
jobs:
build-push:
strategy:
fail-fast: true
matrix:
run:
- arch: linux/amd64
group: x86
- arch: linux/arm64
group: arm64
name: Build & Push Container to ECR
runs-on:
group: ${{ matrix.run.group }}
steps:
- uses: <shared>/build-push@main
id: build-push
with:
docker-repository: ${{ inputs.ecr-repo }}
push: true
path: ${{ inputs.path }}
branch-name: ${{ inputs.branch-name }}
platform: ${{ matrix.run.arch }}
aws-access-key: ${{ secrets.aws-access-key }}
aws-access-secret: ${{ secrets.aws-access-secret }}
target: ${{ inputs.target }}
build-args: ${{ secrets.build-args }}
build-secrets: ${{ secrets.build-secrets }}
region: ${{ inputs.region }}
multi-arch: true
sbom: ${{ inputs.sbom }}
provenance: ${{ inputs.provenance }}
combine-archs:
name: Combine multi-arch builds into a single manifest
needs:
- build-push
runs-on: ubuntu-latest
outputs:
image-name: ${{ steps.combine-archs.outputs.short-image-name }}
image-path: ${{ steps.combine-archs.outputs.image-path }}
image-tag: ${{ steps.combine-archs.outputs.image-tag }}
steps:
- uses: <shared>/combine-multi-arch@main
with:
docker-repository: ${{ inputs.ecr-repo }}
branch-name: ${{ inputs.branch-name }}
aws-access-key: ${{ secrets.aws-access-key }}
aws-access-secret: ${{ secrets.aws-access-secret }}
region: ${{ inputs.region }}