@intentius/chant-lexicon-gitlab 0.124.54 → 0.124.56

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 (42) hide show
  1. package/dist/components/agent-jobs.d.ts +68 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +135 -0
  4. package/dist/components/control-pipeline.d.ts +24 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +28 -0
  7. package/dist/components/generate-op-pipeline.d.ts +9 -0
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +147 -23
  10. package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +110 -12
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +135 -11
  15. package/dist/components/review-jobs.d.ts +72 -0
  16. package/dist/components/review-jobs.d.ts.map +1 -0
  17. package/dist/components/review-jobs.js +124 -0
  18. package/dist/integrity.json +2 -2
  19. package/dist/manifest.json +1 -1
  20. package/package.json +3 -3
  21. package/src/components/__golden__/op-waves-jobs.yml +11 -1
  22. package/src/components/agent-jobs.ts +176 -0
  23. package/src/components/control-pipeline.ts +38 -0
  24. package/src/components/generate-op-pipeline.agent.test.ts +99 -0
  25. package/src/components/generate-op-pipeline.cloud.test.ts +40 -0
  26. package/src/components/generate-op-pipeline.review.test.ts +72 -7
  27. package/src/components/generate-op-pipeline.ts +157 -24
  28. package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
  29. package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
  30. package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
  31. package/src/components/generate-op-waves-pipeline.test.ts +22 -3
  32. package/src/components/generate-op-waves-pipeline.ts +123 -12
  33. package/src/components/generate-pipeline.ts +143 -9
  34. package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
  35. package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
  36. package/src/components/generate-pr-pipeline.comment-targets.test.ts +20 -0
  37. package/src/components/generate-pr-pipeline.control.test.ts +81 -0
  38. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
  39. package/src/components/generate-pr-pipeline.respond.test.ts +27 -0
  40. package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
  41. package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
  42. package/src/components/review-jobs.ts +163 -0
@@ -24,8 +24,9 @@
24
24
  * arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
25
25
  * scope and the Developer role; the job token cannot retry jobs).
26
26
  *
