@intentius/chant 0.124.40 → 0.124.41

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 (281) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A failed member blocks only the members that depend on it (#4291): the
3
+ * rule, the walk over earlier waves, and the outcome files a wave job hands
4
+ * the next one. The rules are terragucci's `blocked.ts`, lifted.
5
+ */
6
+
7
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, test } from "vitest";
11
+ import {
12
+ blockedMembers,
13
+ blockedWords,
14
+ earlierWaveOutcomes,
15
+ onlyWaiting,
16
+ runSummary,
17
+ settleEarlierWaves,
18
+ waveOutcomeFileName,
19
+ writeWaveOutcomeFile,
20
+ type WaveMemberOutcome,
21
+ } from "./wave-blocked";
22
+
23
+ /** cluster-a and cluster-b, then app-01 (reads cluster-a) and app-02 (reads cluster-b), then edge (reads app-01). */
24
+ const UPSTREAMS = new Map<string, string[]>([
25
+ ["app-01", ["cluster-a"]],
26
+ ["app-02", ["cluster-b"]],
27
+ ["edge", ["app-01"]],
28
+ ]);
29
+ const WAVES = [["cluster-a", "cluster-b"], ["app-01", "app-02"], ["edge"]];
30
+
31
+ describe("blockedMembers", () => {
32
+ test("a failed upstream blocks its dependents and names itself; the rest go through", () => {
33
+ const outcomes = new Map<string, WaveMemberOutcome>([
34
+ ["cluster-a", { status: "failed" }],
35
+ ["cluster-b", { status: "applied" }],
36
+ ]);
37
+ const blocked = blockedMembers(["app-01", "app-02"], UPSTREAMS, new Set(WAVES[0]), outcomes);
38
+ expect([...blocked]).toEqual([["app-01", "cluster-a"]]);
39
+ });
40
+
41
+ test("a blocked upstream passes on the member that blocked it", () => {
42
+ const outcomes = new Map<string, WaveMemberOutcome>([["app-01", { status: "blocked", by: "cluster-a" }]]);
43
+ expect([...blockedMembers(["edge"], UPSTREAMS, new Set(["app-01"]), outcomes)]).toEqual([["edge", "cluster-a"]]);
44
+ });
45
+
46
+ test("unchanged lets a dependent through; an upstream with no outcome holds it", () => {
47
+ expect(blockedMembers(["app-01"], UPSTREAMS, new Set(["cluster-a"]), new Map([["cluster-a", { status: "unchanged" }]])).size).toBe(0);
48
+ expect([...blockedMembers(["app-01"], UPSTREAMS, new Set(["cluster-a"]), new Map())]).toEqual([["app-01", "cluster-a"]]);
49
+ });
50
+
51
+ test("an upstream outside the earlier waves is no upstream here", () => {
52
+ expect(blockedMembers(["app-01"], UPSTREAMS, new Set(), new Map([["cluster-a", { status: "failed" }]])).size).toBe(0);
53
+ });
54
+
55
+ test("a wave that holds all blocks every member by its name", () => {
56
+ expect([...blockedMembers(["app-01", "app-02"], UPSTREAMS, new Set(), new Map(), 1)]).toEqual([
57
+ ["app-01", "wave-1"],
58
+ ["app-02", "wave-1"],
59
+ ]);
60
+ });
61
+ });
62
+
63
+ describe("settleEarlierWaves", () => {
64
+ test("walks the earlier waves, blocking behind failures, and names what has not run", () => {
65
+ const { outcomes, unsettled } = settleEarlierWaves(
66
+ WAVES,
67
+ 4,
68
+ UPSTREAMS,
69
+ new Map([
70
+ ["cluster-a", { status: "failed" }],
71
+ ["cluster-b", { status: "applied" }],
72
+ ]),
73
+ );
74
+ expect(outcomes.get("app-01")).toEqual({ status: "blocked", by: "cluster-a" });
75
+ expect(outcomes.get("edge")).toEqual({ status: "blocked", by: "cluster-a" });
76
+ expect(unsettled).toEqual(["app-02"]);
77
+ });
78
+ });
79
+
80
+ describe("onlyWaiting, blockedWords and runSummary", () => {
81
+ test("a run whose only holds are a wait, and what it blocked, waits", () => {
82
+ expect(
83
+ onlyWaiting(
84
+ new Map<string, WaveMemberOutcome>([
85
+ ["cluster-a", { status: "waiting" }],
86
+ ["app-01", { status: "blocked", by: "cluster-a" }],
87
+ ["cluster-b", { status: "applied" }],
88
+ ]),
89
+ ),
90
+ ).toBe(true);
91
+ expect(onlyWaiting(new Map<string, WaveMemberOutcome>([["cluster-a", { status: "failed" }]]))).toBe(false);
92
+ expect(onlyWaiting(new Map<string, WaveMemberOutcome>([["cluster-a", { status: "applied" }]]))).toBe(false);
93
+ });
94
+
95
+ test("the blocked set reads grouped by what blocked it", () => {
96
+ expect(blockedWords(new Map([["edge", "cluster-a"], ["app-01", "cluster-a"], ["app-02", "cluster-b"]]))).toBe(
97
+ "app-01, edge (blocked by cluster-a); app-02 (blocked by cluster-b)",
98
+ );
99
+ });
100
+
101
+ test("the run's summary counts each status, and is undefined when all applied", () => {
102
+ expect(runSummary([{ status: "applied" }, { status: "unchanged" }])).toBeUndefined();
103
+ expect(runSummary([{ status: "applied" }, { status: "failed" }, { status: "blocked", by: "x" }, { status: "waiting" }])).toBe(
104
+ "1 applied, 1 failed, 1 blocked, 1 waiting",
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("outcome files", () => {
110
+ test("the newest outcome of a member wins, later waves are left out, and terragucci's roots key reads too", () => {
111
+ const dir = mkdtempSync(join(tmpdir(), "chant-wave-outcomes-"));
112
+ try {
113
+ writeWaveOutcomeFile(dir, 1, {}, { "cluster-a": { status: "failed" }, "cluster-b": { status: "applied" } }, "2026-10-18T10:00:00.000Z");
114
+ const rerun = join(dir, "rerun");
115
+ mkdirSync(rerun);
116
+ writeWaveOutcomeFile(rerun, 1, {}, { "cluster-a": { status: "applied" } }, "2026-10-18T11:00:00.000Z");
117
+ writeFileSync(
118
+ join(dir, waveOutcomeFileName(2, { share: 1 })),
119
+ JSON.stringify({ version: 1, wave: 2, at: "2026-10-18T10:05:00.000Z", roots: { "app-01": { status: "applied" } } }),
120
+ );
121
+ writeWaveOutcomeFile(dir, 3, {}, { edge: { status: "failed" } }, "2026-10-18T10:10:00.000Z");
122
+ writeFileSync(join(dir, "outcome-wave-9.json"), "not json");
123
+
124
+ const { members, holdsAll } = earlierWaveOutcomes([dir], 3);
125
+ expect(holdsAll).toBeUndefined();
126
+ expect(Object.fromEntries([...members].map(([m, o]) => [m, o.status]))).toEqual({
127
+ "cluster-a": "applied",
128
+ "cluster-b": "applied",
129
+ "app-01": "applied",
130
+ });
131
+ } finally {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test("a wave that holds all is named", () => {
137
+ const dir = mkdtempSync(join(tmpdir(), "chant-wave-outcomes-"));
138
+ try {
139
+ writeWaveOutcomeFile(dir, 1, { group: "core/x" }, {}, "2026-10-18T10:00:00.000Z", true);
140
+ expect(waveOutcomeFileName(1, { group: "core/x" })).toBe("outcome-wave-1-group-core_x.json");
141
+ expect(earlierWaveOutcomes([dir], 2).holdsAll).toBe(1);
142
+ } finally {
143
+ rmSync(dir, { recursive: true, force: true });
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,271 @@
1
+ /**
2
+ * A failed member blocks only the members that depend on it (#4291).
3
+ *
4
+ * A run applied a wave at a time stops a member when something it reads did
5
+ * not apply, and lets every other member go on. This module holds the rule
6
+ * and the records it reads, with no runner in it, so a product that runs its
7
+ * waves in its own CI stages (terragucci's roots, lifted from its
8
+ * `blocked.ts`) and chant's own wave jobs (`pr-apply --wave-gate`,
9
+ * `chant components fan-out --wave-gate`) block the same members for the
10
+ * same reasons. It imports `node:fs` and `node:path` and nothing else, so it
11
+ * bundles small (`wave-blocked-bundle.test.ts`).
12
+ *
13
+ * ## Outcomes
14
+ *
15
+ * Each member of an earlier wave has an outcome. `applied` and `unchanged`
16
+ * let a dependent through. Every other status holds it:
17
+ *
18
+ * - `failed`: its plan or its apply failed.
19
+ * - `waiting`: its wave waits for an approval.
20
+ * - `refused`: its wave was refused, held or stood down.
21
+ * - `decided`: its wave's deciding job decided it, and a share job applies it.
22
+ * - `not-applied`: its wave applied nothing because another member of it failed to plan.
23
+ * - `blocked`: something it reads did not apply. `by` names the member whose
24
+ * outcome held it, past any member blocked in between.
25
+ *
26
+ * ## Outcome files
27
+ *
28
+ * A wave job that hands the next job its outcomes as files writes one per
29
+ * job, `outcome-wave-<k>[-group-<g>][-share-<i>].json`:
30
+ *
31
+ * {
32
+ * "version": 1, "wave": 2, "at": "2026-10-18T10:00:09.000Z",
33
+ * "members": {
34
+ * "cluster-b": { "status": "applied" },
35
+ * "cluster-a": { "status": "failed" },
36
+ * "app-01": { "status": "blocked", "by": "cluster-a" }
37
+ * }
38
+ * }
39
+ *
40
+ * A wave job runs again on a re-run, so the newest outcome of a member wins.
41
+ * A file written with `roots` in place of `members` (terragucci's, before
42
+ * this module) reads the same. `holds_all` marks a wave that holds every
43
+ * later member, such as a wave of state migrations that did not apply: each
44
+ * later member is blocked by `wave-<k>`.
45
+ */
46
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
47
+ import { join } from "node:path";
48
+
49
+ export const WAVE_OUTCOME_VERSION = 1;
50
+
51
+ export type WaveMemberStatus = "applied" | "unchanged" | "failed" | "waiting" | "refused" | "decided" | "not-applied" | "blocked";
52
+
53
+ export interface WaveMemberOutcome {
54
+ status: WaveMemberStatus;
55
+ /** A blocked member's: the member whose outcome held it, past any member blocked in between. */
56
+ by?: string;
57
+ }
58
+
59
+ export interface WaveOutcomeFile {
60
+ version: typeof WAVE_OUTCOME_VERSION;
61
+ wave: number;
62
+ /** When the job wrote it: the newest outcome of a member wins. */
63
+ at: string;
64
+ members: Record<string, WaveMemberOutcome>;
65
+ /** The wave holds every later member, each blocked by `wave-<k>`. */
66
+ holds_all?: true;
67
+ }
68
+
69
+ const THROUGH: ReadonlySet<WaveMemberStatus> = new Set(["applied", "unchanged"]);
70
+
71
+ /** Whether `outcome` lets a dependent through: it applied, or had nothing to apply. */
72
+ export function passesThrough(outcome: WaveMemberOutcome | undefined): boolean {
73
+ return outcome !== undefined && THROUGH.has(outcome.status);
74
+ }
75
+
76
+ /**
77
+ * The members of `members` an upstream that did not apply holds, each with
78
+ * the member whose outcome holds it. `upstreams` is each member's upstreams;
79
+ * only those in `earlier` count (a member of this wave or a later one is no
80
+ * upstream it waits for here). An upstream with no outcome did not apply as
81
+ * far as this run knows, so it holds its dependents and names itself. A
82
+ * blocked upstream passes on the member that blocked it. With `holdsAll`,
83
+ * every member is blocked by `wave-<holdsAll>`.
84
+ */
85
+ export function blockedMembers(
86
+ members: readonly string[],
87
+ upstreams: ReadonlyMap<string, Iterable<string>>,
88
+ earlier: ReadonlySet<string>,
89
+ outcomes: ReadonlyMap<string, WaveMemberOutcome>,
90
+ holdsAll?: number,
91
+ ): Map<string, string> {
92
+ if (holdsAll !== undefined) return new Map(members.map((m) => [m, `wave-${holdsAll}`]));
93
+ const out = new Map<string, string>();
94
+ for (const member of members) {
95
+ const by = [...(upstreams.get(member) ?? [])]
96
+ .filter((u) => earlier.has(u))
97
+ .sort()
98
+ .map((u) => {
99
+ const o = outcomes.get(u);
100
+ if (passesThrough(o)) return undefined;
101
+ return o?.status === "blocked" && o.by ? o.by : u;
102
+ })
103
+ .find((b): b is string => b !== undefined);
104
+ if (by !== undefined) out.set(member, by);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * Walk waves 1 to `before - 1` and give each member that has no outcome the
111
+ * one it ends with: `blocked` behind an upstream that did not apply. A member
112
+ * with no outcome that nothing blocks has not run yet: it is in `unsettled`,
113
+ * and wave `before` cannot run until it has. `outcomes` is what the records
114
+ * say: `applied` for what applied, and `failed`, `waiting` or `not-applied`
115
+ * for what did not. Returns those outcomes with the blocked members added.
116
+ */
117
+ export function settleEarlierWaves(
118
+ waves: readonly (readonly string[])[],
119
+ before: number,
120
+ upstreams: ReadonlyMap<string, Iterable<string>>,
121
+ outcomes: ReadonlyMap<string, WaveMemberOutcome>,
122
+ ): { outcomes: Map<string, WaveMemberOutcome>; unsettled: string[] } {
123
+ const out = new Map(outcomes);
124
+ const unsettled: string[] = [];
125
+ const earlier = new Set<string>();
126
+ for (let k = 1; k < before && k <= waves.length; k++) {
127
+ const wave = waves[k - 1]!;
128
+ const open = wave.filter((m) => !out.has(m));
129
+ const blocked = blockedMembers(open, upstreams, earlier, out);
130
+ for (const m of open) {
131
+ const by = blocked.get(m);
132
+ if (by !== undefined) out.set(m, { status: "blocked", by });
133
+ else unsettled.push(m);
134
+ }
135
+ for (const m of wave) earlier.add(m);
136
+ }
137
+ return { outcomes: out, unsettled };
138
+ }
139
+
140
+ /**
141
+ * Whether every member that did not apply waits for an approval, or is
142
+ * blocked behind one that does: the run waits rather than failed, so the
143
+ * job ends as waiting (exit 3), and the approval's re-run applies them.
144
+ */
145
+ export function onlyWaiting(outcomes: ReadonlyMap<string, WaveMemberOutcome>): boolean {
146
+ let held = false;
147
+ for (const o of outcomes.values()) {
148
+ if (THROUGH.has(o.status)) continue;
149
+ held = true;
150
+ if (o.status === "waiting") continue;
151
+ if (o.status === "blocked" && o.by !== undefined && outcomes.get(o.by)?.status === "waiting") continue;
152
+ return false;
153
+ }
154
+ return held;
155
+ }
156
+
157
+ const byName = ([a]: [string, unknown], [b]: [string, unknown]): number => (a < b ? -1 : a > b ? 1 : 0);
158
+
159
+ /** The blocked members grouped by what blocked them, as a log and a note list them: `app-01, app-02 (blocked by cluster-a)`. */
160
+ export function blockedWords(blocked: ReadonlyMap<string, string>): string {
161
+ const byRoot = new Map<string, string[]>();
162
+ for (const [member, by] of [...blocked].sort(byName)) byRoot.set(by, [...(byRoot.get(by) ?? []), member]);
163
+ return [...byRoot].map(([by, ms]) => `${ms.join(", ")} (blocked by ${by})`).join("; ");
164
+ }
165
+
166
+ /**
167
+ * How the run came out, from every member's newest outcome: "8 applied, 1
168
+ * failed, 6 blocked", with waiting, refused and the rest named when there
169
+ * are any, and undefined when every member applied or had nothing to apply.
170
+ */
171
+ export function runSummary(outcomes: Iterable<WaveMemberOutcome>): string | undefined {
172
+ const counts = new Map<WaveMemberStatus, number>();
173
+ for (const o of outcomes) counts.set(o.status, (counts.get(o.status) ?? 0) + 1);
174
+ if (![...counts.keys()].some((s) => !THROUGH.has(s))) return undefined;
175
+ const parts = [`${counts.get("applied") ?? 0} applied`, `${counts.get("failed") ?? 0} failed`, `${counts.get("blocked") ?? 0} blocked`];
176
+ for (const s of ["waiting", "refused", "decided", "not-applied"] as const) {
177
+ if (counts.get(s)) parts.push(`${counts.get(s)} ${s === "decided" ? "not reported by their share" : s}`);
178
+ }
179
+ if (counts.get("unchanged")) parts.push(`${counts.get("unchanged")} unchanged`);
180
+ return parts.join(", ");
181
+ }
182
+
183
+ /** The outcome file of a wave's job, or of one group or share of it. */
184
+ export function waveOutcomeFileName(wave: number, part: { group?: string; share?: number } = {}): string {
185
+ const group = part.group !== undefined ? `-group-${part.group.replace(/[^A-Za-z0-9._-]/g, "_")}` : "";
186
+ const share = part.share !== undefined ? `-share-${part.share}` : "";
187
+ return `outcome-wave-${wave}${group}${share}.json`;
188
+ }
189
+
190
+ /** Write a wave job's outcome file in `dir`, members sorted by name. Returns its path. */
191
+ export function writeWaveOutcomeFile(
192
+ dir: string,
193
+ wave: number,
194
+ part: { group?: string; share?: number },
195
+ members: Record<string, WaveMemberOutcome>,
196
+ at = new Date().toISOString(),
197
+ holdsAll = false,
198
+ ): string {
199
+ const file = join(dir, waveOutcomeFileName(wave, part));
200
+ const body: WaveOutcomeFile = {
201
+ version: WAVE_OUTCOME_VERSION,
202
+ wave,
203
+ at,
204
+ members: Object.fromEntries(Object.entries(members).sort(byName)),
205
+ ...(holdsAll ? { holds_all: true as const } : {}),
206
+ };
207
+ writeFileSync(file, JSON.stringify(body, null, 2) + "\n");
208
+ return file;
209
+ }
210
+
211
+ /** Every outcome file under `dirs`, four directories deep at most: downloaded artifacts keep each job's in a directory of its own. */
212
+ function outcomeFiles(dirs: readonly string[]): string[] {
213
+ const out: string[] = [];
214
+ const walk = (dir: string, depth: number): void => {
215
+ let names: string[];
216
+ try {
217
+ names = readdirSync(dir);
218
+ } catch {
219
+ return;
220
+ }
221
+ for (const name of names) {
222
+ const abs = join(dir, name);
223
+ let isDir = false;
224
+ try {
225
+ isDir = statSync(abs).isDirectory();
226
+ } catch {
227
+ continue;
228
+ }
229
+ if (isDir) {
230
+ if (depth < 4) walk(abs, depth + 1);
231
+ } else if (/^outcome-wave-\d+.*\.json$/.test(name)) out.push(abs);
232
+ }
233
+ };
234
+ for (const d of dirs) if (existsSync(d)) walk(d, 0);
235
+ return out.sort();
236
+ }
237
+
238
+ /**
239
+ * The outcome of each member of the waves before `wave`, as their jobs wrote
240
+ * them under `dirs`, and the earliest such wave that holds every later
241
+ * member, if any. For a member more than one job wrote (a re-run, or a split
242
+ * wave's deciding job and the share that applied it), the newest. A file
243
+ * that does not parse, or is of another version, is skipped.
244
+ */
245
+ export function earlierWaveOutcomes(
246
+ dirs: readonly string[],
247
+ wave: number,
248
+ ): { members: Map<string, WaveMemberOutcome & { wave: number; at: string }>; holdsAll?: number } {
249
+ const out = new Map<string, WaveMemberOutcome & { wave: number; at: string }>();
250
+ const newest = new Map<number, { at: string; holdsAll: boolean }>();
251
+ for (const file of outcomeFiles(dirs)) {
252
+ let f: Partial<WaveOutcomeFile> & { roots?: unknown };
253
+ try {
254
+ f = JSON.parse(readFileSync(file, "utf-8"));
255
+ } catch {
256
+ continue;
257
+ }
258
+ const members = (f?.members ?? f?.roots) as Record<string, WaveMemberOutcome> | undefined;
259
+ if (f?.version !== WAVE_OUTCOME_VERSION || !Number.isInteger(f.wave) || f.wave! >= wave || typeof f.at !== "string") continue;
260
+ if (typeof members !== "object" || members === null) continue;
261
+ for (const [member, o] of Object.entries(members)) {
262
+ const had = out.get(member);
263
+ if (had && had.at > f.at) continue;
264
+ out.set(member, { ...o, wave: f.wave!, at: f.at });
265
+ }
266
+ const n = newest.get(f.wave!);
267
+ if (!n || n.at <= f.at) newest.set(f.wave!, { at: f.at, holdsAll: f.holds_all === true });
268
+ }
269
+ const holding = [...newest].filter(([, n]) => n.holdsAll).map(([k]) => k).sort((a, b) => a - b);
270
+ return { members: out, ...(holding.length ? { holdsAll: holding[0] } : {}) };
271
+ }
@@ -30,6 +30,8 @@ import { dirname } from "node:path";
30
30
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
31
31
  import { evaluateGate, type GateCheck, type GateLedgerPort } from "./op/gate";
32
32
  import type { AppliedGateRecord } from "./lifecycle/gate-applied";
33
+ import type { ResolvedGateApproval } from "./op/gate-approval";
34
+ import type { GateQuorumJudge } from "./op/gate-quorum";
33
35
 
34
36
  export { WAVE_GATE_NAME_PATTERN, waveGateNameFrom, waveShare } from "./wave-layout";
35
37
  export {
@@ -66,6 +68,10 @@ export interface WaveGateRequest {
66
68
  fields?: Record<string, unknown>;
67
69
  /** The pending fact carries `neverOverMcp` (#3821). */
68
70
  neverOverMcp?: boolean;
71
+ /** The approval rules the wave takes (#4290), as an `approval` block with a quorum. */
72
+ approval?: ResolvedGateApproval;
73
+ /** Judges approvals for those rules (#4290). */
74
+ judge?: GateQuorumJudge;
69
75
  }
70
76
 
71
77
  /**
@@ -85,6 +91,8 @@ export function decideWaveGate(port: GateLedgerPort, request: WaveGateRequest):
85
91
  ...(request.spent ? { spent: request.spent } : {}),
86
92
  ...(request.fields ? { fields: request.fields } : {}),
87
93
  ...(request.neverOverMcp ? { neverOverMcp: true } : {}),
94
+ ...(request.approval ? { approval: request.approval } : {}),
95
+ ...(request.judge ? { judge: request.judge } : {}),
88
96
  });
89
97
  }
90
98
 
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
12
12
  /** A git repository holding a workspace with two members, a root member and an example group. */
13
- export function twoMemberWorkspace(options: { root?: boolean; prefix?: string } = {}): string {
13
+ export function twoMemberWorkspace(options: { root?: boolean; prefix?: string; extra?: Record<string, unknown> } = {}): string {
14
14
  const repo = realpathSync(mkdtempSync(join(tmpdir(), "chant-member-pipeline-")));
15
15
  execFileSync("git", ["init", "-q"], { cwd: repo });
16
16
  const ws = options.prefix ? join(repo, options.prefix) : repo;
@@ -21,7 +21,7 @@ export function twoMemberWorkspace(options: { root?: boolean; prefix?: string }
21
21
  ];
22
22
  if (options.root) members.unshift({ name: "platform", dir: ".", kind: "chant" });
23
23
  const files: Record<string, string> = {
24
- "chant.workspace.json": JSON.stringify({ name: "shop", schema: 1, members }, null, 2),
24
+ "chant.workspace.json": JSON.stringify({ name: "shop", schema: 1, members, ...options.extra }, null, 2),
25
25
  "services/api/chant.config.ts": "export default {};\n",
26
26
  "services/api/src/infra.ts": "\n",
27
27
  "apps/web/chant.config.ts": "export default {};\n",
@@ -0,0 +1,62 @@
1
+ /** Branch routing and label filters for the pull-request loop (#4292). */
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { appliesFrom, plansUnderLabels, pushedBranchFromEnv, routeConflicts, routedBranch } from "./ci-branches";
5
+
6
+ const branches = { release: ["prod-*", "dr"], staging: ["staging-*"] };
7
+
8
+ describe("ci.branches", () => {
9
+ test("a member routes to the first branch whose globs match it, or to none", () => {
10
+ expect(routedBranch(branches, "prod-eu")).toBe("release");
11
+ expect(routedBranch(branches, "dr")).toBe("release");
12
+ expect(routedBranch(branches, "staging-a")).toBe("staging");
13
+ expect(routedBranch(branches, "api")).toBeNull();
14
+ expect(routedBranch(undefined, "api")).toBeNull();
15
+ });
16
+
17
+ test("a routed member applies from its branch alone", () => {
18
+ expect(appliesFrom(branches, "prod-eu", "release")).toEqual({ applies: true, note: "ci.branches: release applies member prod-eu" });
19
+ expect(appliesFrom(branches, "prod-eu", "main")).toEqual({ applies: false, note: "ci.branches: member prod-eu applies from release, not main" });
20
+ expect(appliesFrom(branches, "prod-eu", "staging").applies).toBe(false);
21
+ });
22
+
23
+ test("an unrouted member applies from every branch the map does not name", () => {
24
+ expect(appliesFrom(branches, "api", "main").applies).toBe(true);
25
+ expect(appliesFrom(branches, "api", "hotfix").applies).toBe(true);
26
+ expect(appliesFrom(branches, "api", "release")).toEqual({
27
+ applies: false,
28
+ note: "ci.branches: release applies only the members its globs match, and member api is not one",
29
+ });
30
+ expect(appliesFrom(undefined, "api", "release").applies).toBe(true);
31
+ });
32
+
33
+ test("a member two branches match is a conflict", () => {
34
+ expect(routeConflicts({ release: ["prod-*"], hotfix: ["prod-eu"] }, ["prod-eu", "prod-us", "api"])).toEqual([{ member: "prod-eu", branches: ["release", "hotfix"] }]);
35
+ expect(routeConflicts(branches, ["prod-eu", "dr", "api"])).toEqual([]);
36
+ });
37
+
38
+ test("the pushed branch comes from the forge's variables, never from a tag ref", () => {
39
+ expect(pushedBranchFromEnv({ GITHUB_REF_NAME: "release", GITHUB_REF_TYPE: "branch" })).toBe("release");
40
+ expect(pushedBranchFromEnv({ GITHUB_REF_NAME: "v1.2.0", GITHUB_REF_TYPE: "tag" })).toBeUndefined();
41
+ expect(pushedBranchFromEnv({ CI_COMMIT_BRANCH: "release" })).toBe("release");
42
+ expect(pushedBranchFromEnv({})).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("ci.labels", () => {
47
+ const labels = { "plan:prod": ["prod-*"], "plan:web": ["web"] };
48
+
49
+ test("with none of the labels on the pull request, every member plans", () => {
50
+ expect(plansUnderLabels(labels, "api", [])).toEqual({ plans: true });
51
+ expect(plansUnderLabels(labels, "api", ["bug"])).toEqual({ plans: true });
52
+ expect(plansUnderLabels(undefined, "api", ["plan:prod"])).toEqual({ plans: true });
53
+ });
54
+
55
+ test("with some, only the members their globs match plan", () => {
56
+ expect(plansUnderLabels(labels, "prod-eu", ["plan:prod"])).toEqual({ plans: true, note: "ci.labels: plan:prod plans member prod-eu" });
57
+ expect(plansUnderLabels(labels, "web", ["plan:prod", "plan:web"]).plans).toBe(true);
58
+ const out = plansUnderLabels(labels, "api", ["plan:prod", "bug"]);
59
+ expect(out.plans).toBe(false);
60
+ expect(out.note).toContain("its record and apply on merge still plan it");
61
+ });
62
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Branch routing and label filters for the pull-request loop (#4292).
3
+ *
4
+ * `ci.branches` maps a branch to member-name globs. A member a glob matches
5
+ * applies from that branch: its generated pull-request pipeline targets it,
6
+ * and its record or apply job does nothing on a push to any other branch. A
7
+ * member no glob matches applies from every branch but those, the default
8
+ * branch included. terragucci's `apply.branches` (`branchLayers`) works the
9
+ * same way over Terraform roots.
10
+ *
11
+ * `ci.labels` maps a pull request label to member-name globs. When a pull
12
+ * request carries one of the labels, its plan job plans only the members
13
+ * those labels' globs match, and every other member's plan job plans
14
+ * nothing. A label never gates: the record and apply jobs on the push never
15
+ * read labels, and an apply still waits for an approval of the digest it
16
+ * plans. Anyone with triage access can set a label, so a label may narrow
17
+ * what a reviewer sees ahead of the merge and no more.
18
+ */
19
+
20
+ // @ts-ignore: picomatch has no types declaration
21
+ import picomatch from "picomatch";
22
+
23
+ /** Branch or label name to member-name globs. */
24
+ export type MemberGlobs = Readonly<Record<string, readonly string[]>>;
25
+
26
+ const matches = (globs: readonly string[], member: string): boolean => globs.some((g) => (picomatch(g) as (s: string) => boolean)(member));
27
+
28
+ /** The keys of `map` whose globs match `member`, in declaration order. */
29
+ export function keysMatching(map: MemberGlobs | undefined, member: string): string[] {
30
+ return Object.keys(map ?? {}).filter((k) => matches(map![k]!, member));
31
+ }
32
+
33
+ /** The branch `ci.branches` routes `member` to, or null when no glob matches it. */
34
+ export function routedBranch(branches: MemberGlobs | undefined, member: string): string | null {
35
+ return keysMatching(branches, member)[0] ?? null;
36
+ }
37
+
38
+ /** Each member that two branches' globs match, with the branches. The declaration refuses these. */
39
+ export function routeConflicts(branches: MemberGlobs | undefined, members: readonly string[]): Array<{ member: string; branches: string[] }> {
40
+ return members.map((member) => ({ member, branches: keysMatching(branches, member) })).filter((c) => c.branches.length > 1);
41
+ }
42
+
43
+ /**
44
+ * Whether a push to `pushed` applies `member`, with a line that says why.
45
+ * A routed member applies only from its branch. Any other member applies
46
+ * from every branch `ci.branches` does not name.
47
+ */
48
+ export function appliesFrom(branches: MemberGlobs | undefined, member: string, pushed: string): { applies: boolean; note: string } {
49
+ const own = routedBranch(branches, member);
50
+ if (own !== null) {
51
+ return own === pushed
52
+ ? { applies: true, note: `ci.branches: ${pushed} applies member ${member}` }
53
+ : { applies: false, note: `ci.branches: member ${member} applies from ${own}, not ${pushed}` };
54
+ }
55
+ if (Object.prototype.hasOwnProperty.call(branches ?? {}, pushed)) {
56
+ return { applies: false, note: `ci.branches: ${pushed} applies only the members its globs match, and member ${member} is not one` };
57
+ }
58
+ return { applies: true, note: `ci.branches: member ${member} is routed to no branch, so ${pushed} applies it` };
59
+ }
60
+
61
+ /**
62
+ * Whether a pull request's plan job plans `member`, given the labels the pull
63
+ * request carries. With none of `ci.labels` on the pull request every member
64
+ * plans. With some, a member plans only when one of those labels' globs
65
+ * matches it.
66
+ */
67
+ export function plansUnderLabels(labels: MemberGlobs | undefined, member: string, carried: readonly string[]): { plans: boolean; note?: string } {
68
+ const set = carried.filter((l) => Object.prototype.hasOwnProperty.call(labels ?? {}, l));
69
+ if (set.length === 0) return { plans: true };
70
+ const by = set.filter((l) => matches(labels![l]!, member));
71
+ return by.length > 0
72
+ ? { plans: true, note: `ci.labels: ${by.join(", ")} plan${by.length === 1 ? "s" : ""} member ${member}` }
73
+ : { plans: false, note: `ci.labels: the pull request's labels ${set.join(", ")} plan other members, so member ${member} is not planned here; its record and apply on merge still plan it` };
74
+ }
75
+
76
+ /** The branch the job runs on, from the forge's own variables: GitHub and Forgejo's GITHUB_REF_NAME, GitLab's CI_COMMIT_BRANCH. */
77
+ export function pushedBranchFromEnv(env: Record<string, string | undefined> = process.env): string | undefined {
78
+ return env.CI_COMMIT_BRANCH || (env.GITHUB_REF_TYPE === undefined || env.GITHUB_REF_TYPE === "branch" ? env.GITHUB_REF_NAME : undefined) || undefined;
79
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * #4296: `chant build --pr-loop` reads `ci.identity.cloud` from the working
3
+ * tree of the workspace that holds the project, and resolves it for the
4
+ * pipeline's environment and member.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { afterEach, describe, expect, test } from "vitest";
9
+ import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
10
+ import { declaredCloudIdentity } from "./ci-cloud-identity";
11
+
12
+ afterEach(() => cleanScratch());
13
+
14
+ const MEMBERS = [
15
+ { name: "net", dir: "net", kind: "other", because: "the network" },
16
+ { name: "app", dir: "app", kind: "other", because: "the app" },
17
+ ];
18
+
19
+ function workspace(ci?: Record<string, unknown>): string {
20
+ const root = scratchDir("chant-cloud-identity-");
21
+ git(root, "init", "-q");
22
+ writeFiles(root, { "chant.workspace.json": declaration(MEMBERS, ci ? { ci } : {}), "net/a.txt": "a\n", "app/a.txt": "a\n" });
23
+ return root;
24
+ }
25
+
26
+ describe("declaredCloudIdentity (#4296)", () => {
27
+ test("each member's pipeline takes the first rule for its environment and member", async () => {
28
+ const root = workspace({
29
+ identity: {
30
+ cloud: {
31
+ aws: {
32
+ rules: [
33
+ { members: ["net"], plan: "arn:aws:iam::1:role/net-plan", apply: "arn:aws:iam::1:role/net-apply" },
34
+ { plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ });
40
+ expect((await declaredCloudIdentity(join(root, "net"), "production", "net"))!.apply.aws!.role).toBe("arn:aws:iam::1:role/net-apply");
41
+ expect((await declaredCloudIdentity(join(root, "app"), "production", "app"))!.apply.aws!.role).toBe("arn:aws:iam::1:role/apply");
42
+ });
43
+
44
+ test("a workspace with no block, and a directory outside any workspace, read nothing", async () => {
45
+ expect(await declaredCloudIdentity(workspace(), "production", "net")).toBeUndefined();
46
+ expect(await declaredCloudIdentity(scratchDir("chant-cloud-identity-none-"), "production")).toBeUndefined();
47
+ });
48
+
49
+ test("a declaration that plans as an apply identity is an error naming its code", async () => {
50
+ const root = workspace({ identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } });
51
+ await expect(declaredCloudIdentity(root, "production")).rejects.toThrow(/plans and applies as r.*\[declaration-invalid\]/);
52
+ });
53
+ });