@intentius/chant-lexicon-gitlab 0.102.0 → 0.104.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-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,CAyItB"}
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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "3fa255907266687335043666a68d07369be9edd2ca1526897a357c301cfae2e9",
4
+ "manifest.json": "ef6e7a167965f693557802a8211365e1dee13712ef42ac41e276e9b3528dc307",
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": "54b426d41adbb3988bb2ca30ad774ead4b306821214f752bb5a56587232ae911"
58
+ "composite": "01362067b7c359f45f632c89c2532c2f24ae13c6ee1b01f1fc501524df6383c1"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.102.0",
3
+ "version": "0.104.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.102.0",
3
+ "version": "0.104.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.102.0",
71
- "@intentius/chant-lexicon-github": "^0.102.0",
70
+ "@intentius/chant": "^0.104.0",
71
+ "@intentius/chant-lexicon-github": "^0.104.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -0,0 +1,43 @@
1
+ workflow:
2
+ name: chant-pr-prod
3
+ rules:
4
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
+ - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+
7
+ stages:
8
+ - plan
9
+ - apply
10
+
11
+ variables:
12
+ CHANT_ENV: prod
13
+ GIT_DEPTH: '0'
14
+
15
+ plan:
16
+ stage: plan
17
+ image: node:22-slim
18
+ rules:
19
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
20
+ variables:
21
+ BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
22
+ PR_NUMBER: '$CI_MERGE_REQUEST_IID'
23
+ script:
24
+ - chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab
25
+ artifacts:
26
+ when: always
27
+ paths:
28
+ - .chant/pr
29
+
30
+ apply:
31
+ stage: apply
32
+ image: node:22-slim
33
+ resource_group: chant-apply-prod
34
+ rules:
35
+ - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
36
+ variables:
37
+ BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
38
+ script:
39
+ - chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge gitlab --require-review
40
+ artifacts:
41
+ when: always
42
+ paths:
43
+ - .chant/pr
@@ -327,3 +327,36 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
327
327
  expect(() => generateGitlabPipeline(infraOnly)).not.toThrow();
328
328
  });
329
329
  });
330
+
331
+ describe("generateGitlabPipeline: gated waves (#3049)", () => {
332
+ /** net -> a, b -> app: three waves, four with a canary in front. */
333
+ const roots: DriverComponent[] = [
334
+ { name: "net", deploy: [] },
335
+ { name: "a", dependsOn: ["net"], deploy: [] },
336
+ { name: "b", dependsOn: ["net"], deploy: [] },
337
+ { name: "app", dependsOn: ["a"], deploy: [] },
338
+ ];
339
+
340
+ test("one job per wave, in order, each running that wave of the gated fan-out", () => {
341
+ const result = generateGitlabPipeline(roots, { env: "prod", gatedWaves: { gate: "release" } });
342
+ const parsed = parseYAML(result.yaml) as Record<string, Record<string, unknown>>;
343
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
344
+ expect(parsed["wave-2"].needs).toEqual(["wave-1"]);
345
+ expect(parsed["wave-1"].needs).toBeUndefined();
346
+ expect(parsed["wave-3"].script).toEqual([
347
+ "chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 3 --resume .chant/fan-out.json",
348
+ ]);
349
+ // The record goes to the next job even when this one stops at a gate.
350
+ expect(parsed["wave-1"].artifacts).toEqual({ when: "always", paths: [".chant/fan-out.json"] });
351
+ expect(parsed["wave-1"].variables).toEqual({ GIT_DEPTH: "0" });
352
+ // No per-component jobs.
353
+ expect(parsed.net).toBeUndefined();
354
+ });
355
+
356
+ test("a canary list adds a wave in front and is passed to every job", () => {
357
+ const result = generateGitlabPipeline(roots, { env: "prod", gatedWaves: { gate: "release", canary: ["b"], base: "$CI_COMMIT_BEFORE_SHA" } });
358
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3", "wave-4"]);
359
+ 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");
361
+ });
362
+ });
@@ -35,6 +35,8 @@
35
35
  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
