@intentius/chant-lexicon-gitlab 0.124.52 → 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
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The control repo's own pipeline (#4568) on GitLab: a member's pipeline
3
+ * trigger starts it with `CHANT_CONTROL_MEMBER` and `CHANT_CONTROL_OP` set,
4
+ * and its one job runs the member's dependents with `chant workspace run
5
+ * --dependents-of`. The pieces are core's (`controlPipelineSpec` in
6
+ * `@intentius/chant/control/dispatch`); the file is one the control repo's
7
+ * `.gitlab-ci.yml` includes. The member's side, the `control-dispatch` job,
8
+ * comes from the pull request loop's `control` option
9
+ * (./generate-pipeline.ts).
10
+ */
11
+
12
+ import { CI_IMAGE, GITLAB_FULL_HISTORY } from "@intentius/chant/ci-jobs";
13
+ import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
14
+ import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
15
+ import { emitGitlabFile } from "./ci-jobs";
16
+
17
+ /** A control repo's pipeline file: where it goes, its text, and what a person still sets up. */
18
+ export interface ControlPipelineFile {
19
+ path: string;
20
+ content: string;
21
+ notes: string[];
22
+ }
23
+
24
+ /** The control repo's pipeline on GitLab: `.gitlab/<product>-dependents.yml`. */
25
+ export function generateControlPipeline(options: { words?: CiProductWordsInput | CiProductWords; image?: string; command?: string } = {}): ControlPipelineFile {
26
+ const spec = controlPipelineSpec("gitlab", options.words ?? {}, options.command ? { command: options.command } : {});
27
+ const job = {
28
+ stage: "deploy",
29
+ image: options.image ?? CI_IMAGE,
30
+ // The full history, so the job reads and appends to chant/lifecycle (#4617).
31
+ variables: { ...GITLAB_FULL_HISTORY },
32
+ rules: [{ if: spec.rule }],
33
+ // One member at a time: two applies upstream should not run the same dependents side by side.
34
+ resource_group: spec.group,
35
+ script: [`bash <<'DEPENDENTS' || exit $?\n${spec.script}\nDEPENDENTS`],
36
+ };
37
+ return { path: spec.path, content: emitGitlabFile([["dependents", job]], spec.header), notes: spec.notes };
38
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The agent jobs on GitLab (#4616): the comment agent in a pipeline of the
3
+ * default branch started with the merge request and the ask, the drift agent
4
+ * after a drift check, the push token scoped to the push job's environment,
5
+ * and a runner as tags.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
11
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
12
+
13
+ type Job = { rules?: Array<{ if?: string; when?: string }>; needs?: Array<{ job: string }>; tags?: string[]; timeout?: string; environment?: { name: string }; script: string[]; artifacts?: { paths?: string[]; reports?: Record<string, string> }; variables?: Record<string, string> };
14
+ const gen = (spec: ScheduledOpSpec) => {
15
+ const out = generateGitlabOpPipeline([spec]);
16
+ return { doc: parseYAML(out.files[0]!.yaml) as unknown as Record<string, Job>, yaml: out.files[0]!.yaml };
17
+ };
18
+
19
+ describe("the comment agent on GitLab", () => {
20
+ const { doc, yaml } = gen({ name: "chant-agent", runner: ["agents"], agent: { on: "comment", comment: "/chant agent", maxRuns: 4 } });
21
+ const agent = doc["chant-agent"]!;
22
+ const push = doc["chant-agent-push"]!;
23
+
24
+ test("runs in a pipeline of the default branch started with the merge request and the ask", () => {
25
+ expect(agent.rules?.[0]?.if).toContain("$CHANT_AGENT_PR =~ /^[0-9]+$/");
26
+ expect(agent.rules?.[0]?.if).toContain("$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH");
27
+ expect(agent.tags).toEqual(["agents"]);
28
+ expect(agent.timeout).toBe("45 minutes");
29
+ });
30
+
31
+ test("the agent job drops every forge token before the decision and the model", () => {
32
+ const unset = agent.script.findIndex((l) => l.startsWith("unset "));
33
+ const prepare = agent.script.findIndex((l) => l.includes("chant agent prepare"));
34
+ expect(unset).toBeGreaterThan(-1);
35
+ expect(unset).toBeLessThan(prepare);
36
+ expect(agent.script.at(-1)).toContain("chant agent run --forge gitlab");
37
+ expect(agent.artifacts?.paths).toEqual([".chant/agent"]);
38
+ });
39
+
40
+ test("the push job runs under its own environment with the push token", () => {
41
+ expect(push.environment?.name).toBe("chant-agent-push");
42
+ expect(push.needs).toEqual([{ job: "chant-agent", artifacts: true }]);
43
+ expect(push.script.join("\n")).toContain('export CHANT_GITLAB_TOKEN="$CHANT_FORGE_TOKEN"');
44
+ expect(push.script.at(-1)).toContain("chant agent land --forge gitlab");
45
+ expect(yaml).toContain("scope it to environment chant-agent-push");
46
+ });
47
+ });
48
+
49
+ describe("the drift agent on GitLab", () => {
50
+ const { doc } = gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, agent: { on: "drift" } });
51
+
52
+ test("the drift check keeps its output and a dotenv that says it found drift", () => {
53
+ const op = doc["drift-app"]!;
54
+ expect(op.script.join("\n")).toContain('echo "CHANT_AGENT_DRIFT=1"');
55
+ expect(op.artifacts?.reports).toEqual({ dotenv: ".chant/agent/drift.env" });
56
+ });
57
+
58
+ test("the agent and push jobs follow it whatever it exited, and stop when it found none", () => {
59
+ const agent = doc["drift-app-agent"]!;
60
+ expect(agent.needs).toEqual([{ job: "drift-app", artifacts: true }]);
61
+ expect(agent.rules?.every((r) => r.when === "always")).toBe(true);
62
+ expect(agent.script[0]).toContain('"${CHANT_AGENT_DRIFT:-0}" != "1"');
63
+ expect(doc["drift-app-agent-push"]!.script[0]).toContain("decision.json");
64
+ });
65
+
66
+ test("a runner group is refused", () => {
67
+ expect(() => gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, runner: { group: "infra" } })).toThrow(/runner group/);
68
+ });
69
+ });
70
+
71
+ // #4619: a member's drift Op runs in the member's directory, and its artifact keeps the drift there.
72
+ describe("the drift agent of a workspace member on GitLab", () => {
73
+ const out = generateGitlabOpPipeline([{ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, agent: { on: "drift", maxCostUsd: 4 } }], { member: { name: "app", dir: "stacks/app" } });
74
+ const doc = parseYAML(out.files[0]!.yaml) as unknown as Record<string, Job>;
75
+ const op = doc["app-drift-app"]!;
76
+ const agent = doc["app-drift-app-agent"]!;
77
+ const push = doc["app-drift-app-agent-push"]!;
78
+
79
+ test("the drift check runs in the member's directory and keeps the drift under it", () => {
80
+ expect(op.script[0]).toBe("cd stacks/app");
81
+ expect(op.artifacts?.paths).toEqual(["stacks/app/.chant/agent"]);
82
+ expect(op.artifacts?.reports).toEqual({ dotenv: "stacks/app/.chant/agent/drift.env" });
83
+ });
84
+
85
+ test("the agent and push jobs read the member's own drift output", () => {
86
+ expect(agent.script.some((l) => l.startsWith("cd "))).toBe(false);
87
+ expect(agent.script.find((l) => l.includes("agent prepare"))).toBe("chant agent prepare --forge gitlab --output stacks/app/.chant/agent");
88
+ expect(agent.script.at(-1)).toBe("chant agent run --forge gitlab --output stacks/app/.chant/agent");
89
+ expect(agent.artifacts?.paths).toEqual(["stacks/app/.chant/agent"]);
90
+ expect(push.script[0]).toContain("stacks/app/.chant/agent/decision.json");
91
+ expect(push.script.at(-1)).toBe("chant agent land --forge gitlab --output stacks/app/.chant/agent");
92
+ });
93
+
94
+ test("the money limit reaches both jobs and the file's header", () => {
95
+ expect(agent.variables?.CHANT_AGENT_MAX_COST_USD).toBe("4");
96
+ expect(push.variables?.CHANT_AGENT_MAX_COST_USD).toBe("4");
97
+ expect(out.files[0]!.yaml).toContain("and to $4 a day of recorded cost");
98
+ });
99
+ });
@@ -1,21 +1,86 @@
1
1
  /**
2
- * `reviewAgent` on GitLab (#4326): refused by name. A merge request pipeline
3
- * hands every job the project's unprotected variables, so a review job there
4
- * cannot be kept apart from a token that writes.
2
+ * `reviewAgent` on GitLab (#4544): the review job runs in the merge request
3
+ * pipeline with no forge token and keeps its review as an artifact; the note
4
+ * job runs in a pipeline of the default branch, checks nothing out, and posts
5
+ * with the protected token.
5
6
  */
6
7
 
7
8
  import { describe, expect, test } from "vitest";
9
+ import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
10
+ import { parseYAML } from "@intentius/chant/yaml";
11
+ import type { ComponentPipelineOptions, ScheduledOpSpec } from "@intentius/chant/lexicon";
8
12
  import { generateGitlabOpPipeline } from "./generate-op-pipeline";
9
13
 
14
+ const spec: ScheduledOpSpec = { name: "pr-review", trigger: { kind: "pull_request" }, reviewAgent: true };
15
+
16
+ function generated(specs: ScheduledOpSpec[] = [spec], options: ComponentPipelineOptions = {}) {
17
+ const out = generateGitlabOpPipeline(specs, options);
18
+ return { out, doc: parseYAML(out.files[0].yaml) as Record<string, Record<string, unknown>> };
19
+ }
20
+
21
+ const ruleText = (job: Record<string, unknown>): string => JSON.stringify(job.rules);
22
+ const inMergeRequestPipeline = (job: Record<string, unknown>): boolean => ruleText(job).includes("merge_request_event");
23
+
10
24
  describe("generateGitlabOpPipeline: reviewAgent", () => {
11
- test("is refused, naming the Op and the way out", () => {
12
- expect(() => generateGitlabOpPipeline([{ name: "pr-review", trigger: { kind: "pull_request" }, reviewAgent: true }])).toThrow(
13
- /Scheduled Op "pr-review" sets reviewAgent.*unprotected variables.*findingMode "comment".*GitHub or Forgejo/s,
14
- );
25
+ test("emits the review job and its note job", () => {
26
+ const { out, doc } = generated();
27
+ expect(out.jobs.map((j) => j.jobName)).toEqual(["pr-review"]);
28
+ expect(Object.keys(doc)).toEqual(["stages", "pr-review", "pr-review-note"]);
29
+ expect(out.files[0].yaml).toContain('CHANT_REVIEW_NOTES="1"');
30
+ expect(out.files[0].yaml).toContain("protected, masked CI/CD variable");
31
+ });
32
+
33
+ test("the review job runs in the merge request pipeline, drops every forge token first and keeps the review as an artifact", () => {
34
+ const { doc } = generated();
35
+ const review = doc["pr-review"];
36
+ expect(inMergeRequestPipeline(review)).toBe(true);
37
+ const script = review.script as string[];
38
+ expect(script[0]).toBe(`unset ${FORGE_TOKEN_VARS.join(" ")}`);
39
+ expect(script).toContain("chant run pr-review");
40
+ expect(review.variables).toEqual({ CHANT_REVIEW_DIR: ".chant/review" });
41
+ expect(review.artifacts).toEqual({ paths: [".chant/review"], expire_in: "7 days" });
42
+ });
43
+
44
+ test("no job of the merge request pipeline holds a write token", () => {
45
+ const { doc } = generated([spec, { name: "lint", trigger: { kind: "pull_request" } }], { variables: { FOO: "bar" } });
46
+ const writeTokens = ["CHANT_FORGE_TOKEN", "CHANT_RUNNER_KEY", "GITLAB_TOKEN", "CHANT_GITLAB_TOKEN"];
47
+ for (const [name, job] of Object.entries(doc)) {
48
+ if (name === "stages" || name === "variables" || !inMergeRequestPipeline(job)) continue;
49
+ const text = JSON.stringify({ variables: job.variables ?? {}, script: (job.script as string[]).filter((l) => !l.startsWith("unset ")) });
50
+ for (const token of writeTokens) expect(text, `${name} ${token}`).not.toContain(token);
51
+ }
52
+ expect(Object.keys(doc.variables ?? {})).toEqual(["FOO"]);
53
+ });
54
+
55
+ test("the note job runs only in a pipeline of the default branch, checks nothing out and treats the artifact as data", () => {
56
+ const { doc } = generated([{ ...spec, trigger: { kind: "pull_request", branches: ["main", "release"] } }]);
57
+ const note = doc["pr-review-note"];
58
+ expect(inMergeRequestPipeline(note)).toBe(false);
59
+ expect(note.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_REVIEW_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
60
+ expect(note.variables).toEqual({ GIT_STRATEGY: "none" });
61
+ const script = (note.script as string[]).join("\n");
62
+ expect(script).toMatch(/npm install .*@intentius\/chant@\d+\.\d+\.\d+/);
63
+ expect(script).toContain("postOpenReviewNotes");
64
+ expect(script).toContain('const branches = ["main","release"]');
65
+ expect(script).toContain('job: "pr-review", artifactDir: ".chant/review", op: "pr-review"');
66
+ expect(script).not.toContain("chant run");
67
+ });
68
+
69
+ test("a workspace member's review keeps its artifact under the member's directory", () => {
70
+ const { doc } = generated([spec], { member: { name: "web", dir: "apps/web" } });
71
+ expect(doc["web-pr-review"].artifacts).toEqual({ paths: ["apps/web/.chant/review"], expire_in: "7 days" });
72
+ expect((doc["web-pr-review-note"].script as string[]).join("\n")).toContain('job: "web-pr-review", artifactDir: "apps/web/.chant/review"');
73
+ });
74
+
75
+ test("is refused on a trigger with no merge request, a posting finding-mode, or a write scope", () => {
76
+ expect(() => generateGitlabOpPipeline([{ ...spec, trigger: { kind: "push" } }])).toThrow(/sets reviewAgent.*trigger is "push"/s);
77
+ expect(() => generateGitlabOpPipeline([{ ...spec, findingMode: "comment" }])).toThrow(/findingMode "comment".*must be "report"/s);
78
+ expect(() => generateGitlabOpPipeline([{ ...spec, permissions: { "pull-requests": "write" } }])).toThrow(/"pull-requests: write"/);
15
79
  });
16
80
 
17
81
  test("an Op without it generates as before", () => {
18
82
  const out = generateGitlabOpPipeline([{ name: "pr-review", trigger: { kind: "pull_request" }, findingMode: "comment" }]);
19
83
  expect(out.jobs.map((j) => j.jobName)).toEqual(["pr-review"]);
84
+ expect(out.files[0].yaml).not.toContain("CHANT_REVIEW_NOTES");
20
85
  });
21
86
  });
@@ -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,10 @@ 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";
132
147
 
133
148
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
134
149
 
@@ -202,24 +217,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
202
217
  );
203
218
  }
204
219
 
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
220
  /**
224
221
  * The follow-up line an Op with an `environment` adds under its own header
225
222
  * line (#2257). The `environment:` key on the job binds it to the
@@ -303,20 +300,62 @@ export function generateGitlabOpPipeline(
303
300
 
304
301
  const opLines: string[] = [];
305
302
  let anyCron = false;
303
+ let anyReview = false;
304
+ // The note job of each split review (#4544), emitted after the Op jobs.
305
+ const noteJobs: string[] = [];
306
+ const words = activeProductWords();
306
307
 
307
308
  for (const spec of ops) {
308
309
  const subject = `Scheduled Op "${spec.name}"`;
310
+ // #4616: GitLab picks a runner by tags.
311
+ assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
312
+ const tags = gitlabTags(spec.runner);
309
313
  const setupScript = gitlabSetupScript(subject, spec.setup);
314
+ // #4616: the comment agent is its own pair of jobs, in a pipeline of the default branch; the spec's trigger is not read.
315
+ if (spec.agent?.on === "comment") {
316
+ const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
317
+ const jobName = jobPrefix + ciJobName(spec.name);
318
+ jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
319
+ opLines.push(...gitlabAgentHeader(jobName, settings, spec.agent.tokenEnv));
320
+ const { agent, push } = gitlabAgentJobs({
321
+ jobName,
322
+ stage,
323
+ image,
324
+ settings,
325
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
326
+ script: [...setupScript, ...beforeScript],
327
+ ...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
328
+ ...(words.bin ? { bin: words.bin } : {}),
329
+ });
330
+ doc[jobName] = agent;
331
+ const pushName = gitlabAgentPushJobName(jobName);
332
+ doc[pushName] = push;
333
+ noteJobs.push(pushName);
334
+ continue;
335
+ }
310
336
  const idTokens = gitlabIdTokens(spec.name, spec.permissions);
311
337
  const findingMode = spec.findingMode ?? "report";
312
338
  const trigger = resolveOpTrigger(spec);
313
339
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
314
- assertNoReviewSplit(spec);
340
+ // A split review (#4544): the merge request pipeline runs the model with no forge token, and a pipeline of the default branch posts.
341
+ const split = spec.reviewAgent === true;
342
+ if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
315
343
  if (trigger.kind === "cron") anyCron = true;
344
+ const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
345
+ if (driftAgent && (split || trigger.kind === "push")) {
346
+ 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.`);
347
+ }
316
348
 
