diff --git a/src/parser-includes.ts b/src/parser-includes.ts index cbb3f6aa6..b81b972df 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((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); + // 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); @@ -374,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); @@ -387,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} ${cwd}/${target}/${normalizedFile}`, + `cp ${tmpDir}/${normalizedFile} ${destDir}/`, ], 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"]); +});