+ 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";
38
40
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
39
41
  import type {
40
42
  ComponentPipelineJob as GeneratedJob,
@@ -73,6 +75,19 @@ export function generateGitlabPipeline(
73
75
  const { waves } = resolveComponentGraph(components);
74
76
  const byName = new Map(components.map((c) => [c.name, c]));
75
77
 
78
+ if (options.prLoop) {
79
+ if (options.gatedWaves || options.promoteTo !== undefined) {
80
+ throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
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
+ return prLoopGitlabPipeline(env, image, beforeScript, extraScript, options);
84
+ }
85
+
86
+ if (options.gatedWaves) {
87
+ if (options.promoteTo !== undefined) throw new Error("a gated-wave pipeline has no promote job; drop --promote-to or --wave-gate");
88
+ return gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScript, options);
89
+ }
90
+
76
91
  // Components that something else depends on must hand their resolved outputs
77
92
  // (stack outputs, published artifact refs) to their dependents, which run as
78
93
  // separate jobs/processes. Each such producer dumps its outputs to a file and
@@ -202,6 +217,102 @@ export function generateGitlabPipeline(
202
217
  return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
203
218
  }
204
219
 
220
+ /**
221
+ * The gated-wave pipeline (#3049): one job per wave, each running one wave of
222
+ * `chant components fan-out --wave-gate`, in order through `needs:`. The
223
+ * attempt record is the artifact GitLab hands from job to job, kept even when
224
+ * a job stops at a gate (exit 3) so the re-run after `chant approve` reads it.
225
+ * `GIT_DEPTH: "0"` gives the job the history `--base` diffs against.
226
+ */
227
+ function gatedWaveGitlabPipeline(
228
+ components: DriverComponent[],
229
+ env: string,
230
+ image: string,
231
+ beforeScript: string[],
232
+ extraScript: string[],
233
+ options: GenerateGitlabOptions,
234
+ ): GenerateGitlabResult {
235
+ const waveJobs = gatedWaveJobs(components, env, options.gatedWaves!);
236
+ const stages = waveJobs.map((j) => j.jobName);
237
+ const doc: Record<string, unknown> = {
238
+ workflow: { name: `chant-components-${env}` },
239
+ stages,
240
+ variables: { ...options.variables, CHANT_ENV: env },
241
+ };
242
+ const jobs: GeneratedJob[] = [];
243
+ for (const job of waveJobs) {
244
+ jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
245
+ doc[job.jobName] = {
246
+ stage: job.jobName,
247
+ image,
248
+ variables: { GIT_DEPTH: "0" },
249
+ script: [...beforeScript, job.command.join(" "), ...extraScript],
250
+ ...(job.needs.length > 0 ? { needs: job.needs } : {}),
251
+ artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
252
+ };
253
+ }
254
+ if (options.member) scopeToMember(doc, jobs, undefined, options.member, env);
255
+ const sections = [
256
+ emitYAMLEntry("workflow", doc.workflow),
257
+ emitYAMLEntry("stages", stages),
258
+ emitYAMLEntry("variables", doc.variables),
259
+ ...jobs.map((job) => emitYAMLEntry(job.jobName, doc[job.jobName])),
260
+ ];
261
+ return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
262
+ }
263
+
264
+ /**
265
+ * The merge-request pipeline (#3183): `plan` in each merge request pipeline,
266
+ * `apply` on each push to the target branch, one apply at a time per
267
+ * environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
268
+ * they measure the change with. The jobs talk to GitLab with
269
+ * `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
270
+ * set as a masked CI/CD variable, since a job token cannot write notes.
271
+ */
272
+ function prLoopGitlabPipeline(
273
+ env: string,
274
+ image: string,
275
+ beforeScript: string[],
276
+ extraScript: string[],
277
+ options: GenerateGitlabOptions,
278
+ ): GenerateGitlabResult {
279
+ const loop = options.prLoop!;
280
+ const [plan, apply] = prLoopJobs("gitlab", env, loop);
281
+ const onMergeRequest = '$CI_PIPELINE_SOURCE == "merge_request_event"';
282
+ const target = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
283
+ const onMerge = `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ${target}`;
284
+ const script = (job: typeof plan): string[] => [...beforeScript, job.command, ...extraScript];
285
+ const stages = ["plan", "apply"];
286
+ const doc: Record<string, unknown> = {
287
+ workflow: { name: `chant-pr-${env}`, rules: [{ if: onMergeRequest }, { if: onMerge }] },
288
+ stages,
289
+ variables: { ...options.variables, CHANT_ENV: env, GIT_DEPTH: "0" },
290
+ plan: {
291
+ stage: "plan",
292
+ image,
293
+ rules: [{ if: onMergeRequest }],
294
+ variables: plan.env,
295
+ script: script(plan),
296
+ artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
297
+ },
298
+ apply: {
299
+ stage: "apply",
300
+ image,
301
+ resource_group: prApplyGroup(env),
302
+ rules: [{ if: onMerge }],
303
+ variables: apply.env,
304
+ script: script(apply),
305
+ artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
306
+ },
307
+ };
308
+ const jobs: GeneratedJob[] = [
309
+ { jobName: "plan", component: "merge request plan", stage: "plan", needs: [] },
310
+ { jobName: "apply", component: "merge request apply", stage: "apply", needs: [] },
311
+ ];
312
+ const sections = ["workflow", "stages", "variables", "plan", "apply"].map((key) => emitYAMLEntry(key, doc[key]));
313
+ return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
314
+ }
315
+
205
316
  /**
206
317
  * Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
207
318
  * `doc` and `jobs`. Returns the promote job's new name.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The generated merge-request pipeline for GitLab CI (#3183): a plan job in
3
+ * each merge request pipeline, an apply job on each push to the default
4
+ * branch. The whole file has a golden, and it validates against the GitLab
5
+ * CI schema this lexicon vendors.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test } from "vitest";
12
+ import { parseYAML } from "@intentius/chant/yaml";
13
+ import type { DriverComponent } from "@intentius/chant/components/driver";
14
+ import { generateGitlabPipeline } from "./generate-pipeline";
15
+
16
+ const ESTATE: DriverComponent[] = [
17
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
18
+ { name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
19
+ ];
20
+
21
+ const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop.gitlab.golden.yml");
22
+ const SCHEMA = join(import.meta.dirname, "..", "codegen", "vendored", "gitlab-ci-schema.json");
23
+
24
+ interface Job {
25
+ rules: Array<{ if: string }>;
26
+ variables: Record<string, string>;
27
+ script: string[];
28
+ resource_group?: string;
29
+ artifacts: Record<string, unknown>;
30
+ }
31
+
32
+ describe("the GitLab merge-request pipeline", () => {
33
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} });
34
+ const doc = parseYAML(result.yaml) as { workflow: { rules: unknown[] }; variables: Record<string, string>; plan: Job; apply: Job };
35
+
36
+ test("matches its golden", () => {
37
+ if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
38
+ expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
39
+ });
40
+
41
+ test("validates against the vendored GitLab CI schema", () => {
42
+ const req = createRequire(import.meta.url);
43
+ const mod = req("ajv") as { default?: unknown };
44
+ const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): ((d: unknown) => boolean) & { errors?: unknown } };
45
+ const validate = new Ajv({ strict: false, allErrors: true }).compile(JSON.parse(readFileSync(SCHEMA, "utf-8")));
46
+ const ok = validate(doc);
47
+ expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
48
+ });
49
+
50
+ test("plans in a merge request pipeline from its diff base, applies on a push to the default branch, one at a time", () => {
51
+ expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
52
+ 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']);
54
+ expect(doc.apply.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
55
+ expect(doc.apply.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
56
+ expect(doc.apply.script[0]).toContain("--require-review");
57
+ expect(doc.apply.resource_group).toBe("chant-apply-prod");
58
+ expect(doc.variables.GIT_DEPTH).toBe("0");
59
+ expect(doc.apply.artifacts).toEqual({ when: "always", paths: [".chant/pr"] });
60
+ });
61
+
62
+ test("a named branch replaces the default branch in the apply rule", () => {
63
+ const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "trunk" } }).yaml;
64
+ expect(yaml).toContain('$CI_COMMIT_BRANCH == "trunk"');
65
+ });
66
+
67
+ test("refuses a wave or promote job beside it", () => {
68
+ expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
69
+ });
70
+ });