Skip to content

Commit 17fd367

Browse files
committed
feat: add native ARM matrix build support
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.
1 parent aefa886 commit 17fd367

4 files changed

Lines changed: 93 additions & 17 deletions

File tree

action.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ inputs:
6666
cacheTo:
6767
required: false
6868
description: Specify the image to cache the built image to
69+
platformTag:
70+
required: false
71+
description: 'Tag suffix for this platform build (e.g., "linux-amd64"). Used in matrix builds to push per-platform images that are later merged.'
72+
mergeTag:
73+
required: false
74+
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.'
6975
outputs:
7076
runCmdOutput:
7177
description: The output of the command specified in the runCmd input

common/src/docker.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,25 @@ export async function pushImage(
337337
}
338338
}
339339

340+
export async function createManifest(
341+
exec: ExecFunction,
342+
imageName: string,
343+
tag: string,
344+
platformTags: string[],
345+
): Promise<void> {
346+
const args = ['buildx', 'imagetools', 'create'];
347+
args.push('-t', `${imageName}:${tag}`);
348+
for (const platformTag of platformTags) {
349+
args.push(`${imageName}:${tag}-${platformTag}`);
350+
}
351+
352+
const {exitCode} = await exec('docker', args, {});
353+
354+
if (exitCode !== 0) {
355+
throw new Error(`manifest creation failed with ${exitCode}`);
356+
}
357+
}
358+
340359
export interface DockerMount {
341360
type: string;
342361
source: string;

github-action/src/docker.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,20 @@ export async function pushImage(
7777
core.endGroup();
7878
}
7979
}
80+
81+
export async function createManifest(
82+
imageName: string,
83+
tag: string,
84+
platformTags: string[],
85+
): Promise<boolean> {
86+
core.startGroup(`📦 Creating multi-arch manifest for '${imageName}:${tag}'...`);
87+
try {
88+
await docker.createManifest(exec, imageName, tag, platformTags);
89+
return true;
90+
} catch (error) {
91+
core.setFailed(error);
92+
return false;
93+
} finally {
94+
core.endGroup();
95+
}
96+
}

github-action/src/main.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ 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 {populateDefaults} from '../../common/src/envvars';
1515

