@intentius/chant-lexicon-gitlab 0.124.13 → 0.124.15

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.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The GitLab CI pieces the three generators share (#3765): the Op pipeline
3
+ * (`./generate-op-pipeline.ts`), the Op waves pipeline
4
+ * (`./generate-op-waves-pipeline.ts`) and the component pipeline
5
+ * (`./generate-pipeline.ts`). The `rules:` expressions, `id_tokens:`, setup
6
+ * and environment checks are core's, in `@intentius/chant/ci-jobs`.
7
+ */
8
+ /**
9
+ * A `.gitlab-ci.yml` document: the comment header, when there is one, then
10
+ * each top-level entry in the order given, a blank line between each.
11
+ */
12
+ export declare function emitGitlabFile(entries: ReadonlyArray<readonly [string, unknown]>, header?: readonly string[]): string;
13
+ //# sourceMappingURL=ci-jobs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-jobs.d.ts","sourceRoot":"","sources":["../../src/components/ci-jobs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,MAAM,GAAE,SAAS,MAAM,EAAO,GAAG,MAAM,CAGzH"}
@@ -61,7 +61,7 @@
61
61
  * option's lines in this same lexicon, and which concatenates identically at
62
62
  * run time. An additive `permissions` map has exactly one entry with a GitLab
63
63
  * meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
64
- * idTokensFor}), the OIDC surface the old refusal already named as the shape
64
+ * gitlabIdTokens}), the OIDC surface the old refusal already named as the shape
65
65
  * to reach for. Every other scope is still refused, because there is no
66
66
  * per-job token-scope mapping to put it in and a silently dropped scope emits
67
67
  * a job that reads as granted and runs with nothing.
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqShE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAgHxB"}
1
+ {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAeH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqHhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAsGxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAK9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CA4EvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAeH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAG9F,gHAAgH;AAChH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAqEvH"}
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAS1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAqJtB"}
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;AAKjG,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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ba04bc04b196c88dec4304a7687eef1b01a9bc31939015560d129fd3decacff5",
4
+ "manifest.json": "d159b23db036b47dbf7b08997d690987e67ae767fdc7cfc974166e82a3e554e2",
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": "42f34e8e6caf92f82cfa7ff108f956ab161c497c50eb36613b05e39134453ac8"
58
+ "composite": "1024305a66cb4b31fdc78d78424aa9c84bff0c4161728ea2424591b895df6892"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.13",
3
+ "version": "0.124.15",
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.13",
3
+ "version": "0.124.15",
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.13",
71
- "@intentius/chant-lexicon-github": "^0.124.13"
70
+ "@intentius/chant": "^0.124.15",
71
+ "@intentius/chant-lexicon-github": "^0.124.15"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The GitLab CI pieces the three generators share (#3765): the Op pipeline
3
+ * (`./generate-op-pipeline.ts`), the Op waves pipeline
4
+ * (`./generate-op-waves-pipeline.ts`) and the component pipeline
5
+ * (`./generate-pipeline.ts`). The `rules:` expressions, `id_tokens:`, setup
6
+ * and environment checks are core's, in `@intentius/chant/ci-jobs`.
7
+ */
8
+
9
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
10
+
11
+ /**
12
+ * A `.gitlab-ci.yml` document: the comment header, when there is one, then
13
+ * each top-level entry in the order given, a blank line between each.
14
+ */
15
+ export function emitGitlabFile(entries: ReadonlyArray<readonly [string, unknown]>, header: readonly string[] = []): string {
16
+ const body = entries.map(([key, value]) => emitYAMLEntry(key, value)).join("\n\n") + "\n";
17
+ return header.length > 0 ? header.join("\n") + "\n\n" + body : body;
18
+ }
@@ -61,7 +61,7 @@
61
61
  * option's lines in this same lexicon, and which concatenates identically at
62
62
  * run time. An additive `permissions` map has exactly one entry with a GitLab
63
63
  * meaning: `id-token: write` becomes an `id_tokens:` declaration ({@link
64
- * idTokensFor}), the OIDC surface the old refusal already named as the shape
64
+ * gitlabIdTokens}), the OIDC surface the old refusal already named as the shape
65
65
  * to reach for. Every other scope is still refused, because there is no
66
66
  * per-job token-scope mapping to put it in and a silently dropped scope emits
67
67
  * a job that reads as granted and runs with nothing.
@@ -105,7 +105,18 @@
105
105
  * beside it does not also point.
106
106
  */
107
107
 
