@intentius/chant-lexicon-gitlab 0.124.54 → 0.124.55

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 (34) 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 +107 -22
  10. package/dist/components/generate-op-waves-pipeline.d.ts +12 -0
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +59 -5
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +112 -10
  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/agent-jobs.ts +176 -0
  22. package/src/components/control-pipeline.ts +38 -0
  23. package/src/components/generate-op-pipeline.agent.test.ts +99 -0
  24. package/src/components/generate-op-pipeline.review.test.ts +72 -7
  25. package/src/components/generate-op-pipeline.ts +117 -23
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
  27. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  28. package/src/components/generate-op-waves-pipeline.ts +68 -5
  29. package/src/components/generate-pipeline.ts +117 -9
  30. package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
  31. package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
  32. package/src/components/generate-pr-pipeline.control.test.ts +81 -0
  33. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
  34. package/src/components/review-jobs.ts +163 -0
@@ -37,6 +37,18 @@
37
37
  * entry per cloud, and its script starts by trading them for those
38
38
  * identities and unsetting the token variables, before any line of the
39
39
  * checkout runs.
40
+ *
41
+ * With `control` on the spec (#4617), a `control-dispatch` job in a
42
+ * `control` stage needs the last wave's jobs, whose artifacts hold every
43
+ * job's outcome file. It adds them up with `chant run wave --waves-outcome`
44
+ * (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
45
+ * control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
46
+ * whose runs failed (exit 1) still lets it run; a wave that waits at its
47
+ * gate stops it with the waves, and it runs once the retried job carries
48
+ * on. A CI/CD variable reaches every job, so every other job starts by
49
+ * unsetting the control repo's token variables and the product's two
50
+ * secret names; the dispatch step reads the token from the product's
51
+ * variable alone and hands it to curl on its stdin (#4568).
40
52
  */
41
53
 
42
54
  import {
@@ -55,14 +67,25 @@ import {
55
67
  nonEmpty,
56
68
  pushBranches,
57
69
  } from "@intentius/chant/ci-jobs";
58
- import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
70
+ import {
71
+ opWaveGroupOf,
72
+ opWaveJobs,
73
+ opWaveOutcomeDir,
74
+ opWavesControlTarget,
75
+ opWavesLastJobs,
76
+ opWavesOutcomeCommand,
77
+ opWavesRecordPlansCommand,
78
+ opWavesResumeCommand,
79
+ type OpWavesSpec,
80
+ } from "@intentius/chant/op/op-waves";
81
+ import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
59
82
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
60
83
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
61
84
  import { emitGitlabFile } from "./ci-jobs";
62
85
 
63
86
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
64
87
  export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
65
- const jobs = opWaveJobs(spec, options.specFile);
88
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
66
89
  const image = options.image ?? CI_IMAGE;
67
90
  const subject = `Op waves "${spec.name}"`;
68
91
  const rules = pushBranches(spec.branches).map((branch) => {
@@ -87,6 +110,10 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
87
110
 
88
111
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
89
112
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
113
+ // #4617: the control repo's token variables, which every job but the dispatch drops first.
114
+ const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
115
+ const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
116
+ if (control) stages.push("control");
90
117
  if (spec.resume) stages.push("resume");
91
118
  const entries: Array<readonly [string, unknown]> = [["stages", stages]];
92
119
  const variables = nonEmpty("variables", options.variables).variables;
@@ -100,7 +127,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
100
127
  ...(specTags ? { tags: specTags } : {}),
101
128
  variables: { ...GITLAB_FULL_HISTORY },
102
129
  rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
103
- script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
130
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
104
131
  },
105
132
  ]);
106
133
  }
@@ -126,13 +153,48 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
126
153
  variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
127
154
  rules,
128
155
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
129
- script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
156
+ script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
130
157
  // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
131
158
  allow_failure: { exit_codes: [1] },
132
159
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
133
160
  },
134
161
  ]);
135
162
  }