27
- * A `pr-review` wave (#3684) adds `<name>-record-plans`, run in merge request
28
- * pipelines only, which records the digests the merge request's head plans.
27
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
28
+ * `<name>-record-plans`, run in merge request pipelines only, which records
29
+ * the digests the merge request's head plans.
29
30
  *
30
31
  * `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
31
32
  * GitHub runner group is a spec error here. A wave's `jobs` renders its
@@ -37,6 +38,26 @@
37
38
  * entry per cloud, and its script starts by trading them for those
38
39
  * identities and unsetting the token variables, before any line of the
39
40
  * checkout runs.
41
+ *
42
+ * With `control` on the spec (#4617), a `control-dispatch` job in a
43
+ * `control` stage needs the last wave's jobs, whose artifacts hold every
44
+ * job's outcome file. It adds them up with `chant run wave --waves-outcome`
45
+ * (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
46
+ * control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
47
+ * whose runs failed (exit 1) still lets it run; a wave that waits at its
48
+ * gate stops it with the waves, and it runs once the retried job carries
49
+ * on. A CI/CD variable reaches every job, so every other job starts by
50
+ * unsetting the control repo's token variables and the product's two
51
+ * secret names; the dispatch step reads the token from the product's
52
+ * variable alone and hands it to curl on its stdin (#4568).
53
+ *
54
+ * Each wave job's script starts with the apply-job guard (#4624,
55
+ * `@intentius/chant/ci-job-guard`), before the trade: it stops the job
56
+ * unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
57
+ * `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
58
+ * holds each of the job's tags. A refused guard exits chant's `misplaced`
59
+ * (9), outside the job's allowed failure, so the job fails and the waves
60
+ * after it stop.
40
61
  */
41
62
 
42
63
  import {
@@ -55,14 +76,34 @@ import {
55
76
  nonEmpty,
56
77
  pushBranches,
57
78
  } from "@intentius/chant/ci-jobs";
58
- import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
79
+ import {
80
+ opWaveGroupOf,
81
+ opWaveJobs,
82
+ opWaveOutcomeDir,
83
+ opWavesControlTarget,
84
+ opWavesLastJobs,
85
+ OP_WAVES_APPROVAL_STATUS_VAR,
86
+ opWavesApprovalStatusCommand,
87
+ opWavesPostsApprovalStatus,
88
+ opWavesOutcomeCommand,
89
+ opWavesRecordPlansCommand,
90
+ opWavesResumeCommand,
91
+ type OpWavesSpec,
92
+ } from "@intentius/chant/op/op-waves";
93
+ import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
59
94
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
60
95
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
96
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
97
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
98
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
61
99
  import { emitGitlabFile } from "./ci-jobs";
62
100
 
101
+ /** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
102
+ export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
103
+
63
104
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
64
105
  export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
65
- const jobs = opWaveJobs(spec, options.specFile);
106
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
66
107
  const image = options.image ?? CI_IMAGE;
67
108
  const subject = `Op waves "${spec.name}"`;
68
109
  const rules = pushBranches(spec.branches).map((branch) => {
@@ -84,9 +125,15 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
84
125
  }
85
126
  }
86
127
  const specTags = gitlabTags(spec.runner);
128
+ const product = resolveProductWords(options.words ?? {}).product;
87
129
 
88
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
130
+ // #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
131
+ const byReview = opWavesPostsApprovalStatus(spec);
89
132
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
133
+ // #4617: the control repo's token variables, which every job but the dispatch drops first.
134
+ const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
135
+ const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
136
+ if (control) stages.push("control");
90
137
  if (spec.resume) stages.push("resume");
91
138
  const entries: Array<readonly [string, unknown]> = [["stages", stages]];
92
139
  const variables = nonEmpty("variables", options.variables).variables;
@@ -100,7 +147,19 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
100
147
  ...(specTags ? { tags: specTags } : {}),
101
148
  variables: { ...GITLAB_FULL_HISTORY },
102
149
  rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
103
- script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
150
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
151
+ },
152
+ ]);
153
+ // #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
154
+ entries.push([
155
+ `${spec.name}-approval-status`,
156
+ {
157
+ stage: "plans",
158
+ image,
159
+ ...(specTags ? { tags: specTags } : {}),
160
+ variables: { ...GITLAB_FULL_HISTORY },
161
+ rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
162
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
104
163
  },
105
164
  ]);
106
165
  }
@@ -114,6 +173,16 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
114
173
  const cloud = group?.cloud ?? wave.cloud;
115
174
  const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
116
175
  const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
176
+ // #4624: the apply-job guard, the script's first line, before the trade.
177
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
178
+ const guard = jobGuardSpec({
179
+ forge: "gitlab",
180
+ job: opWaveJobLabel(spec.name, job),
181
+ product,
182
+ ...(environment ? { environment: environment.name } : {}),
183
+ branches: pushBranches(spec.branches),
184
+ ...(runner !== undefined ? { runner } : {}),
185
+ });
117
186
  entries.push([
118
187
  job.jobName,
119
188
  {
@@ -123,16 +192,52 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
123
192
  resource_group: `${spec.name}-${wave.name}`,
124
193
  ...(environment ? { environment: environmentDoc(environment) } : {}),
125
194
  ...(idTokens ? { id_tokens: idTokens } : {}),
126
- variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
195
+ variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
127
196
  rules,
128
197
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
129
- script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
130
- // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
131
- allow_failure: { exit_codes: [1] },
198
+ script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
199
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
200
+ // a refused guard (9, #4624) included.
201
+ allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
132
202
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
133
203
  },
134
204
  ]);
135
205
  }
206
+ if (control) {
207
+ const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
208
+ ...(options.words ? { words: options.words } : {}),
209
+ ...(spec.control!.sentCheck ? { sentCheck: spec.control!.sentCheck } : {}),
210
+ ...(spec.control!.markSent ? { markSent: spec.control!.markSent } : {}),
211
+ outcome: true,
212
+ });
213
+ entries.push([
214
+ dispatch.name,
215
+ {
216
+ stage: "control",
217
+ image,
218
+ ...(specTags ? { tags: specTags } : {}),
219
+ rules,
220
+ // The last wave's jobs hand on every job's outcome file through their artifacts.
221
+ needs: opWavesLastJobs(jobs),
222
+ // The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
223
+ script: [
224
+ [
225
+ 'outcome="$(',
226
+ ...dropControl,
227
+ "{ :",
228
+ ...setupOf[0]!,
229
+ ...(options.beforeScript ?? []),
230
+ "} 1>&2",
231
+ opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
232
+ ')" || exit 1',
233
+ ].join("\n"),
234
+ `export ${CONTROL_OUTCOME_VAR}="$outcome"`,
235
+ ...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
236
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
237
+ ],
238
+ },
239
+ ]);
240
+ }
136
241
  if (spec.resume) {
137
242
  entries.push([
138
243
  `${spec.name}-resume`,
@@ -141,7 +246,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
141
246
  image,
142
247
  ...(specTags ? { tags: specTags } : {}),
143
248
  rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
144
- script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
249
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
145
250
  },
146
251
  ]);
147
252
  }
@@ -149,10 +254,16 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
149
254
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
150
255
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
151
256
  "# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
152
- "# failure, and the waves after it run what does not depend on those runs.",
257
+ "# failure, and the waves after it run what does not depend on those runs. A job",
258
+ "# whose guard finds it running where the spec did not set it to run exits 9 and",
259
+ "# stops the waves after it.",
153
260
  ...(spec.resume
154
261
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
155
262
  : []),
263
+ ...(byReview
264
+ ? [`# The ${spec.name}-approval-status job posts each open merge request's approval status from a pipeline schedule that sets ${OP_WAVES_APPROVAL_STATUS_VAR}=1.`]
265
+ : []),
266
+ ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
156
267
  ];
157
268
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
158
269
  return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
@@ -34,12 +34,14 @@
34
34
 
35
35
  import {
36
36
  assertGitlabBranch,
37
+ assertRunnerLabels,
37
38
  CI_IMAGE,
38
39
  ciJobName,
39
40
  fillCommand,
40
41
  GITLAB_FULL_HISTORY,
41
42
  GITLAB_MERGE_REQUEST_SOURCE,
42
43
  gitlabPushRule,
44
+ gitlabTags,
43
45
  } from "@intentius/chant/ci-jobs";
44
46
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
45
47
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
@@ -61,11 +63,19 @@ import {
61
63
  prLoopWaveJobs,
62
64
  prLoopWithWaves,
63
65
  prEnvJobs,
66
+ prCommentLockJobs,
67
+ prCommentPreviewJob,
64
68
  PR_ENV_REPORT_DIR,
65
69
  PR_ENV_POST_JOB,
70
+ PR_STALE_JOB,
71
+ prStaleJob,
72
+ prLoopJobStage,
73
+ prLoopRunner,
66
74
  } from "@intentius/chant/components/pr-pipeline";
75
+ import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
67
76
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
68
77
  import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
78
+ import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
69
79
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV, type CloudTradeSpec } from "@intentius/chant/ci-identity";
70
80
  import type {
71
81
  ComponentPipelineJob as GeneratedJob,
@@ -358,6 +368,9 @@ function gitlabPlanNotesRule(branch: string): string {
358
368
  return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
359
369
  }
360
370
 
371
+ /** Drops the credentials GitLab's runner writes into origin's URL (#4620), before a merge request's code runs in a pipeline of the target branch. */
372
+ const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
373
+
361
374
  /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
362
375
  const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
363
376
 
@@ -397,7 +410,7 @@ function prLoopGitlabPipeline(
397
410
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
398
411
  // #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
399
412
  const handOff = (job: typeof plan): string[] => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
400
- const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
413
+ const script = (job: typeof plan): string[] => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
401
414
  // #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
402
415
  const identity = loop.identity;
403
416
  const trading = (spec: CloudTradeSpec | undefined): Record<string, unknown> =>
@@ -406,13 +419,46 @@ function prLoopGitlabPipeline(
406
419
  const applySpec = (job: typeof plan): CloudTradeSpec | undefined => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
407
420
  // #4298: a pull request environment's jobs, in a stage of their own after the plan.
408
421
  const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
409
- const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
422
+ // #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
423
+ const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1]!.name : undefined;
424
+ const control =
425
+ loop.control && lastGreen
426
+ ? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
427
+ ...(loop.words ? { words: loop.words } : {}),
428
+ ...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
429
+ ...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
430
+ })
431
+ : undefined;
432
+ const controlJob = control ? named(control.name) : undefined;
433
+ // A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
434
+ const dropControl = loop.control
435
+ ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
436
+ : [];
437
+ // #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
438
+ // pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
439
+ const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
440
+ const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
441
+ job,
442
+ name: named(job.jobName),
443
+ rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
444
+ }));
445
+ // #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
446
+ const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
447
+ const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
448
+ const previewName = preview ? named(preview.jobName) : undefined;
449
+ const previewNoteName = preview ? named(preview.noteJobName) : undefined;
450
+ // #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
451
+ const stale = prStaleJob("gitlab", env, loop, member?.name);
452
+ const staleName = stale ? named(PR_STALE_JOB) : undefined;
453
+ const stages = ["plan", PR_PLAN_NOTE_JOB, ...(stale ? [PR_STALE_JOB] : []), ...commentLocks.map((c) => c.job.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applies.map((a) => a.stage), ...(control ? [control.name] : []), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
410
454
  const noteJob = named(PR_PLAN_NOTE_JOB);
411
- const post = prLoopPostJob("gitlab", env, member?.name);
455
+ const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
456
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
457
+ const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
412
458
  const doc: Record<string, unknown> = {
413
459
  // Included files merge their top-level keys, so a member's file carries
414
460
  // the rules every member shares and no name: two names would collide.
415
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
461
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...commentLocks.map((c) => ({ if: c.rule })), ...(previewRule ? [{ if: previewRule }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
416
462
  stages,
417
463
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
418
464
  [planJob]: {
@@ -423,7 +469,7 @@ function prLoopGitlabPipeline(
423
469
  variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
424
470
  // #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
425
471
  // #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
426
- script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
472
+ script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
427
473
  artifacts: { when: "always", paths: [reportPath] },
428
474
  },
429
475
  // #4295, #4328: a job of the target branch's pipeline posts each open merge request's note and status from its plan job's report.
@@ -431,8 +477,62 @@ function prLoopGitlabPipeline(
431
477
  stage: PR_PLAN_NOTE_JOB,
432
478
  image,
433
479
  rules: [{ if: onPlanNotes }],
434
- script: [...enter, ...beforeScript, post.command],
480
+ ...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
481
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
435
482
  },
483
+ ...(stale && staleName
484
+ ? {
485
+ // The pushed commit of the target branch: none of a merge request's code is here.
486
+ [staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
487
+ }
488
+ : {}),
489
+ ...Object.fromEntries(
490
+ commentLocks.map(({ job, name, rule }) => [
491
+ name,
492
+ // The target branch's commit, so none of the merge request's code runs here.
493
+ { stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
494
+ ]),
495
+ ),
496
+ ...(preview && previewName && previewNoteName && previewRule
497
+ ? {
498
+ [previewName]: {
499
+ stage: preview.jobName,
500
+ image,
501
+ rules: [{ if: previewRule }],
502
+ ...trading(identity?.plan),
503
+ variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
504
+ // A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
505
+ script: [
506
+ ...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
507
+ ...dropControl,
508
+ "unset CHANT_RUNNER_KEY",
509
+ ...("line" in preview.head ? [preview.head.line] : []),
510
+ // The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
511
+ "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
512
+ // The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
513
+ GITLAB_SCRUB_ORIGIN,
514
+ preview.base,
515
+ ...prLoopSetup(image, loop.engineSetup),
516
+ ...enter,
517
+ ...beforeScript,
518
+ ...(preview.plan.setup ?? []),
519
+ ...(preview.plan.build ?? []),
520
+ preview.plan.run.command,
521
+ ...extraScript,
522
+ ],
523
+ artifacts: { when: "always", paths: [reportPath] },
524
+ },
525
+ [previewNoteName]: {
526
+ stage: preview.noteJobName,
527
+ image,
528
+ needs: [{ job: previewName, artifacts: true }],
529
+ rules: [{ if: previewRule, when: "always" }],
530
+ variables: preview.post.env,
531
+ // The target branch's commit: the job's own clone, where none of the merge request's code runs.
532
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
533
+ },
534
+ }
535
+ : {}),
436
536
  ...Object.fromEntries(
437
537
  applies.map(({ job, name, stage }, i) => [
438
538
  name,
@@ -440,7 +540,8 @@ function prLoopGitlabPipeline(
440
540
  stage,
441
541
  image,
442
542
  ...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
443
- resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
543
+ // #4552: on the repo grain every apply of the repository shares one resource group.
544
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
444
545
  // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
445
546
  rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
446
547
  ...trading(applySpec(job)),
@@ -462,20 +563,53 @@ function prLoopGitlabPipeline(
462
563
  },
463
564
  ]),
464
565
  ),
566
+ ...(control && controlJob
567
+ ? {
568
+ [controlJob]: {
569
+ stage: control.name,
570
+ image,
571
+ needs: [lastGreen],
572
+ rules: [{ if: onGreen }],
573
+ // The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
574
+ script: [
575
+ ...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
576
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
577
+ ],
578
+ },
579
+ }
580
+ : {}),
465
581
  ...(prEnv ? prEnv.jobs : {}),
466
582
  };
467
583
  const jobs: GeneratedJob[] = [
468
584
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
469
585
  { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
586
+ ...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
587
+ ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
588
+ ...(preview && previewName && previewNoteName
589
+ ? [
590
+ { jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
591
+ { jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
592
+ ]
593
+ : []),
470
594
  ...applies.map(({ job, name, stage }, i) => ({
471
595
  jobName: name,
472
596
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
473
597
  stage,
474
598
  needs: i > 1 ? [applies[i - 1]!.name] : [],
475
599
  })),
600
+ ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control!.name, needs: [lastGreen!] }] : []),
476
601
  ...(prEnv ? prEnv.listed : []),
477
602
  ];
478
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]] as const));
603
+ // #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
604
+ if (loop.runners) {
605
+ for (const stage of ["plan", "apply", "default"] as const) assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
606
+ for (const job of jobs) {
607
+ const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
608
+ const entry = doc[job.jobName] as Record<string, unknown> | undefined;
609
+ if (tags && entry) doc[job.jobName] = { ...entry, tags };
610
+ }
611
+ }
612
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...(staleName ? [staleName] : []), ...commentLocks.map((c) => c.name), ...(previewName && previewNoteName ? [previewName, previewNoteName] : []), ...applies.map((a) => a.name), ...(controlJob ? [controlJob] : []), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]] as const));
479
613
  return { yaml, stages, jobs, env };
