@intentius/chant 0.124.22 → 0.124.24

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.
Files changed (187) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/ci-jobs.d.ts +41 -1
  3. package/dist/ci-jobs.d.ts.map +1 -1
  4. package/dist/ci-jobs.js +48 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/build.js +14 -0
  7. package/dist/cli/handlers/approve.d.ts.map +1 -1
  8. package/dist/cli/handlers/approve.js +9 -0
  9. package/dist/cli/handlers/emulator.d.ts +13 -2
  10. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  11. package/dist/cli/handlers/emulator.js +63 -3
  12. package/dist/cli/handlers/graph.js +24 -1
  13. package/dist/cli/handlers/promote.d.ts.map +1 -1
  14. package/dist/cli/handlers/promote.js +7 -1
  15. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  16. package/dist/cli/handlers/run-wave.js +5 -4
  17. package/dist/cli/handlers/run.d.ts +4 -3
  18. package/dist/cli/handlers/run.d.ts.map +1 -1
  19. package/dist/cli/handlers/run.js +73 -9
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +17 -4
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +31 -0
  24. package/dist/cli/registry.d.ts +12 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/cli-support.d.ts +45 -6
  27. package/dist/components/cli-support.d.ts.map +1 -1
  28. package/dist/components/cli-support.js +97 -22
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +25 -10
  31. package/dist/graph-detail.d.ts.map +1 -1
  32. package/dist/graph-detail.js +24 -2
  33. package/dist/graph-ir.d.ts +43 -0
  34. package/dist/graph-ir.d.ts.map +1 -1
  35. package/dist/graph-ir.js +27 -0
  36. package/dist/lexicon.d.ts +25 -1
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/git.d.ts +11 -0
  39. package/dist/lifecycle/git.d.ts.map +1 -1
  40. package/dist/lifecycle/git.js +62 -6
  41. package/dist/lifecycle/no-record.d.ts +59 -0
  42. package/dist/lifecycle/no-record.d.ts.map +1 -0
  43. package/dist/lifecycle/no-record.js +88 -0
  44. package/dist/op/activities/shell.d.ts.map +1 -1
  45. package/dist/op/activities/shell.js +30 -9
  46. package/dist/op/emulator-lifecycle.d.ts +23 -0
  47. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  48. package/dist/op/emulator-lifecycle.js +39 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +3 -0
  51. package/dist/op/index.d.ts +1 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +1 -1
  54. package/dist/op/local-executor.d.ts +10 -0
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +7 -2
  57. package/dist/op/op-waves-notify.d.ts +2 -0
  58. package/dist/op/op-waves-notify.d.ts.map +1 -1
  59. package/dist/op/op-waves-notify.js +9 -3
  60. package/dist/op/op-waves-run.d.ts +8 -2
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves-run.js +36 -11
  63. package/dist/op/op-waves.d.ts +85 -1
  64. package/dist/op/op-waves.d.ts.map +1 -1
  65. package/dist/op/op-waves.js +153 -1
  66. package/dist/op/runtimes/local.d.ts.map +1 -1
  67. package/dist/op/runtimes/local.js +6 -0
  68. package/dist/op/step-console.d.ts +65 -0
  69. package/dist/op/step-console.d.ts.map +1 -0
  70. package/dist/op/step-console.js +140 -0
  71. package/dist/point-decision.d.ts.map +1 -1
  72. package/dist/point-decision.js +3 -0
  73. package/dist/wave-runtime.mjs +240 -224
  74. package/dist/workspace/checks/links.d.ts +5 -0
  75. package/dist/workspace/checks/links.d.ts.map +1 -1
  76. package/dist/workspace/checks/links.js +54 -8
  77. package/dist/workspace/checks.d.ts +4 -1
  78. package/dist/workspace/checks.d.ts.map +1 -1
  79. package/dist/workspace/checks.js +6 -2
  80. package/dist/workspace/compose-graph.d.ts +26 -4
  81. package/dist/workspace/compose-graph.d.ts.map +1 -1
  82. package/dist/workspace/compose-graph.js +21 -7
  83. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  84. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  85. package/dist/workspace/dbt-project-reader.js +290 -0
  86. package/dist/workspace/graph-cli.d.ts.map +1 -1
  87. package/dist/workspace/graph-cli.js +3 -1
  88. package/dist/workspace/graph.schema.json +109 -4
  89. package/dist/workspace/intent.js +7 -2
  90. package/dist/workspace/kind-link-readers.d.ts +84 -0
  91. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  92. package/dist/workspace/kind-link-readers.js +53 -0
  93. package/dist/workspace/kinds.d.ts +15 -0
  94. package/dist/workspace/kinds.d.ts.map +1 -1
  95. package/dist/workspace/kinds.js +11 -0
  96. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  97. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  98. package/dist/workspace/lexicon-outputs.js +241 -0
  99. package/dist/workspace/links.d.ts +29 -7
  100. package/dist/workspace/links.d.ts.map +1 -1
  101. package/dist/workspace/links.js +109 -34
  102. package/dist/workspace/member-handles.d.ts +18 -1
  103. package/dist/workspace/member-handles.d.ts.map +1 -1
  104. package/dist/workspace/member-handles.js +31 -14
  105. package/dist/workspace/nested-graph.d.ts +1 -1
  106. package/dist/workspace/nested-graph.d.ts.map +1 -1
  107. package/dist/workspace/nested-graph.js +5 -1
  108. package/dist/workspace/records-checkout.d.ts +11 -1
  109. package/dist/workspace/records-checkout.d.ts.map +1 -1
  110. package/dist/workspace/records-checkout.js +38 -2
  111. package/dist/workspace/runs.d.ts.map +1 -1
  112. package/dist/workspace/runs.js +4 -2
  113. package/dist/workspace/status.d.ts.map +1 -1
  114. package/dist/workspace/status.js +3 -2
  115. package/dist/workspace/workspace-kinds.schema.json +5 -0
  116. package/package.json +1 -1
  117. package/src/audit/rules-doc.ts +1 -1
  118. package/src/ci-jobs.ts +58 -1
  119. package/src/cli/commands/build.ts +18 -0
  120. package/src/cli/handlers/approve.ts +9 -0
  121. package/src/cli/handlers/emulator.test.ts +61 -1
  122. package/src/cli/handlers/emulator.ts +65 -4
  123. package/src/cli/handlers/graph.ts +22 -1
  124. package/src/cli/handlers/promote.test.ts +7 -0
  125. package/src/cli/handlers/promote.ts +7 -1
  126. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  127. package/src/cli/handlers/run-wave.ts +5 -4
  128. package/src/cli/handlers/run.test.ts +126 -0
  129. package/src/cli/handlers/run.ts +69 -9
  130. package/src/cli/main.test.ts +24 -0
  131. package/src/cli/main.ts +17 -4
  132. package/src/cli/parse-args.ts +21 -0
  133. package/src/cli/registry.ts +12 -0
  134. package/src/components/cli-support.test.ts +75 -0
  135. package/src/components/cli-support.ts +130 -25
  136. package/src/components/driver.test.ts +28 -0
  137. package/src/components/driver.ts +23 -10
  138. package/src/components/generate-disabled.test.ts +56 -0
  139. package/src/graph-ci-edges.test.ts +50 -0
  140. package/src/graph-detail.ts +21 -2
  141. package/src/graph-ir.test.ts +23 -1
  142. package/src/graph-ir.ts +65 -0
  143. package/src/lexicon.ts +26 -1
  144. package/src/lifecycle/git.ts +61 -6
  145. package/src/lifecycle/no-record.test.ts +175 -0
  146. package/src/lifecycle/no-record.ts +108 -0
  147. package/src/op/activities/shell.ts +28 -6
  148. package/src/op/emulator-lifecycle.test.ts +24 -1
  149. package/src/op/emulator-lifecycle.ts +58 -2
  150. package/src/op/gate.ts +3 -0
  151. package/src/op/index.ts +1 -1
  152. package/src/op/local-executor.ts +20 -3
  153. package/src/op/op-waves-jobs.test.ts +213 -0
  154. package/src/op/op-waves-notify.ts +11 -5
  155. package/src/op/op-waves-run.ts +42 -11
  156. package/src/op/op-waves.ts +202 -2
  157. package/src/op/runtimes/local.ts +6 -0
  158. package/src/op/step-console.test.ts +150 -0
  159. package/src/op/step-console.ts +155 -0
  160. package/src/point-decision-bundle.test.ts +1 -0
  161. package/src/point-decision.ts +3 -0
  162. package/src/wave-loop-golden.test.ts +3 -0
  163. package/src/workspace/checks/links.ts +57 -8
  164. package/src/workspace/checks.e2e.test.ts +1 -1
  165. package/src/workspace/checks.ts +9 -2
  166. package/src/workspace/compose-graph.test.ts +55 -0
  167. package/src/workspace/compose-graph.ts +33 -9
  168. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  169. package/src/workspace/dbt-kind.test.ts +283 -0
  170. package/src/workspace/dbt-project-reader.ts +291 -0
  171. package/src/workspace/graph-cli.ts +3 -1
  172. package/src/workspace/graph-contract.test.ts +26 -1
  173. package/src/workspace/graph.schema.json +109 -4
  174. package/src/workspace/intent.ts +7 -2
  175. package/src/workspace/kind-link-readers.ts +109 -0
  176. package/src/workspace/kinds.ts +21 -1
  177. package/src/workspace/lexicon-outputs.ts +272 -0
  178. package/src/workspace/links.test.ts +22 -0
  179. package/src/workspace/links.ts +129 -34
  180. package/src/workspace/member-handles.ts +28 -8
  181. package/src/workspace/nested-graph.test.ts +14 -0
  182. package/src/workspace/nested-graph.ts +7 -2
  183. package/src/workspace/records-checkout.ts +36 -2
  184. package/src/workspace/records-uncommitted.test.ts +5 -1
  185. package/src/workspace/runs.ts +4 -2
  186. package/src/workspace/status.ts +3 -2
  187. package/src/workspace/workspace-kinds.schema.json +5 -0
