From 68f7a62097fcf6a0189b6d893f1c9cd8c284acde Mon Sep 17 00:00:00 2001 From: Ion Nistor Date: Sat, 9 May 2026 16:14:52 +0300 Subject: [PATCH 1/2] feat: support image.docker.platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for `image.docker.platform` per GitLab CI spec (introduced in GitLab 18.0). The platform string is forwarded to both: - `docker pull --platform ` to fetch the requested manifest - `docker run --platform ` so the container runs against that platform even if a multi-arch image was already cached locally Mirrors the existing `image.docker.user` plumbing. Refs https://docs.gitlab.com/ci/yaml/#imagedockerplatform — supersedes the abandoned attempt in #1595 (closed for missing tests + style). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/data-expander.ts | 2 +- src/job.ts | 23 ++++++++++++++++++---- tests/test-cases/image/.gitlab-ci.yml | 8 ++++++++ tests/test-cases/image/integration.test.ts | 21 ++++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/data-expander.ts b/src/data-expander.ts index 4a3241ec3..71d873445 100644 --- a/src/data-expander.ts +++ b/src/data-expander.ts @@ -188,7 +188,7 @@ export function imageComplex (data: any) { return { name: typeof data === "string" ? data : data.name, entrypoint: data.entrypoint, - ...(data.docker?.user ? {docker: {user: data.docker?.user}} : {}), + ...(data.docker?.user || data.docker?.platform ? {docker: {user: data.docker.user, platform: data.docker.platform}} : {}), }; } diff --git a/src/job.ts b/src/job.ts index 4d2548ae0..a9a65da5d 100644 --- a/src/job.ts +++ b/src/job.ts @@ -670,6 +670,7 @@ If you know what you're doing and would like to suppress this warning, use one o this._dotenvVariables = await this.initProducerReportsDotenvVariables(writeStreams, Utils.expandVariables(this._variables)); const expanded = Utils.unscape$$Variables(Utils.expandVariables({...this._variables, ...this._dotenvVariables})); const imageName = this.imageName(expanded); + const imagePlatform = this.imagePlatform(expanded); const helperImageName = argv.helperImage; const safeJobName = this.safeJobName; @@ -682,7 +683,7 @@ If you know what you're doing and would like to suppress this warning, use one o } if (imageName) { - await this.pullImage(writeStreams, imageName); + await this.pullImage(writeStreams, imageName, imagePlatform); const buildVolumeName = this.buildVolumeName; 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 dockerCmd += `--user ${imageUser} `; } + const imagePlatform = this.imagePlatform(expanded); + if (imagePlatform) { + dockerCmd += `--platform ${imagePlatform} `; + } + if (this.argv.containerEmulate) { const runnerName: string = this.argv.containerEmulate; @@ -1207,6 +1213,13 @@ If you know what you're doing and would like to suppress this warning, use one o return Utils.expandText(image["docker"]["user"], vars); } + private imagePlatform (vars: {[key: string]: string} = {}): string | null { + const image = this.jobData["image"]; + if (!image) return null; + if (!image["docker"]) return null; + return Utils.expandText(image["docker"]["platform"], vars); + } + get imageEntrypoint (): string[] | null { const image = this.jobData["image"]; @@ -1235,14 +1248,16 @@ If you know what you're doing and would like to suppress this warning, use one o } } - private async pullImage (writeStreams: WriteStreams, imageToPull: string) { + private async pullImage (writeStreams: WriteStreams, imageToPull: string, imagePlatform: string | null = null) { const pullPolicy = this.argv.pullPolicy; + const platformArgs = imagePlatform ? ["--platform", imagePlatform] : []; + const platformSuffix = imagePlatform ? ` (${imagePlatform})` : ""; const actualPull = async () => { await this.validateCiDependencyProxyServerAuthentication(imageToPull); const time = process.hrtime(); - await Utils.spawn([this.argv.containerExecutable, "pull", imageToPull]); + await Utils.spawn([this.argv.containerExecutable, "pull", imageToPull, ...platformArgs]); const endTime = process.hrtime(time); - writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright pulled} ${imageToPull} in {magenta ${prettyHrtime(endTime)}}\n`); + writeStreams.stdout(chalk`${this.formattedJobName} {magentaBright pulled} ${imageToPull}${platformSuffix} in {magenta ${prettyHrtime(endTime)}}\n`); this.refreshLongRunningSilentTimeout(writeStreams); }; diff --git a/tests/test-cases/image/.gitlab-ci.yml b/tests/test-cases/image/.gitlab-ci.yml index 99941b897..89efc1f71 100644 --- a/tests/test-cases/image/.gitlab-ci.yml +++ b/tests/test-cases/image/.gitlab-ci.yml @@ -64,6 +64,14 @@ image-user: script: - id -u +image-platform: + image: + name: alpine + docker: + platform: linux/amd64 + script: + - uname -m + image-entrypoint-with-variables: variables: FOO: BAR diff --git a/tests/test-cases/image/integration.test.ts b/tests/test-cases/image/integration.test.ts index 04ffff3c0..9e6ac6f46 100644 --- a/tests/test-cases/image/integration.test.ts +++ b/tests/test-cases/image/integration.test.ts @@ -128,6 +128,27 @@ test.concurrent("image ", async () => { expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected)); }); +test.concurrent("image ", async () => { + const writeStreams = new WriteStreamsMock(); + + await handler({ + pullPolicy: "always", + cwd: "tests/test-cases/image", + job: ["image-platform"], + stateDir: ".gitlab-ci-local-image-platform", + }, writeStreams); + + // The "pulled" log line includes the requested platform, proving --platform + // was forwarded to `docker pull` rather than silently dropped. Use `.*` to + // span the chalk ANSI escape codes between tokens. + expect(writeStreams.stdoutLines.join("\n")).toMatch(/pulled.*alpine.*linux\/amd64/); + + // The job runs successfully on the host (linux/amd64 matches the CI runner arch). + expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining([ + chalk`{blueBright image-platform} {greenBright >} x86_64`, + ])); +}); + test.concurrent("pull invalid image", async () => { const jobs: Job[] = []; const writeStreams = new WriteStreamsMock(); From 5fc70f4aeb3b42aa02ca9f398d7b43ecfa9586c9 Mon Sep 17 00:00:00 2001 From: Ion Nistor Date: Sat, 9 May 2026 17:09:10 +0300 Subject: [PATCH 2/2] fix(pullImage): force pull when platform is requested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cubic flagged on PR #1856 that the `image inspect` cache-check inside pullImage is platform-agnostic — a different-arch variant of the same image name will satisfy it, so the requested platform silently diverges from what's actually on disk. Fix: short-circuit the cache check when `imagePlatform` is set and always invoke the explicit pull (which then carries `--platform` to docker). Pulls are idempotent: if the requested variant is already local, `docker pull` returns "Image is up to date" quickly. Tightens the integration test by dropping `pullPolicy: "always"` — the new short-circuit is now the path under test. Identified by cubic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/job.ts | 10 ++++++++++ tests/test-cases/image/integration.test.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/job.ts b/src/job.ts index a9a65da5d..c18f1c88c 100644 --- a/src/job.ts +++ b/src/job.ts @@ -1265,6 +1265,16 @@ If you know what you're doing and would like to suppress this warning, use one o await actualPull(); return; } + // The `image inspect` cache check is platform-agnostic — a different-arch + // variant of the same image name will satisfy it, causing the requested + // platform to silently differ from what's actually on disk. Force a pull + // when a specific platform was requested so the matching manifest is + // guaranteed locally. Pulls are idempotent: if the variant is already + // cached, `docker pull` short-circuits with "Image is up to date". + if (imagePlatform) { + await actualPull(); + return; + } try { await Utils.spawn([this.argv.containerExecutable, "image", "inspect", imageToPull]); } catch { diff --git a/tests/test-cases/image/integration.test.ts b/tests/test-cases/image/integration.test.ts index 9e6ac6f46..cb345f629 100644 --- a/tests/test-cases/image/integration.test.ts +++ b/tests/test-cases/image/integration.test.ts @@ -132,7 +132,6 @@ test.concurrent("image ", async () => { const writeStreams = new WriteStreamsMock(); await handler({ - pullPolicy: "always", cwd: "tests/test-cases/image", job: ["image-platform"], stateDir: ".gitlab-ci-local-image-platform", @@ -140,7 +139,10 @@ test.concurrent("image ", async () => { // The "pulled" log line includes the requested platform, proving --platform // was forwarded to `docker pull` rather than silently dropped. Use `.*` to - // span the chalk ANSI escape codes between tokens. + // span the chalk ANSI escape codes between tokens. We deliberately don't + // pass `pullPolicy: "always"` — the platform-aware short-circuit inside + // pullImage forces a pull whenever a platform is set, regardless of whether + // a different-arch variant of the same image happens to be cached locally. expect(writeStreams.stdoutLines.join("\n")).toMatch(/pulled.*alpine.*linux\/amd64/); // The job runs successfully on the host (linux/amd64 matches the CI runner arch).