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