@intentius/chant-lexicon-gitlab 0.124.55 → 0.124.57

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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAiBH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAQlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAwMxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkHG;AAiBH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAUlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AA4HhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CA6OxB"}
@@ -120,6 +120,8 @@ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DI
120
120
  import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
121
121
  import { activeProductWords } from "@intentius/chant/ci-product-words";
122
122
  import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs.js";
123
+ import { assertScheduledOpCloud, scheduledOpUpdateJobName } from "@intentius/chant/scheduled-op-cloud";
124
+ import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
123
125
  /**
124
126
  * Default stage every generated job runs in, and the default base of the
125
127
  * generated file's name (#2293). Overridable via `options.opsStage` (and the
@@ -247,6 +249,11 @@ export function generateGitlabOpPipeline(ops, options = {}) {
247
249
  const words = activeProductWords();
248
250
  for (const spec of ops) {
249
251
  const subject = `Scheduled Op "${spec.name}"`;
252
+ // #4631: the job's cloud identities and the update on drift, checked before anything renders.
253
+ assertScheduledOpCloud(spec);
254
+ if (spec.update?.permissions && Object.keys(spec.update.permissions).length > 0) {
255
+ throw new Error(`${subject}: update.permissions has no GitLab form; a GitLab job's token scopes are the project's (contents: write included: whether a job token may push is a project setting), and its cloud identity is update.cloud.`);
256
+ }
250
257
  // #4616: GitLab picks a runner by tags.
251
258
  assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
252
259
  const tags = gitlabTags(spec.runner);
@@ -273,7 +280,10 @@ export function generateGitlabOpPipeline(ops, options = {}) {
273
280
  noteJobs.push(pushName);
274
281
  continue;
275
282
  }
276
- const idTokens = gitlabIdTokens(spec.name, spec.permissions);
283
+ const declaredIdTokens = gitlabIdTokens(spec.name, spec.permissions);
284
+ // #4631: a cloud trade takes an id token per cloud, beside the ones the spec declares.
285
+ const idTokens = spec.cloud ? { ...(declaredIdTokens ?? {}), ...gitlabTradeIdTokens(spec.cloud) } : declaredIdTokens;
286
+ const update = spec.update;
277
287
  const findingMode = spec.findingMode ?? "report";
278
288
  const trigger = resolveOpTrigger(spec);
279
289
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
@@ -313,6 +323,7 @@ export function generateGitlabOpPipeline(ops, options = {}) {
313
323
  // than a caller being able to repoint the gate block's own artifact path.
314
324
  const jobVariables = {
315
325
  ...(spec.variables ?? {}),
326
+ ...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
316
327
  ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
317
328
  };
318
329
  const rulesFor = changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules;
@@ -335,7 +346,10 @@ export function generateGitlabOpPipeline(ops, options = {}) {
335
346
  noteJobs.push(noteName);
336
347
  continue;
337
348
  }
338
- const runLines = driftAgent ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
349
+ // #4631: an update follows a drift check the same way the drift agent does, on the dotenv the run leaves.
350
+ const flagsDrift = driftAgent !== undefined || update !== undefined;
351
+ const runLines = flagsDrift ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
352
+ const driftDir = member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR;
339
353
  doc[jobName] = {
340
354
  stage,
341
355
  image,
@@ -347,17 +361,42 @@ export function generateGitlabOpPipeline(ops, options = {}) {
347
361
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
348
362
  ...nonEmpty("variables", jobVariables),
349
363
  rules: rulesFor,
350
- script: [...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
364
+ // #4631: the trade first, before any line of the checkout's code runs, and the token variables unset by it.
365
+ script: [...(spec.cloud ? gitlabTradeLines() : []), ...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
351
366
  // `when: always` because the run this publishes for is the green one: a
352
367
  // gated apply succeeds, and the block is the only thing that says a
353
368
  // human still has to act. A run that walked through its gate writes no
354
369
  // block, and GitLab reports the empty upload as a warning, not a failure.
355
370
  ...(gated
356
371
  ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
357
- : driftAgent
358
- ? { artifacts: gitlabDriftArtifacts(member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR) }
372
+ : flagsDrift
373
+ ? { artifacts: gitlabDriftArtifacts(driftDir) }
359
374
  : {}),
360
375
  };
376
+ if (update) {
377
+ // #4631: the update's own job, which alone holds the update's identity, after a run that found drift.
378
+ const name = scheduledOpUpdateJobName(jobName);
379
+ opLines.push(`# update: ${name} runs \`${update.command}\` when ${jobName} exits with chant's drift code${update.cloud ? ", holding its own cloud identity" : ""}`);
380
+ doc[name] = {
381
+ stage,
382
+ image,
383
+ ...(tags ? { tags } : {}),
384
+ needs: [{ job: jobName, artifacts: true }],
385
+ ...(update.cloud ? { id_tokens: gitlabTradeIdTokens(update.cloud) } : {}),
386
+ ...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
387
+ ...nonEmpty("variables", { ...(update.variables ?? {}), ...(update.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(update.cloud) } : {}) }),
388
+ rules: rulesFor.map((r) => ({ ...r, when: "always" })),
389
+ script: [
390
+ `if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the update does not run"; exit 0; fi`,
391
+ ...(update.cloud ? gitlabTradeLines() : []),
392
+ ...cdLine,
393
+ ...setupScript,
394
+ ...beforeScript,
395
+ update.command,
396
+ ],
397
+ };
398
+ noteJobs.push(name);
399
+ }
361
400
  if (driftAgent) {
362
401
  const name = `${jobName}-agent`;
363
402
  opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
@@ -18,14 +18,23 @@
18
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
19
19
  * project settings to put a reviewer in front of the job as well.
20
20
  *
21
+ * The wave jobs carry no `resource_group` (ruling for ci-products
22
+ * 2026-10-28, "Ledger lock"), the same as the GitHub and Forgejo waves files
23
+ * carry no concurrency group: a per-wave resource group would keep two
24
+ * pipelines' plunge stacks apart where their resource rows do not meet. A
25
+ * stock Pulumi stack or a Terraform root waits for its row on chant's ledger,
26
+ * oldest commit first, up to the job's `timeout`, which `CHANT_LOCK_WAIT`
27
+ * sits five minutes under; the project's own maximum timeout still caps it.
28
+ *
21
29
  * With `resume` on the spec (#3683), a `<name>-resume` job runs `chant run
22
30
  * resume --op <name>` in scheduled pipelines only: create a pipeline schedule
23
31
  * with the spec's cron. It retries a waiting wave's job once its approval
24
32
  * arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
25
33
  * scope and the Developer role; the job token cannot retry jobs).
26
34
  *
27
- * A `pr-review` wave (#3684) adds `<name>-record-plans`, run in merge request
28
- * pipelines only, which records the digests the merge request's head plans.
35
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
36
+ * `<name>-record-plans`, run in merge request pipelines only, which records
37
+ * the digests the merge request's head plans.
29
38
  *
30
39
  * `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
31
40
  * GitHub runner group is a spec error here. A wave's `jobs` renders its
@@ -49,9 +58,19 @@
49
58
  * unsetting the control repo's token variables and the product's two
50
59
  * secret names; the dispatch step reads the token from the product's
51
60
  * variable alone and hands it to curl on its stdin (#4568).
61
+ *
62
+ * Each wave job's script starts with the apply-job guard (#4624,
63
+ * `@intentius/chant/ci-job-guard`), before the trade: it stops the job
64
+ * unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
65
+ * `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
66
+ * holds each of the job's tags. A refused guard exits chant's `misplaced`
67
+ * (9), outside the job's allowed failure, so the job fails and the waves
68
+ * after it stop.
52
69
  */
