@intentius/chant-lexicon-gitlab 0.61.0 → 0.63.0

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.
@@ -90,6 +90,19 @@
90
90
  * job log, since the invocation carries no `--json`. There is no follow-up
91
91
  * notice job: it would need a forge API call and a token this generator does
92
92
  * not require of a `report`-mode Op.
93
+ *
94
+ * A spec's own `variables` (#2290) is the one per-Op option that is cheap
95
+ * here rather than merely possible: this generator already puts a job-level
96
+ * `variables:` block on a gated job (`CHANT_GATE_SUMMARY` above), so a spec's
97
+ * own entries merge into the same mapping instead of asking for a shape
98
+ * GitLab has no concept of. `options.variables` (forge-wide) is unaffected —
99
+ * it keeps landing on the file's own top-level `variables:`, read by every
100
+ * job the way a GitLab CI/CD variable always is — and a spec's own entry wins
101
+ * the job over a same-named top-level one, the ordinary variable-precedence
102
+ * GitLab already gives a job over the pipeline. `CHANT_GATE_SUMMARY` itself is
103
+ * internal and always wins at the job over a same-named spec entry, so a
104
+ * caller cannot point the gate block somewhere the `artifacts:` declaration
105
+ * beside it does not also point.
93
106
  */
94
107
  import type { ComponentPipelineOptions as GenerateGitlabOpOptions, OpPipelineResult as GenerateGitlabOpResult, ScheduledOpSpec } from "@intentius/chant/lexicon";
95
108
  export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4FG;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;AAkQhE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CA0FxB"}
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,CAwGxB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "c68e008d70828d518ed8908e47729c0783c3b473fe4997f54d67366ae58be0b5",
4
+ "manifest.json": "c9e544549a328ea0caf1525f0db78ca7ecbef5062ba69afdf6edda52aac41bad",
5
5
  "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
6
  "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "40909e34fc71fe00e52febba2c3bd87e1287416e19c8ab58a5f769a24af7f34e"
58
+ "composite": "6884b988c1ceb86829497d39ac827e256f3e0f97fb150911577341398cc1eee3"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
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.61.0",
3
+ "version": "0.63.0",
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.61.0",
71
- "@intentius/chant-lexicon-github": "^0.61.0",
70
+ "@intentius/chant": "^0.63.0",
71
+ "@intentius/chant-lexicon-github": "^0.63.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -26,10 +26,10 @@ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
26
26
  const result = generateGitlabOpPipeline(specs);
27
27
 
28
28
  expect(result.files).toHaveLength(1);
29
- expect(result.files[0].name).toBe("scheduled-ops.gitlab-ci.yml");
29
+ expect(result.files[0].name).toBe("ops.gitlab-ci.yml");
30
30
 
31
31
  const parsed = parseYAML(result.files[0].yaml);
32
- expect(parsed.stages).toEqual(["scheduled-ops"]);
32
+ expect(parsed.stages).toEqual(["ops"]);
33
33
  expect(parsed["actions-audit"]).toBeDefined();
34
34
  expect(parsed["prod-reconcile"]).toBeDefined();
35
35
  });
@@ -39,7 +39,7 @@ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
39
39
  const parsed = parseYAML(result.files[0].yaml);
40
40
  const job = parsed["actions-audit"] as Record<string, unknown>;
41
41
 