317
349
  const jobName = jobPrefix + ciJobName(spec.name);
318
350
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
319
351
  opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
352
+ if (split) {
353
+ anyReview = true;
354
+ opLines.push(
355
+ `# reviewAgent: the review job holds no forge token and keeps its review as an artifact; ${reviewNoteJobName(jobName)} posts it` +
356
+ ` from a pipeline of the default branch started with ${GITLAB_REVIEW_NOTES_VAR}="1"`,
357
+ );
358
+ }
320
359
  if (spec.environment) {
321
360
  assertGitlabEnvironment(spec.name, spec.environment);
322
361
  opLines.push(environmentLine(spec.environment));
@@ -340,25 +379,70 @@ export function generateGitlabOpPipeline(
340
379
  ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
341
380
  };
342
381
 
382
+ const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
383
+ if (split) {
384
+ const { review, note } = gitlabReviewJobs({
385
+ name: spec.name,
386
+ jobName,
387
+ stage,
388
+ image,
389
+ rules: rulesFor,
390
+ script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
391
+ ...(spec.variables ? { variables: spec.variables } : {}),
392
+ ...(idTokens ? { idTokens } : {}),
393
+ artifactDir: member ? memberRepoPath(member, REVIEW_DIR) : REVIEW_DIR,
394
+ ...(trigger.kind === "pull_request" && trigger.branches?.length ? { branches: trigger.branches } : {}),
395
+ });
396
+ doc[jobName] = { ...review, ...(tags ? { tags } : {}), ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) };
397
+ const noteName = reviewNoteJobName(jobName);
398
+ doc[noteName] = { ...note, ...(tags ? { tags } : {}) };
399
+ noteJobs.push(noteName);
400
+ continue;
401
+ }
402
+
403
+ const runLines = driftAgent ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
343
404
  doc[jobName] = {
344
405
  stage,
345
406
  image,
407
+ ...(tags ? { tags } : {}),
346
408
  // GitLab's stand-in for github's per-Op concurrency group: queue the
347
409
  // next run rather than cancel the current one.
348
410
  resource_group: jobName,
349
411
  ...(idTokens ? { id_tokens: idTokens } : {}),
350
412
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
351
413
  ...nonEmpty("variables", jobVariables),
352
- rules: changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules,
353
- script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
414
+ rules: rulesFor,
415
+ script: [...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
354
416
  // `when: always` because the run this publishes for is the green one: a
355
417
  // gated apply succeeds, and the block is the only thing that says a
356
418
  // human still has to act. A run that walked through its gate writes no
357
419
  // block, and GitLab reports the empty upload as a warning, not a failure.
358
420
  ...(gated
359
421
  ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
360
- : {}),
422
+ : driftAgent
423
+ ? { artifacts: gitlabDriftArtifacts(member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR) }
424
+ : {}),
361
425
  };
