@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
@@ -103,6 +103,15 @@
103
103
  * internal and always wins at the job over a same-named spec entry, so a
104
104
  * caller cannot point the gate block somewhere the `artifacts:` declaration
105
105
  * beside it does not also point.
106
+ *
107
+ * `reviewAgent` (#4544) splits the review in two, as on GitHub and Forgejo,
108
+ * with GitLab's own shape (`./review-jobs.ts`). The review job runs in the
109
+ * merge request pipeline, unsets every forge token variable and keeps the
110
+ * review as its artifact. Its note job runs in a pipeline of the default
111
+ * branch that a schedule starts with `CHANT_REVIEW_NOTES` set to `1`, checks
112
+ * nothing out and posts each open merge request's review with the protected
113
+ * `CHANT_FORGE_TOKEN`. The header says to create that schedule and protect
114
+ * the token.
106
115
  */
107
116
 
108
117
  import {
@@ -114,6 +123,8 @@ import {
114
123
  gitlabIdTokens,
115
124
  gitlabRules,
116
125
  gitlabSetupScript,
126
+ gitlabTags,
127
+ assertRunnerLabels,
117
128
  nonEmpty,
118
129
  pushBranches,
119
130
  } from "@intentius/chant/ci-jobs";
@@ -129,6 +140,12 @@ import type {
129
140
  } from "@intentius/chant/lexicon";
130
141
 
131
142
  import { emitGitlabFile } from "./ci-jobs";
143
+ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DIR, reviewNoteJobName } from "./review-jobs";
144
+ import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
145
+ import { activeProductWords } from "@intentius/chant/ci-product-words";
146
+ import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs";
147
+ import { assertScheduledOpCloud, scheduledOpUpdateJobName } from "@intentius/chant/scheduled-op-cloud";
148
+ import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
132
149
 
133
150
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
134
151
 
@@ -202,24 +219,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
202
219
  );
203
220
  }
204
221
 
