@intentius/chant-lexicon-gitlab 0.124.31 → 0.124.33

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.
Files changed (137) hide show
  1. package/dist/codegen/docs-cli.js +2 -0
  2. package/dist/codegen/docs.js +157 -0
  3. package/dist/codegen/fetch.js +93 -0
  4. package/dist/codegen/generate-cli.js +30 -0
  5. package/dist/codegen/generate-lexicon.js +36 -0
  6. package/dist/codegen/generate-typescript.js +105 -0
  7. package/dist/codegen/generate.js +125 -0
  8. package/dist/codegen/naming.js +48 -0
  9. package/dist/codegen/package.js +41 -0
  10. package/dist/codegen/parse.js +539 -0
  11. package/dist/codegen/patches.js +84 -0
  12. package/dist/codegen/typecheck.js +4 -0
  13. package/dist/components/ci-jobs.js +16 -0
  14. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  15. package/dist/components/generate-op-pipeline.js +294 -0
  16. package/dist/components/generate-op-waves-pipeline.js +125 -0
  17. package/dist/components/generate-pipeline.d.ts.map +1 -1
  18. package/dist/components/generate-pipeline.js +376 -0
  19. package/dist/composites/catalog.js +418 -0
  20. package/dist/composites/docker-build.js +38 -0
  21. package/dist/composites/index.js +5 -0
  22. package/dist/composites/mr-plan-report.js +42 -0
  23. package/dist/composites/node-pipeline.js +67 -0
  24. package/dist/composites/python-pipeline.js +45 -0
  25. package/dist/composites/review-app.js +32 -0
  26. package/dist/coverage.js +35 -0
  27. package/dist/generated/index.js +25 -0
  28. package/dist/generated/runtime.js +4 -0
  29. package/dist/graph-edges.js +202 -0
  30. package/dist/import/generator.js +196 -0
  31. package/dist/import/parser.js +111 -0
  32. package/dist/index.js +26 -0
  33. package/dist/integrity.json +3 -3
  34. package/dist/intrinsics.js +33 -0
  35. package/dist/lint/audit-catalog.js +53 -0
  36. package/dist/lint/audit-lineage.js +64 -0
  37. package/dist/lint/post-synth/index.js +84 -0
  38. package/dist/lint/post-synth/wgl010.js +34 -0
  39. package/dist/lint/post-synth/wgl011.js +48 -0
  40. package/dist/lint/post-synth/wgl012.d.ts.map +1 -1
  41. package/dist/lint/post-synth/wgl012.js +68 -0
  42. package/dist/lint/post-synth/wgl013.js +57 -0
  43. package/dist/lint/post-synth/wgl014.js +43 -0
  44. package/dist/lint/post-synth/wgl015.js +70 -0
  45. package/dist/lint/post-synth/wgl016.js +70 -0
  46. package/dist/lint/post-synth/wgl017.js +38 -0
  47. package/dist/lint/post-synth/wgl018.js +34 -0
  48. package/dist/lint/post-synth/wgl019.js +43 -0
  49. package/dist/lint/post-synth/wgl020.js +47 -0
  50. package/dist/lint/post-synth/wgl021.js +52 -0
  51. package/dist/lint/post-synth/wgl022.js +38 -0
  52. package/dist/lint/post-synth/wgl023.js +44 -0
  53. package/dist/lint/post-synth/wgl024.js +41 -0
  54. package/dist/lint/post-synth/wgl025.js +42 -0
  55. package/dist/lint/post-synth/wgl026.js +58 -0
  56. package/dist/lint/post-synth/wgl027.js +49 -0
  57. package/dist/lint/post-synth/wgl028.js +58 -0
  58. package/dist/lint/post-synth/wgl029.js +55 -0
  59. package/dist/lint/post-synth/wgl030.js +43 -0
  60. package/dist/lint/post-synth/wgl031.js +33 -0
  61. package/dist/lint/post-synth/wgl032.js +64 -0
  62. package/dist/lint/post-synth/wgl033.js +32 -0
  63. package/dist/lint/post-synth/wgl034.js +37 -0
  64. package/dist/lint/post-synth/wgl035.js +40 -0
  65. package/dist/lint/post-synth/wgl036.js +38 -0
  66. package/dist/lint/post-synth/wgl037.js +41 -0
  67. package/dist/lint/post-synth/wgl038.js +43 -0
  68. package/dist/lint/post-synth/wgl039.js +34 -0
  69. package/dist/lint/post-synth/wgl040.js +35 -0
  70. package/dist/lint/post-synth/wgl041.js +53 -0
  71. package/dist/lint/post-synth/wgl042.js +41 -0
  72. package/dist/lint/post-synth/wgl043.js +39 -0
  73. package/dist/lint/post-synth/wgl044.js +35 -0
  74. package/dist/lint/post-synth/wgl045.js +54 -0
  75. package/dist/lint/post-synth/wgl046.js +40 -0
  76. package/dist/lint/post-synth/wgl047.js +34 -0
  77. package/dist/lint/post-synth/wgl048.js +32 -0
  78. package/dist/lint/post-synth/wgl049.js +53 -0
  79. package/dist/lint/post-synth/wgl050.js +42 -0
  80. package/dist/lint/post-synth/yaml-helpers.js +432 -0
  81. package/dist/lint/rules/artifact-no-expiry.js +53 -0
  82. package/dist/lint/rules/data/known-include-sources.js +44 -0
  83. package/dist/lint/rules/data/untrusted-variables.js +30 -0
  84. package/dist/lint/rules/deprecated-only-except.js +46 -0
  85. package/dist/lint/rules/import-source.js +61 -0
  86. package/dist/lint/rules/index.js +7 -0
  87. package/dist/lint/rules/missing-script.js +67 -0
  88. package/dist/lint/rules/missing-stage.js +60 -0
  89. package/dist/lsp/completions.d.ts.map +1 -1
  90. package/dist/lsp/completions.js +18 -0
  91. package/dist/lsp/hover.d.ts.map +1 -1
  92. package/dist/lsp/hover.js +33 -0
  93. package/dist/manifest.json +1 -1
  94. package/dist/mcp/context-tools.js +262 -0
  95. package/dist/migrate/from-github/actions/index.js +17 -0
  96. package/dist/migrate/from-github/actions/registry.js +61 -0
  97. package/dist/migrate/from-github/actions/tier-1.js +298 -0
  98. package/dist/migrate/from-github/actions/tier-2.js +241 -0
  99. package/dist/migrate/from-github/actions/tier-3.js +96 -0
  100. package/dist/migrate/from-github/composites/patterns.js +151 -0
  101. package/dist/migrate/from-github/composites/rewriter.js +22 -0
  102. package/dist/migrate/from-github/diagnostics.js +53 -0
  103. package/dist/migrate/from-github/emit-yaml.js +121 -0
  104. package/dist/migrate/from-github/expressions.js +275 -0
  105. package/dist/migrate/from-github/index.js +96 -0
  106. package/dist/migrate/from-github/provenance.js +35 -0
  107. package/dist/migrate/from-github/rules.js +74 -0
  108. package/dist/migrate/from-github/security.js +189 -0
  109. package/dist/migrate/from-github/stages.js +156 -0
  110. package/dist/migrate/from-github/transformer.js +633 -0
  111. package/dist/op/activities/gitlab.js +38 -0
  112. package/dist/op/activities/index.js +10 -0
  113. package/dist/op/builders.js +26 -0
  114. package/dist/package-cli.js +21 -0
  115. package/dist/plugin.js +536 -0
  116. package/dist/rules/wgl012.ts +2 -2
  117. package/dist/serializer.js +197 -0
  118. package/dist/testdata/create-fixture.js +40 -0
  119. package/dist/testdata/load-fixtures.js +20 -0
  120. package/dist/validate-cli.js +16 -0
  121. package/dist/validate.js +29 -0
  122. package/dist/variables.js +30 -0
  123. package/package.json +8 -8
  124. package/src/codegen/fetch.ts +2 -2
  125. package/src/codegen/package.ts +2 -2
  126. package/src/components/__fixtures__/pr-loop-build.gitlab.golden.yml +3 -3
  127. package/src/components/__fixtures__/pr-loop-record.gitlab.golden.yml +46 -0
  128. package/src/components/__fixtures__/pr-loop-waves.gitlab.golden.yml +101 -0
  129. package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +3 -3
  130. package/src/components/generate-op-pipeline.test.ts +10 -0
  131. package/src/components/generate-op-pipeline.ts +8 -1
  132. package/src/components/generate-pipeline.ts +71 -30
  133. package/src/components/generate-pr-pipeline.test.ts +61 -18
  134. package/src/components/generate-pr-waves-pipeline.test.ts +88 -0
  135. package/src/lint/post-synth/wgl012.ts +2 -2
  136. package/src/lsp/completions.ts +2 -1
  137. package/src/lsp/hover.ts +2 -1
