@intentius/chant-lexicon-gitlab 0.105.1 → 0.106.3

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-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,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,CAsJtB"}
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;AAKjG,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,CAqJtB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "66544136a83cad1b87a2b66db21e685581d2f4d06018cfee23695b39399c8f07",
4
+ "manifest.json": "fb4d145f385ac1702b9084aa87ef84069138eff4010d2a8d0b61d50ce64e235f",
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": "32497eb358c009df40dff92485e679c8c8ea4a5ea633a8274e762a9053652ba7"
58
+ "composite": "b4573669289f773787b3da6c76e8d20c8adf55482914d721749f627b308b7d11"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.105.1",
3
+ "version": "0.106.3",
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.105.1",
3
+ "version": "0.106.3",
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.105.1",
71
- "@intentius/chant-lexicon-github": "^0.105.1",
70
+ "@intentius/chant": "^0.106.3",
71
+ "@intentius/chant-lexicon-github": "^0.106.3",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -14,13 +14,14 @@ variables:
14
14
 
15
15
  plan:
16
16
  stage: plan
17
- image: node:22-slim
17
+ image: node:22
18
18
  rules:
19
19
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
20
20
  variables:
21
21
  BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
22
22
  PR_NUMBER: '$CI_MERGE_REQUEST_IID'
23
23
  script:
24
+ - command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
24
25
  - chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab
25
26
  artifacts:
26
27
  when: always
@@ -29,14 +30,21 @@ plan:
29
30
 
30
31
  apply:
31
32
  stage: apply
32
- image: node:22-slim
33
+ image: node:22
33
34
  resource_group: chant-apply-prod
34
35
  rules:
35
36
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
36
37
  variables:
37
38
  BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
38
39
  script:
39
- - chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge gitlab --require-review
40
+ - command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
41
+ - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse "$CI_COMMIT_SHA^")"; fi; fi; fi; export BASE_SHA
42
+ - chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge gitlab --require-review --resume .chant/pr-resume/pr-apply.json
43
+ cache:
44
+ key: chant-apply-prod-$CI_COMMIT_SHA
45
+ paths:
46
+ - .chant/pr-resume/pr-apply.json
47
+ when: always
40
48
  artifacts:
41
49
  when: always
42
50
  paths:
