From 1f6e4ad65b17f6d762ce885f3a78ea58b41cde9f Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Tue, 4 Aug 2026 14:52:15 +0200 Subject: [PATCH 1/2] fix: support wildcard file paths in project includes The glob expanded to several files but cp targeted a literal destination path, and the later load used that same literal path instead of the matches. --- src/parser-includes.ts | 20 ++++++++------ .../.gitlab-ci.yml | 7 +++++ .../integration.test.ts | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 tests/test-cases/include-project-file-wildcard/.gitlab-ci.yml create mode 100644 tests/test-cases/include-project-file-wildcard/integration.test.ts diff --git a/src/parser-includes.ts b/src/parser-includes.ts index cbb3f6aa6..ad24c55c6 100644 --- a/src/parser-includes.ts +++ b/src/parser-includes.ts @@ -11,6 +11,7 @@ import path from "node:path"; import prettyHrtime from "pretty-hrtime"; import semver from "semver"; import {RE2JS} from "re2js"; +import {globbySync} from "globby"; type ParserIncludesInitOptions = { argv: Argv; @@ -147,13 +148,16 @@ export class ParserIncludes { } else if (value["project"]) { for (const fileValue of Array.isArray(value["file"]) ? value["file"] : [value["file"]]) { const mergedInputs = {...(value.inputs ?? {}), ...globalInputs}; - const fileDoc = await Parser.loadYaml( - `${cwd}/${stateDir}/includes/${gitData.remote.host}/${value["project"]}/${value["ref"] || "HEAD"}/${fileValue}` - , {inputs: mergedInputs} - , expandVariables, writeStreams); - // Expand local includes inside a "project"-like include - fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], value["project"], value["ref"], opts); - includeDatas = includeDatas.concat(await this.init(fileDoc, opts)); + const includeDir = `${cwd}/${stateDir}/includes/${gitData.remote.host}/${value["project"]}/${value["ref"] || "HEAD"}`; + const normalizedFile = fileValue.replace(/^\/+/, ""); + const matches = globbySync(normalizedFile, {cwd: includeDir, absolute: true}).sort(); + const filePaths = matches.length > 0 ? matches : [`${includeDir}/${normalizedFile}`]; + for (const filePath of filePaths) { + const fileDoc = await Parser.loadYaml(filePath, {inputs: mergedInputs}, expandVariables, writeStreams); + // Expand local includes inside a "project"-like include + fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], value["project"], value["ref"], opts); + includeDatas = includeDatas.concat(await this.init(fileDoc, opts)); + } } } else if (value["component"]) { const component = componentParseCache.get(index); @@ -387,7 +391,7 @@ export class ParserIncludes { `git sparse-checkout set --no-cone ${normalizedFile}`, isCommitSha ? "git checkout FETCH_HEAD" : "git checkout", `cd ${cwd}/${stateDir}`, - `cp ${tmpDir}/${normalizedFile} ${cwd}/${target}/${normalizedFile}`, + `cp ${tmpDir}/${normalizedFile} ${path.dirname(`${cwd}/${target}/${normalizedFile}`)}/`, ], cwd); } else { await fs.mkdirp(`${cwd}/${target}`); diff --git a/tests/test-cases/include-project-file-wildcard/.gitlab-ci.yml b/tests/test-cases/include-project-file-wildcard/.gitlab-ci.yml new file mode 100644 index 000000000..e820a2eda --- /dev/null +++ b/tests/test-cases/include-project-file-wildcard/.gitlab-ci.yml @@ -0,0 +1,7 @@ +--- +# templates/[bt]*.yml matches both templates/build.yml and templates/test.yml, +# so the glob expands to more than one file. +include: + - project: components/go + ref: main + file: templates/[bt]*.yml diff --git a/tests/test-cases/include-project-file-wildcard/integration.test.ts b/tests/test-cases/include-project-file-wildcard/integration.test.ts new file mode 100644 index 000000000..39e9ad2a0 --- /dev/null +++ b/tests/test-cases/include-project-file-wildcard/integration.test.ts @@ -0,0 +1,26 @@ +import {WriteStreamsMock} from "../../../src/write-streams.js"; +import {handler} from "../../../src/handler.js"; +import {initSpawnSpy} from "../../mocks/utils.mock.js"; +import {WhenStatics} from "../../mocks/when-statics.js"; + +beforeAll(() => { + initSpawnSpy([...WhenStatics.all, WhenStatics.mockGitRemoteHttp]); +}); + +test.concurrent("include:project with a wildcard file path includes every match", async () => { + const writeStreams = new WriteStreamsMock(); + + await handler({ + cwd: "tests/test-cases/include-project-file-wildcard", + noColor: true, + list: true, + stateDir: ".gitlab-ci-local-include-project-file-wildcard", + }, writeStreams); + + const jobNames = writeStreams.stdoutLines + .filter(l => /^(build|test)\s/.test(l)) + .map(l => l.split(/\s+/)[0]) + .sort(); + + expect(jobNames).toEqual(["build", "test"]); +}); From 4ace62137fa87737c813b63c5033934478069ba9 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Tue, 4 Aug 2026 15:03:28 +0200 Subject: [PATCH 2/2] fix: extract the copy destination and sort matches explicitly --- src/parser-includes.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/parser-includes.ts b/src/parser-includes.ts index ad24c55c6..b81b972df 100644 --- a/src/parser-includes.ts +++ b/src/parser-includes.ts @@ -150,7 +150,7 @@ export class ParserIncludes { const mergedInputs = {...(value.inputs ?? {}), ...globalInputs}; const includeDir = `${cwd}/${stateDir}/includes/${gitData.remote.host}/${value["project"]}/${value["ref"] || "HEAD"}`; const normalizedFile = fileValue.replace(/^\/+/, ""); - const matches = globbySync(normalizedFile, {cwd: includeDir, absolute: true}).sort(); + const matches = globbySync(normalizedFile, {cwd: includeDir, absolute: true}).sort((a, b) => a.localeCompare(b)); const filePaths = matches.length > 0 ? matches : [`${includeDir}/${normalizedFile}`]; for (const filePath of filePaths) { const fileDoc = await Parser.loadYaml(filePath, {inputs: mergedInputs}, expandVariables, writeStreams); @@ -378,7 +378,8 @@ export class ParserIncludes { if (remote.schema.startsWith("http")) { const ext = "tmp-" + Math.random(); - await fs.mkdirp(path.dirname(`${cwd}/${target}/${normalizedFile}`)); + const destDir = path.dirname(`${cwd}/${target}/${normalizedFile}`); + await fs.mkdirp(destDir); tmpDir = `${cwd}/${target}.${ext}`; const isCommitSha = /^[0-9a-f]{40}$/i.test(ref); @@ -391,7 +392,7 @@ export class ParserIncludes { `git sparse-checkout set --no-cone ${normalizedFile}`, isCommitSha ? "git checkout FETCH_HEAD" : "git checkout", `cd ${cwd}/${stateDir}`, - `cp ${tmpDir}/${normalizedFile} ${path.dirname(`${cwd}/${target}/${normalizedFile}`)}/`, + `cp ${tmpDir}/${normalizedFile} ${destDir}/`, ], cwd); } else { await fs.mkdirp(`${cwd}/${target}`);