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 }}