426
+ if (driftAgent) {
427
+ const name = `${jobName}-agent`;
428
+ opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
429
+ const { agent, push } = gitlabAgentJobs({
430
+ jobName: name,
431
+ stage,
432
+ image,
433
+ settings: driftAgent,
434
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
435
+ script: [...setupScript, ...beforeScript],
436
+ ...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
437
+ ...(words.bin ? { bin: words.bin } : {}),
438
+ after: { job: jobName, rules: rulesFor },
439
+ // #4619: a member's drift Op left the drift under the member's directory.
440
+ ...(member ? { dir: memberRepoPath(member, AGENT_DIR) } : {}),
441
+ });
442
+ doc[name] = agent;
443
+ doc[gitlabAgentPushJobName(name)] = push;
444
+ noteJobs.push(name, gitlabAgentPushJobName(name));
445
+ }
362
446
  }
363
447
 
364
448
  const headerLines = [
@@ -375,10 +459,20 @@ export function generateGitlabOpPipeline(
375
459
  "# that job runs on that schedule.",
376
460
  );
377
461
  }
462
+ if (anyReview) {
463
+ headerLines.push(
464
+ "#",
465
+ "# A reviewAgent Op's note job posts from a pipeline of the default branch,",
466
+ `# never from the merge request's own. Create a Pipeline Schedule on the`,
467
+ `# default branch with ${GITLAB_REVIEW_NOTES_VAR}="1", and make CHANT_FORGE_TOKEN`,
468
+ "# (a token with the api scope) a protected, masked CI/CD variable so no",
469
+ "# merge request pipeline gets it.",
470
+ );
471
+ }
378
472
  headerLines.push("#", ...opLines);
379
473
 
380
474
  const yaml = emitGitlabFile(
381
- [["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const)],
475
+ [["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
476
  headerLines,
383
477
  );
384
478
 
@@ -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+- 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
+ });
@@ -129,4 +129,21 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
129
129
  expect(JSON.stringify(doc["wave-1-prod-decide"])).not.toContain("apply\"");
130
130
  expect(JSON.stringify(doc["wave-1-prod-default"])).not.toContain("prod-apply");
131
131
  });
132
+
133
+ test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
134
+ const bin = "npx --no-install plywood";
135
+ const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
136
+ const { files, jobs } = generateGitlabOpWavesPipeline(withBin, { specFile: "waves.json", bin });
137
+ expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
138
+ const yaml = files.map((f) => f.yaml).join("\n");
139
+ expect(yaml).toContain("- npx --no-install plywood run wave --spec waves.json --wave 1");
140
+ expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
141
+ expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
142
+ expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
143
+ });
144
+
145
+ test("no bin keeps chant (#4607)", () => {
146
+ const { jobs } = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" });
147
+ expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
148
+ });
132
149
  });