@intentius/chant-lexicon-gitlab 0.79.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;AACjG,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,CAqFtB"}
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": "77df7f883d9d0a9adf5d80b4e936dd5a076473afaffba5b70d0606f3ac927343",
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": "fe857e0abc3900a63ccea9a656ec8bd996cd4330bb4efc8c8fdc2ae2cfc1072e"
58
+ "composite": "5abfb372c339e030eaac05059cc8b7ebb333c81abd61323a468fb5c66e432256"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.79.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.79.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.79.0",
71
- "@intentius/chant-lexicon-github": "^0.79.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
+ });
@@ -257,3 +257,73 @@ describe("generateGitlabPipeline: options", () => {
257
257
  expect((job.script as string[])[0]).toContain("--env production");
258
258
  });
259
259
  });
260
+
261
+ describe("generateGitlabPipeline: a promote job (#2575)", () => {
262
+ const components = (): DriverComponent[] => [
263
+ { name: "shared-alb", dependsOn: [], deploy: [] },
264
+ {
265
+ name: "api",
266
+ dependsOn: ["shared-alb"],
267
+ deploy: [
268
+ { phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
269
+ { phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
270
+ ],
271
+ },
272
+ ];
273
+
274
+ test("without promoteTo the pipeline is unchanged", () => {
275
+ const yaml = generateGitlabPipeline(components(), { env: "staging" }).yaml;
276
+ expect(yaml).not.toContain("promote");
277
+ expect(yaml).not.toContain("dist/api.tar");
278
+ });
279
+
280
+ test("building jobs keep their archive as artifacts, and a promote stage job needs every component job", () => {
281
+ const result = generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" });
282
+ const parsed = parseYAML(result.yaml);
283
+
284
+ expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar", "api.digest"] });
285
+ expect((parsed["shared-alb"] as Record<string, unknown>).artifacts).toEqual({ paths: ["shared-alb.outputs.json"] });
286
+
287
+ expect(parsed.stages).toEqual(["wave-1", "wave-2", "promote"]);
288
+ expect(result.stages).toEqual(["wave-1", "wave-2"]);
289
+ expect(parsed["promote-prod"]).toEqual({
290
+ stage: "promote",
291
+ image: "node:22-slim",
292
+ script: ['chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"'],
293
+ needs: ["api", "shared-alb"],
294
+ });
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
+ });
329
+ });
@@ -34,10 +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 { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
38
+ import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
37
39
  import type {
38
40
  ComponentPipelineJob as GeneratedJob,
39
41
  ComponentPipelineOptions as GenerateGitlabOptions,
40
42
  ComponentPipelineResult as GenerateGitlabResult,
43
+ PipelineMember,
41
44
  } from "@intentius/chant/lexicon";
42
45
 
43
46
  export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
@@ -82,6 +85,29 @@ export function generateGitlabPipeline(
82
85
  for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
83
86
  const outputsFile = (name: string) => `${name}.outputs.json`;
84
87
 
88
+ // A promote job (#2575) runs apart from the deploy jobs, and a promote
89
+ // publishes from the build archive on disk, so each component job keeps the
90
+ // files its build steps wrote as artifacts. GitLab hands them to the promote
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.
96
+ const promoteTo = options.promoteTo;
97
+ const archives = new Map<string, string[]>();
98
+ const pinned = new Set<string>();
99
+ const digestFile = (name: string) => `${name}.digest`;
100
+ if (promoteTo !== undefined) {
101
+ for (const c of components) {
102
+ const paths = promoteArchivePaths(c);
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");
108
+ }
109
+ }
110
+
85
111
  const stages = waves.map((_, i) => `wave-${i + 1}`);
86
112
  const jobs: GeneratedJob[] = [];
87
113
  const jobNameByComponent = new Map<string, string>();
@@ -115,6 +141,7 @@ export function generateGitlabPipeline(
115
141
  const runParts = runCommand.map((part) => part.replace("{name}", name));
116
142
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
117
143
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
144
+ if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
118
145
 
119
146
  const script = [...beforeScript, runParts.join(" "), ...extraScript];
120
147
 
@@ -125,11 +152,40 @@ export function generateGitlabPipeline(
125
152
  };
126
153
  if (needs.length > 0) jobProps.needs = needs;
127
154
  // Publish this component's dumped outputs so dependent jobs receive it.
128
- if (dependedUpon.has(name)) jobProps.artifacts = { paths: [outputsFile(name)] };
155
+ const artifactPaths = [
156
+ ...(dependedUpon.has(name) ? [outputsFile(name)] : []),
157
+ ...(archives.get(name) ?? []),
158
+ ...(pinned.has(name) ? [digestFile(name)] : []),
159
+ ];
160
+ if (artifactPaths.length > 0) jobProps.artifacts = { paths: artifactPaths };
129
161
  doc[jobName] = jobProps;
130
162
  }
131
163
  });
132
164
 
165
+ let promoteJob: string | undefined;
166
+ if (promoteTo !== undefined) {
167
+ promoteJob = `promote-${toJobName(promoteTo)}`;
168
+ if (promoteJob in doc) {
169
+ throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
170
+ }
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
+ }
177
+ doc[promoteJob] = {
178
+ stage: "promote",
179
+ image,
180
+ script: [...beforeScript, command.join(" "), ...extraScript],
181
+ needs: [...jobNameByComponent.values()].sort(),
182
+ };
183
+ }
184
+
185
+ if (options.member) {
186
+ promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
187
+ }
188
+
133
189
  const sections: string[] = [];
134
190
  // `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
135
191
  // `[]` for an empty one, so the header needs a space in the second case.
@@ -137,12 +193,61 @@ export function generateGitlabPipeline(
137
193
  // when whitespace or the line's end follows it — which is how GitLab's own
138
194
  // reader takes it, and now how `parseYAML` does too (chant #2013).
139
195
  sections.push("workflow:" + emitYAML(doc.workflow, 1));
140
- sections.push(stages.length > 0 ? "stages:" + emitYAML(stages, 1) : "stages: []");
196
+ // The promote stage is YAML only: `stages` in the result stays one entry
197
+ // per graph wave.
198
+ const yamlStages = promoteJob ? [...stages, "promote"] : stages;
199
+ sections.push(yamlStages.length > 0 ? "stages:" + emitYAML(yamlStages, 1) : "stages: []");
141
200
  if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
142
201
  for (const job of jobs) {
143
202
  const props = doc[job.jobName] as Record<string, unknown>;
144
203
  sections.push(`${job.jobName}:` + emitYAML(props, 1));
145
204
  }
205
+ if (promoteJob) sections.push(`${promoteJob}:` + emitYAML(doc[promoteJob], 1));
146
206
 
147
207
  return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
148
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
+ }