@intentius/chant-lexicon-gitlab 0.124.32 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAeH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqHhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAsGxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAeH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAsGxB"}
@@ -191,8 +191,14 @@ function headerLineFor(spec, trigger, jobName, mode) {
191
191
  }
192
192
  case "push": {
193
193
  const branches = pushBranches(trigger.branches);
194
+ // #4169: GitLab cancels a waiting pipeline when a newer commit's starts, under the project's auto-cancel setting.
195
+ const build = trigger.build === "newest"
196
+ ? "; builds the newest commit and each ci/build/<sha> tag: keep Settings > CI/CD > Auto-cancel redundant pipelines on"
197
+ : trigger.build === "every"
198
+ ? "; builds every commit: turn Settings > CI/CD > Auto-cancel redundant pipelines off"
199
+ : "";
194
200
  return (`# ${jobName}: push to ${branches.join(", ")}, finding-mode ${mode}${tokenNote}` +
195
- ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}`);
201
+ ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}${build}`);
196
202
  }
197
203
  }
198
204
  }
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAejG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAgBjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
@@ -35,7 +35,7 @@ import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTO
35
35
  import { resolveComponentGraph } from "@intentius/chant/components/driver";
36
36
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
37
37
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
38
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
38
+ import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
39
39
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
40
40
  import { emitGitlabFile } from "./ci-jobs.js";