53
70
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
54
71
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
72
+ /** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
73
+ export declare const OP_WAVE_ALLOWED_EXIT: 1;
55
74
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
56
75
  export declare function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
57
76
  //# sourceMappingURL=generate-op-waves-pipeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAkBH,OAAO,EASL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAI9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAuIvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAkBH,OAAO,EAaL,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAO9F,yHAAyH;AACzH,eAAO,MAAM,oBAAoB,GAAoB,CAAC;AAEtD,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAuKvH"}
@@ -18,14 +18,23 @@
18
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
19
19
  * project settings to put a reviewer in front of the job as well.
20
20
  *
21
+ * The wave jobs carry no `resource_group` (ruling for ci-products
22
+ * 2026-10-28, "Ledger lock"), the same as the GitHub and Forgejo waves files
23
+ * carry no concurrency group: a per-wave resource group would keep two
24
+ * pipelines' plunge stacks apart where their resource rows do not meet. A
25
+ * stock Pulumi stack or a Terraform root waits for its row on chant's ledger,
26
+ * oldest commit first, up to the job's `timeout`, which `CHANT_LOCK_WAIT`
27
+ * sits five minutes under; the project's own maximum timeout still caps it.
28
+ *
21
29
  * With `resume` on the spec (#3683), a `<name>-resume` job runs `chant run
22
30
  * resume --op <name>` in scheduled pipelines only: create a pipeline schedule
23
31
  * with the spec's cron. It retries a waiting wave's job once its approval
24
32
  * arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
25
33
  * scope and the Developer role; the job token cannot retry jobs).
26
34
  *
27
- * A `pr-review` wave (#3684) adds `<name>-record-plans`, run in merge request
28
- * pipelines only, which records the digests the merge request's head plans.
35
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
36
+ * `<name>-record-plans`, run in merge request pipelines only, which records
37
+ * the digests the merge request's head plans.
29
38
  *
30
39
  * `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
31
40
  * GitHub runner group is a spec error here. A wave's `jobs` renders its
@@ -49,12 +58,25 @@
49
58
  * unsetting the control repo's token variables and the product's two
50
59
  * secret names; the dispatch step reads the token from the product's
51
60
  * variable alone and hands it to curl on its stdin (#4568).
61
+ *
62
+ * Each wave job's script starts with the apply-job guard (#4624,
63
+ * `@intentius/chant/ci-job-guard`), before the trade: it stops the job
64
+ * unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
65
+ * `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
66
+ * holds each of the job's tags. A refused guard exits chant's `misplaced`
67
+ * (9), outside the job's allowed failure, so the job fails and the waves
68
+ * after it stop.
52
69
  */
53
70
  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";