205
- /**
206
- * Refuse `reviewAgent` (#4326). The split posts the review from a second job
207
- * that holds the forge write token, and a GitLab merge request pipeline hands
208
- * every job the project's unprotected CI/CD variables, the review job's
209
- * included. A token protected enough to stay out of it is not available to a
210
- * job of that pipeline either, so the post has to start from a pipeline of
211
- * the default branch, which this generator does not build. The refusal names
212
- * the Op and the way out rather than emitting a review job that holds the token.
213
- */
214
- function assertNoReviewSplit(spec: ScheduledOpSpec): void {
215
- if (!spec.reviewAgent) return;
216
- throw new Error(
217
- `Scheduled Op "${spec.name}" sets reviewAgent, which keeps the model's job free of the forge write token and posts the note from a second job. ` +
218
- `A GitLab merge request pipeline hands every job the project's unprotected variables, so the gitlab generator does not build that split. ` +
219
- `Drop reviewAgent and run the review with findingMode "comment" (the model command still runs with a cleared environment), or generate this Op for GitHub or Forgejo.`,
220
- );
221
- }
222
-
223
222
  /**
224
223
  * The follow-up line an Op with an `environment` adds under its own header
225
224
  * line (#2257). The `environment:` key on the job binds it to the
@@ -303,20 +302,70 @@ export function generateGitlabOpPipeline(
303
302
 
304
303
  const opLines: string[] = [];
305
304
  let anyCron = false;
305
+ let anyReview = false;
306
+ // The note job of each split review (#4544), emitted after the Op jobs.
307
+ const noteJobs: string[] = [];
308
+ const words = activeProductWords();
306
309
 
307
310
  for (const spec of ops) {
308
311
  const subject = `Scheduled Op "${spec.name}"`;
312
+ // #4631: the job's cloud identities and the update on drift, checked before anything renders.
313
+ assertScheduledOpCloud(spec);
314
+ if (spec.update?.permissions && Object.keys(spec.update.permissions).length > 0) {
315
+ throw new Error(`${subject}: update.permissions has no GitLab form; a GitLab job's token scopes are the project's (contents: write included: whether a job token may push is a project setting), and its cloud identity is update.cloud.`);
316
+ }
317
+ // #4616: GitLab picks a runner by tags.
318
+ assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
319
+ const tags = gitlabTags(spec.runner);
309
320
  const setupScript = gitlabSetupScript(subject, spec.setup);
310
- const idTokens = gitlabIdTokens(spec.name, spec.permissions);
321
+ // #4616: the comment agent is its own pair of jobs, in a pipeline of the default branch; the spec's trigger is not read.
322
+ if (spec.agent?.on === "comment") {
323
+ const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
324
+ const jobName = jobPrefix + ciJobName(spec.name);
325
+ jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
326
+ opLines.push(...gitlabAgentHeader(jobName, settings, spec.agent.tokenEnv));
327
+ const { agent, push } = gitlabAgentJobs({
328
+ jobName,
329
+ stage,
330
+ image,
331
+ settings,
332
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
333
+ script: [...setupScript, ...beforeScript],
334
+ ...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
335
+ ...(words.bin ? { bin: words.bin } : {}),
336
+ });
337
+ doc[jobName] = agent;
338
+ const pushName = gitlabAgentPushJobName(jobName);
339
+ doc[pushName] = push;
340
+ noteJobs.push(pushName);
341
+ continue;
342
+ }
343
+ const declaredIdTokens = gitlabIdTokens(spec.name, spec.permissions);
344
+ // #4631: a cloud trade takes an id token per cloud, beside the ones the spec declares.
345
+ const idTokens = spec.cloud ? { ...(declaredIdTokens ?? {}), ...gitlabTradeIdTokens(spec.cloud) } : declaredIdTokens;
346
+ const update = spec.update;
311
347
  const findingMode = spec.findingMode ?? "report";
312
348
  const trigger = resolveOpTrigger(spec);
313
349
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
314
- assertNoReviewSplit(spec);
350
+ // A split review (#4544): the merge request pipeline runs the model with no forge token, and a pipeline of the default branch posts.
351
+ const split = spec.reviewAgent === true;
352
+ if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
315
353
  if (trigger.kind === "cron") anyCron = true;
354
+ const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
355
+ if (driftAgent && (split || trigger.kind === "push")) {
356
+ throw new Error(`${subject} sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
357
+ }
316
358
 
317
359
  const jobName = jobPrefix + ciJobName(spec.name);
318
360
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
319
361
  opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
362
+ if (split) {
363
+ anyReview = true;
364
+ opLines.push(
365
+ `# reviewAgent: the review job holds no forge token and keeps its review as an artifact; ${reviewNoteJobName(jobName)} posts it` +
366
+ ` from a pipeline of the default branch started with ${GITLAB_REVIEW_NOTES_VAR}="1"`,
367
+ );
368
+ }
320
369
  if (spec.environment) {
321
370
  assertGitlabEnvironment(spec.name, spec.environment);
322
371
  opLines.push(environmentLine(spec.environment));
@@ -337,28 +386,102 @@ export function generateGitlabOpPipeline(
337
386
  // than a caller being able to repoint the gate block's own artifact path.
338
387
  const jobVariables: Record<string, string> = {
339
388
  ...(spec.variables ?? {}),
389
+ ...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
340
390
  ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
341
391
  };
342
392
 
393
+ const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
394
+ if (split) {
395
+ const { review, note } = gitlabReviewJobs({
396
+ name: spec.name,
397
+ jobName,
398
+ stage,
399
+ image,
400
+ rules: rulesFor,
401
+ script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
402
+ ...(spec.variables ? { variables: spec.variables } : {}),
403
+ ...(idTokens ? { idTokens } : {}),
404
+ artifactDir: member ? memberRepoPath(member, REVIEW_DIR) : REVIEW_DIR,
405
+ ...(trigger.kind === "pull_request" && trigger.branches?.length ? { branches: trigger.branches } : {}),
406
+ });
407
+ doc[jobName] = { ...review, ...(tags ? { tags } : {}), ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) };
408
+ const noteName = reviewNoteJobName(jobName);
409
+ doc[noteName] = { ...note, ...(tags ? { tags } : {}) };
410
+ noteJobs.push(noteName);
411
+ continue;
412
+ }
413
+
414
+ // #4631: an update follows a drift check the same way the drift agent does, on the dotenv the run leaves.
415
+ const flagsDrift = driftAgent !== undefined || update !== undefined;
416
+ const runLines = flagsDrift ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
417
+ const driftDir = member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR;
343
418
  doc[jobName] = {
344
419
  stage,
345
420
  image,
421
+ ...(tags ? { tags } : {}),
346
422
  // GitLab's stand-in for github's per-Op concurrency group: queue the
347
423
  // next run rather than cancel the current one.
348
424
  resource_group: jobName,
349
425
  ...(idTokens ? { id_tokens: idTokens } : {}),
350
426
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
351
427
  ...nonEmpty("variables", jobVariables),
352
- rules: changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules,
353
- script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
428
+ rules: rulesFor,
429
+ // #4631: the trade first, before any line of the checkout's code runs, and the token variables unset by it.
430
+ script: [...(spec.cloud ? gitlabTradeLines() : []), ...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
354
431
  // `when: always` because the run this publishes for is the green one: a
355
432
  // gated apply succeeds, and the block is the only thing that says a
356
433
  // human still has to act. A run that walked through its gate writes no
357
434
  // block, and GitLab reports the empty upload as a warning, not a failure.
358
435
  ...(gated
359
436
  ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
360
- : {}),
437
+ : flagsDrift
438
+ ? { artifacts: gitlabDriftArtifacts(driftDir) }
439
+ : {}),
361
440
  };
441
+ if (update) {
442
+ // #4631: the update's own job, which alone holds the update's identity, after a run that found drift.
443
+ const name = scheduledOpUpdateJobName(jobName);
444
+ opLines.push(`# update: ${name} runs \`${update.command}\` when ${jobName} exits with chant's drift code${update.cloud ? ", holding its own cloud identity" : ""}`);
445
+ doc[name] = {
446
+ stage,
447
+ image,
448
+ ...(tags ? { tags } : {}),
449
+ needs: [{ job: jobName, artifacts: true }],
450
+ ...(update.cloud ? { id_tokens: gitlabTradeIdTokens(update.cloud) } : {}),
451
+ ...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
452
+ ...nonEmpty("variables", { ...(update.variables ?? {}), ...(update.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(update.cloud) } : {}) }),
453
+ rules: rulesFor.map((r) => ({ ...(r as Record<string, unknown>), when: "always" })),
454
+ script: [
455
+ `if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the update does not run"; exit 0; fi`,
456
+ ...(update.cloud ? gitlabTradeLines() : []),
457
+ ...cdLine,
458
+ ...setupScript,
459
+ ...beforeScript,
460
+ update.command,
461
+ ],
462
+ };
463
+ noteJobs.push(name);
464
+ }
465
+ if (driftAgent) {
466
+ const name = `${jobName}-agent`;
467
+ opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
468
+ const { agent, push } = gitlabAgentJobs({
469
+ jobName: name,
470
+ stage,
471
+ image,
472
+ settings: driftAgent,
473
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
474
+ script: [...setupScript, ...beforeScript],
475
+ ...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
476
+ ...(words.bin ? { bin: words.bin } : {}),
477
+ after: { job: jobName, rules: rulesFor },
478
+ // #4619: a member's drift Op left the drift under the member's directory.
479
+ ...(member ? { dir: memberRepoPath(member, AGENT_DIR) } : {}),
480
+ });
481
+ doc[name] = agent;
482
+ doc[gitlabAgentPushJobName(name)] = push;
483
+ noteJobs.push(name, gitlabAgentPushJobName(name));
484
+ }
362
485
  }
363
486
 
364
487
  const headerLines = [
@@ -375,10 +498,20 @@ export function generateGitlabOpPipeline(
375
498
  "# that job runs on that schedule.",
376
499
  );
377
500
  }
501
+ if (anyReview) {
502
+ headerLines.push(
503
+ "#",
504
+ "# A reviewAgent Op's note job posts from a pipeline of the default branch,",
505
+ `# never from the merge request's own. Create a Pipeline Schedule on the`,
506
+ `# default branch with ${GITLAB_REVIEW_NOTES_VAR}="1", and make CHANT_FORGE_TOKEN`,
507
+ "# (a token with the api scope) a protected, masked CI/CD variable so no",
508
+ "# merge request pipeline gets it.",
509
+ );
510
+ }
378
511
  headerLines.push("#", ...opLines);
379
512
 
380
513
  const yaml = emitGitlabFile(
381
- [["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const)],
514
+ [["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const), ...noteJobs.map((name) => [name, doc[name]] as const)],
382
515
  headerLines,
383
516
  );
384
517
 
@@ -0,0 +1,28 @@
1
+ /** #4630: GitLab approvals start no pipeline, so a schedule posts every open merge request's approval status. */
2
+ import { describe, expect, test } from "vitest";
3
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
4
+ import { generateGitlabOpWavesPipeline } from "./generate-op-waves-pipeline";
5
+
6
+ const spec: OpWavesSpec = {
7
+ name: "plywood-up",
8
+ op: "plywood-up",
9
+ plan: ["./bin/plan", "{target}", "{plan}"],
10
+ waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
11
+ };
12
+
13
+ describe("the approval status job on GitLab (#4630)", () => {
14
+ test("runs in a schedule that sets CHANT_APPROVAL_STATUS, with --open", () => {
15
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
16
+ expect(yaml).toContain("plywood-up-approval-status:");
17
+ expect(yaml).toContain('$CI_PIPELINE_SOURCE == "schedule" && $CHANT_APPROVAL_STATUS == "1"');
18
+ expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
19
+ });
20
+
21
+ test("a pull-request wave records its plans and posts the status too (2026-10-28 ruling)", () => {
22
+ const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
23
+ const yaml = generateGitlabOpWavesPipeline(pullRequest, { specFile: "waves.json" }).files[0]!.yaml;
24
+ expect(yaml).toContain("plywood-up-record-plans:");
25
+ expect(yaml).toContain("plywood-up-approval-status:");
26
+ expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
27
+ });
28
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * An Op waves spec's `control` on GitLab (#4617): a control stage after the
3
+ * waves, the outcome added up without the token, and every other job
4
+ * dropping the control repo's token variables. Written for the ci-products
5
+ * session; run at epic-ready.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
10
+ import { generateGitlabOpWavesPipeline } from "./generate-op-waves-pipeline";
11
+
12
+ const spec: OpWavesSpec = {
13
+ name: "stacks",
14
+ op: "pulumi-up",
15
+ plan: ["./bin/plan", "{target}", "{plan}"],
16
+ waves: [
17
+ { name: "canary", runs: [{ target: "dev" }], gate: "never" },
18
+ { name: "rest", runs: [{ target: "a" }] },
19
+ ],
20
+ resume: { schedule: "*/10 * * * *" },
21
+ control: { repo: "gitlab.com/acme/control", op: "deploy", member: "network", tokenSecret: "PLYWOOD_CONTROL_TOKEN" },
22
+ };
23
+
24
+ const options = { specFile: "waves.json", bin: "npx --no-install plywood", words: { product: "plywood" } };
25
+
26
+ describe("the control dispatch after the last wave on GitLab (#4617)", () => {
27
+ test("a control stage after the waves, needing the last wave's job", () => {
28
+ const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
29
+ expect(yaml).toMatch(/stages:\n\s+- wave-1-canary\n\s+- wave-2-rest\n\s+- control\n\s+- resume/);
30
+ expect(yaml).toMatch(/control-dispatch:\n\s+stage: control[\s\S]*needs:\n\s+- wave-2-rest\n/);
31
+ });
32
+
33
+ test("adds up the outcome in a subshell without the token, then sends it with the commit", () => {
34
+ const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
35
+ expect(yaml).toContain("unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n { :\n } 1>&2\n npx --no-install plywood run wave --spec waves.json --waves-outcome");
36
+ expect(yaml).toContain('export CHANT_CONTROL_OUTCOME="$outcome"');
37
+ expect(yaml).toContain('export CHANT_CONTROL_SHA="$CI_COMMIT_SHA"');
38
+ expect(yaml).toContain('CHANT_CONTROL_TOKEN="${PLYWOOD_CONTROL_TOKEN:-}"');
39
+ expect(yaml).toContain("--form 'token=<-'");
40
+ expect(yaml).toContain('--form "variables[CHANT_CONTROL_OUTCOME]=$outcome"');
41
+ });
42
+
43
+ test("every wave job and the resume job drop the control repo's token variables first", () => {
44
+ const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
45
+ expect(yaml).toMatch(/wave-1-canary:[\s\S]*script:\n\s+- [^\n]*node -e[^\n]*\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n\s+- npx --no-install plywood run wave --spec waves.json --wave 1/);
46
+ expect(yaml).toMatch(/stacks-resume:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN/);
47
+ });
48
+
49
+ test("no control, no stage, no unset", () => {
50
+ const { control: _drop, ...plain } = spec;
51
+ const yaml = generateGitlabOpWavesPipeline(plain, options).files[0]!.yaml;
52
+ expect(yaml).not.toContain("control-dispatch");
53
+ expect(yaml).not.toContain("unset CHANT_CONTROL_TOKEN");
54
+ });
55
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The apply-job guard in GitLab op-wave jobs (#4624): the script's first
3
+ * line, before the trade, with the job's expectation in a variable: the
4
+ * environment, the branches and the runner's tags. Written for the
5
+ * ci-products post-merge pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
10
+ import { spawnSync } from "node:child_process";
11
+ import { JOB_GUARD_ENV, JOB_GUARD_JS } from "@intentius/chant/ci-job-guard";
12
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
13
+ import { generateGitlabOpWavesPipeline, OP_WAVE_ALLOWED_EXIT } from "./generate-op-waves-pipeline";
14
+
15
+ const spec: OpWavesSpec = {
16
+ name: "deploy",
17
+ op: "pulumi-up",
18
+ plan: ["./bin/plan", "{target}", "{plan}"],
19
+ branches: ["main"],
20
+ runner: ["deploy"],
21
+ waves: [
22
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
23
+ { name: "prod", runs: [{ target: "prod" }], environment: { name: "production" } },
24
+ ],
25
+ };
26
+
27
+ describe("the apply-job guard on GitLab (#4624)", () => {
28
+ test("the first script line of every wave job", () => {
29
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
30
+ for (const job of ["wave-1-dev", "wave-2-prod"]) {
31
+ const body = yaml.slice(yaml.indexOf(`${job}:`));
32
+ const script = body.slice(body.indexOf("script:"));
33
+ expect(script.split("\n")[1]).toContain(JOB_GUARD_JS.slice(0, 40));
34
+ }
35
+ });
36
+
37
+ test("the variable names the environment, the branches, the tags and the product", () => {
38
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
39
+ const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
40
+ expect(prod).toContain(JOB_GUARD_ENV);
41
+ for (const part of ['"forge":"gitlab"', '"forgeEnvironment":"production"', '"branches":["main"]', '"runnerTags":["deploy"]', '"product":"plywood"', '"job":"wave 2 (prod) of deploy"']) {
42
+ expect(prod).toContain(part);
43
+ }
44
+ });
45
+
46
+ test("a refused guard fails the job and stops the wave (#4624 ruling: fail hard)", () => {
47
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
48
+ const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
49
+ expect(prod).toContain(`exit_codes:\n - ${OP_WAVE_ALLOWED_EXIT}`);
50
+ const guard = { forge: "gitlab", job: "wave 2 (prod) of deploy", forgeEnvironment: "production", branches: ["main"], runnerTags: ["deploy"] };
51
+ const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], {
52
+ env: { PATH: process.env.PATH ?? "", [JOB_GUARD_ENV]: JSON.stringify(guard), CI_ENVIRONMENT_NAME: "dev", CI_COMMIT_REF_NAME: "main", CI_COMMIT_REF_PROTECTED: "true", CI_RUNNER_TAGS: "deploy" },
53
+ encoding: "utf-8",
54
+ });
55
+ expect(r.status).toBe(CHANT_EXIT.misplaced);
56
+ expect(r.status).not.toBe(OP_WAVE_ALLOWED_EXIT);
57
+ });
58
+
59
+ test("a guard with no spec to read fails the job too", () => {
60
+ const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], { env: { PATH: process.env.PATH ?? "" }, encoding: "utf-8" });
61
+ expect(r.status).toBe(CHANT_EXIT.misplaced);
62
+ });
63
+ });
@@ -97,7 +97,7 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
97
97
  expect(yaml).toMatch(/wave-1-dev:\n\s+stage: wave-1-dev\n\s+image: node:22\n\s+tags:\n\s+- docker\n\s+- dev\n/);
98
98
  expect(yaml).toMatch(/wave-2-prod-decide:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- plan\n\s+resource_group: migrations-prod\n\s+id_tokens:\n\s+PLAN_TOKEN:\n\s+aud: plan.example\n/);
99
99
  expect(yaml).toMatch(
100
- /wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+resource_group: migrations-prod\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+REGION: us\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
100
+ /wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+resource_group: migrations-prod\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+REGION: us\n\s+CHANT_JOB_GUARD: [^\n]*\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- [^\n]*node -e[^\n]*\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
101
101
  );
102
102
  expect(yaml).toMatch(/wave-2-prod-eu:[\s\S]*?id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: eu.example\n/);
103
103
  expect(yaml).not.toContain("_AUDIENCE");
@@ -122,11 +122,30 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
122
122
  for (const [name, ids] of Object.entries(want)) {
123
123
  const job = doc[name]!;
124
124
  expect(job.id_tokens).toEqual({ OTHER_TOKEN: { aud: "other.example" }, CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
125
- expect(job.script.slice(0, 3)).toEqual(gitlabTradeLines());
126
- expect(job.script[3]).toBe("npm ci");
125
+ // #4624: the apply-job guard first, then the trade.
126
+ expect(job.script[0]).toMatch(/^\(cd \/ && node -e '/);
127
+ expect(job.script.slice(1, 4)).toEqual(gitlabTradeLines());
128
+ expect(job.script[4]).toBe("npm ci");
127
129
  expect(tradeIdentities(JSON.parse(job.variables[TRADE_SPEC_ENV]!))).toEqual(ids);
128
130
  }
129
131
  expect(JSON.stringify(doc["wave-1-prod-decide"])).not.toContain("apply\"");
130
132
  expect(JSON.stringify(doc["wave-1-prod-default"])).not.toContain("prod-apply");
131
133
  });
134
+
135
+ test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
136
+ const bin = "npx --no-install plywood";
137
+ const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
138
+ const { files, jobs } = generateGitlabOpWavesPipeline(withBin, { specFile: "waves.json", bin });
139
+ expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
140
+ const yaml = files.map((f) => f.yaml).join("\n");
141
+ expect(yaml).toContain("- npx --no-install plywood run wave --spec waves.json --wave 1");
142
+ expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
143
+ expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
144
+ expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
145
+ });
146
+
147
+ test("no bin keeps chant (#4607)", () => {
148
+ const { jobs } = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" });
149
+ expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
150
+ });
132
151
  });