@@ -67,6 +67,7 @@
67
67
  */
68
68
 
69
69
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
70
+ import type { CiRunner } from "../ci-jobs";
70
71
  import { gatePointProblems, type GatePoint } from "./gate-point";
71
72
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
72
73
  import { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "../wave-layout";
@@ -197,6 +198,51 @@ export interface OpWaveRun {
197
198
  op?: string;
198
199
  }
199
200
 
201
+ /**
202
+ * Where a wave's jobs run (#3914): a runner label, a list of labels the
203
+ * runner must all carry, or on GitHub a runner group with optional labels.
204
+ * Rendered as `runs-on` on GitHub and Forgejo and as `tags` on GitLab; a
205
+ * runner group on GitLab or Forgejo is a spec error.
206
+ */
207
+ export type OpWaveRunner = CiRunner;
208
+
209
+ /**
210
+ * One job group of a wave (#3914): the runs it applies, and the job that
211
+ * applies them, with its own runner, forge environment, variables, setup and
212
+ * id tokens. The wave's deciding job plans every run and decides the wave's
213
+ * one gate; the group's job plans its own runs again and applies those whose
214
+ * plan has not moved since.
215
+ */
216
+ export interface OpWaveJobGroup {
217
+ /** The group's name, in its job's name (`wave-<k>-<wave>-<name>`). Lowercase letters, digits, `-` and `_`. */
218
+ name: string;
219
+ /**
220
+ * The runs it applies: targets, or patterns where `*` matches within one
221
+ * path segment and `**` across segments. Each run of the wave is in exactly
222
+ * one group. On a `discover` wave the deciding job checks this once the
223
+ * runs are found.
224
+ */
225
+ runs: string[];
226
+ /** Split the group's runs across this many jobs, as a wave's `shares` does. Default 1. */
227
+ shares?: number;
228
+ /** The group's runner. Default: the wave's, else the spec's. */
229
+ runner?: OpWaveRunner;
230
+ /** The forge deployment environment of the group's jobs (GitHub and GitLab; a spec error on Forgejo). Default: the wave's. */
231
+ environment?: OpEnvironment;
232
+ /** Variables for the group's jobs, over the wave's own. */
233
+ variables?: Record<string, string>;
234
+ /** Steps the group's jobs run after the wave's setup. */
235
+ setup?: OpSetupStep[];
236
+ /**
237
+ * The OIDC tokens the group's jobs get, as `{ <variable>: <audience> }`, in
238
+ * place of the wave's. GitLab declares them as the job's `id_tokens:`.
239
+ * GitHub and Forgejo give the job `id-token: write` and the audience as
240
+ * `<variable>_AUDIENCE`, since there the audience is chosen when the token
241
+ * is requested.
242
+ */
243
+ idTokens?: Record<string, string>;
244
+ }
245
+
200
246
  /** One wave: runs that apply together, behind one gate. */
201
247
  export interface OpWave {
202
248
  /** The wave's name, usually its environment. Lowercase letters, digits, `-` and `_`. */
@@ -247,6 +293,21 @@ export interface OpWave {
247
293
  setup?: OpSetupStep[];
248
294
  /** This wave's stage hooks (#3762), run after the spec's own. Read at the base commit. */
249
295
  hooks?: OpWaveHooks;
296
+ /** Where this wave's jobs run (#3914). Default: the spec's `runner`. */
297
+ runner?: OpWaveRunner;
298
+ /**
299
+ * The OIDC tokens this wave's jobs get (#3914), as `{ <variable>: <audience> }`;
300
+ * see {@link OpWaveJobGroup.idTokens}. With `jobs`, these are the deciding
301
+ * job's, and a group's own replace them.
302
+ */
303
+ idTokens?: Record<string, string>;
304
+ /**
305
+ * Split the wave's apply into job groups (#3914): a deciding job, which
306
+ * runs with the wave's own settings, plans every run and decides the gate,
307
+ * and a job per group (per share of a group), each applying its runs. Not
308
+ * with the wave's own `shares` (a group has its own) or `rest`.
309
+ */
310
+ jobs?: OpWaveJobGroup[];
250
311
  }
251
312
 
252
313
  /**
@@ -279,6 +340,12 @@ export interface OpWavesSpec {
279
340
  neverOverMcp?: boolean;
280
341
  /** Branches whose pushes run the waves. Default `["main"]`. */
281
342
  branches?: string[];
343
+ /**
344
+ * Where every job the spec renders runs unless a wave or a job group names
345
+ * another (#3914). Default: the forge's default runner (`ubuntu-latest` on
346
+ * GitHub, the dialect's label on Forgejo, no `tags` on GitLab).
347
+ */
348
+ runner?: OpWaveRunner;
282
349
  /**
283
350
  * The commit the gate policy is read from. Default `HEAD^1`, the first
284
351
  * parent: on a merge or squash onto the branch, the branch before the change.
@@ -423,8 +490,10 @@ export interface OpWaveJob {
423
490
  waveName: string;
424
491
  /** `wave`: plan, decide and apply. `decide`: plan and decide. `share`: apply one slice. */
425
492
  kind: "wave" | "decide" | "share";
426
- /** 1-based, on a share job. */
493
+ /** 1-based, on a share job (and a group's job when the group has shares). */
427
494
  share?: number;
495
+ /** On a job group's job (#3914): the group's name. Its kind is `share`. */
496
+ group?: string;
428
497
  /** Jobs this one waits on. */
429
498
  needs: string[];
430
499
  /** The job's command, as argv. */
@@ -508,6 +577,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
508
577
  if (problems.length > 0) throw new Error(`${where}: ${problems.join("; ")}.`);
509
578
  }
510
579
  if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
580
+ if (spec.runner !== undefined) assertOpWaveRunner(where, "runner", spec.runner);
511
581
  if (spec.notify !== undefined) {
512
582
  const problem = notifyConfigProblem(spec.notify, "notify");
513
583
  if (problem) throw new Error(`${where}: ${problem}`);
@@ -530,6 +600,9 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
530
600
  const problems = opWavePointProblems(wave.point);
531
601
  if (problems.length > 0) throw new Error(`${where}: wave "${wave.name}": ${problems.join("; ")}.`);
532
602
  }
603
+ if (wave.runner !== undefined) assertOpWaveRunner(where, `wave "${wave.name}" runner`, wave.runner);
604
+ if (wave.idTokens !== undefined) assertOpWaveIdTokens(where, `wave "${wave.name}" idTokens`, wave.idTokens);
605
+ if (wave.jobs !== undefined) assertOpWaveJobGroups(where, wave);
533
606
  if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
534
607
  if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
535
608
  if (wave.discover) {
@@ -560,7 +633,109 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
560
633
  if (shares > wave.runs.length) {
561
634
  throw new Error(`${where}: wave "${wave.name}" has ${shares} shares for ${wave.runs.length} runs, so a share would apply nothing.`);
562
635
  }
636
+ if (wave.jobs !== undefined) {
637
+ const problem = opWaveGroupsProblem(wave, wave.runs.map((r) => r.target));
638
+ if (problem) throw new Error(`${where}: ${problem}.`);
639
+ }
640
+ }
641
+ }
642
+
643
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
644
+
645
+ /** Refuse a `runner` no forge could read (#3914). */
646
+ function assertOpWaveRunner(where: string, field: string, runner: unknown): void {
647
+ const labels = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every((l) => typeof l === "string" && l.trim() !== "");
648
+ if (typeof runner === "string" && runner.trim() !== "") return;
649
+ if (labels(runner)) return;
650
+ if (runner !== null && typeof runner === "object" && !Array.isArray(runner)) {
651
+ const { group, labels: l, ...rest } = runner as Record<string, unknown>;
652
+ const extra = Object.keys(rest)[0];
653
+ if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a runner group is { "group": "<name>", "labels"?: [...] }.`);
654
+ if (typeof group === "string" && group.trim() !== "" && (l === undefined || labels(l))) return;
655
+ }
656
+ throw new Error(`${where}: ${field} must be a runner label, a non-empty list of labels, or { "group": "<name>", "labels"?: [...] } (GitHub only).`);
657
+ }
658
+
659
+ /** Refuse `idTokens` that are not `{ <variable>: <audience> }` (#3914). */
660
+ function assertOpWaveIdTokens(where: string, field: string, idTokens: unknown): void {
661
+ if (idTokens === null || typeof idTokens !== "object" || Array.isArray(idTokens) || Object.keys(idTokens).length === 0) {
662
+ throw new Error(`${where}: ${field} must be { "<variable>": "<audience>" }, with at least one token.`);
663
+ }
664
+ for (const [name, aud] of Object.entries(idTokens as Record<string, unknown>)) {
665
+ if (!ENV_NAME.test(name)) throw new Error(`${where}: ${field} names "${name}", which is not a variable name (letters, digits and "_", not starting with a digit).`);
666
+ if (typeof aud !== "string" || aud.trim() === "") throw new Error(`${where}: ${field}.${name} must be an audience, a non-empty string.`);
667
+ }
668
+ }
669
+
670
+ /** Refuse a wave's `jobs` no pipeline could render (#3914). Which runs each group holds is {@link opWaveGroupsProblem}'s. */
671
+ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
672
+ const at = `wave "${wave.name}"`;
673
+ if (!Array.isArray(wave.jobs) || wave.jobs.length === 0) throw new Error(`${where}: ${at} jobs must be a non-empty list of job groups.`);
674
+ if ((wave.shares ?? 1) > 1) throw new Error(`${where}: ${at} has jobs and shares; split each job group with its own shares.`);
675
+ if (wave.rest) throw new Error(`${where}: ${at} has jobs and rest; the waves after it run in its one job, so its apply cannot be split into groups.`);
676
+ const names = new Set<string>();
677
+ wave.jobs.forEach((group, i) => {
678
+ if (group === null || typeof group !== "object") throw new Error(`${where}: ${at} jobs[${i}] must be { "name", "runs", ... }.`);
679
+ const field = `${at} jobs[${i}]${typeof group.name === "string" ? ` (${group.name})` : ""}`;
680
+ if (typeof group.name !== "string" || !NAME.test(group.name)) throw new Error(`${where}: ${field} must be named with lowercase letters, digits, "-" and "_".`);
681
+ if (group.name === "decide" || /^share-\d+$/.test(group.name)) throw new Error(`${where}: ${field} is named like the wave's own jobs (decide, share-<i>); name it another way.`);
682
+ if (names.has(group.name)) throw new Error(`${where}: ${at} names job group "${group.name}" twice.`);
683
+ names.add(group.name);
684
+ const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens"]);
685
+ const extra = Object.keys(group).find((key) => !known.has(key));
686
+ if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a job group takes ${[...known].join(", ")}.`);
687
+ if (!Array.isArray(group.runs) || group.runs.length === 0 || !group.runs.every((r) => typeof r === "string" && r.trim() !== "")) {
688
+ throw new Error(`${where}: ${field} runs must be a non-empty list of targets or patterns.`);
689
+ }
690
+ const shares = group.shares ?? 1;
691
+ if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: ${field} has shares ${group.shares}; use a whole number from 1.`);
692
+ if (group.runner !== undefined) assertOpWaveRunner(where, `${field} runner`, group.runner);
693
+ if (group.idTokens !== undefined) assertOpWaveIdTokens(where, `${field} idTokens`, group.idTokens);
694
+ if (group.variables !== undefined && (group.variables === null || typeof group.variables !== "object" || Array.isArray(group.variables) || !Object.values(group.variables).every((v) => typeof v === "string"))) {
695
+ throw new Error(`${where}: ${field} variables must be { "<name>": "<value>" }.`);
696
+ }
697
+ if (group.setup !== undefined && !Array.isArray(group.setup)) throw new Error(`${where}: ${field} setup must be a list of steps.`);
698
+ if (group.environment !== undefined && (group.environment === null || typeof group.environment !== "object" || typeof group.environment.name !== "string")) {
699
+ throw new Error(`${where}: ${field} environment must be { "name", "url"? }.`);
700
+ }
701
+ });
702
+ }
703
+
704
+ /**
705
+ * True when `target` matches a job group's `runs` entry (#3914): the target
706
+ * itself, or a pattern where `*` matches within one `/` segment and `**`
707
+ * across segments.
708
+ */
709
+ export function opWaveRunMatches(pattern: string, target: string): boolean {
710
+ if (!pattern.includes("*")) return pattern === target;
711
+ let re = "";
712
+ for (let i = 0; i < pattern.length; i++) {
713
+ const c = pattern[i]!;
714
+ if (c === "*" && pattern[i + 1] === "*") {
715
+ re += ".*";
716
+ i++;
717
+ } else if (c === "*") re += "[^/]*";
718
+ else re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
563
719
  }
720
+ return new RegExp(`^${re}$`).test(target);
721
+ }
722
+
723
+ /** The runs of `runs` that job group `group` holds (#3914). */
724
+ export function opWaveGroupRuns<T extends { target: string }>(group: Pick<OpWaveJobGroup, "runs">, runs: readonly T[]): T[] {
725
+ return runs.filter((run) => group.runs.some((pattern) => opWaveRunMatches(pattern, run.target)));
726
+ }
727
+
728
+ /**
729
+ * What is wrong with how a wave's job groups hold `targets` (#3914): a run in
730
+ * two groups, or in none. Undefined when each run is in exactly one.
731
+ */
732
+ export function opWaveGroupsProblem(wave: Pick<OpWave, "name" | "jobs">, targets: readonly string[]): string | undefined {
733
+ for (const target of targets) {
734
+ const holders = (wave.jobs ?? []).filter((group) => group.runs.some((pattern) => opWaveRunMatches(pattern, target))).map((g) => `"${g.name}"`);
735
+ if (holders.length > 1) return `wave "${wave.name}" jobs: run "${target}" is in job groups ${holders.join(" and ")}; each run is in exactly one`;
736
+ if (holders.length === 0) return `wave "${wave.name}" jobs: run "${target}" is in no job group; each run is in exactly one`;
737
+ }
738
+ return undefined;
564
739
  }
