Skip to content

Commit c959376

Browse files
committed
feat: add Azure DevOps parity, tests, and docs for platformTag/mergeTag
- 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
1 parent 98082f9 commit c959376

File tree

7 files changed

+264
-8
lines changed

7 files changed

+264
-8
lines changed

azdo-task/DevcontainersCi/src/docker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,18 @@ export async function pushImage(
7171
return false;
7272
}
7373
}
74+
75+
export async function createManifest(
76+
imageName: string,
77+
tag: string,
78+
platformTags: string[],
79+
): Promise<boolean> {
80+
console.log(`Creating multi-arch manifest for '${imageName}:${tag}'...`);
81+
try {
82+
await docker.createManifest(exec, imageName, tag, platformTags);
83+
return true;
84+
} catch (error) {
85+
task.setResult(task.TaskResult.Failed, `${error}`);
86+
return false;
87+
}
88+
}

azdo-task/DevcontainersCi/src/main.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@ import {
99
DevContainerCliUpArgs,
1010
} from '../../../common/src/dev-container-cli';
1111

12-
import {isDockerBuildXInstalled, pushImage} from './docker';
12+
import {isDockerBuildXInstalled, pushImage, createManifest} from './docker';
1313
import {isSkopeoInstalled, copyImage} from './skopeo';
1414
import {exec} from './exec';
1515

