@intentius/chant-lexicon-gitlab 0.124.46 → 0.124.49

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.
@@ -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;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAsGxB"}
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"}
@@ -164,6 +164,22 @@ function assertTriggerSupportsMode(name, mode, trigger) {
164
164
  `no merge request to post on. Give it a { kind: "pull_request" } trigger, or use findingMode "issue" ` +
165
165
  `or "merge-request".`);
166
166
  }
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
+ }
167
183
  /**
168
184
  * The follow-up line an Op with an `environment` adds under its own header
169
185
  * line (#2257). The `environment:` key on the job binds it to the
@@ -235,6 +251,7 @@ export function generateGitlabOpPipeline(ops, options = {}) {
235
251
  const findingMode = spec.findingMode ?? "report";
236
252
  const trigger = resolveOpTrigger(spec);
237
253
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
254
+ assertNoReviewSplit(spec);
238
255
  if (trigger.kind === "cron")
239
256
  anyCron = true;
240
257
  const jobName = jobPrefix + ciJobName(spec.name);
@@ -3,8 +3,16 @@
3
3
  *
4
4
  * One file, one stage per wave, on a push to the spec's branches. Each job is
5
5
  * one of core's `opWaveJobs`, and `needs:` chains them so a wave that waits at
6
- * its gate (exit 3) or fails stops every later wave; retrying the failed job
7
- * after `chant approve` carries on. `GIT_DEPTH: "0"` gives the runner the base
6
+ * its gate (exit 3) stops every later wave; retrying the failed job after
7
+ * `chant approve` carries on, and the jobs it skipped run after it.
8
+ *
9
+ * A job that exits 1 (a run failed, or was blocked) is an allowed failure
10
+ * (`allow_failure: exit_codes: [1]`, #4322), so the next wave's jobs run
11
+ * after it and block only the runs that depend on a failed one. Every job
12
+ * keeps the outcome directory as an artifact, holding the outcome files it
13
+ * received through `needs:` and its own, so each later job reads every
14
+ * earlier one. Any other non-zero exit still stops the waves after it.
15
+ * `GIT_DEPTH: "0"` gives the runner the base
8
16
  * commit it reads the gate policy at. A wide wave's deciding job keeps its
9
17
  * decision as an artifact, which GitLab hands each share job through `needs:`.
10
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAkBH,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACjH,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,CA4FvH"}
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"}
@@ -3,8 +3,16 @@
3
3
  *
4
4
  * One file, one stage per wave, on a push to the spec's branches. Each job is
5
5
  * one of core's `opWaveJobs`, and `needs:` chains them so a wave that waits at
6
- * its gate (exit 3) or fails stops every later wave; retrying the failed job
7
- * after `chant approve` carries on. `GIT_DEPTH: "0"` gives the runner the base
6
+ * its gate (exit 3) stops every later wave; retrying the failed job after
7
+ * `chant approve` carries on, and the jobs it skipped run after it.
8
+ *
9
+ * A job that exits 1 (a run failed, or was blocked) is an allowed failure
10
+ * (`allow_failure: exit_codes: [1]`, #4322), so the next wave's jobs run
11
+ * after it and block only the runs that depend on a failed one. Every job
12
+ * keeps the outcome directory as an artifact, holding the outcome files it
13
+ * received through `needs:` and its own, so each later job reads every
14
+ * earlier one. Any other non-zero exit still stops the waves after it.
15
+ * `GIT_DEPTH: "0"` gives the runner the base
8
16
  * commit it reads the gate policy at. A wide wave's deciding job keeps its
9
17
  * decision as an artifact, which GitLab hands each share job through `needs:`.
10
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
@@ -31,7 +39,7 @@
31
39
  * checkout runs.
32
40
  */
33
41
  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";
34
- import { opWaveGroupOf, opWaveJobs, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
42
+ import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
35
43
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
36
44
  import { emitGitlabFile } from "./ci-jobs.js";
37
45
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
@@ -105,7 +113,9 @@ export function generateGitlabOpWavesPipeline(spec, options) {
105
113
  rules,
106
114
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
107
115
  script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
108
- ...(job.kind === "decide" ? { artifacts: { when: "always", paths: [job.record], expire_in: "30 days" } } : {}),
116
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
117
+ allow_failure: { exit_codes: [1] },
118
+ artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
109
119
  },
110
120
  ]);