108
- import { emitYAMLEntry } from "@intentius/chant/yaml";
108
+ import {
109
+ assertGitlabEnvironment,
110
+ CI_IMAGE,
111
+ ciJobName,
112
+ environmentDoc,
113
+ fillCommand,
114
+ gitlabIdTokens,
115
+ gitlabRules,
116
+ gitlabSetupScript,
117
+ nonEmpty,
118
+ pushBranches,
119
+ } from "@intentius/chant/ci-jobs";
109
120
  import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
110
121
  import type {
111
122
  ComponentPipelineOptions as GenerateGitlabOpOptions,
@@ -117,14 +128,9 @@ import type {
117
128
  ScheduledOpSpec,
118
129
  } from "@intentius/chant/lexicon";
119
130
 
120
- export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
121
-
122
- /** GitLab CI job names must be safe YAML keys; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
123
- function toJobName(opName: string): string {
124
- return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
125
- }
131
+ import { emitGitlabFile } from "./ci-jobs";
126
132
 
127
- const DEFAULT_IMAGE = "node:22";
133
+ export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
128
134
 
129
135
  /**
130
136
  * Default stage every generated job runs in, and the default base of the
@@ -145,33 +151,6 @@ const DEFAULT_STAGE = "ops";
145
151
  /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
146
152
  const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
147
153
 
148
- /**
149
- * Default branch assumed for a `push` trigger with no `branches` override.
150
- * The same value github's generator documents, for the same reason: this
151
- * operates on a `ScheduledOpSpec` rather than a git checkout, so it cannot
152
- * read a downstream project's actual default branch. Set `trigger.branches`
153
- * explicitly on a project whose default branch is something else.
154
- */
155
- const DEFAULT_PUSH_BRANCH = "main";
156
-
157
- /** The pipeline source GitLab reports for a merge-request pipeline. */
158
- const MERGE_REQUEST_SOURCE = '$CI_PIPELINE_SOURCE == "merge_request_event"';
159
-
160
- /**
161
- * The `id_tokens:` entry an `id-token: write` spec gets. The name is the
162
- * environment variable the JWT lands in, which the job's own setup line reads
163
- * (`aws sts assume-role-with-web-identity --web-identity-token
164
- * "$CHANT_ID_TOKEN"`, or the provider's equivalent).
165
- *
166
- * `$CI_SERVER_URL` as the audience is GitLab's own documented default: an
167
- * identity provider federated to a GitLab instance is registered with that
168
- * instance's URL as its audience, and the variable expands to exactly that on
169
- * gitlab.com and on a self-managed instance alike. A project whose provider
170
- * was registered with some other audience edits the generated declaration.
171
- */
172
- const ID_TOKEN_NAME = "CHANT_ID_TOKEN";
173
- const ID_TOKEN_AUDIENCE = "$CI_SERVER_URL";
174
-
175
154
  /**
176
155
  * `chant run` returns 3 when a run stops at an unapproved gate; `--gated-exit
177
156
  * 0` maps that one outcome to success (#2243). `push` only — a cron watch or
@@ -185,28 +164,6 @@ const GATE_SUMMARY_VAR = "CHANT_GATE_SUMMARY";
185
164
  /** How long a pending-gate artifact is worth keeping: long enough to outlive the approval it is waiting for. */
186
165
  const GATE_ARTIFACT_EXPIRY = "30 days";
187
166
 
188
- /**
189
- * Refuse a branch name that cannot be interpolated into a `rules:`
190
- * if-expression. The expression is a string GitLab parses, so a `"` would end
191
- * it early and a `$` would expand as a variable — either one silently changes
192
- * which pipelines match the job, which is worse than not generating it.
193
- */
194
- function assertBranchName(specName: string, branch: string): void {
195
- if (branch.trim() === "") {
196
- throw new Error(
197
- `Scheduled Op "${specName}" has an empty branch filter. Name a branch, or drop the filter.`,
198
- );
199
- }
200
- if (/["$\\]/.test(branch)) {
201
- throw new Error(
202
- `Scheduled Op "${specName}" filters on branch "${branch}", which carries a character this generator ` +
203
- `cannot put in a GitLab \`rules:\` expression: a quote would end the expression early and a "$" ` +
204
- `would expand as a CI/CD variable, either of which silently changes which pipelines run the job. ` +
205
- `Name a branch without \`"\`, \`$\` or \`\\\`.`,
206
- );
207
- }
208
- }
209
-
210
167
  /** Refuse a blank `opsStage` (#2293): every job's `stage:` and the top-level `stages:` entry would otherwise be an empty string. */
211
168
  function assertOpsStage(stage: string): void {
212
169
  if (stage.trim() === "") {
@@ -227,33 +184,6 @@ function assertOpsFileName(fileName: string): void {
227
184
  }
228
185
  }
229
186
 
230
- /**
231
- * This trigger's `rules:` entries. Two or more branches are two or more
232
- * entries: `rules:` is an OR over its list, which is how GitLab spells the
233
- * alternation github expresses as a `branches:` array.
234
- */
235
- function rulesFor(spec: ScheduledOpSpec, trigger: OpTrigger): Array<{ if: string }> {
236
- switch (trigger.kind) {
237
- case "cron":
238
- return [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }];
239
- case "pull_request": {
240
- const branches = trigger.branches ?? [];
241
- if (branches.length === 0) return [{ if: MERGE_REQUEST_SOURCE }];
242
- return branches.map((branch) => {
243
- assertBranchName(spec.name, branch);
244
- return { if: `${MERGE_REQUEST_SOURCE} && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "${branch}"` };
245
- });
246
- }
247
- case "push": {
248
- const branches = trigger.branches?.length ? trigger.branches : [DEFAULT_PUSH_BRANCH];
249
- return branches.map((branch) => {
250
- assertBranchName(spec.name, branch);
251
- return { if: `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "${branch}"` };
252
- });
253
- }
254
- }
255
- }
256
-
257
187
  /**
258
188
  * Refuse `findingMode: "comment"` on a trigger that has no merge request
259
189
  * (#2231, #2256). The mode's activity reads the merge request out of the
@@ -272,99 +202,6 @@ function assertTriggerSupportsMode(name: string, mode: OpFindingMode, trigger: O
272
202
  );
273
203
  }
274
204
 
275
- /**
276
- * Turn a spec's additive `permissions` (#2242) into the one GitLab
277
- * declaration that carries the same meaning, refusing everything else by name.
278
- *
279
- * `id-token: write` is that one: GitLab's OIDC surface is `id_tokens:`, a
280
- * per-job declaration of a JWT the job exchanges for cloud credentials
281
- * itself. `id-token: read` is refused rather than mapped, because GitLab
282
- * either mints the token into the job or does not — there is no read-only
283
- * half of it, and emitting the declaration for a spec that asked for read
284
- * would hand the job more than it asked for.
285
- *
286
- * Every other scope is refused, unchanged from the pre-#2256 behaviour:
287
- * GitLab has no per-job token-scope mapping at all, and a scope quietly
288
- * dropped would emit a job that reads as granted and runs with nothing.
289
- */
290
- function idTokensFor(spec: ScheduledOpSpec): Record<string, unknown> | undefined {
291
- let wantsIdToken = false;
292
- for (const [rawScope, value] of Object.entries(spec.permissions ?? {})) {
293
- const scope = rawScope.trim();
294
- if (scope === "id-token") {
295
- if (value !== "write") {
296
- throw new Error(
297
- `Scheduled Op "${spec.name}" adds permission "id-token: ${value}", but GitLab either mints an ` +
298
- `OIDC token into a job or does not — there is no read-only half of an \`id_tokens:\` ` +
299
- `declaration. Ask for { "id-token": "write" }, or drop the option.`,
300
- );
301
- }
302
- wantsIdToken = true;
303
- continue;
304
- }
305
- throw new Error(
306
- `Scheduled Op "${spec.name}" adds permission "${scope}: ${value}", but GitLab CI has no per-job ` +
307
- `token-scope mapping — there is no \`permissions:\` key to add it to (#2242). The one scope that ` +
308
- `does cross over is "id-token": "write", which becomes an \`id_tokens:\` declaration. Drop the ` +
309
- `option here, or generate this Op for github.`,
310
- );
311
- }
312
- return wantsIdToken ? { [ID_TOKEN_NAME]: { aud: ID_TOKEN_AUDIENCE } } : undefined;
313
- }
314
-
315
- /**
316
- * Refuse a `uses` setup step by name and return the `run` lines that do
317
- * translate. See the module doc for why a marketplace action is a refusal
318
- * rather than a silent drop.
319
- */
320
- function gitlabSetupScript(spec: ScheduledOpSpec): string[] {
321
- const lines: string[] = [];
322
- (spec.setup ?? []).forEach((step, index) => {
323
- if ("uses" in step) {
324
- throw new Error(
325
- `Scheduled Op "${spec.name}" setup step ${index + 1} is \`uses: "${step.uses}"\`, a GitHub Actions ` +
326
- `marketplace action. GitLab CI jobs run \`script\` lines only, so there is nothing to translate it ` +
327
- `into (#2242). Express the setup as a \`{ run }\` entry, or generate this Op for github/forgejo.`,
328
- );
329
- }
330
- lines.push(step.run);
331
- });
332
- return lines;
333
- }
334
-
335
- /**
336
- * Validate a spec's `environment` (#2257) on GitLab's own terms. A GitLab job
337
- * naming an environment that does not exist creates an unprotected one on
338
- * first deploy rather than failing, so — as on github — the only shapes worth
339
- * refusing at build time are the ones that could never bind: a blank name,
340
- * and a `url` that is neither absolute nor a variable expression GitLab
341
- * expands, which would render as a dead "View deployment" link.
342
- */
343
- function assertGitlabEnvironment(name: string, environment: OpEnvironment): void {
344
- const where = `Scheduled Op "${name}" environment`;
345
- if (environment.name.trim() === "") {
346
- throw new Error(
347
- `${where} has an empty \`name\`. A GitLab environment is a project object, and its approval ` +
348
- `rules live on that object rather than in this file, so a blank name resolves to nothing. ` +
349
- `Give it the environment's name, or drop the option.`,
350
- );
351
- }
352
- if (environment.url === undefined) return;
353
- const url = environment.url.trim();
354
- if (url === "") {
355
- throw new Error(
356
- `${where} "${environment.name}" has an empty \`url\`. Omit the field rather than setting it to "".`,
357
- );
358
- }
359
- if (!/^https?:\/\//.test(url) && !url.includes("$")) {
360
- throw new Error(
361
- `${where} "${environment.name}" has \`url: "${environment.url}"\`, which is neither an absolute ` +
362
- `http(s) URL nor a variable expression GitLab expands. It becomes the environment's own link, ` +
363
- `so a relative path is a dead link on the environment page rather than an error anywhere.`,
364
- );
365
- }
366
- }
367
-
368
205
  /**
369
206
  * The follow-up line an Op with an `environment` adds under its own header
370
207
  * line (#2257). The `environment:` key on the job binds it to the
@@ -401,7 +238,7 @@ function headerLineFor(
401
238
  return `# ${jobName}: merge_request_event onto ${onto}, finding-mode ${mode}${tokenNote}`;
402
239
  }
403
240
  case "push": {
404
- const branches = trigger.branches?.length ? trigger.branches : [DEFAULT_PUSH_BRANCH];
241
+ const branches = pushBranches(trigger.branches);
405
242
  return (
406
243
  `# ${jobName}: push to ${branches.join(", ")}, finding-mode ${mode}${tokenNote}` +
407
244
  ` — a gated apply stays green, its pending gate in the log and in ${gateSummaryPath(jobName)}`
@@ -419,7 +256,7 @@ export function generateGitlabOpPipeline(
419
256
  ops: ScheduledOpSpec[],
420
257
  options: GenerateGitlabOpOptions = {},
421
258
  ): GenerateGitlabOpResult {
422
- const image = options.image ?? DEFAULT_IMAGE;
259
+ const image = options.image ?? CI_IMAGE;
423
260
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
424
261
  const beforeScript = options.beforeScript ?? [];
425
262
  const extraScript = options.extraScript ?? [];
@@ -437,21 +274,21 @@ export function generateGitlabOpPipeline(
437
274
  const cdLine = member && member.dir !== "." && member.dir !== "" ? [`cd ${memberShellDir(member)}`] : [];
438
275
 
439
276
  const jobs: OpPipelineJob[] = [];
440
- const doc: Record<string, unknown> = { stages: [stage] };
441
- if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
277
+ const doc: Record<string, unknown> = { stages: [stage], ...nonEmpty("variables", options.variables) };
442
278
 
443
279
  const opLines: string[] = [];
444
280
  let anyCron = false;
445
281
 
446
282
  for (const spec of ops) {
447
- const setupScript = gitlabSetupScript(spec);
448
- const idTokens = idTokensFor(spec);
283
+ const subject = `Scheduled Op "${spec.name}"`;
284
+ const setupScript = gitlabSetupScript(subject, spec.setup);
285
+ const idTokens = gitlabIdTokens(spec.name, spec.permissions);
449
286
  const findingMode = spec.findingMode ?? "report";
450
287
  const trigger = resolveOpTrigger(spec);
451
288
  assertTriggerSupportsMode(spec.name, findingMode, trigger);
452
289
  if (trigger.kind === "cron") anyCron = true;
453
290
 
454
- const jobName = jobPrefix + toJobName(spec.name);
291
+ const jobName = jobPrefix + ciJobName(spec.name);
455
292
  jobs.push({ jobName, op: spec.name, trigger, findingMode });
456
293
  opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
457
294
  if (spec.environment) {
@@ -461,8 +298,9 @@ export function generateGitlabOpPipeline(
461
298
 
462
299
  // A push job is the one a gate would otherwise paint red on every merge
463
300
  // (#2243). Every other trigger keeps the plain one-line invocation.
301
+ const rules = gitlabRules(subject, trigger, { variable: SELECTOR_VAR, value: spec.name });
464
302
  const gated = trigger.kind === "push";
465
- const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
303
+ const runParts = fillCommand(runCommand, spec.name);
466
304
  const invocation = gated ? [...runParts, ...GATED_EXIT_FLAG] : runParts;
467
305
  const gateSummary = gateSummaryPath(jobName);
468
306
 
@@ -483,16 +321,9 @@ export function generateGitlabOpPipeline(
483
321
  // next run rather than cancel the current one.
484
322
  resource_group: jobName,
485
323
  ...(idTokens ? { id_tokens: idTokens } : {}),
486
- ...(spec.environment
487
- ? {
488
- environment: {
489
- name: spec.environment.name,
490
- ...(spec.environment.url === undefined ? {} : { url: spec.environment.url }),
491
- },
492
- }
493
- : {}),
494
- ...(Object.keys(jobVariables).length > 0 ? { variables: jobVariables } : {}),
495
- rules: changes && trigger.kind !== "cron" ? rulesFor(spec, trigger).map((r) => ({ ...r, changes })) : rulesFor(spec, trigger),
324
+ ...(spec.environment ? { environment: environmentDoc(spec.environment) } : {}),
325
+ ...nonEmpty("variables", jobVariables),
326
+ rules: changes && trigger.kind !== "cron" ? rules.map((r) => ({ ...r, changes })) : rules,
496
327
  script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
497
328
  // `when: always` because the run this publishes for is the green one: a
498
329
  // gated apply succeeds, and the block is the only thing that says a
@@ -520,14 +351,10 @@ export function generateGitlabOpPipeline(
520
351
  }
521
352
  headerLines.push("#", ...opLines);
522
353
 
523
- const sections: string[] = [];
524
- sections.push(emitYAMLEntry("stages", doc.stages));
525
- if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
526
- for (const { jobName } of jobs) {
527
- sections.push(emitYAMLEntry(jobName, doc[jobName]));
528
- }
529
-
530
- const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
354
+ const yaml = emitGitlabFile(
355
+ [["stages", doc.stages], ...(doc.variables ? [["variables", doc.variables] as const] : []), ...jobs.map(({ jobName }) => [jobName, doc[jobName]] as const)],
356
+ headerLines,
357
+ );
531
358
 
532
359
  return { files: [{ name: fileName, yaml }], jobs };
533
360
  }
@@ -60,3 +60,12 @@ describe("generateGitlabOpWavesPipeline", () => {
60
60
  expect(() => generateGitlabOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
61
61
  });
62
62
  });
63
+
64
+ describe("a wave's environment is checked as an Op's is (#3765)", () => {
65
+ test("a blank name or a relative url is refused, and a $VARIABLE url passes", () => {
66
+ const one = (environment: { name: string; url?: string }): OpWavesSpec => ({ ...spec, waves: [{ name: "dev", runs: [{ target: "dev" }], environment }] });
67
+ expect(() => generateGitlabOpWavesPipeline(one({ name: " " }), { specFile: "waves.json" })).toThrow(/environment has an empty `name`/);
68
+ expect(() => generateGitlabOpWavesPipeline(one({ name: "dev", url: "dev/app" }), { specFile: "waves.json" })).toThrow(/neither an absolute/);
69
+ expect(generateGitlabOpWavesPipeline(one({ name: "dev", url: "$CI_ENVIRONMENT_URL" }), { specFile: "waves.json" }).files[0]!.yaml).toContain("url: '$CI_ENVIRONMENT_URL'");
70
+ });
71
+ });
@@ -20,79 +20,82 @@
20
20
  * pipelines only, which records the digests the merge request's head plans.
21
21
  */
22
22
 
23
- import { emitYAMLEntry } from "@intentius/chant/yaml";
23
+ import {
24
+ assertGitlabBranch,
25
+ assertGitlabEnvironment,
26
+ CI_IMAGE,
27
+ environmentDoc,
28
+ GITLAB_FULL_HISTORY,
29
+ GITLAB_MERGE_REQUEST_SOURCE,
30
+ GITLAB_SCHEDULE_SOURCE,
31
+ gitlabPushRule,
32
+ gitlabSetupScript,
33
+ nonEmpty,
34
+ pushBranches,
35
+ } from "@intentius/chant/ci-jobs";
24
36
  import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
25
37
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
26
-
27
- /** The default image: it carries git, which the runner needs to read the base commit. */
28
- const OP_WAVES_IMAGE = "node:22";
38
+ import { emitGitlabFile } from "./ci-jobs";
29
39
 
30
40
  /** Render an Op waves spec as one GitLab CI file, `<name>.gitlab-ci.yml` unless `opsFileName` names another. */
31
41
  export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
32
42
  const jobs = opWaveJobs(spec, options.specFile);
33
- const image = options.image ?? OP_WAVES_IMAGE;
34
- const branches = spec.branches?.length ? spec.branches : ["main"];
35
- for (const branch of branches) {
36
- if (/["$\\]/.test(branch) || branch.trim() === "") {
37
- throw new Error(`Op waves "${spec.name}" filters on branch "${branch}", which cannot go in a GitLab rules: expression.`);
38
- }
39
- }
40
- const rules = branches.map((branch) => ({ if: `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "${branch}"` }));
43
+ const image = options.image ?? CI_IMAGE;
44
+ const subject = `Op waves "${spec.name}"`;
45
+ const rules = pushBranches(spec.branches).map((branch) => {
46
+ assertGitlabBranch(subject, branch);
47
+ return { if: gitlabPushRule(JSON.stringify(branch)) };
48
+ });
49
+ // The setup and environment each wave's jobs carry, checked as an Op's are.
50
+ const setupOf = spec.waves.map((wave) => gitlabSetupScript(`${subject} wave "${wave.name}"`, wave.setup));
51
+ for (const wave of spec.waves) if (wave.environment) assertGitlabEnvironment(`${spec.name}/${wave.name}`, wave.environment);
41
52
 
42
53
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
43
54
  const stages = [...(byReview ? ["plans"] : []), ...spec.waves.map((wave, i) => `wave-${i + 1}-${wave.name}`)];
44
55
  if (spec.resume) stages.push("resume");
45
- const sections: string[] = [emitYAMLEntry("stages", stages)];
46
- if (options.variables && Object.keys(options.variables).length > 0) sections.push(emitYAMLEntry("variables", options.variables));
56
+ const entries: Array<readonly [string, unknown]> = [["stages", stages]];
57
+ const variables = nonEmpty("variables", options.variables).variables;
58
+ if (variables) entries.push(["variables", variables]);
59
+ if (byReview) {
60
+ entries.push([
61
+ `${spec.name}-record-plans`,
62
+ {
63
+ stage: "plans",
64
+ image,
65
+ variables: { ...GITLAB_FULL_HISTORY },
66
+ rules: [{ if: GITLAB_MERGE_REQUEST_SOURCE }],
67
+ script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
68
+ },
69
+ ]);
70
+ }
47
71
  for (const job of jobs) {
48
72
  const wave = spec.waves[job.wave - 1]!;
49
- const setup = (wave.setup ?? []).map((step, index) => {
50
- if ("uses" in step) {
51
- throw new Error(
52
- `Op waves "${spec.name}" wave "${wave.name}" setup step ${index + 1} is \`uses: "${step.uses}"\`, a GitHub ` +
53
- `Actions marketplace action. GitLab CI runs script lines only; express it as a { run } entry.`,
54
- );
55
- }
56
- return step.run;
57
- });
58
- sections.push(
59
- emitYAMLEntry(job.jobName, {
73
+ const setup = setupOf[job.wave - 1]!;
74
+ entries.push([
75
+ job.jobName,
76
+ {
60
77
  stage: stages[job.wave - 1 + (byReview ? 1 : 0)],
61
78
  image,
62
79
  resource_group: `${spec.name}-${wave.name}`,
63
- ...(wave.environment
64
- ? { environment: { name: wave.environment.name, ...(wave.environment.url ? { url: wave.environment.url } : {}) } }
65
- : {}),
66
- variables: { GIT_DEPTH: "0", ...(wave.variables ?? {}) },
80
+ ...(wave.environment ? { environment: environmentDoc(wave.environment) } : {}),
81
+ variables: { ...GITLAB_FULL_HISTORY, ...(wave.variables ?? {}) },
67
82
  rules,
68
83
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
69
84
  script: [...setup, ...(options.beforeScript ?? []), job.command.join(" "), ...(options.extraScript ?? [])],
70
85
  ...(job.kind === "decide" ? { artifacts: { when: "always", paths: [job.record!], expire_in: "30 days" } } : {}),
71
- }),
72
- );
86
+ },
87
+ ]);
73
88
  }
74
89
  if (spec.resume) {
75
- sections.push(
76
- emitYAMLEntry(`${spec.name}-resume`, {
90
+ entries.push([
91
+ `${spec.name}-resume`,
92
+ {
77
93
  stage: "resume",
78
94
  image,
79
- rules: [{ if: '$CI_PIPELINE_SOURCE == "schedule"' }],
95
+ rules: [{ if: GITLAB_SCHEDULE_SOURCE }],
80
96
  script: [...(options.beforeScript ?? []), opWavesResumeCommand(spec).join(" ")],
81
- }),
82
- );
83
- }
84
- if (byReview) {
85
- sections.splice(
86
- options.variables && Object.keys(options.variables).length > 0 ? 2 : 1,
87
- 0,
88
- emitYAMLEntry(`${spec.name}-record-plans`, {
89
- stage: "plans",
90
- image,
91
- variables: { GIT_DEPTH: "0" },
92
- rules: [{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }],
93
- script: [...(options.beforeScript ?? []), ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ")],
94
- }),
95
- );
97
+ },
98
+ ]);
96
99
  }
97
100
  const header = [
98
101
  `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
@@ -103,5 +106,5 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
103
106
  : []),
104
107
  ];
105
108
  const name = options.opsFileName ?? `${spec.name}.gitlab-ci.yml`;
106
- return { files: [{ name, yaml: header.join("\n") + "\n\n" + sections.join("\n\n") + "\n" }], jobs };
109
+ return { files: [{ name, yaml: emitGitlabFile(entries, header) }], jobs };
107
110
  }
@@ -32,11 +32,19 @@
32
32
  * touching the component declarations.
33
33
  */
34
34
 
35
- import { emitYAMLEntry } from "@intentius/chant/yaml";
35
+ import {
36
+ assertGitlabBranch,
37
+ CI_IMAGE,
38
+ ciJobName,
39
+ fillCommand,
40
+ GITLAB_FULL_HISTORY,
41
+ GITLAB_MERGE_REQUEST_SOURCE,
42
+ gitlabPushRule,
43
+ } from "@intentius/chant/ci-jobs";
36
44
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
37
45
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
38
46
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
39
- import { PR_APPLY_RECORD, PR_LOOP_IMAGE, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
47
+ import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prApplyRecordKey, prLoopJobs, prLoopSetup } from "@intentius/chant/components/pr-pipeline";
40
48
  import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
41
49
  import type {
42
50
  ComponentPipelineJob as GeneratedJob,
@@ -45,14 +53,9 @@ import type {
45
53
  PipelineMember,
46
54
  } from "@intentius/chant/lexicon";
47
55
 
48
- export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
49
-
50
- /** GitLab CI job names must be safe YAML keys; component names are already kebab-case in every fixture, but normalize defensively. */
51
- function toJobName(componentName: string): string {
52
- return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
53
- }
56
+ import { emitGitlabFile } from "./ci-jobs";
54
57
 
55
- const DEFAULT_IMAGE = "node:22";
58
+ export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
56
59
 
57
60
  /**
58
61
  * Synthesize a `.gitlab-ci.yml` pipeline from a set of components: one stage
@@ -67,7 +70,7 @@ export function generateGitlabPipeline(
67
70
  options: GenerateGitlabOptions = {},
68
71
  ): GenerateGitlabResult {
69
72
  const env = options.env ?? "production";
70
- const image = options.image ?? (options.prLoop || options.gatedWaves ? PR_LOOP_IMAGE : DEFAULT_IMAGE);
73
+ const image = options.image ?? CI_IMAGE;
71
74
  const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
72
75
  const beforeScript = options.beforeScript ?? [];
73
76
  const extraScript = options.extraScript ?? [];
@@ -126,7 +129,7 @@ export function generateGitlabPipeline(
126
129
  const jobs: GeneratedJob[] = [];
127
130
  const jobNameByComponent = new Map<string, string>();
128
131
  for (const wave of waves) {
129
- for (const name of wave) jobNameByComponent.set(name, toJobName(name));
132
+ for (const name of wave) jobNameByComponent.set(name, ciJobName(name));
130
133
  }
131
134
 
132
135
  const doc: Record<string, unknown> = {};
@@ -152,7 +155,7 @@ export function generateGitlabPipeline(
152
155
  // Build the run invocation, then append output-threading flags: seed from
153
156
  // each dependency's dumped outputs, and dump this component's own outputs
154
157
  // if a dependent will need them.
155
- const runParts = runCommand.map((part) => part.replace("{name}", name));
158
+ const runParts = fillCommand(runCommand, name);
156
159
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
157
160
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
158
161
  if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
@@ -178,7 +181,7 @@ export function generateGitlabPipeline(
178
181
 
179
182
  let promoteJob: string | undefined;
180
183
  if (promoteTo !== undefined) {
181
- promoteJob = `promote-${toJobName(promoteTo)}`;
184
+ promoteJob = `promote-${ciJobName(promoteTo)}`;
182
185
  if (promoteJob in doc) {
183
186
  throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
184
187
  }
@@ -200,20 +203,17 @@ export function generateGitlabPipeline(
200
203
  promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
201
204
  }
202
205
 
203
- const sections: string[] = [];
204
- sections.push(emitYAMLEntry("workflow", doc.workflow));
205
206
  // The promote stage is YAML only: `stages` in the result stays one entry
206
207
  // per graph wave.
207
208
  const yamlStages = promoteJob ? [...stages, "promote"] : stages;
208
- sections.push(emitYAMLEntry("stages", yamlStages));
209
- if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
210
- for (const job of jobs) {
211
- const props = doc[job.jobName] as Record<string, unknown>;
212
- sections.push(emitYAMLEntry(job.jobName, props));
213
- }
214
- if (promoteJob) sections.push(emitYAMLEntry(promoteJob, doc[promoteJob]));
215
-
216
- return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
209
+ const yaml = emitGitlabFile([
210
+ ["workflow", doc.workflow],
211
+ ["stages", yamlStages],
212
+ ["variables", doc.variables],
213
+ ...jobs.map((job) => [job.jobName, doc[job.jobName]] as const),
214
+ ...(promoteJob ? [[promoteJob, doc[promoteJob]] as const] : []),
215
+ ]);
216
+ return { yaml, stages, jobs, env };
217
217
  }
218
218
 
219
219
  /**
@@ -244,20 +244,20 @@ function gatedWaveGitlabPipeline(
244
244
  doc[job.jobName] = {
245
245
  stage: job.jobName,
246
246
  image,
247
- variables: { GIT_DEPTH: "0" },
247
+ variables: { ...GITLAB_FULL_HISTORY },
248
248
  script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
249
249
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
250
250
  artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
251
251
  };
252
252
  }
253
253
  if (options.member) scopeToMember(doc, jobs, undefined, options.member, env);
254
- const sections = [
255
- emitYAMLEntry("workflow", doc.workflow),
256
- emitYAMLEntry("stages", stages),
257
- emitYAMLEntry("variables", doc.variables),
258
- ...jobs.map((job) => emitYAMLEntry(job.jobName, doc[job.jobName])),
259
- ];
260
- return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
254
+ const yaml = emitGitlabFile([
255
+ ["workflow", doc.workflow],
256
+ ["stages", stages],
257
+ ["variables", doc.variables],
258
+ ...jobs.map((job) => [job.jobName, doc[job.jobName]] as const),
259
+ ]);
260
+ return { yaml, stages, jobs, env };
261
261
  }
262
262
 
263
263
  /**
@@ -297,9 +297,9 @@ function prLoopGitlabPipeline(
297
297
  const reportPath = member ? memberRepoPath(member, PR_LOOP_REPORT_DIR) : PR_LOOP_REPORT_DIR;
298
298
  const planJob = member ? `${member.name}-plan` : "plan";
299
299
  const applyJob = member ? `${member.name}-apply` : "apply";
300
- const onMergeRequest = '$CI_PIPELINE_SOURCE == "merge_request_event"';
301
- const target = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
302
- const onMerge = `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ${target}`;
300
+ const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
301
+ if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
302
+ const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
303
303
  // A member's jobs start in its directory, after the image's install lines.
304
304
  const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
305
305
  const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), job.command, ...extraScript];
@@ -309,7 +309,7 @@ function prLoopGitlabPipeline(
309
309
  // the rules every member shares and no name: two names would collide.
310
310
  workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }] },
311
311
  stages,
312
- variables: { ...options.variables, CHANT_ENV: env, GIT_DEPTH: "0" },
312
+ variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
313
313
  [planJob]: {
314
314
  stage: "plan",
315
315
  image,
@@ -339,8 +339,8 @@ function prLoopGitlabPipeline(
339
339
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
340
340
  { jobName: applyJob, component: "merge request apply", stage: "apply", needs: [] },
341
341
  ];
342
- const sections = ["workflow", "stages", "variables", planJob, applyJob].map((key) => emitYAMLEntry(key, doc[key]));
343
- return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
342
+ const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, applyJob].map((key) => [key, doc[key]] as const));
343
+ return { yaml, stages, jobs, env };
344
344
  }
345
345
 
346
346
  /**
@@ -119,3 +119,9 @@ describe("the GitLab merge-request pipeline", () => {
119
119
  expect(apply.cache).toEqual({ key: "chant-apply.network.prod-$CI_COMMIT_SHA", paths: ["infra/network/.chant/pr-resume/pr-apply.json"], when: "always" });
120
120
  });
121
121
  });
122
+
123
+ describe("the merge-request pipeline's branch (#3765)", () => {
124
+ test("a branch that would break the rules: expression is refused, as an Op's is", () => {
125
+ expect(() => generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "re$lease" } })).toThrow(/filters on branch "re\$lease"/);
126
+ });
127
+ });