@@ -44,7 +44,18 @@ import {
44
44
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
45
45
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
46
46
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
47
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
47
+ import {
48
+ PR_APPLY_RECORD,
49
+ PR_LOOP_REPORT_DIR,
50
+ prApplyGroup,
51
+ prRecordGroup,
52
+ prApplyRecordKey,
53
+ prApplyWaveJobName,
54
+ prLoopJobs,
55
+ prLoopSetup,
56
+ prLoopWaveJobs,
57
+ prLoopWithWaves,
58
+ } from "@intentius/chant/components/pr-pipeline";
48
59
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
49
60
  import type {
50
61
  ComponentPipelineJob as GeneratedJob,
@@ -79,10 +90,10 @@ export function generateGitlabPipeline(
79
90
  const byName = new Map(components.map((c) => [c.name, c]));
80
91
 
81
92
  if (options.prLoop) {
82
- if (options.gatedWaves || options.promoteTo !== undefined) {
83
- throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
84
- }
85
- return prLoopGitlabPipeline(env, image, beforeScript, extraScript, options);
93
+ if (options.promoteTo !== undefined) throw new Error("a pull-request pipeline has no promote job; drop --promote-to or --pr-loop");
94
+ // #4003: --wave-gate beside --pr-loop gives the apply a gate per wave.
95
+ const prLoop = prLoopWithWaves(options.prLoop, options.gatedWaves);
96
+ return prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, { ...options, prLoop });
86
97
  }
87
98
 
88
99
  if (options.gatedWaves) {
@@ -262,8 +273,8 @@ function gatedWaveGitlabPipeline(
262
273
 
263
274
  /**
264
275
  * The merge-request pipeline (#3183): `plan` in each merge request pipeline,
265
- * `apply` on each push to the target branch, one apply at a time per
266
- * environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
276
+ * and on each push to the target branch `record` (#4168), or `apply` with
277
+ * `applyOnMerge`, one at a time per environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
267
278
  * they measure the change with. The jobs talk to GitLab with
268
279
  * `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
269
280
  * set as a masked CI/CD variable, since a job token cannot write notes.
@@ -282,8 +293,15 @@ function gatedWaveGitlabPipeline(
282
293
  * apply passes it to `pr-apply --resume` (#3543). GitLab keeps protected
283
294
  * branches' caches apart from other branches' by default, so a merge request
284
295
  * pipeline cannot write the record the target branch's apply reads.
296
+ *
297
+ * With a gate per wave (`waveGate`, #4003) the apply job becomes
298
+ * `apply-wave-1` to `apply-wave-<n>`, one stage each, each needing the one
299
+ * before. They share the commit's cache key, so wave k pulls the record wave
300
+ * k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
301
+ * pushed itself.
285
302
  */
286
303
  function prLoopGitlabPipeline(
304
+ components: DriverComponent[],
287
305
  env: string,
288
306
  image: string,
289
307
  beforeScript: string[],
@@ -293,17 +311,24 @@ function prLoopGitlabPipeline(
293
311
  const loop = options.prLoop!;
294
312
  const member = options.member;
295
313
  const rooted = !member || member.dir === "." || member.dir === "";
296
- const [plan, apply] = prLoopJobs("gitlab", env, loop, member?.name);
314
+ const [plan, apply, record] = prLoopJobs("gitlab", env, loop, member?.name);
297
315
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
298
316
  const planJob = member ? `${member.name}-plan` : "plan";
299
- const applyJob = member ? `${member.name}-apply` : "apply";
317
+ const named = (job: string) => (member ? `${member.name}-${job}` : job);
318
+ const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
319
+ const applies = loop.waveGate
320
+ ? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave!)), stage: prApplyWaveJobName(job.wave!) }))
321
+ : loop.applyOnMerge
322
+ ? [{ job: apply, name: named("apply"), stage: "apply" }]
323
+ : [{ job: record, name: named("record"), stage: "record" }];
300
324
  const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
301
325
  if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
302
326
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
303
327
  // A member's jobs start in its directory, after the image's install lines.
304
328
  const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
305
- const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), ...(job.build ?? []), job.command, ...extraScript];
306
- const stages = ["plan", "apply"];
329
+ // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
330
+ const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
331
+ const stages = ["plan", ...applies.map((a) => a.stage)];
307
332
  const doc: Record<string, unknown> = {
308
333
  // Included files merge their top-level keys, so a member's file carries
309
334
  // the rules every member shares and no name: two names would collide.
@@ -314,32 +339,48 @@ function prLoopGitlabPipeline(
314
339
  stage: "plan",
315
340
  image,
316
341
  rules: [{ if: onMergeRequest }],
317
- variables: plan.env,
342
+ variables: plan.run.env,
318
343
  script: script(plan),
319
344
  artifacts: { when: "always", paths: [reportPath] },
320
345
  },
321
- [applyJob]: {
322
- stage: "apply",
323
- image,
324
- resource_group: prApplyGroup(env, member?.name),
325
- rules: [{ if: onMerge }],
326
- variables: apply.env,
327
- script: script(apply),
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] },
336
- },
346
+ ...Object.fromEntries(
347
+ applies.map(({ job, name, stage }, i) => [
348
+ name,
349
+ {
350
+ stage,
351
+ image,
352
+ ...(i > 0 ? { needs: [applies[i - 1]!.name] } : {}),
353
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
354
+ rules: [{ if: onMerge }],
355
+ variables: job.run.env,
356
+ script: script(job),
357
+ // The attempt record (#3543), kept under the pushed commit even when
358
+ // the job fails, so a retry finishes the apply under the same approval.
359
+ // The record job (#4168) applies nothing and keeps none.
360
+ ...(job.jobName === "record"
361
+ ? {}
362
+ : {
363
+ cache: {
364
+ key: prApplyRecordKey(env, "$CI_COMMIT_SHA", member?.name),
365
+ paths: [member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD],
366
+ when: "always",
367
+ },
368
+ }),
369
+ artifacts: { when: "always", paths: [reportPath] },
370
+ },
371
+ ]),
372
+ ),
337
373
  };