54
- import { opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
71
+ import { opWaveGroupOf, opWaveJobs, opWaveJobTimeout, opWaveOutcomeDir, opWavesControlTarget, opWavesLastJobs, OP_WAVES_APPROVAL_STATUS_VAR, opWavesApprovalStatusCommand, opWavesPostsApprovalStatus, opWavesOutcomeCommand, opWavesRecordPlansCommand, opWavesResumeCommand, } from "@intentius/chant/op/op-waves";
55
72
  import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
56
73
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
74
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
75
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
76
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
57
77
  import { emitGitlabFile } from "./ci-jobs.js";
78
+ /** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
79
+ export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
58
80
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
59
81
  export function generateGitlabOpWavesPipeline(spec, options) {
60
82
  const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
@@ -82,7 +104,9 @@ export function generateGitlabOpWavesPipeline(spec, options) {
82
104
  }
83
105
  }
84
106
  const specTags = gitlabTags(spec.runner);
85
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
107
+ const product = resolveProductWords(options.words ?? {}).product;
108
+ // #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
109
+ const byReview = opWavesPostsApprovalStatus(spec);
86
110
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
87
111
  // #4617: the control repo's token variables, which every job but the dispatch drops first.
88
112
  const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
@@ -92,6 +116,8 @@ export function generateGitlabOpWavesPipeline(spec, options) {
92
116
  if (spec.resume)
93
117
  stages.push("resume");
94
118
  const entries = [["stages", stages]];
119
+ // The ruling for ci-products (2026-10-28): each wave job's timeout, and the lock wait five minutes under it.
120
+ const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
95
121
  const variables = nonEmpty("variables", options.variables).variables;
96
122
  if (variables)
97
123
  entries.push(["variables", variables]);
@@ -107,6 +133,18 @@ export function generateGitlabOpWavesPipeline(spec, options) {
107
133
  script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
108
134
  },
109
135
  ]);
136
+ // #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
137
+ entries.push([
138
+ `${spec.name}-approval-status`,
139
+ {
140
+ stage: "plans",
141
+ image,
142
+ ...(specTags ? { tags: specTags } : {}),
143
+ variables: { ...GITLAB_FULL_HISTORY },
144
+ rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
145
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
146
+ },
147
+ ]);
110
148
  }
111
149
  for (const job of jobs) {
112
150
  const wave = spec.waves[job.wave - 1];
@@ -118,21 +156,32 @@ export function generateGitlabOpWavesPipeline(spec, options) {
118
156
  const cloud = group?.cloud ?? wave.cloud;
119
157
  const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
120
158
  const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
159
+ // #4624: the apply-job guard, the script's first line, before the trade.
160
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
161
+ const guard = jobGuardSpec({
162
+ forge: "gitlab",
163
+ job: opWaveJobLabel(spec.name, job),
164
+ product,
165
+ ...(environment ? { environment: environment.name } : {}),
166
+ branches: pushBranches(spec.branches),
167
+ ...(runner !== undefined ? { runner } : {}),
168
+ });
121
169
  entries.push([
122
170
  job.jobName,
123
171
  {
124
172
  stage: stages[job.wave - 1 + (byReview ? 1 : 0)],
125
173
  image,
126
174
  ...(tags ? { tags } : {}),
127
- resource_group: `${spec.name}-${wave.name}`,
175
+ timeout: `${timeout.minutes} minutes`,
128
176
  ...(environment ? { environment: environmentDoc(environment) } : {}),
129
177
  ...(idTokens ? { id_tokens: idTokens } : {}),
130
- variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
178
+ variables: { ...GITLAB_FULL_HISTORY, ...timeout.env, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
131
179
  rules,
132
180
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
133
- script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
134
- // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
135
- allow_failure: { exit_codes: [1] },
181
+ script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
182
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
183
+ // a refused guard (9, #4624) included.
184
+ allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
136
185
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
137
186
  },
138
187
  ]);
@@ -188,10 +237,15 @@ export function generateGitlabOpWavesPipeline(spec, options) {
188
237
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
189
238
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
190
239
  "# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
191
- "# failure, and the waves after it run what does not depend on those runs.",
240
+ "# failure, and the waves after it run what does not depend on those runs. A job",
241
+ "# whose guard finds it running where the spec did not set it to run exits 9 and",
242
+ "# stops the waves after it.",
192
243
  ...(spec.resume
193
244
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
194
245
  : []),
246
+ ...(byReview
247
+ ? [`# 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.`]
248
+ : []),
195
249
  ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
196
250
  ];
197
251
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
@@ -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;AA8BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
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,11 +31,11 @@
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, prCommentLockJobs, prCommentPreviewJob, 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
39
  import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
40
40
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
41
41
  import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
@@ -374,9 +374,12 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
374
374
  const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
375
375
  const previewName = preview ? named(preview.jobName) : undefined;
376
376
  const previewNoteName = preview ? named(preview.noteJobName) : undefined;
377
- const stages = ["plan", PR_PLAN_NOTE_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] : [])];
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] : [])];
378
381
  const noteJob = named(PR_PLAN_NOTE_JOB);
379
- const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
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 } : {}) });
380
383
  // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
381
384
  const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
382
385
  const doc = {
@@ -401,8 +404,15 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
401
404
  stage: PR_PLAN_NOTE_JOB,
402
405
  image,
403
406
  rules: [{ if: onPlanNotes }],
407
+ ...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
404
408
  script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
405
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
+ : {}),
406
416
  ...Object.fromEntries(commentLocks.map(({ job, name, rule }) => [
407
417
  name,
408
418
  // The target branch's commit, so none of the merge request's code runs here.
@@ -496,6 +506,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
496
506
  const jobs = [
497
507
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
498
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: [] }] : []),
499
510
  ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
500
511
  ...(preview && previewName && previewNoteName
501
512
  ? [
@@ -512,7 +523,18 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
512
523
  ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control.name, needs: [lastGreen] }] : []),
513
524
  ...(prEnv ? prEnv.listed : []),
514
525
  ];
515
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...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]]));
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]]));
516
538
  return { yaml, stages, jobs, env };
517
539
  }
518
540
  /** The stage a pull request environment's jobs run in (#4298). */
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "f5a242ce00bc716bcb44314a1301000b9ecad9187d4591c776829885535369e2",
4
+ "manifest.json": "617558151e681cf5f4ea5df936170cd8ec7739e81744fb3157b8f34f416b02eb",
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": "91aa3c4a176252cd5cd8ab7f02ecef7274e79a95aa85a1bfc31270ef32021897"
58
+ "composite": "841ec4d4eec31c0a7724d61da9bf8b8f1592960dde4938b1b9140fe3ec0d9b7b"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.55",
3
+ "version": "0.124.57",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.124.55",
3
+ "version": "0.124.57",
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.55",
71
- "@intentius/chant-lexicon-github": "^0.124.55"
70
+ "@intentius/chant": "^0.124.57",
71
+ "@intentius/chant-lexicon-github": "^0.124.57"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -1,7 +1,9 @@
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
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
+ # failure, and the waves after it run what does not depend on those runs. A job
5
+ # whose guard finds it running where the spec did not set it to run exits 9 and
6
+ # stops the waves after it.
5
7
 
6
8
  stages:
7
9
  - wave-1-dev
@@ -13,12 +15,15 @@ wave-1-dev:
13
15
  tags:
14
16
  - docker
15
17
  - dev
16
- resource_group: migrations-dev
18
+ timeout: '180 minutes'
17
19
  variables:
18
20
  GIT_DEPTH: '0'
21
+ CHANT_LOCK_WAIT: '10500'
22
+ CHANT_JOB_GUARD: '{"forge":"gitlab","job":"wave 1 (dev) of migrations","branches":["main"],"runnerTags":["docker","dev"]}'
19
23
  rules:
20
24
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
21
25
  script:
26
+ - '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
22
27
  - chant run wave --spec waves.json --wave 1
23
28
  allow_failure:
24
29
  exit_codes:
@@ -34,17 +39,20 @@ wave-2-prod-decide:
34
39
  image: node:22
35
40
  tags:
36
41
  - plan
37
- resource_group: migrations-prod
42
+ timeout: '180 minutes'
38
43
  id_tokens:
39
44
  PLAN_TOKEN:
40
45
  aud: plan.example
41
46
  variables:
42
47
  GIT_DEPTH: '0'
48
+ CHANT_LOCK_WAIT: '10500'
49
+ CHANT_JOB_GUARD: '{"forge":"gitlab","job":"the deciding job of wave 2 (prod) of migrations","branches":["main"],"runnerTags":["plan"]}'
43
50
  rules:
44
51
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
45
52
  needs:
46
53
  - wave-1-dev
47
54
  script:
55
+ - '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
48
56
  - chant run wave --spec waves.json --wave 2 --decide
49
57
  allow_failure:
50
58
  exit_codes:
@@ -61,7 +69,7 @@ wave-2-prod-us:
61
69
  image: node:22
62
70
  tags:
63
71
  - us-apply
64
- resource_group: migrations-prod
72
+ timeout: '180 minutes'
65
73
  environment:
66
74
  name: prod-us
67
75
  id_tokens:
@@ -69,12 +77,15 @@ wave-2-prod-us:
69
77
  aud: us.example
70
78
  variables:
71
79
  GIT_DEPTH: '0'
80
+ CHANT_LOCK_WAIT: '10500'
72
81
  REGION: us
82
+ CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group us of wave 2 (prod) of migrations","forgeEnvironment":"prod-us","branches":["main"],"runnerTags":["us-apply"]}'
73
83
  rules:
74
84
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
75
85
  needs:
76
86
  - wave-2-prod-decide
77
87
  script:
88
+ - '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
78
89
  - echo us
79
90
  - chant run wave --spec waves.json --wave 2 --group us
80
91
  allow_failure:
@@ -91,7 +102,7 @@ wave-2-prod-eu:
91
102
  image: node:22
92
103
  tags:
93
104
  - eu-apply
94
- resource_group: migrations-prod
105
+ timeout: '180 minutes'
95
106
  environment:
96
107
  name: prod-eu
97
108
  id_tokens:
@@ -99,11 +110,14 @@ wave-2-prod-eu:
99
110
  aud: eu.example
100
111
  variables:
101
112
  GIT_DEPTH: '0'
113
+ CHANT_LOCK_WAIT: '10500'
114
+ CHANT_JOB_GUARD: '{"forge":"gitlab","job":"job group eu of wave 2 (prod) of migrations","forgeEnvironment":"prod-eu","branches":["main"],"runnerTags":["eu-apply"]}'
102
115
  rules:
103
116
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
104
117
  needs:
105
118
  - wave-2-prod-decide
106
119
  script:
120
+ - '(cd / && node -e ''"use strict";const E=process.env,A="chant-check-job";const shown=(v)=>v===undefined||v===null||v===""?"none":v;const fail=(m)=>{console.error("check-job: "+m);process.exit(9)};let x;try{x=JSON.parse(E.CHANT_JOB_GUARD||"")}catch(e){fail("CHANT_JOB_GUARD is not JSON")}if(!x||!["github","gitlab","forgejo"].includes(x.forge)||!Array.isArray(x.branches))fail("CHANT_JOB_GUARD is not a guard spec");const tags=(r)=>{if(r===undefined)return undefined;const t=r.trim();if(t.startsWith("[")){try{const v=JSON.parse(t);if(Array.isArray(v))return v.map(String)}catch(e){}}return t===""?[]:t.split(",").map((s)=>s.trim()).filter((s)=>s!=="")};const claimsOf=(tok)=>{const p=tok.split(".")[1];if(!p)throw new Error("the forge\u0027s OIDC token is not a JWT");const c=JSON.parse(Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf-8"));if(!c||typeof c!=="object")throw new Error("the forge\u0027s OIDC token has no claims");return c};async function seenOf(){if(x.forge==="gitlab")return{forgeEnvironment:E.CI_ENVIRONMENT_NAME||null,ref:E.CI_COMMIT_REF_NAME,refProtected:E.CI_COMMIT_REF_PROTECTED,runnerTags:tags(E.CI_RUNNER_TAGS)};const s={ref:E.GITHUB_REF};let c;if(x.forge==="github"&&x.forgeEnvironment!==undefined){try{if(!E.ACTIONS_ID_TOKEN_REQUEST_URL||!E.ACTIONS_ID_TOKEN_REQUEST_TOKEN)throw new Error("the job may request no OIDC token (no ACTIONS_ID_TOKEN_REQUEST_URL); it needs the id-token: write permission");const r=await fetch(E.ACTIONS_ID_TOKEN_REQUEST_URL+"&audience="+encodeURIComponent(A),{headers:{authorization:"bearer "+E.ACTIONS_ID_TOKEN_REQUEST_TOKEN}});if(!r.ok)throw new Error("the OIDC token request answered "+r.status);const v=(await r.json()).value;if(typeof v!=="string")throw new Error("the OIDC token request answered no token");c=claimsOf(v);s.forgeEnvironment=typeof c.environment==="string"&&c.environment!==""?c.environment:null}catch(e){s.forgeEnvironmentError=e&&e.message?e.message:String(e)}}const run=c&&typeof c.runner_environment==="string"?c.runner_environment:E.RUNNER_ENVIRONMENT;if(run)s.runnerEnvironment=run;return s}seenOf().then((s)=>{const P=[],C=[],S=[];const fe=x.forgeEnvironment;if(x.forge==="forgejo")S.push("forge environment (Forgejo has none)");else if(fe!==undefined){if(s.forgeEnvironmentError!==undefined)P.push("forge environment: expected "+fe+", and it could not be read: "+s.forgeEnvironmentError);else if(s.forgeEnvironment!==fe)P.push("forge environment: expected "+fe+", found "+shown(s.forgeEnvironment));else C.push("forge environment "+fe)}const b=x.branches.filter((v)=>v!=="");if(b.length===0)P.push("ref: the job names no branch to run from");else if(x.forge==="gitlab"){if(!s.ref||!b.includes(s.ref))P.push("ref: expected "+b.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref);if(s.refProtected!=="true")P.push("ref: expected a protected ref (CI_COMMIT_REF_PROTECTED true), found "+shown(s.refProtected));else C.push("protected ref")}else{const refs=b.map((v)=>"refs/heads/"+v);if(!s.ref||!refs.includes(s.ref))P.push("ref: expected "+refs.join(" or ")+", found "+shown(s.ref));else C.push("ref "+s.ref)}if(x.forge==="gitlab"){const w=x.runnerTags||[];if(w.length>0){const h=s.runnerTags;const miss=w.filter((t)=>!(h||[]).includes(t));if(h===undefined)P.push("runner: expected tags "+w.join(", ")+", and the job has no CI_RUNNER_TAGS");else if(miss.length>0)P.push("runner: expected tags "+w.join(", ")+", found "+(h.length?h.join(", "):"none"));else C.push("runner tags "+w.join(", "))}}else if(x.selfHosted){if(s.runnerEnvironment===undefined)S.push("runner (the runner says nothing about itself: no RUNNER_ENVIRONMENT)");else if(s.runnerEnvironment!=="self-hosted")P.push("runner: expected self-hosted, found "+s.runnerEnvironment);else C.push("self-hosted runner")}const at=(x.product||"chant")+" check-job: "+x.job;if(P.length>0){for(const p of P)console.error(at+": "+p);console.error(at+": this job does not run where the spec set it to run, so it stops before any credential is taken or code checked out");process.exit(9)}console.log(at+": "+(C.length?C.join(", "):"nothing to check")+(S.length?"; not checked: "+S.join(", "):""))}).catch((e)=>fail(e&&e.message?e.message:String(e)));'')'
107
121
  - chant run wave --spec waves.json --wave 2 --group eu
108
122
  allow_failure:
109
123
  exit_codes:
@@ -0,0 +1,40 @@
1
+ /** #4631: a GitLab scheduled Op job trades first, and an update on drift is a job of its own on the run's dotenv. */
2
+ import { describe, expect, test } from "vitest";
3
+ import { parseYAML } from "@intentius/chant/yaml";
4
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
5
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
6
+
7
+ interface Job { needs?: unknown; rules?: Array<Record<string, unknown>>; id_tokens?: Record<string, { aud: string }>; variables?: Record<string, string>; script: string[]; artifacts?: Record<string, unknown> }
8
+
9
+ const PREVIEW = { aws: { role: "arn:aws:iam::1:role/preview", region: "us-east-1", audience: "sts.amazonaws.com" } };
10
+ const UPDATE = { aws: { role: "arn:aws:iam::1:role/update", region: "us-east-1", audience: "sts.amazonaws.com" } };
11
+ const spec: ScheduledOpSpec = { name: "drift-dev", trigger: { kind: "cron", schedule: "0 6 * * *" }, findingMode: "issue", cloud: PREVIEW, update: { command: "plywood up", cloud: UPDATE } };
12
+
13
+ describe("cloud identities on a GitLab scheduled Op (#4631)", () => {
14
+ const doc = parseYAML(generateGitlabOpPipeline([spec]).files[0]!.yaml) as unknown as Record<string, Job>;
15
+ const check = doc["drift-dev"]!;
16
+ const update = doc["drift-dev-update"]!;
17
+
18
+ test("the check's script trades first and unsets the token variables, with an id token per cloud", () => {
19
+ expect(check.id_tokens).toEqual({ CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
20
+ expect(JSON.parse(check.variables!.CHANT_CLOUD_TRADE!)).toEqual(PREVIEW);
21
+ expect(check.script[0]).toContain("node -e");
22
+ expect(check.script[1]).toMatch(/^unset .*CHANT_ID_TOKEN_AWS/);
23
+ expect(check.script.join("\n")).toContain('echo "CHANT_AGENT_DRIFT=1"');
24
+ expect(check.artifacts).toMatchObject({ reports: { dotenv: ".chant/agent/drift.env" } });
25
+ expect(JSON.stringify(check)).not.toContain("role/update");
26
+ });
27
+
28
+ test("the update job needs the check, stops with no drift, and trades for its own identity", () => {
29
+ expect(update.needs).toEqual([{ job: "drift-dev", artifacts: true }]);
30
+ expect(update.rules!.every((r) => r.when === "always")).toBe(true);
31
+ expect(update.script[0]).toContain('"${CHANT_AGENT_DRIFT:-0}" != "1"');
32
+ expect(JSON.parse(update.variables!.CHANT_CLOUD_TRADE!)).toEqual(UPDATE);
33
+ expect(update.script.at(-1)).toBe("plywood up");
34
+ });
35
+
36
+ test("update.permissions has no GitLab form", () => {
37
+ expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { issues: "write" } } }])).toThrow(/no GitLab form/);
38
+ expect(() => generateGitlabOpPipeline([{ ...spec, update: { command: "x", permissions: { contents: "write" } } }])).toThrow(/no GitLab form/);
39
+ });
40
+ });
@@ -144,6 +144,8 @@ import { assertReviewSplit, GITLAB_REVIEW_NOTES_VAR, gitlabReviewJobs, REVIEW_DI
144
144
  import { AGENT_DIR, resolveAgentSpec } from "@intentius/chant/ci-agent";
145
145
  import { activeProductWords } from "@intentius/chant/ci-product-words";
146
146
  import { gitlabAgentHeader, gitlabAgentJobs, gitlabAgentPushJobName, gitlabDriftArtifacts, gitlabDriftRunLines } from "./agent-jobs";
147
+ import { assertScheduledOpCloud, scheduledOpUpdateJobName } from "@intentius/chant/scheduled-op-cloud";
148
+ import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
147
149
 
148
150
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
149
151
 
@@ -307,6 +309,11 @@ export function generateGitlabOpPipeline(
307
309
 
308
310
  for (const spec of ops) {
309
311
  const subject = `Scheduled Op "${spec.name}"`;
312
+ // #4631: the job's cloud identities and the update on drift, checked before anything renders.
313
+ assertScheduledOpCloud(spec);
314
+ if (spec.update?.permissions && Object.keys(spec.update.permissions).length > 0) {
315
+ throw new Error(`${subject}: update.permissions has no GitLab form; a GitLab job's token scopes are the project's (contents: write included: whether a job token may push is a project setting), and its cloud identity is update.cloud.`);
316
+ }
310
317
  // #4616: GitLab picks a runner by tags.
311
318
  assertRunnerLabels(subject, "runner", spec.runner, "GitLab");
312
319
  const tags = gitlabTags(spec.runner);
@@ -333,7 +340,10 @@ export function generateGitlabOpPipeline(
333
340
  noteJobs.push(pushName);
334
341
  continue;
335
342
  }
336
- const idTokens = gitlabIdTokens(spec.name, spec.permissions);
343
+ const declaredIdTokens = gitlabIdTokens(spec.name, spec.permissions);
344
+ // #4631: a cloud trade takes an id token per cloud, beside the ones the spec declares.
345
+ const idTokens = spec.cloud ? { ...(declaredIdTokens ?? {}), ...gitlabTradeIdTokens(spec.cloud) } : declaredIdTokens;
346
+ const update = spec.update;
337
347
  const findingMode = spec.findingMode ?? "report";
338
348
  const trigger = resolveOpTrigger(spec);
339
349
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
@@ -376,6 +386,7 @@ export function generateGitlabOpPipeline(
376
386
  // than a caller being able to repoint the gate block's own artifact path.
377
387
  const jobVariables: Record<string, string> = {
378
388
  ...(spec.variables ?? {}),
389
+ ...(spec.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(spec.cloud) } : {}),
379
390
  ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
380
391
  };
381
392
 
@@ -400,7 +411,10 @@ export function generateGitlabOpPipeline(
400
411
  continue;
401
412
  }
402
413
 
403
- const runLines = driftAgent ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
414
+ // #4631: an update follows a drift check the same way the drift agent does, on the dotenv the run leaves.
415
+ const flagsDrift = driftAgent !== undefined || update !== undefined;
416
+ const runLines = flagsDrift ? gitlabDriftRunLines(invocation.join(" ")) : [invocation.join(" ")];
417
+ const driftDir = member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR;
404
418
  doc[jobName] = {
405
419
  stage,
406
420
  image,
@@ -412,17 +426,42 @@ export function generateGitlabOpPipeline(
412
426
  ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
413
427
  ...nonEmpty("variables", jobVariables),
414
428
  rules: rulesFor,
415
- script: [...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
429
+ // #4631: the trade first, before any line of the checkout's code runs, and the token variables unset by it.
430
+ script: [...(spec.cloud ? gitlabTradeLines() : []), ...cdLine, ...setupScript, ...beforeScript, ...runLines, ...extraScript],
416
431
  // `when: always` because the run this publishes for is the green one: a
417
432
  // gated apply succeeds, and the block is the only thing that says a
418
433
  // human still has to act. A run that walked through its gate writes no
419
434
  // block, and GitLab reports the empty upload as a warning, not a failure.
420
435
  ...(gated
421
436
  ? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
422
- : driftAgent
423
- ? { artifacts: gitlabDriftArtifacts(member ? memberRepoPath(member, AGENT_DIR) : AGENT_DIR) }
437
+ : flagsDrift
438
+ ? { artifacts: gitlabDriftArtifacts(driftDir) }
424
439
  : {}),
425
440
  };
441
+ if (update) {
442
+ // #4631: the update's own job, which alone holds the update's identity, after a run that found drift.
443
+ const name = scheduledOpUpdateJobName(jobName);
444
+ opLines.push(`# update: ${name} runs \`${update.command}\` when ${jobName} exits with chant's drift code${update.cloud ? ", holding its own cloud identity" : ""}`);
445
+ doc[name] = {
446
+ stage,
447
+ image,
448
+ ...(tags ? { tags } : {}),
449
+ needs: [{ job: jobName, artifacts: true }],
450
+ ...(update.cloud ? { id_tokens: gitlabTradeIdTokens(update.cloud) } : {}),
451
+ ...(update.environment ? { environment: environmentDoc(update.environment) } : {}),
452
+ ...nonEmpty("variables", { ...(update.variables ?? {}), ...(update.cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(update.cloud) } : {}) }),
453
+ rules: rulesFor.map((r) => ({ ...(r as Record<string, unknown>), when: "always" })),
454
+ script: [
455
+ `if [ "\${CHANT_AGENT_DRIFT:-0}" != "1" ]; then echo "no drift, so the update does not run"; exit 0; fi`,
456
+ ...(update.cloud ? gitlabTradeLines() : []),
457
+ ...cdLine,
458
+ ...setupScript,
459
+ ...beforeScript,
460
+ update.command,
461
+ ],
462
+ };
463
+ noteJobs.push(name);
464
+ }
426
465
  if (driftAgent) {
427
466
  const name = `${jobName}-agent`;
428
467
  opLines.push(...gitlabAgentHeader(name, driftAgent, spec.agent?.tokenEnv));
@@ -0,0 +1,28 @@
1
+ /** #4630: GitLab approvals start no pipeline, so a schedule posts every open merge request's approval status. */
2
+ import { describe, expect, test } from "vitest";
3
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
4
+ import { generateGitlabOpWavesPipeline } from "./generate-op-waves-pipeline";
5
+
6
+ const spec: OpWavesSpec = {
7
+ name: "plywood-up",
8
+ op: "plywood-up",
9
+ plan: ["./bin/plan", "{target}", "{plan}"],
10
+ waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pr-review" }],
11
+ };
12
+
13
+ describe("the approval status job on GitLab (#4630)", () => {
14
+ test("runs in a schedule that sets CHANT_APPROVAL_STATUS, with --open", () => {
15
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
16
+ expect(yaml).toContain("plywood-up-approval-status:");
17
+ expect(yaml).toContain('$CI_PIPELINE_SOURCE == "schedule" && $CHANT_APPROVAL_STATUS == "1"');
18
+ expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
19
+ });
20
+
21
+ test("a pull-request wave records its plans and posts the status too (2026-10-28 ruling)", () => {
22
+ const pullRequest: OpWavesSpec = { ...spec, waves: [{ name: "prod", runs: [{ target: "app" }], approval: "pull-request" }], approvals: { pullRequest: { gate: "up" } } };
23
+ const yaml = generateGitlabOpWavesPipeline(pullRequest, { specFile: "waves.json" }).files[0]!.yaml;
24
+ expect(yaml).toContain("plywood-up-record-plans:");
25
+ expect(yaml).toContain("plywood-up-approval-status:");
26
+ expect(yaml).toContain("chant run wave --spec waves.json --approval-status --open --forge gitlab");
27
+ });
28
+ });
@@ -42,7 +42,7 @@ describe("the control dispatch after the last wave on GitLab (#4617)", () => {
42
42
 
43
43
  test("every wave job and the resume job drop the control repo's token variables first", () => {
44
44
  const yaml = generateGitlabOpWavesPipeline(spec, options).files[0]!.yaml;
45
- expect(yaml).toMatch(/wave-1-canary:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n\s+- npx --no-install plywood run wave --spec waves.json --wave 1/);
45
+ expect(yaml).toMatch(/wave-1-canary:[\s\S]*script:\n\s+- [^\n]*node -e[^\n]*\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN\n\s+- npx --no-install plywood run wave --spec waves.json --wave 1/);
46
46
  expect(yaml).toMatch(/stacks-resume:[\s\S]*script:\n\s+- unset CHANT_CONTROL_TOKEN CHANT_CONTROL_READ_TOKEN PLYWOOD_CONTROL_TOKEN/);
47
47
  });
48
48
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The apply-job guard in GitLab op-wave jobs (#4624): the script's first
3
+ * line, before the trade, with the job's expectation in a variable: the
4
+ * environment, the branches and the runner's tags. Written for the
5
+ * ci-products post-merge pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
10
+ import { spawnSync } from "node:child_process";
11
+ import { JOB_GUARD_ENV, JOB_GUARD_JS } from "@intentius/chant/ci-job-guard";
12
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
13
+ import { generateGitlabOpWavesPipeline, OP_WAVE_ALLOWED_EXIT } from "./generate-op-waves-pipeline";
14
+
15
+ const spec: OpWavesSpec = {
16
+ name: "deploy",
17
+ op: "pulumi-up",
18
+ plan: ["./bin/plan", "{target}", "{plan}"],
19
+ branches: ["main"],
20
+ runner: ["deploy"],
21
+ waves: [
22
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
23
+ { name: "prod", runs: [{ target: "prod" }], environment: { name: "production" } },
24
+ ],
25
+ };
26
+
27
+ describe("the apply-job guard on GitLab (#4624)", () => {
28
+ test("the first script line of every wave job", () => {
29
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
30
+ for (const job of ["wave-1-dev", "wave-2-prod"]) {
31
+ const body = yaml.slice(yaml.indexOf(`${job}:`));
32
+ const script = body.slice(body.indexOf("script:"));
33
+ expect(script.split("\n")[1]).toContain(JOB_GUARD_JS.slice(0, 40));
34
+ }
35
+ });
36
+
37
+ test("the variable names the environment, the branches, the tags and the product", () => {
38
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
39
+ const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
40
+ expect(prod).toContain(JOB_GUARD_ENV);
41
+ for (const part of ['"forge":"gitlab"', '"forgeEnvironment":"production"', '"branches":["main"]', '"runnerTags":["deploy"]', '"product":"plywood"', '"job":"wave 2 (prod) of deploy"']) {
42
+ expect(prod).toContain(part);
43
+ }
44
+ });
45
+
46
+ test("a refused guard fails the job and stops the wave (#4624 ruling: fail hard)", () => {
47
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json", words: { product: "plywood" } }).files[0]!.yaml;
48
+ const prod = yaml.slice(yaml.indexOf("wave-2-prod:"));
49
+ expect(prod).toContain(`exit_codes:\n - ${OP_WAVE_ALLOWED_EXIT}`);
50
+ const guard = { forge: "gitlab", job: "wave 2 (prod) of deploy", forgeEnvironment: "production", branches: ["main"], runnerTags: ["deploy"] };
51
+ const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], {
52
+ env: { PATH: process.env.PATH ?? "", [JOB_GUARD_ENV]: JSON.stringify(guard), CI_ENVIRONMENT_NAME: "dev", CI_COMMIT_REF_NAME: "main", CI_COMMIT_REF_PROTECTED: "true", CI_RUNNER_TAGS: "deploy" },
53
+ encoding: "utf-8",
54
+ });
55
+ expect(r.status).toBe(CHANT_EXIT.misplaced);
56
+ expect(r.status).not.toBe(OP_WAVE_ALLOWED_EXIT);
57
+ });
58
+
59
+ test("a guard with no spec to read fails the job too", () => {
60
+ const r = spawnSync(process.execPath, ["-e", JOB_GUARD_JS], { env: { PATH: process.env.PATH ?? "" }, encoding: "utf-8" });
61
+ expect(r.status).toBe(CHANT_EXIT.misplaced);
62
+ });
63
+ });
@@ -95,9 +95,9 @@ describe("a runner per wave and job groups per wave (#3914)", () => {
95
95
  test("tags, environment, id_tokens, variables and the group's command on each job", () => {
96
96
  const yaml = generateGitlabOpWavesPipeline(grouped, { specFile: "waves.json" }).files[0]!.yaml;
97
97
  expect(yaml).toMatch(/wave-1-dev:\n\s+stage: wave-1-dev\n\s+image: node:22\n\s+tags:\n\s+- docker\n\s+- dev\n/);
98
- expect(yaml).toMatch(/wave-2-prod-decide:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- plan\n\s+resource_group: migrations-prod\n\s+id_tokens:\n\s+PLAN_TOKEN:\n\s+aud: plan.example\n/);
98
+ expect(yaml).toMatch(/wave-2-prod-decide:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- plan\n\s+timeout: '180 minutes'\n\s+id_tokens:\n\s+PLAN_TOKEN:\n\s+aud: plan.example\n/);
99
99
  expect(yaml).toMatch(
100
- /wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+resource_group: migrations-prod\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+REGION: us\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
100
+ /wave-2-prod-us:\n\s+stage: wave-2-prod\n\s+image: node:22\n\s+tags:\n\s+- us-apply\n\s+timeout: '180 minutes'\n\s+environment:\n\s+name: prod-us\n\s+id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: us.example\n\s+variables:\n\s+GIT_DEPTH: '0'\n\s+CHANT_LOCK_WAIT: '10500'\n\s+REGION: us\n\s+CHANT_JOB_GUARD: [^\n]*\n[\s\S]*?needs:\n\s+- wave-2-prod-decide\n\s+script:\n\s+- [^\n]*node -e[^\n]*\n\s+- echo us\n\s+- chant run wave --spec waves.json --wave 2 --group us\n/,
101
101
  );
102
102
  expect(yaml).toMatch(/wave-2-prod-eu:[\s\S]*?id_tokens:\n\s+CLOUD_TOKEN:\n\s+aud: eu.example\n/);
103
103
  expect(yaml).not.toContain("_AUDIENCE");
@@ -122,8 +122,10 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
122
122
  for (const [name, ids] of Object.entries(want)) {
123
123
  const job = doc[name]!;
124
124
  expect(job.id_tokens).toEqual({ OTHER_TOKEN: { aud: "other.example" }, CHANT_ID_TOKEN_AWS: { aud: "sts.amazonaws.com" } });
125
- expect(job.script.slice(0, 3)).toEqual(gitlabTradeLines());
126
- expect(job.script[3]).toBe("npm ci");
125
+ // #4624: the apply-job guard first, then the trade.
126
+ expect(job.script[0]).toMatch(/^\(cd \/ && node -e '/);
127
+ expect(job.script.slice(1, 4)).toEqual(gitlabTradeLines());
128
+ expect(job.script[4]).toBe("npm ci");
127
129
  expect(tradeIdentities(JSON.parse(job.variables[TRADE_SPEC_ENV]!))).toEqual(ids);
128
130
  }
129
131
  expect(JSON.stringify(doc["wave-1-prod-decide"])).not.toContain("apply\"");
@@ -147,3 +149,12 @@ describe("an Op wave's jobs by cloud identity (#4332)", () => {
147
149
  expect(jobs.every((j) => j.command[0] === "chant")).toBe(true);
148
150
  });
149
151
  });
152
+
153
+ describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
154
+ test("the wave jobs carry no resource_group; each carries its timeout and the lock wait under it", () => {
155
+ const yaml = generateGitlabOpWavesPipeline(spec, { specFile: "waves.json" }).files[0]!.yaml;
156
+ expect(yaml).not.toContain("resource_group");
157
+ expect(yaml).toContain("timeout: '180 minutes'\n");
158
+ expect(yaml).toContain("CHANT_LOCK_WAIT: '10500'\n");
159
+ });
160
+ });
@@ -18,14 +18,23 @@
18
18
  * A wave's `environment` is the job's own `environment:`; protect it in the
19
19
  * project settings to put a reviewer in front of the job as well.
20
20
  *
21
+ * The wave jobs carry no `resource_group` (ruling for ci-products
22
+ * 2026-10-28, "Ledger lock"), the same as the GitHub and Forgejo waves files
23
+ * carry no concurrency group: a per-wave resource group would keep two
24
+ * pipelines' plunge stacks apart where their resource rows do not meet. A
25
+ * stock Pulumi stack or a Terraform root waits for its row on chant's ledger,
26
+ * oldest commit first, up to the job's `timeout`, which `CHANT_LOCK_WAIT`
27
+ * sits five minutes under; the project's own maximum timeout still caps it.
28
+ *
21
29
  * With `resume` on the spec (#3683), a `<name>-resume` job runs `chant run
22
30
  * resume --op <name>` in scheduled pipelines only: create a pipeline schedule
23
31
  * with the spec's cron. It retries a waiting wave's job once its approval
24
32
  * arrived, with `CHANT_FORGE_TOKEN` (a project access token with the `api`
25
33
  * scope and the Developer role; the job token cannot retry jobs).
26
34
  *
27
- * A `pr-review` wave (#3684) adds `<name>-record-plans`, run in merge request
28
- * pipelines only, which records the digests the merge request's head plans.
35
+ * A `pr-review` wave (#3684), or a `pull-request` wave (#4630), adds
36
+ * `<name>-record-plans`, run in merge request pipelines only, which records
37
+ * the digests the merge request's head plans.
29
38
  *
30
39
  * `runner` (on the spec, a wave or a job group, #3914) is the job's `tags:`; a
31
40
  * GitHub runner group is a spec error here. A wave's `jobs` renders its
@@ -49,6 +58,14 @@
49
58
  * unsetting the control repo's token variables and the product's two
50
59
  * secret names; the dispatch step reads the token from the product's
51
60
  * variable alone and hands it to curl on its stdin (#4568).
61
+ *
62
+ * Each wave job's script starts with the apply-job guard (#4624,
63
+ * `@intentius/chant/ci-job-guard`), before the trade: it stops the job
64
+ * unless `CI_COMMIT_REF_NAME` is one of the spec's branches and protected,
65
+ * `CI_ENVIRONMENT_NAME` is the job's environment, and `CI_RUNNER_TAGS`
66
+ * holds each of the job's tags. A refused guard exits chant's `misplaced`
67
+ * (9), outside the job's allowed failure, so the job fails and the waves
68
+ * after it stop.
52
69
  */
53
70
 
54
71
  import {
@@ -70,9 +87,13 @@ import {
70
87
  import {
71
88
  opWaveGroupOf,
72
89
  opWaveJobs,
90
+ opWaveJobTimeout,
73
91
  opWaveOutcomeDir,
74
92
  opWavesControlTarget,
75
93
  opWavesLastJobs,
94
+ OP_WAVES_APPROVAL_STATUS_VAR,
95
+ opWavesApprovalStatusCommand,
96
+ opWavesPostsApprovalStatus,
76
97
  opWavesOutcomeCommand,
77
98
  opWavesRecordPlansCommand,
78
99
  opWavesResumeCommand,
@@ -81,8 +102,14 @@ import {
81
102
  import { CONTROL_OUTCOME_VAR, CONTROL_TOKEN_VARS, controlDispatchJob } from "@intentius/chant/control/dispatch";
82
103
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
83
104
  import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
105
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
106
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
107
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
84
108
  import { emitGitlabFile } from "./ci-jobs";
85
109
 
110
+ /** The one exit an op-wave job allows as a failure (#4322): a run that failed. The guard's refusal (9) is outside it. */
111
+ export const OP_WAVE_ALLOWED_EXIT = CHANT_EXIT.failed;
112
+
86
113
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
87
114
  export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
88
115
  const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
@@ -107,8 +134,10 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
107
134
  }
108
135
  }
109
136
  const specTags = gitlabTags(spec.runner);
137
+ const product = resolveProductWords(options.words ?? {}).product;
110
138
 
111
- const byReview = spec.waves.some((w) => w.approval === "pr-review");
139
+ // #4630: a pr-review or pull-request wave records each head's plans and posts the approval status.
140
+ const byReview = opWavesPostsApprovalStatus(spec);
112
141
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
113
142
  // #4617: the control repo's token variables, which every job but the dispatch drops first.
114
143
  const control = spec.control ? opWavesControlTarget(spec.control) : undefined;
@@ -116,6 +145,8 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
116
145
  if (control) stages.push("control");
117
146
  if (spec.resume) stages.push("resume");
118
147
  const entries: Array<readonly [string, unknown]> = [["stages", stages]];
148
+ // The ruling for ci-products (2026-10-28): each wave job's timeout, and the lock wait five minutes under it.
149
+ const timeout = opWaveJobTimeout(options.jobTimeoutMinutes);
119
150
  const variables = nonEmpty("variables", options.variables).variables;
120
151
  if (variables) entries.push(["variables", variables]);
121
152
  if (byReview) {
@@ -130,6 +161,18 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
130
161
  script: [...dropControl, ...(options.beforeScript ?? []), opWavesRecordPlansCommand(options.specFile, options.bin).join(" ")],
131
162
  },
132
163
  ]);
164
+ // #4630: a GitLab approval starts no pipeline, so a schedule of the target branch posts every open merge request's status.
165
+ entries.push([
166
+ `${spec.name}-approval-status`,
167
+ {
168
+ stage: "plans",
169
+ image,
170
+ ...(specTags ? { tags: specTags } : {}),
171
+ variables: { ...GITLAB_FULL_HISTORY },
172
+ rules: [{ if: `${GITLAB_SCHEDULE_SOURCE} && $${OP_WAVES_APPROVAL_STATUS_VAR} == "1"` }],
173
+ script: [...dropControl, ...(options.beforeScript ?? []), opWavesApprovalStatusCommand(options.specFile, "gitlab", options.bin).join(" ")],
174
+ },
175
+ ]);
133
176
  }
134
177
  for (const job of jobs) {
135
178
  const wave = spec.waves[job.wave - 1]!;
@@ -141,21 +184,32 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
141
184
  const cloud = group?.cloud ?? wave.cloud;
142
185
  const declared = gitlabIdTokensDoc(group?.idTokens ?? wave.idTokens);
143
186
  const idTokens = cloud ? { ...declared, ...gitlabTradeIdTokens(cloud) } : declared;
187
+ // #4624: the apply-job guard, the script's first line, before the trade.
188
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
189
+ const guard = jobGuardSpec({
190
+ forge: "gitlab",
191
+ job: opWaveJobLabel(spec.name, job),
192
+ product,
193
+ ...(environment ? { environment: environment.name } : {}),
194
+ branches: pushBranches(spec.branches),
195
+ ...(runner !== undefined ? { runner } : {}),
196
+ });
144
197
  entries.push([
145
198
  job.jobName,
146
199
  {
147
200
  stage: stages[job.wave - 1 + (byReview ? 1 : 0)],
148
201
  image,
149
202
  ...(tags ? { tags } : {}),
150
- resource_group: `${spec.name}-${wave.name}`,
203
+ timeout: `${timeout.minutes} minutes`,
151
204
  ...(environment ? { environment: environmentDoc(environment) } : {}),
152
205
  ...(idTokens ? { id_tokens: idTokens } : {}),
153
- variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}) },
206
+ variables: { ...GITLAB_FULL_HISTORY, ...timeout.env, ...(wave.variables ?? {}), ...(group?.variables ?? {}), ...(cloud ? { [TRADE_SPEC_ENV]: JSON.stringify(cloud) } : {}), [JOB_GUARD_ENV]: JSON.stringify(guard) },
154
207
  rules,
155
208
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
156
- script: [...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
157
- // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it.
158
- allow_failure: { exit_codes: [1] },
209
+ script: [jobGuardLine(), ...(cloud ? gitlabTradeLines() : []), ...dropControl, ...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
210
+ // #4322: a run that failed, or was blocked, lets the next wave run; any other failure stops it,
211
+ // a refused guard (9, #4624) included.
212
+ allow_failure: { exit_codes: [OP_WAVE_ALLOWED_EXIT] },
159
213
  artifacts: { when: "always", paths: [...(job.kind === "decide" ? [job.record!] : []), `${opWaveOutcomeDir(spec)}/`], expire_in: "30 days" },
160
214
  },
161
215
  ]);
@@ -211,10 +265,15 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
211
265
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
212
266
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
213
267
  "# digest it prints, then retry the job. A job whose runs failed exits 1, an allowed",
214
- "# failure, and the waves after it run what does not depend on those runs.",
268
+ "# failure, and the waves after it run what does not depend on those runs. A job",
269
+ "# whose guard finds it running where the spec did not set it to run exits 9 and",
270
+ "# stops the waves after it.",
215
271
  ...(spec.resume
216
272
  ? [`# The ${spec.name}-resume job retries it for you in a pipeline schedule; give the schedule the cron "${spec.resume.schedule}".`]
217
273
  : []),
274
+ ...(byReview
275
+ ? [`# 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.`]
276
+ : []),
218
277
  ...(control ? [`# After the last wave, the control-dispatch job tells ${control.repo} how the waves ended.`] : []),
219
278
  ];
220
279
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
@@ -34,12 +34,14 @@
34
34
 
35
35
  import {
36
36
  assertGitlabBranch,
37
+ assertRunnerLabels,
37
38
  CI_IMAGE,
38
39
  ciJobName,
39
40
  fillCommand,
40
41
  GITLAB_FULL_HISTORY,
41
42
  GITLAB_MERGE_REQUEST_SOURCE,
42
43
  gitlabPushRule,
44
+ gitlabTags,
43
45
  } from "@intentius/chant/ci-jobs";
44
46
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
45
47
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
@@ -65,6 +67,10 @@ import {
65
67
  prCommentPreviewJob,
66
68
  PR_ENV_REPORT_DIR,
67
69
  PR_ENV_POST_JOB,
70
+ PR_STALE_JOB,
71
+ prStaleJob,
72
+ prLoopJobStage,
73
+ prLoopRunner,
68
74
  } from "@intentius/chant/components/pr-pipeline";
69
75
  import { REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
70
76
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
@@ -441,9 +447,12 @@ function prLoopGitlabPipeline(
441
447
  const previewRule = preview ? `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${preview.gitlabVar} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${targetBranch}` : undefined;
442
448
  const previewName = preview ? named(preview.jobName) : undefined;
443
449
  const previewNoteName = preview ? named(preview.noteJobName) : undefined;
444
- const stages = ["plan", PR_PLAN_NOTE_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] : [])];
450
+ // #4628: a push to the target branch marks the open merge requests' notes posted against another commit stale.
451
+ const stale = prStaleJob("gitlab", env, loop, member?.name);
452
+ const staleName = stale ? named(PR_STALE_JOB) : undefined;
453
+ 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] : [])];
445
454
  const noteJob = named(PR_PLAN_NOTE_JOB);
446
- const post = prLoopPostJob("gitlab", env, member?.name, loop.words ? { ...(loop.words.bin ? { bin: loop.words.bin } : {}) } : undefined);
455
+ 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 } : {}) });
447
456
  // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