163
+ if (control) {
164
+ const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
165
+ ...(options.words ? { words: options.words } : {}),
166
+ ...(spec.control!.sentCheck ? { sentCheck: spec.control!.sentCheck } : {}),
167
+ ...(spec.control!.markSent ? { markSent: spec.control!.markSent } : {}),
168
+ outcome: true,
169
+ });
170
+ entries.push([
171
+ dispatch.name,
172
+ {
173
+ stage: "control",
174
+ image,
175
+ ...(specTags ? { tags: specTags } : {}),
176
+ rules,
177
+ // The last wave's jobs hand on every job's outcome file through their artifacts.
178
+ needs: opWavesLastJobs(jobs),
179
+ // The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
180
+ script: [
181
+ [
182
+ 'outcome="$(',
183
+ ...dropControl,
184
+ "{ :",
185
+ ...setupOf[0]!,
186
+ ...(options.beforeScript ?? []),
187
+ "} 1>&2",
188
+ opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
189
+ ')" || exit 1',
190
+ ].join("\n"),
191
+ `export ${CONTROL_OUTCOME_VAR}="$outcome"`,
192
+ ...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
193
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
194
+ ],
195
+ },
196
+ ]);
197
+ }
136
198
  if (spec.resume) {
137
199
  entries.push([
138
200
  `${spec.name}-resume`,
@@ -141,7 +203,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
141
203
  image,
142
204
  ...(specTags ? { tags: specTags } : {}),
143
205
  rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
144
- script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
206
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
145
207
  },
146
208
  ]);
147
209
  }
@@ -153,6 +215,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
153
215
  ...(spec.resume
154
216
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
155
217
  : []),
218
+ ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
156
219
  ];
157
220
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
158
221
  return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
@@ -61,11 +61,15 @@ import {
61
61
  prLoopWaveJobs,
62
62
  prLoopWithWaves,
63
63
  prEnvJobs,
64
+ prCommentLockJobs,
65
+ prCommentPreviewJob,
64
66
  PR_ENV_REPORT_DIR,
65
67
  PR_ENV_POST_JOB,
66
68
  } from "@intentius/chant/components/pr-pipeline";
69
+ import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
67
70
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
68
71
  import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
72
+ import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
69
73
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV, type CloudTradeSpec } from "@intentius/chant/ci-identity";
70
74
  import type {
71
75
  ComponentPipelineJob as GeneratedJob,
@@ -358,6 +362,9 @@ function gitlabPlanNotesRule(branch: string): string {
358
362
  return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
359
363
  }
360
364
 
365
+ /** 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. */
366
+ const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
367
+
361
368
  /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
362
369
  const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
363
370
 
@@ -397,7 +404,7 @@ function prLoopGitlabPipeline(
397
404
  // 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
405
  // #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
399
406
  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];
407
+ 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
408
  // #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
402
409
  const identity = loop.identity;
403
410
  const trading = (spec: CloudTradeSpec | undefined): Record<string, unknown> =>
@@ -406,13 +413,43 @@ function prLoopGitlabPipeline(
406
413
  const applySpec = (job: typeof plan): CloudTradeSpec | undefined => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
407
414
  // #4298: a pull request environment's jobs, in a stage of their own after the plan.
408
415
  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] : [])];
