@intentius/chant-lexicon-gitlab 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqShE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAwGxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqShE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAgHxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAEjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAChD,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAS1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAyHtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAGjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAS1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CA8ItB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "073de2644bb1121ba60a3afcbffb9c821b972968c75e1b966446fe5a1833bd29",
4
+ "manifest.json": "e23beb8934ca2c67212955955495a0b00ce12ba9e793a433602d14fda561f397",
5
5
  "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
6
  "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "163341752b7f5181bd0682992b19dd2242df3f5b977dd5c6c64303b4ebc11f7e"
58
+ "composite": "5abfb372c339e030eaac05059cc8b7ebb333c81abd61323a468fb5c66e432256"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.80.0",
71
- "@intentius/chant-lexicon-github": "^0.80.0",
70
+ "@intentius/chant": "^0.81.0",
71
+ "@intentius/chant-lexicon-github": "^0.81.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -106,7 +106,7 @@
106
106
  */
107
107
 
108
108
  import { emitYAML } from "@intentius/chant/yaml";
109
- import { resolveOpTrigger } from "@intentius/chant/lexicon";
109
+ import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
110
110
  import type {
111
111
  ComponentPipelineOptions as GenerateGitlabOpOptions,
112
112
  OpEnvironment,
@@ -425,8 +425,16 @@ export function generateGitlabOpPipeline(
425
425
  const extraScript = options.extraScript ?? [];
426
426
  const stage = options.opsStage ?? DEFAULT_STAGE;
427
427
  assertOpsStage(stage);
428
- const fileName = options.opsFileName ?? `${stage}.gitlab-ci.yml`;
429
- assertOpsFileName(fileName);
428
+ const baseFileName = options.opsFileName ?? `${stage}.gitlab-ci.yml`;
429
+ assertOpsFileName(baseFileName);
430
+ // A workspace member's file, jobs and resource groups carry its name
431
+ // (#2542): the root .gitlab-ci.yml includes every member's file, and job
432
+ // names share one namespace across them.
433
+ const member = options.member;
434
+ const fileName = member ? `${member.name}-${baseFileName}` : baseFileName;
435
+ const jobPrefix = member ? `${member.name}-` : "";
436
+ const changes = member ? memberGitlabChanges(member, member.fileDir ? `${member.fileDir}/${fileName}` : undefined) : undefined;
437
+ const cdLine = member && member.dir !== "." && member.dir !== "" ? [`cd ${memberShellDir(member)}`] : [];
430
438
 
431
439
  const jobs: OpPipelineJob[] = [];
432
440
  const doc: Record<string, unknown> = { stages: [stage] };
@@ -443,7 +451,7 @@ export function generateGitlabOpPipeline(
443
451
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
444
452
  if (trigger.kind === "cron") anyCron = true;
445
453
 
446
- const jobName = toJobName(spec.name);
454
+ const jobName = jobPrefix + toJobName(spec.name);
447
455
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
448
456
  opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
449
457
  if (spec.environment) {
@@ -484,14 +492,14 @@ export function generateGitlabOpPipeline(
484
492
  }
485
493
  : {}),
486
494
  ...(Object.keys(jobVariables).length > 0 ? { variables: jobVariables } : {}),
487
- rules: rulesFor(spec, trigger),
488
- script: [...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
495
+ rules: changes && trigger.kind !== "cron" ? rulesFor(spec, trigger).map((r) => ({ ...r, changes })) : rulesFor(spec, trigger),
496
+ script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
489
497
  // `when: always` because the run this publishes for is the green one: a
490
498
  // gated apply succeeds, and the block is the only thing that says a
491
499
  // human still has to act. A run that walked through its gate writes no
492
500
  // block, and GitLab reports the empty upload as a warning, not a failure.
493
501
  ...(gated
494
- ? { artifacts: { when: "always", paths: [gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
502
+ ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
495
503
  : {}),
496
504
  };
497
505
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A workspace member's pipelines on GitLab (#2542, #2524 D19). GitLab reads
3
+ * one .gitlab-ci.yml, which includes each member's file, so job names carry
4
+ * the member's name and each job carries its own `rules: changes:`.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import type { PipelineMember } from "@intentius/chant/lexicon";
11
+ import { generateGitlabPipeline } from "./generate-pipeline";
12
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
13
+
14
+ const components: DriverComponent[] = [
15
+ { name: "shared-alb", dependsOn: [], deploy: [] },
16
+ {
17
+ name: "api",
18
+ dependsOn: ["shared-alb"],
19
+ deploy: [
20
+ { phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
21
+ { phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
22
+ ],
23
+ },
24
+ ];
25
+
26
+ const api: PipelineMember = { name: "api", dir: "services/api", file: ".gitlab/ci/chant-api-staging.gitlab-ci.yml" };
27
+
28
+ type Job = { script: string[]; needs?: string[]; rules?: unknown[]; artifacts?: { paths: string[] }; stage: string };
29
+
30
+ describe("generateGitlabPipeline for a workspace member", () => {
31
+ test("jobs take the member's name, run in its directory and only on its changes", () => {
32
+ const result = generateGitlabPipeline(components, { env: "staging", member: api });
33
+ const doc = parseYAML(result.yaml) as Record<string, unknown>;
34
+ expect(doc.workflow).toEqual({ name: "chant-components-api-staging" });
35
+ expect(doc["shared-alb"]).toBeUndefined();
36
+
37
+ const job = doc["api-api"] as Job;
38
+ expect(job.script).toEqual(["cd services/api", "chant run --components api --env staging --seed-outputs shared-alb.outputs.json"]);
39
+ expect(job.needs).toEqual(["api-shared-alb"]);
40
+ expect(job.rules).toEqual([{ changes: ["services/api/**/*", ".gitlab/ci/chant-api-staging.gitlab-ci.yml"] }]);
41
+
42
+ const shared = doc["api-shared-alb"] as Job;
43
+ expect(shared.artifacts).toEqual({ paths: ["services/api/shared-alb.outputs.json"] });
44
+
45
+ expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
46
+ ["api-shared-alb", []],
47
+ ["api-api", ["api-shared-alb"]],
48
+ ]);
49
+ });
50
+
51
+ test("the promote job is renamed and needs the renamed jobs; archive paths move under the member", () => {
52
+ const doc = parseYAML(generateGitlabPipeline(components, { env: "staging", promoteTo: "prod", member: api }).yaml) as Record<string, unknown>;
53
+ const promote = doc["api-promote-prod"] as Job;
54
+ expect(promote.needs).toEqual(["api-api", "api-shared-alb"]);
55
+ expect(promote.script[0]).toBe("cd services/api");
56
+ expect((doc["api-api"] as Job).artifacts?.paths).toEqual(["services/api/dist/api.tar", "services/api/api.digest"]);
57
+ // The promote job reads the digest file after its cd, from where the api job wrote it (#2602).
58
+ expect(promote.script[1]).toContain('--digest "api=$(cut -d= -f2- api.digest)"');
59
+ expect(doc.stages).toEqual(["wave-1", "wave-2", "promote"]);
60
+ });
61
+
62
+ test("a member at the root has no changes rule, since GitLab's changes can't exclude the other members", () => {
63
+ const doc = parseYAML(generateGitlabPipeline(components, { env: "prod", member: { name: "platform", dir: ".", exclude: ["services/api"] } }).yaml) as Record<string, unknown>;
64
+ const job = doc["platform-api"] as Job;
65
+ expect(job.rules).toBeUndefined();
66
+ expect(job.script[0]).toMatch(/^chant run/);
67
+ });
68
+
69
+ test("a directory with a space is quoted for cd", () => {
70
+ const doc = parseYAML(generateGitlabPipeline(components, { env: "prod", member: { name: "odd", dir: "my apps/odd" } }).yaml) as Record<string, unknown>;
71
+ expect((doc["odd-api"] as Job).script[0]).toBe("cd 'my apps/odd'");
72
+ });
73
+ });
74
+
75
+ describe("generateGitlabOpPipeline for a workspace member", () => {
76
+ const member: PipelineMember = { name: "api", dir: "services/api", fileDir: ".gitlab/ci" };
77
+
78
+ test("the file and job names carry the member, push and merge-request rules gain changes, cron does not", () => {
79
+ const result = generateGitlabOpPipeline(
80
+ [
81
+ { name: "nightly", trigger: { kind: "cron", schedule: "0 6 * * *" } },
82
+ { name: "plan", trigger: { kind: "pull_request" } },
83
+ { name: "apply", trigger: { kind: "push" } },
84
+ ],
85
+ { member },
86
+ );
87
+ expect(result.files.map((f) => f.name)).toEqual(["api-ops.gitlab-ci.yml"]);
88
+ const doc = parseYAML(result.files[0].yaml) as Record<string, Omit<Job, "rules"> & { resource_group: string; rules: Array<Record<string, unknown>> }>;
89
+ const changes = ["services/api/**/*", ".gitlab/ci/api-ops.gitlab-ci.yml"];
90
+
91
+ expect(doc["api-nightly"].rules.every((r) => !("changes" in r))).toBe(true);
92
+ expect(doc["api-plan"].rules.every((r) => JSON.stringify(r.changes) === JSON.stringify(changes))).toBe(true);
93
+ expect(doc["api-apply"].rules.every((r) => JSON.stringify(r.changes) === JSON.stringify(changes))).toBe(true);
94
+ expect(doc["api-apply"].resource_group).toBe("api-apply");
95
+ expect(doc["api-apply"].script[0]).toBe("cd services/api");
96
+ expect(doc["api-apply"].artifacts?.paths).toEqual(["services/api/chant-gate-api-apply.md"]);
97
+ });
98
+ });
@@ -281,7 +281,7 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
281
281
  const result = generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" });
282
282
  const parsed = parseYAML(result.yaml);
283
283
 
284
- expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar"] });
284
+ expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar", "api.digest"] });
285
285
  expect((parsed["shared-alb"] as Record<string, unknown>).artifacts).toEqual({ paths: ["shared-alb.outputs.json"] });
286
286
 
287
287
  expect(parsed.stages).toEqual(["wave-1", "wave-2", "promote"]);
@@ -289,8 +289,41 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
289
289
  expect(parsed["promote-prod"]).toEqual({
290
290
  stage: "promote",
291
291
  image: "node:22-slim",
292
- script: ["chant components promote --from staging --to prod"],
292
+ script: ['chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"'],
293
293
  needs: ["api", "shared-alb"],
294
294
  });
295
295
  });
296
+
297
+ test("the promote job promotes the digest this pipeline's component job recorded (#2602)", () => {
298
+ const parsed = parseYAML(generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" }).yaml);
299
+ const job = (name: string) => parsed[name] as { script: string[]; artifacts?: { paths: string[] } };
300
+
301
+ // The component job writes the digest its run recorded, and keeps the file
302
+ // as an artifact, which GitLab hands to the promote job across `needs:`.
303
+ expect(job("api").script).toEqual(["chant run --components api --env staging --seed-outputs shared-alb.outputs.json --digest-file api.digest"]);
304
+ expect(job("api").artifacts?.paths).toContain("api.digest");
305
+ // shared-alb publishes nothing, so it records no release and is not pinned.
306
+ expect(job("shared-alb").script[0]).not.toContain("--digest-file");
307
+
308
+ const promote = job("promote-prod").script[0];
309
+ expect(promote).toContain('--digest "api=$(cut -d= -f2- api.digest)"');
310
+ expect(promote).not.toContain("shared-alb=");
311
+ });
312
+
313
+ test("promoteCommand still gets the pinned digests", () => {
314
+ const parsed = parseYAML(generateGitlabPipeline(components(), {
315
+ env: "staging",
316
+ promoteTo: "prod",
317
+ promoteCommand: ["npx", "chant", "components", "promote", "--from", "staging", "--to", "prod"],
318
+ }).yaml);
319
+ expect((parsed["promote-prod"] as { script: string[] }).script).toEqual([
320
+ 'npx chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"',
321
+ ]);
322
+ });
323
+
324
+ test("with no component that publishes, there is no release to pin, and generation fails", () => {
325
+ const infraOnly: DriverComponent[] = [{ name: "shared-alb", dependsOn: [], deploy: [] }];
326
+ expect(() => generateGitlabPipeline(infraOnly, { promoteTo: "prod" })).toThrow(/no component has a publish step/);
327
+ expect(() => generateGitlabPipeline(infraOnly)).not.toThrow();
328
+ });
296
329
  });
@@ -34,11 +34,13 @@
34
34
 
35
35
  import { emitYAML } from "@intentius/chant/yaml";
36
36
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
37
- import { promoteArchivePaths } from "@intentius/chant/components/promote";
37
+ import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
38
+ import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
38
39
  import type {
39
40
  ComponentPipelineJob as GeneratedJob,
40
41
  ComponentPipelineOptions as GenerateGitlabOptions,
41
42
  ComponentPipelineResult as GenerateGitlabResult,
43
+ PipelineMember,
42
44
  } from "@intentius/chant/lexicon";
43
45
 
44
46
  export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
@@ -86,13 +88,23 @@ export function generateGitlabPipeline(
86
88
  // A promote job (#2575) runs apart from the deploy jobs, and a promote
87
89
  // publishes from the build archive on disk, so each component job keeps the
88
90
  // files its build steps wrote as artifacts. GitLab hands them to the promote
89
- // job across its `needs:` edges.
91
+ // job across its `needs:` edges. Each component job with a publish step
92
+ // also keeps the digest its run recorded (`--digest-file`), and the promote
93
+ // job passes it back as `--digest <component>=<digest>` (#2602), so it
94
+ // promotes the release this run built rather than whatever is latest in the
95
+ // source environment.
90
96
  const promoteTo = options.promoteTo;
91
97
  const archives = new Map<string, string[]>();
98
+ const pinned = new Set<string>();
99
+ const digestFile = (name: string) => `${name}.digest`;
92
100
  if (promoteTo !== undefined) {
93
101
  for (const c of components) {
94
102
  const paths = promoteArchivePaths(c);
95
103
  if (paths.length > 0) archives.set(c.name, paths);
104
+ if (hasPublishStep(c)) pinned.add(c.name);
105
+ }
106
+ if (pinned.size === 0) {
107
+ throw new Error("no component has a publish step, so no deploy records a release for the promote job to promote");
96
108
  }
97
109
  }
98
110
 
@@ -129,6 +141,7 @@ export function generateGitlabPipeline(
129
141
  const runParts = runCommand.map((part) => part.replace("{name}", name));
130
142
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
131
143
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
144
+ if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
132
145
 
133
146
  const script = [...beforeScript, runParts.join(" "), ...extraScript];
134
147
 
@@ -142,6 +155,7 @@ export function generateGitlabPipeline(
142
155
  const artifactPaths = [
143
156
  ...(dependedUpon.has(name) ? [outputsFile(name)] : []),
144
157
  ...(archives.get(name) ?? []),
158
+ ...(pinned.has(name) ? [digestFile(name)] : []),
145
159
  ];
146
160
  if (artifactPaths.length > 0) jobProps.artifacts = { paths: artifactPaths };
147
161
  doc[jobName] = jobProps;
@@ -154,7 +168,12 @@ export function generateGitlabPipeline(
154
168
  if (promoteJob in doc) {
155
169
  throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
156
170
  }
157
- const command = options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo];
171
+ const command = [...(options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo])];
172
+ // The file holds `<component>=<digest>`; a missing or empty one leaves
173
+ // `<component>=`, which the promote refuses.
174
+ for (const name of [...pinned].sort()) {
175
+ command.push("--digest", `"${name}=$(cut -d= -f2- ${digestFile(name)})"`);
176
+ }
158
177
  doc[promoteJob] = {
159
178
  stage: "promote",
160
179
  image,
@@ -163,6 +182,10 @@ export function generateGitlabPipeline(
163
182
  };
164
183
  }
165
184
 
185
+ if (options.member) {
186
+ promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
187
+ }
188
+
166
189
  const sections: string[] = [];
167
190
  // `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
168
191
  // `[]` for an empty one, so the header needs a space in the second case.
@@ -183,3 +206,48 @@ export function generateGitlabPipeline(
183
206
 
184
207
  return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
185
208
  }
209
+
210
+ /**
211
+ * Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
212
+ * `doc` and `jobs`. Returns the promote job's new name.
213
+ *
214
+ * GitLab reads one `.gitlab-ci.yml`, so a member's pipeline is a file that
215
+ * the root file includes, and job names share one namespace across every
216
+ * included file. Each job therefore takes the member's name as a prefix. A
217
+ * `rules: changes:` entry limits each job to changes under the member's
218
+ * directory or to the pipeline file; for a member at `"."` there is no such
219
+ * rule, since `changes` has no exclusions. Each job's script starts with a
220
+ * `cd` into the member's directory, and artifact paths, which GitLab resolves
221
+ * against the repository root, move under it.
222
+ */
223
+ function scopeToMember(
224
+ doc: Record<string, unknown>,
225
+ jobs: GeneratedJob[],
226
+ promoteJob: string | undefined,
227
+ member: PipelineMember,
228
+ env: string,
229
+ ): string | undefined {
230
+ const rooted = member.dir === "." || member.dir === "";
231
+ const changes = memberGitlabChanges(member);
232
+ const renamed = new Map<string, string>();
233
+ for (const job of jobs) renamed.set(job.jobName, `${member.name}-${job.jobName}`);
234
+ if (promoteJob) renamed.set(promoteJob, `${member.name}-${promoteJob}`);
235
+
236
+ for (const [from, to] of renamed) {
237
+ const props = doc[from] as Record<string, unknown>;
238
+ delete doc[from];
239
+ const next: Record<string, unknown> = { ...props };
240
+ if (!rooted) next.script = [`cd ${memberShellDir(member)}`, ...(props.script as string[])];
241
+ if (Array.isArray(props.needs)) next.needs = (props.needs as string[]).map((n) => renamed.get(n) ?? n);
242
+ const artifacts = props.artifacts as { paths?: string[] } | undefined;
243
+ if (artifacts?.paths) next.artifacts = { ...artifacts, paths: artifacts.paths.map((p) => memberRepoPath(member, p)) };
244
+ if (changes) next.rules = [{ changes }];
245
+ doc[to] = next;
246
+ }
247
+ for (const job of jobs) {
248
+ job.jobName = renamed.get(job.jobName)!;
249
+ job.needs = job.needs.map((n) => renamed.get(n) ?? n);
250
+ }
251
+ doc.workflow = { name: `chant-components-${member.name}-${env}` };
252
+ return promoteJob ? renamed.get(promoteJob) : undefined;
253
+ }