41
41
  /**
@@ -229,8 +229,8 @@ function gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScri
229
229
  }
230
230
  /**
231
231
  * The merge-request pipeline (#3183): `plan` in each merge request pipeline,
232
- * `apply` on each push to the target branch, one apply at a time per
233
- * environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
232
+ * and on each push to the target branch `record` (#4168), or `apply` with
233
+ * `applyOnMerge`, one at a time per environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
234
234
  * they measure the change with. The jobs talk to GitLab with
235
235
  * `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
236
236
  * set as a masked CI/CD variable, since a job token cannot write notes.
@@ -260,14 +260,16 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
260
260
  const loop = options.prLoop;
261
261
  const member = options.member;
262
262
  const rooted = !member || member.dir === "." || member.dir === "";
263
- const [plan, apply] = prLoopJobs("gitlab", env, loop, member?.name);
263
+ const [plan, apply, record] = prLoopJobs("gitlab", env, loop, member?.name);
264
264
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
265
265
  const planJob = member ? `${member.name}-plan` : "plan";
266
266
  const named = (job) => (member ? `${member.name}-${job}` : job);
267
267
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
268
268
  const applies = loop.waveGate
269
269
  ? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave)), stage: prApplyWaveJobName(job.wave) }))
270
- : [{ job: apply, name: named("apply"), stage: "apply" }];
270
+ : loop.applyOnMerge
271
+ ? [{ job: apply, name: named("apply"), stage: "apply" }]
272
+ : [{ job: record, name: named("record"), stage: "record" }];
271
273
  const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
272
274
  if (loop.branch !== undefined)
273
275
  assertGitlabBranch("The merge-request pipeline", loop.branch);
@@ -297,17 +299,22 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
297
299
  stage,
298
300
  image,
299
301
  ...(i > 0 ? { needs: [applies[i - 1].name] } : {}),
300
- resource_group: prApplyGroup(env, member?.name),
302
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
301
303
  rules: [{ if: onMerge }],
302
304
  variables: job.run.env,
303
305
  script: script(job),
304
306
  // The attempt record (#3543), kept under the pushed commit even when
305
307
  // the job fails, so a retry finishes the apply under the same approval.
306
- cache: {
307
- key: prApplyRecordKey(env, "$CI_COMMIT_SHA", member?.name),
308
- paths: [member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD],
309
- when: "always",
310
- },
308
+ // The record job (#4168) applies nothing and keeps none.
309
+ ...(job.jobName === "record"
310
+ ? {}
311
+ : {
312
+ cache: {
313
+ key: prApplyRecordKey(env, "$CI_COMMIT_SHA", member?.name),
314
+ paths: [member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD],
315
+ when: "always",
316
+ },
317
+ }),
311
318
  artifacts: { when: "always", paths: [reportPath] },
312
319
  },
313
320
  ])),
@@ -316,7 +323,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
316
323
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
317
324
  ...applies.map(({ job, name, stage }, i) => ({
318
325
  jobName: name,
319
- component: job.wave ? `merge request apply, wave ${job.wave}` : "merge request apply",
326
+ component: job.wave ? `merge request apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "merge request apply",
320
327
  stage,
321
328
  needs: i > 0 ? [applies[i - 1].name] : [],
322
329
  })),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "be91342ce1ba95512e87deebcd929d0afcadc076160491773630b2e7b62097de",
4
+ "manifest.json": "ebb9c8f2b23dabf3657d08be03ccb5ac5aa083073552df047f3d84f56ee8b288",
5
5
  "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
6
  "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "b0fefd5cf9a9c881b5a84df608913c43c5995c4b319d2ee76a89555a635de276"
58
+ "composite": "8da150c5882bad8c14689fa870f26cff56ae3edb1d262d88ae5f11749ad2a3f7"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.32",
3
+ "version": "0.124.33",
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.124.32",
3
+ "version": "0.124.33",
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.124.32",
71
- "@intentius/chant-lexicon-github": "^0.124.32"
70
+ "@intentius/chant": "^0.124.33",
71
+ "@intentius/chant-lexicon-github": "^0.124.33"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -0,0 +1,46 @@
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
+ - record
10
+
11
+ variables:
12
+ CHANT_ENV: prod
13
+ GIT_DEPTH: '0'
14
+
15
+ plan:
16
+ stage: plan
17
+ image: node:22
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
+ - 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)
25
+ - chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab
26
+ artifacts:
27
+ when: always
28
+ paths:
29
+ - .chant/pr
30
+
31
+ record:
32
+ stage: record
33
+ image: node:22
34
+ resource_group: chant-record-prod
35
+ rules:
36
+ - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
37
+ variables:
38
+ BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
39
+ script:
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 --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
42
+ - chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
43
+ artifacts:
44
+ when: always
45
+ paths:
46
+ - .chant/pr
@@ -185,6 +185,16 @@ describe("generateGitlabOpPipeline: the merge_request and push triggers (#2084,
185
185
  ]);
186
186
  });
187
187
 
188
+ test("a newest push build also runs at a build request's tag, and the header says how auto-cancel must be set (#4169)", () => {
189
+ const newest = generateGitlabOpPipeline([{ name: "suites", trigger: { kind: "push", build: "newest" } }]);
190
+ const job = parseYAML(newest.files[0].yaml)["suites"] as { rules: Array<{ if: string }> };
191
+ expect(job.rules.map((r) => r.if)).toEqual(['$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"', "$CI_COMMIT_TAG =~ /^ci\\/build\\//"]);
192
+ expect(newest.files[0].yaml).toContain("Auto-cancel redundant pipelines on");
193
+ const every = generateGitlabOpPipeline([{ name: "suites", trigger: { kind: "push", build: "every" } }]);
194
+ expect((parseYAML(every.files[0].yaml)["suites"] as { rules: unknown[] }).rules).toHaveLength(1);
195
+ expect(every.files[0].yaml).toContain("Auto-cancel redundant pipelines off");
196
+ });
197
+
188
198
  test("an unfiltered push trigger defaults to main, the same branch github's generator assumes", () => {
189
199
  const result = generateGitlabOpPipeline([{ name: "tf-apply", trigger: { kind: "push" } }]);
190
200
  const job = parseYAML(result.files[0].yaml)["tf-apply"] as { rules: Array<{ if: string }> };
@@ -239,9 +239,16 @@ function headerLineFor(
239
239
  }
240
240
  case "push": {
241
241
  const branches = pushBranches(trigger.branches);
242
+ // #4169: GitLab cancels a waiting pipeline when a newer commit's starts, under the project's auto-cancel setting.
243
+ const build =
244
+ trigger.build === "newest"
245
+ ? "; builds the newest commit and each ci/build/<sha> tag: keep Settings > CI/CD > Auto-cancel redundant pipelines on"
246
+ : trigger.build === "every"
247
+ ? "; builds every commit: turn Settings > CI/CD > Auto-cancel redundant pipelines off"
248
+ : "";
242
249
  return (
243
250
  `# ${jobName}: push to ${branches.join(", ")}, finding-mode ${mode}${tokenNote}` +
244
- ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}`
251
+ ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}${build}`
245
252
  );
246
253
  }
247
254
  }
@@ -48,6 +48,7 @@ import {
48
48
  PR_APPLY_RECORD,
49
49
  PR_LOOP_REPORT_DIR,
50
50
  prApplyGroup,
51
+ prRecordGroup,
51
52
  prApplyRecordKey,
52
53
  prApplyWaveJobName,
53
54
  prLoopJobs,
@@ -272,8 +273,8 @@ function gatedWaveGitlabPipeline(
272
273
 
273
274
  /**
274
275
  * The merge-request pipeline (#3183): `plan` in each merge request pipeline,
275
- * `apply` on each push to the target branch, one apply at a time per
276
- * 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
277
278
  * they measure the change with. The jobs talk to GitLab with
278
279
  * `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
279
280
  * set as a masked CI/CD variable, since a job token cannot write notes.
@@ -310,14 +311,16 @@ function prLoopGitlabPipeline(
310
311
  const loop = options.prLoop!;
311
312
  const member = options.member;
312
313
  const rooted = !member || member.dir === "." || member.dir === "";
313
- const [plan, apply] = prLoopJobs("gitlab", env, loop, member?.name);
314
+ const [plan, apply, record] = prLoopJobs("gitlab", env, loop, member?.name);
314
315
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
315
316
  const planJob = member ? `${member.name}-plan` : "plan";
316
317
  const named = (job: string) => (member ? `${member.name}-${job}` : job);
317
318
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
318
319
  const applies = loop.waveGate
319
320
  ? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave!)), stage: prApplyWaveJobName(job.wave!) }))
320
- : [{ job: apply, name: named("apply"), stage: "apply" }];
321
+ : loop.applyOnMerge
322
+ ? [{ job: apply, name: named("apply"), stage: "apply" }]
323
+ : [{ job: record, name: named("record"), stage: "record" }];
321
324
  const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
322
325
  if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
323
326
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
@@ -347,17 +350,22 @@ function prLoopGitlabPipeline(
347
350
  stage,
348
351
  image,
349
352
  ...(i > 0 ? { needs: [applies[i - 1]!.name] } : {}),
350
- resource_group: prApplyGroup(env, member?.name),
353
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
351
354
  rules: [{ if: onMerge }],
352
355
  variables: job.run.env,
353
356
  script: script(job),
354
357
  // The attempt record (#3543), kept under the pushed commit even when
355
358
  // the job fails, so a retry finishes the apply under the same approval.
356
- cache: {
357
- key: prApplyRecordKey(env, "$CI_COMMIT_SHA", member?.name),
358
- paths: [member ? memberRepoPath(member, PR_APPLY_RECORD) : PR_APPLY_RECORD],
359
- when: "always",
360
- },
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
+ }),
361
369
  artifacts: { when: "always", paths: [reportPath] },
362
370
  },
363
371
  ]),
@@ -367,7 +375,7 @@ function prLoopGitlabPipeline(
367
375
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
368
376
  ...applies.map(({ job, name, stage }, i) => ({
369
377
  jobName: name,
370
- component: job.wave ? `merge request apply, wave ${job.wave}` : "merge request apply",
378
+ component: job.wave ? `merge request apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "merge request apply",
371
379
  stage,
372
380
  needs: i > 0 ? [applies[i - 1]!.name] : [],
373
381
  })),
@@ -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", () => {
@@ -57,7 +57,7 @@ describe("the GitLab merge-request pipeline", () => {
57
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
- // #4156: ci run builds pr-apply with --require-review, and GitLab hands CHANT_RUNNER_KEY to the job as a CI/CD variable when it has one.
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
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
62
  expect(result.yaml).not.toContain("CHANT_RUNNER_KEY");
63
63
  expect(doc.apply.resource_group).toBe("chant-apply-prod");
@@ -66,7 +66,7 @@ describe("the GitLab merge-request pipeline", () => {
66
66
  });
67
67
 
68
68
  test("events and reportUrl stream the run's events into the report artifact and copy the reports out (#4007)", () => {
69
- 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 };
70
70
  for (const job of [loud.plan, loud.apply]) {
71
71
  expect(job.script.at(-1)).toContain('--events-json --report-url "$CHANT_REPORT_URL"');
72
72
  expect(job.variables.CHANT_RUN_EVENTS_DIR).toBe(".chant/pr/run-events");
@@ -78,7 +78,7 @@ describe("the GitLab merge-request pipeline", () => {
78
78
  test("runs in an image with git and installs OpenTofu, unless the caller names an image", () => {
79
79
  expect(doc.plan.image).toBe("node:22");
80
80
  expect(doc.apply.script[0]).toBe(PR_LOOP_SETUP[0]);
81
- 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;
82
82
  expect(own).toContain("image: example/ci:1");
83
83
  expect(own).not.toContain("install-opentofu");
84
84
  });
@@ -92,7 +92,7 @@ describe("the GitLab merge-request pipeline", () => {
92
92
  });
93
93
 
94
94
  test("a named branch replaces the default branch in the apply rule", () => {
95
- const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "trunk" } }).yaml;
95
+ const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "trunk" } }).yaml;
96
96
  expect(yaml).toContain('$CI_COMMIT_BRANCH == "trunk"');
97
97
  });
98
98
 
@@ -106,13 +106,13 @@ describe("the GitLab merge-request pipeline", () => {
106
106
  });
107
107
 
108
108
  test("refuses a promote job beside it, and a canary beside --wave-gate (#4003)", () => {
109
- expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, promoteTo: "prod" })).toThrow(/pull-request pipeline has no promote job/);
110
- expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g", canary: ["net"] } })).toThrow(/takes no --canary/);
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/);
111
111
  });
112
112
 
113
113
  test("inside a workspace member: member-named jobs that cd into the member, with no changes rule (#3465)", () => {
114
114
  const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
115
- const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, member });
115
+ const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member });
116
116
  const mdoc = parseYAML(scoped.yaml) as Record<string, unknown> & { workflow: { name?: string; rules: unknown[] } };
117
117
  expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-apply"]);
118
118
  expect(mdoc.plan).toBeUndefined();
@@ -137,12 +137,12 @@ describe("the GitLab merge-request pipeline", () => {
137
137
 
138
138
  describe("the merge-request pipeline's branch (#3765)", () => {
139
139
  test("a branch that would break the rules: expression is refused, as an Op's is", () => {
140
- 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"/);
141
141
  });
142
142
  });
143
143
 
144
144
  describe("the gitlab pull-request pipeline with build (#4088)", () => {
145
- const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { build: true } });
145
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, build: true } });
146
146
  const doc = parseYAML(result.yaml) as Record<string, any>;
147
147
  const golden = join(import.meta.dirname, "__fixtures__", "pr-loop-build.gitlab.golden.yml");
148
148
 
@@ -161,14 +161,52 @@ describe("the gitlab pull-request pipeline with build (#4088)", () => {
161
161
  });
162
162
 
163
163
  test("buildCommand replaces the build line and turns the option on", () => {
164
- 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;
165
165
  expect(yaml).toContain("npm run build");
166
166
  expect(yaml).not.toContain("chant build --env");
167
167
  expect(yaml).toContain("chant lint");
168
168
  });
169
169
 
170
170
  test("the default output carries neither line", () => {
171
- const plain = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} }).yaml;
171
+ const plain = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } }).yaml;
172
172
  expect(plain).not.toContain("chant lint");
173
173
  });
174
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
+ });
@@ -34,7 +34,7 @@ interface Job {
34
34
  }
35
35
 
36
36
  describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () => {
37
- const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {}, gatedWaves: { gate: "release" } });
37
+ const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" } });
38
38
  const doc = parseYAML(result.yaml) as Record<string, unknown> & { stages: string[] };
39
39
 
40
40
  test("matches its golden", () => {
@@ -63,7 +63,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
63
63
  expect(job.stage).toBe(`apply-wave-${k}`);
64
64
  expect(job.needs).toEqual(k > 1 ? [`apply-wave-${k - 1}`] : undefined);
65
65
  expect(job.resource_group).toBe("chant-apply-prod");
66
- // #4156: through chant ci run, which runs pr-apply --require-review --wave-gate release --resume ... --wave <k>.
66
+ // #4156: through chant ci run, which runs pr-apply --wave-gate release (no --require-review since #4168) --resume ... --wave <k>.
67
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
68
  // Every wave shares the commit's record, so wave k pulls what wave k-1 pushed.
69
69
  expect(job.cache).toEqual({ key: "chant-apply-prod-$CI_COMMIT_SHA", paths: [".chant/pr-resume/pr-apply.json"], when: "always" });
@@ -74,7 +74,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
74
74
 
75
75
  test("inside a workspace member the wave jobs carry the member's name", () => {
76
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: {}, gatedWaves: { gate: "release" }, member });
77
+ const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" }, member });
78
78
  expect(scoped.jobs.map((j) => [j.jobName, j.needs])).toEqual([
79
79
  ["network-plan", []],
80
80
  ["network-apply-wave-1", []],