111
121
  }
@@ -124,7 +134,8 @@ export function generateGitlabOpWavesPipeline(spec, options) {
124
134
  const header = [
125
135
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
126
136
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
127
- "# digest it prints, then retry the job.",
137
+ "# 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.",
128
139
  ...(spec.resume
129
140
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
130
141
  : []),
@@ -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;AAwBjG,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;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"}
@@ -35,7 +35,7 @@ import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTO
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, 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, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
39
39
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
40
40
  import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
41
41
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
@@ -263,12 +263,16 @@ function gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScri
263
263
  * The plan job holds no forge token while the merge request's code runs
264
264
  * (#4295, `@intentius/chant/ci-handover`): its script starts by unsetting
265
265
  * the token variables, and `pr-plan --no-status` writes the note and the
266
- * status into its report. The `plan-note` job after it, which GitLab hands
267
- * the report artifact, checks out the target branch's commit and posts them
268
- * with `chant components pr-post`. A CI/CD variable reaches every job of a
269
- * merge request pipeline, so `unset` keeps it from the plan's code, and
270
- * `CHANT_FORGE_TOKEN` as a protected variable keeps it from the pipeline
271
- * altogether.
266
+ * status into its report, which it keeps as its artifact. GitLab builds a
267
+ * merge request pipeline from the merge request's own files, so no job in it
268
+ * may hold the token (#4328): `CHANT_FORGE_TOKEN` and `CHANT_RUNNER_KEY` are
269
+ * protected variables, which a merge request pipeline never gets. The
270
+ * `plan-note` job runs in a pipeline of the target branch, built from that
271
+ * branch's files, that a pipeline schedule (or a person) starts with
272
+ * `CHANT_PLAN_NOTES` set to `1`. It runs `chant components pr-post --open`,
273
+ * which asks GitLab for each open merge request's plan job, reads the report
274
+ * from its artifacts and posts the note and the status for each job that
275
+ * settled since the last run.
272
276
  *
273
277
  * With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
274
278
  * and apply jobs take an `id_tokens:` entry per cloud, and each script
@@ -290,6 +294,15 @@ const WAVE_GREEN_DOTENV = "chant-green.env";
290
294
  function gitlabApplyPrRule(branch) {
291
295
  return `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${GITLAB_APPLY_PR_VAR} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${branch}`;
292
296
  }
297
+ /**
298
+ * The rule of the plan-note job (#4328): a pipeline of `branch` that a
299
+ * pipeline schedule, a person or a trigger started with `CHANT_PLAN_NOTES`
300
+ * set to `1`. A merge request pipeline's own trigger job starts a pipeline
301
+ * whose source is `pipeline`, which this rule leaves out.
302
+ */
303
+ function gitlabPlanNotesRule(branch) {
304
+ return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
305
+ }
293
306
  /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
294
307
  const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
295
308
  function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
@@ -298,7 +311,8 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
298
311
  const rooted = !member || member.dir === "." || member.dir === "";
299
312
  const [plan, apply, record] = prLoopJobs("gitlab", env, loop, member?.name);
300
313
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
301
- const planJob = member ? `${member.name}-plan` : "plan";
314
+ // #4328: the plan-note job's pr-post --open finds the plan job by this name.
315
+ const planJob = gitlabPlanJobName(member?.name);
302
316
  const named = (job) => (member ? `${member.name}-${job}` : job);
303
317
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
304
318
  // #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
@@ -313,6 +327,8 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
313
327
  assertGitlabBranch("The merge-request pipeline", loop.branch);
314
328
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
315
329
  // #4301: the apply before merge runs in a pipeline of the target branch a person starts with CHANT_APPLY_PR set.
330
+ // #4328: the plan notes are posted from a pipeline of the target branch, never from the merge request's own.
331
+ const onPlanNotes = gitlabPlanNotesRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
316
332
  const onGreen = loop.applyBeforeMerge ? gitlabApplyPrRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH") : GITLAB_GREEN_TAG_RULE;
317
333
  // A member's jobs start in its directory, after the image's install lines.
318
334
  const enter = rooted ? [] : [`cd ${memberShellDir(member)}`];
@@ -333,7 +349,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
333
349
  const doc = {
334
350
  // Included files merge their top-level keys, so a member's file carries
335
351
  // the rules every member shares and no name: two names would collide.
336
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
352
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
337
353
  stages,
338
354
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
339
355
  [planJob]: {
@@ -347,14 +363,12 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
347
363
  script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
348
364
  artifacts: { when: "always", paths: [reportPath] },
349
365
  },
350
- // #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
366
+ // #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.
351
367
  [noteJob]: {
352
368
  stage: PR_PLAN_NOTE_JOB,
353
369
  image,
354
- needs: [planJob],
355
- rules: [{ if: onMergeRequest, when: "always" }],
356
- variables: post.env,
357
- script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
370
+ rules: [{ if: onPlanNotes }],
371
+ script: [...enter, ...beforeScript, post.command],
358
372
  },
359
373
  ...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
360
374
  name,
@@ -387,7 +401,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
387
401
  };
388
402
  const jobs = [
389
403
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
390
- { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
404
+ { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
391
405
  ...applies.map(({ job, name, stage }, i) => ({
392
406
  jobName: name,
393
407
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
@@ -465,7 +479,8 @@ function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member,
465
479
  [named("env-sweep")]: {
466
480
  stage: PR_ENV_STAGE,
467
481
  image,
468
- rules: [{ if: GITLAB_SCHEDULE_RULE }],
482
+ // #4328: not in the plan notes' schedule, which runs far more often than a sweep needs.
483
+ rules: [{ if: `${GITLAB_SCHEDULE_RULE} && $${GITLAB_PLAN_NOTES_VAR} != "1"` }],
469
484
  resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
470
485
  ...trading,
471
486
  variables: { ...sweep.env, ...tradeVars },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "512e066f33446ad5ab2b6f4d3486a395e4a6117d03c4518037b4c1f5a3cc1690",
4
+ "manifest.json": "dc30895365f349bcbfaeb0990c8b0638219aba7d367239c2b505f788643e8ffc",
5
5
  "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
6
  "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "0820ad847cb53d45adea410db693b97dc4c721a832366c65876dbf640f66fda1"
58
+ "composite": "6a7c912a036e6265bb02e2fe786dcbd81fade613ec38bf3c342b9886df92d122"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAsB7F,eAAO,MAAM,YAAY,EAAE,aA4hB1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAsB7F,eAAO,MAAM,YAAY,EAAE,aA6hB1B,CAAC"}
package/dist/plugin.js CHANGED
@@ -305,7 +305,8 @@ export const test = new Job({
305
305
  async transform(content, opts) {
306
306
  const { transform } = await import("./migrate/from-github/index.js");
307
307
  const result = await transform(content, {
308
- emit: opts.emit,
308
+ // "sql" is for sql targets (#4399); chant migrate refuses it for this pair.
309
+ emit: opts.emit === "sql" ? undefined : opts.emit,
309
310
  useComposites: opts.useComposites,
310
311
  sourceFile: opts.sourceFile,
311
312
  strict: opts.strict,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.46",
71
- "@intentius/chant-lexicon-github": "^0.124.46"
70
+ "@intentius/chant": "^0.124.49",
71
+ "@intentius/chant-lexicon-github": "^0.124.49"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -3,6 +3,7 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
7
  - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
7
8
 
8
9
  stages:
@@ -37,18 +38,10 @@ plan:
37
38
  plan-note:
38
39
  stage: plan-note
39
40
  image: node:22
40
- needs:
41
- - plan
42
41
  rules:
43
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
44
- when: always
45
- variables:
46
- PR_NUMBER: '$CI_MERGE_REQUEST_IID'
47
- HEAD_SHA: '$CI_COMMIT_SHA'
48
- BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
42
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
49
43
  script:
50
- - git checkout --quiet --detach "$BASE_SHA"
51
- - chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
44
+ - chant components pr-post --open --env prod --output .chant/pr --forge gitlab
52
45
 
53
46
  record:
54
47
  stage: record
@@ -3,6 +3,7 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
7
 
7
8
  stages:
8
9
  - plan
@@ -33,18 +34,10 @@ plan:
33
34
  plan-note:
34
35
  stage: plan-note
35
36
  image: node:22
36
- needs:
37
- - plan
38
37
  rules:
39
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
40
- when: always
41
- variables:
42
- PR_NUMBER: '$CI_MERGE_REQUEST_IID'
43
- HEAD_SHA: '$CI_COMMIT_SHA'
44
- BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
38
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
45
39
  script:
46
- - git checkout --quiet --detach "$BASE_SHA"
47
- - chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
40
+ - chant components pr-post --open --env prod --output .chant/pr --forge gitlab
48
41
 
49
42
  record:
50
43
  stage: record
@@ -3,6 +3,7 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
7
  - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
7
8
 
8
9
  stages:
@@ -37,18 +38,10 @@ plan:
37
38
  plan-note:
38
39
  stage: plan-note
39
40
  image: node:22
40
- needs:
41
- - plan
42
41
  rules:
43
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
44
- when: always
45
- variables:
46
- PR_NUMBER: '$CI_MERGE_REQUEST_IID'
47
- HEAD_SHA: '$CI_COMMIT_SHA'
48
- BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
42
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
49
43
  script:
50
- - git checkout --quiet --detach "$BASE_SHA"
51
- - chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
44
+ - chant components pr-post --open --env prod --output .chant/pr --forge gitlab
52
45
 
53
46
  record:
54
47
  stage: record
@@ -3,6 +3,7 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
7
  - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
7
8
 
8
9
  stages:
@@ -35,18 +36,10 @@ plan:
35
36
  plan-note:
36
37
  stage: plan-note
37
38
  image: node:22
38
- needs:
39
- - plan
40
39
  rules:
41
- - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
42
- when: always
43
- variables:
44
- PR_NUMBER: '$CI_MERGE_REQUEST_IID'
45
- HEAD_SHA: '$CI_COMMIT_SHA'
46
- BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
40
+ - if: '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
47
41
  script:
48
- - git checkout --quiet --detach "$BASE_SHA"
49
- - chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
42
+ - chant components pr-post --open --env prod --output .chant/pr --forge gitlab
50
43
 
51
44
  record:
52
45
  stage: record
@@ -1,6 +1,7 @@
1
1
  # chant Op waves "migrations": one stage per wave, each needing the one before.
2
2
  # A wave that waits at its gate exits 3 and stops the waves after it; approve the
3
- # digest it prints, then retry the job.
3
+ # digest it prints, then retry the job. A job whose runs failed exits 1, an allowed
4
+ # failure, and the waves after it run what does not depend on those runs.
4
5
 
5
6
  stages:
6
7
  - wave-1-dev
@@ -19,6 +20,14 @@ wave-1-dev:
19
20
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
20
21
  script:
21
22
  - chant run wave --spec waves.json --wave 1
23
+ allow_failure:
24
+ exit_codes:
25
+ - 1
26
+ artifacts:
27
+ when: always
28
+ paths:
29
+ - .chant/op-waves/migrations/outcomes/
30
+ expire_in: '30 days'
22
31
 
23
32
  wave-2-prod-decide:
24
33
  stage: wave-2-prod
@@ -37,10 +46,14 @@ wave-2-prod-decide:
37
46
  - wave-1-dev
38
47
  script:
39
48
  - chant run wave --spec waves.json --wave 2 --decide
49
+ allow_failure:
50
+ exit_codes:
51
+ - 1
40
52
  artifacts:
41
53
  when: always
42
54
  paths:
43
55
  - .chant/op-waves/migrations/wave-2.json
56
+ - .chant/op-waves/migrations/outcomes/
44
57
  expire_in: '30 days'
45
58
 
46
59
  wave-2-prod-us:
@@ -64,6 +77,14 @@ wave-2-prod-us:
64
77
  script:
65
78
  - echo us
66
79
  - chant run wave --spec waves.json --wave 2 --group us
80
+ allow_failure:
81
+ exit_codes:
82
+ - 1
83
+ artifacts:
84
+ when: always
85
+ paths:
86
+ - .chant/op-waves/migrations/outcomes/
87
+ expire_in: '30 days'
67
88
 
68
89
  wave-2-prod-eu:
69
90
  stage: wave-2-prod
@@ -84,3 +105,11 @@ wave-2-prod-eu:
84
105
  - wave-2-prod-decide
85
106
  script:
86
107
  - chant run wave --spec waves.json --wave 2 --group eu
108
+ allow_failure:
109
+ exit_codes:
110
+ - 1
111
+ artifacts:
112
+ when: always
113
+ paths:
114
+ - .chant/op-waves/migrations/outcomes/
115
+ expire_in: '30 days'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `reviewAgent` on GitLab (#4326): refused by name. A merge request pipeline
3
+ * hands every job the project's unprotected variables, so a review job there
4
+ * cannot be kept apart from a token that writes.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
9
+
10
+ describe("generateGitlabOpPipeline: reviewAgent", () => {
11
+ test("is refused, naming the Op and the way out", () => {
12
+ expect(() => generateGitlabOpPipeline([{ name: "pr-review", trigger: { kind: "pull_request" }, reviewAgent: true }])).toThrow(
13
+ /Scheduled Op "pr-review" sets reviewAgent.*unprotected variables.*findingMode "comment".*GitHub or Forgejo/s,
14
+ );
15
+ });
16
+
17
+ test("an Op without it generates as before", () => {
18
+ const out = generateGitlabOpPipeline([{ name: "pr-review", trigger: { kind: "pull_request" }, findingMode: "comment" }]);
19
+ expect(out.jobs.map((j) => j.jobName)).toEqual(["pr-review"]);
20
+ });
21
+ });
@@ -202,6 +202,24 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
202
202
  );
203
203
  }
204
204
 
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
+
205
223
  /**
206
224
  * The follow-up line an Op with an `environment` adds under its own header
207
225
  * line (#2257). The `environment:` key on the job binds it to the
@@ -293,6 +311,7 @@ export function generateGitlabOpPipeline(
293
311
  const findingMode = spec.findingMode ?? "report";
294
312
  const trigger = resolveOpTrigger(spec);
295
313
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
314
+ assertNoReviewSplit(spec);
296
315
  if (trigger.kind === "cron") anyCron = true;
297
316
 
298
317
  const jobName = jobPrefix + ciJobName(spec.name);
@@ -3,8 +3,16 @@
3
3
  *
4
4
  * One file, one stage per wave, on a push to the spec's branches. Each job is
5
5
  * one of core's `opWaveJobs`, and `needs:` chains them so a wave that waits at
6
- * its gate (exit 3) or fails stops every later wave; retrying the failed job
7
- * after `chant approve` carries on. `GIT_DEPTH: "0"` gives the runner the base
6
+ * its gate (exit 3) stops every later wave; retrying the failed job after
7
+ * `chant approve` carries on, and the jobs it skipped run after it.
8
+ *
9
+ * A job that exits 1 (a run failed, or was blocked) is an allowed failure
10
+ * (`allow_failure: exit_codes: [1]`, #4322), so the next wave's jobs run
11
+ * after it and block only the runs that depend on a failed one. Every job
12
+ * keeps the outcome directory as an artifact, holding the outcome files it
13
+ * received through `needs:` and its own, so each later job reads every
14
+ * earlier one. Any other non-zero exit still stops the waves after it.
15
+ * `GIT_DEPTH: "0"` gives the runner the base
8
16
  * commit it reads the gate policy at. A wide wave's deciding job keeps its
9
17
  * decision as an artifact, which GitLab hands each share job through `needs:`.
10
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
@@ -47,7 +55,7 @@ import {
47
55
  nonEmpty,
48
56
  pushBranches,
49
57
  } from "@intentius/chant/ci-jobs";
50
- import { opWaveGroupOf, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
58
+ import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
51
59
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
52
60
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
53
61
  import { emitGitlabFile } from "./ci-jobs";
@@ -119,7 +127,9 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
119
127
  rules,
120
128
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
121
129
  script: [...(cloud ? gitlabTradeLines() : []), ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
122
- ...(job.kind === "decide" ? { artifacts: { when: "always", paths: [job.record!], expire_in: "30 days" } } : {}),
130
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
131
+ allow_failure: { exit_codes: [1] },
132
+ artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
123
133
  },
124
134
  ]);
125
135
  }
@@ -138,7 +148,8 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
138
148
  const header = [
139
149
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
140
150
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
141
- "# digest it prints, then retry the job.",
151
+ "# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
152
+ "# failure, and the waves after it run what does not depend on those runs.",
142
153
  ...(spec.resume
143
154
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
144
155
  : []),
@@ -53,6 +53,8 @@ import {
53
53
  prApplyWaveJobName,
54
54
  prLoopJobs,
55
55
  GITLAB_APPLY_PR_VAR,
56
+ GITLAB_PLAN_NOTES_VAR,
57
+ gitlabPlanJobName,
56
58
  prLoopPostJob,
57
59
  prLoopSetup,
58
60
  PR_PLAN_NOTE_JOB,
@@ -313,12 +315,16 @@ function gatedWaveGitlabPipeline(
313
315
  * The plan job holds no forge token while the merge request's code runs
314
316
  * (#4295, `@intentius/chant/ci-handover`): its script starts by unsetting
315
317
  * the token variables, and `pr-plan --no-status` writes the note and the
316
- * status into its report. The `plan-note` job after it, which GitLab hands
317
- * the report artifact, checks out the target branch's commit and posts them
318
- * with `chant components pr-post`. A CI/CD variable reaches every job of a
319
- * merge request pipeline, so `unset` keeps it from the plan's code, and
320
- * `CHANT_FORGE_TOKEN` as a protected variable keeps it from the pipeline
321
- * altogether.
318
+ * status into its report, which it keeps as its artifact. GitLab builds a
319
+ * merge request pipeline from the merge request's own files, so no job in it
320
+ * may hold the token (#4328): `CHANT_FORGE_TOKEN` and `CHANT_RUNNER_KEY` are
321
+ * protected variables, which a merge request pipeline never gets. The
322
+ * `plan-note` job runs in a pipeline of the target branch, built from that
323
+ * branch's files, that a pipeline schedule (or a person) starts with
324
+ * `CHANT_PLAN_NOTES` set to `1`. It runs `chant components pr-post --open`,
325
+ * which asks GitLab for each open merge request's plan job, reads the report
326
+ * from its artifacts and posts the note and the status for each job that
327
+ * settled since the last run.
322
328
  *
323
329
  * With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
324
330
  * and apply jobs take an `id_tokens:` entry per cloud, and each script
@@ -342,6 +348,16 @@ function gitlabApplyPrRule(branch: string): string {
342
348
  return `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${GITLAB_APPLY_PR_VAR} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${branch}`;
343
349
  }
344
350
 
351
+ /**
352
+ * The rule of the plan-note job (#4328): a pipeline of `branch` that a
353
+ * pipeline schedule, a person or a trigger started with `CHANT_PLAN_NOTES`
354
+ * set to `1`. A merge request pipeline's own trigger job starts a pipeline
355
+ * whose source is `pipeline`, which this rule leaves out.
356
+ */
357
+ function gitlabPlanNotesRule(branch: string): string {
358
+ return `$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $${GITLAB_PLAN_NOTES_VAR} == "1" && $CI_COMMIT_BRANCH == ${branch}`;
359
+ }
360
+
345
361
  /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
346
362
  const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
347
363
 
@@ -358,7 +374,8 @@ function prLoopGitlabPipeline(
358
374
  const rooted = !member || member.dir === "." || member.dir === "";
359
375
  const [plan, apply, record] = prLoopJobs("gitlab", env, loop, member?.name);
360
376
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
361
- const planJob = member ? `${member.name}-plan` : "plan";
377
+ // #4328: the plan-note job's pr-post --open finds the plan job by this name.
378
+ const planJob = gitlabPlanJobName(member?.name);
362
379
  const named = (job: string) => (member ? `${member.name}-${job}` : job);
363
380
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
364
381
  // #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
@@ -372,6 +389,8 @@ function prLoopGitlabPipeline(
372
389
  if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
373
390
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
374
391
  // #4301: the apply before merge runs in a pipeline of the target branch a person starts with CHANT_APPLY_PR set.
392
+ // #4328: the plan notes are posted from a pipeline of the target branch, never from the merge request's own.
393
+ const onPlanNotes = gitlabPlanNotesRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
375
394
  const onGreen = loop.applyBeforeMerge ? gitlabApplyPrRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH") : GITLAB_GREEN_TAG_RULE;
376
395
  // A member's jobs start in its directory, after the image's install lines.
377
396
  const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
@@ -393,7 +412,7 @@ function prLoopGitlabPipeline(
393
412
  const doc: Record<string, unknown> = {
394
413
  // Included files merge their top-level keys, so a member's file carries
395
414
  // the rules every member shares and no name: two names would collide.
396
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
415
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, { if: onPlanNotes }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
397
416
  stages,
398
417
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
399
418
  [planJob]: {
@@ -407,14 +426,12 @@ function prLoopGitlabPipeline(
407
426
  script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
408
427
  artifacts: { when: "always", paths: [reportPath] },
409
428
  },
410
- // #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
429
+ // #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.
411
430
  [noteJob]: {
412
431
  stage: PR_PLAN_NOTE_JOB,
413
432
  image,
414
- needs: [planJob],
415
- rules: [{ if: onMergeRequest, when: "always" }],
416
- variables: post.env,
417
- script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
433
+ rules: [{ if: onPlanNotes }],
434
+ script: [...enter, ...beforeScript, post.command],
418
435
  },
419
436
  ...Object.fromEntries(
420
437
  applies.map(({ job, name, stage }, i) => [
@@ -449,7 +466,7 @@ function prLoopGitlabPipeline(
449
466
  };
450
467
  const jobs: GeneratedJob[] = [
451
468
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
452
- { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
469
+ { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
453
470
  ...applies.map(({ job, name, stage }, i) => ({
454
471
  jobName: name,
455
472
  component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
@@ -539,7 +556,8 @@ function prEnvGitlabJobs(
539
556
  [named("env-sweep")]: {
540
557
  stage: PR_ENV_STAGE,
541
558
  image,
542
- rules: [{ if: GITLAB_SCHEDULE_RULE }],
559
+ // #4328: not in the plan notes' schedule, which runs far more often than a sweep needs.
560
+ rules: [{ if: `${GITLAB_SCHEDULE_RULE} && $${GITLAB_PLAN_NOTES_VAR} != "1"` }],
543
561
  resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
544
562
  ...trading,
545
563
  variables: { ...sweep.env, ...tradeVars },
@@ -55,8 +55,8 @@ describe("the GitLab merge-request pipeline with a merge request environment", (
55
55
  expect(down.resource_group).toBe(up.resource_group);
56
56
  });
57
57
 
58
- test("env-sweep runs from a pipeline schedule, which the workflow rules let start", () => {
59
- expect(doc["env-sweep"]!.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "schedule"' }]);
58
+ test("env-sweep runs from a pipeline schedule other than the plan notes' (#4328), which the workflow rules let start", () => {
59
+ expect(doc["env-sweep"]!.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_PLAN_NOTES != "1"' }]);
60
60
  expect(doc.workflow.rules).toContainEqual({ if: '$CI_PIPELINE_SOURCE == "schedule"' });
61
61
  expect(doc["env-sweep"]!.script.at(-1)).toBe("chant components pr-env sweep --forge gitlab --output .chant/pr-env");
62
62
  });
@@ -13,7 +13,7 @@ import { describe, expect, test } from "vitest";
13
13
  import { parseYAML } from "@intentius/chant/yaml";
14
14
  import type { DriverComponent } from "@intentius/chant/components/driver";
15
15
  import { generateGitlabPipeline } from "./generate-pipeline";
16
- import { greenCheckout, PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
16
+ import { gitlabPlanJobName, greenCheckout, PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
17
17
  import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
18
18
 
19
19
  const ESTATE: DriverComponent[] = [
@@ -129,7 +129,8 @@ describe("the GitLab merge-request pipeline", () => {
129
129
  expect(mdoc.plan).toBeUndefined();
130
130
  // Included files merge top-level keys: no name, so members do not collide on it.
131
131
  expect(mdoc.workflow.name).toBeUndefined();
132
- expect(mdoc.workflow.rules).toHaveLength(3);
132
+ // The merge request, the push and the plan notes' pipeline (#4328), and the green tag.
133
+ expect(mdoc.workflow.rules).toHaveLength(4);
133
134
  const plan = mdoc["network-plan"] as Job;
134
135
  const apply = mdoc["network-apply"] as Job;
135
136
  expect(plan.script).toContain("cd infra/network");
@@ -143,6 +144,11 @@ describe("the GitLab merge-request pipeline", () => {
143
144
  expect(plan.artifacts).toEqual({ when: "always", paths: ["infra/network/.chant/pr"] });
144
145
  expect(apply.artifacts).toEqual({ when: "always", paths: ["infra/network/.chant/pr"] });
145
146
  expect(apply.cache).toEqual({ key: "chant-apply.network.prod-$CI_COMMIT_SHA", paths: ["infra/network/.chant/pr-resume/pr-apply.json"], when: "always" });
147
+ // #4328: the member's plan-note job finds the member's plan job by name and reads its artifact from the member's directory.
148
+ const note = mdoc["network-plan-note"] as Job;
149
+ expect(note.script.slice(0, 1)).toEqual(["cd infra/network"]);
150
+ expect(note.script.at(-1)).toBe("chant components pr-post --open --env prod --output .chant/pr --forge gitlab --member network");
151
+ expect(gitlabPlanJobName("network")).toBe("network-plan");
146
152
  });
147
153
  });
148
154
 
@@ -239,14 +245,16 @@ describe("the GitLab plan job holds no forge token while the merge request's cod
239
245
  expect(prLoopJobs("gitlab", "prod")[0]!.command.endsWith(" --no-status")).toBe(true);
240
246
  });
241
247
 
242
- test("the plan-note job takes the plan's artifact, checks out the target branch's commit, and posts", () => {
248
+ test("the plan-note job runs in a pipeline of the target branch, never the merge request's, and posts every open merge request's plan (#4328)", () => {
243
249
  const note = doc["plan-note"]!;
244
250
  expect(note.stage).toBe("plan-note");
245
- expect(note.needs).toEqual(["plan"]);
246
- expect(note.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: "always" }]);
247
- expect(note.variables).toEqual({ PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" });
248
- expect(note.script[0]).toBe('git checkout --quiet --detach "$BASE_SHA"');
249
- expect(note.script.at(-1)).toBe('chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab');
251
+ expect(note.needs).toBeUndefined();
252
+ const rule = '$CI_PIPELINE_SOURCE =~ /^(schedule|web|api|trigger)$/ && $CHANT_PLAN_NOTES == "1" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
253
+ expect(note.rules).toEqual([{ if: rule }]);
254
+ expect(note.rules.some((r) => r.if.includes("merge_request_event"))).toBe(false);
255
+ expect((doc.workflow as unknown as { rules: Array<{ if: string }> }).rules).toContainEqual({ if: rule });
256
+ expect(note.variables).toBeUndefined();
257
+ expect(note.script.at(-1)).toBe("chant components pr-post --open --env prod --output .chant/pr --forge gitlab");
250
258
  // It runs no build, lint or plan: none of the merge request's code.
251
259
  expect(note.script.join("\n")).not.toMatch(/chant (build|lint|ci run)/);
252
260
  expect(result.stages).toEqual(["plan", "plan-note", "record"]);
@@ -65,7 +65,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
65
65
  expect(doc.stages).toEqual(["plan", "plan-note", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
66
66
  expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
67
67
  ["plan", []],
68
- ["plan-note", ["plan"]],
68
+ ["plan-note", []],
69
69
  ["record", []],
70
70
  ["apply-wave-1", []],
71
71
  ["apply-wave-2", ["apply-wave-1"]],
@@ -90,7 +90,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
90
90
  const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" }, member });
91
91
  expect(scoped.jobs.map((j) => [j.jobName, j.needs])).toEqual([
92
92
  ["network-plan", []],
93
- ["network-plan-note", ["network-plan"]],
93
+ ["network-plan-note", []],
94
94
  ["network-record", []],
95
95
  ["network-apply-wave-1", []],
96
96
  ["network-apply-wave-2", ["network-apply-wave-1"]],
package/src/plugin.ts CHANGED
@@ -337,7 +337,8 @@ export const test = new Job({
337
337
  async transform(content: string, opts) {
338
338
  const { transform } = await import("./migrate/from-github/index");
339
339
  const result = await transform(content, {
340
- emit: opts.emit,
340
+ // "sql" is for sql targets (#4399); chant migrate refuses it for this pair.
341
+ emit: opts.emit === "sql" ? undefined : opts.emit,
341
342
  useComposites: opts.useComposites,
342
343
  sourceFile: opts.sourceFile,
343
344
  strict: opts.strict,