42
- expect(job.stage).toBe("scheduled-ops");
42
+ expect(job.stage).toBe("ops");
43
43
  expect(job.rules).toEqual([
44
44
  { if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "actions-audit"' },
45
45
  ]);
@@ -66,7 +66,72 @@ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
66
66
  expect(result.files).toHaveLength(1);
67
67
  expect(result.jobs).toEqual([]);
68
68
  const parsed = parseYAML(result.files[0].yaml);
69
- expect(parsed.stages).toEqual(["scheduled-ops"]);
69
+ expect(parsed.stages).toEqual(["ops"]);
70
+ });
71
+ });
72
+
73
+ /**
74
+ * #2293 — the stage and file name follow the pipeline's actual contents
75
+ * instead of the cron-only-era constant "scheduled-ops", and both are
76
+ * overridable via `ComponentPipelineOptions` the way every other generator
77
+ * knob is (`image`, `variables`, ...).
78
+ */
79
+ describe("generateGitlabOpPipeline: the stage and file name (#2293)", () => {
80
+ test("a mixed-trigger project's stage and file no longer say 'scheduled'", () => {
81
+ // cron + merge_request + push in one project, exactly the shape that
82
+ // made "scheduled-ops" wrong: a GitLab UI would show live-check-style
83
+ // pull-request/push jobs grouped under a stage claiming they run on a
84
+ // schedule.
85
+ const result = generateGitlabOpPipeline([
86
+ { name: "nightly", schedule: "0 6 * * *" },
87
+ { name: "live-plan", trigger: { kind: "pull_request", branches: ["main"] } },
88
+ { name: "live-apply", trigger: { kind: "push", branches: ["main"] } },
89
+ ]);
90
+
91
+ expect(result.files[0].name).not.toMatch(/scheduled/);
92
+ expect(result.files[0].name).toBe("ops.gitlab-ci.yml");
93
+
94
+ const parsed = parseYAML(result.files[0].yaml);
95
+ expect(parsed.stages).toEqual(["ops"]);
96
+ expect((parsed["live-plan"] as Record<string, unknown>).stage).toBe("ops");
97
+ expect((parsed["live-apply"] as Record<string, unknown>).stage).toBe("ops");
98
+ expect((parsed.nightly as Record<string, unknown>).stage).toBe("ops");
99
+ });
100
+
101
+ test("the header names the stage every job runs in", () => {
102
+ const yaml = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]).files[0]
103
+ .yaml;
104
+ expect(yaml).toContain('one job per Op under stage "ops"');
105
+ });
106
+
107
+ test("opsStage overrides the stage, and the file name follows it by default", () => {
108
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }], {
109
+ opsStage: "deploy",
110
+ });
111
+ expect(result.files[0].name).toBe("deploy.gitlab-ci.yml");
112
+ const parsed = parseYAML(result.files[0].yaml);
113
+ expect(parsed.stages).toEqual(["deploy"]);
114
+ expect((parsed["actions-audit"] as Record<string, unknown>).stage).toBe("deploy");
115
+ expect(result.files[0].yaml).toContain('one job per Op under stage "deploy"');
116
+ });
117
+
118
+ test("opsFileName overrides the file name independently of opsStage", () => {
119
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }], {
120
+ opsStage: "deploy",
121
+ opsFileName: "ci/deploy-ops.yml",
122
+ });
123
+ expect(result.files[0].name).toBe("ci/deploy-ops.yml");
124
+ const parsed = parseYAML(result.files[0].yaml);
125
+ expect(parsed.stages).toEqual(["deploy"]);
126
+ });
127
+
128
+ test("a blank opsStage or opsFileName is refused by name", () => {
129
+ expect(() =>
130
+ generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }], { opsStage: " " }),
131
+ ).toThrow(/opsStage.*empty/s);
132
+ expect(() =>
133
+ generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }], { opsFileName: "" }),
134
+ ).toThrow(/opsFileName.*empty/s);
70
135
  });
71
136
  });
72
137
 
