diff --git a/action.yml b/action.yml index 3af8a63eb..3ad37b574 100644 --- a/action.yml +++ b/action.yml @@ -66,6 +66,12 @@ inputs: cacheTo: required: false description: Specify the image to cache the built image to + platformTag: + required: false + description: 'Tag suffix for this platform build (e.g., "linux-amd64"). Used in matrix builds to push per-platform images that are later merged.' + mergeTag: + required: false + description: 'Comma-separated list of platform tags to merge into a multi-arch manifest (e.g., "linux-amd64,linux-arm64"). Used in the merge job after matrix builds complete.' outputs: runCmdOutput: description: The output of the command specified in the runCmd input diff --git a/azdo-task/DevcontainersCi/src/docker.ts b/azdo-task/DevcontainersCi/src/docker.ts index e4987ca1d..08967c1f1 100644 --- a/azdo-task/DevcontainersCi/src/docker.ts +++ b/azdo-task/DevcontainersCi/src/docker.ts @@ -71,3 +71,17 @@ export async function pushImage( return false; } } + +export async function createMultiPlatformImage( + imageName: string, + tag: string, + platformTags: string[], +): Promise { + try { + await docker.createMultiPlatformImage(exec, imageName, tag, platformTags); + return true; + } catch (error) { + task.setResult(task.TaskResult.Failed, `${error}`); + return false; + } +} diff --git a/azdo-task/DevcontainersCi/src/main.ts b/azdo-task/DevcontainersCi/src/main.ts index 875895fb3..6f4e0bdb1 100644 --- a/azdo-task/DevcontainersCi/src/main.ts +++ b/azdo-task/DevcontainersCi/src/main.ts @@ -9,14 +9,75 @@ import { DevContainerCliUpArgs, } from '../../../common/src/dev-container-cli'; -import {isDockerBuildXInstalled, pushImage} from './docker'; +import { + isDockerBuildXInstalled, + pushImage, + createMultiPlatformImage, +} from './docker'; import {isSkopeoInstalled, copyImage} from './skopeo'; import {exec} from './exec'; +import { + buildImageNames, + mergeMultiPlatformImages, +} from '../../../common/src/platform'; export async function runMain(): Promise { try { task.setTaskVariable('hasRunMain', 'true'); + + const rawMergeTag = task.getInput('mergeTag'); + const mergeTag = rawMergeTag?.trim() || undefined; + const rawPlatformTag = task.getInput('platformTag'); + const platformTag = rawPlatformTag?.trim() || undefined; + + if (platformTag && /[\s,]/.test(platformTag)) { + task.setResult( + task.TaskResult.Failed, + `Invalid platformTag '${platformTag}' - must not contain whitespace or commas. Use mergeTag to specify multiple platforms.`, + ); + return; + } + + if (mergeTag && platformTag) { + task.setResult( + task.TaskResult.Failed, + 'mergeTag and platformTag cannot be used together - mergeTag is for the manifest merge job, platformTag is for per-platform build jobs', + ); + return; + } + const buildXInstalled = await isDockerBuildXInstalled(); + + if (mergeTag) { + const imageName = task.getInput('imageName'); + if (!imageName) { + task.setResult( + task.TaskResult.Failed, + 'imageName is required when using mergeTag', + ); + return; + } + if (!buildXInstalled) { + task.setResult( + task.TaskResult.Failed, + 'docker buildx is required for mergeTag - add a step to set up docker buildx', + ); + return; + } + const pushOption = task.getInput('push'); + if (pushOption !== 'always') { + task.setResult( + task.TaskResult.Failed, + "push must be set to 'always' when using mergeTag - the manifest merge job must push the resulting multi-arch image", + ); + return; + } + console.log( + 'mergeTag is set - skipping build (manifest merge will run in post step)', + ); + task.setTaskVariable('mergeTag', mergeTag); + return; + } if (!buildXInstalled) { console.log( '### WARNING: docker buildx not available: add a step to set up with docker/setup-buildx-action - see https://github.com/devcontainers/ci/blob/main/docs/azure-devops-task.md', @@ -52,7 +113,7 @@ export async function runMain(): Promise { const skipContainerUserIdUpdate = (task.getInput('skipContainerUserIdUpdate') ?? 'false') === 'true'; - if (platform) { + if (platform && !platformTag) { const skopeoInstalled = await isSkopeoInstalled(); if (!skopeoInstalled) { console.log( @@ -61,7 +122,14 @@ export async function runMain(): Promise { return; } } - const buildxOutput = platform ? 'type=oci,dest=/tmp/output.tar' : undefined; + let buildxOutput: string | undefined; + if (platform && !platformTag) { + buildxOutput = 'type=oci,dest=/tmp/output.tar'; + } + + if (platformTag) { + task.setTaskVariable('platformTag', platformTag); + } const log = (message: string): void => console.log(message); const workspaceFolder = path.resolve(checkoutPath, subFolder); @@ -70,10 +138,9 @@ export async function runMain(): Promise { const resolvedImageTag = imageTag ?? 'latest'; const imageTagArray = resolvedImageTag.split(/\s*,\s*/); - const fullImageNameArray: string[] = []; - for (const tag of imageTagArray) { - fullImageNameArray.push(`${imageName}:${tag}`); - } + const fullImageNameArray = imageName + ? buildImageNames(imageName, imageTagArray, platformTag) + : []; if (imageName) { if (fullImageNameArray.length === 1) { if (!noCache && !cacheFrom.includes(fullImageNameArray[0])) { @@ -98,9 +165,9 @@ export async function runMain(): Promise { workspaceFolder, configFile, imageName: fullImageNameArray, - platform, + platform: platformTag ? undefined : platform, additionalCacheFroms: cacheFrom, - output: buildxOutput, + output: platformTag ? undefined : buildxOutput, noCache, cacheTo, }; @@ -192,6 +259,9 @@ export async function runPost(): Promise { const pushOnFailedBuild = (task.getInput('pushOnFailedBuild') ?? 'false') === 'true'; + const mergeTag = task.getTaskVariable('mergeTag'); + const platformTag = task.getTaskVariable('platformTag'); + // default to 'never' if not set and no imageName if (pushOption === 'never' || (!pushOption && !imageName)) { console.log(`Image push skipped because 'push' is set to '${pushOption}'`); @@ -259,8 +329,27 @@ export async function runPost(): Promise { } const imageTag = task.getInput('imageTag') ?? 'latest'; const imageTagArray = imageTag.split(/\s*,\s*/); + + if (mergeTag) { + await mergeMultiPlatformImages( + imageName, + imageTagArray, + mergeTag, + createMultiPlatformImage, + (msg: string) => console.log(msg), + ); + return; + } + const platform = task.getInput('platform'); - if (platform) { + if (platformTag) { + for (const tag of imageTagArray) { + console.log( + `Pushing platform image '${imageName}:${tag}-${platformTag}'...`, + ); + await pushImage(imageName, `${tag}-${platformTag}`); + } + } else if (platform) { for (const tag of imageTagArray) { console.log(`Copying multiplatform image '${imageName}:${tag}'...`); const imageSource = `oci-archive:/tmp/output.tar:${tag}`; diff --git a/azdo-task/DevcontainersCi/task.json b/azdo-task/DevcontainersCi/task.json index a3c12680c..dfbe84008 100644 --- a/azdo-task/DevcontainersCi/task.json +++ b/azdo-task/DevcontainersCi/task.json @@ -128,6 +128,18 @@ "type": "multiLine", "label": "Specify the image to cache the built image to", "required": false + }, + { + "name": "platformTag", + "type": "string", + "label": "Tag suffix for this platform build (e.g., 'linux-amd64'). Used in matrix builds to push per-platform images that are later merged.", + "required": false + }, + { + "name": "mergeTag", + "type": "string", + "label": "Comma-separated list of platform tags to merge into a multi-arch manifest (e.g., 'linux-amd64,linux-arm64'). Used in the merge job after matrix builds complete.", + "required": false } ], "outputVariables": [{ diff --git a/common/__tests__/docker.test.ts b/common/__tests__/docker.test.ts index c686ac373..4a95a37f4 100644 --- a/common/__tests__/docker.test.ts +++ b/common/__tests__/docker.test.ts @@ -1,4 +1,5 @@ -import {parseMount} from '../src/docker'; +import {parseMount, createMultiPlatformImage} from '../src/docker'; +import {ExecFunction, ExecResult} from '../src/exec'; describe('parseMount', () => { test('handles type,src,dst', () => { @@ -58,3 +59,127 @@ describe('parseMount', () => { expect(result.target).toBe('/my/dest'); }); }); + +describe('createMultiPlatformImage', () => { + test('should call docker buildx imagetools create with correct args for two platforms', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', 'linux-arm64']); + + expect(mockExec).toHaveBeenCalledTimes(1); + expect(mockExec).toHaveBeenCalledWith( + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:v1.0.0', + 'ghcr.io/my-org/my-image:v1.0.0-linux-amd64', + 'ghcr.io/my-org/my-image:v1.0.0-linux-arm64', + ], + {}, + ); + }); + + test('should throw when docker command returns non-zero exit code', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 1, stdout: '', stderr: 'error'}); + + await expect( + createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', 'linux-arm64']), + ).rejects.toThrow('manifest creation failed with exit code 1: error'); + }); + + test('should handle a single platform tag', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'latest', ['linux-amd64']); + + expect(mockExec).toHaveBeenCalledTimes(1); + expect(mockExec).toHaveBeenCalledWith( + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:latest', + 'ghcr.io/my-org/my-image:latest-linux-amd64', + ], + {}, + ); + }); + + test('should handle multiple image tags', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64']); + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'latest', ['linux-amd64']); + + expect(mockExec).toHaveBeenCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:v1.0.0', + 'ghcr.io/my-org/my-image:v1.0.0-linux-amd64', + ], + {}, + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:latest', + 'ghcr.io/my-org/my-image:latest-linux-amd64', + ], + {}, + ); + }); + + test('should trim whitespace from platform tags', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', [' linux-amd64 ', 'linux-arm64']); + + expect(mockExec).toHaveBeenCalledWith( + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:v1.0.0', + 'ghcr.io/my-org/my-image:v1.0.0-linux-amd64', + 'ghcr.io/my-org/my-image:v1.0.0-linux-arm64', + ], + {}, + ); + }); + + test('should filter out empty platform tags', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', '', ' ']); + + expect(mockExec).toHaveBeenCalledWith( + 'docker', + [ + 'buildx', 'imagetools', 'create', + '-t', 'ghcr.io/my-org/my-image:v1.0.0', + 'ghcr.io/my-org/my-image:v1.0.0-linux-amd64', + ], + {}, + ); + }); + + test('should throw when all platform tags are empty', async () => { + const mockExec = jest.fn, Parameters>() + .mockResolvedValue({exitCode: 0, stdout: '', stderr: ''}); + + await expect( + createMultiPlatformImage(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['', ' ']), + ).rejects.toThrow('platformTags must contain at least one non-empty entry'); + + expect(mockExec).not.toHaveBeenCalled(); + }); +}); diff --git a/common/__tests__/platform.test.ts b/common/__tests__/platform.test.ts new file mode 100644 index 000000000..ea167881c --- /dev/null +++ b/common/__tests__/platform.test.ts @@ -0,0 +1,78 @@ +import {buildImageNames, mergeMultiPlatformImages} from '../src/platform'; + +describe('buildImageNames', () => { + test('single tag without platformTag', () => { + expect(buildImageNames('img', ['v1'])).toEqual(['img:v1']); + }); + + test('single tag with platformTag', () => { + expect(buildImageNames('img', ['v1'], 'linux-amd64')).toEqual(['img:v1-linux-amd64']); + }); + + test('multiple tags without platformTag', () => { + expect(buildImageNames('img', ['v1', 'latest'])).toEqual(['img:v1', 'img:latest']); + }); + + test('multiple tags with platformTag', () => { + expect(buildImageNames('img', ['v1', 'latest'], 'linux-amd64')).toEqual([ + 'img:v1-linux-amd64', + 'img:latest-linux-amd64', + ]); + }); + + test('empty tags array', () => { + expect(buildImageNames('img', [])).toEqual([]); + }); + + test('undefined platformTag explicitly passed', () => { + expect(buildImageNames('img', ['v1'], undefined)).toEqual(['img:v1']); + }); +}); + +describe('mergeMultiPlatformImages', () => { + test('calls createFn for each tag with correct platform tags split from mergeTag', async () => { + const createFn = jest.fn, [string, string, string[]]>() + .mockResolvedValue(true); + const log = jest.fn(); + + const result = await mergeMultiPlatformImages('img', ['v1', 'latest'], 'linux-amd64,linux-arm64', createFn, log); + + expect(createFn).toHaveBeenCalledTimes(2); + expect(createFn).toHaveBeenNthCalledWith(1, 'img', 'v1', ['linux-amd64', 'linux-arm64']); + expect(createFn).toHaveBeenNthCalledWith(2, 'img', 'latest', ['linux-amd64', 'linux-arm64']); + expect(result).toBe(true); + }); + + test('returns false and stops on first createFn failure', async () => { + const createFn = jest.fn, [string, string, string[]]>() + .mockResolvedValueOnce(false); + const log = jest.fn(); + + const result = await mergeMultiPlatformImages('img', ['v1', 'latest'], 'linux-amd64,linux-arm64', createFn, log); + + expect(result).toBe(false); + expect(createFn).toHaveBeenCalledTimes(1); + }); + + test('logs a message for each tag', async () => { + const createFn = jest.fn, [string, string, string[]]>() + .mockResolvedValue(true); + const log = jest.fn(); + + await mergeMultiPlatformImages('img', ['v1', 'latest'], 'linux-amd64,linux-arm64', createFn, log); + + expect(log).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenNthCalledWith(1, "Creating multi-arch manifest for 'img:v1'..."); + expect(log).toHaveBeenNthCalledWith(2, "Creating multi-arch manifest for 'img:latest'..."); + }); + + test('handles comma-separated mergeTag with whitespace', async () => { + const createFn = jest.fn, [string, string, string[]]>() + .mockResolvedValue(true); + const log = jest.fn(); + + await mergeMultiPlatformImages('img', ['v1'], 'linux-amd64 , linux-arm64', createFn, log); + + expect(createFn).toHaveBeenCalledWith('img', 'v1', ['linux-amd64', 'linux-arm64']); + }); +}); diff --git a/common/src/docker.ts b/common/src/docker.ts index fa640781b..c3acb57a0 100644 --- a/common/src/docker.ts +++ b/common/src/docker.ts @@ -337,6 +337,30 @@ export async function pushImage( } } +export async function createMultiPlatformImage( + exec: ExecFunction, + imageName: string, + tag: string, + platformTags: string[], +): Promise { + platformTags = platformTags.map(t => t.trim()).filter(t => t.length > 0); + if (platformTags.length === 0) { + throw new Error('platformTags must contain at least one non-empty entry'); + } + + const args = ['buildx', 'imagetools', 'create']; + args.push('-t', `${imageName}:${tag}`); + for (const platformTag of platformTags) { + args.push(`${imageName}:${tag}-${platformTag}`); + } + + const {exitCode, stdout, stderr} = await exec('docker', args, {}); + + if (exitCode !== 0) { + throw new Error(`manifest creation failed with exit code ${exitCode}${stderr ? `: ${stderr}` : ''}${stdout ? `\nstdout: ${stdout}` : ''}`); + } +} + export interface DockerMount { type: string; source: string; diff --git a/common/src/platform.ts b/common/src/platform.ts new file mode 100644 index 000000000..6fa49d11c --- /dev/null +++ b/common/src/platform.ts @@ -0,0 +1,45 @@ +/** + * Build full image name strings, optionally suffixed with a platform tag. + * + * Example: + * buildImageNames('ghcr.io/org/img', ['v1', 'latest'], 'linux-amd64') + * => ['ghcr.io/org/img:v1-linux-amd64', 'ghcr.io/org/img:latest-linux-amd64'] + */ +export function buildImageNames( + imageName: string, + imageTags: string[], + platformTag?: string, +): string[] { + return imageTags.map(tag => + platformTag + ? `${imageName}:${tag}-${platformTag}` + : `${imageName}:${tag}`, + ); +} + +/** + * Create multi-arch manifests for each image tag by merging per-platform images. + * + * Returns true if all manifests were created successfully, false otherwise. + */ +export async function mergeMultiPlatformImages( + imageName: string, + imageTags: string[], + mergeTag: string, + createFn: ( + imageName: string, + tag: string, + platformTags: string[], + ) => Promise, + log: (message: string) => void, +): Promise { + const platformTags = mergeTag.split(/\s*,\s*/); + for (const tag of imageTags) { + log(`Creating multi-arch manifest for '${imageName}:${tag}'...`); + const success = await createFn(imageName, tag, platformTags); + if (!success) { + return false; + } + } + return true; +} diff --git a/docs/azure-devops-task.md b/docs/azure-devops-task.md index 0f67cfdc1..245e08cf4 100644 --- a/docs/azure-devops-task.md +++ b/docs/azure-devops-task.md @@ -83,7 +83,9 @@ In the example above, the devcontainer-build-run will perform the following step | cacheFrom | false | Specify additional images to use for build caching | | noCache | false | Builds the image with `--no-cache` (takes precedence over `cacheFrom`) | | cacheTo | false | Specify the image to cache the built image to | -| platform | false | Platforms for which the image should be built. If omitted, defaults to the platform of the GitHub Actions Runner. Multiple platforms should be comma separated. | +| platform | false | Platforms for which the image should be built. If omitted, defaults to the platform of the Azure Pipelines agent. Multiple platforms should be comma separated. Ignored when `platformTag` is set (matrix mode). | +| platformTag | false | Tag suffix for this platform build (e.g., `linux-amd64`). In matrix builds, this controls which per-platform image is built and tagged; when set, `platform` is ignored. Used to push per-platform images that are later merged into a multi-arch manifest. | +| mergeTag | false | Comma-separated list of platform tags to merge into a multi-arch manifest (e.g., `linux-amd64,linux-arm64`). When set, the task skips building an image and instead merges previously built `platformTag` images in the merge job after matrix builds complete. | ## Outputs diff --git a/docs/github-action.md b/docs/github-action.md index 806f5fc3f..96563d6f8 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -142,7 +142,9 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai | cacheFrom | false | Specify additional images to use for build caching | | noCache | false | Builds the image with `--no-cache` (takes precedence over `cacheFrom`) | | cacheTo | false | Specify the image to cache the built image to | -| platform | false | Platforms for which the image should be built. If omitted, defaults to the platform of the GitHub Actions Runner. Multiple platforms should be comma separated. | +| platform | false | Platforms for which the image should be built. If omitted, defaults to the platform of the GitHub Actions Runner. Multiple platforms should be comma separated. Ignored when `platformTag` is set for a native per-platform build, or when `mergeTag` is set for a merge-only job. | +| platformTag | false | Tag suffix for this platform build (e.g., `linux-amd64`). Used in matrix builds to push per-platform images that are later merged into a multi-arch manifest. When set, this job is treated as a native per-platform build and the `platform` input is ignored. | +| mergeTag | false | Comma-separated list of platform tags to merge into a multi-arch manifest (e.g., `linux-amd64,linux-arm64`). Used in the merge job after matrix builds complete. When set, the job only merges existing per-platform images and does not build; `platform` and `platformTag` are ignored. | ## Outputs diff --git a/docs/multi-platform-builds.md b/docs/multi-platform-builds.md index 1ba9cd34d..ab772da3a 100644 --- a/docs/multi-platform-builds.md +++ b/docs/multi-platform-builds.md @@ -4,9 +4,9 @@ Building dev containers to support multiple platforms (aka CPU architectures) is ## General Notes/Caveats -- Multiplatform builds utilize emulation to build on architectures not native to the system the build is running on. This will significantly increase build times over native, single architecture builds. -- If you are using runCmd, the command will only be run on the architecure of the system the build is running on. This means that, if you are using runCmd to test the image, there may be bugs on the alternate platforms that will not be caught by your test suite. Manual post-build testing is advised. -- As of October 2022, all hosted servers for GitHub Actions and Azure Pipelines are x86_64 only. If you want to automatically run runCmd-based tests on your devcontainer on another architecure, you'll need a self-hosted runner on that architecture. It is possible that there will be future support for hosted arm64 machines, see [here for a tracking issue for Linux](https://github.com/actions/runner-images/issues/5631). +- Emulation-based multiplatform builds (using QEMU) will significantly increase build times over native, single architecture builds. For faster builds, consider using the [native matrix strategy](#native-multi-platform-builds-matrix-strategy) instead. +- If you are using runCmd, the command will only be run on the architecture of the system the build is running on. This means that, if you are using runCmd to test the image, there may be bugs on the alternate platforms that will not be caught by your test suite. Manual post-build testing is advised. +- GitHub Actions now offers hosted ARM runners (e.g. `ubuntu-24.04-arm`). For Azure Pipelines, you will need a self-hosted ARM agent for native ARM builds. ## GitHub Actions Example @@ -72,3 +72,121 @@ jobs: imageName: UserNameHere/ImageNameHere platform: linux/amd64,linux/arm64 ``` + +## Native Multi-Platform Builds (Matrix Strategy) + +Instead of using QEMU emulation on a single runner, you can use native runners in a matrix strategy. Each runner builds for its own architecture and pushes a platform-specific image. A final job then merges the per-platform images into a single multi-arch manifest. + +### Benefits + +- **Faster builds** -- no emulation overhead since each runner compiles natively. +- **More reliable** -- native compilation avoids QEMU compatibility issues. +- **Flexible runners** -- works with GitHub's hosted ARM runners (`ubuntu-24.04-arm`) or self-hosted ARM agents. + +### GitHub Actions Example + +```yaml +jobs: + build: + strategy: + matrix: + include: + - runner: ubuntu-latest + platformTag: linux-amd64 + - runner: ubuntu-24.04-arm + 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/example/myimage + 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/example/myimage + mergeTag: linux-amd64,linux-arm64 + push: always +``` + +### Azure DevOps Pipelines Example + +```yaml +stages: +- stage: Build + jobs: + - job: BuildAmd64 + pool: + vmImage: ubuntu-latest + steps: + - task: Docker@2 + displayName: Login to Container Registry + inputs: + command: login + containerRegistry: RegistryNameHere + - script: docker buildx create --use + displayName: Set up docker buildx + - task: DevcontainersCi@0 + inputs: + imageName: myregistry.azurecr.io/devcontainer + platform: linux/amd64 + platformTag: linux-amd64 + push: always + + - job: BuildArm64 + pool: + name: 'Self-Hosted-ARM64' # Use an ARM64 agent for native builds + steps: + - task: Docker@2 + displayName: Login to Container Registry + inputs: + command: login + containerRegistry: RegistryNameHere + - script: docker buildx create --use + displayName: Set up docker buildx + - task: DevcontainersCi@0 + inputs: + imageName: myregistry.azurecr.io/devcontainer + platform: linux/arm64 + platformTag: linux-arm64 + push: always + +- stage: Manifest + dependsOn: Build + jobs: + - job: MergeManifest + pool: + vmImage: ubuntu-latest + steps: + - task: Docker@2 + displayName: Login to Container Registry + inputs: + command: login + containerRegistry: RegistryNameHere + - script: docker buildx create --use + displayName: Set up docker buildx + - task: DevcontainersCi@0 + inputs: + imageName: myregistry.azurecr.io/devcontainer + mergeTag: linux-amd64,linux-arm64 + push: always +``` diff --git a/github-action/src/docker.ts b/github-action/src/docker.ts index 1c65db1be..50beb39ee 100644 --- a/github-action/src/docker.ts +++ b/github-action/src/docker.ts @@ -77,3 +77,20 @@ export async function pushImage( core.endGroup(); } } + +export async function createMultiPlatformImage( + imageName: string, + tag: string, + platformTags: string[], +): Promise { + core.startGroup(`📦 ${imageName}:${tag}`); + try { + await docker.createMultiPlatformImage(exec, imageName, tag, platformTags); + return true; + } catch (error) { + core.setFailed(error); + return false; + } finally { + core.endGroup(); + } +} diff --git a/github-action/src/main.ts b/github-action/src/main.ts index 1189788cd..998fcb813 100644 --- a/github-action/src/main.ts +++ b/github-action/src/main.ts @@ -9,9 +9,17 @@ import { DevContainerCliUpArgs, } from '../../common/src/dev-container-cli'; -import {isDockerBuildXInstalled, pushImage} from './docker'; +import { + isDockerBuildXInstalled, + pushImage, + createMultiPlatformImage, +} from './docker'; import {isSkopeoInstalled, copyImage} from './skopeo'; import {populateDefaults} from '../../common/src/envvars'; +import { + buildImageNames, + mergeMultiPlatformImages, +} from '../../common/src/platform'; // List the env vars that point to paths to mount in the dev container // See https://docs.github.com/en/actions/learn-github-actions/variables @@ -26,7 +34,53 @@ export async function runMain(): Promise { try { core.info('Starting...'); core.saveState('hasRunMain', 'true'); + + const mergeTag = emptyStringAsUndefined(core.getInput('mergeTag').trim()); + const platformTag = emptyStringAsUndefined( + core.getInput('platformTag').trim(), + ); + + if (platformTag && /[\s,]/.test(platformTag)) { + core.setFailed( + `Invalid platformTag '${platformTag}' - must not contain whitespace or commas. Use mergeTag to specify multiple platforms.`, + ); + return; + } + + if (mergeTag && platformTag) { + core.setFailed( + 'mergeTag and platformTag cannot be used together - mergeTag is for the manifest merge job, platformTag is for per-platform build jobs', + ); + return; + } + const buildXInstalled = await isDockerBuildXInstalled(); + + if (mergeTag) { + const imageName = emptyStringAsUndefined(core.getInput('imageName')); + if (!imageName) { + core.setFailed('imageName is required when using mergeTag'); + return; + } + if (!buildXInstalled) { + core.setFailed( + 'docker buildx is required for mergeTag - add a step to set up with docker/setup-buildx-action', + ); + return; + } + const pushOption = emptyStringAsUndefined(core.getInput('push')); + if (pushOption !== 'always') { + core.setFailed( + "push must be set to 'always' when using mergeTag - the manifest merge job must push the resulting multi-arch image", + ); + return; + } + core.info( + 'mergeTag is set - skipping build (manifest merge will run in post step)', + ); + core.saveState('mergeTag', mergeTag); + return; + } if (!buildXInstalled) { core.warning( 'docker buildx not available: add a step to set up with docker/setup-buildx-action - see https://github.com/devcontainers/ci/blob/main/docs/github-action.md', @@ -64,7 +118,7 @@ export async function runMain(): Promise { const userDataFolder: string = core.getInput('userDataFolder'); const mounts: string[] = core.getMultilineInput('mounts'); - if (platform) { + if (platform && !platformTag) { const skopeoInstalled = await isSkopeoInstalled(); if (!skopeoInstalled) { core.warning( @@ -73,7 +127,14 @@ export async function runMain(): Promise { return; } } - const buildxOutput = platform ? 'type=oci,dest=/tmp/output.tar' : undefined; + let buildxOutput: string | undefined; + if (platform && !platformTag) { + buildxOutput = 'type=oci,dest=/tmp/output.tar'; + } + + if (platformTag) { + core.saveState('platformTag', platformTag); + } const log = (message: string): void => core.info(message); const workspaceFolder = path.resolve(checkoutPath, subFolder); @@ -82,10 +143,9 @@ export async function runMain(): Promise { const resolvedImageTag = imageTag ?? 'latest'; const imageTagArray = resolvedImageTag.split(/\s*,\s*/); - const fullImageNameArray: string[] = []; - for (const tag of imageTagArray) { - fullImageNameArray.push(`${imageName}:${tag}`); - } + const fullImageNameArray = imageName + ? buildImageNames(imageName, imageTagArray, platformTag) + : []; if (imageName) { if (fullImageNameArray.length === 1) { if (!noCache && !cacheFrom.includes(fullImageNameArray[0])) { @@ -117,10 +177,10 @@ export async function runMain(): Promise { workspaceFolder, configFile, imageName: fullImageNameArray, - platform, + platform: platformTag ? undefined : platform, additionalCacheFroms: cacheFrom, userDataFolder, - output: buildxOutput, + output: platformTag ? undefined : buildxOutput, noCache, cacheTo, }; @@ -217,6 +277,9 @@ export async function runPost(): Promise { const eventFilterForPush: string[] = core.getMultilineInput('eventFilterForPush'); + const mergeTag = emptyStringAsUndefined(core.getState('mergeTag')); + const platformTag = emptyStringAsUndefined(core.getState('platformTag')); + // default to 'never' if not set and no imageName if (pushOption === 'never' || (!pushOption && !imageName)) { core.info(`Image push skipped because 'push' is set to '${pushOption}'`); @@ -262,8 +325,26 @@ export async function runPost(): Promise { return; } + if (mergeTag) { + await mergeMultiPlatformImages( + imageName, + imageTagArray, + mergeTag, + createMultiPlatformImage, + (msg: string) => core.info(msg), + ); + return; + } + const platform = emptyStringAsUndefined(core.getInput('platform')); - if (platform) { + if (platformTag) { + for (const tag of imageTagArray) { + core.info( + `Pushing platform image '${imageName}:${tag}-${platformTag}'...`, + ); + await pushImage(imageName, `${tag}-${platformTag}`); + } + } else if (platform) { for (const tag of imageTagArray) { core.info(`Copying multiplatform image '${imageName}:${tag}'...`); const imageSource = `oci-archive:/tmp/output.tar:${tag}`;