@@ -31,7 +31,7 @@ describe("generateGitlabPipeline for a workspace member", () => {
31
31
  test("jobs take the member's name, run in its directory and only on its changes", () => {
32
32
  const result = generateGitlabPipeline(components, { env: "staging", member: api });
33
33
  const doc = parseYAML(result.yaml) as Record<string, unknown>;
34
- expect(doc.workflow).toEqual({ name: "chant-components-api-staging" });
34
+ expect(doc.workflow).toEqual({ name: "chant-components.api.staging" });
35
35
  expect(doc["shared-alb"]).toBeUndefined();
36
36
 
37
37
  const job = doc["api-api"] as Job;
@@ -343,7 +343,9 @@ describe("generateGitlabPipeline: gated waves (#3049)", () => {
343
343
  expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
344
344
  expect(parsed["wave-2"].needs).toEqual(["wave-1"]);
345
345
  expect(parsed["wave-1"].needs).toBeUndefined();
346
+ expect(parsed["wave-3"].image).toBe("node:22");
346
347
  expect(parsed["wave-3"].script).toEqual([
348
+ expect.stringMatching(/^command -v tofu /),
347
349
  "chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 3 --resume .chant/fan-out.json",
348
350
  ]);
349
351
  // The record goes to the next job even when this one stops at a gate.
@@ -357,6 +359,6 @@ describe("generateGitlabPipeline: gated waves (#3049)", () => {
357
359
  const result = generateGitlabPipeline(roots, { env: "prod", gatedWaves: { gate: "release", canary: ["b"], base: "$CI_COMMIT_BEFORE_SHA" } });
358
360
  expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3", "wave-4"]);
359
361
  const parsed = parseYAML(result.yaml) as Record<string, Record<string, unknown>>;
360
- expect((parsed["wave-1"].script as string[])[0]).toContain("--base $CI_COMMIT_BEFORE_SHA --env prod --wave-gate release --canary b --wave 1");
362
+ expect((parsed["wave-1"].script as string[])[1]).toContain("--base $CI_COMMIT_BEFORE_SHA --env prod --wave-gate release --canary b --wave 1");
361
363
  });
362
364
  });
@@ -36,7 +36,7 @@ import { emitYAMLEntry } from "@intentius/chant/yaml";
36
36
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
37
37
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
38
38
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
39
- import { PR_LOOP_REPORT_DIR, prApplyGroup, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
39
+ import { PR_APPLY_RECORD, PR_LOOP_IMAGE, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
40
40
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
41
41
  import type {
42
42
  ComponentPipelineJob as GeneratedJob,
@@ -67,7 +67,7 @@ export function generateGitlabPipeline(
67
67
  options: GenerateGitlabOptions = {},
68
68
  ): GenerateGitlabResult {
69
69
  const env = options.env ?? "production";
70
- const image = options.image ?? DEFAULT_IMAGE;
70
+ const image = options.image ?? (options.prLoop || options.gatedWaves ? PR_LOOP_IMAGE : DEFAULT_IMAGE);
71
71
  const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
72
72
  const beforeScript = options.beforeScript ?? [];
73
73
  const extraScript = options.extraScript ?? [];
@@ -79,7 +79,6 @@ export function generateGitlabPipeline(
79
79
  if (options.gatedWaves || options.promoteTo !== undefined) {
80
80
  throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
81
81
  }
82
- if (options.member) throw new Error("a pull-request pipeline is not generated for a workspace member yet; generate it from the project outside the workspace");
83
82
  return prLoopGitlabPipeline(env, image, beforeScript, extraScript, options);
84
83
  }
85
84
 
@@ -246,7 +245,7 @@ function gatedWaveGitlabPipeline(
246
245
  stage: job.jobName,
247
246
  image,
248
247
  variables: { GIT_DEPTH: "0" },
249
- script: [...beforeScript, job.command.join(" "), ...extraScript],
248
+ script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
250
249
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
251
250
  artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
252
251
  };
@@ -268,6 +267,21 @@ function gatedWaveGitlabPipeline(
268
267
  * they measure the change with. The jobs talk to GitLab with
269
268
  * `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
270
269
  * set as a masked CI/CD variable, since a job token cannot write notes.
270
+ *
271
+ * For a workspace member (#3465) the file is one the root `.gitlab-ci.yml`
272
+ * includes, so the jobs are `<member>-plan` and `<member>-apply`, each
273
+ * script starts with a `cd` into the member's directory and the report
274
+ * artifact is kept from there. The jobs take no `changes:` rule, since a
275
+ * change outside the member can reach it; the plan selects from the whole
276
+ * change and passes `--member`, which keeps the member's gate, note and
277
+ * statuses apart from the other members'. The apply's resource group is the
278
+ * member's own.
279
+ *
280
+ * The apply job keeps its attempt record in the job cache under the pushed
281
+ * commit, uploaded whether the job passed or failed, so a retry of a failed
282
+ * apply passes it to `pr-apply --resume` (#3543). GitLab keeps protected
283
+ * branches' caches apart from other branches' by default, so a merge request
284
+ * pipeline cannot write the record the target branch's apply reads.
271
285
  */
272
286
  function prLoopGitlabPipeline(
273
287
  env: string,
@@ -277,39 +291,55 @@ function prLoopGitlabPipeline(
277
291
  options: GenerateGitlabOptions,
278
292
  ): GenerateGitlabResult {
279
293
  const loop = options.prLoop!;
280
- const [plan, apply] = prLoopJobs("gitlab", env, loop);
294
+ const member = options.member;
295
+ const rooted = !member || member.dir === "." || member.dir === "";
296
+ const [plan, apply] = prLoopJobs("gitlab", env, loop, member?.name);
297
+ const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
298
+ const planJob = member ? `${member.name}-plan` : "plan";
299
+ const applyJob = member ? `${member.name}-apply` : "apply";
281
300
  const onMergeRequest = '$CI_PIPELINE_SOURCE == "merge_request_event"';
282
301
  const target = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
283
302
  const onMerge = `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ${target}`;
284
- const script = (job: typeof plan): string[] => [...beforeScript, job.command, ...extraScript];
303
+ // A member's jobs start in its directory, after the image's install lines.
304
+ const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
305
+ const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), job.command, ...extraScript];
285
306
  const stages = ["plan", "apply"];
286
307
  const doc: Record<string, unknown> = {
287
- workflow: { name: `chant-pr-${env}`, rules: [{ if: onMergeRequest }, { if: onMerge }] },
308
+ // Included files merge their top-level keys, so a member's file carries
309
+ // the rules every member shares and no name: two names would collide.
310
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }] },
288
311
  stages,
289
312
  variables: { ...options.variables, CHANT_ENV: env, GIT_DEPTH: "0" },
290
- plan: {
313
+ [planJob]: {
291
314
  stage: "plan",
292
315
  image,
293
316
  rules: [{ if: onMergeRequest }],
294
317
  variables: plan.env,
295
318
  script: script(plan),
296
- artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
319
+ artifacts: { when: "always", paths: [reportPath] },
297
320
  },
298
- apply: {
321
+ [applyJob]: {
299
322
  stage: "apply",
300
323
  image,
301
- resource_group: prApplyGroup(env),
324
+ resource_group: prApplyGroup(env, member?.name),
302
325
  rules: [{ if: onMerge }],
303
326
  variables: apply.env,
304
327
  script: script(apply),
305
- artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
328
+ // The attempt record (#3543), kept under the pushed commit even when
329
+ // the job fails, so a retry finishes the apply under the same approval.
330
+ cache: {
331
+ key: prApplyRecordKey(env, "$CI_COMMIT_SHA", member?.name),
332
+ paths: [member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD],
333
+ when: "always",
334
+ },
335
+ artifacts: { when: "always", paths: [reportPath] },
306
336
  },
307
337
  };
308
338
  const jobs: GeneratedJob[] = [
309
- { jobName: "plan", component: "merge request plan", stage: "plan", needs: [] },
310
- { jobName: "apply", component: "merge request apply", stage: "apply", needs: [] },
339
+ { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
340
+ { jobName: applyJob, component: "merge request apply", stage: "apply", needs: [] },
311
341
  ];
312
- const sections = ["workflow", "stages", "variables", "plan", "apply"].map((key) => emitYAMLEntry(key, doc[key]));
342
+ const sections = ["workflow", "stages", "variables", planJob, applyJob].map((key) => emitYAMLEntry(key, doc[key]));
313
343
  return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
314
344
  }
315
345
 
@@ -354,6 +384,6 @@ function scopeToMember(
354
384
  job.jobName = renamed.get(job.jobName)!;
355
385
  job.needs = job.needs.map((n) => renamed.get(n) ?? n);
356
386
  }
357
- doc.workflow = { name: `chant-components-${member.name}-${env}` };
387
+ doc.workflow = { name: `chant-components.${member.name}.${env}` };
358
388
  return promoteJob ? renamed.get(promoteJob) : undefined;
359
389
  }
@@ -12,6 +12,7 @@ import { describe, expect, test } from "vitest";
12
12
  import { parseYAML } from "@intentius/chant/yaml";
13
13
  import type { DriverComponent } from "@intentius/chant/components/driver";
14
14
  import { generateGitlabPipeline } from "./generate-pipeline";
15
+ import { PR_LOOP_SETUP } from "@intentius/chant/components/pr-pipeline";
15
16
 
16
17
  const ESTATE: DriverComponent[] = [
17
18
  { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
@@ -26,7 +27,9 @@ interface Job {
26
27
  variables: Record<string, string>;
27
28
  script: string[];
28
29
  resource_group?: string;
30
+ image?: string;
29
31
  artifacts: Record<string, unknown>;
32
+ cache?: Record<string, unknown>;
30
33
  }
31
34
 
32
35
  describe("the GitLab merge-request pipeline", () => {
@@ -50,21 +53,69 @@ describe("the GitLab merge-request pipeline", () => {
50
53
  test("plans in a merge request pipeline from its diff base, applies on a push to the default branch, one at a time", () => {
51
54
  expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
52
55
  expect(doc.plan.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID" });
53
- expect(doc.plan.script).toEqual(['chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab']);
56
+ expect(doc.plan.script).toEqual([
57
+ ...PR_LOOP_SETUP,'chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab']);
54
58
  expect(doc.apply.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
55
59
  expect(doc.apply.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
56
- expect(doc.apply.script[0]).toContain("--require-review");
60
+ expect(doc.apply.script.at(-1)).toContain("--require-review");
57
61
  expect(doc.apply.resource_group).toBe("chant-apply-prod");
58
62
  expect(doc.variables.GIT_DEPTH).toBe("0");
59
63
  expect(doc.apply.artifacts).toEqual({ when: "always", paths: [".chant/pr"] });
60
64
  });
61
65
 
66
+ test("runs in an image with git and installs OpenTofu, unless the caller names an image", () => {
67
+ expect(doc.plan.image).toBe("node:22");
68
+ expect(doc.apply.script[0]).toBe(PR_LOOP_SETUP[0]);
69
+ const own = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: {} }).yaml;
70
+ expect(own).toContain("image: example/ci:1");
71
+ expect(own).not.toContain("install-opentofu");
72
+ });
73
+
74
+ test("an all-zero CI_COMMIT_BEFORE_SHA falls back to the merge request diff base, then the target branch", () => {
75
+ const line = doc.apply.script.find((l) => l.includes("CI_MERGE_REQUEST_DIFF_BASE_SHA"))!;
76
+ expect(line).toContain("0000000000000000000000000000000000000000");
77
+ expect(line).toContain("git fetch origin $CI_DEFAULT_BRANCH");
78
+ expect(doc.apply.script.indexOf(line)).toBeLessThan(doc.apply.script.length - 1);
79
+ expect(doc.plan.script.some((l) => l.includes("CI_MERGE_REQUEST_DIFF_BASE_SHA"))).toBe(false);
80
+ });
81
+
62
82
  test("a named branch replaces the default branch in the apply rule", () => {
63
83
  const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "trunk" } }).yaml;
64
84
  expect(yaml).toContain('$CI_COMMIT_BRANCH == "trunk"');
65
85
  });
66
86
 
87
+ test("the apply resumes from a record the job cache keeps for the pushed commit, failed or not (#3543)", () => {
88
+ expect(doc.apply.script.at(-1)).toContain("--resume .chant/pr-resume/pr-apply.json");
89
+ expect(doc.apply.cache).toEqual({ key: "chant-apply-prod-$CI_COMMIT_SHA", paths: [".chant/pr-resume/pr-apply.json"], when: "always" });
90
+ // The plan runs the merge request's code: no cache, no record.
91
+ expect(doc.plan.cache).toBeUndefined();
92
+ expect(doc.plan.script.at(-1)).not.toContain("--resume");
93
+ });
94
+
67
95
  test("refuses a wave or promote job beside it", () => {
68
96
  expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
69
97
  });
98
+
99
+ test("inside a workspace member: member-named jobs that cd into the member, with no changes rule (#3465)", () => {
100
+ const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
101
+ const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, member });
102
+ const mdoc = parseYAML(scoped.yaml) as Record<string, unknown> & { workflow: { name?: string; rules: unknown[] } };
103
+ expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-apply"]);
104
+ expect(mdoc.plan).toBeUndefined();
105
+ // Included files merge top-level keys: no name, so members do not collide on it.
106
+ expect(mdoc.workflow.name).toBeUndefined();
107
+ expect(mdoc.workflow.rules).toHaveLength(2);
108
+ const plan = mdoc["network-plan"] as Job;
109
+ const apply = mdoc["network-apply"] as Job;
110
+ expect(plan.script).toContain("cd infra/network");
111
+ expect(plan.script.indexOf("cd infra/network")).toBeLessThan(plan.script.findIndex((l) => l.includes("pr-plan")));
112
+ expect(plan.script.find((l) => l.includes("pr-plan"))).toContain("--member network");
113
+ expect(apply.script.find((l) => l.includes("pr-apply"))).toContain("--member network");
114
+ // A change outside the member can reach it, so the jobs keep only their pipeline-source rules.
115
+ expect(plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
116
+ expect(apply.resource_group).toBe("chant-apply.network.prod");
117
+ expect(plan.artifacts).toEqual({ when: "always", paths: ["infra/network/.chant/pr"] });
118
+ expect(apply.artifacts).toEqual({ when: "always", paths: ["infra/network/.chant/pr"] });
119
+ expect(apply.cache).toEqual({ key: "chant-apply.network.prod-$CI_COMMIT_SHA", paths: ["infra/network/.chant/pr-resume/pr-apply.json"], when: "always" });
120
+ });
70
121
  });