@@ -375,19 +440,23 @@ describe("generateGitlabOpPipeline: a deployment environment (#2257)", () => {
375
440
  /**
376
441
  * The document a cron spec with no `environment` emits. #2257 pinned this to
377
442
  * prove its option is additive; #2256 moved two lines of it and it is
378
- * re-pinned rather than loosened, so it still proves the same thing.
443
+ * re-pinned rather than loosened, so it still proves the same thing. #2293
444
+ * re-pinned it again: the stage (and the header line naming it) moved from
445
+ * the constant "scheduled-ops" to the default "ops".
379
446
  *
380
447
  * What moved: the header's opening paragraph, because a merge_request_event
381
448
  * or push job needs no Pipeline Schedule and the cron instructions are now
382
- * printed only for the Ops that do; and `resource_group`, which every job
383
- * gains as GitLab's stand-in for github's per-Op concurrency group. The
384
- * cron job's own rule, script and stage are byte-for-byte what they were.
449
+ * printed only for the Ops that do, and because the header now names the
450
+ * stage every job runs in (#2293); `resource_group`, which every job gains
451
+ * as GitLab's stand-in for github's per-Op concurrency group; and the
452
+ * `stage:`/`stages:` values themselves (#2293). The cron job's own rule and
453
+ * script are byte-for-byte what they were.
385
454
  */
386
455
  const YAML_BEFORE_2257 =
387
456
  [
388
- "# chant Ops (#927, #2084) — one job per Op, each selected by its own",
389
- "# rules:. A merge_request_event or push job needs no setup; its rule",
390
- "# fires on the event itself.",
457
+ '# chant Ops (#927, #2084, #2293) — one job per Op under stage "ops", each',
458
+ "# selected by its own rules:. A merge_request_event or push job needs no",
459
+ "# setup; its rule fires on the event itself.",
391
460
  "#",
392
461
  "# GitLab has no in-file cron. Create one Pipeline Schedule per cron Op",
393
462
  "# below (Settings > CI/CD > Schedules): set its cron to the value noted",
@@ -398,10 +467,10 @@ describe("generateGitlabOpPipeline: a deployment environment (#2257)", () => {
398
467
  " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)",
399
468
  "",
400
469
  "stages:",
401
- " - scheduled-ops",
470
+ " - ops",
402
471
  "",
403
472
  "actions-audit:",
404
- " stage: scheduled-ops",
473
+ " stage: ops",
405
474
  " image: node:22-slim",
406
475
  " resource_group: actions-audit",
407
476
  " rules:",
@@ -456,3 +525,51 @@ describe("generateGitlabOpPipeline: a deployment environment (#2257)", () => {
456
525
  ).toThrow(/neither an absolute http\(s\) URL nor a variable expression/);
457
526
  });
458
527
  });
528
+
529
+ /**
530
+ * chant #2290 — per-Op credentials. Cheap here because a gated job already
531
+ * carries a job-level `variables:` block (`CHANT_GATE_SUMMARY`); a spec's own
532
+ * `variables` merge into the same block rather than asking for a shape
533
+ * GitLab has no concept of.
534
+ */
535
+ describe("generateGitlabOpPipeline: per-Op variables merge into the job's own variables: (#2290)", () => {
536
+ const CREDENTIAL = { AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}" };
537
+
538
+ test("a spec with no variables emits no job-level variables:, even when top-level variables: is set", () => {
539
+ const result = generateGitlabOpPipeline(
540
+ [{ name: "live-check", trigger: { kind: "pull_request", branches: ["main"] } }],
541
+ { variables: { CHANT_FORGE: "gitlab" } },
542
+ );
543
+ const job = parseYAML(result.files[0].yaml)["live-check"] as Record<string, unknown>;
544
+ expect(job.variables).toBeUndefined();
545
+ });
546
+
547
+ test("a spec's own variables land on the job's variables:, beside the top-level variables:", () => {
548
+ const result = generateGitlabOpPipeline(
549
+ [{ name: "live-apply", trigger: { kind: "pull_request" }, variables: CREDENTIAL }],
550
+ { variables: { CHANT_FORGE: "gitlab" } },
551
+ );
552
+ const job = parseYAML(result.files[0].yaml)["live-apply"] as { variables?: Record<string, string> };
553
+ expect(job.variables).toEqual(CREDENTIAL);
554
+ });
555
+
556
+ test("on a gated push job, a spec's own variables merge alongside CHANT_GATE_SUMMARY rather than replacing it", () => {
557
+ const result = generateGitlabOpPipeline([
558
+ { name: "live-apply", trigger: { kind: "push", branches: ["main"] }, variables: CREDENTIAL },
559
+ ]);
560
+ const job = parseYAML(result.files[0].yaml)["live-apply"] as { variables?: Record<string, string> };
561
+ expect(job.variables).toEqual({ ...CREDENTIAL, CHANT_GATE_SUMMARY: "chant-gate-live-apply.md" });
562
+ });
563
+
564
+ test("CHANT_GATE_SUMMARY always wins a name collision — a caller cannot repoint the gate artifact", () => {
565
+ const result = generateGitlabOpPipeline([
566
+ {
567
+ name: "live-apply",
568
+ trigger: { kind: "push", branches: ["main"] },
569
+ variables: { CHANT_GATE_SUMMARY: "somewhere-else.md" },
570
+ },
571
+ ]);
572
+ const job = parseYAML(result.files[0].yaml)["live-apply"] as { variables?: Record<string, string> };
573
+ expect(job.variables?.CHANT_GATE_SUMMARY).toBe("chant-gate-live-apply.md");
574
+ });
575
+ });
@@ -90,6 +90,19 @@
90
90
  * job log, since the invocation carries no `--json`. There is no follow-up
91
91
  * notice job: it would need a forge API call and a token this generator does
92
92
  * not require of a `report`-mode Op.
93
+ *
94
+ * A spec's own `variables` (#2290) is the one per-Op option that is cheap
95
+ * here rather than merely possible: this generator already puts a job-level
96
+ * `variables:` block on a gated job (`CHANT_GATE_SUMMARY` above), so a spec's
97
+ * own entries merge into the same mapping instead of asking for a shape
98
+ * GitLab has no concept of. `options.variables` (forge-wide) is unaffected —
99
+ * it keeps landing on the file's own top-level `variables:`, read by every
100
+ * job the way a GitLab CI/CD variable always is — and a spec's own entry wins
101
+ * the job over a same-named top-level one, the ordinary variable-precedence
102
+ * GitLab already gives a job over the pipeline. `CHANT_GATE_SUMMARY` itself is
103
+ * internal and always wins at the job over a same-named spec entry, so a
104
+ * caller cannot point the gate block somewhere the `artifacts:` declaration
105
+ * beside it does not also point.
93
106
  */
94
107
 
95
108
  import { emitYAML } from "@intentius/chant/yaml";
@@ -112,7 +125,22 @@ function toJobName(opName: string): string {
112
125
  }
113
126
 
114
127
  const DEFAULT_IMAGE = "node:22-slim";
115
- const STAGE = "scheduled-ops";
128
+
129
+ /**
130
+ * Default stage every generated job runs in, and the default base of the
131
+ * generated file's name (#2293). Overridable via `options.opsStage` (and the
132
+ * file name separately via `options.opsFileName`) — see
133
+ * {@link GenerateGitlabOpOptions} (`ComponentPipelineOptions` in
134
+ * `../../../../packages/core/src/lexicon.ts`).
135
+ *
136
+ * `"scheduled-ops"` before #2293: a constant from when this generator was
137
+ * cron-only, left in place through #2084/#2256 even after every
138
+ * merge-request and push job started sharing it, so a GitLab UI showed
139
+ * `live-check`/`live-plan`/`live-apply`/`live-adopt` grouped under a stage
140
+ * called "scheduled" and a consuming project's `include:` named a file that
141
+ * said the opposite of what it held.
142
+ */
143
+ const DEFAULT_STAGE = "ops";
116
144
 
117
145
  /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
118
146
  const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
@@ -179,6 +207,26 @@ function assertBranchName(specName: string, branch: string): void {
179
207
  }
180
208
  }
181
209
 
210
+ /** Refuse a blank `opsStage` (#2293): every job's `stage:` and the top-level `stages:` entry would otherwise be an empty string. */
211
+ function assertOpsStage(stage: string): void {
212
+ if (stage.trim() === "") {
213
+ throw new Error(
214
+ `The gitlab Op generator's \`opsStage\` option is empty. Name the stage every generated job shares, ` +
215
+ `or drop the option to keep the default "${DEFAULT_STAGE}".`,
216
+ );
217
+ }
218
+ }
219
+
220
+ /** Refuse a blank `opsFileName` (#2293): `main.ts` in a consuming project writes `result.files[].name` straight to disk. */
221
+ function assertOpsFileName(fileName: string): void {
222
+ if (fileName.trim() === "") {
223
+ throw new Error(
224
+ `The gitlab Op generator's \`opsFileName\` option is empty. Name the generated file, or drop the ` +
225
+ `option to derive it from \`opsStage\`.`,
226
+ );
227
+ }
228
+ }
229
+
182
230
  /**
183
231
  * This trigger's `rules:` entries. Two or more branches are two or more
184
232
  * entries: `rules:` is an OR over its list, which is how GitLab spells the
@@ -375,9 +423,13 @@ export function generateGitlabOpPipeline(
375
423
  const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
376
424
  const beforeScript = options.beforeScript ?? [];
377
425
  const extraScript = options.extraScript ?? [];
426
+ const stage = options.opsStage ?? DEFAULT_STAGE;
427
+ assertOpsStage(stage);
428
+ const fileName = options.opsFileName ?? `${stage}.gitlab-ci.yml`;
429
+ assertOpsFileName(fileName);
378
430
 
379
431
  const jobs: OpPipelineJob[] = [];
380
- const doc: Record<string, unknown> = { stages: [STAGE] };
432
+ const doc: Record<string, unknown> = { stages: [stage] };
381
433
  if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
382
434
 
383
435
  const opLines: string[] = [];
@@ -406,8 +458,18 @@ export function generateGitlabOpPipeline(
406
458
  const invocation = gated ? [...runParts, ...GATED_EXIT_FLAG] : runParts;
407
459
  const gateSummary = gateSummaryPath(jobName);
408
460
 
461
+ // Per-Op credentials (#2290): a spec's own `variables` merge into this
462
+ // job's `variables:` block, the same block the gated apply's
463
+ // `CHANT_GATE_SUMMARY` already occupies below — GATE_SUMMARY_VAR is
464
+ // internal and listed last, so it always wins a name collision rather
465
+ // than a caller being able to repoint the gate block's own artifact path.
466
+ const jobVariables: Record<string, string> = {
467
+ ...(spec.variables ?? {}),
468
+ ...(gated ? { [GATE_SUMMARY_VAR]: gateSummary } : {}),
469
+ };
470
+
409
471
  doc[jobName] = {
410
- stage: STAGE,
472
+ stage,
411
473
  image,
412
474
  // GitLab's stand-in for github's per-Op concurrency group: queue the
413
475
  // next run rather than cancel the current one.
@@ -421,7 +483,7 @@ export function generateGitlabOpPipeline(
421
483
  },
422
484
  }
423
485
  : {}),
424
- ...(gated ? { variables: { [GATE_SUMMARY_VAR]: gateSummary } } : {}),
486
+ ...(Object.keys(jobVariables).length > 0 ? { variables: jobVariables } : {}),
425
487
  rules: rulesFor(spec, trigger),
426
488
  script: [...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
427
489
  // `when: always` because the run this publishes for is the green one: a
@@ -435,9 +497,9 @@ export function generateGitlabOpPipeline(
435
497
  }
436
498
 
437
499
  const headerLines = [
438
- "# chant Ops (#927, #2084) — one job per Op, each selected by its own",
439
- "# rules:. A merge_request_event or push job needs no setup; its rule",
440
- "# fires on the event itself.",
500
+ `# chant Ops (#927, #2084, #2293) — one job per Op under stage "${stage}", each`,
501
+ "# selected by its own rules:. A merge_request_event or push job needs no",
502
+ "# setup; its rule fires on the event itself.",
441
503
  ];
442
504
  if (anyCron) {
443
505
  headerLines.push(
@@ -459,5 +521,5 @@ export function generateGitlabOpPipeline(
459
521
 
460
522
  const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
461
523
 
462
- return { files: [{ name: "scheduled-ops.gitlab-ci.yml", yaml }], jobs };
524
+ return { files: [{ name: fileName, yaml }], jobs };
463
525
  }