Skip to content

feat: native multi-platform builds via runner matrix (no QEMU emulation)#435

Open
tgenov wants to merge 7 commits intodevcontainers:mainfrom
tgenov:main
Open

feat: native multi-platform builds via runner matrix (no QEMU emulation)#435
tgenov wants to merge 7 commits intodevcontainers:mainfrom
tgenov:main

Conversation

@tgenov
Copy link

@tgenov tgenov commented Feb 26, 2026

Problem

The current multi-platform build support (platform input) relies on QEMU emulation through Docker buildx. This works but has significant drawbacks:

  • Performance: Cross-architecture emulation is 5-10x slower than native builds. A linux/arm64 build on an amd64 runner can take 30+ minutes for large images.
  • Reliability: QEMU emulation can produce subtle runtime differences and occasionally fails on complex build steps (e.g., compiling native extensions).
  • Cost: The long build times consume more CI minutes than necessary.

GitHub Actions and Azure DevOps both offer native ARM runners (ubuntu-24.04-arm, ARM64 pool), making emulation unnecessary if the action supports a matrix-based workflow where each platform builds natively on its own runner.

Proposed Solution

Add two new inputs that enable a split build/merge workflow:

platformTag (per-platform build phase)

Used in matrix jobs. Each job runs on a native runner for its target platform and sets platformTag to a suffix (e.g., linux-amd64, linux-arm64). The action:

  1. Appends -{platformTag} to each image tag (e.g., myimage:latest-linux-amd64)
  2. Builds using the native runner architecture (no --platform flag needed)
  3. Pushes the platform-specific image

mergeTag (manifest merge phase)

Used in a final job after all matrix builds complete. The action:

  1. Skips the build entirely
  2. Calls docker buildx imagetools create to combine the per-platform images into a multi-arch manifest under the original tag (e.g., myimage:latest)

Example Workflow (GitHub Actions)

jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: ubuntu-latest
            platform: linux/amd64
            platformTag: linux-amd64
          - runner: ubuntu-24.04-arm
            platform: linux/arm64
            platformTag: linux-arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: devcontainers/ci@v0.3
        with:
          imageName: ghcr.io/org/repo/devcontainer
          imageTag: latest
          platform: ${{ matrix.platform }}
          platformTag: ${{ matrix.platformTag }}
          push: always

  manifest:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: devcontainers/ci@v0.3
        with:
          imageName: ghcr.io/org/repo/devcontainer
          imageTag: latest
          mergeTag: linux-amd64,linux-arm64

Scope of Work

Per CONTRIBUTING.md, changes must maintain feature parity between GitHub Actions and Azure DevOps, include tests, and compile via ./scripts/build-local.sh.

GitHub Action

  • Add platformTag and mergeTag inputs to action.yml
  • Update runMain() in github-action/src/main.ts:
    • Early return when mergeTag is set (save state for post step)
    • Skip --platform and --output flags when platformTag is set (native runner builds correct arch)
    • Append -{platformTag} suffix to image tags
    • Skip skopeo requirement when platformTag is set
  • Update runPost() in github-action/src/main.ts:
    • Manifest merge via docker buildx imagetools create when mergeTag state is present
    • Direct docker push with platform-suffixed tags when platformTag state is present
  • Add createManifest wrapper to github-action/src/docker.ts

Azure DevOps Task

  • Add platformTag and mergeTag inputs to azdo-task/DevcontainersCi/task.json
  • Mirror the runMain() / runPost() logic in azdo-task/DevcontainersCi/src/main.ts
  • Add createManifest wrapper to azdo-task/DevcontainersCi/src/docker.ts

Common

  • Add createManifest function to common/src/docker.ts using docker buildx imagetools create

Tests

  • Unit test for createManifest in common/__tests__/docker.test.ts
  • Unit tests for platformTag / mergeTag input handling
  • Integration test workflow demonstrating the matrix build pattern

Documentation

  • Update docs/github-action.md with platformTag and mergeTag input descriptions
  • Update docs/azure-devops-task.md with equivalent input descriptions
  • Add native multi-platform example to docs/multi-platform-builds.md alongside the existing QEMU approach

Design Notes

Why two inputs instead of extending platform?

The existing platform input (e.g., linux/amd64,linux/arm64) triggers a single QEMU-emulated build. The new inputs are orthogonal — platformTag tags the output of a single-platform native build, and mergeTag merges tagged outputs. This avoids breaking the existing QEMU-based workflow.

Why docker buildx imagetools create instead of docker manifest?

imagetools create works with remote registry images without pulling them locally, and supports OCI image indexes natively. It is the recommended approach for combining multi-platform images that are already pushed to a registry.

Runner polymorphism

This design makes the action polymorphic across runner types. Without the new flags, the action behaves as before — a single QEMU-emulated build on one runner. With platformTag and mergeTag, the same action drives a matrix of native runners where each builds its own architecture natively, then a final job merges the results.

Mode Flags Runner Build strategy
QEMU (existing) platform only Single runner Cross-arch emulation via buildx
Native (new) platform + platformTag + mergeTag Matrix of native runners Each runner builds its own arch natively

Backwards compatibility

When neither platformTag nor mergeTag is set, the action behaves exactly as before. The existing platform input with QEMU emulation continues to work unchanged.

Open Questions

Polymorphism vs. separation of concerns

The current design overloads a single action with two distinct multi-platform strategies (QEMU vs. native runners) controlled by input flags. An alternative would be to ship the native runner workflow as a separate action (e.g., devcontainers/ci/native-multiplatform) with a dedicated interface, keeping the original action focused on single-runner builds.

Polymorphism keeps the surface area small and avoids forcing users to switch actions when migrating from QEMU to native runners. Separation would make each action's contract simpler and avoid the conditional logic around platformTag/mergeTag that touches both runMain and runPost. Which trade-off does the project prefer?

Note

We are already using the fork internally with GitHub Actions. The DevOps task implementation follows the same patterns but has not been tested in an AzDO pipeline.

Add platformTag and mergeTag inputs to support building on native
ARM runners in a matrix strategy, then merging per-platform images
into a multi-arch manifest via docker buildx imagetools create.

This avoids slow QEMU emulation for multi-platform builds by allowing
each matrix job to build natively for its own platform.
The devcontainer CLI rejects --platform without --output. For native
single-platform builds (platformTag set), use type=docker to load
the image into the local daemon for subsequent docker push.
The devcontainer CLI rejects --platform for docker-compose-based
devcontainers. When platformTag is set, the runner is already the
correct native architecture, so --platform is unnecessary.
- Mirror platformTag/mergeTag logic in azdo-task (task.json inputs,
  runMain/runPost in main.ts, createManifest wrapper in docker.ts)
- Add unit tests for createManifest in common/__tests__/docker.test.ts
- Update docs/github-action.md and docs/azure-devops-task.md input tables
- Add native multi-platform builds section to docs/multi-platform-builds.md
  with examples for both GitHub Actions and Azure DevOps Pipelines
@tgenov
Copy link
Author

tgenov commented Feb 26, 2026

@microsoft-github-policy-service agree

@tgenov tgenov marked this pull request as ready for review February 26, 2026 07:41
@tgenov tgenov requested review from a team and stuartleeks as code owners February 26, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant