Skip to content

Commit 118f5d4

Browse files
committed
Fix extends resolution behavior
1 parent 90c02e9 commit 118f5d4

File tree

9 files changed

+114
-80
lines changed

9 files changed

+114
-80
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@noxify/gitlab-ci-builder": patch
3+
---
4+
5+
Fixed extends resolution behavior with `resolveTemplatesOnly: true`
6+
7+
Previously, when `resolveTemplatesOnly: true` was set (the default), ALL extends were removed after merging, including normal jobs and remote references that should have been preserved.
8+
9+
**Old behavior (incorrect):**
10+
11+
- Templates (`.prefix`) were merged ✅
12+
- Normal jobs (without `.`) were merged ❌ (should stay in extends)
13+
- Remote jobs were merged ❌ (should stay in extends)
14+
- Unknown/external jobs were merged ❌ (should stay in extends)
15+
16+
**New behavior (correct):**
17+
18+
- Templates (`.prefix`) are merged ✅
19+
- Normal jobs (without `.`) remain in extends ✅
20+
- Remote jobs remain in extends ✅
21+
- Unknown/external jobs remain in extends ✅
22+
23+
This fix enables proper GitLab CI template composition patterns, particularly for shallow jobs that use `remote: true` to reference jobs from other configurations without merging them.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"js-yaml": "4.1.1",
5252
"tinyglobby": "0.2.15",
5353
"typescript": "5.9.3",
54-
"zod": "4.1.12"
54+
"zod": "4.1.13"
5555
},
5656
"devDependencies": {
5757
"@changesets/cli": "2.29.7",
@@ -67,10 +67,10 @@
6767
"eslint-plugin-package-json": "0.85.0",
6868
"json-schema-to-typescript": "15.0.4",
6969
"jsonc-eslint-parser": "2.4.1",
70-
"memfs": "^4.51.0",
70+
"memfs": "4.51.0",
7171
"prettier": "3.6.2",
7272
"tsdown": "0.16.6",
73-
"tsx": "^4.20.6",
73+
"tsx": "4.20.6",
7474
"typescript-eslint": "8.47.0",
7575
"vitest": "4.0.13"
7676
},

pnpm-lock.yaml

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/resolution/resolver.ts

Lines changed: 71 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,66 @@ export function resolveExtends(
7979
// Start with empty definition
8080
let mergedDef: JobDefinitionNormalized = {}
8181

82+
// Collect all extends that should be kept (not merged)
83+
const keptExtends: string[] = []
84+
85+
// Recursively collect non-template extends from merged templates
86+
const collectNonTemplateExtends = (extendName: string, visited = new Set<string>()): void => {
87+
if (visited.has(extendName)) return
88+
visited.add(extendName)
89+
90+
const targetName = graph.has(extendName) ? extendName : `.${extendName}`
91+
const targetNode = graph.get(targetName)
92+
93+
if (!targetNode?.extends) return
94+
95+
for (const nestedExtend of targetNode.extends) {
96+
const nestedTargetName = graph.has(nestedExtend) ? nestedExtend : `.${nestedExtend}`
97+
const nestedNode = graph.get(nestedTargetName)
98+
99+
if (!nestedNode) {
100+
// Unknown extend, keep it
101+
if (!keptExtends.includes(nestedExtend)) {
102+
keptExtends.push(nestedExtend)
103+
}
104+
} else if (nestedNode.isRemote) {
105+
// Remote extend, keep it
106+
if (!keptExtends.includes(nestedExtend)) {
107+
keptExtends.push(nestedExtend)
108+
}
109+
} else if (!nestedTargetName.startsWith(".")) {
110+
// Normal job (not template), keep it
111+
if (!keptExtends.includes(nestedExtend)) {
112+
keptExtends.push(nestedExtend)
113+
}
114+
} else {
115+
// It's a template, recurse into it
116+
collectNonTemplateExtends(nestedExtend, visited)
117+
}
118+
}
119+
}
120+
82121
// Merge extends chain (ALWAYS resolve for merging, regardless of mergeExtends)
83122
if (node.extends.length > 0) {
84123
for (const extendName of node.extends) {
85124
// Try with and without dot prefix
86125
const targetName = graph.has(extendName) ? extendName : `.${extendName}`
87126
const targetNode = graph.get(targetName)
88127

89-
if (!targetNode) continue
128+
if (!targetNode) {
129+
// Unknown target, keep in extends
130+
keptExtends.push(extendName)
131+
continue
132+
}
90133

91134
// Check if we should merge this extend
92135
const shouldMerge = resolveTemplatesOnly ? targetName.startsWith(".") : true
93136

94-
// Skip remote extends
95-
if (targetNode.isRemote) continue
137+
// Skip remote extends from merging
138+
if (targetNode.isRemote) {
139+
keptExtends.push(extendName)
140+
continue
141+
}
96142

97143
if (shouldMerge) {
98144
// Use fully resolved definition (without extends field) for merging
@@ -102,6 +148,14 @@ export function resolveExtends(
102148
} else {
103149
mergedDef = mergeJobDefinitions(mergedDef, targetNode.definition)
104150
}
151+
152+
// If resolveTemplatesOnly and this is a template, collect non-template extends from it
153+
if (resolveTemplatesOnly && targetName.startsWith(".")) {
154+
collectNonTemplateExtends(extendName)
155+
}
156+
} else {
157+
// Don't merge, but keep in extends (normal job when resolveTemplatesOnly: true)
158+
keptExtends.push(extendName)
105159
}
106160
}
107161
}
@@ -119,10 +173,21 @@ export function resolveExtends(
119173
continue
120174
}
121175

122-
// Clean up extends field
123-
const cleanedDef = cleanExtendsField(finalDef, node, graph, globalOptions, jobOpts)
176+
// Add kept extends to final definition (or remove extends entirely if none kept)
177+
let outputDef: JobDefinitionOutput
178+
if (keptExtends.length > 0) {
179+
if (keptExtends.length === 1) {
180+
outputDef = { ...finalDef, extends: keptExtends[0] }
181+
} else {
182+
outputDef = { ...finalDef, extends: keptExtends }
183+
}
184+
} else {
185+
// Remove extends field entirely if nothing to keep
186+
const { extends: _extends, ...rest } = finalDef
187+
outputDef = rest as JobDefinitionOutput
188+
}
124189

125-
resolved.set(name, cleanedDef)
190+
resolved.set(name, outputDef)
126191
}
127192

128193
// Convert Map back to Record
@@ -138,61 +203,3 @@ export function resolveExtends(
138203
skippedChecks: context.skippedChecks,
139204
}
140205
}
141-
142-
/**
143-
* Clean extends field after resolution
144-
* Remove local extends, keep only remote/external ones
145-
*/
146-
function cleanExtendsField(
147-
definition: JobDefinitionNormalized,
148-
node: { extends: string[]; isRemote: boolean },
149-
graph: Map<string, { isRemote: boolean }>,
150-
globalOptions: GlobalOptions,
151-
jobOpts?: JobOptions,
152-
): JobDefinitionOutput {
153-
const mergeExtends = jobOpts?.mergeExtends ?? globalOptions.mergeExtends
154-
155-
if (mergeExtends === false) {
156-
// Keep extends as-is, just optimize to string if single entry
157-
if (definition.extends?.length === 1) {
158-
return {
159-
...definition,
160-
extends: definition.extends[0],
161-
} as JobDefinitionOutput
162-
}
163-
return definition as JobDefinitionOutput
164-
}
165-
166-
// Filter extends to keep only remote/external ones
167-
if (definition.extends && definition.extends.length > 0) {
168-
const filtered = definition.extends.filter((extendName) => {
169-
const targetName = graph.has(extendName) ? extendName : `.${extendName}`
170-
const targetNode = graph.get(targetName)
171-
172-
// Keep if remote or not found in local graph
173-
return !targetNode || targetNode.isRemote
174-
})
175-
176-
if (filtered.length === 0) {
177-
// Remove extends field entirely
178-
const { extends: _extends, ...rest } = definition
179-
return rest as JobDefinitionOutput
180-
}
181-
182-
if (filtered.length === 1) {
183-
// Optimize to string
184-
return {
185-
...definition,
186-
extends: filtered[0],
187-
} as JobDefinitionOutput
188-
}
189-
190-
// Keep as array
191-
return {
192-
...definition,
193-
extends: filtered,
194-
} as JobDefinitionOutput
195-
}
196-
197-
return definition as JobDefinitionOutput
198-
}

tests/unit/config.edgecases.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,7 @@ describe("ConfigBuilder - edge cases and branches", () => {
5454

5555
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5656
const consumer = out.jobs.consumer!
57-
// Local `.local` should be removed from extends (because it refers to a local template),
58-
// while `external` remains since it does not match any local job id.
57+
// Local `.local` template is merged, only external unknown job remains in extends
5958
// Single extends is optimized to string format
6059
expect(consumer.extends).toEqual("external")
6160
})

tests/unit/extends.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe("ConfigBuilder - extends", () => {
5555
paths: ["node_modules/"],
5656
})
5757
expect(testJob?.script).toEqual(["npm test"])
58-
// Local extends are resolved and removed from output by default
58+
// Template extends are resolved and removed from output
5959
expect(testJob?.extends).toBeUndefined()
6060
})
6161

tests/unit/job-options.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ describe("Job Options", () => {
4444
script: ["template"],
4545
stage: "test",
4646
})
47-
expect(result.jobs?.child?.extends).toBeUndefined()
47+
// resolveTemplatesOnly: true means templates are merged but normal jobs remain in extends
48+
expect(result.jobs?.child?.extends).toBe("basejob")
4849
})
4950

5051
it("should allow job-level override of resolveTemplatesOnly", () => {
@@ -62,6 +63,7 @@ describe("Job Options", () => {
6263
script: ["template", "job"],
6364
stage: "test",
6465
})
66+
// resolveTemplatesOnly: false means both templates and jobs are merged, no extends left
6567
expect(result.jobs?.child?.extends).toBeUndefined()
6668
})
6769
})
@@ -76,6 +78,7 @@ describe("Job Options", () => {
7678
script: ["base command"],
7779
stage: "test",
7880
})
81+
// Template extends are fully resolved and removed
7982
expect(result.jobs?.child?.extends).toBeUndefined()
8083
})
8184

@@ -130,6 +133,7 @@ describe("Job Options", () => {
130133
script: ["base command"],
131134
stage: "deploy",
132135
})
136+
// Template extends are fully resolved when mergeExtends is true
133137
expect(result.jobs?.enabled?.extends).toBeUndefined()
134138
})
135139
})
@@ -260,6 +264,7 @@ describe("Job Options", () => {
260264

261265
// job2: local override mergeExtends=true
262266
expect(result.jobs?.job2?.script).toEqual(["base"])
267+
// Template extends are fully resolved
263268
expect(result.jobs?.job2?.extends).toBeUndefined()
264269

265270
// job3: global mergeExisting=false, replaced

tests/unit/real-world-extends.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe("ConfigBuilder - Real-world extends scenario", () => {
148148
optional: true,
149149
})
150150

151-
// No extends in final output
151+
// Templates are resolved, extends removed
152152
expect(job?.extends).toBeUndefined()
153153
})
154154

tests/unit/template-extends-chain.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe("ConfigBuilder - template extends chain resolution", () => {
7676
// Should have stage from job definition
7777
expect(job?.stage).toBe("review")
7878

79-
// Should not have extends in final output (all resolved)
79+
// Templates are resolved, extends removed
8080
expect(job?.extends).toBeUndefined()
8181
})
8282

0 commit comments

Comments
 (0)