Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions azdo-task/DevcontainersCi/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,18 @@ export async function pushImage(
return false;
}
}

export async function createManifest(
imageName: string,
tag: string,
platformTags: string[],
): Promise<boolean> {
console.log(`Creating multi-arch manifest for '${imageName}:${tag}'...`);
try {
await docker.createManifest(exec, imageName, tag, platformTags);
return true;
} catch (error) {
task.setResult(task.TaskResult.Failed, `${error}`);
return false;
}
}
62 changes: 55 additions & 7 deletions azdo-task/DevcontainersCi/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import {
DevContainerCliUpArgs,
} from '../../../common/src/dev-container-cli';

import {isDockerBuildXInstalled, pushImage} from './docker';
import {isDockerBuildXInstalled, pushImage, createManifest} from './docker';
import {isSkopeoInstalled, copyImage} from './skopeo';
import {exec} from './exec';

export async function runMain(): Promise<void> {
try {
task.setTaskVariable('hasRunMain', 'true');

const mergeTag = task.getInput('mergeTag');
if (mergeTag) {
console.log('mergeTag is set - skipping build (manifest merge will run in post step)');
task.setTaskVariable('mergeTag', mergeTag);
return;
}

const buildXInstalled = await isDockerBuildXInstalled();
if (!buildXInstalled) {
console.log(
Expand All @@ -40,6 +48,7 @@ export async function runMain(): Promise<void> {
const imageName = task.getInput('imageName');
const imageTag = task.getInput('imageTag');
const platform = task.getInput('platform');
const platformTag = task.getInput('platformTag');
const subFolder = task.getInput('subFolder') ?? '.';
const relativeConfigFile = task.getInput('configFile');
const runCommand = task.getInput('runCmd');
Expand All @@ -52,7 +61,7 @@ export async function runMain(): Promise<void> {
const skipContainerUserIdUpdate =
(task.getInput('skipContainerUserIdUpdate') ?? 'false') === 'true';

if (platform) {
if (platform && !platformTag) {
const skopeoInstalled = await isSkopeoInstalled();
if (!skopeoInstalled) {
console.log(
Expand All @@ -61,7 +70,16 @@ export async function runMain(): Promise<void> {
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';
} else if (platform && platformTag) {
buildxOutput = 'type=docker';
}

if (platformTag) {
task.setTaskVariable('platformTag', platformTag);
}

const log = (message: string): void => console.log(message);
const workspaceFolder = path.resolve(checkoutPath, subFolder);
Expand All @@ -72,7 +90,11 @@ export async function runMain(): Promise<void> {
const imageTagArray = resolvedImageTag.split(/\s*,\s*/);
const fullImageNameArray: string[] = [];
for (const tag of imageTagArray) {
fullImageNameArray.push(`${imageName}:${tag}`);
if (platformTag) {
fullImageNameArray.push(`${imageName}:${tag}-${platformTag}`);
} else {
fullImageNameArray.push(`${imageName}:${tag}`);
}
}
if (imageName) {
if (fullImageNameArray.length === 1) {
Expand All @@ -98,9 +120,9 @@ export async function runMain(): Promise<void> {
workspaceFolder,
configFile,
imageName: fullImageNameArray,
platform,
platform: platformTag ? undefined : platform,
additionalCacheFroms: cacheFrom,
output: buildxOutput,
output: platformTag ? undefined : buildxOutput,
noCache,
cacheTo,
};
Expand Down Expand Up @@ -192,6 +214,27 @@ export async function runPost(): Promise<void> {
const pushOnFailedBuild =
(task.getInput('pushOnFailedBuild') ?? 'false') === 'true';

const mergeTag = task.getTaskVariable('mergeTag');
if (mergeTag) {
if (!imageName) {
task.setResult(task.TaskResult.Failed, 'imageName is required for manifest merge');
return;
}
const imageTag = task.getInput('imageTag') ?? 'latest';
const imageTagArray = imageTag.split(/\s*,\s*/);
const platformTags = mergeTag.split(/\s*,\s*/);
for (const tag of imageTagArray) {
console.log(`Creating multi-arch manifest for '${imageName}:${tag}'...`);
const success = await createManifest(imageName, tag, platformTags);
if (!success) {
return;
}
}
return;
}

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}'`);
Expand Down Expand Up @@ -260,7 +303,12 @@ export async function runPost(): Promise<void> {
const imageTag = task.getInput('imageTag') ?? 'latest';
const imageTagArray = imageTag.split(/\s*,\s*/);
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}`;
Expand Down
12 changes: 12 additions & 0 deletions azdo-task/DevcontainersCi/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [{
Expand Down
81 changes: 80 additions & 1 deletion common/__tests__/docker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {parseMount} from '../src/docker';
import {parseMount, createManifest} from '../src/docker';
import {ExecFunction, ExecResult} from '../src/exec';

describe('parseMount', () => {
test('handles type,src,dst', () => {
Expand Down Expand Up @@ -58,3 +59,81 @@ describe('parseMount', () => {
expect(result.target).toBe('/my/dest');
});
});

describe('createManifest', () => {
test('should call docker buildx imagetools create with correct args for two platforms', async () => {
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});

await createManifest(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<Promise<ExecResult>, Parameters<ExecFunction>>()
.mockResolvedValue({exitCode: 1, stdout: '', stderr: 'error'});

await expect(
createManifest(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', 'linux-arm64']),
).rejects.toThrow('manifest creation failed with 1');
});

test('should handle a single platform tag', async () => {
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});

await createManifest(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<Promise<ExecResult>, Parameters<ExecFunction>>()
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});

await createManifest(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64']);
await createManifest(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',
],
{},
);
});
});
19 changes: 19 additions & 0 deletions common/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,25 @@ export async function pushImage(
}
}

export async function createManifest(
exec: ExecFunction,
imageName: string,
tag: string,
platformTags: string[],
): Promise<void> {
const args = ['buildx', 'imagetools', 'create'];
args.push('-t', `${imageName}:${tag}`);
for (const platformTag of platformTags) {
args.push(`${imageName}:${tag}-${platformTag}`);
}

const {exitCode} = await exec('docker', args, {});

if (exitCode !== 0) {
throw new Error(`manifest creation failed with ${exitCode}`);
}
}

export interface DockerMount {
type: string;
source: string;
Expand Down
2 changes: 2 additions & 0 deletions docs/azure-devops-task.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ In the example above, the devcontainer-build-run will perform the following step
| 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. |
| 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. |
| 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. |

## Outputs

Expand Down
2 changes: 2 additions & 0 deletions docs/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai
| 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. |
| 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. |
| 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. |

## Outputs

Expand Down
Loading