@intentius/chant-lexicon-gitlab 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 (34) hide show
  1. package/dist/components/agent-jobs.d.ts +68 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +135 -0
  4. package/dist/components/control-pipeline.d.ts +24 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +28 -0
  7. package/dist/components/generate-op-pipeline.d.ts +9 -0
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +107 -22
  10. package/dist/components/generate-op-waves-pipeline.d.ts +12 -0
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +59 -5
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +112 -10
  15. package/dist/components/review-jobs.d.ts +72 -0
  16. package/dist/components/review-jobs.d.ts.map +1 -0
  17. package/dist/components/review-jobs.js +124 -0
  18. package/dist/integrity.json +2 -2
  19. package/dist/manifest.json +1 -1
  20. package/package.json +3 -3
  21. package/src/components/agent-jobs.ts +176 -0
  22. package/src/components/control-pipeline.ts +38 -0
  23. package/src/components/generate-op-pipeline.agent.test.ts +99 -0
  24. package/src/components/generate-op-pipeline.review.test.ts +72 -7
  25. package/src/components/generate-op-pipeline.ts +117 -23
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
  27. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  28. package/src/components/generate-op-waves-pipeline.ts +68 -5
  29. package/src/components/generate-pipeline.ts +117 -9
  30. package/src/components/generate-pr-pipeline.comment-locks.test.ts +43 -0
  31. package/src/components/generate-pr-pipeline.comment-preview.test.ts +61 -0
  32. package/src/components/generate-pr-pipeline.control.test.ts +81 -0
  33. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +46 -0
  34. package/src/components/review-jobs.ts +163 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The agent jobs on GitLab (#4616): `ScheduledOpSpec.agent`. What each job
3
+ * does is @intentius/chant/ci-agent's; this file lays them out in GitLab's
4
+ * terms.
5
+ *
6
+ * GitLab starts no pipeline on a note, and every job in a pipeline gets the
7
+ * same protected variables. So:
8
+ *
9
+ * - a comment agent runs in a pipeline of the default branch that the API, a
10
+ * trigger or a person starts with `CHANT_AGENT_PR` (the merge request's
11
+ * iid) and `CHANT_AGENT_ASK` (the ask). Whoever starts that pipeline is
12
+ * who asked. A drift agent follows the drift Op's job in its scheduled
13
+ * pipeline and runs when that job's run exited with chant's `drift` code.
14
+ * - the agent job unsets every forge token variable and drops the job token
15
+ * from the remote before the model runs, and keeps the agent directory as
16
+ * an artifact. It holds the model's key.
17
+ * - the push job runs in the same pipeline under environment
18
+ * `<agent>-push`, takes the artifact, and pushes with `tokenEnv`
19
+ * (`CHANT_FORGE_TOKEN` by default). Scope that variable to the
20
+ * `<agent>-push` environment, protected and masked, so the agent job never
21
+ * holds it.
22
+ *
23
+ * A workspace member's drift Op runs in the member's directory, so its
24
+ * artifact holds `<member>/.chant/agent` (#4619). Both agent jobs run at
25
+ * the repository root and take `dir`, that path, as their agent directory.
26
+ */
27
+ import { type CiRunner } from "@intentius/chant/ci-jobs";
28
+ import { type AgentSettings } from "@intentius/chant/ci-agent";
29
+ /** The comment agent's rule: a pipeline of the default branch started with the merge request and the ask. */
30
+ export declare const GITLAB_AGENT_RULE = "$CI_PIPELINE_SOURCE =~ /^(web|api|trigger|chat)$/ && $CHANT_AGENT_PR =~ /^[0-9]+$/ && $CHANT_AGENT_ASK && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH";
31
+ export declare function gitlabAgentPushJobName(jobName: string): string;
32
+ /** The environment the push job runs under, which the push token is scoped to. */
33
+ export declare function gitlabAgentEnvironment(jobName: string): string;
34
+ /**
35
+ * The drift Op's script lines (#4616): the run with its output kept for the
36
+ * agent, and `CHANT_AGENT_DRIFT=1` in a dotenv when it exits with chant's
37
+ * `drift` code. The job still exits with the run's code.
38
+ */
39
+ export declare function gitlabDriftRunLines(invocation: string): string[];
40
+ /** The drift Op job's artifacts: the output for the agent and the dotenv. */
41
+ export declare function gitlabDriftArtifacts(dir: string): Record<string, unknown>;
42
+ export interface GitlabAgentJobsInput {
43
+ jobName: string;
44
+ stage: string;
45
+ image: string;
46
+ settings: AgentSettings;
47
+ runner?: CiRunner;
48
+ /** Setup and before-script lines, run before chant in both jobs. */
49
+ script: readonly string[];
50
+ /** The variable holding the push token. Default {@link AGENT_GITLAB_TOKEN_ENV}. */
51
+ tokenEnv?: string;
52
+ bin?: string;
53
+ /** For a drift agent: the Op's job and its rules. */
54
+ after?: {
55
+ job: string;
56
+ rules: unknown[];
57
+ };
58
+ /** The agent directory from the repository root: where the drift Op's artifact put the drift. Default {@link AGENT_DIR}. */
59
+ dir?: string;
60
+ }
61
+ /** The agent job and the push job. */
62
+ export declare function gitlabAgentJobs(i: GitlabAgentJobsInput): {
63
+ agent: Record<string, unknown>;
64
+ push: Record<string, unknown>;
65
+ };
66
+ /** The header lines a file with agent jobs carries: how to start a comment agent and where the token goes. */
67
+ export declare function gitlabAgentHeader(jobName: string, s: AgentSettings, tokenVar?: string): string[];
68
+ //# 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;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAc,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAYL,KAAK,aAAa,EACnB,MAAM,2BAA2B,CAAC;AAOnC,6GAA6G;AAC7G,eAAO,MAAM,iBAAiB,sJAAsK,CAAC;AAErM,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE9D;AAOD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAOhE;AAED,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,aAAa,CAAC;IACxB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,oEAAoE;IACpE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,KAAK,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,EAAE,CAAA;KAAE,CAAC;IAC1C,4HAA4H;IAC5H,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;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,CAwD1H;AAED,8GAA8G;AAC9G,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,aAAa,EAAE,QAAQ,SAAyB,GAAG,MAAM,EAAE,CAShH"}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The agent jobs on GitLab (#4616): `ScheduledOpSpec.agent`. What each job
3
+ * does is @intentius/chant/ci-agent's; this file lays them out in GitLab's
4
+ * terms.
5
+ *
6
+ * GitLab starts no pipeline on a note, and every job in a pipeline gets the
7
+ * same protected variables. So:
8
+ *
9
+ * - a comment agent runs in a pipeline of the default branch that the API, a
10
+ * trigger or a person starts with `CHANT_AGENT_PR` (the merge request's
11
+ * iid) and `CHANT_AGENT_ASK` (the ask). Whoever starts that pipeline is
12
+ * who asked. A drift agent follows the drift Op's job in its scheduled
13
+ * pipeline and runs when that job's run exited with chant's `drift` code.
14
+ * - the agent job unsets every forge token variable and drops the job token
15
+ * from the remote before the model runs, and keeps the agent directory as
16
+ * an artifact. It holds the model's key.
17
+ * - the push job runs in the same pipeline under environment
18
+ * `<agent>-push`, takes the artifact, and pushes with `tokenEnv`
19
+ * (`CHANT_FORGE_TOKEN` by default). Scope that variable to the
20
+ * `<agent>-push` environment, protected and masked, so the agent job never
21
+ * holds it.
22
+ *
23
+ * A workspace member's drift Op runs in the member's directory, so its
24
+ * artifact holds `<member>/.chant/agent` (#4619). Both agent jobs run at
25
+ * the repository root and take `dir`, that path, as their agent directory.
26
+ */
27
+ import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
28
+ import { gitlabTags } from "@intentius/chant/ci-jobs";
29
+ import { AGENT_DECISION_FILE, AGENT_DIR, AGENT_DRIFT_FILE, AGENT_GITLAB_TOKEN_ENV, AGENT_HEAD_REF, agentCommandLines, agentGitAuthLines, agentHeadFetchLine, agentSettingsEnv, GITLAB_AGENT_ASK_VAR, GITLAB_AGENT_PR_VAR, } from "@intentius/chant/ci-agent";
30
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
31
+ /** The dotenv the drift Op's job leaves, which says whether it found drift. */
32
+ const DRIFT_DOTENV = `${AGENT_DIR}/drift.env`;
33
+ const AGENT_ARTIFACT_EXPIRY = "7 days";
34
+ /** The comment agent's rule: a pipeline of the default branch started with the merge request and the ask. */
35
+ export const GITLAB_AGENT_RULE = `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger|chat)$/ && $${GITLAB_AGENT_PR_VAR} =~ /^[0-9]+$/ && $${GITLAB_AGENT_ASK_VAR} && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
36
+ export function gitlabAgentPushJobName(jobName) {
37
+ return `${jobName}-push`;
38
+ }
39
+ /** The environment the push job runs under, which the push token is scoped to. */
40
+ export function gitlabAgentEnvironment(jobName) {
41
+ return `${jobName}-push`;
42
+ }
43
+ /** The remote without the job token GitLab put in its URL. */
44
+ const PLAIN_REMOTE = 'git remote set-url origin "$CI_SERVER_URL/$CI_PROJECT_PATH.git"';
45
+ /** The ledger branch, for the run count and the record. */
46
+ const LEDGER_FETCH = "git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true";
47
+ /**
48
+ * The drift Op's script lines (#4616): the run with its output kept for the
49
+ * agent, and `CHANT_AGENT_DRIFT=1` in a dotenv when it exits with chant's
50
+ * `drift` code. The job still exits with the run's code.
51
+ */
52
+ export function gitlabDriftRunLines(invocation) {
53
+ return [
54
+ `mkdir -p ${AGENT_DIR}`,
55
+ `set +e; { ${invocation}; echo "$?" > ${AGENT_DIR}/code; } 2>&1 | tee ${AGENT_DIR}/${AGENT_DRIFT_FILE}; code=$(cat ${AGENT_DIR}/code); set -e`,
56
+ `if [ "$code" -eq ${CHANT_EXIT.drift} ]; then echo "CHANT_AGENT_DRIFT=1" > ${DRIFT_DOTENV}; else echo "CHANT_AGENT_DRIFT=0" > ${DRIFT_DOTENV}; fi`,
57
+ 'exit "$code"',
58
+ ];
59
+ }
60
+ /** The drift Op job's artifacts: the output for the agent and the dotenv. */
61
+ export function gitlabDriftArtifacts(dir) {
62
+ return { when: "always", paths: [dir], reports: { dotenv: `${dir}/drift.env` }, expire_in: AGENT_ARTIFACT_EXPIRY };
63
+ }
64
+ /** The agent job and the push job. */
65
+ export function gitlabAgentJobs(i) {
66
+ const s = i.settings;
67
+ const dir = i.dir ?? AGENT_DIR;
68
+ const lines = agentCommandLines("gitlab", i.bin, dir);
69
+ const comment = s.on === "comment";
70
+ const tags = gitlabTags(i.runner);
71
+ const variables = { ...agentSettingsEnv(s), GIT_DEPTH: "0" };
72
+ const rules = comment ? [{ if: GITLAB_AGENT_RULE }] : i.after.rules.map((r) => ({ ...r, when: "always" }));
73
+ const head = comment ? [agentHeadFetchLine("gitlab", GITLAB_AGENT_PR_VAR)] : [];
74
+ // A drift agent's job runs whatever the drift check found, and stops here when it found none.
75
+ const driftGate = comment ? [] : [`if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the agent does not run"; exit 0; fi`];
76
+ const agent = {
77
+ stage: i.stage,
78
+ image: i.image,
79
+ ...(tags ? { tags } : {}),
80
+ resource_group: i.jobName,
81
+ timeout: `${s.timeout + 15} minutes`,
82
+ variables,
83
+ rules,
84
+ ...(comment ? {} : { needs: [{ job: i.after.job, artifacts: true }] }),
85
+ script: [
86
+ ...driftGate,
87
+ ...head,
88
+ LEDGER_FETCH,
89
+ PLAIN_REMOTE,
90
+ ...i.script,
91
+ // No forge token reaches the model: the decision needs none on GitLab, and the push job checks the merge request.
92
+ GITLAB_DROP_FORGE_TOKENS,
93
+ lines.prepare,
94
+ lines.run,
95
+ ],
96
+ artifacts: { when: "always", paths: [dir], expire_in: AGENT_ARTIFACT_EXPIRY },
97
+ };
98
+ const tokenVar = i.tokenEnv ?? AGENT_GITLAB_TOKEN_ENV;
99
+ const push = {
100
+ stage: i.stage,
101
+ image: i.image,
102
+ ...(tags ? { tags } : {}),
103
+ resource_group: gitlabAgentPushJobName(i.jobName),
104
+ environment: { name: gitlabAgentEnvironment(i.jobName), action: "access" },
105
+ variables,
106
+ rules,
107
+ needs: [{ job: i.jobName, artifacts: true }],
108
+ script: [
109
+ // The agent job leaves a decision whenever it ran; with none, the drift check found nothing.
110
+ ...(comment ? [] : [`if [ ! -f ${dir}/${AGENT_DECISION_FILE} ]; then echo "the agent did not run"; exit 0; fi`]),
111
+ ...head,
112
+ LEDGER_FETCH,
113
+ PLAIN_REMOTE,
114
+ ...i.script,
115
+ `export CHANT_GITLAB_TOKEN="$${tokenVar}"`,
116
+ ...agentGitAuthLines(tokenVar, "oauth2"),
117
+ lines.land,
118
+ ],
119
+ };
120
+ return { agent, push };
121
+ }
122
+ /** The header lines a file with agent jobs carries: how to start a comment agent and where the token goes. */
123
+ export function gitlabAgentHeader(jobName, s, tokenVar = AGENT_GITLAB_TOKEN_ENV) {
124
+ return [
125
+ comment(s)
126
+ ? `# ${jobName}: answers ${s.comment} from a pipeline of the default branch started with ${GITLAB_AGENT_PR_VAR}=<iid> and ${GITLAB_AGENT_ASK_VAR}=<ask>`
127
+ : `# ${jobName}: after the drift check exits ${CHANT_EXIT.drift}, opens or updates a merge request from ${s.branch}`,
128
+ `# ${gitlabAgentPushJobName(jobName)} pushes with ${tokenVar}: scope it to environment ${gitlabAgentEnvironment(jobName)}, protected and masked; the model's key is ${s.keyEnv}`,
129
+ `# the fetched head is ${AGENT_HEAD_REF}; runs are limited to ${s.maxRuns ?? "no fixed number of"} a day on the ledger` +
130
+ (s.maxCostUsd !== undefined ? `, and to $${s.maxCostUsd} a day of recorded cost` : ""),
131
+ ];
132
+ }
133
+ function comment(s) {
134
+ return s.on === "comment";
135
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The control repo's own pipeline (#4568) on GitLab: a member's pipeline
3
+ * trigger starts it with `CHANT_CONTROL_MEMBER` and `CHANT_CONTROL_OP` set,
4
+ * and its one job runs the member's dependents with `chant workspace run
5
+ * --dependents-of`. The pieces are core's (`controlPipelineSpec` in
6
+ * `@intentius/chant/control/dispatch`); the file is one the control repo's
7
+ * `.gitlab-ci.yml` includes. The member's side, the `control-dispatch` job,
8
+ * comes from the pull request loop's `control` option
9
+ * (./generate-pipeline.ts).
10
+ */
11
+ import type { CiProductWords, CiProductWordsInput } from "@intentius/chant/ci-product-words";
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
+ /** The control repo's pipeline on GitLab: `.gitlab/<product>-dependents.yml`. */
19
+ export declare function generateControlPipeline(options?: {
20
+ words?: CiProductWordsInput | CiProductWords;
21
+ image?: string;
22
+ command?: string;
23
+ }): ControlPipelineFile;
24
+ //# 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;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAG7F,gGAAgG;AAChG,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,CAAC,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,mBAAmB,GAAG,cAAc,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,mBAAmB,CAa7J"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The control repo's own pipeline (#4568) on GitLab: a member's pipeline
3
+ * trigger starts it with `CHANT_CONTROL_MEMBER` and `CHANT_CONTROL_OP` set,
4
+ * and its one job runs the member's dependents with `chant workspace run
5
+ * --dependents-of`. The pieces are core's (`controlPipelineSpec` in
6
+ * `@intentius/chant/control/dispatch`); the file is one the control repo's
7
+ * `.gitlab-ci.yml` includes. The member's side, the `control-dispatch` job,
8
+ * comes from the pull request loop's `control` option
9
+ * (./generate-pipeline.ts).
10
+ */
11
+ import { CI_IMAGE, GITLAB_FULL_HISTORY } from "@intentius/chant/ci-jobs";
12
+ import { controlPipelineSpec } from "@intentius/chant/control/dispatch";
13
+ import { emitGitlabFile } from "./ci-jobs.js";
14
+ /** The control repo's pipeline on GitLab: `.gitlab/<product>-dependents.yml`. */
15
+ export function generateControlPipeline(options = {}) {
16
+ const spec = controlPipelineSpec("gitlab", options.words ?? {}, options.command ? { command: options.command } : {});
17
+ const job = {
18
+ stage: "deploy",
19
+ image: options.image ?? CI_IMAGE,
20
+ // The full history, so the job reads and appends to chant/lifecycle (#4617).
21
+ variables: { ...GITLAB_FULL_HISTORY },
22
+ rules: [{ if: spec.rule }],
23
+ // One member at a time: two applies upstream should not run the same dependents side by side.
24
+ resource_group: spec.group,
25
+ script: [`bash <<'DEPENDENTS' || exit $?\n${spec.script}\nDEPENDENTS`],
26
+ };
27
+ return { path: spec.path, content: emitGitlabFile([["dependents", job]], spec.header), notes: spec.notes };
28
+ }
@@ -103,6 +103,15 @@
103
103
  * internal and always wins at the job over a same-named spec entry, so a
104
104
  * caller cannot point the gate block somewhere the `artifacts:` declaration
105
105
  * beside it does not also point.
106
+ *
107
+ * `reviewAgent` (#4544) splits the review in two, as on GitHub and Forgejo,
108
+ * with GitLab's own shape (`./review-jobs.ts`). The review job runs in the
109
+ * merge request pipeline, unsets every forge token variable and keeps the
110
+ * review as its artifact. Its note job runs in a pipeline of the default
111
+ * branch that a schedule starts with `CHANT_REVIEW_NOTES` set to `1`, checks
112
+ * nothing out and posts each open merge request's review with the protected
113
+ * `CHANT_FORGE_TOKEN`. The header says to create that schedule and protect
114
+ * the token.
106
115
  */
107
116
  import type { ComponentPipelineOptions as GenerateGitlabOpOptions, OpPipelineResult as GenerateGitlabOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
108
117
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAeH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA8IhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAuGxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAiBH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAQlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAwMxB"}
@@ -103,10 +103,23 @@
103
103
  * internal and always wins at the job over a same-named spec entry, so a
104
104
  * caller cannot point the gate block somewhere the `artifacts:` declaration
105
105
  * beside it does not also point.
106
+ *
107
+ * `reviewAgent` (#4544) splits the review in two, as on GitHub and Forgejo,
108
+ * with GitLab's own shape (`./review-jobs.ts`). The review job runs in the
109
+ * merge request pipeline, unsets every forge token variable and keeps the
110
+ * review as its artifact. Its note job runs in a pipeline of the default
111
+ * branch that a schedule starts with `CHANT_REVIEW_NOTES` set to `1`, checks
112
+ * nothing out and posts each open merge request's review with the protected
113
+ * `CHANT_FORGE_TOKEN`. The header says to create that schedule and protect
114
+ * the token.
106
115
  */
107
- import { assertGitlabEnvironment, CI_IMAGE, ciJobName, environmentDoc, fillCommand, gitlabIdTokens, gitlabRules, gitlabSetupScript, nonEmpty, pushBranches, } from "@intentius/chant/ci-jobs";
116
+ import { assertGitlabEnvironment, CI_IMAGE, ciJobName, environmentDoc, fillCommand, gitlabIdTokens, gitlabRules, gitlabSetupScript, gitlabTags, assertRunnerLabels, nonEmpty, pushBranches, } from "@intentius/chant/ci-jobs";
108
117
  import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
109
118
  import { emitGitlabFile } from "./ci-jobs.js";
119
+ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DIR, reviewNoteJobName } from "./review-jobs.js";
120
+ import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
121
+ import { activeProductWords } from "@intentius/chant/ci-product-words";
122
+ import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs.js";
110
123
  /**
111
124
  * Default stage every generated job runs in, and the default base of the
112
125
  * generated file's name (#2293). Overridable via `options.opsStage` (and the
@@ -164,22 +177,6 @@ function assertTriggerSupportsMode(name, mode, trigger) {
164
177
  `no merge request to post on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue" ` +
165
178
  `or "merge-request".`);
166
179
  }
167
- /**
168
- * Refuse `reviewAgent` (#4326). The split posts the review from a second job
169
- * that holds the forge write token, and a GitLab merge request pipeline hands
170
- * every job the project's unprotected CI/CD variables, the review job's
171
- * included. A token protected enough to stay out of it is not available to a
172
- * job of that pipeline either, so the post has to start from a pipeline of
173
- * the default branch, which this generator does not build. The refusal names
174
- * the Op and the way out rather than emitting a review job that holds the token.
175
- */
176
- function assertNoReviewSplit(spec) {
177
- if (!spec.reviewAgent)
178
- return;
179
- throw new Error(`Scheduled Op "${spec.name}" sets reviewAgent, which keeps the model's job free of the forge write token and posts the note from a second job. ` +
180
- `A GitLab merge request pipeline hands every job the project's unprotected variables, so the gitlab generator does not build that split. ` +
181
- `Drop reviewAgent and run the review with findingMode "comment" (the model command still runs with a cleared environment), or generate this Op for GitHub or Forgejo.`);
182
- }
183
180
  /**
184
181
  * The follow-up line an Op with an `environment` adds under its own header
185
182
  * line (#2257). The `environment:` key on the job binds it to the
@@ -244,19 +241,60 @@ export function generateGitlabOpPipeline(ops, options = {}) {
244
241
  const doc = { stages: [stage], ...nonEmpty("variables", options.variables) };
245
242
  const opLines = [];
246
243
  let anyCron = false;
244
+ let anyReview = false;
245
+ // The note job of each split review (#4544), emitted after the Op jobs.
246
+ const noteJobs = [];
247
+ const words = activeProductWords();
247
248
  for (const spec of ops) {
248
249
  const subject = `Scheduled Op "${spec.name}"`;
250
+ // #4616: GitLab picks a runner by tags.
251
+ assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
252
+ const tags = gitlabTags(spec.runner);
249
253
  const setupScript = gitlabSetupScript(subject, spec.setup);
254
+ // #4616: the comment agent is its own pair of jobs, in a pipeline of the default branch; the spec's trigger is not read.
255
+ if (spec.agent?.on === "comment") {
256
+ const settings = resolveAgentSpec(spec.name, spec.agent, words.product);
257
+ const jobName = jobPrefix + ciJobName(spec.name);
258
+ jobs.push({ jobName, op: spec.name, trigger: { kind: "pull_request" }, findingMode: "report" });
259
+ opLines.push(...gitlabAgentHeader(jobName, settings, spec.agent.tokenEnv));
260
+ const { agent, push } = gitlabAgentJobs({
261
+ jobName,
262
+ stage,
263
+ image,
264
+ settings,
265
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
266
+ script: [...setupScript, ...beforeScript],
267
+ ...(spec.agent.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
268
+ ...(words.bin ? { bin: words.bin } : {}),
269
+ });
270
+ doc[jobName] = agent;
271
+ const pushName = gitlabAgentPushJobName(jobName);
272
+ doc[pushName] = push;
273
+ noteJobs.push(pushName);
274
+ continue;
275
+ }
250
276
  const idTokens = gitlabIdTokens(spec.name, spec.permissions);
251
277
  const findingMode = spec.findingMode ?? "report";
252
278
  const trigger = resolveOpTrigger(spec);
253
279
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
254
- assertNoReviewSplit(spec);
280
+ // A split review (#4544): the merge request pipeline runs the model with no forge token, and a pipeline of the default branch posts.
281
+ const split = spec.reviewAgent === true;
282
+ if (split)
283
+ assertReviewSplit(spec.name, trigger, findingMode, spec.permissions);
255
284
  if (trigger.kind === "cron")
256
285
  anyCron = true;
286
+ const driftAgent = spec.agent?.on === "drift" ? resolveAgentSpec(spec.name, spec.agent, words.product) : undefined;
287
+ if (driftAgent && (split || trigger.kind === "push")) {
288
+ throw new Error(`${subject} sets a drift agent, which follows a drift check's run; give it a cron or pull_request trigger and no reviewAgent.`);
289
+ }
257
290
  const jobName = jobPrefix + ciJobName(spec.name);
258
291
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
259
292
  opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
293
+ if (split) {
294
+ anyReview = true;
295
+ opLines.push(`# reviewAgent: the review job holds no forge token and keeps its review as an artifact; ${reviewNoteJobName(jobName)} posts it` +
296
+ ` from a pipeline of the default branch started with ${GITLAB_REVIEW_NOTES_VAR}="1"`);
297
+ }
260
298
  if (spec.environment) {
261
299
  assertGitlabEnvironment(spec.name, spec.environment);
262
300
  opLines.push(environmentLine(spec.environment));
@@ -277,25 +315,69 @@ export function generateGitlabOpPipeline(ops, options = {}) {
277
315
  ...(spec.variables ?? {}),
278
316
  ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
279
317
  };
318
+ const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
319
+ if (split) {
320
+ const { review, note } = gitlabReviewJobs({
321
+ name: spec.name,
322
+ jobName,
323
+ stage,
324
+ image,
325
+ rules: rulesFor,
326
+ script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
327
+ ...(spec.variables ? { variables: spec.variables } : {}),
328
+ ...(idTokens ? { idTokens } : {}),
329
+ artifactDir: member ? memberRepoPath(member, REVIEW_DIR) : REVIEW_DIR,
330
+ ...(trigger.kind === "pull_request" && trigger.branches?.length ? { branches: trigger.branches } : {}),
331
+ });
332
+ doc[jobName] = { ...review, ...(tags ? { tags } : {}), ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}) };
333
+ const noteName = reviewNoteJobName(jobName);
334
+ doc[noteName] = { ...note, ...(tags ? { tags } : {}) };
335
+ noteJobs.push(noteName);
336
+ continue;
337
+ }
338
+ const runLines = driftAgent ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
280
339
  doc[jobName] = {
281
340
  stage,
282
341
  image,
342
+ ...(tags ? { tags } : {}),
283
343
  // GitLab's stand-in for github's per-Op concurrency group: queue the
284
344
  // next run rather than cancel the current one.
285
345
  resource_group: jobName,
286
346
  ...(idTokens ? { id_tokens: idTokens } : {}),
287
347
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
288
348
  ...nonEmpty("variables", jobVariables),
289
- rules: changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules,
290
- script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
349
+ rules: rulesFor,
350
+ script: [...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
291
351
  // `when: always` because the run this publishes for is the green one: a
292
352
  // gated apply succeeds, and the block is the only thing that says a
293
353
  // human still has to act. A run that walked through its gate writes no
294
354
  // block, and GitLab reports the empty upload as a warning, not a failure.
295
355
  ...(gated
296
356
  ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
297
- : {}),
357
+ : driftAgent
358
+ ? { artifacts: gitlabDriftArtifacts(member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR) }
359
+ : {}),
298
360
  };
361
+ if (driftAgent) {
362
+ const name = `${jobName}-agent`;
363
+ opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
364
+ const { agent, push } = gitlabAgentJobs({
365
+ jobName: name,
366
+ stage,
367
+ image,
368
+ settings: driftAgent,
369
+ ...(spec.runner !== undefined ? { runner: spec.runner } : {}),
370
+ script: [...setupScript, ...beforeScript],
371
+ ...(spec.agent?.tokenEnv ? { tokenEnv: spec.agent.tokenEnv } : {}),
372
+ ...(words.bin ? { bin: words.bin } : {}),
373
+ after: { job: jobName, rules: rulesFor },
374
+ // #4619: a member's drift Op left the drift under the member's directory.
375
+ ...(member ? { dir: memberRepoPath(member, AGENT_DIR) } : {}),
376
+ });
377
+ doc[name] = agent;
378
+ doc[gitlabAgentPushJobName(name)] = push;
379
+ noteJobs.push(name, gitlabAgentPushJobName(name));
380
+ }
299
381
  }
300
382
  const headerLines = [
301
383
  `# chant Ops — one job per Op under stage "${stage}", each`,
@@ -305,7 +387,10 @@ export function generateGitlabOpPipeline(ops, options = {}) {
305
387
  if (anyCron) {
306
388
  headerLines.push("#", "# GitLab has no in-file cron. Create one Pipeline Schedule per cron Op", "# below (Settings > CI/CD > Schedules): set its cron to the value noted", `# here and its ${SELECTOR_VAR} CI/CD variable to the Op's name, so only`, "# that job runs on that schedule.");
307
389
  }
390
+ if (anyReview) {
391
+ headerLines.push("#", "# A reviewAgent Op's note job posts from a pipeline of the default branch,", `# never from the merge request's own. Create a Pipeline Schedule on the`, `# default branch with ${GITLAB_REVIEW_NOTES_VAR}="1", and make CHANT_FORGE_TOKEN`, "# (a token with the api scope) a protected, masked CI/CD variable so no", "# merge request pipeline gets it.");
392
+ }
308
393
  headerLines.push("#", ...opLines);
309
- const yaml = emitGitlabFile([["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables]] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]])], headerLines);
394
+ const yaml = emitGitlabFile([["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables]] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]]), ...noteJobs.map((name) => [name, doc[name]])], headerLines);
310
395
  return { files: [{ name: fileName, yaml }], jobs };
311
396
  }
@@ -37,6 +37,18 @@
37
37
  * entry per cloud, and its script starts by trading them for those
38
38
  * identities and unsetting the token variables, before any line of the
39
39
  * checkout runs.
40
+ *
41
+ * With `control` on the spec (#4617), a `control-dispatch` job in a
42
+ * `control` stage needs the last wave's jobs, whose artifacts hold every
43
+ * job's outcome file. It adds them up with `chant run wave --waves-outcome`
44
+ * (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
45
+ * control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
46
+ * whose runs failed (exit 1) still lets it run; a wave that waits at its
47
+ * gate stops it with the waves, and it runs once the retried job carries
48
+ * on. A CI/CD variable reaches every job, so every other job starts by
49
+ * unsetting the control repo's token variables and the product's two
50
+ * secret names; the dispatch step reads the token from the product's
51
+ * variable alone and hands it to curl on its stdin (#4568).
40
52
  */
41
53
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
42
54
  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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAkBH,OAAO,EAAqE,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACnI,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAI9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CA+FvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAkBH,OAAO,EASL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAI9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAuIvH"}
@@ -37,14 +37,27 @@
37
37
  * entry per cloud, and its script starts by trading them for those
38
38
  * identities and unsetting the token variables, before any line of the
39
39
  * checkout runs.
40
+ *
41
+ * With `control` on the spec (#4617), a `control-dispatch` job in a
42
+ * `control` stage needs the last wave's jobs, whose artifacts hold every
43
+ * job's outcome file. It adds them up with `chant run wave --waves-outcome`
44
+ * (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
45
+ * control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
46
+ * whose runs failed (exit 1) still lets it run; a wave that waits at its
47
+ * gate stops it with the waves, and it runs once the retried job carries
48
+ * on. A CI/CD variable reaches every job, so every other job starts by
49
+ * unsetting the control repo's token variables and the product's two
50
+ * secret names; the dispatch step reads the token from the product's
51
+ * variable alone and hands it to curl on its stdin (#4568).
40
52
  */
41
53
  import { assertGitlabBranch, assertGitlabEnvironment, assertRunnerLabels, CI_IMAGE, environmentDoc, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, GITLAB_SCHEDULE_SOURCE, gitlabIdTokensDoc, gitlabPushRule, gitlabSetupScript, gitlabTags, nonEmpty, pushBranches, } from "@intentius/chant/ci-jobs";
42
- import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
54
+ import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
55
+ import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
43
56
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
44
57
  import { emitGitlabFile } from "./ci-jobs.js";
45
58
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
46
59
  export function generateGitlabOpWavesPipeline(spec, options) {
47
- const jobs = opWaveJobs(spec, options.specFile);
60
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
48
61
  const image = options.image ?? CI_IMAGE;
49
62
  const subject = `Op waves "${spec.name}"`;
50
63
  const rules = pushBranches(spec.branches).map((branch) => {
@@ -71,6 +84,11 @@ export function generateGitlabOpWavesPipeline(spec, options) {
71
84
  const specTags = gitlabTags(spec.runner);
72
85
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
73
86
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
87
+ // #4617: the control repo's token variables, which every job but the dispatch drops first.
88
+ const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
89
+ const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
90
+ if (control)
91
+ stages.push("control");
74
92
  if (spec.resume)
75
93
  stages.push("resume");
76
94
  const entries = [["stages", stages]];
@@ -86,7 +104,7 @@ export function generateGitlabOpWavesPipeline(spec, options) {
86
104
  ...(specTags ? { tags: specTags } : {}),
87
105
  variables: { ...GITLAB_FULL_HISTORY },
88
106
  rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
89
- script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
107
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
90
108
  },
91
109
  ]);
92
110
  }
@@ -112,13 +130,48 @@ export function generateGitlabOpWavesPipeline(spec, options) {
112
130
  variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
113
131
  rules,
114
132
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
115
- script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
133
+ script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
116
134
  // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
117
135
  allow_failure: { exit_codes: [1] },
118
136
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
119
137
  },
120
138
  ]);
121
139
  }
140
+ if (control) {
141
+ const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
142
+ ...(options.words ? { words: options.words } : {}),
143
+ ...(spec.control.sentCheck ? { sentCheck: spec.control.sentCheck } : {}),
144
+ ...(spec.control.markSent ? { markSent: spec.control.markSent } : {}),
145
+ outcome: true,
146
+ });
147
+ entries.push([
148
+ dispatch.name,
149
+ {
150
+ stage: "control",
151
+ image,
152
+ ...(specTags ? { tags: specTags } : {}),
153
+ rules,
154
+ // The last wave's jobs hand on every job's outcome file through their artifacts.
155
+ needs: opWavesLastJobs(jobs),
156
+ // The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
157
+ script: [
158
+ [
159
+ 'outcome="$(',
160
+ ...dropControl,
161
+ "{ :",
162
+ ...setupOf[0],
163
+ ...(options.beforeScript ?? []),
164
+ "} 1>&2",
165
+ opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
166
+ ')" || exit 1',
167
+ ].join("\n"),
168
+ `export ${CONTROL_OUTCOME_VAR}="$outcome"`,
169
+ ...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
170
+ `bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
171
+ ],
172
+ },
173
+ ]);
174
+ }
122
175
  if (spec.resume) {
123
176
  entries.push([
124
177
  `${spec.name}-resume`,
@@ -127,7 +180,7 @@ export function generateGitlabOpWavesPipeline(spec, options) {
127
180
  image,
128
181
  ...(specTags ? { tags: specTags } : {}),
129
182
  rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
130
- script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
183
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
131
184
  },
132
185
  ]);
133
186
  }
@@ -139,6 +192,7 @@ export function generateGitlabOpWavesPipeline(spec, options) {
139
192
  ...(spec.resume
140
193
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
141
194
  : []),
195
+ ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
142
196
  ];
143
197
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
144
198
  return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA0BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA8BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}