565
740
 
566
741
  function assertArgv(where: string, field: string, argv: unknown): void {
@@ -655,7 +830,7 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
655
830
  const command = ["chant", "run", "wave", "--spec", specFile, "--wave", String(k), ...(spec.base ? ["--base", spec.base] : [])];
656
831
  const prefix = `wave-${k}-${wave.name}`;
657
832
  const shares = wave.shares ?? 1;
658
- if (shares === 1) {
833
+ if (shares === 1 && wave.jobs === undefined) {
659
834
  jobs.push({ jobName: prefix, wave: k, waveName: wave.name, kind: "wave", needs: previous, command });
660
835
  previous = [prefix];
661
836
  return;
@@ -664,6 +839,26 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
664
839
  const decide = `${prefix}-decide`;
665
840
  jobs.push({ jobName: decide, wave: k, waveName: wave.name, kind: "decide", needs: previous, command: [...command, "--decide"], record });
666
841
  const shareJobs: string[] = [];
842
+ if (wave.jobs !== undefined) {
843
+ // #3914: a job per group, or per share of a group, each applying its runs under the deciding job's decision.
844
+ for (const group of wave.jobs) {
845
+ const groupShares = group.shares ?? 1;
846
+ const groupCommand = [...command, "--group", group.name];
847
+ if (groupShares === 1) {
848
+ const jobName = `${prefix}-${group.name}`;
849
+ shareJobs.push(jobName);
850
+ jobs.push({ jobName, wave: k, waveName: wave.name, kind: "share", group: group.name, needs: [decide], command: groupCommand, record });
851
+ continue;
852
+ }
853
+ for (let share = 1; share <= groupShares; share++) {
854
+ const jobName = `${prefix}-${group.name}-share-${share}`;
855
+ shareJobs.push(jobName);
856
+ jobs.push({ jobName, wave: k, waveName: wave.name, kind: "share", group: group.name, share, needs: [decide], command: [...groupCommand, "--share", String(share)], record });
857
+ }
858
+ }
859
+ previous = shareJobs;
860
+ return;
861
+ }
667
862
  for (let share = 1; share <= shares; share++) {
668
863
  const jobName = `${prefix}-share-${share}`;
669
864
  shareJobs.push(jobName);
@@ -674,6 +869,11 @@ export function opWaveJobs(spec: OpWavesSpec, specFile: string): OpWaveJob[] {
674
869
  return jobs;
675
870
  }
676
871
 
872
+ /** The job group a job applies (#3914), or undefined for a wave's own jobs. */
873
+ export function opWaveGroupOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave" | "group">): OpWaveJobGroup | undefined {
874
+ return job.group === undefined ? undefined : spec.waves[job.wave - 1]?.jobs?.find((g) => g.name === job.group);
875
+ }
876
+
677
877
  /** The wave a job belongs to. */
678
878
  export function opWaveOf(spec: OpWavesSpec, job: Pick<OpWaveJob, "wave">): OpWave {
679
879
  return spec.waves[job.wave - 1]!;
@@ -22,6 +22,7 @@ import { lexiconNames } from "../../lexicon-module";
22
22
  import { loadChantConfig } from "../../config";
23
23
  import { loadActivities, loadProfiles } from "../activity-registry";
24
24
  import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
25
+ import { stepHeartbeatMs } from "../step-console";
25
26
  import { runComponents } from "../../components/cli-support";
26
27
  import { discoverOps } from "../discover";
27
28
  import { readRunLedger, runEnvOf, DEFAULT_RUN_ENV } from "../../lifecycle/run-ledger";
@@ -172,6 +173,11 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
172
173
  `appended to the run ledger: ${err instanceof Error ? err.message : String(err)}\n`,
173
174
  ),
174
175
  ...(startOpts.progress ? { onRecord: startOpts.progress } : {}),
176
+ // #3956: a running step's output, and a still-running line while
177
+ // it is quiet, on stderr as they happen; stdout stays the
178
+ // `--json` record's and `--progress-json`'s.
179
+ stepOutput: (line) => process.stderr.write(`${line}\n`),
180
+ stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
175
181
  ...(startOpts.work ? { work: startOpts.work } : {}),
176
182
  onWorkLeaseWarning: (message) =>
177
183
  process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A running step's console (#3956): a step's output is printed as it
3
+ * arrives, each line carrying its phase and step, and a quiet step says it
4
+ * is still running.
5
+ */
6
+
7
+ import { describe, test, expect, vi } from "vitest";
8
+ import { shellCmd, type ShellCmdArgs } from "./activities/shell";
9
+ import { activity, phase, shell } from "./builders";
10
+ import { ACTIVITY_PROFILES } from "./activity-profiles";
11
+ import { runOpLocally } from "./local-executor";
12
+ import { memoryGateLedgerPort } from "./gate";
13
+ import type { ActivityFn } from "./activity-registry";
14
+ import { DEFAULT_STEP_HEARTBEAT_MS, stepHeartbeatMs, stepLabel } from "./step-console";
15
+
16
+ const shellFn: ActivityFn = (args, signal) => shellCmd(args as unknown as ShellCmdArgs, signal);
17
+
18
+ /** A writer that keeps each line with when it arrived. */
19
+ function recorder() {
20
+ const lines: Array<{ at: number; text: string }> = [];
21
+ return { lines, write: (text: string) => void lines.push({ at: Date.now(), text }) };
22
+ }
23
+
24
+ // A command that writes a line, waits, and writes another on stderr, then a
25
+ // last line on stdout with no newline.
26
+ const SLOW = `node -e "console.log('first'); setTimeout(() => { console.error('second'); process.stdout.write('third'); }, 600)"`;
27
+
28
+ describe("a step's output streams as it arrives (#3956)", () => {
29
+ test("the first line is printed before the command exits, prefixed with its phase and step", async () => {
30
+ const out = recorder();
31
+ let ended = 0;
32
+ const timed: ActivityFn = async (args, signal) => {
33
+ try {
34
+ return await shellFn(args, signal);
35
+ } finally {
36
+ ended = Date.now();
37
+ }
38
+ };
39
+ const result = await runOpLocally(
40
+ { name: "seed", overview: "", phases: [phase("Seed", [shell(SLOW, { id: "wait-agent" })])] },
41
+ new Map([["shellCmd", timed]]),
42
+ ACTIVITY_PROFILES,
43
+ undefined,
44
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
45
+ );
46
+
47
+ expect(result.status).toBe("ok");
48
+ expect(out.lines.map((l) => l.text)).toEqual([
49
+ "[Seed] wait-agent | first",
50
+ "[Seed] wait-agent | second",
51
+ "[Seed] wait-agent | third",
52
+ ]);
53
+ // The first line arrived well before the command ended, not with it.
54
+ expect(ended - out.lines[0]!.at).toBeGreaterThan(300);
55
+ });
56
+
57
+ test("a step without an id is named by its call, as the run's render names it", async () => {
58
+ const out = recorder();
59
+ await runOpLocally(
60
+ { name: "hello", overview: "", phases: [phase("Go", [shell("echo hi")])] },
61
+ new Map([["shellCmd", shellFn]]),
62
+ ACTIVITY_PROFILES,
63
+ undefined,
64
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
65
+ );
66
+ expect(out.lines.map((l) => l.text)).toEqual(["[Go] shellCmd(cmd=echo hi) | hi"]);
67
+ });
68
+
69
+ test("a failing command's output is printed too, before the failure", async () => {
70
+ const out = recorder();
71
+ await expect(
72
+ runOpLocally(
73
+ { name: "broken", overview: "", phases: [phase("Go", [shell(`node -e "console.error('no such table'); process.exit(2)"`, { id: "migrate" })])] },
74
+ new Map([["shellCmd", shellFn]]),
75
+ ACTIVITY_PROFILES,
76
+ undefined,
77
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 0 },
78
+ ),
79
+ ).rejects.toThrow();
80
+ expect(out.lines.map((l) => l.text)).toEqual(["[Go] migrate | no such table"]);
81
+ });
82
+
83
+ test("without a writer nothing is printed while the step runs, and stdout is not echoed", async () => {
84
+ const written = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
85
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
86
+ const result = await runOpLocally(
87
+ { name: "quiet", overview: "", phases: [phase("Go", [shell("echo hi", { id: "say" })])] },
88
+ new Map([["shellCmd", shellFn]]),
89
+ ACTIVITY_PROFILES,
90
+ undefined,
91
+ { gates: memoryGateLedgerPort() },
92
+ ).finally(() => {
93
+ written.mockRestore();
94
+ logged.mockRestore();
95
+ });
96
+ expect(result.status).toBe("ok");
97
+ expect(written).not.toHaveBeenCalled();
98
+ expect(logged).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe("a quiet step says it is still running (#3956)", () => {
103
+ test("an activity that only returns a value gets a still-running line each interval", async () => {
104
+ const out = recorder();
105
+ const slow: ActivityFn = () => new Promise((r) => setTimeout(() => r({ done: true }), 280));
106
+ await runOpLocally(
107
+ { name: "wait", overview: "", phases: [phase("Deploy", [activity("waitForAgent", {}, { id: "agent" })])] },
108
+ new Map([["waitForAgent", slow]]),
109
+ ACTIVITY_PROFILES,
110
+ undefined,
111
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 100 },
112
+ );
113
+ expect(out.lines.map((l) => l.text.replace(/\d+s$/, "Ns"))).toEqual([
114
+ "[Deploy] agent: still running after Ns",
115
+ "[Deploy] agent: still running after Ns",
116
+ ]);
117
+ });
118
+
119
+ test("a step printing output is not interrupted by the still-running line", async () => {
120
+ const out = recorder();
121
+ const chatty = `node -e "let n = 0; const t = setInterval(() => { console.log('tick ' + n); if (++n === 6) clearInterval(t); }, 40)"`;
122
+ await runOpLocally(
123
+ { name: "chatty", overview: "", phases: [phase("Go", [shell(chatty, { id: "ticks" })])] },
124
+ new Map([["shellCmd", shellFn]]),
125
+ ACTIVITY_PROFILES,
126
+ undefined,
127
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 100 },
128
+ );
129
+ expect(out.lines.filter((l) => l.text.includes("still running"))).toEqual([]);
130
+ expect(out.lines.map((l) => l.text)).toContain("[Go] ticks | tick 5");
131
+ });
132
+
133
+ test("CHANT_STEP_HEARTBEAT sets the interval, and 0 or off turns it off", () => {
134
+ expect(stepHeartbeatMs({})).toBe(DEFAULT_STEP_HEARTBEAT_MS);
135
+ expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "30s" })).toBe(30_000);
136
+ expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "5m" })).toBe(300_000);
137
+ expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "0" })).toBe(0);
138
+ expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "off" })).toBe(0);
139
+ const warnings: string[] = [];
140
+ expect(stepHeartbeatMs({ CHANT_STEP_HEARTBEAT: "soon" }, (m) => warnings.push(m))).toBe(DEFAULT_STEP_HEARTBEAT_MS);
141
+ expect(warnings).toHaveLength(1);
142
+ });
143
+
144
+ test("a long call is cut to a readable label", () => {
145
+ const label = stepLabel("shellCmd", { cmd: "x".repeat(200) });
146
+ expect(label.length).toBeLessThanOrEqual(60);
147
+ expect(label.endsWith("...)")).toBe(true);
148
+ expect(stepLabel("shellCmd", { cmd: "echo hi" }, "say")).toBe("say");
149
+ });
150
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * A running step's console (#3956): its output as it arrives, and a
3
+ * "still running" line while it is quiet.
4
+ *
5
+ * The run's own render (`./local-output.ts`) is printed once the run
6
+ * settles, so before this a 15-minute step printed nothing for 15 minutes and
7
+ * a watchdog reading the output killed a healthy run. When the caller hands
8
+ * the executor a writer (`RunOpOptions.stepOutput`, which the local runtime
9
+ * passes, writing to stderr), each activity step runs with a console:
10
+ *
11
+ * - an activity that runs a process (`shellCmd`) passes each line the process
12
+ * writes, stdout and stderr alike, through {@link stepOutputLine}, and it is
13
+ * printed at once as `[<phase>] <step> | <line>`;
14
+ * - a step that prints nothing for `heartbeatMs` (an activity that only
15
+ * returns a value, or a command that is quiet) gets
16
+ * `[<phase>] <step>: still running after <n>s` each time that much passes.
17
+ *
18
+ * `<step>` is the step's id when it has one, else its call as the run's
19
+ * render names it, `fn(arg=value, ...)`, cut to a readable length. Without a
20
+ * writer there is no console, and `shellCmd` prints a command's stderr when it
21
+ * ends, as it did before.
22
+ */
23
+
24
+ import { AsyncLocalStorage } from "node:async_hooks";
25
+ import { parseDuration } from "./duration";
26
+
27
+ /** How long a step may be quiet before the runner says it is still running. */
28
+ export const DEFAULT_STEP_HEARTBEAT_MS = 60_000;
29
+
30
+ /** The environment variable that sets the interval: a duration ("30s", "5m"), or `0` / `off`. */
31
+ export const STEP_HEARTBEAT_ENV = "CHANT_STEP_HEARTBEAT";
32
+
33
+ /** The longest step label a line carries before it is cut. */
34
+ const MAX_LABEL = 60;
35
+
36
+ interface StepConsole {
37
+ line(text: string): void;
38
+ }
39
+
40
+ /** Where a run's step lines go, and how often a quiet step says it is still running. */
41
+ export interface RunConsole {
42
+ write: (line: string) => void;
43
+ heartbeatMs?: number;
44
+ }
45
+
46
+ const store = new AsyncLocalStorage<StepConsole>();
47
+ const runStore = new AsyncLocalStorage<RunConsole>();
48
+
49
+ /** Run `fn` as a run whose steps print through `run`. Without one, steps get no console. */
50
+ export function withRunConsole<T>(run: RunConsole | undefined, fn: () => Promise<T>): Promise<T> {
51
+ return run ? runStore.run(run, fn) : fn();
52
+ }
53
+
54
+ function formatArgs(args: Record<string, unknown>): string {
55
+ return Object.entries(args)
56
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
57
+ .join(", ");
58
+ }
59
+
60
+ /** The name a step's lines carry: its id, else `fn(args)` on one line, cut to {@link MAX_LABEL}. */
61
+ export function stepLabel(fn: string, args: Record<string, unknown>, id?: string): string {
62
+ if (id) return id;
63
+ const call = `${fn}(${formatArgs(args)})`.replace(/\s+/g, " ");
64
+ return call.length > MAX_LABEL ? `${call.slice(0, MAX_LABEL - 4)}...)` : call;
65
+ }
66
+
67
+ /**
68
+ * Run `fn` with a console for one step, when the run has one
69
+ * ({@link withRunConsole}). The heartbeat timer is stopped when `fn`
70
+ * settles; it never holds the process open on its own.
71
+ */
72
+ export async function withStepConsole<T>(opts: { phase: string; label: string }, fn: () => Promise<T>): Promise<T> {
73
+ const run = runStore.getStore();
74
+ if (!run) return fn();
75
+ const { write } = run;
76
+ const prefix = `[${opts.phase}] ${opts.label}`;
77
+ const started = Date.now();
78
+ let spoke = false;
79
+ const sink: StepConsole = {
80
+ line(text) {
81
+ spoke = true;
82
+ write(`${prefix} | ${text}`);
83
+ },
84
+ };
85
+ const every = run.heartbeatMs ?? DEFAULT_STEP_HEARTBEAT_MS;
86
+ const timer = every > 0
87
+ ? setInterval(() => {
88
+ if (spoke) {
89
+ spoke = false;
90
+ return;
91
+ }
92
+ write(`${prefix}: still running after ${Math.round((Date.now() - started) / 1000)}s`);
93
+ }, every)
94
+ : undefined;
95
+ timer?.unref();
96
+ try {
97
+ return await store.run(sink, fn);
98
+ } finally {
99
+ if (timer) clearInterval(timer);
100
+ }
101
+ }
102
+
103
+ /** Whether the calling activity runs under a step console, so its output is printed as it arrives. */
104
+ export function hasStepConsole(): boolean {
105
+ return store.getStore() !== undefined;
106
+ }
107
+
108
+ /** Print one line of the calling step's output. A no-op outside a step console. */
109
+ export function stepOutputLine(text: string): void {
110
+ store.getStore()?.line(text);
111
+ }
112
+
113
+ /**
114
+ * A sink for a stream's chunks that prints whole lines to the calling step's
115
+ * console as they complete; `end()` prints what is left. The console is the
116
+ * one current when the splitter is made, since a stream's `data` events need
117
+ * not run in the step's async context.
118
+ */
119
+ export function stepLineSplitter(): { push(chunk: string | Buffer): void; end(): void } {
120
+ const sink = store.getStore();
121
+ let pending = "";
122
+ const emit = (line: string) => sink?.line(line.endsWith("\r") ? line.slice(0, -1) : line);
123
+ return {
124
+ push(chunk) {
125
+ pending += typeof chunk === "string" ? chunk : chunk.toString("utf8");
126
+ let at = pending.indexOf("\n");
127
+ while (at >= 0) {
128
+ emit(pending.slice(0, at));
129
+ pending = pending.slice(at + 1);
130
+ at = pending.indexOf("\n");
131
+ }
132
+ },
133
+ end() {
134
+ if (pending !== "") emit(pending);
135
+ pending = "";
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The heartbeat interval `CHANT_STEP_HEARTBEAT` sets, or the default. `0` and
142
+ * `off` turn it off. A value that is not a duration falls back to the default
143
+ * and says so through `warn`.
144
+ */
145
+ export function stepHeartbeatMs(env: NodeJS.ProcessEnv = process.env, warn?: (message: string) => void): number {
146
+ const raw = env[STEP_HEARTBEAT_ENV]?.trim();
147
+ if (!raw) return DEFAULT_STEP_HEARTBEAT_MS;
148
+ if (raw === "0" || raw.toLowerCase() === "off") return 0;
149
+ try {
150
+ return parseDuration(raw);
151
+ } catch {
152
+ warn?.(`${STEP_HEARTBEAT_ENV}="${raw}" is not a duration (such as 30s or 5m) or off; using ${DEFAULT_STEP_HEARTBEAT_MS / 1000}s`);
153
+ return DEFAULT_STEP_HEARTBEAT_MS;
154
+ }
155
+ }
@@ -51,6 +51,7 @@ describe("the point-decision bundle", () => {
51
51
  "packages/core/src/intrinsic.ts",
52
52
  "packages/core/src/lifecycle/gate-ledger-select.ts",
53
53
  "packages/core/src/lifecycle/gate-pending.ts",
54
+ "packages/core/src/lifecycle/no-record.ts",
54
55
  "packages/core/src/lifecycle/plan-digest.ts",
55
56
  "packages/core/src/op/duration.ts",
56
57
  "packages/core/src/op/gate-point.ts",
@@ -37,6 +37,7 @@ import type { GateLedgerPort } from "./op/gate";
37
37
  import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
38
38
  import type { WaitingPoint } from "./op/steward-points";
39
39
  import { resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
40
+ import { refuseGateWithoutRecording } from "./lifecycle/no-record";
40
41
 
41
42
  export {
42
43
  DEFAULT_GATE_POINT_PASS,
@@ -170,6 +171,8 @@ function resolvedByOf(q: GatePointQuestion): string {
170
171
 
171
172
  /** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
172
173
  export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
174
+ // #3955: refused before the point is asked, which would itself write to the ledger.
175
+ refuseGateWithoutRecording(input.op, input.gate);
173
176
  const now = input.now ?? new Date().toISOString();
174
177
  const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
175
178
  const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env), ...(input.point.store ? { store: input.point.store } : {}) });
@@ -43,6 +43,9 @@ beforeEach(() => {
43
43
  // A CI job's locator would land on every pending fact.
44
44
  vi.stubEnv("GITHUB_ACTIONS", "");
45
45
  vi.stubEnv("GITLAB_CI", "");
46
+ // A pull request's address would land on every pending fact too.
47
+ vi.stubEnv("GITHUB_REF_NAME", "");
48
+ vi.stubEnv("CI_MERGE_REQUEST_IID", "");
46
49
  });
47
50
  afterEach(() => vi.unstubAllEnvs());
48
51