416
+ // #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
417
+ const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1]!.name : undefined;
418
+ const control =
419
+ loop.control && lastGreen
420
+ ? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
421
+ ...(loop.words ? { words: loop.words } : {}),
422
+ ...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
423
+ ...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
424
+ })
425
+ : undefined;
426
+ const controlJob = control ? named(control.name) : undefined;
427
+ // A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
428
+ const dropControl = loop.control
429
+ ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
430
+ : [];
431
+ // #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
432
+ // pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
433
+ const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
434
+ const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
435
+ job,
436
+ name: named(job.jobName),
437
+ rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
438
+ }));
439
+ // #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
440
+ const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
441
+ const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
442
+ const previewName = preview ? named(preview.jobName) : undefined;
443
+ const previewNoteName = preview ? named(preview.noteJobName) : undefined;
444
+ const stages = ["plan", PR_PLAN_NOTE_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
445
  const noteJob = named(PR_PLAN_NOTE_JOB);
411
- const post = prLoopPostJob("gitlab", env, member?.name);
446
+ const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
447
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
448
+ const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
412
449
  const doc: Record<string, unknown> = {
413
450
  // Included files merge their top-level keys, so a member's file carries
414
451
  // 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 }] : [])] },
452
+ 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
453
  stages,
417
454
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
418
455
  [planJob]: {
@@ -423,7 +460,7 @@ function prLoopGitlabPipeline(
423
460
  variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
424
461
  // #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
425
462
  // #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)],
463
+ script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
427
464
  artifacts: { when: "always", paths: [reportPath] },
428
465
  },
429
466
  // #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 +468,55 @@ function prLoopGitlabPipeline(
431
468
  stage: PR_PLAN_NOTE_JOB,
432
469
  image,
433
470
  rules: [{ if: onPlanNotes }],
434
- script: [...enter, ...beforeScript, post.command],
471
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
435
472
  },
473
+ ...Object.fromEntries(
474
+ commentLocks.map(({ job, name, rule }) => [
475
+ name,
476
+ // The target branch's commit, so none of the merge request's code runs here.
477
+ { stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
478
+ ]),
479
+ ),
480
+ ...(preview && previewName && previewNoteName && previewRule
481
+ ? {
482
+ [previewName]: {
483
+ stage: preview.jobName,
484
+ image,
485
+ rules: [{ if: previewRule }],
486
+ ...trading(identity?.plan),
487
+ variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
488
+ // A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
489
+ script: [
490
+ ...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
491
+ ...dropControl,
492
+ "unset CHANT_RUNNER_KEY",
493
+ ...("line" in preview.head ? [preview.head.line] : []),
494
+ // The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
495
+ "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
496
+ // The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
497
+ GITLAB_SCRUB_ORIGIN,
498
+ preview.base,
499
+ ...prLoopSetup(image, loop.engineSetup),
500
+ ...enter,
501
+ ...beforeScript,
502
+ ...(preview.plan.setup ?? []),
503
+ ...(preview.plan.build ?? []),
504
+ preview.plan.run.command,
505
+ ...extraScript,
506
+ ],
507
+ artifacts: { when: "always", paths: [reportPath] },
508
+ },
509
+ [previewNoteName]: {
510
+ stage: preview.noteJobName,
511
+ image,
512
+ needs: [{ job: previewName, artifacts: true }],
513
+ rules: [{ if: previewRule, when: "always" }],
514
+ variables: preview.post.env,
515
+ // The target branch's commit: the job's own clone, where none of the merge request's code runs.
516
+ script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
517
+ },
518
+ }
519
+ : {}),
436
520
  ...Object.fromEntries(
437
521
  applies.map(({ job, name, stage }, i) => [
438
522
  name,
@@ -440,7 +524,8 @@ function prLoopGitlabPipeline(
440
524
  stage,
441
525
  image,
442
526
  ...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
443
- resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
527
+ // #4552: on the repo grain every apply of the repository shares one resource group.
528
+ resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
444
529
  // #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
445
530
  rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
446
531
  ...trading(applySpec(job)),
@@ -462,20 +547,43 @@ function prLoopGitlabPipeline(
462
547
  },
463
548
  ]),
464
549
  ),
550
+ ...(control && controlJob
551
+ ? {
552
+ [controlJob]: {
553
+ stage: control.name,
554
+ image,
555
+ needs: [lastGreen],
556
+ rules: [{ if: onGreen }],
557
+ // The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
558
+ script: [
559
+ ...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
560
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
561
+ ],
562
+ },
563
+ }
564
+ : {}),
465
565
  ...(prEnv ? prEnv.jobs : {}),
466
566
  };
467
567
  const jobs: GeneratedJob[] = [
468
568
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
469
569
  { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
570
+ ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
571
+ ...(preview && previewName && previewNoteName
572
+ ? [
573
+ { jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
574
+ { jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
575
+ ]
576
+ : []),
470
577
  ...applies.map(({ job, name, stage }, i) => ({
471
578
  jobName: name,
472
579
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
473
580
  stage,
474
581
  needs: i > 1 ? [applies[i - 1]!.name] : [],
475
582
  })),
583
+ ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control!.name, needs: [lastGreen!] }] : []),
476
584
  ...(prEnv ? prEnv.listed : []),
477
585
  ];
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));
586
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...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
587
  return { yaml, stages, jobs, env };
480
588
  }