1616
export async function runMain(): Promise<void> {
1717
try {
1818
task.setTaskVariable('hasRunMain', 'true');
19+
20+
const mergeTag = task.getInput('mergeTag');
21+
if (mergeTag) {
22+
console.log('mergeTag is set - skipping build (manifest merge will run in post step)');
23+
task.setTaskVariable('mergeTag', mergeTag);
24+
return;
25+
}
26+
1927
const buildXInstalled = await isDockerBuildXInstalled();
2028
if (!buildXInstalled) {
2129
console.log(
@@ -40,6 +48,7 @@ export async function runMain(): Promise<void> {
4048
const imageName = task.getInput('imageName');
4149
const imageTag = task.getInput('imageTag');
4250
const platform = task.getInput('platform');
51+
const platformTag = task.getInput('platformTag');
4352
const subFolder = task.getInput('subFolder') ?? '.';
4453
const relativeConfigFile = task.getInput('configFile');
4554
const runCommand = task.getInput('runCmd');
@@ -52,7 +61,7 @@ export async function runMain(): Promise<void> {
5261
const skipContainerUserIdUpdate =
5362
(task.getInput('skipContainerUserIdUpdate') ?? 'false') === 'true';
5463

55-
if (platform) {
64+
if (platform && !platformTag) {
5665
const skopeoInstalled = await isSkopeoInstalled();
5766
if (!skopeoInstalled) {
5867
console.log(
@@ -61,7 +70,16 @@ export async function runMain(): Promise<void> {
6170
return;
6271
}
6372
}
64-
const buildxOutput = platform ? 'type=oci,dest=/tmp/output.tar' : undefined;
73+
let buildxOutput: string | undefined;
74+
if (platform && !platformTag) {
75+
buildxOutput = 'type=oci,dest=/tmp/output.tar';
76+
} else if (platform && platformTag) {
77+
buildxOutput = 'type=docker';
78+
}
79+
80+
if (platformTag) {
81+
task.setTaskVariable('platformTag', platformTag);
82+
}
6583

6684
const log = (message: string): void => console.log(message);
6785
const workspaceFolder = path.resolve(checkoutPath, subFolder);
@@ -72,7 +90,11 @@ export async function runMain(): Promise<void> {
7290
const imageTagArray = resolvedImageTag.split(/\s*,\s*/);
7391
const fullImageNameArray: string[] = [];
7492
for (const tag of imageTagArray) {
75-
fullImageNameArray.push(`${imageName}:${tag}`);
93+
if (platformTag) {
94+
fullImageNameArray.push(`${imageName}:${tag}-${platformTag}`);
95+
} else {
96+
fullImageNameArray.push(`${imageName}:${tag}`);
97+
}
7698
}
7799
if (imageName) {
78100
if (fullImageNameArray.length === 1) {
@@ -98,9 +120,9 @@ export async function runMain(): Promise<void> {
98120
workspaceFolder,
99121
configFile,
100122
imageName: fullImageNameArray,
101-
platform,
123+
platform: platformTag ? undefined : platform,
102124
additionalCacheFroms: cacheFrom,
103-
output: buildxOutput,
125+
output: platformTag ? undefined : buildxOutput,
104126
noCache,
105127
cacheTo,
106128
};
@@ -192,6 +214,27 @@ export async function runPost(): Promise<void> {
192214
const pushOnFailedBuild =
193215
(task.getInput('pushOnFailedBuild') ?? 'false') === 'true';
194216

217+
const mergeTag = task.getTaskVariable('mergeTag');
218+
if (mergeTag) {
219+
if (!imageName) {
220+
task.setResult(task.TaskResult.Failed, 'imageName is required for manifest merge');
221+
return;
222+
}
223+
const imageTag = task.getInput('imageTag') ?? 'latest';
224+
const imageTagArray = imageTag.split(/\s*,\s*/);
225+
const platformTags = mergeTag.split(/\s*,\s*/);
226+
for (const tag of imageTagArray) {
227+
console.log(`Creating multi-arch manifest for '${imageName}:${tag}'...`);
228+
const success = await createManifest(imageName, tag, platformTags);
229+
if (!success) {
230+
return;
231+
}
232+
}
233+
return;
234+
}
235+
236+
const platformTag = task.getTaskVariable('platformTag');
237+
195238
// default to 'never' if not set and no imageName
196239
if (pushOption === 'never' || (!pushOption && !imageName)) {
197240
console.log(`Image push skipped because 'push' is set to '${pushOption}'`);
@@ -260,7 +303,12 @@ export async function runPost(): Promise<void> {
260303
const imageTag = task.getInput('imageTag') ?? 'latest';
261304
const imageTagArray = imageTag.split(/\s*,\s*/);
262305
const platform = task.getInput('platform');
263-
if (platform) {
306+
if (platformTag) {
307+
for (const tag of imageTagArray) {
308+
console.log(`Pushing platform image '${imageName}:${tag}-${platformTag}'...`);
309+
await pushImage(imageName, `${tag}-${platformTag}`);
310+
}
311+
} else if (platform) {
264312
for (const tag of imageTagArray) {
265313
console.log(`Copying multiplatform image '${imageName}:${tag}'...`);
266314
const imageSource = `oci-archive:/tmp/output.tar:${tag}`;

azdo-task/DevcontainersCi/task.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@
128128
"type": "multiLine",
129129
"label": "Specify the image to cache the built image to",
130130
"required": false
131+
},
132+
{
133+
"name": "platformTag",
134+
"type": "string",
135+
"label": "Tag suffix for this platform build (e.g., 'linux-amd64'). Used in matrix builds to push per-platform images that are later merged.",
136+
"required": false
137+
},
138+
{
139+
"name": "mergeTag",
140+
"type": "string",
141+
"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.",
142+
"required": false
131143
}
132144
],
133145
"outputVariables": [{

common/__tests__/docker.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {parseMount} from '../src/docker';
1+
import {parseMount, createManifest} from '../src/docker';
2+
import {ExecFunction, ExecResult} from '../src/exec';
23

34
describe('parseMount', () => {
45
test('handles type,src,dst', () => {
@@ -58,3 +59,81 @@ describe('parseMount', () => {
5859
expect(result.target).toBe('/my/dest');
5960
});
6061
});
62+
63+
describe('createManifest', () => {
64+
test('should call docker buildx imagetools create with correct args for two platforms', async () => {
65+
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
66+
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});
67+
68+
await createManifest(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', 'linux-arm64']);
69+
70+
expect(mockExec).toHaveBeenCalledTimes(1);
71+
expect(mockExec).toHaveBeenCalledWith(
72+
'docker',
73+
[
74+
'buildx', 'imagetools', 'create',
75+
'-t', 'ghcr.io/my-org/my-image:v1.0.0',
76+
'ghcr.io/my-org/my-image:v1.0.0-linux-amd64',
77+
'ghcr.io/my-org/my-image:v1.0.0-linux-arm64',
78+
],
79+
{},
80+
);
81+
});
82+
83+
test('should throw when docker command returns non-zero exit code', async () => {
84+
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
85+
.mockResolvedValue({exitCode: 1, stdout: '', stderr: 'error'});
86+
87+
await expect(
88+
createManifest(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64', 'linux-arm64']),
89+
).rejects.toThrow('manifest creation failed with 1');
90+
});
91+
92+
test('should handle a single platform tag', async () => {
93+
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
94+
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});
95+
96+
await createManifest(mockExec, 'ghcr.io/my-org/my-image', 'latest', ['linux-amd64']);
97+
98+
expect(mockExec).toHaveBeenCalledTimes(1);
99+
expect(mockExec).toHaveBeenCalledWith(
100+
'docker',
101+
[
102+
'buildx', 'imagetools', 'create',
103+
'-t', 'ghcr.io/my-org/my-image:latest',
104+
'ghcr.io/my-org/my-image:latest-linux-amd64',
105+
],
106+
{},
107+
);
108+
});
109+
110+
test('should handle multiple image tags', async () => {
111+
const mockExec = jest.fn<Promise<ExecResult>, Parameters<ExecFunction>>()
112+
.mockResolvedValue({exitCode: 0, stdout: '', stderr: ''});
113+
114+
await createManifest(mockExec, 'ghcr.io/my-org/my-image', 'v1.0.0', ['linux-amd64']);
115+
await createManifest(mockExec, 'ghcr.io/my-org/my-image', 'latest', ['linux-amd64']);
116+
117+
expect(mockExec).toHaveBeenCalledTimes(2);
118+
expect(mockExec).toHaveBeenNthCalledWith(
119+
1,
120+
'docker',
121+
[
122+
'buildx', 'imagetools', 'create',
123+
'-t', 'ghcr.io/my-org/my-image:v1.0.0',
124+
'ghcr.io/my-org/my-image:v1.0.0-linux-amd64',
125+
],
126+
{},
127+
);
128+
expect(mockExec).toHaveBeenNthCalledWith(
129+
2,
130+
'docker',
131+
[
132+
'buildx', 'imagetools', 'create',
133+
'-t', 'ghcr.io/my-org/my-image:latest',
134+
'ghcr.io/my-org/my-image:latest-linux-amd64',
135+
],
136+
{},
137+
);
138+
});
139+
});

docs/azure-devops-task.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ In the example above, the devcontainer-build-run will perform the following step
8484
| noCache | false | Builds the image with `--no-cache` (takes precedence over `cacheFrom`) |
8585
| cacheTo | false | Specify the image to cache the built image to |
8686
| 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. |
87+
| 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. |
88+
| 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. |
8789

8890
## Outputs
8991

docs/github-action.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ The [`devcontainers/ci` action](https://github.com/marketplace/actions/devcontai
143143
| noCache | false | Builds the image with `--no-cache` (takes precedence over `cacheFrom`) |
144144
| cacheTo | false | Specify the image to cache the built image to |
145145
| 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. |
146+
| 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. |
147+
| 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. |
146148

147149
## Outputs
148150

docs/multi-platform-builds.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,101 @@ jobs:
7272
imageName: UserNameHere/ImageNameHere
7373
platform: linux/amd64,linux/arm64
7474
```
75+
76+
## Native Multi-Platform Builds (Matrix Strategy)
77+
78+
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.
79+
80+
### Benefits
81+
82+
- **Faster builds** -- no emulation overhead since each runner compiles natively.
83+
- **More reliable** -- native compilation avoids QEMU compatibility issues.
84+
- **Flexible runners** -- works with GitHub's hosted ARM runners (`ubuntu-24.04-arm`) or self-hosted ARM agents.
85+
86+
### GitHub Actions Example
87+
88+
```yaml
89+
jobs:
90+
build:
91+
strategy:
92+
matrix:
93+
include:
94+
- runner: ubuntu-latest
95+
platform: linux/amd64
96+
platformTag: linux-amd64
97+
- runner: ubuntu-24.04-arm
98+
platform: linux/arm64
99+
platformTag: linux-arm64
100+
runs-on: ${{ matrix.runner }}
101+
steps:
102+
- uses: actions/checkout@v4
103+
- uses: docker/login-action@v3
104+
with:
105+
registry: ghcr.io
106+
username: ${{ github.actor }}
107+
password: ${{ secrets.GITHUB_TOKEN }}
108+
- uses: docker/setup-buildx-action@v3
109+
- uses: devcontainers/ci@v0.3
110+
with:
111+
imageName: ghcr.io/example/myimage
112+
platform: ${{ matrix.platform }}
113+
platformTag: ${{ matrix.platformTag }}
114+
push: always
115+
116+
manifest:
117+
needs: build
118+
runs-on: ubuntu-latest
119+
steps:
120+
- uses: actions/checkout@v4
121+
- uses: docker/login-action@v3
122+
with:
123+
registry: ghcr.io
124+
username: ${{ github.actor }}
125+
password: ${{ secrets.GITHUB_TOKEN }}
126+
- uses: docker/setup-buildx-action@v3
127+
- uses: devcontainers/ci@v0.3
128+
with:
129+
imageName: ghcr.io/example/myimage
130+
mergeTag: linux-amd64,linux-arm64
131+
```
132+
133+
### Azure DevOps Pipelines Example
134+
135+
```yaml
136+
stages:
137+
- stage: Build
138+
jobs:
139+
- job: BuildAmd64
140+
pool:
141+
vmImage: ubuntu-latest
142+
steps:
143+
- task: DevcontainersCi@0
144+
inputs:
145+
imageName: myregistry.azurecr.io/devcontainer
146+
platform: linux/amd64
147+
platformTag: linux-amd64
148+
push: always
149+
150+
- job: BuildArm64
151+
pool:
152+
vmImage: ubuntu-latest
153+
steps:
154+
- task: DevcontainersCi@0
155+
inputs:
156+
imageName: myregistry.azurecr.io/devcontainer
157+
platform: linux/arm64
158+
platformTag: linux-arm64
159+
push: always
160+
161+
- stage: Manifest
162+
dependsOn: Build
163+
jobs:
164+
- job: MergeManifest
165+
pool:
166+
vmImage: ubuntu-latest
167+
steps:
168+
- task: DevcontainersCi@0
169+
inputs:
170+
imageName: myregistry.azurecr.io/devcontainer
171+
mergeTag: linux-amd64,linux-arm64
172+
```

0 commit comments

Comments
 (0)