@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.
- package/dist/components/agent-jobs.d.ts +67 -0
- package/dist/components/agent-jobs.d.ts.map +1 -0
- package/dist/components/agent-jobs.js +139 -0
- package/dist/components/control-pipeline.d.ts +34 -0
- package/dist/components/control-pipeline.d.ts.map +1 -0
- package/dist/components/control-pipeline.js +45 -0
- package/dist/components/generate-op-pipeline.d.ts +8 -1
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-pipeline.js +75 -12
- package/dist/components/generate-op-waves-pipeline.d.ts +13 -0
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +62 -4
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +133 -14
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/agent-jobs.ts +177 -0
- package/src/components/control-pipeline.ts +65 -0
- package/src/components/generate-op-pipeline.agent.test.ts +147 -0
- package/src/components/generate-op-pipeline.notice.test.ts +2 -1
- package/src/components/generate-op-pipeline.ts +82 -11
- package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
- package/src/components/generate-op-waves-pipeline.test.ts +17 -0
- package/src/components/generate-op-waves-pipeline.ts +81 -4
- package/src/components/generate-pipeline.ts +139 -14
- package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
- package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
- package/src/components/generate-pr-pipeline.control.test.ts +94 -0
- 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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
:
|
|
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":
|
|
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
|
-
|
|
523
|
-
doc.
|
|
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 {
|
|
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:
|
|
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) } },
|