@intentius/chant-lexicon-github 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 (30) hide show
  1. package/dist/components/agent-jobs.d.ts +67 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +139 -0
  4. package/dist/components/control-pipeline.d.ts +34 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +45 -0
  7. package/dist/components/generate-op-pipeline.d.ts +8 -1
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +75 -12
  10. package/dist/components/generate-op-waves-pipeline.d.ts +13 -0
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +62 -4
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +133 -14
  15. package/dist/integrity.json +2 -2
  16. package/dist/manifest.json +1 -1
  17. package/package.json +2 -2
  18. package/src/components/agent-jobs.ts +177 -0
  19. package/src/components/control-pipeline.ts +65 -0
  20. package/src/components/generate-op-pipeline.agent.test.ts +147 -0
  21. package/src/components/generate-op-pipeline.notice.test.ts +2 -1
  22. package/src/components/generate-op-pipeline.ts +82 -11
  23. package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
  24. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  25. package/src/components/generate-op-waves-pipeline.ts +81 -4
  26. package/src/components/generate-pipeline.ts +139 -14
  27. package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
  28. package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
  29. package/src/components/generate-pr-pipeline.control.test.ts +94 -0
  30. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The agent jobs (#4616): `ScheduledOpSpec.agent` and `runner`. The model's
3
+ * job holds a read token and the model's key; only the push job writes.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import { FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
9
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
10
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
11
+
12
+ interface Step {
13
+ id?: string;
14
+ name?: string;
15
+ run?: string;
16
+ env?: Record<string, string>;
17
+ }
18
+ interface Job {
19
+ needs?: string;
20
+ if?: string;
21
+ "runs-on"?: unknown;
22
+ "timeout-minutes"?: number;
23
+ permissions?: Record<string, string>;
24
+ env?: Record<string, string>;
25
+ outputs?: Record<string, string>;
26
+ steps: Step[];
27
+ }
28
+ interface Doc {
29
+ on: Record<string, unknown>;
30
+ permissions?: Record<string, string>;
31
+ jobs: Record<string, Job>;
32
+ }
33
+
34
+ const gen = (spec: ScheduledOpSpec) => {
35
+ const out = generateGithubOpPipeline([spec]);
36
+ return { doc: parseYAML(out.files[0]!.yaml) as unknown as Doc, name: out.files[0]!.name, jobs: out.jobs };
37
+ };
38
+
39
+ const COMMENT: ScheduledOpSpec = { name: "chant-agent", agent: { on: "comment", comment: "/chant agent", maxRuns: 3, timeout: 20 } };
40
+
41
+ describe("the comment agent", () => {
42
+ const { doc, name } = gen(COMMENT);
43
+ const agent = doc.jobs["chant-agent"]!;
44
+ const push = doc.jobs["chant-agent-push"]!;
45
+
46
+ test("is a workflow of its own on pull request comments from a writer", () => {
47
+ expect(name).toBe("chant-agent.yml");
48
+ expect(doc.on).toEqual({ issue_comment: { types: ["created"] } });
49
+ expect(agent.if).toContain("startsWith(github.event.comment.body, '/chant agent ')");
50
+ expect(agent.if).toContain("author_association");
51
+ });
52
+
53
+ test("the agent job reads, stops at its timeout, and runs the model with its key and no forge token", () => {
54
+ expect(agent.permissions).toEqual({ contents: "read", "pull-requests": "read" });
55
+ expect(agent["timeout-minutes"]).toBe(35);
56
+ expect(agent.env).toMatchObject({ CHANT_AGENT_NAME: "chant-agent", CHANT_AGENT_ON: "comment", CHANT_AGENT_MAX_RUNS: "3", CHANT_AGENT_TIMEOUT: "20" });
57
+ const run = agent.steps.at(-1)!;
58
+ expect(run.id).toBe("chant-agent");
59
+ expect(run.env).toEqual({ ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" });
60
+ expect(run.run).toContain("chant agent run --forge github");
61
+ expect(run.run).toContain("trap chant_handover EXIT");
62
+ expect(Object.keys(run.env ?? {}).filter((n) => (FORGE_TOKEN_VARS as readonly string[]).includes(n))).toEqual([]);
63
+ expect(agent.steps[0]!.run).toContain("refs/pull/$PR_NUMBER/head:refs/chant/agent-head");
64
+ expect(agent.steps.some((s) => s.run?.includes("chant agent prepare"))).toBe(true);
65
+ });
66
+
67
+ test("the push job holds the write token, takes the change as data and lands it", () => {
68
+ expect(push.needs).toBe("chant-agent");
69
+ expect(push.permissions).toEqual({ contents: "write", "pull-requests": "write", issues: "write" });
70
+ const land = push.steps.at(-1)!;
71
+ expect(land.run).toContain("chant agent land --forge github");
72
+ expect(land.env?.CHANT_AGENT_TOKEN).toBe("${{ github.token }}");
73
+ expect(push.steps.some((s) => s.run?.includes("needs.chant-agent.outputs.report_0"))).toBe(true);
74
+ });
75
+
76
+ test("a push token secret replaces the job token, and the runner names where both jobs run", () => {
77
+ const { doc: d } = gen({ ...COMMENT, runner: ["self-hosted", "agents"], agent: { ...COMMENT.agent!, tokenEnv: "AGENT_PUSH_TOKEN" } });
78
+ expect(d.jobs["chant-agent"]!["runs-on"]).toEqual(["self-hosted", "agents"]);
79
+ expect(d.jobs["chant-agent-push"]!["runs-on"]).toEqual(["self-hosted", "agents"]);
80
+ expect(d.jobs["chant-agent-push"]!.steps.at(-1)!.env?.CHANT_AGENT_TOKEN).toBe("${{ secrets.AGENT_PUSH_TOKEN || github.token }}");
81
+ });
82
+
83
+ test("refuses settings no job could run", () => {
84
+ expect(() => gen({ name: "a", agent: { on: "comment" } })).toThrow(/comment/);
85
+ });
86
+ });
87
+
88
+ describe("the drift agent", () => {
89
+ const DRIFT: ScheduledOpSpec = { name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", agent: { on: "drift", maxRuns: 1 } };
90
+ const { doc } = gen(DRIFT);
91
+ const op = doc.jobs["drift-app"]!;
92
+ const agent = doc.jobs["drift-app-agent"]!;
93
+ const push = doc.jobs["drift-app-agent-push"]!;
94
+
95
+ test("the Op's job keeps its output and says when it found drift", () => {
96
+ const run = op.steps.find((s) => s.id === "chant-run")!;
97
+ expect(run.run).toContain('if [ "$code" -eq 2 ]; then echo "drift=true"');
98
+ expect(run.run).toContain("tee .chant/agent/drift.txt");
99
+ expect(op.outputs?.drift).toBe("${{ steps.chant-run.outputs.drift }}");
100
+ });
101
+
102
+ test("the agent job follows it on drift and takes its output", () => {
103
+ expect(agent.needs).toBe("drift-app");
104
+ expect(agent.if).toBe("always() && needs.drift-app.outputs.drift == 'true'");
105
+ expect(agent.steps.some((s) => s.run?.includes("needs.drift-app.outputs.report_0"))).toBe(true);
106
+ expect(agent.env?.CHANT_AGENT_BRANCH).toBe("chant/agent/drift-app");
107
+ });
108
+
109
+ test("the push job opens or updates the agent's pull request", () => {
110
+ expect(push.needs).toBe("drift-app-agent");
111
+ expect(push.steps.at(-1)!.name).toBe("Open or update the agent's pull request");
112
+ });
113
+
114
+ test("refuses a drift agent on a push trigger", () => {
115
+ expect(() => gen({ ...DRIFT, trigger: { kind: "push" } })).toThrow(/drift agent/);
116
+ });
117
+ });
118
+
119
+ describe("the runner field", () => {
120
+ test("names the Op job's runner, a group included", () => {
121
+ const { doc } = gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, runner: { group: "infra", labels: ["linux"] } });
122
+ expect(doc.jobs["drift-app"]!["runs-on"]).toEqual({ group: "infra", labels: ["linux"] });
123
+ expect(gen({ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" } }).doc.jobs["drift-app"]!["runs-on"]).toBe("ubuntu-latest");
124
+ });
125
+ });
126
+
127
+ // #4619: a member's drift Op runs in the member's directory and hands its output on; the agent job takes it at the root.
128
+ describe("the drift agent of a workspace member", () => {
129
+ const out = generateGithubOpPipeline([{ name: "drift-app", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", agent: { on: "drift", maxCostUsd: 2.5 } }], { member: { name: "app", dir: "stacks/app" } });
130
+ const doc = parseYAML(out.files[0]!.yaml) as unknown as Doc & { jobs: Record<string, Job & { defaults?: { run?: { "working-directory"?: string } } }> };
131
+ const op = doc.jobs["drift-app"]!;
132
+ const agent = doc.jobs["drift-app-agent"]!;
133
+
134
+ test("the drift check runs in the member's directory and hands on the member's own output", () => {
135
+ expect(op.defaults?.run?.["working-directory"]).toBe("stacks/app");
136
+ expect(op.steps.find((s) => s.id === "chant-run")!.run).toContain("tee .chant/agent/drift.txt");
137
+ });
138
+
139
+ test("the agent job runs at the root and takes that output into its agent directory", () => {
140
+ expect(agent.defaults).toBeUndefined();
141
+ const take = agent.steps.find((s) => s.name === "Take the drift from the drift check")!;
142
+ expect(take.run).toContain("needs.drift-app.outputs.report_0");
143
+ expect(take.run).toContain(" .chant/agent <<");
144
+ expect(agent.steps.some((s) => s.run?.includes("chant agent prepare --forge github --output .chant/agent"))).toBe(true);
145
+ expect(agent.env?.CHANT_AGENT_MAX_COST_USD).toBe("2.5");
146
+ });
147
+ });
@@ -87,7 +87,8 @@ async function fakeForge(routes: Record<string, (body?: Record<string, unknown>)
87
87
  }
88
88
 
89
89
  async function runNotice(env: Record<string, string>): Promise<string> {
90
- const { stdout } = await run(process.execPath, ["--import", "tsx", "notice.mjs"], {
90
+ // chant here is the source tree, with no dist/: the development condition picks src/*.ts over the import condition's dist/*.js (#4591).
91
+ const { stdout } = await run(process.execPath, ["--conditions=development", "--import", "tsx", "notice.mjs"], {
91
92
  cwd: jobDir(),
92
93
  env: {
93
94
  PATH: process.env.PATH ?? "",
@@ -79,7 +79,7 @@ import {
79
79
  environmentDoc,
80
80
  fillCommand,
81
81
  GITHUB_JOB_TOKEN,
82
- GITHUB_RUNNER,
82
+ githubRunsOn,
83
83
  mergePermissions,
84
84
  nonEmpty,
85
85
  permissionsFor,
@@ -97,6 +97,9 @@ import type {
97
97
  } from "@intentius/chant/lexicon";
98
98
  import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, opConcurrencyGroup, runSteps } from "./ci-jobs";
99
99
  import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs";
100
+ import { resolveAgentSpec } from "@intentius/chant/ci-agent";
101
+ import { activeProductWords } from "@intentius/chant/ci-product-words";
102
+ import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs";
100
103
 
101
104
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
102
105
 
@@ -162,6 +165,13 @@ export interface GithubOpPipelineDoc {
162
165
  * dialect transforms this job on its own terms.
163
166
  */
164
167
  reviewNoteDoc?: Record<string, unknown>;
168
+ /**
169
+ * The agent jobs (#4616), when the spec sets `agent`: the agent job and
170
+ * the push job. Kept out of {@link jobsDoc} so a member's
171
+ * `working-directory` default stays on the Op's own job: these check out
172
+ * at the repository root.
173
+ */
174
+ agentDoc?: Record<string, unknown>;
165
175
  }
166
176
 
167
177
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
@@ -361,12 +371,12 @@ function gateNoticeScript(version: string): string {
361
371
  * no branch and merges nothing. The Forgejo dialect drops them, as it drops
362
372
  * every scope but `id-token`.
363
373
  */
364
- function gateNoticeJob(applyJobName: string, image: string): Record<string, unknown> {
374
+ function gateNoticeJob(applyJobName: string, image: string, runner?: ScheduledOpSpec["runner"]): Record<string, unknown> {
365
375
  const output = (name: string) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
366
376
  return {
367
377
  needs: applyJobName,
368
378
  if: `needs.${applyJobName}.outputs.gated == 'true'`,
369
- "runs-on": GITHUB_RUNNER,
379
+ "runs-on": githubRunsOn(runner),
370
380
  container: image,
371
381
  permissions: { contents: "read", issues: "write", "pull-requests": "write" },
372
382
  steps: [
@@ -415,6 +425,7 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
415
425
  export function buildGithubOpPipelineDocs(
416
426
  ops: ScheduledOpSpec[],
417
427
  options: GenerateGithubOpOptions = {},
428
+ forge: "github" | "forgejo" = "github",
418
429
  ): { files: GithubOpPipelineFile[]; jobs: OpPipelineJob[] } {
419
430
  const image = options.image ?? CI_IMAGE;
420
431
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
@@ -424,7 +435,41 @@ export function buildGithubOpPipelineDocs(
424
435
  const files: GithubOpPipelineFile[] = [];
425
436
  const jobs: OpPipelineJob[] = [];
426
437
 
438
+ const words = activeProductWords();
427
439
  for (const spec of ops) {
440
+ // #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
441
+ if (spec.agent?.on === "comment") {
442
+ const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
443
+ const jobName = ciJobName(spec.name);
444
+ const setup = spec.setup ?? [];
445
+ assertSetupSteps(spec.name, setup);
446
+ jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
447
+ const { agent, push } = githubAgentJobs({
448
+ forge,
449
+ jobName,
450
+ image,
451
+ settings,
452
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
453
+ setup: setup.map(setupStepDoc),
454
+ beforeScript,
455
+ ...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
456
+ ...(words.bin ? { bin: words.bin } : {}),
457
+ });
458
+ files.push({
459
+ name: `${spec.name}.yml`,
460
+ doc: {
461
+ name: spec.name,
462
+ on: { issue_comment: { types: ["created"] } },
463
+ ...nonEmpty("env", options.variables),
464
+ // One agent run at a time, so two asks never count the limit at once.
465
+ concurrency: concurrency(jobName),
466
+ permissions: { contents: "read" },
467
+ jobsDoc: {},
468
+ agentDoc: { [jobName]: agent, [agentPushJobName(jobName)]: push },
469
+ },
470
+ });
471
+ continue;
472
+ }
428
473
  const findingMode = spec.findingMode ?? "report";
429
474
  const trigger = resolveOpTrigger(spec);
430
475
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
@@ -438,6 +483,11 @@ export function buildGithubOpPipelineDocs(
438
483
  // A split review (#4326) runs the change's code beside a model: no step of that job holds a forge token.
439
484
  const split = spec.reviewAgent === true;
440
485
  if (split) assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
486
+ // #4616: a drift agent follows the Op's job, which hands its output on and says whether it found drift.
487
+ const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
488
+ if (driftAgent && (split || trigger.kind === "push")) {
489
+ throw new Error(`Scheduled Op "${spec.name}" sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
490
+ }
441
491
  const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
442
492
 
443
493
  const setup = spec.setup ?? [];
@@ -457,7 +507,9 @@ export function buildGithubOpPipelineDocs(
457
507
  steps.push(
458
508
  gated
459
509
  ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
460
- : { run: invocation, env: stepEnv },
510
+ : driftAgent
511
+ ? driftRunStep(invocation, stepEnv, forge)
512
+ : { run: invocation, env: stepEnv },
461
513
  );
462
514
  steps.push(...runSteps(extraScript));
463
515
 
@@ -490,7 +542,7 @@ export function buildGithubOpPipelineDocs(
490
542
  ),
491
543
  jobsDoc: {
492
544
  [jobName]: {
493
- "runs-on": GITHUB_RUNNER,
545
+ "runs-on": githubRunsOn(spec.runner),
494
546
  container: image,
495
547
  // Per-Op credentials (#2290): job-level `env:`, one level more
496
548
  // specific than `options.variables`'s workflow-level `env:` above,
@@ -511,16 +563,35 @@ export function buildGithubOpPipelineDocs(
511
563
  ]),
512
564
  ),
513
565
  }
514
- : {}),
566
+ : driftAgent
567
+ ? { outputs: driftRunOutputs(forge) }
568
+ : {}),
515
569
  steps,
516
570
  },
517
571
  },
518
- ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
572
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
519
573
  };
574
+ if (driftAgent) {
575
+ const name = agentJobName(jobName, "drift");
576
+ const { agent, push } = githubAgentJobs({
577
+ forge,
578
+ jobName: name,
579
+ image,
580
+ settings: driftAgent,
581
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
582
+ setup: setup.map(setupStepDoc),
583
+ beforeScript,
584
+ ...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
585
+ ...(words.bin ? { bin: words.bin } : {}),
586
+ after: jobName,
587
+ });
588
+ doc.agentDoc = { [name]: agent, [agentPushJobName(name)]: push };
589
+ }
520
590
  // A split review (#4326): the Op's job is the review job, and the note job rides beside it.
521
591
  if (review) {
522
- doc.jobsDoc = { [jobName]: { ...review.review, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
523
- doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: review.note };
592
+ const runsOn = spec.runner !== undefined ? { "runs-on": githubRunsOn(spec.runner) } : {};
593
+ doc.jobsDoc = { [jobName]: { ...review.review, ...runsOn, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
594
+ doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: { ...review.note, ...runsOn } };
524
595
  }
525
596
 
526
597
  files.push({ name: `${spec.name}.yml`, doc });
@@ -580,8 +651,8 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
580
651
  // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
581
652
  // is carried separately on the doc so a dialect transforms it on its own.
582
653
  // A dialect's `header` note (#2257) is absent on github.
583
- const { gatedNoticeDoc, reviewNoteDoc, ...workflow } = doc;
584
- return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}) } });
654
+ const { gatedNoticeDoc, reviewNoteDoc, agentDoc, ...workflow } = doc;
655
+ return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}), ...(agentDoc ?? {}) } });
585
656
  }
586
657
 
587
658
  /**
@@ -0,0 +1,64 @@
1
+ /**
2
+ * An Op waves spec's `control` (#4617): after the last wave, one job adds up
3
+ * the wave jobs' outcomes and tells the control repo how the waves ended.
4
+ * Written for the ci-products session; run at epic-ready.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
9
+ import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
10
+
11
+ const spec: OpWavesSpec = {
12
+ name: "stacks",
13
+ op: "pulumi-up",
14
+ plan: ["./bin/plan", "{target}", "{plan}"],
15
+ waves: [
16
+ { name: "canary", runs: [{ target: "dev" }], gate: "never" },
17
+ { name: "rest", runs: [{ target: "a" }, { target: "b" }], shares: 2 },
18
+ ],
19
+ control: { repo: "github.com/acme/control", op: "deploy", member: "network", tokenSecret: "PLYWOOD_CONTROL_TOKEN" },
20
+ };
21
+
22
+ const options = { specFile: "waves.json", bin: "npx --no-install plywood", words: { product: "plywood" } };
23
+
24
+ describe("the control dispatch after the last wave (#4617)", () => {
25
+ test("needs the last wave's share jobs and runs after a failure, unless the run was cancelled", () => {
26
+ const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
27
+ expect(yaml).toMatch(/control-dispatch:\n\s+runs-on: ubuntu-latest\n\s+needs:\n\s+- wave-2-rest-share-1\n\s+- wave-2-rest-share-2\n\s+if: '!cancelled\(\)'/);
28
+ });
29
+
30
+ test("downloads every outcome file and adds them up through the product's bin", () => {
31
+ const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
32
+ expect(yaml).toContain("name: Download the waves' outcomes");
33
+ expect(yaml).toContain("path: .chant/op-waves/stacks/outcomes");
34
+ expect(yaml).toContain('outcome="$(npx --no-install plywood run wave --spec waves.json --waves-outcome)" || exit 1');
35
+ expect(yaml).toContain('echo "CHANT_CONTROL_OUTCOME=$outcome" >> "$GITHUB_ENV"');
36
+ });
37
+
38
+ test("the dispatch step alone holds the token, maps it from the product's secret and pipes it to curl", () => {
39
+ const yaml = generateGithubOpWavesPipeline(spec, options).files[0]!.yaml;
40
+ expect(yaml.match(/secrets\.PLYWOOD_CONTROL_TOKEN/g)).toHaveLength(1);
41
+ expect(yaml).toContain("CHANT_CONTROL_TOKEN: '${{ secrets.PLYWOOD_CONTROL_TOKEN }}'");
42
+ expect(yaml).toContain("CHANT_CONTROL_SHA: '${{ github.sha }}'");
43
+ expect(yaml).toContain("-H @-");
44
+ expect(yaml).toContain('"event_type":"plywood-applied"');
45
+ expect(yaml).toContain(`"outcome":"'"$outcome"'"`);
46
+ });
47
+
48
+ test("markSent asks for statuses: write and hands the job token over", () => {
49
+ const yaml = generateGithubOpWavesPipeline({ ...spec, control: { ...spec.control!, markSent: "plywood post status control success" } }, options).files[0]!.yaml;
50
+ expect(yaml).toMatch(/control-dispatch:[\s\S]*permissions:\n\s+contents: write\n\s+statuses: write/);
51
+ expect(yaml).toContain("plywood post status control success");
52
+ expect(yaml).toContain("GITHUB_TOKEN: '${{ github.token }}'");
53
+ });
54
+
55
+ test("no control, no job", () => {
56
+ const { control: _drop, ...plain } = spec;
57
+ expect(generateGithubOpWavesPipeline(plain, options).files[0]!.yaml).not.toContain("control-dispatch");
58
+ });
59
+
60
+ test("a control without a member is refused, since a spec has no repository name to default it to", () => {
61
+ const { member: _m, ...noMember } = spec.control!;
62
+ expect(() => generateGithubOpWavesPipeline({ ...spec, control: noMember as never }, options)).toThrow(/control\.member/);
63
+ });
64
+ });
@@ -121,4 +121,21 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
121
121
  expect(files.map((f) => f.name)).toEqual(["migrations.yml", "migrations-resume.yml", "migrations-plans.yml"]);
122
122
  for (const f of files) expect(f.yaml).not.toContain("ubuntu-latest");
123
123
  });
124
+
125
+ test("words.bin is the command in every job line: the waves, resume and the plans job (#4607)", () => {
126
+ const bin = "npx --no-install plywood";
127
+ const withBin = { ...spec, resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) };
128
+ const { files, jobs } = generateGithubOpWavesPipeline(withBin, { specFile: "waves.json", bin });
129
+ expect(jobs.every((j) => j.command.slice(0, 3).join(" ") === bin)).toBe(true);
130
+ const yaml = files.map((f) => f.yaml).join("\n");
131
+ expect(yaml).toContain("run: npx --no-install plywood run wave --spec waves.json --wave 1");
132
+ expect(yaml).toContain("npx --no-install plywood run resume --op migrations");
133
+ expect(yaml).toContain("npx --no-install plywood run wave --spec waves.json --record-plans");
134
+ expect(yaml).not.toMatch(/(^|\s)chant run (wave|resume)/m);
135
+ });
136
+
137
+ test("no bin keeps chant (#4607)", () => {
138
+ const { jobs } = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" });
139
+ expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
140
+ });
124
141
  });
@@ -22,6 +22,9 @@
22
22
  * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
23
23
  * and applies its dialect.
24
24
  *
25
+ * `options.bin` (#4607) is a product's command in place of `chant` in every
26
+ * job line: the wave jobs, the resume job and the plans job.
27
+ *
25
28
  * With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
26
29
  * runs `chant run resume --op <name>` on that schedule: it re-runs the failed
27
30
  * jobs of a run whose waiting wave has been approved since, with the job's
@@ -49,9 +52,31 @@
49
52
  * checkout. Every later run step starts with an unset of the variables a
50
53
  * token is requested with, so the code the job runs holds no OIDC token
51
54
  * and cannot request one for another group's identity.
55
+ *
56
+ * With `control` on the spec (#4617), a `control-dispatch` job needs the
57
+ * last wave's jobs and runs after them unless the run was cancelled, so it
58
+ * runs after a failure too. It downloads every job's outcome file, adds
59
+ * them up with `chant run wave --waves-outcome` (through the product's
60
+ * bin) into `CHANT_CONTROL_OUTCOME`, and sends the control repo one
61
+ * dispatch carrying that outcome and the pushed commit. Only the dispatch
62
+ * step holds the control token: the `tokenSecret` secret, mapped into
63
+ * `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
64
+ * (`controlDispatchScript`, #4568).
52
65
  */
53
66
 
54
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
67
+ import {
68
+ notifyVariables,
69
+ opWaveGroupOf,
70
+ opWaveJobs,
71
+ opWaveOutcomeDir,
72
+ opWavesControlTarget,
73
+ opWavesLastJobs,
74
+ opWavesOutcomeCommand,
75
+ opWavesRecordPlansCommand,
76
+ opWavesResumeCommand,
77
+ type OpWavesSpec,
78
+ } from "@intentius/chant/op/op-waves";
79
+ import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
55
80
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
56
81
  import {
57
82
  assertEnvironment,
@@ -89,7 +114,7 @@ export function buildGithubOpWavesDoc(
89
114
  spec: OpWavesSpec,
90
115
  options: OpWavesPipelineOptions,
91
116
  ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
92
- const jobs = opWaveJobs(spec, options.specFile);
117
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
93
118
  const image = options.image ?? OP_WAVES_IMAGE;
94
119
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
95
120
  for (const wave of spec.waves) {
@@ -143,6 +168,12 @@ export function buildGithubOpWavesDoc(
143
168
  };
144
169
  }
145
170
 
171
+ // #4617: once the last wave ran, one job tells the control repo how the waves ended.
172
+ if (spec.control) {
173
+ const [name, job] = controlJob(spec, options, jobs, image, permissions);
174
+ jobsDoc[name] = job;
175
+ }
176
+
146
177
  // #3761: the notify variables come from the secrets of the same name.
147
178
  const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
148
179
  const doc: GithubOpPipelineDoc = {
@@ -161,12 +192,58 @@ export function buildGithubOpWavesDoc(
161
192
  };
162
193
  }
163
194
 
195
+ /**
196
+ * The control dispatch job (#4617): after the last wave, whatever it did,
197
+ * the waves' outcome read from every job's outcome file, then one dispatch
198
+ * carrying it. The pushed commit is the one every wave ran at.
199
+ */
200
+ function controlJob(
201
+ spec: OpWavesSpec,
202
+ options: OpWavesPipelineOptions,
203
+ jobs: OpWavesPipelineResult["jobs"],
204
+ image: string,
205
+ permissions: Record<string, unknown>,
206
+ ): [string, Record<string, unknown>] {
207
+ const control = spec.control!;
208
+ const dispatch = controlDispatchJob("github", opWavesControlTarget(control), "${{ github.sha }}", {
209
+ ...(options.words ? { words: options.words } : {}),
210
+ ...(control.sentCheck ? { sentCheck: control.sentCheck } : {}),
211
+ ...(control.markSent ? { markSent: control.markSent } : {}),
212
+ outcome: true,
213
+ });
214
+ const marks = control.sentCheck !== undefined || control.markSent !== undefined;
215
+ const steps: Array<Record<string, unknown>> = [
216
+ checkoutStep(true),
217
+ { name: "Download the waves' outcomes", uses: DOWNLOAD_ARTIFACT, with: { path: opWaveOutcomeDir(spec) } },
218
+ ...(spec.waves[0]?.setup ?? []).map(setupStepDoc),
219
+ ...runSteps(options.beforeScript ?? []),
220
+ {
221
+ name: "Add up the waves' outcomes",
222
+ run: `outcome="$(${opWavesOutcomeCommand(options.specFile, options.bin).join(" ")})" || exit 1\necho "${CONTROL_OUTCOME_VAR}=$outcome" >> "$GITHUB_ENV"`,
223
+ },
224
+ { name: `Tell the control repo ${control.repo} how ${control.member}'s waves ended`, env: { ...dispatch.env, ...(marks ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script },
225
+ ];
226
+ return [
227
+ dispatch.name,
228
+ {
229
+ "runs-on": githubRunsOn(spec.runner),
230
+ needs: opWavesLastJobs(jobs),
231
+ // The waves ran, whatever they did: a failed run is an outcome the control repo is told about.
232
+ if: "!cancelled()",
233
+ container: image,
234
+ // markSent sets a commit status; the rest reads the repository and the run's artifacts.
235
+ permissions: marks ? { ...permissions, statuses: "write" } : permissions,
236
+ steps,
237
+ },
238
+ ];
239
+ }
240
+
164
241
  /** The scheduled resume workflow (#3683). */
165
242
  function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
166
243
  const steps: Array<Record<string, unknown>> = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
167
244
  steps.push({
168
245
  name: `Resume the waves of ${spec.name} whose approval arrived`,
169
- run: opWavesResumeCommand(spec).join(" "),
246
+ run: opWavesResumeCommand(spec, options.bin).join(" "),
170
247
  env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
171
248
  });
172
249
  return {
@@ -182,7 +259,7 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
182
259
  /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
183
260
  function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
184
261
  const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
185
- steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
262
+ steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
186
263
  return {
187
264
  name: `${spec.name}-plans`,
188
265
  on: { pull_request: { branches: pushBranches(spec.branches) } },