480
614
  }
481
615
 
@@ -511,7 +645,7 @@ function prEnvGitlabJobs(
511
645
  const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
512
646
  const environment = "pr-$CI_MERGE_REQUEST_IID";
513
647
  const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
514
- const script = (j: typeof up): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
648
+ const script = (j: typeof up): string[] => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
515
649
  const artifacts = { when: "always", paths: [reportPath] };
516
650
  // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
517
651
  const identity = loop.prEnvIdentity;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `/<product> lock` and `/<product> unlock` on GitLab (#4588). GitLab starts
3
+ * no pipeline on a note, so each job runs in a pipeline of the target
4
+ * branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set to the merge
5
+ * request's iid, as the apply before merge does with CHANT_APPLY_PR.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { generateGitlabPipeline } from "./generate-pipeline";
12
+
13
+ const ESTATE: DriverComponent[] = [
14
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
15
+ ];
16
+
17
+ const WORDS = { product: "plywood", commands: { lock: "lock" as const, unlock: "unlock" as const }, bin: "npx --no-install plywood" };
18
+
19
+ interface Job { stage: string; rules: Array<{ if: string }>; variables: Record<string, string>; script: string[] }
20
+ type Doc = { workflow: { rules: Array<{ if: string }> }; stages: string[] } & Record<string, Job>;
21
+
22
+ describe("the GitLab pipeline's lock and unlock comments", () => {
23
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { locks: "plan", words: WORDS } }).yaml) as Doc;
24
+ const LOCK = '$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $CHANT_LOCK_PR =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
25
+
26
+ test("each job runs in a target-branch pipeline started with its variable", () => {
27
+ expect(doc["comment-lock"]!.rules).toEqual([{ if: LOCK }]);
28
+ expect(doc.workflow.rules.map((r) => r.if)).toContain(LOCK);
29
+ expect(doc["comment-unlock"]!.rules[0]!.if).toContain("$CHANT_UNLOCK_PR");
30
+ expect(doc.stages).toEqual(expect.arrayContaining(["comment-lock", "comment-unlock"]));
31
+ });
32
+
33
+ test("the merge request's iid comes from the variable, and the lines run the product's bin", () => {
34
+ expect(doc["comment-lock"]!.variables).toEqual({ PR_NUMBER: "$CHANT_LOCK_PR" });
35
+ expect(doc["comment-lock"]!.script.at(-1)).toBe('npx --no-install plywood lock --keep --pr "$PR_NUMBER" --forge gitlab');
36
+ expect(doc["comment-unlock"]!.script.at(-1)).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
37
+ });
38
+
39
+ test("neither job without ci.locks", () => {
40
+ const plain = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { words: WORDS } }).yaml) as Doc;
41
+ expect(plain["comment-lock"]).toBeUndefined();
42
+ });
43
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The preview comment on GitLab (#4620). GitLab starts no pipeline on a
3
+ * note, so both jobs run in a pipeline of the target branch started with
4
+ * CHANT_PREVIEW_PR set to the merge request's iid. The preview job drops the
5
+ * protected tokens and origin's credentials before the merge request's code
6
+ * runs; the note job posts from its own clone.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { parseYAML } from "@intentius/chant/yaml";
11
+ import type { DriverComponent } from "@intentius/chant/components/driver";
12
+ import { generateGitlabPipeline } from "./generate-pipeline";
13
+
14
+ const ESTATE: DriverComponent[] = [
15
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
16
+ ];
17
+
18
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
19
+
20
+ interface Job { stage: string; rules: Array<{ if: string; when?: string }>; needs?: unknown[]; variables: Record<string, string>; script: string[] }
21
+ type Doc = { workflow: { rules: Array<{ if: string }> }; stages: string[] } & Record<string, Job>;
22
+
23
+ describe("the GitLab pipeline's preview comment", () => {
24
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Doc;
25
+ const RULE = '$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $CHANT_PREVIEW_PR =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
26
+ const preview = doc["comment-preview"]!;
27
+ const note = doc["comment-preview-note"]!;
28
+
29
+ test("both jobs run in a target-branch pipeline started with CHANT_PREVIEW_PR", () => {
30
+ expect(preview.rules).toEqual([{ if: RULE }]);
31
+ expect(note.rules).toEqual([{ if: RULE, when: "always" }]);
32
+ expect(doc.workflow.rules.map((r) => r.if)).toContain(RULE);
33
+ expect(doc.stages).toEqual(expect.arrayContaining(["comment-preview", "comment-preview-note"]));
34
+ });
35
+
36
+ test("the preview job drops the tokens, then checks out the head and scrubs origin before any of its code runs", () => {
37
+ const script = preview.script;
38
+ const drop = script.findIndex((l) => l.startsWith("unset CHANT_FORGE_TOKEN"));
39
+ const head = script.findIndex((l) => l.includes("refs/merge-requests/$PR_NUMBER/head"));
40
+ const scrub = script.findIndex((l) => l.startsWith("git remote set-url origin"));
41
+ const plan = script.findIndex((l) => l.includes("plywood ci run plan"));
42
+ expect(drop).toBeGreaterThanOrEqual(0);
43
+ expect(script).toContain("unset CHANT_RUNNER_KEY");
44
+ expect(drop).toBeLessThan(head);
45
+ expect(head).toBeLessThan(scrub);
46
+ expect(scrub).toBeLessThan(plan);
47
+ expect(preview.variables.PR_NUMBER).toBe("$CHANT_PREVIEW_PR");
48
+ expect(preview.variables.BASE_SHA).toBe("$CI_COMMIT_SHA");
49
+ });
50
+
51
+ test("the note job finds the head again and posts for that merge request", () => {
52
+ expect(note.variables).toEqual({ PR_NUMBER: "$CHANT_PREVIEW_PR" });
53
+ expect(note.script.some((l) => l.includes("git ls-remote origin"))).toBe(true);
54
+ expect(note.script.at(-1)).toBe('npx --no-install plywood components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab');
55
+ });
56
+
57
+ test("no preview job without previewComment", () => {
58
+ const plain = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { words: WORDS } }).yaml) as Doc;
59
+ expect(plain["comment-preview"]).toBeUndefined();
60
+ });
61
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The preview comment's --target on GitLab (#4626): the preview job runs
3
+ * `pr-plan --comment-targets`, which reads CHANT_PREVIEW_COMMENT, a variable
4
+ * of the target-branch pipeline. Written for the ci-products post-merge pass.
5
+ */
6
+ import { expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import type { DriverComponent } from "@intentius/chant/components/driver";
9
+ import { generateGitlabPipeline } from "./generate-pipeline";
10
+
11
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
12
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
13
+
14
+ test("the preview job runs pr-plan --comment-targets (#4626)", () => {
15
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Record<string, { script: string[]; variables: Record<string, string> }>;
16
+ const preview = doc["comment-preview"]!;
17
+ expect(preview.script.join("\n")).toContain("--comment-targets");
18
+ expect(preview.variables).not.toHaveProperty("CHANT_PREVIEW_COMMENT");
19
+ expect(doc.plan!.script.join("\n")).not.toContain("--comment-targets");
20
+ });