Skip to content

Commit 207c148

Browse files
inistorclaude
andauthored
feat: support image.docker.platform (#1856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b08e503 commit 207c148

4 files changed

Lines changed: 61 additions & 5 deletions

File tree

src/data-expander.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export function imageComplex (data: any) {
190190
return {
191191
name: typeof data === "string" ? data : data.name,
192192
entrypoint: data.entrypoint,
193-
...(data.docker?.user ? {docker: {user: data.docker?.user}} : {}),
193+
...(data.docker?.user || data.docker?.platform ? {docker: {user: data.docker.user, platform: data.docker.platform}} : {}),
194194
};
195195
}
196196

src/job.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,7 @@ If you know what you're doing and would like to suppress this warning, use one o
670670
this._dotenvVariables = await this.initProducerReportsDotenvVariables(writeStreams, Utils.expandVariables(this._variables));
671671
const expanded = Utils.unscape$$Variables(Utils.expandVariables({...this._variables, ...this._dotenvVariables}));
672672
const imageName = this.imageName(expanded);
673+
const imagePlatform = this.imagePlatform(expanded);
673674
const helperImageName = argv.helperImage;
674675
const safeJobName = this.safeJobName;
675676

@@ -682,7 +683,7 @@ If you know what you're doing and would like to suppress this warning, use one o
682683
}
683684

684685
if (imageName) {
685-
await this.pullImage(writeStreams, imageName);
686+
await this.pullImage(writeStreams, imageName, imagePlatform);
686687

687688
const buildVolumeName = this.buildVolumeName;
688689
const tmpVolumeName = this.tmpVolumeName;
@@ -971,6 +972,11 @@ If you know what you're doing and would like to suppress this warning, use one o
971972
dockerCmd += `--user ${imageUser} `;
972973
}
973974

975+
const imagePlatform = this.imagePlatform(expanded);
976+
if (imagePlatform) {
977+
dockerCmd += `--platform ${imagePlatform} `;
978+
}
979+
974980
if (this.argv.containerEmulate) {
975981
const runnerName: string = this.argv.containerEmulate;
976982

@@ -1213,6 +1219,13 @@ If you know what you're doing and would like to suppress this warning, use one o
12131219
return Utils.expandText(image["docker"]["user"], vars);
12141220
}
12151221

1222+
private imagePlatform (vars: {[key: string]: string} = {}): string | null {
1223+
const image = this.jobData["image"];
1224+
if (!image) return null;
1225+
if (!image["docker"]) return null;
1226+
return Utils.expandText(image["docker"]["platform"], vars);
1227+
}
1228+
12161229
get imageEntrypoint (): string[] | null {
12171230
const image = this.jobData["image"];
12181231

@@ -1254,21 +1267,33 @@ If you know what you're doing and would like to suppress this warning, use one o
12541267
}
12551268
}
12561269

1257-
private async pullImage (writeStreams: WriteStreams, imageToPull: string) {
1270+
private async pullImage (writeStreams: WriteStreams, imageToPull: string, imagePlatform: string | null = null) {
12581271
const pullPolicy = this.argv.pullPolicy;
1272+
const platformArgs = imagePlatform ? ["--platform", imagePlatform] : [];
1273+
const platformSuffix = imagePlatform ? ` (${imagePlatform})` : "";
12591274
const actualPull = async () => {
12601275
await this.validateCiDependencyProxyServerAuthentication(imageToPull);
12611276
const time = process.hrtime();
1262-
await Utils.spawn([this.argv.containerExecutable, "pull", imageToPull]);
1277+
await Utils.spawn([this.argv.containerExecutable, "pull", imageToPull, ...platformArgs]);
12631278
const endTime = process.hrtime(time);
1264-
writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright pulled} ${imageToPull} in {magenta ${prettyHrtime(endTime)}}\n`);
1279+
writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright pulled} ${imageToPull}${platformSuffix} in {magenta ${prettyHrtime(endTime)}}\n`);
12651280
this.refreshLongRunningSilentTimeout(writeStreams);
12661281
};
12671282

12681283
if (pullPolicy === "always") {
12691284
await actualPull();
12701285
return;
12711286
}
1287+
// The `image inspect` cache check is platform-agnostic — a different-arch
1288+
// variant of the same image name will satisfy it, causing the requested
1289+
// platform to silently differ from what's actually on disk. Force a pull
1290+
// when a specific platform was requested so the matching manifest is
1291+
// guaranteed locally. Pulls are idempotent: if the variant is already
1292+
// cached, `docker pull` short-circuits with "Image is up to date".
1293+
if (imagePlatform) {
1294+
await actualPull();
1295+
return;
1296+
}
12721297
try {
12731298
await Utils.spawn([this.argv.containerExecutable, "image", "inspect", imageToPull]);
12741299
} catch {

tests/test-cases/image/.gitlab-ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ image-user:
6464
script:
6565
- id -u
6666

67+
image-platform:
68+
image:
69+
name: alpine
70+
docker:
71+
platform: linux/amd64
72+
script:
73+
- uname -m
74+
6775
image-entrypoint-with-variables:
6876
variables:
6977
FOO: BAR

tests/test-cases/image/integration.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,29 @@ test.concurrent("image <image-user>", async () => {
128128
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
129129
});
130130

131+
test.concurrent("image <image-platform>", async () => {
132+
const writeStreams = new WriteStreamsMock();
133+
134+
await handler({
135+
cwd: "tests/test-cases/image",
136+
job: ["image-platform"],
137+
stateDir: ".gitlab-ci-local-image-platform",
138+
}, writeStreams);
139+
140+
// The "pulled" log line includes the requested platform, proving --platform
141+
// was forwarded to `docker pull` rather than silently dropped. Use `.*` to
142+
// span the chalk ANSI escape codes between tokens. We deliberately don't
143+
// pass `pullPolicy: "always"` — the platform-aware short-circuit inside
144+
// pullImage forces a pull whenever a platform is set, regardless of whether
145+
// a different-arch variant of the same image happens to be cached locally.
146+
expect(writeStreams.stdoutLines.join("\n")).toMatch(/pulled.*alpine.*linux\/amd64/);
147+
148+
// The job runs successfully on the host (linux/amd64 matches the CI runner arch).
149+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining([
150+
chalk`{blueBright image-platform} {greenBright >} x86_64`,
151+
]));
152+
});
153+
131154
test.concurrent("pull invalid image", async () => {
132155
const jobs: Job[] = [];
133156
const writeStreams = new WriteStreamsMock();

0 commit comments

Comments
 (0)