@@ -26,6 +26,14 @@ export async function runMain(): Promise<void> {
2626
try {
2727
core.info('Starting...');
2828
core.saveState('hasRunMain', 'true');
29+
30+
const mergeTag = emptyStringAsUndefined(core.getInput('mergeTag'));
31+
if (mergeTag) {
32+
core.info('mergeTag is set - skipping build (manifest merge will run in post step)');
33+
core.saveState('mergeTag', mergeTag);
34+
return;
35+
}
36+
2937
const buildXInstalled = await isDockerBuildXInstalled();
3038
if (!buildXInstalled) {
3139
core.warning(
@@ -47,6 +55,7 @@ export async function runMain(): Promise<void> {
4755
const imageName = emptyStringAsUndefined(core.getInput('imageName'));
4856
const imageTag = emptyStringAsUndefined(core.getInput('imageTag'));
4957
const platform = emptyStringAsUndefined(core.getInput('platform'));
58+
const platformTag = emptyStringAsUndefined(core.getInput('platformTag'));
5059
const subFolder: string = core.getInput('subFolder');
5160
const relativeConfigFile = emptyStringAsUndefined(
5261
core.getInput('configFile'),
@@ -64,7 +73,7 @@ export async function runMain(): Promise<void> {
6473
const userDataFolder: string = core.getInput('userDataFolder');
6574
const mounts: string[] = core.getMultilineInput('mounts');
6675

67-
if (platform) {
76+
if (platform && !platformTag) {
6877
const skopeoInstalled = await isSkopeoInstalled();
6978
if (!skopeoInstalled) {
7079
core.warning(
@@ -73,7 +82,11 @@ export async function runMain(): Promise<void> {
7382
return;
7483
}
7584
}
76-
const buildxOutput = platform ? 'type=oci,dest=/tmp/output.tar' : undefined;
85+
const buildxOutput = platform && !platformTag ? 'type=oci,dest=/tmp/output.tar' : undefined;
86+
87+
if (platformTag) {
88+
core.saveState('platformTag', platformTag);
89+
}
7790

7891
const log = (message: string): void => core.info(message);
7992
const workspaceFolder = path.resolve(checkoutPath, subFolder);
@@ -84,23 +97,21 @@ export async function runMain(): Promise<void> {
8497
const imageTagArray = resolvedImageTag.split(/\s*,\s*/);
8598
const fullImageNameArray: string[] = [];
8699
for (const tag of imageTagArray) {
87-
fullImageNameArray.push(`${imageName}:${tag}`);
100+
if (platformTag) {
101+
fullImageNameArray.push(`${imageName}:${tag}-${platformTag}`);
102+
} else {
103+
fullImageNameArray.push(`${imageName}:${tag}`);
104+
}
88105
}
89106
if (imageName) {
90107
if (fullImageNameArray.length === 1) {
91108
if (!noCache && !cacheFrom.includes(fullImageNameArray[0])) {
92-
// If the cacheFrom options don't include the fullImageName, add it here
93-
// This ensures that when building a PR where the image specified in the action
94-
// isn't included in devcontainer.json (or docker-compose.yml), the action still
95-
// resolves a previous image for the tag as a layer cache (if pushed to a registry)
96-
97109
core.info(
98110
`Adding --cache-from ${fullImageNameArray[0]} to build args`,
99111
);
100112
cacheFrom.splice(0, 0, fullImageNameArray[0]);
101113
}
102114
} else {
103-
// Don't automatically add --cache-from if multiple image tags are specified
104115
core.info(
105116
'Not adding --cache-from automatically since multiple image tags were supplied',
106117
);
@@ -217,18 +228,37 @@ export async function runPost(): Promise<void> {
217228
const eventFilterForPush: string[] =
218229
core.getMultilineInput('eventFilterForPush');
219230

220-
// default to 'never' if not set and no imageName
231+
const mergeTag = emptyStringAsUndefined(core.getState('mergeTag'));
232+
if (mergeTag) {
233+
if (!imageName) {
234+
core.setFailed('imageName is required for manifest merge');
235+
return;
236+
}
237+
const imageTag =
238+
emptyStringAsUndefined(core.getInput('imageTag')) ?? 'latest';
239+
const imageTagArray = imageTag.split(/\s*,\s*/);
240+
const platformTags = mergeTag.split(/\s*,\s*/);
241+
for (const tag of imageTagArray) {
242+
core.info(`Creating multi-arch manifest for '${imageName}:${tag}'...`);
243+
const success = await createManifest(imageName, tag, platformTags);
244+
if (!success) {
245+
return;
246+
}
247+
}
248+
return;
249+
}
250+
251+
const platformTag = emptyStringAsUndefined(core.getState('platformTag'));
252+
221253
if (pushOption === 'never' || (!pushOption && !imageName)) {
222254
core.info(`Image push skipped because 'push' is set to '${pushOption}'`);
223255
return;
224256
}
225257

226-
// default to 'filter' if not set and imageName is set
227258
if (pushOption === 'filter' || (!pushOption && imageName)) {
228-
// https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables
229259
const ref = process.env.GITHUB_REF;
230260
if (
231-
refFilterForPush.length !== 0 && // empty filter allows all
261+
refFilterForPush.length !== 0 &&
232262
!refFilterForPush.some(s => s === ref)
233263
) {
234264
core.info(
@@ -238,7 +268,7 @@ export async function runPost(): Promise<void> {
238268
}
239269
const eventName = process.env.GITHUB_EVENT_NAME;
240270
if (
241-
eventFilterForPush.length !== 0 && // empty filter allows all
271+
eventFilterForPush.length !== 0 &&
242272
!eventFilterForPush.some(s => s === eventName)
243273
) {
244274
core.info(
@@ -256,14 +286,18 @@ export async function runPost(): Promise<void> {
256286
const imageTagArray = imageTag.split(/\s*,\s*/);
257287
if (!imageName) {
258288
if (pushOption) {
259-
// pushOption was set (and not to "never") - give an error that imageName is required
260289
core.error('imageName is required to push images');
261290
}
262291
return;
263292
}
264293

265294
const platform = emptyStringAsUndefined(core.getInput('platform'));
266-
if (platform) {
295+
if (platformTag) {
296+
for (const tag of imageTagArray) {
297+
core.info(`Pushing platform image '${imageName}:${tag}-${platformTag}'...`);
298+
await pushImage(imageName, `${tag}-${platformTag}`);
299+
}
300+
} else if (platform) {
267301
for (const tag of imageTagArray) {
268302
core.info(`Copying multiplatform image '${imageName}:${tag}'...`);
269303
const imageSource = `oci-archive:/tmp/output.tar:${tag}`;

0 commit comments

Comments
 (0)