481
589
 
@@ -511,7 +619,7 @@ function prEnvGitlabJobs(
511
619
  const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
512
620
  const environment = "pr-$CI_MERGE_REQUEST_IID";
513
621
  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];
622
+ const script = (j: typeof up): string[] => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
515
623
  const artifacts = { when: "always", paths: [reportPath] };
516
624
  // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
517
625
  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,81 @@
1
+ /**
2
+ * #4568: with a control repo, the GitLab merge-request pipeline adds a
3
+ * `control-dispatch` job after the last apply on merge, and the plan job
4
+ * unsets the control repo's token variables with the forge tokens, since a
5
+ * CI/CD variable reaches every job. The control repo's own pipeline runs on
6
+ * a trigger. Generated YAML only; the request is tested against fake forges
7
+ * in packages/core/src/control/control.test.ts.
8
+ */
9
+
10
+ import { describe, expect, test } from "vitest";
11
+ import { parseYAML } from "@intentius/chant/yaml";
12
+ import type { DriverComponent } from "@intentius/chant/components/driver";
13
+ import { resolveControlTarget } from "@intentius/chant/control/dispatch";
14
+ import { generateGitlabPipeline } from "./generate-pipeline";
15
+ import { generateControlPipeline } from "./control-pipeline";
16
+
17
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({ name, dependsOn, deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: name }] }] });
18
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
19
+ const control = { target: resolveControlTarget({ repo: "gitlab.com/acme/control", op: "deploy", tokenSecret: "TERRAGUCCI_CONTROL_TOKEN" }, "network") };
20
+
21
+ interface Job {
22
+ stage: string;
23
+ needs?: string[];
24
+ rules: Array<{ if: string }>;
25
+ script: string[];
26
+ resource_group?: string;
27
+ }
28
+
29
+ describe("the GitLab pipeline's control dispatch (#4568)", () => {
30
+ test("one job after the apply, and the plan job drops the token variables", () => {
31
+ const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
32
+ const doc = parseYAML(yaml) as Record<string, Job> & { stages: string[] };
33
+ const job = doc["control-dispatch"]!;
34
+ expect(job.needs).toEqual(["apply"]);
35
+ expect(doc.stages.at(-1)).toBe("control-dispatch");
36
+ expect(job.script[0]).toBe('export CHANT_CONTROL_SHA="$CI_COMMIT_SHA"');
37
+ expect(job.script[1]).toContain('CHANT_CONTROL_TOKEN="${TERRAGUCCI_CONTROL_TOKEN:-}"');
38
+ expect(job.script[1]).toContain("/trigger/pipeline");
39
+ expect(doc.plan!.script.some((l) => l.startsWith("unset ") && l.includes("TERRAGUCCI_CONTROL_TOKEN") && l.includes("CHANT_CONTROL_TOKEN"))).toBe(true);
40
+ });
41
+
42
+ test("with a gate per wave, the job follows the last wave and reads its GREEN_SHA", () => {
43
+ const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control }, gatedWaves: { gate: "release" } });
44
+ const doc = parseYAML(yaml) as Record<string, Job>;
45
+ expect(doc["control-dispatch"]!.needs).toEqual(["apply-wave-2"]);
46
+ expect(doc["control-dispatch"]!.script[0]).toBe('export CHANT_CONTROL_SHA="$GREEN_SHA"');
47
+ });
48
+ });
49
+
50
+ describe("the control repo's pipeline on GitLab (#4568)", () => {
51
+ test("a trigger with the member and Op runs the dependents, one member at a time", () => {
52
+ const file = generateControlPipeline({ words: { product: "terragucci" } });
53
+ expect(file.path).toBe(".gitlab/terragucci-dependents.yml");
54
+ const doc = parseYAML(file.content) as Record<string, Job>;
55
+ expect(doc.dependents!.rules[0]!.if).toBe('$CI_PIPELINE_SOURCE == "trigger" && $CHANT_CONTROL_MEMBER && $CHANT_CONTROL_OP');
56
+ expect(doc.dependents!.resource_group).toBe("terragucci-dependents-$CHANT_CONTROL_MEMBER");
57
+ expect(doc.dependents!.script[0]).toContain("workspace run --dependents-of");
58
+ });
59
+ });
60
+
61
+ // #4619: the note job reads the control repo, read-only, so the note marks the cross-stack edges.
62
+ describe("the GitLab plan-note job's read of the control repo (#4619)", () => {
63
+ test("fetches the control repo's chant/lifecycle before it posts, and exports CHANT_CONTROL_DIR", () => {
64
+ const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, control, words: { product: "terragucci" } } });
65
+ const doc = parseYAML(yaml) as Record<string, Job>;
66
+ const script = doc["plan-note"]!.script;
67
+ const read = script.findIndex((l) => l.includes("refs/heads/chant/lifecycle"));
68
+ expect(read).toBeGreaterThan(-1);
69
+ expect(script[read + 1]).toContain("components pr-post");
70
+ expect(script[read]).toContain('CHANT_CONTROL_READ_TOKEN="${CHANT_CONTROL_READ_TOKEN:-}"');
71
+ expect(script[read]).toContain('export CHANT_CONTROL_DIR="$chant_control_dir"');
72
+ expect(script[read]).toContain("'https://gitlab.com/acme/control.git'");
73
+ // The dispatch token is not the note job's.
74
+ expect(script[read]).not.toContain("TERRAGUCCI_CONTROL_TOKEN");
75
+ });
76
+
77
+ test("without a control repo the note job reads none", () => {
78
+ const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
79
+ expect(yaml).not.toContain("CHANT_CONTROL_DIR");
80
+ });
81
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * #4549: with a Pulumi Cloud identity, the merge-request pipeline's plan and
3
+ * record jobs trade their `id_tokens` for a preview token and the apply job
4
+ * for an up token. No job reads a Pulumi token from a CI/CD variable, and
5
+ * the plan job names no up token. Generated YAML only; the trade itself is
6
+ * tested against the fake exchange in packages/core/src/ci-identity-pulumi.test.ts.
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 { gitlabTradeLines, resolveCloudIdentity, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
13
+ import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
14
+ import { generateGitlabPipeline } from "./generate-pipeline";
15
+
16
+ const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] }];
17
+
18
+ const IDENTITY = resolveCloudIdentity({ pulumi: { organization: "acme", rules: [{ plan: "team:preview", apply: "team:deploy" }] } }, "prod")!;
19
+
20
+ interface GitlabJob {
21
+ id_tokens?: Record<string, { aud: string }>;
22
+ variables?: Record<string, string>;
23
+ script: string[];
24
+ }
25
+
26
+ describe("the GitLab pipeline's Pulumi Cloud tokens (#4549)", () => {
27
+ const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", prLoop: { applyOnMerge: true, identity: IDENTITY } });
28
+ const doc = parseYAML(yaml) as Record<string, GitlabJob>;
29
+
30
+ test("the plan job asks for the Pulumi id token and trades for the preview token before anything else", () => {
31
+ expect(doc.plan!.id_tokens).toEqual({ CHANT_ID_TOKEN_PULUMI: { aud: "urn:pulumi:org:acme" } });
32
+ expect(doc.plan!.script.slice(0, 3)).toEqual(gitlabTradeLines(FORGE_TOKEN_VARS));
33
+ expect(JSON.parse(doc.plan!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
34
+ });
35
+
36
+ test("the record job takes the preview token and the apply job the up token", () => {
37
+ expect(JSON.parse(doc.record!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
38
+ expect(JSON.parse(doc.apply!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.apply);
39
+ expect(doc.apply!.id_tokens).toEqual(doc.plan!.id_tokens);
40
+ });
41
+
42
+ test("the plan job names no up token, and nothing reads PULUMI_ACCESS_TOKEN from a CI/CD variable", () => {
43
+ expect(JSON.stringify(doc.plan)).not.toContain("team:deploy");
44
+ expect(yaml).not.toContain("$PULUMI_ACCESS_TOKEN");
45
+ });
46
+ });