338
374
  const jobs: GeneratedJob[] = [
339
375
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
340
- { jobName: applyJob, component: "merge request apply", stage: "apply", needs: [] },
376
+ ...applies.map(({ job, name, stage }, i) => ({
377
+ jobName: name,
378
+ component: job.wave ? `merge request apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "merge request apply",
379
+ stage,
380
+ needs: i > 0 ? [applies[i - 1]!.name] : [],
381
+ })),
341
382
  ];
342
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, applyJob].map((key) => [key, doc[key]] as const));
383
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]] as const));
343
384
  return { yaml, stages, jobs, env };
344
385
  }
345
386
 
@@ -12,7 +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
+ import { PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
16
16
 
17
17
  const ESTATE: DriverComponent[] = [
18
18
  { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
@@ -33,7 +33,7 @@ interface Job {
33
33
  }
34
34
 
35
35
  describe("the GitLab merge-request pipeline", () => {
36
- const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} });
36
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
37
37
  const doc = parseYAML(result.yaml) as { workflow: { rules: unknown[] }; variables: Record<string, string>; plan: Job; apply: Job };
38
38
 
39
39
  test("matches its golden", () => {
@@ -54,17 +54,19 @@ describe("the GitLab merge-request pipeline", () => {
54
54
  expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
55
55
  expect(doc.plan.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID" });
56
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']);
57
+ ...PR_LOOP_SETUP,'chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab']);
58
58
  expect(doc.apply.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
59
59
  expect(doc.apply.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
60
- expect(doc.apply.script.at(-1)).toContain("--require-review");
60
+ // #4156: ci run builds pr-apply (with no --require-review since #4168), and GitLab hands CHANT_RUNNER_KEY to the job as a CI/CD variable when it has one.
61
+ expect(doc.apply.script.at(-1)).toBe('chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab');
62
+ expect(result.yaml).not.toContain("CHANT_RUNNER_KEY");
61
63
  expect(doc.apply.resource_group).toBe("chant-apply-prod");
62
64
  expect(doc.variables.GIT_DEPTH).toBe("0");
63
65
  expect(doc.apply.artifacts).toEqual({ when: "always", paths: [".chant/pr"] });
64
66
  });
65
67
 
66
68
  test("events and reportUrl stream the run's events into the report artifact and copy the reports out (#4007)", () => {
67
- const loud = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { events: true, reportUrl: "$CHANT_REPORT_URL" } }).yaml) as { plan: Job; apply: Job };
69
+ const loud = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, events: true, reportUrl: "$CHANT_REPORT_URL" } }).yaml) as { plan: Job; apply: Job };
68
70
  for (const job of [loud.plan, loud.apply]) {
69
71
  expect(job.script.at(-1)).toContain('--events-json --report-url "$CHANT_REPORT_URL"');
70
72
  expect(job.variables.CHANT_RUN_EVENTS_DIR).toBe(".chant/pr/run-events");
@@ -76,7 +78,7 @@ describe("the GitLab merge-request pipeline", () => {
76
78
  test("runs in an image with git and installs OpenTofu, unless the caller names an image", () => {
77
79
  expect(doc.plan.image).toBe("node:22");
78
80
  expect(doc.apply.script[0]).toBe(PR_LOOP_SETUP[0]);
79
- const own = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: {} }).yaml;
81
+ const own = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: { applyOnMerge: true } }).yaml;
80
82
  expect(own).toContain("image: example/ci:1");
81
83
  expect(own).not.toContain("install-opentofu");
82
84
  });
@@ -90,25 +92,27 @@ describe("the GitLab merge-request pipeline", () => {
90
92
  });
91
93
 
92
94
  test("a named branch replaces the default branch in the apply rule", () => {
93
- const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "trunk" } }).yaml;
95
+ const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "trunk" } }).yaml;
94
96
  expect(yaml).toContain('$CI_COMMIT_BRANCH == "trunk"');
95
97
  });
96
98
 
97
99
  test("the apply resumes from a record the job cache keeps for the pushed commit, failed or not (#3543)", () => {
98
- expect(doc.apply.script.at(-1)).toContain("--resume .chant/pr-resume/pr-apply.json");
100
+ expect(doc.apply.script.at(-1)).toContain("--in-place");
101
+ expect(prLoopJobs("gitlab", "prod")[1]!.command).toContain("--resume .chant/pr-resume/pr-apply.json");
99
102
  expect(doc.apply.cache).toEqual({ key: "chant-apply-prod-$CI_COMMIT_SHA", paths: [".chant/pr-resume/pr-apply.json"], when: "always" });
100
103
  // The plan runs the merge request's code: no cache, no record.
101
104
  expect(doc.plan.cache).toBeUndefined();
102
105
  expect(doc.plan.script.at(-1)).not.toContain("--resume");
103
106
  });
104
107
 
105
- test("refuses a wave or promote job beside it", () => {
106
- expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
108
+ test("refuses a promote job beside it, and a canary beside --wave-gate (#4003)", () => {
109
+ expect(() => generateGitlabPipeline(ESTATE, { prLoop: { applyOnMerge: true }, promoteTo: "prod" })).toThrow(/pull-request pipeline has no promote job/);
110
+ expect(() => generateGitlabPipeline(ESTATE, { prLoop: { applyOnMerge: true }, gatedWaves: { gate: "g", canary: ["net"] } })).toThrow(/takes no --canary/);
107
111
  });
108
112
 
109
113
  test("inside a workspace member: member-named jobs that cd into the member, with no changes rule (#3465)", () => {
110
114
  const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
111
- const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, member });
115
+ const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member });
112
116
  const mdoc = parseYAML(scoped.yaml) as Record<string, unknown> & { workflow: { name?: string; rules: unknown[] } };
113
117
  expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-apply"]);
114
118
  expect(mdoc.plan).toBeUndefined();
@@ -118,9 +122,10 @@ describe("the GitLab merge-request pipeline", () => {
118
122
  const plan = mdoc["network-plan"] as Job;
119
123
  const apply = mdoc["network-apply"] as Job;
120
124
  expect(plan.script).toContain("cd infra/network");
121
- expect(plan.script.indexOf("cd infra/network")).toBeLessThan(plan.script.findIndex((l) => l.includes("pr-plan")));
122
- expect(plan.script.find((l) => l.includes("pr-plan"))).toContain("--member network");
123
- expect(apply.script.find((l) => l.includes("pr-apply"))).toContain("--member network");
125
+ // ci run, started in the member's directory, finds the member and passes --member to pr-plan and pr-apply.
126
+ expect(plan.script.indexOf("cd infra/network")).toBeLessThan(plan.script.findIndex((l) => l.includes("ci run plan")));
127
+ expect(apply.script.indexOf("cd infra/network")).toBeLessThan(apply.script.findIndex((l) => l.includes("ci run apply")));
128
+ expect(prLoopJobs("gitlab", "prod", {}, "network")[0]!.command).toContain("--member network");
124
129
  // A change outside the member can reach it, so the jobs keep only their pipeline-source rules.
125
130
  expect(plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
126
131
  expect(apply.resource_group).toBe("chant-apply.network.prod");
@@ -132,12 +137,12 @@ describe("the GitLab merge-request pipeline", () => {
132
137
 
133
138
  describe("the merge-request pipeline's branch (#3765)", () => {
134
139
  test("a branch that would break the rules: expression is refused, as an Op's is", () => {
135
- expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "re$lease" } })).toThrow(/filters on branch "re\$lease"/);
140
+ expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "re$lease" } })).toThrow(/filters on branch "re\$lease"/);
136
141
  });
137
142
  });
138
143
 
139
144
  describe("the gitlab pull-request pipeline with build (#4088)", () => {
140
- const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { build: true } });
145
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, build: true } });
141
146
  const doc = parseYAML(result.yaml) as Record<string, any>;
142
147
  const golden = join(import.meta.dirname, "__fixtures__", "pr-loop-build.gitlab.golden.yml");
143
148
 
@@ -156,14 +161,52 @@ describe("the gitlab pull-request pipeline with build (#4088)", () => {
156
161
  });
157
162
 
158
163
  test("buildCommand replaces the build line and turns the option on", () => {
159
- const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { buildCommand: "npm run build" } }).yaml;
164
+ const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, buildCommand: "npm run build" } }).yaml;
160
165
  expect(yaml).toContain("npm run build");
161
166
  expect(yaml).not.toContain("chant build --env");
162
167
  expect(yaml).toContain("chant lint");
163
168
  });
164
169
 
165
170
  test("the default output carries neither line", () => {
166
- const plain = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml;
171
+ const plain = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } }).yaml;
167
172
  expect(plain).not.toContain("chant lint");
168
173
  });
169
174
  });
175
+
176
+ describe("the GitLab merge-request pipeline records on merge by default (#4168)", () => {
177
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} });
178
+ const doc = parseYAML(result.yaml) as Record<string, unknown> & { stages: string[]; record: Job };
179
+ const golden = join(import.meta.dirname, "__fixtures__", "pr-loop-record.gitlab.golden.yml");
180
+
181
+ test("matches its golden", () => {
182
+ if (process.env.UPDATE_GOLDEN) writeFileSync(golden, result.yaml);
183
+ expect(result.yaml).toBe(readFileSync(golden, "utf-8"));
184
+ });
185
+
186
+ test("validates against the vendored GitLab CI schema", () => {
187
+ const req = createRequire(import.meta.url);
188
+ const mod = req("ajv") as { default?: unknown };
189
+ const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): ((d: unknown) => boolean) & { errors?: unknown } };
190
+ const validate = new Ajv({ strict: false, allErrors: true }).compile(JSON.parse(readFileSync(SCHEMA, "utf-8")));
191
+ expect(validate(doc), JSON.stringify(validate.errors, null, 2)).toBe(true);
192
+ });
193
+
194
+ test("a push to the default branch runs the record job, which applies nothing and keeps no cache", () => {
195
+ expect(doc.stages).toEqual(["plan", "record"]);
196
+ expect(doc.apply).toBeUndefined();
197
+ expect(doc.record.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
198
+ expect(doc.record.script.at(-1)).toBe('chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab');
199
+ expect(doc.record.resource_group).toBe("chant-record-prod");
200
+ expect(doc.record.cache).toBeUndefined();
201
+ });
202
+
203
+ test("a gate per wave gates the apply, so it needs applyOnMerge", () => {
204
+ expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, gatedWaves: { gate: "release" } })).toThrow(/needs --apply-on-merge/);
205
+ });
206
+
207
+ test("inside a workspace member the record job takes the member's name and group", () => {
208
+ const scoped = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, member: { name: "network", dir: "infra/network" } }).yaml) as Record<string, Job>;
209
+ expect(scoped["network-record"].resource_group).toBe("chant-record.network.prod");
210
+ expect(scoped["network-record"].script.at(-1)).toContain("chant ci run record");
211
+ });
212
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The GitLab merge-request pipeline with a gate per wave on merge (#4003):
3
+ * the plan job in each merge request pipeline, and one apply job per wave of
4
+ * the component graph, one stage each, sharing the commit's record in the
5
+ * job cache. The whole file has a golden, and it validates against the GitLab
6
+ * CI schema this lexicon vendors.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { join } from "node:path";
12
+ import { describe, expect, test } from "vitest";
13
+ import { parseYAML } from "@intentius/chant/yaml";
14
+ import type { DriverComponent } from "@intentius/chant/components/driver";
15
+ import { generateGitlabPipeline } from "./generate-pipeline";
16
+
17
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
18
+ name,
19
+ dependsOn,
20
+ deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }],
21
+ });
22
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"]), root("web", ["app"])];
23
+
24
+ const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop-waves.gitlab.golden.yml");
25
+ const SCHEMA = join(import.meta.dirname, "..", "codegen", "vendored", "gitlab-ci-schema.json");
26
+
27
+ interface Job {
28
+ stage: string;
29
+ needs?: string[];
30
+ rules: Array<{ if: string }>;
31
+ script: string[];
32
+ resource_group?: string;
33
+ cache?: Record<string, unknown>;
34
+ }
35
+
36
+ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () => {
37
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" } });
38
+ const doc = parseYAML(result.yaml) as Record<string, unknown> & { stages: string[] };
39
+
40
+ test("matches its golden", () => {
41
+ if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
42
+ expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
43
+ });
44
+
45
+ test("validates against the vendored GitLab CI schema", () => {
46
+ const req = createRequire(import.meta.url);
47
+ const mod = req("ajv") as { default?: unknown };
48
+ const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): ((d: unknown) => boolean) & { errors?: unknown } };
49
+ const validate = new Ajv({ strict: false, allErrors: true }).compile(JSON.parse(readFileSync(SCHEMA, "utf-8")));
50
+ expect(validate(doc), JSON.stringify(validate.errors, null, 2)).toBe(true);
51
+ });
52
+
53
+ test("one apply job and stage per wave, each needing the one before, on the merge, one at a time", () => {
54
+ expect(doc.stages).toEqual(["plan", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
55
+ expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
56
+ ["plan", []],
57
+ ["apply-wave-1", []],
58
+ ["apply-wave-2", ["apply-wave-1"]],
59
+ ["apply-wave-3", ["apply-wave-2"]],
60
+ ]);
61
+ for (const k of [1, 2, 3]) {
62
+ const job = doc[`apply-wave-${k}`] as Job;
63
+ expect(job.stage).toBe(`apply-wave-${k}`);
64
+ expect(job.needs).toEqual(k > 1 ? [`apply-wave-${k - 1}`] : undefined);
65
+ expect(job.resource_group).toBe("chant-apply-prod");
66
+ // #4156: through chant ci run, which runs pr-apply --wave-gate release (no --require-review since #4168) --resume ... --wave <k>.
67
+ expect(job.script.at(-1)).toBe(`chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave ${k}`);
68
+ // Every wave shares the commit's record, so wave k pulls what wave k-1 pushed.
69
+ expect(job.cache).toEqual({ key: "chant-apply-prod-$CI_COMMIT_SHA", paths: [".chant/pr-resume/pr-apply.json"], when: "always" });
70
+ }
71
+ expect((doc.plan as Job).script.at(-1)).toContain("--forge gitlab --wave-gate release");
72
+ expect((doc.plan as Job).script.at(-1)).not.toContain("--wave ");
73
+ });
74
+
75
+ test("inside a workspace member the wave jobs carry the member's name", () => {
76
+ const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
77
+ const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" }, member });
78
+ expect(scoped.jobs.map((j) => [j.jobName, j.needs])).toEqual([
79
+ ["network-plan", []],
80
+ ["network-apply-wave-1", []],
81
+ ["network-apply-wave-2", ["network-apply-wave-1"]],
82
+ ["network-apply-wave-3", ["network-apply-wave-2"]],
83
+ ]);
84
+ // ci run, started in the member's directory, passes --member network to pr-apply.
85
+ expect(scoped.yaml).toContain("cd infra/network");
86
+ expect(scoped.yaml).toContain("chant ci run apply --in-place --base \"$BASE_SHA\" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 3");
87
+ });
88
+ });
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { readFileSync } from "fs";
9
9
  import { join } from "path";
10
+ import { moduleSourceDir } from "@intentius/chant/module-source";
10
11
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
12
  import { isPropertyDeclarable } from "@intentius/chant/declarable";
12
13
 
@@ -23,8 +24,7 @@ interface LexiconEntry {
23
24
  function loadDeprecatedProperties(): Map<string, Set<string>> {
24
25
  const map = new Map<string, Set<string>>();
25
26
  try {
26
- const pkgDir = join(__dirname, "..", "..", "..");
27
- const lexiconPath = join(pkgDir, "src", "generated", "lexicon-gitlab.json");
27
+ const lexiconPath = join(moduleSourceDir(import.meta.url), "..", "..", "generated", "lexicon-gitlab.json");
28
28
  const content = readFileSync(lexiconPath, "utf-8");
29
29
  const data = JSON.parse(content) as Record<string, LexiconEntry>;
30
30
 
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
package/src/lsp/hover.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8