448
457
  const controlRead = loop.control ? controlNoteCheckout("gitlab", loop.control.target, loop.words ?? {}) : undefined;
449
458
  const doc: Record<string, unknown> = {
@@ -468,8 +477,15 @@ function prLoopGitlabPipeline(
468
477
  stage: PR_PLAN_NOTE_JOB,
469
478
  image,
470
479
  rules: [{ if: onPlanNotes }],
480
+ ...(Object.keys(post.env).length > 0 ? { variables: post.env } : {}),
471
481
  script: [...enter, ...beforeScript, ...(controlRead ? [controlRead.script] : []), post.command],
472
482
  },
483
+ ...(stale && staleName
484
+ ? {
485
+ // The pushed commit of the target branch: none of a merge request's code is here.
486
+ [staleName]: { stage: PR_STALE_JOB, image, rules: [{ if: onMerge }], variables: stale.env, script: [...enter, ...beforeScript, stale.command] },
487
+ }
488
+ : {}),
473
489
  ...Object.fromEntries(
474
490
  commentLocks.map(({ job, name, rule }) => [
475
491
  name,
@@ -567,6 +583,7 @@ function prLoopGitlabPipeline(
567
583
  const jobs: GeneratedJob[] = [
568
584
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
569
585
  { jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [] },
586
+ ...(stale && staleName ? [{ jobName: staleName, component: "merge request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
570
587
  ...commentLocks.map(({ job, name }) => ({ jobName: name, component: `merge request ${job.command} comment`, stage: job.jobName, needs: [] })),
571
588
  ...(preview && previewName && previewNoteName
572
589
  ? [
@@ -583,7 +600,16 @@ function prLoopGitlabPipeline(
583
600
  ...(controlJob ? [{ jobName: controlJob, component: "control repo dispatch", stage: control!.name, needs: [lastGreen!] }] : []),
584
601
  ...(prEnv ? prEnv.listed : []),
585
602
  ];
586
- const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...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]] as const));
603
+ // #4625: each job on its stage's runner tags, the plan side's, the apply side's, or the default.
604
+ if (loop.runners) {
605
+ for (const stage of ["plan", "apply", "default"] as const) assertRunnerLabels("The merge-request pipeline", `runners.${stage}`, loop.runners[stage], "GitLab");
606
+ for (const job of jobs) {
607
+ const tags = gitlabTags(prLoopRunner(loop, prLoopJobStage(job.jobName, member?.name)));
608
+ const entry = doc[job.jobName] as Record<string, unknown> | undefined;
609
+ if (tags && entry) doc[job.jobName] = { ...entry, tags };
610
+ }
611
+ }
612
+ 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]] as const));
587
613
  return { yaml, stages, jobs, env };
588
614
  }
589
615
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The preview comment's --target on GitLab (#4626): the preview job runs
3
+ * `pr-plan --comment-targets`, which reads CHANT_PREVIEW_COMMENT, a variable
4
+ * of the target-branch pipeline. Written for the ci-products post-merge pass.
5
+ */
6
+ import { expect, test } from "vitest";
7
+ import { parseYAML } from "@intentius/chant/yaml";
8
+ import type { DriverComponent } from "@intentius/chant/components/driver";
9
+ import { generateGitlabPipeline } from "./generate-pipeline";
10
+
11
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
12
+ const WORDS = { product: "plywood", stages: { plan: "preview", apply: "up" }, commands: { preview: "plan" as const }, bin: "npx --no-install plywood" };
13
+
14
+ test("the preview job runs pr-plan --comment-targets (#4626)", () => {
15
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { previewComment: true, words: WORDS } }).yaml) as Record<string, { script: string[]; variables: Record<string, string> }>;
16
+ const preview = doc["comment-preview"]!;
17
+ expect(preview.script.join("\n")).toContain("--comment-targets");
18
+ expect(preview.variables).not.toHaveProperty("CHANT_PREVIEW_COMMENT");
19
+ expect(doc.plan!.script.join("\n")).not.toContain("--comment-targets");
20
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The pull request loop's responses and description check on GitLab
3
+ * (#4629), as `chant build --pr-loop --respond --decide-backend
4
+ * --decide-model` sets them: `pr-post --open` asks the decide service, and
5
+ * the apply before merge carries CHANT_RESPOND and the merge (#4636).
6
+ */
7
+ import { expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateGitlabPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+
14
+ type Job = { variables?: Record<string, string>; script?: string[] };
15
+
16
+ test("the note and apply jobs carry the responses, the decide service and the merge (#4629, #4636)", () => {
17
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, {
18
+ env: "prod",
19
+ prLoop: { applyBeforeMerge: true, applyMerge: { tokenEnv: "CHANT_MERGE_TOKEN" }, respond: { description: "check" }, decide: { backend: "review", model: "m-1" } },
20
+ }).yaml) as Record<string, Job>;
21
+ const note = doc["plan-note"]!;
22
+ expect(note.script!.at(-1)).toMatch(/pr-post --open .* --decide-backend "review" --decide-model "m-1"$/);
23
+ expect(note.variables!.CHANT_RESPOND).toBe("description=check");
24
+ const apply = Object.values(doc).find((j) => typeof j === "object" && j?.script?.some((l) => l.includes("--apply-before-merge")))!;
25
+ expect(apply.script!.join("\n")).toContain("--merge auto --merge-token-env CHANT_MERGE_TOKEN");
26
+ expect(apply.variables!.CHANT_RESPOND).toBe("description=check");
27
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The merge request pipeline's runners, variables and passed secrets on
3
+ * GitLab (#4625): each job's `tags` from its stage's runner, and a passed
4
+ * secret mapped from the CI/CD variable when the names differ. Written for
5
+ * the ci-products post-merge pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { generateGitlabPipeline } from "./generate-pipeline";
12
+
13
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
14
+
15
+ type Job = { tags?: string[]; variables: Record<string, string>; script: string[] };
16
+ type Doc = Record<string, Job>;
17
+
18
+ describe("the GitLab merge request pipeline's runners and variables (#4625)", () => {
19
+ const doc = parseYAML(
20
+ generateGitlabPipeline(ESTATE, {
21
+ env: "prod",
22
+ prLoop: { applyOnMerge: true, runners: { plan: "planner", apply: ["deploy", "eu"], default: "small" }, variables: { REGION: "eu" }, secrets: { DB_PASSWORD: "PROD_DB", SAME: "SAME" } },
23
+ }).yaml,
24
+ ) as Doc;
25
+
26
+ test("each job's tags come from its stage", () => {
27
+ expect(doc.plan!.tags).toEqual(["planner"]);
28
+ expect(doc.apply!.tags).toEqual(["deploy", "eu"]);
29
+ expect(doc.record!.tags).toEqual(["deploy", "eu"]);
30
+ expect(doc["plan-note"]!.tags).toEqual(["small"]);
31
+ });
32
+
33
+ test("the plan's variables carry the variable and the renamed secret; a same-named one needs no line", () => {
34
+ expect(doc.plan!.variables).toMatchObject({ REGION: "eu", DB_PASSWORD: "$PROD_DB" });
35
+ expect(doc.plan!.variables).not.toHaveProperty("SAME");
36
+ });
37
+
38
+ test("a runner group is refused", () => {
39
+ expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { runners: { plan: { group: "g" } } } })).toThrow(/runner group/);
40
+ });
41
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Stale marking on GitLab (#4628): the note job's `pr-post --open` passes
3
+ * the target branch pipeline's commit, and a stale-notes job in the push
4
+ * pipeline of the target branch runs `pr-post --stale`. Written for the
5
+ * ci-products post-merge pass.
6
+ */
7
+ import { expect, test } from "vitest";
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ import type { DriverComponent } from "@intentius/chant/components/driver";
10
+ import { generateGitlabPipeline } from "./generate-pipeline";
11
+
12
+ const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
13
+
14
+ test("the note job carries the tip and a push job marks the notes stale (#4628)", () => {
15
+ const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { staleNotes: true } }).yaml) as Record<string, unknown> & { stages: string[] };
16
+ const note = doc["plan-note"] as { variables: Record<string, string>; script: string[] };
17
+ expect(note.variables.TIP_SHA).toBe("$CI_COMMIT_SHA");
18
+ expect(note.script.at(-1)).toContain('--tip "$TIP_SHA"');
19
+ const stale = doc["stale-notes"] as { stage: string; rules: Array<{ if: string }>; variables: Record<string, string>; script: string[] };
20
+ expect(doc.stages).toContain("stale-notes");
21
+ expect(stale.rules[0]!.if).toContain("$CI_PIPELINE_SOURCE == \"push\"");
22
+ expect(stale.variables).toEqual({ TIP_SHA: "$CI_COMMIT_SHA" });
23
+ expect(stale.script.at(-1)).toBe('chant components pr-post --stale --tip "$TIP_SHA" --env prod --forge gitlab');
24
+ });