@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,67 @@
1
+ /**
2
+ * The agent jobs on GitHub and Forgejo (#4616): `ScheduledOpSpec.agent`.
3
+ * What each job does is @intentius/chant/ci-agent's; this file lays them
4
+ * out so the model never shares a job with a write token:
5
+ *
6
+ * - the agent job holds `contents: read` and `pull-requests: read`. Its
7
+ * checkout script alone sees the job token, for the fetches (the target
8
+ * branch's commit, chant/lifecycle for the run count, and for a comment
9
+ * the pull request's head). `chant agent prepare` reads with the job
10
+ * token; `chant agent run` is the last step and starts without any forge
11
+ * token, with the model's key from its secret, and hands the agent
12
+ * directory on as the job's outputs (`handoverTrap`).
13
+ * - the push job checks out the target branch's commit, takes the agent
14
+ * directory back as data and runs `chant agent land` with the push token:
15
+ * `tokenEnv`'s secret, else the job token. A push made with the job token
16
+ * starts no workflow on GitHub, so the pull request is planned again only
17
+ * with a token of a user or an app.
18
+ *
19
+ * Forgejo's runner ignores `permissions:` and has no author association, so
20
+ * there `chant agent prepare` asks the forge whether the commenter can write,
21
+ * and both jobs drop the token variables before any step that does not need
22
+ * them.
23
+ */
24
+ import { type CiRunner } from "@intentius/chant/ci-jobs";
25
+ import { type AgentSettings } from "@intentius/chant/ci-agent";
26
+ /** The agent step's id, which the agent job's outputs come from. */
27
+ export declare const AGENT_STEP_ID = "chant-agent";
28
+ /** The drift Op's run step id, whose outputs carry the drift and the code. */
29
+ export declare const AGENT_DRIFT_STEP_ID = "chant-run";
30
+ /** The name of the push job beside the agent job. */
31
+ export declare function agentPushJobName(jobName: string): string;
32
+ /** The agent job's name for a drift Op's job. */
33
+ export declare function agentJobName(jobName: string, on: AgentSettings["on"]): string;
34
+ export interface GithubAgentJobsInput {
35
+ forge: "github" | "forgejo";
36
+ /** The agent job's name. */
37
+ jobName: string;
38
+ image: string;
39
+ settings: AgentSettings;
40
+ runner?: CiRunner;
41
+ /** Setup steps, as built by `setupStepDoc`. */
42
+ setup: Array<Record<string, unknown>>;
43
+ beforeScript: readonly string[];
44
+ /** The secret holding the push token; absent: the job token. */
45
+ tokenEnv?: string;
46
+ /** The command in place of `chant`: the product's bin. */
47
+ bin?: string;
48
+ /** For a drift agent: the Op's job, which handed the drift on. */
49
+ after?: string;
50
+ }
51
+ /** The comment condition of the agent job: a pull request comment that starts with the agent's line, from a writer on GitHub. */
52
+ export declare function agentCommentIf(forge: "github" | "forgejo", comment: string): string;
53
+ /**
54
+ * The drift Op's run step (#4616): the run with its output kept as the
55
+ * drift for the agent, `drift=true` among the step's outputs when it exits
56
+ * with chant's `drift` code, and the output handed on. The step still exits
57
+ * with the run's code.
58
+ */
59
+ export declare function driftRunStep(invocation: string, env: Record<string, string>, forge: "github" | "forgejo"): Record<string, unknown>;
60
+ /** The drift Op job's outputs: the handed-on drift and the drift flag. */
61
+ export declare function driftRunOutputs(forge: "github" | "forgejo"): Record<string, string>;
62
+ /** The agent job and the push job. */
63
+ export declare function githubAgentJobs(i: GithubAgentJobsInput): {
64
+ agent: Record<string, unknown>;
65
+ push: Record<string, unknown>;
66
+ };
67
+ //# sourceMappingURL=agent-jobs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-jobs.d.ts","sourceRoot":"","sources":["../../src/components/agent-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAwE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAC/H,OAAO,EAUL,KAAK,aAAa,EACnB,MAAM,2BAA2B,CAAC;AAGnC,oEAAoE;AACpE,eAAO,MAAM,aAAa,gBAAgB,CAAC;AAC3C,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB,cAAc,CAAC;AAQ/C,qDAAqD;AACrD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,MAAM,CAE7E;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC5B,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,aAAa,CAAC;IACxB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,+CAA+C;IAC/C,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,iIAAiI;AACjI,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAOnF;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAclI;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEnF;AAED,sCAAsC;AACtC,wBAAgB,eAAe,CAAC,CAAC,EAAE,oBAAoB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CA2D1H"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The agent jobs on GitHub and Forgejo (#4616): `ScheduledOpSpec.agent`.
3
+ * What each job does is @intentius/chant/ci-agent's; this file lays them
4
+ * out so the model never shares a job with a write token:
5
+ *
6
+ * - the agent job holds `contents: read` and `pull-requests: read`. Its
7
+ * checkout script alone sees the job token, for the fetches (the target
8
+ * branch's commit, chant/lifecycle for the run count, and for a comment
9
+ * the pull request's head). `chant agent prepare` reads with the job
10
+ * token; `chant agent run` is the last step and starts without any forge
11
+ * token, with the model's key from its secret, and hands the agent
12
+ * directory on as the job's outputs (`handoverTrap`).
13
+ * - the push job checks out the target branch's commit, takes the agent
14
+ * directory back as data and runs `chant agent land` with the push token:
15
+ * `tokenEnv`'s secret, else the job token. A push made with the job token
16
+ * starts no workflow on GitHub, so the pull request is planned again only
17
+ * with a token of a user or an app.
18
+ *
19
+ * Forgejo's runner ignores `permissions:` and has no author association, so
20
+ * there `chant agent prepare` asks the forge whether the commenter can write,
21
+ * and both jobs drop the token variables before any step that does not need
22
+ * them.
23
+ */
24
+ import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
25
+ import { githubRunsOn, GITHUB_JOB_TOKEN, githubSecret, githubSecretOrJobToken } from "@intentius/chant/ci-jobs";
26
+ import { AGENT_DECISION_FILE, AGENT_DIR, AGENT_DRIFT_FILE, AGENT_PATCH_FILE, AGENT_RC_FILE, agentCommandLines, agentGitAuthLines, agentHeadFetchLine, agentSettingsEnv, } from "@intentius/chant/ci-agent";
27
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
28
+ /** The agent step's id, which the agent job's outputs come from. */
29
+ export const AGENT_STEP_ID = "chant-agent";
30
+ /** The drift Op's run step id, whose outputs carry the drift and the code. */
31
+ export const AGENT_DRIFT_STEP_ID = "chant-run";
32
+ /** The variable the push step holds its token in, for git's header and the forge client. */
33
+ const PUSH_TOKEN_ENV = "CHANT_AGENT_TOKEN";
34
+ /** The files the handover keeps when the agent directory is too large to hand on whole. */
35
+ const HANDED = { label: "chant-agent", noteFiles: [AGENT_DECISION_FILE, AGENT_RC_FILE, AGENT_PATCH_FILE, "prompt.sha256"] };
36
+ const DRIFT_HANDED = { label: "chant-agent", noteFiles: [AGENT_DRIFT_FILE] };
37
+ /** The name of the push job beside the agent job. */
38
+ export function agentPushJobName(jobName) {
39
+ return `${jobName}-push`;
40
+ }
41
+ /** The agent job's name for a drift Op's job. */
42
+ export function agentJobName(jobName, on) {
43
+ return on === "drift" ? `${jobName}-agent` : jobName;
44
+ }
45
+ /** The comment condition of the agent job: a pull request comment that starts with the agent's line, from a writer on GitHub. */
46
+ export function agentCommentIf(forge, comment) {
47
+ return [
48
+ "github.event_name == 'issue_comment'",
49
+ forge === "forgejo" ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
50
+ `startsWith(github.event.comment.body, '${comment.replace(/'/g, "''")} ')`,
51
+ ...(forge === "forgejo" ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
52
+ ].join(" && ");
53
+ }
54
+ /**
55
+ * The drift Op's run step (#4616): the run with its output kept as the
56
+ * drift for the agent, `drift=true` among the step's outputs when it exits
57
+ * with chant's `drift` code, and the output handed on. The step still exits
58
+ * with the run's code.
59
+ */
60
+ export function driftRunStep(invocation, env, forge) {
61
+ return {
62
+ id: AGENT_DRIFT_STEP_ID,
63
+ env,
64
+ run: [
65
+ "set +e",
66
+ `mkdir -p ${AGENT_DIR}`,
67
+ ...handoverTrap(AGENT_DIR, forge, DRIFT_HANDED),
68
+ `{ ${invocation}; echo "$?" >"\${RUNNER_TEMP:-/tmp}/chant-run.code"; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}`,
69
+ 'code=$(cat "${RUNNER_TEMP:-/tmp}/chant-run.code")',
70
+ `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "drift=true" >>"$GITHUB_OUTPUT"; fi`,
71
+ 'exit "$code"',
72
+ ].join("\n"),
73
+ };
74
+ }
75
+ /** The drift Op job's outputs: the handed-on drift and the drift flag. */
76
+ export function driftRunOutputs(forge) {
77
+ return { ...handoverOutputs(AGENT_DRIFT_STEP_ID, forge), drift: `\${{ steps.${AGENT_DRIFT_STEP_ID}.outputs.drift }}` };
78
+ }
79
+ /** The agent job and the push job. */
80
+ export function githubAgentJobs(i) {
81
+ const s = i.settings;
82
+ const lines = agentCommandLines(i.forge, i.bin);
83
+ const drop = dropForgeTokens("sh");
84
+ const comment = s.on === "comment";
85
+ const settingsEnv = agentSettingsEnv(s);
86
+ const checkout = (head) => ({
87
+ name: head ? "Check out the target branch's commit and the pull request's head" : "Check out the commit the drift check ran on",
88
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, ...(head ? { PR_NUMBER: "${{ github.event.issue.number }}" } : {}) },
89
+ // The ledger branch too: prepare counts the agent's runs on it, and land appends to it.
90
+ run: [checkoutScript(true, true), ...(head ? [agentHeadFetchLine(i.forge, "PR_NUMBER")] : [])].join("\n"),
91
+ });
92
+ const before = i.beforeScript.map((line) => ({ run: `${drop}\n${line}` }));
93
+ const timeout = s.timeout + 15;
94
+ const agent = {
95
+ ...(comment ? { if: agentCommentIf(i.forge, s.comment) } : { needs: i.after, if: `always() && needs.${i.after}.outputs.drift == 'true'` }),
96
+ "runs-on": githubRunsOn(i.runner),
97
+ container: i.image,
98
+ "timeout-minutes": timeout,
99
+ permissions: { contents: "read", "pull-requests": "read" },
100
+ env: settingsEnv,
101
+ outputs: handoverOutputs(AGENT_STEP_ID, i.forge),
102
+ steps: [
103
+ checkout(comment),
104
+ ...(comment ? [] : [{ name: "Take the drift from the drift check", run: takeOverScript(i.after, AGENT_DIR, i.forge) }]),
105
+ ...i.setup,
106
+ ...before,
107
+ // Reads only: the comment, the commenter's access, the pull request's head and the ledger.
108
+ { name: "Decide whether the agent runs", env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN }, run: lines.prepare },
109
+ // The job's last step: the model's key and no forge token, and the agent directory handed on however it ends.
110
+ {
111
+ id: AGENT_STEP_ID,
112
+ name: "Run the agent",
113
+ env: { [s.keyEnv]: githubSecret(s.keyEnv) },
114
+ run: [drop, ...handoverTrap(AGENT_DIR, i.forge, HANDED), lines.run].join("\n"),
115
+ },
116
+ ],
117
+ };
118
+ const token = i.tokenEnv ? githubSecretOrJobToken(i.tokenEnv) : GITHUB_JOB_TOKEN;
119
+ const push = {
120
+ needs: i.jobName,
121
+ if: `always() && needs.${i.jobName}.result == 'success'`,
122
+ "runs-on": githubRunsOn(i.runner),
123
+ container: i.image,
124
+ permissions: { contents: "write", "pull-requests": "write", issues: "write" },
125
+ env: settingsEnv,
126
+ steps: [
127
+ checkout(comment),
128
+ { name: "Take the change from the agent job", run: takeOverScript(i.jobName, AGENT_DIR, i.forge) },
129
+ ...i.setup,
130
+ ...before,
131
+ {
132
+ name: comment ? "Push the change to the pull request" : "Open or update the agent's pull request",
133
+ env: { [PUSH_TOKEN_ENV]: token, GITHUB_TOKEN: token, ...(i.forge === "forgejo" ? { CHANT_FORGEJO_TOKEN: token } : {}) },
134
+ run: [...agentGitAuthLines(PUSH_TOKEN_ENV, "x-access-token"), lines.land].join("\n"),
135
+ },
136
+ ],
137
+ };
138
+ return { agent, push };
139
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on GitHub, and on Forgejo through
3
+ * its dialect: a member's dispatch starts it, and it runs the member's
4
+ * dependents with `chant workspace run --dependents-of`. The pieces are
5
+ * core's (`controlPipelineSpec` in `@intentius/chant/control/dispatch`);
6
+ * this file writes them as a workflow. The member's side, the
7
+ * `control-dispatch` job, comes from the pull request loop's `control`
8
+ * option (./generate-pipeline.ts).
9
+ */
10
+ import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
11
+ import { type GithubWorkflowDoc } from "./ci-jobs.js";
12
+ /** A control repo's pipeline file: where it goes, its text, and what a person still sets up. */
13
+ export interface ControlPipelineFile {
14
+ path: string;
15
+ content: string;
16
+ notes: string[];
17
+ }
18
+ /** What {@link controlGithubDoc} and the generators take. */
19
+ export interface ControlPipelineOptions {
20
+ words?: CiProductWordsInput | CiProductWords;
21
+ /** The image the job runs in. Default chant's CI image. */
22
+ image?: string;
23
+ /** The command the job runs chant with. Default `npx --no-install chant`. */
24
+ command?: string;
25
+ }
26
+ /** The control repo's workflow document for a GitHub-shaped forge, before emission. Forgejo transforms it first. */
27
+ export declare function controlGithubDoc(forge: "github" | "forgejo", options?: ControlPipelineOptions): {
28
+ doc: GithubWorkflowDoc;
29
+ path: string;
30
+ notes: string[];
31
+ };
32
+ /** The control repo's workflow on GitHub: `.github/workflows/<product>-dependents.yml`. */
33
+ export declare function generateControlPipeline(options?: ControlPipelineOptions): ControlPipelineFile;
34
+ //# sourceMappingURL=control-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/control-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7F,OAAO,EAA2C,KAAK,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAE5F,gGAAgG;AAChG,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,6DAA6D;AAC7D,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,mBAAmB,GAAG,cAAc,CAAC;IAC7C,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,oHAAoH;AACpH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,EAAE,OAAO,GAAE,sBAA2B,GAAG;IAAE,GAAG,EAAE,iBAAiB,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CA0B7J;AAED,2FAA2F;AAC3F,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,sBAA2B,GAAG,mBAAmB,CAGjG"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The control repo's own workflow (#4568) on GitHub, and on Forgejo through
3
+ * its dialect: a member's dispatch starts it, and it runs the member's
4
+ * dependents with `chant workspace run --dependents-of`. The pieces are
5
+ * core's (`controlPipelineSpec` in `@intentius/chant/control/dispatch`);
6
+ * this file writes them as a workflow. The member's side, the
7
+ * `control-dispatch` job, comes from the pull request loop's `control`
8
+ * option (./generate-pipeline.ts).
9
+ */
10
+ import { CI_IMAGE, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
11
+ import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
12
+ import { CHECKOUT, concurrency, emitWorkflowYAML } from "./ci-jobs.js";
13
+ /** The control repo's workflow document for a GitHub-shaped forge, before emission. Forgejo transforms it first. */
14
+ export function controlGithubDoc(forge, options = {}) {
15
+ const spec = controlPipelineSpec(forge, options.words ?? {}, options.command ? { command: options.command } : {});
16
+ const doc = {
17
+ header: spec.header,
18
+ name: spec.path.split("/").pop().replace(/\.yml$/, ""),
19
+ on: {
20
+ ...(forge === "github" ? { repository_dispatch: { types: [spec.event] } } : {}),
21
+ workflow_dispatch: { inputs: spec.inputs },
22
+ },
23
+ // #4617: contents: write pushes the record of each dispatch that carries an outcome to chant/lifecycle.
24
+ permissions: { contents: "write" },
25
+ // One run per member at a time, and none cancelled: a later apply of the same member waits for the one running.
26
+ concurrency: concurrency(spec.group),
27
+ jobsDoc: {
28
+ dependents: {
29
+ "runs-on": GITHUB_RUNNER,
30
+ container: options.image ?? CI_IMAGE,
31
+ steps: [
32
+ // The full history, so the job reads and appends to chant/lifecycle (#4617).
33
+ { uses: CHECKOUT, with: { "fetch-depth": 0 } },
34
+ { name: "Run the dependents of the member that applied", shell: "bash", env: spec.env, run: spec.script },
35
+ ],
36
+ },
37
+ },
38
+ };
39
+ return { doc, path: spec.path, notes: spec.notes };
40
+ }
41
+ /** The control repo's workflow on GitHub: `.github/workflows/<product>-dependents.yml`. */
42
+ export function generateControlPipeline(options = {}) {
43
+ const { doc, path, notes } = controlGithubDoc("github", options);
44
+ return { path, content: emitWorkflowYAML(doc), notes };
45
+ }
@@ -131,6 +131,13 @@ export interface GithubOpPipelineDoc {
131
131
  * dialect transforms this job on its own terms.
132
132
  */
133
133
  reviewNoteDoc?: Record<string, unknown>;
134
+ /**
135
+ * The agent jobs (#4616), when the spec sets `agent`: the agent job and
136
+ * the push job. Kept out of {@link jobsDoc} so a member's
137
+ * `working-directory` default stays on the Op's own job: these check out
138
+ * at the repository root.
139
+ */
140
+ agentDoc?: Record<string, unknown>;
134
141
  }
135
142
  /** One generated file: a suggested name plus its pipeline document, pre-emission. */
136
143
  export interface GithubOpPipelineFile {
@@ -150,7 +157,7 @@ export interface GithubOpPipelineFile {
150
157
  * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
151
158
  * mergePermissions}).
152
159
  */
153
- export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions): {
160
+ export declare function buildGithubOpPipelineDocs(ops: ScheduledOpSpec[], options?: GenerateGithubOpOptions, forge?: "github" | "forgejo"): {
154
161
  files: GithubOpPipelineFile[];
155
162
  jobs: OpPipelineJob[];
156
163
  };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAoH1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAGH,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAOhB,gBAAgB,EAIjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAEnD,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAG1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAOlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAIhE,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAuOD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,KAAK,GAAE,QAAQ,GAAG,SAAoB,GACrC;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CAgL1D;AAwCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAMnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
@@ -70,10 +70,13 @@
70
70
  * that job either, same as `environment:` above does not).
71
71
  */
72
72
  import { readFileSync } from "node:fs";
73
- import { assertEnvironment, assertSetupSteps, CI_IMAGE, ciJobName, environmentDoc, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, mergePermissions, nonEmpty, permissionsFor, setupStepDoc, } from "@intentius/chant/ci-jobs";
73
+ import { assertEnvironment, assertSetupSteps, CI_IMAGE, ciJobName, environmentDoc, fillCommand, GITHUB_JOB_TOKEN, githubRunsOn, mergePermissions, nonEmpty, permissionsFor, setupStepDoc, } from "@intentius/chant/ci-jobs";
74
74
  import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
75
  import { checkoutStep, concurrency, emitWorkflowYAML, jobTokenEnv, onTrigger, opConcurrencyGroup, runSteps } from "./ci-jobs.js";
76
76
  import { assertReviewSplit, reviewJobs, reviewNoteJobName } from "./review-jobs.js";
77
+ import { resolveAgentSpec } from "@intentius/chant/ci-agent";
78
+ import { activeProductWords } from "@intentius/chant/ci-product-words";
79
+ import { agentJobName, agentPushJobName, driftRunOutputs, driftRunStep, githubAgentJobs } from "./agent-jobs.js";
77
80
  // The setup, environment and permission checks are shared with the Op waves
78
81
  // generator and the forgejo lexicon (#3765); they stay importable from here.
79
82
  export { assertEnvironment, assertSetupSteps, mergePermissions };
@@ -253,12 +256,12 @@ function gateNoticeScript(version) {
253
256
  * no branch and merges nothing. The Forgejo dialect drops them, as it drops
254
257
  * every scope but `id-token`.
255
258
  */
256
- function gateNoticeJob(applyJobName, image) {
259
+ function gateNoticeJob(applyJobName, image, runner) {
257
260
  const output = (name) => "${{ needs." + applyJobName + ".outputs." + name + ' }}';
258
261
  return {
259
262
  needs: applyJobName,
260
263
  if: `needs.${applyJobName}.outputs.gated == 'true'`,
261
- "runs-on": GITHUB_RUNNER,
264
+ "runs-on": githubRunsOn(runner),
262
265
  container: image,
263
266
  permissions: { contents: "read", issues: "write", "pull-requests": "write" },
264
267
  steps: [
@@ -301,14 +304,48 @@ function assertTriggerSupportsMode(name, mode, trigger) {
301
304
  * assertSetupSteps}), or a `permissions` entry that is not additive ({@link
302
305
  * mergePermissions}).
303
306
  */
304
- export function buildGithubOpPipelineDocs(ops, options = {}) {
307
+ export function buildGithubOpPipelineDocs(ops, options = {}, forge = "github") {
305
308
  const image = options.image ?? CI_IMAGE;
306
309
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
307
310
  const beforeScript = options.beforeScript ?? [];
308
311
  const extraScript = options.extraScript ?? [];
309
312
  const files = [];
310
313
  const jobs = [];
314
+ const words = activeProductWords();
311
315
  for (const spec of ops) {
316
+ // #4616: the comment agent is a workflow of its own, on pull request comments; the spec's trigger is not read.
317
+ if (spec.agent?.on === "comment") {
318
+ const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
319
+ const jobName = ciJobName(spec.name);
320
+ const setup = spec.setup ?? [];
321
+ assertSetupSteps(spec.name, setup);
322
+ jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
323
+ const { agent, push } = githubAgentJobs({
324
+ forge,
325
+ jobName,
326
+ image,
327
+ settings,
328
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
329
+ setup: setup.map(setupStepDoc),
330
+ beforeScript,
331
+ ...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
332
+ ...(words.bin ? { bin: words.bin } : {}),
333
+ });
334
+ files.push({
335
+ name: `${spec.name}.yml`,
336
+ doc: {
337
+ name: spec.name,
338
+ on: { issue_comment: { types: ["created"] } },
339
+ ...nonEmpty("env", options.variables),
340
+ // One agent run at a time, so two asks never count the limit at once.
341
+ concurrency: concurrency(jobName),
342
+ permissions: { contents: "read" },
343
+ jobsDoc: {},
344
+ agentDoc: { [jobName]: agent, [agentPushJobName(jobName)]: push },
345
+ },
346
+ });
347
+ continue;
348
+ }
312
349
  const findingMode = spec.findingMode ?? "report";
313
350
  const trigger = resolveOpTrigger(spec);
314
351
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
@@ -321,6 +358,11 @@ export function buildGithubOpPipelineDocs(ops, options = {}) {
321
358
  const split = spec.reviewAgent === true;
322
359
  if (split)
323
360
  assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
361
+ // #4616: a drift agent follows the Op's job, which hands its output on and says whether it found drift.
362
+ const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
363
+ if (driftAgent && (split || trigger.kind === "push")) {
364
+ 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.`);
365
+ }
324
366
  const stepEnv = split ? {} : jobTokenEnv(findingMode !== "report");
325
367
  const setup = spec.setup ?? [];
326
368
  assertSetupSteps(spec.name, setup);
@@ -337,7 +379,9 @@ export function buildGithubOpPipelineDocs(ops, options = {}) {
337
379
  const steps = [checkoutStep(), ...setup.map(setupStepDoc), ...runSteps(beforeScript)];
338
380
  steps.push(gated
339
381
  ? gatedRunStep(RUN_STEP_ID, spec.name, invocation, stepEnv)
340
- : { run: invocation, env: stepEnv });
382
+ : driftAgent
383
+ ? driftRunStep(invocation, stepEnv, forge)
384
+ : { run: invocation, env: stepEnv });
341
385
  steps.push(...runSteps(extraScript));
342
386
  const review = split
343
387
  ? reviewJobs({
@@ -362,7 +406,7 @@ export function buildGithubOpPipelineDocs(ops, options = {}) {
362
406
  permissions: mergePermissions(spec.name, permissionsFor(findingMode, trigger), spec.permissions ?? {}, trigger),
363
407
  jobsDoc: {
364
408
  [jobName]: {
365
- "runs-on": GITHUB_RUNNER,
409
+ "runs-on": githubRunsOn(spec.runner),
366
410
  container: image,
367
411
  // Per-Op credentials (#2290): job-level `env:`, one level more
368
412
  // specific than `options.variables`'s workflow-level `env:` above,
@@ -381,16 +425,35 @@ export function buildGithubOpPipelineDocs(ops, options = {}) {
381
425
  `\${{ steps.${RUN_STEP_ID}.outputs.${name} }}`,
382
426
  ])),
383
427
  }
384
- : {}),
428
+ : driftAgent
429
+ ? { outputs: driftRunOutputs(forge) }
430
+ : {}),
385
431
  steps,
386
432
  },
387
433
  },
388
- ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image) } } : {}),
434
+ ...(gated ? { gatedNoticeDoc: { [`${jobName}-gate-notice`]: gateNoticeJob(jobName, image, spec.runner) } } : {}),
389
435
  };
436
+ if (driftAgent) {
437
+ const name = agentJobName(jobName, "drift");
438
+ const { agent, push } = githubAgentJobs({
439
+ forge,
440
+ jobName: name,
441
+ image,
442
+ settings: driftAgent,
443
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
444
+ setup: setup.map(setupStepDoc),
445
+ beforeScript,
446
+ ...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
447
+ ...(words.bin ? { bin: words.bin } : {}),
448
+ after: jobName,
449
+ });
450
+ doc.agentDoc = { [name]: agent, [agentPushJobName(name)]: push };
451
+ }
390
452
  // A split review (#4326): the Op's job is the review job, and the note job rides beside it.
391
453
  if (review) {
392
- doc.jobsDoc = { [jobName]: { ...review.review, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
393
- doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: review.note };
454
+ const runsOn = spec.runner !== undefined ? { "runs-on": githubRunsOn(spec.runner) } : {};
455
+ doc.jobsDoc = { [jobName]: { ...review.review, ...runsOn, ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) } };
456
+ doc.reviewNoteDoc = { [reviewNoteJobName(jobName)]: { ...review.note, ...runsOn } };
394
457
  }
395
458
  files.push({ name: `${spec.name}.yml`, doc });
396
459
  }
@@ -445,8 +508,8 @@ export function emitOpPipelineYAML(doc) {
445
508
  // The gated-apply notice job rides in `jobs:` beside the Op's own job; it
446
509
  // is carried separately on the doc so a dialect transforms it on its own.
447
510
  // A dialect's `header` note (#2257) is absent on github.
448
- const { gatedNoticeDoc, reviewNoteDoc, ...workflow } = doc;
449
- return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}) } });
511
+ const { gatedNoticeDoc, reviewNoteDoc, agentDoc, ...workflow } = doc;
512
+ return emitWorkflowYAML({ ...workflow, jobsDoc: { ...doc.jobsDoc, ...(gatedNoticeDoc ?? {}), ...(reviewNoteDoc ?? {}), ...(agentDoc ?? {}) } });
450
513
  }
451
514
  /**
452
515
  * Synthesize one `.github/workflows/*.yml` per scheduled Op. Thin wrapper
@@ -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,6 +52,16 @@
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
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
54
67
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAEH,OAAO,EAAsF,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACpJ,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAuEpI;AAoDD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAEH,OAAO,EAUL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,4DAA4D;AAC5D,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CA6EpI;AAkGD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -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,8 +52,19 @@
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
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
66
+ import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
67
+ import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
54
68
  import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
55
69
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
56
70
  import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs.js";
@@ -67,7 +81,7 @@ export function opWaveOutcomeArtifact(spec, jobName) {
67
81
  }
68
82
  /** The workflow document, before emission, and the jobs it holds. */
69
83
  export function buildGithubOpWavesDoc(spec, options) {
70
- const jobs = opWaveJobs(spec, options.specFile);
84
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
71
85
  const image = options.image ?? OP_WAVES_IMAGE;
72
86
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
73
87
  for (const wave of spec.waves) {
@@ -121,6 +135,11 @@ export function buildGithubOpWavesDoc(spec, options) {
121
135
  steps,
122
136
  };
123
137
  }
138
+ // #4617: once the last wave ran, one job tells the control repo how the waves ended.
139
+ if (spec.control) {
140
+ const [name, job] = controlJob(spec, options, jobs, image, permissions);
141
+ jobsDoc[name] = job;
142
+ }
124
143
  // #3761: the notify variables come from the secrets of the same name.
125
144
  const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
126
145
  const doc = {
@@ -138,12 +157,51 @@ export function buildGithubOpWavesDoc(spec, options) {
138
157
  ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
139
158
  };
140
159
  }
160
+ /**
161
+ * The control dispatch job (#4617): after the last wave, whatever it did,
162
+ * the waves' outcome read from every job's outcome file, then one dispatch
163
+ * carrying it. The pushed commit is the one every wave ran at.
164
+ */
165
+ function controlJob(spec, options, jobs, image, permissions) {
166
+ const control = spec.control;
167
+ const dispatch = controlDispatchJob("github", opWavesControlTarget(control), "${{ github.sha }}", {
168
+ ...(options.words ? { words: options.words } : {}),
169
+ ...(control.sentCheck ? { sentCheck: control.sentCheck } : {}),
170
+ ...(control.markSent ? { markSent: control.markSent } : {}),
171
+ outcome: true,
172
+ });
173
+ const marks = control.sentCheck !== undefined || control.markSent !== undefined;
174
+ const steps = [
175
+ checkoutStep(true),
176
+ { name: "Download the waves' outcomes", uses: DOWNLOAD_ARTIFACT, with: { path: opWaveOutcomeDir(spec) } },
177
+ ...(spec.waves[0]?.setup ?? []).map(setupStepDoc),
178
+ ...runSteps(options.beforeScript ?? []),
179
+ {
180
+ name: "Add up the waves' outcomes",
181
+ run: `outcome="$(${opWavesOutcomeCommand(options.specFile, options.bin).join(" ")})" || exit 1\necho "${CONTROL_OUTCOME_VAR}=$outcome" >> "$GITHUB_ENV"`,
182
+ },
183
+ { 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 },
184
+ ];
185
+ return [
186
+ dispatch.name,
187
+ {
188
+ "runs-on": githubRunsOn(spec.runner),
189
+ needs: opWavesLastJobs(jobs),
190
+ // The waves ran, whatever they did: a failed run is an outcome the control repo is told about.
191
+ if: "!cancelled()",
192
+ container: image,
193
+ // markSent sets a commit status; the rest reads the repository and the run's artifacts.
194
+ permissions: marks ? { ...permissions, statuses: "write" } : permissions,
195
+ steps,
196
+ },
197
+ ];
198
+ }
141
199
  /** The scheduled resume workflow (#3683). */
142
200
  function buildResumeDoc(spec, options) {
143
201
  const steps = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
144
202
  steps.push({
145
203
  name: `Resume the waves of ${spec.name} whose approval arrived`,
146
- run: opWavesResumeCommand(spec).join(" "),
204
+ run: opWavesResumeCommand(spec, options.bin).join(" "),
147
205
  env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
148
206
  });
149
207
  return {
@@ -158,7 +216,7 @@ function buildResumeDoc(spec, options) {
158
216
  /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
159
217
  function buildPlansDoc(spec, options) {
160
218
  const steps = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
161
- steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
219
+ steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
162
220
  return {
163
221
  name: `${spec.name}-plans`,
164
222
  on: { pull_request: { branches: pushBranches(spec.branches) } },