@intentius/chant-lexicon-gitlab 0.124.54 → 0.124.56
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 +147 -23
- package/dist/components/generate-op-waves-pipeline.d.ts +25 -2
- package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
- package/dist/components/generate-op-waves-pipeline.js +110 -12
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +135 -11
- 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/__golden__/op-waves-jobs.yml +11 -1
- 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.cloud.test.ts +40 -0
- package/src/components/generate-op-pipeline.review.test.ts +72 -7
- package/src/components/generate-op-pipeline.ts +157 -24
- package/src/components/generate-op-waves-pipeline.approval.test.ts +28 -0
- package/src/components/generate-op-waves-pipeline.control.test.ts +55 -0
- package/src/components/generate-op-waves-pipeline.guard.test.ts +63 -0
- package/src/components/generate-op-waves-pipeline.test.ts +22 -3
- package/src/components/generate-op-waves-pipeline.ts +123 -12
- package/src/components/generate-pipeline.ts +143 -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.comment-targets.test.ts +20 -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/generate-pr-pipeline.respond.test.ts +27 -0
- package/src/components/generate-pr-pipeline.runners.test.ts +41 -0
- package/src/components/generate-pr-pipeline.stale-notes.test.ts +24 -0
- package/src/components/review-jobs.ts +163 -0
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
|
|
25
25
|
* scope and the Developer role; the job token cannot retry jobs).
|
|
26
26
|
*
|
|
27
|
-
* A `pr-review` wave (#3684)
|
|
28
|
-
* pipelines only, which records
|
|
27
|
+
* A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
|
|
28
|
+
* `<name>-record-plans`, run in merge request pipelines only, which records
|
|
29
|
+
* the digests the merge request's head plans.
|
|
29
30
|
*
|
|
30
31
|
* `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
|
|
31
32
|
* GitHub runner group is a spec error here. A wave's `jobs` renders its
|
|
@@ -37,14 +38,40 @@
|
|
|
37
38
|
* entry per cloud, and its script starts by trading them for those
|
|
38
39
|
* identities and unsetting the token variables, before any line of the
|
|
39
40
|
* checkout runs.
|
|
41
|
+
*
|
|
42
|
+
* With `control` on the spec (#4617), a `control-dispatch` job in a
|
|
43
|
+
* `control` stage needs the last wave's jobs, whose artifacts hold every
|
|
44
|
+
* job's outcome file. It adds them up with `chant run wave --waves-outcome`
|
|
45
|
+
* (through the product's bin) into `CHANT_CONTROL_OUTCOME` and sends the
|
|
46
|
+
* control repo one dispatch carrying it and `$CI_COMMIT_SHA`. A last wave
|
|
47
|
+
* whose runs failed (exit 1) still lets it run; a wave that waits at its
|
|
48
|
+
* gate stops it with the waves, and it runs once the retried job carries
|
|
49
|
+
* on. A CI/CD variable reaches every job, so every other job starts by
|
|
50
|
+
* unsetting the control repo's token variables and the product's two
|
|
51
|
+
* secret names; the dispatch step reads the token from the product's
|
|
52
|
+
* variable alone and hands it to curl on its stdin (#4568).
|
|
53
|
+
*
|
|
54
|
+
* Each wave job's script starts with the apply-job guard (#4624,
|
|
55
|
+
* `@intentius/chant/ci-job-guard`), before the trade: it stops the job
|
|
56
|
+
* unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
|
|
57
|
+
* `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
|
|
58
|
+
* holds each of the job's tags. A refused guard exits chant's `misplaced`
|
|
59
|
+
* (9), outside the job's allowed failure, so the job fails and the waves
|
|
60
|
+
* after it stop.
|
|
40
61
|
*/
|
|
41
62
|
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";
|
|
63
|
+
import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, OP_WAVES_APPROVAL_STATUS_VAR, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
|
|
64
|
+
import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
|
|
43
65
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
66
|
+
import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
|
|
67
|
+
import { CHANT_EXIT } from "@intentius/chant/exit-codes";
|
|
68
|
+
import { resolveProductWords } from "@intentius/chant/ci-product-words";
|
|
44
69
|
import { emitGitlabFile } from "./ci-jobs.js";
|
|
70
|
+
/** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
|
|
71
|
+
export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
|
|
45
72
|
/** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
|
|
46
73
|
export function generateGitlabOpWavesPipeline(spec, options) {
|
|
47
|
-
const jobs = opWaveJobs(spec, options.specFile);
|
|
74
|
+
const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
|
|
48
75
|
const image = options.image ?? CI_IMAGE;
|
|
49
76
|
const subject = `Op waves "${spec.name}"`;
|
|
50
77
|
const rules = pushBranches(spec.branches).map((branch) => {
|
|
@@ -69,8 +96,15 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
69
96
|
}
|
|
70
97
|
}
|
|
71
98
|
const specTags = gitlabTags(spec.runner);
|
|
72
|
-
const
|
|
99
|
+
const product = resolveProductWords(options.words ?? {}).product;
|
|
100
|
+
// #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
|
|
101
|
+
const byReview = opWavesPostsApprovalStatus(spec);
|
|
73
102
|
const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
|
|
103
|
+
// #4617: the control repo's token variables, which every job but the dispatch drops first.
|
|
104
|
+
const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
|
|
105
|
+
const dropControl = control ? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, control.tokenSecret, control.readTokenSecret])].join(" ")}`] : [];
|
|
106
|
+
if (control)
|
|
107
|
+
stages.push("control");
|
|
74
108
|
if (spec.resume)
|
|
75
109
|
stages.push("resume");
|
|
76
110
|
const entries = [["stages", stages]];
|
|
@@ -86,7 +120,19 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
86
120
|
...(specTags ? { tags: specTags } : {}),
|
|
87
121
|
variables: { ...GITLAB_FULL_HISTORY },
|
|
88
122
|
rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
|
|
89
|
-
script: [...(options.beforeScript ?? []),
|
|
123
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
// #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
|
|
127
|
+
entries.push([
|
|
128
|
+
`${spec.name}-approval-status`,
|
|
129
|
+
{
|
|
130
|
+
stage: "plans",
|
|
131
|
+
image,
|
|
132
|
+
...(specTags ? { tags: specTags } : {}),
|
|
133
|
+
variables: { ...GITLAB_FULL_HISTORY },
|
|
134
|
+
rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
|
|
135
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
|
|
90
136
|
},
|
|
91
137
|
]);
|
|
92
138
|
}
|
|
@@ -100,6 +146,16 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
100
146
|
const cloud = group?.cloud ?? wave.cloud;
|
|
101
147
|
const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
|
|
102
148
|
const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
|
|
149
|
+
// #4624: the apply-job guard, the script's first line, before the trade.
|
|
150
|
+
const runner = group?.runner ?? wave.runner ?? spec.runner;
|
|
151
|
+
const guard = jobGuardSpec({
|
|
152
|
+
forge: "gitlab",
|
|
153
|
+
job: opWaveJobLabel(spec.name, job),
|
|
154
|
+
product,
|
|
155
|
+
...(environment ? { environment: environment.name } : {}),
|
|
156
|
+
branches: pushBranches(spec.branches),
|
|
157
|
+
...(runner !== undefined ? { runner } : {}),
|
|
158
|
+
});
|
|
103
159
|
entries.push([
|
|
104
160
|
job.jobName,
|
|
105
161
|
{
|
|
@@ -109,16 +165,52 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
109
165
|
resource_group: `${spec.name}-${wave.name}`,
|
|
110
166
|
...(environment ? { environment: environmentDoc(environment) } : {}),
|
|
111
167
|
...(idTokens ? { id_tokens: idTokens } : {}),
|
|
112
|
-
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
|
|
168
|
+
variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
|
|
113
169
|
rules,
|
|
114
170
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
115
|
-
script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
116
|
-
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it
|
|
117
|
-
|
|
171
|
+
script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
|
|
172
|
+
// #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
|
|
173
|
+
// a refused guard (9, #4624) included.
|
|
174
|
+
allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
|
|
118
175
|
artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
|
|
119
176
|
},
|
|
120
177
|
]);
|
|
121
178
|
}
|
|
179
|
+
if (control) {
|
|
180
|
+
const dispatch = controlDispatchJob("gitlab", control, "$CI_COMMIT_SHA", {
|
|
181
|
+
...(options.words ? { words: options.words } : {}),
|
|
182
|
+
...(spec.control.sentCheck ? { sentCheck: spec.control.sentCheck } : {}),
|
|
183
|
+
...(spec.control.markSent ? { markSent: spec.control.markSent } : {}),
|
|
184
|
+
outcome: true,
|
|
185
|
+
});
|
|
186
|
+
entries.push([
|
|
187
|
+
dispatch.name,
|
|
188
|
+
{
|
|
189
|
+
stage: "control",
|
|
190
|
+
image,
|
|
191
|
+
...(specTags ? { tags: specTags } : {}),
|
|
192
|
+
rules,
|
|
193
|
+
// The last wave's jobs hand on every job's outcome file through their artifacts.
|
|
194
|
+
needs: opWavesLastJobs(jobs),
|
|
195
|
+
// The setup and the outcome run in a subshell without the control token, and only the outcome reaches stdout.
|
|
196
|
+
script: [
|
|
197
|
+
[
|
|
198
|
+
'outcome="$(',
|
|
199
|
+
...dropControl,
|
|
200
|
+
"{ :",
|
|
201
|
+
...setupOf[0],
|
|
202
|
+
...(options.beforeScript ?? []),
|
|
203
|
+
"} 1>&2",
|
|
204
|
+
opWavesOutcomeCommand(options.specFile, options.bin).join(" "),
|
|
205
|
+
')" || exit 1',
|
|
206
|
+
].join("\n"),
|
|
207
|
+
`export ${CONTROL_OUTCOME_VAR}="$outcome"`,
|
|
208
|
+
...Object.entries(dispatch.env).map(([k, v]) => `export ${k}="${v}"`),
|
|
209
|
+
`bash <<'CONTROL_DISPATCH' || exit $?\n${dispatch.script}\nCONTROL_DISPATCH`,
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
122
214
|
if (spec.resume) {
|
|
123
215
|
entries.push([
|
|
124
216
|
`${spec.name}-resume`,
|
|
@@ -127,7 +219,7 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
127
219
|
image,
|
|
128
220
|
...(specTags ? { tags: specTags } : {}),
|
|
129
221
|
rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
|
|
130
|
-
script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
|
|
222
|
+
script: [...dropControl, ...(options.beforeScript ?? []), opWavesResumeCommand(spec, options.bin).join(" ")],
|
|
131
223
|
},
|
|
132
224
|
]);
|
|
133
225
|
}
|
|
@@ -135,10 +227,16 @@ export function generateGitlabOpWavesPipeline(spec, options) {
|
|
|
135
227
|
`# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
|
|
136
228
|
"# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
|
|
137
229
|
"# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
|
|
138
|
-
"# failure, and the waves after it run what does not depend on those runs.",
|
|
230
|
+
"# failure, and the waves after it run what does not depend on those runs. A job",
|
|
231
|
+
"# whose guard finds it running where the spec did not set it to run exits 9 and",
|
|
232
|
+
"# stops the waves after it.",
|
|
139
233
|
...(spec.resume
|
|
140
234
|
? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
|
|
141
235
|
: []),
|
|
236
|
+
...(byReview
|
|
237
|
+
? [`# The ${spec.name}-approval-status job posts each open merge request's approval status from a pipeline schedule that sets ${OP_WAVES_APPROVAL_STATUS_VAR}=1.`]
|
|
238
|
+
: []),
|
|
239
|
+
...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
|
|
142
240
|
];
|
|
143
241
|
const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
|
|
144
242
|
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;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAaH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAkCjG,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"}
|
|
@@ -31,13 +31,15 @@
|
|
|
31
31
|
* demonstration: one generator-option edit reflects in every job without
|
|
32
32
|
* touching the component declarations.
|
|
33
33
|
*/
|
|
34
|
-
import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, } from "@intentius/chant/ci-jobs";
|
|
34
|
+
import { assertGitlabBranch, assertRunnerLabels, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTORY, GITLAB_MERGE_REQUEST_SOURCE, gitlabPushRule, gitlabTags, } from "@intentius/chant/ci-jobs";
|
|
35
35
|
import { resolveComponentGraph } from "@intentius/chant/components/driver";
|
|
36
36
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
37
37
|
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
38
|
-
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
|
|
38
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, GITLAB_APPLY_PR_VAR, GITLAB_PLAN_NOTES_VAR, gitlabPlanJobName, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, prCommentLockJobs, prCommentPreviewJob, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, PR_STALE_JOB, prStaleJob, prLoopJobStage, prLoopRunner, } from "@intentius/chant/components/pr-pipeline";
|
|
39
|
+
import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
|
|
39
40
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
40
41
|
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
42
|
+
import { CONTROL_TOKEN_VARS, controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
|
|
41
43
|
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
42
44
|
import { emitGitlabFile } from "./ci-jobs.js";
|
|
43
45
|
/**
|
|
@@ -303,6 +305,8 @@ function gitlabApplyPrRule(branch) {
|
|
|
303
305
|
function gitlabPlanNotesRule(branch) {
|
|
304
306
|
return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
|
|
305
307
|
}
|
|
308
|
+
/** Drops the credentials GitLab's runner writes into origin's URL (#4620), before a merge request's code runs in a pipeline of the target branch. */
|
|
309
|
+
const GITLAB_SCRUB_ORIGIN = `git remote set-url origin "$(git remote get-url origin | sed -E 's#//[^@/]*@#//#')"`;
|
|
306
310
|
/** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
|
|
307
311
|
const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
|
|
308
312
|
function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
|
|
@@ -335,7 +339,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
335
339
|
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
|
|
336
340
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
337
341
|
const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
338
|
-
const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
342
|
+
const script = (job) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
339
343
|
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
340
344
|
const identity = loop.identity;
|
|
341
345
|
const trading = (spec) => spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
@@ -343,13 +347,45 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
343
347
|
const applySpec = (job) => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
344
348
|
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
345
349
|
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
346
|
-
|
|
350
|
+
// #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
|
|
351
|
+
const lastGreen = loop.control && !loop.applyBeforeMerge && greens.length > 0 ? greens[greens.length - 1].name : undefined;
|
|
352
|
+
const control = loop.control && lastGreen
|
|
353
|
+
? controlDispatchJob("gitlab", loop.control.target, loop.waveGate ? "$GREEN_SHA" : "$CI_COMMIT_SHA", {
|
|
354
|
+
...(loop.words ? { words: loop.words } : {}),
|
|
355
|
+
...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
|
|
356
|
+
...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
|
|
357
|
+
})
|
|
358
|
+
: undefined;
|
|
359
|
+
const controlJob = control ? named(control.name) : undefined;
|
|
360
|
+
// A CI/CD variable reaches every job, so the plan job drops the control repo's tokens with the forge tokens.
|
|
361
|
+
const dropControl = loop.control
|
|
362
|
+
? [`unset ${[...new Set([...CONTROL_TOKEN_VARS, loop.control.target.tokenSecret, loop.control.target.readTokenSecret])].join(" ")}`]
|
|
363
|
+
: [];
|
|
364
|
+
// #4588: `/<product> lock` and `/<product> unlock`: GitLab starts no pipeline on a note, so each runs in a
|
|
365
|
+
// pipeline of the target branch started with CHANT_LOCK_PR or CHANT_UNLOCK_PR set, as the apply before merge does.
|
|
366
|
+
const targetBranch = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
|
|
367
|
+
const commentLocks = prCommentLockJobs("gitlab", loop).map((job) => ({
|
|
368
|
+
job,
|
|
369
|
+
name: named(job.jobName),
|
|
370
|
+
rule: `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${job.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}`,
|
|
371
|
+
}));
|
|
372
|
+
// #4620: `/<product> <plan word>`: a pipeline of the target branch started with CHANT_PREVIEW_PR set previews the merge request's head again.
|
|
373
|
+
const preview = prCommentPreviewJob("gitlab", env, loop, member?.name);
|
|
374
|
+
const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
|
|
375
|
+
const previewName = preview ? named(preview.jobName) : undefined;
|
|
376
|
+
const previewNoteName = preview ? named(preview.noteJobName) : undefined;
|
|
377
|
+
// #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
|
|
378
|
+
const stale = prStaleJob("gitlab", env, loop, member?.name);
|
|
379
|
+
const staleName = stale ? named(PR_STALE_JOB) : undefined;
|
|
380
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...(stale ? [PR_STALE_JOB] : []), ...commentLocks.map((c) => c.job.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...applies.map((a) => a.stage), ...(control ? [control.name] : []), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
347
381
|
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
348
|
-
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
382
|
+
const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
|
|
383
|
+
// #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
|
|
384
|
+
const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
|
|
349
385
|
const doc = {
|
|
350
386
|
// Included files merge their top-level keys, so a member's file carries
|
|
351
387
|
// the rules every member shares and no name: two names would collide.
|
|
352
|
-
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
388
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...commentLocks.map((c) => ({ if: c.rule })), ...(previewRule ? [{ if: previewRule }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
353
389
|
stages,
|
|
354
390
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
355
391
|
[planJob]: {
|
|
@@ -360,7 +396,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
360
396
|
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
361
397
|
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
362
398
|
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
363
|
-
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
399
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...dropControl, ...script(plan)],
|
|
364
400
|
artifacts: { when: "always", paths: [reportPath] },
|
|
365
401
|
},
|
|
366
402
|
// #4295, #4328: a job of the target branch's pipeline posts each open merge request's note and status from its plan job's report.
|
|
@@ -368,15 +404,68 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
368
404
|
stage: PR_PLAN_NOTE_JOB,
|
|
369
405
|
image,
|
|
370
406
|
rules: [{ if: onPlanNotes }],
|
|
371
|
-
|
|
407
|
+
...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
|
|
408
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
|
|
372
409
|
},
|
|
410
|
+
...(stale && staleName
|
|
411
|
+
? {
|
|
412
|
+
// The pushed commit of the target branch: none of a merge request's code is here.
|
|
413
|
+
[staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
416
|
+
...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
|
|
417
|
+
name,
|
|
418
|
+
// The target branch's commit, so none of the merge request's code runs here.
|
|
419
|
+
{ stage: job.jobName, image, rules: [{ if: rule }], variables: job.env, script: [...enter, ...beforeScript, job.line] },
|
|
420
|
+
])),
|
|
421
|
+
...(preview && previewName && previewNoteName && previewRule
|
|
422
|
+
? {
|
|
423
|
+
[previewName]: {
|
|
424
|
+
stage: preview.jobName,
|
|
425
|
+
image,
|
|
426
|
+
rules: [{ if: previewRule }],
|
|
427
|
+
...trading(identity?.plan),
|
|
428
|
+
variables: { ...preview.plan.run.env, ...tradeVars(identity?.plan) },
|
|
429
|
+
// A pipeline of the target branch holds the protected tokens, so the job drops them before it checks out the merge request's head.
|
|
430
|
+
script: [
|
|
431
|
+
...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]),
|
|
432
|
+
...dropControl,
|
|
433
|
+
"unset CHANT_RUNNER_KEY",
|
|
434
|
+
...("line" in preview.head ? [preview.head.line] : []),
|
|
435
|
+
// The ledger while the clone can still fetch it: the plan reads the gate ledger from the local branch.
|
|
436
|
+
"git fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true",
|
|
437
|
+
// The runner's clone keeps the job token in origin's URL; the merge request's code never sees it.
|
|
438
|
+
GITLAB_SCRUB_ORIGIN,
|
|
439
|
+
preview.base,
|
|
440
|
+
...prLoopSetup(image, loop.engineSetup),
|
|
441
|
+
...enter,
|
|
442
|
+
...beforeScript,
|
|
443
|
+
...(preview.plan.setup ?? []),
|
|
444
|
+
...(preview.plan.build ?? []),
|
|
445
|
+
preview.plan.run.command,
|
|
446
|
+
...extraScript,
|
|
447
|
+
],
|
|
448
|
+
artifacts: { when: "always", paths: [reportPath] },
|
|
449
|
+
},
|
|
450
|
+
[previewNoteName]: {
|
|
451
|
+
stage: preview.noteJobName,
|
|
452
|
+
image,
|
|
453
|
+
needs: [{ job: previewName, artifacts: true }],
|
|
454
|
+
rules: [{ if: previewRule, when: "always" }],
|
|
455
|
+
variables: preview.post.env,
|
|
456
|
+
// The target branch's commit: the job's own clone, where none of the merge request's code runs.
|
|
457
|
+
script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), ...(preview.post.setup ?? []), preview.post.command],
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
: {}),
|
|
373
461
|
...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
|
|
374
462
|
name,
|
|
375
463
|
{
|
|
376
464
|
stage,
|
|
377
465
|
image,
|
|
378
466
|
...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
|
|
379
|
-
|
|
467
|
+
// #4552: on the repo grain every apply of the repository shares one resource group.
|
|
468
|
+
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name),
|
|
380
469
|
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
381
470
|
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
382
471
|
...trading(applySpec(job)),
|
|
@@ -397,20 +486,55 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
397
486
|
artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
|
|
398
487
|
},
|
|
399
488
|
])),
|
|
489
|
+
...(control && controlJob
|
|
490
|
+
? {
|
|
491
|
+
[controlJob]: {
|
|
492
|
+
stage: control.name,
|
|
493
|
+
image,
|
|
494
|
+
needs: [lastGreen],
|
|
495
|
+
rules: [{ if: onGreen }],
|
|
496
|
+
// The runner sets a needed job's dotenv values when the script starts, so the commit is read there.
|
|
497
|
+
script: [
|
|
498
|
+
...Object.entries(control.env).map(([k, v]) => `export ${k}="${v}"`),
|
|
499
|
+
`bash <<'CONTROL_DISPATCH' || exit $?\n${control.script}\nCONTROL_DISPATCH`,
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
: {}),
|
|
400
504
|
...(prEnv ? prEnv.jobs : {}),
|
|
401
505
|
};
|
|
402
506
|
const jobs = [
|
|
403
507
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
404
508
|
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
|
|
509
|
+
...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
|
|
510
|
+
...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
|
|
511
|
+
...(preview && previewName && previewNoteName
|
|
512
|
+
? [
|
|
513
|
+
{ jobName: previewName, component: "merge request preview comment", stage: preview.jobName, needs: [] },
|
|
514
|
+
{ jobName: previewNoteName, component: "merge request preview comment note", stage: preview.noteJobName, needs: [previewName] },
|
|
515
|
+
]
|
|
516
|
+
: []),
|
|
405
517
|
...applies.map(({ job, name, stage }, i) => ({
|
|
406
518
|
jobName: name,
|
|
407
519
|
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
|
|
408
520
|
stage,
|
|
409
521
|
needs: i > 1 ? [applies[i - 1].name] : [],
|
|
410
522
|
})),
|
|
523
|
+
...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
|
|
411
524
|
...(prEnv ? prEnv.listed : []),
|
|
412
525
|
];
|
|
413
|
-
|
|
526
|
+
// #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
|
|
527
|
+
if (loop.runners) {
|
|
528
|
+
for (const stage of ["plan", "apply", "default"])
|
|
529
|
+
assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
|
|
530
|
+
for (const job of jobs) {
|
|
531
|
+
const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
|
|
532
|
+
const entry = doc[job.jobName];
|
|
533
|
+
if (tags && entry)
|
|
534
|
+
doc[job.jobName] = { ...entry, tags };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...(staleName ? [staleName] : []), ...commentLocks.map((c) => c.name), ...(previewName && previewNoteName ? [previewName, previewNoteName] : []), ...applies.map((a) => a.name), ...(controlJob ? [controlJob] : []), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
|
|
414
538
|
return { yaml, stages, jobs, env };
|
|
415
539
|
}
|
|
416
540
|
/** The stage a pull request environment's jobs run in (#4298). */
|
|
@@ -434,7 +558,7 @@ function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member,
|
|
|
434
558
|
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
435
559
|
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
436
560
|
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
437
|
-
const script = (j) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
561
|
+
const script = (j) => [...prLoopSetup(image, loop.engineSetup), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
438
562
|
const artifacts = { when: "always", paths: [reportPath] };
|
|
439
563
|
// #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity first.
|
|
440
564
|
const identity = loop.prEnvIdentity;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two jobs of a split review on GitLab (#4544): `ScheduledOpSpec.reviewAgent`.
|
|
3
|
+
*
|
|
4
|
+
* An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
|
|
5
|
+
* code (`chant run` loads the merge request's Op files) beside a model.
|
|
6
|
+
* GitHub and Forgejo keep the write token out of that job with per-job
|
|
7
|
+
* permissions (`lexicons/github/src/components/review-jobs.ts`). GitLab has
|
|
8
|
+
* none: a merge request pipeline is built from the merge request's own files
|
|
9
|
+
* and every job in it gets the project's unprotected variables. So the token
|
|
10
|
+
* stays out of that pipeline altogether, the way the plan note does (#4328):
|
|
11
|
+
*
|
|
12
|
+
* - the review job runs in the merge request pipeline. Its script starts by
|
|
13
|
+
* unsetting every forge token variable (`GITLAB_DROP_FORGE_TOKENS`), and it
|
|
14
|
+
* keeps the review directory as its artifact. `CHANT_FORGE_TOKEN` is a
|
|
15
|
+
* protected variable, which a merge request pipeline never gets.
|
|
16
|
+
* - the note job runs in a pipeline of the default branch, built from that
|
|
17
|
+
* branch's files, that a pipeline schedule (or a person, the API or a
|
|
18
|
+
* trigger) starts with `CHANT_REVIEW_NOTES` set to `1`. It checks nothing
|
|
19
|
+
* out (`GIT_STRATEGY: none`), installs the chant release this lexicon
|
|
20
|
+
* belongs to, and runs `postOpenReviewNotes` with the protected token: for
|
|
21
|
+
* each open merge request whose newest pipeline's review job succeeded it
|
|
22
|
+
* reads `review.json` from that job's artifacts as data and posts the note.
|
|
23
|
+
* The Op's name, the review job's name and the branches come from this
|
|
24
|
+
* file, never from the artifact.
|
|
25
|
+
*/
|
|
26
|
+
import type { OpTrigger } from "@intentius/chant/lexicon";
|
|
27
|
+
/** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
|
|
28
|
+
export declare const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
|
|
29
|
+
/** Where the review job writes the review, from the directory its script runs in. */
|
|
30
|
+
export declare const REVIEW_DIR = ".chant/review";
|
|
31
|
+
/** The variable a pipeline of the default branch is started with to run the note jobs. */
|
|
32
|
+
export declare const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
|
|
33
|
+
/** The name of the note job beside the Op's own job. */
|
|
34
|
+
export declare function reviewNoteJobName(jobName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* The note job's rule: a pipeline of the default branch that a pipeline
|
|
37
|
+
* schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
|
|
38
|
+
* set to `1`. A merge request pipeline's own trigger job starts a pipeline
|
|
39
|
+
* whose source is `pipeline`, which this rule leaves out.
|
|
40
|
+
*/
|
|
41
|
+
export declare const GITLAB_REVIEW_NOTES_RULE = "$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_REVIEW_NOTES == \"1\" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH";
|
|
42
|
+
/**
|
|
43
|
+
* Refuse a spec whose review cannot be split the way this shape needs: a
|
|
44
|
+
* trigger with no merge request, a finding-mode that would post from the
|
|
45
|
+
* review job, or an additive scope other than `id-token: write`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertReviewSplit(name: string, trigger: OpTrigger, findingMode: string, permissions: Record<string, string> | undefined): void;
|
|
48
|
+
export interface GitlabReviewJobsInput {
|
|
49
|
+
/** The Op's name: what `chant run` targets and what keys the note. */
|
|
50
|
+
name: string;
|
|
51
|
+
/** The review job's name: the Op's job name. */
|
|
52
|
+
jobName: string;
|
|
53
|
+
stage: string;
|
|
54
|
+
image: string;
|
|
55
|
+
/** The review job's `rules:`, the Op's merge request rules. */
|
|
56
|
+
rules: unknown[];
|
|
57
|
+
/** The review job's script after the token unset: cd, setup, before-script, the invocation and the extra script. */
|
|
58
|
+
script: string[];
|
|
59
|
+
/** The Op's own per-job environment (`ScheduledOpSpec.variables`). */
|
|
60
|
+
variables?: Record<string, string>;
|
|
61
|
+
idTokens?: Record<string, unknown>;
|
|
62
|
+
/** The review directory from the repository root: `.chant/review`, under a workspace member's directory for a member. */
|
|
63
|
+
artifactDir: string;
|
|
64
|
+
/** The branches the merge requests merge into, from the trigger; the default branch when absent. */
|
|
65
|
+
branches?: readonly string[];
|
|
66
|
+
}
|
|
67
|
+
/** The review job and the note job of one split review. */
|
|
68
|
+
export declare function gitlabReviewJobs(i: GitlabReviewJobsInput): {
|
|
69
|
+
review: Record<string, unknown>;
|
|
70
|
+
note: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=review-jobs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-jobs.d.ts","sourceRoot":"","sources":["../../src/components/review-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,uGAAuG;AACvG,eAAO,MAAM,cAAc,qBAAqB,CAAC;AAEjD,qFAAqF;AACrF,eAAO,MAAM,UAAU,kBAAkB,CAAC;AAE1C,0FAA0F;AAC1F,eAAO,MAAM,uBAAuB,uBAAuB,CAAC;AAK5D,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,qIAA2I,CAAC;AAEjL;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAAG,IAAI,CAkB9I;AAyCD,MAAM,WAAW,qBAAqB;IACpC,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,oHAAoH;IACpH,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,yHAAyH;IACzH,WAAW,EAAE,MAAM,CAAC;IACpB,oGAAoG;IACpG,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9B;AAED,2DAA2D;AAC3D,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,qBAAqB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsB7H"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two jobs of a split review on GitLab (#4544): `ScheduledOpSpec.reviewAgent`.
|
|
3
|
+
*
|
|
4
|
+
* An Op that runs a `reviewAgent` step in `handoff` mode runs the change's
|
|
5
|
+
* code (`chant run` loads the merge request's Op files) beside a model.
|
|
6
|
+
* GitHub and Forgejo keep the write token out of that job with per-job
|
|
7
|
+
* permissions (`lexicons/github/src/components/review-jobs.ts`). GitLab has
|
|
8
|
+
* none: a merge request pipeline is built from the merge request's own files
|
|
9
|
+
* and every job in it gets the project's unprotected variables. So the token
|
|
10
|
+
* stays out of that pipeline altogether, the way the plan note does (#4328):
|
|
11
|
+
*
|
|
12
|
+
* - the review job runs in the merge request pipeline. Its script starts by
|
|
13
|
+
* unsetting every forge token variable (`GITLAB_DROP_FORGE_TOKENS`), and it
|
|
14
|
+
* keeps the review directory as its artifact. `CHANT_FORGE_TOKEN` is a
|
|
15
|
+
* protected variable, which a merge request pipeline never gets.
|
|
16
|
+
* - the note job runs in a pipeline of the default branch, built from that
|
|
17
|
+
* branch's files, that a pipeline schedule (or a person, the API or a
|
|
18
|
+
* trigger) starts with `CHANT_REVIEW_NOTES` set to `1`. It checks nothing
|
|
19
|
+
* out (`GIT_STRATEGY: none`), installs the chant release this lexicon
|
|
20
|
+
* belongs to, and runs `postOpenReviewNotes` with the protected token: for
|
|
21
|
+
* each open merge request whose newest pipeline's review job succeeded it
|
|
22
|
+
* reads `review.json` from that job's artifacts as data and posts the note.
|
|
23
|
+
* The Op's name, the review job's name and the branches come from this
|
|
24
|
+
* file, never from the artifact.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync } from "node:fs";
|
|
27
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
28
|
+
/** The variable `reviewAgent` reads its handoff directory from (`REVIEW_DIR_ENV`, review-agent.ts). */
|
|
29
|
+
export const REVIEW_DIR_ENV = "CHANT_REVIEW_DIR";
|
|
30
|
+
/** Where the review job writes the review, from the directory its script runs in. */
|
|
31
|
+
export const REVIEW_DIR = ".chant/review";
|
|
32
|
+
/** The variable a pipeline of the default branch is started with to run the note jobs. */
|
|
33
|
+
export const GITLAB_REVIEW_NOTES_VAR = "CHANT_REVIEW_NOTES";
|
|
34
|
+
/** How long a review artifact is kept: long enough for a schedule to come round and post it. */
|
|
35
|
+
const REVIEW_ARTIFACT_EXPIRY = "7 days";
|
|
36
|
+
/** The name of the note job beside the Op's own job. */
|
|
37
|
+
export function reviewNoteJobName(jobName) {
|
|
38
|
+
return `${jobName}-note`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The note job's rule: a pipeline of the default branch that a pipeline
|
|
42
|
+
* schedule, a person, the API or a trigger started with `CHANT_REVIEW_NOTES`
|
|
43
|
+
* set to `1`. A merge request pipeline's own trigger job starts a pipeline
|
|
44
|
+
* whose source is `pipeline`, which this rule leaves out.
|
|
45
|
+
*/
|
|
46
|
+
export const GITLAB_REVIEW_NOTES_RULE = `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_REVIEW_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`;
|
|
47
|
+
/**
|
|
48
|
+
* Refuse a spec whose review cannot be split the way this shape needs: a
|
|
49
|
+
* trigger with no merge request, a finding-mode that would post from the
|
|
50
|
+
* review job, or an additive scope other than `id-token: write`.
|
|
51
|
+
*/
|
|
52
|
+
export function assertReviewSplit(name, trigger, findingMode, permissions) {
|
|
53
|
+
if (trigger.kind !== "pull_request") {
|
|
54
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent, which posts the review as a note on the merge request that triggered the run, but its trigger is "${trigger.kind}". Give it a { kind: "pull_request" } trigger.`);
|
|
55
|
+
}
|
|
56
|
+
if (findingMode !== "report") {
|
|
57
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent with findingMode "${findingMode}". The review job holds no forge token, and the note job posts from a pipeline of the default branch instead, so the Op's own finding-mode must be "report".`);
|
|
58
|
+
}
|
|
59
|
+
for (const [scope, level] of Object.entries(permissions ?? {})) {
|
|
60
|
+
if (level === "write" && scope !== "id-token") {
|
|
61
|
+
throw new Error(`Scheduled Op "${name}" sets reviewAgent and adds the permission "${scope}: write". The review job runs the change's code and may hold only "id-token: write".`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The chant release the note job installs: this lexicon's own, which releases in lockstep with `@intentius/chant`. */
|
|
66
|
+
function chantVersion() {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
68
|
+
if (typeof pkg.version !== "string" || pkg.version === "") {
|
|
69
|
+
throw new Error("the gitlab lexicon's package.json has no version to pin the review note job's chant to");
|
|
70
|
+
}
|
|
71
|
+
return pkg.version;
|
|
72
|
+
}
|
|
73
|
+
/** What the note job runs under node. The inputs are JSON literals from this file; nothing comes from the merge request. */
|
|
74
|
+
function noteModule(op, job, artifactDir, branches) {
|
|
75
|
+
return [
|
|
76
|
+
'import { forgeFromEnv } from "@intentius/chant/pr-forge";',
|
|
77
|
+
'import { postOpenReviewNotes } from "@intentius/chant/op/activities/review-agent";',
|
|
78
|
+
`const branches = ${JSON.stringify(branches ?? null)} ?? [process.env.CI_DEFAULT_BRANCH];`,
|
|
79
|
+
"const forge = forgeFromEnv(\"gitlab\");",
|
|
80
|
+
"let failed = false;",
|
|
81
|
+
"for (const branch of branches) {",
|
|
82
|
+
` const { posts, warnings } = await postOpenReviewNotes({ forge, branch, job: ${JSON.stringify(job)}, artifactDir: ${JSON.stringify(artifactDir)}, op: ${JSON.stringify(op)} });`,
|
|
83
|
+
" for (const p of posts) console.log(`!${p.pr} at ${p.head.slice(0, 12)}: ${p.skipped ?? `posted, risk ${p.risk}`}`);",
|
|
84
|
+
" for (const w of warnings) { console.error(w); failed = true; }",
|
|
85
|
+
"}",
|
|
86
|
+
"if (failed) process.exitCode = 1;",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
/** The note job's script: install the pinned chant outside any checkout and run {@link noteModule} with tsx. */
|
|
90
|
+
function noteScript(version, module) {
|
|
91
|
+
return [
|
|
92
|
+
'dir="${TMPDIR:-/tmp}/chant-review-note"',
|
|
93
|
+
'mkdir -p "$dir"',
|
|
94
|
+
'cd "$dir"',
|
|
95
|
+
"echo '{\"private\":true}' > package.json",
|
|
96
|
+
`npm install --omit=peer --no-audit --no-fund --loglevel=error @intentius/chant@${version}`,
|
|
97
|
+
["cat > note.mjs <<'NOTE'", module, "NOTE"].join("\n"),
|
|
98
|
+
"node --import tsx note.mjs",
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
/** The review job and the note job of one split review. */
|
|
102
|
+
export function gitlabReviewJobs(i) {
|
|
103
|
+
const review = {
|
|
104
|
+
stage: i.stage,
|
|
105
|
+
image: i.image,
|
|
106
|
+
resource_group: i.jobName,
|
|
107
|
+
...(i.idTokens ? { id_tokens: i.idTokens } : {}),
|
|
108
|
+
variables: { ...(i.variables ?? {}), [REVIEW_DIR_ENV]: REVIEW_DIR },
|
|
109
|
+
rules: i.rules,
|
|
110
|
+
// The merge request's code runs in this job, so its shell drops every forge token first.
|
|
111
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...i.script],
|
|
112
|
+
artifacts: { paths: [i.artifactDir], expire_in: REVIEW_ARTIFACT_EXPIRY },
|
|
113
|
+
};
|
|
114
|
+
const note = {
|
|
115
|
+
stage: i.stage,
|
|
116
|
+
image: i.image,
|
|
117
|
+
resource_group: reviewNoteJobName(i.jobName),
|
|
118
|
+
// Nothing of the repository is needed: the review is read from the review job's artifacts through the API.
|
|
119
|
+
variables: { GIT_STRATEGY: "none" },
|
|
120
|
+
rules: [{ if: GITLAB_REVIEW_NOTES_RULE }],
|
|
121
|
+
script: noteScript(chantVersion(), noteModule(i.name, i.jobName, i.artifactDir, i.branches)),
|
|
122
|
+
};
|
|
123
|
+
return { review, note };
|
|
124
|
+
}
|