@intentius/chant 0.124.32 → 0.124.34

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 (320) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +277 -74
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +99 -22
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +30 -1
  24. package/dist/cli/registry.d.ts +21 -5
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/promote-rounds.d.ts +102 -0
  40. package/dist/components/promote-rounds.d.ts.map +1 -0
  41. package/dist/components/promote-rounds.js +174 -0
  42. package/dist/components/release-compile.d.ts +37 -2
  43. package/dist/components/release-compile.d.ts.map +1 -1
  44. package/dist/components/release-compile.js +64 -8
  45. package/dist/components/release-gate.d.ts +30 -0
  46. package/dist/components/release-gate.d.ts.map +1 -1
  47. package/dist/components/release-gate.js +92 -42
  48. package/dist/components/release-promote.d.ts +17 -3
  49. package/dist/components/release-promote.d.ts.map +1 -1
  50. package/dist/components/release-promote.js +79 -18
  51. package/dist/components/verbs/bom-writer.d.ts +1 -1
  52. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  53. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  54. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  56. package/dist/components/wave-plan.d.ts +10 -0
  57. package/dist/components/wave-plan.d.ts.map +1 -1
  58. package/dist/components/wave-plan.js +32 -4
  59. package/dist/graph-declared.d.ts +4 -0
  60. package/dist/graph-declared.d.ts.map +1 -1
  61. package/dist/graph-declared.js +11 -1
  62. package/dist/graph-detail.d.ts.map +1 -1
  63. package/dist/graph-detail.js +32 -7
  64. package/dist/health.d.ts +110 -0
  65. package/dist/health.d.ts.map +1 -0
  66. package/dist/health.js +105 -0
  67. package/dist/health.schema.json +191 -0
  68. package/dist/lexicon.d.ts +27 -0
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/merge-record.d.ts +104 -0
  71. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  72. package/dist/lifecycle/merge-record.js +102 -0
  73. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  74. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  75. package/dist/lifecycle/publish-attachments.js +27 -13
  76. package/dist/lifecycle/publish-npm.d.ts +3 -2
  77. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  78. package/dist/lifecycle/publish-npm.js +4 -3
  79. package/dist/lifecycle/release-at.d.ts +40 -0
  80. package/dist/lifecycle/release-at.d.ts.map +1 -0
  81. package/dist/lifecycle/release-at.js +83 -0
  82. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  83. package/dist/lifecycle/release-cut.js +23 -12
  84. package/dist/lifecycle/release-publish.d.ts +28 -12
  85. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  86. package/dist/lifecycle/release-publish.js +29 -17
  87. package/dist/lifecycle/release-store.d.ts +25 -2
  88. package/dist/lifecycle/release-store.d.ts.map +1 -1
  89. package/dist/lifecycle/release-store.js +40 -13
  90. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  91. package/dist/lifecycle/run-ledger.js +1 -0
  92. package/dist/lint/rules/op/index.d.ts +2 -1
  93. package/dist/lint/rules/op/index.d.ts.map +1 -1
  94. package/dist/lint/rules/op/index.js +4 -2
  95. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  96. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  97. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  98. package/dist/op/activities/activity-contracts.d.ts +5 -0
  99. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  100. package/dist/op/activities/activity-contracts.js +3 -0
  101. package/dist/op/activities/shell.d.ts +38 -0
  102. package/dist/op/activities/shell.d.ts.map +1 -1
  103. package/dist/op/activities/shell.js +89 -3
  104. package/dist/op/builders.d.ts +7 -0
  105. package/dist/op/builders.d.ts.map +1 -1
  106. package/dist/op/builders.js +7 -0
  107. package/dist/op/runtime.d.ts +2 -0
  108. package/dist/op/runtime.d.ts.map +1 -1
  109. package/dist/pr-loop.d.ts +14 -3
  110. package/dist/pr-loop.d.ts.map +1 -1
  111. package/dist/pr-loop.js +15 -5
  112. package/dist/pr-report-url.d.ts +2 -2
  113. package/dist/pr-report-url.d.ts.map +1 -1
  114. package/dist/runner-start.d.ts +179 -0
  115. package/dist/runner-start.d.ts.map +1 -0
  116. package/dist/runner-start.js +277 -0
  117. package/dist/wave-runtime.mjs +236 -229
  118. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  119. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  120. package/dist/workspace/check.schema.json +88 -2
  121. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  122. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  123. package/dist/workspace/checks/ci-workflow.js +123 -0
  124. package/dist/workspace/checks/security.d.ts +50 -0
  125. package/dist/workspace/checks/security.d.ts.map +1 -0
  126. package/dist/workspace/checks/security.js +305 -0
  127. package/dist/workspace/checks.d.ts +2 -0
  128. package/dist/workspace/checks.d.ts.map +1 -1
  129. package/dist/workspace/checks.js +8 -0
  130. package/dist/workspace/ci-builds.d.ts +163 -0
  131. package/dist/workspace/ci-builds.d.ts.map +1 -0
  132. package/dist/workspace/ci-builds.js +315 -0
  133. package/dist/workspace/ci-green-cli.d.ts +2 -1
  134. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  135. package/dist/workspace/ci-green-cli.js +25 -5
  136. package/dist/workspace/ci-green-forge.d.ts +12 -0
  137. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  138. package/dist/workspace/ci-green-forge.js +12 -0
  139. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  140. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  141. package/dist/workspace/ci-green-workflow.js +128 -1
  142. package/dist/workspace/ci-green.d.ts +89 -4
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +310 -23
  145. package/dist/workspace/ci-init.d.ts +100 -0
  146. package/dist/workspace/ci-init.d.ts.map +1 -0
  147. package/dist/workspace/ci-init.js +434 -0
  148. package/dist/workspace/ci-last-green.schema.json +49 -0
  149. package/dist/workspace/ci-list.schema.json +35 -1
  150. package/dist/workspace/ci-run-index.d.ts +1 -1
  151. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  152. package/dist/workspace/ci-run-index.schema.json +1 -1
  153. package/dist/workspace/ci-run.d.ts +12 -6
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +72 -16
  156. package/dist/workspace/ci-runner-start.d.ts +77 -0
  157. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  158. package/dist/workspace/ci-runner-start.js +185 -0
  159. package/dist/workspace/ci-serve.d.ts +76 -7
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +153 -19
  162. package/dist/workspace/ci-workflow.d.ts +91 -0
  163. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  164. package/dist/workspace/ci-workflow.js +33 -0
  165. package/dist/workspace/declaration.d.ts +35 -0
  166. package/dist/workspace/declaration.d.ts.map +1 -1
  167. package/dist/workspace/declaration.js +12 -2
  168. package/dist/workspace/declaration.schema.json +51 -0
  169. package/dist/workspace/init.js +2 -2
  170. package/dist/workspace/ls.d.ts +2 -0
  171. package/dist/workspace/ls.d.ts.map +1 -1
  172. package/dist/workspace/ls.js +1 -1
  173. package/dist/workspace/ls.schema.json +2 -1
  174. package/dist/workspace/member-pipeline.d.ts +1 -0
  175. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  176. package/dist/workspace/member-pipeline.js +2 -0
  177. package/dist/workspace/merge-record.schema.json +49 -0
  178. package/dist/workspace/pr-report.schema.json +3 -3
  179. package/dist/workspace/reason-codes.d.ts +26 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/reason-codes.js +28 -0
  182. package/dist/workspace/release-candidates.d.ts +129 -0
  183. package/dist/workspace/release-candidates.d.ts.map +1 -0
  184. package/dist/workspace/release-candidates.js +233 -0
  185. package/dist/workspace/release-candidates.schema.json +353 -0
  186. package/dist/workspace/release-workflow.d.ts +31 -0
  187. package/dist/workspace/release-workflow.d.ts.map +1 -0
  188. package/dist/workspace/release-workflow.js +150 -0
  189. package/dist/workspace/security.d.ts +40 -0
  190. package/dist/workspace/security.d.ts.map +1 -0
  191. package/dist/workspace/security.js +99 -0
  192. package/package.json +8 -1
  193. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  194. package/src/ci-jobs.ts +11 -2
  195. package/src/cli/handlers/build.test.ts +36 -0
  196. package/src/cli/handlers/build.ts +18 -3
  197. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  198. package/src/cli/handlers/lifecycle-health.ts +157 -0
  199. package/src/cli/handlers/lifecycle.ts +1 -1
  200. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  201. package/src/cli/handlers/pr-record.test.ts +201 -0
  202. package/src/cli/handlers/pr.test.ts +22 -0
  203. package/src/cli/handlers/pr.ts +127 -4
  204. package/src/cli/handlers/promote.test.ts +17 -0
  205. package/src/cli/handlers/promote.ts +296 -84
  206. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  207. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  208. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  209. package/src/cli/handlers/release.e2e.test.ts +19 -28
  210. package/src/cli/handlers/release.ts +81 -11
  211. package/src/cli/main.ts +99 -22
  212. package/src/cli/parse-args.ts +22 -1
  213. package/src/cli/registry.ts +21 -5
  214. package/src/components/deploy-inputs.test.ts +108 -0
  215. package/src/components/deploy-inputs.ts +225 -0
  216. package/src/components/driver.ts +11 -0
  217. package/src/components/gate-plan.ts +9 -0
  218. package/src/components/pr-pipeline.ts +56 -15
  219. package/src/components/pr-run.ts +1 -1
  220. package/src/components/promote-rounds.test.ts +220 -0
  221. package/src/components/promote-rounds.ts +232 -0
  222. package/src/components/release-compile.test.ts +34 -0
  223. package/src/components/release-compile.ts +89 -8
  224. package/src/components/release-gate.test.ts +47 -0
  225. package/src/components/release-gate.ts +111 -46
  226. package/src/components/release-promote.test.ts +55 -2
  227. package/src/components/release-promote.ts +93 -23
  228. package/src/components/verbs/bom-writer.ts +1 -1
  229. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  230. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  231. package/src/components/wave-plan.ts +32 -4
  232. package/src/graph-declared-exports.test.ts +40 -0
  233. package/src/graph-declared.ts +9 -1
  234. package/src/graph-detail.test.ts +57 -0
  235. package/src/graph-detail.ts +33 -7
  236. package/src/health.schema.json +191 -0
  237. package/src/health.test.ts +62 -0
  238. package/src/health.ts +178 -0
  239. package/src/lexicon.ts +41 -1
  240. package/src/lifecycle/merge-record.test.ts +111 -0
  241. package/src/lifecycle/merge-record.ts +154 -0
  242. package/src/lifecycle/publish-attachments.ts +25 -13
  243. package/src/lifecycle/publish-npm.ts +4 -3
  244. package/src/lifecycle/publish-targets.test.ts +34 -4
  245. package/src/lifecycle/release-at.test.ts +48 -0
  246. package/src/lifecycle/release-at.ts +100 -0
  247. package/src/lifecycle/release-cut.test.ts +41 -0
  248. package/src/lifecycle/release-cut.ts +22 -11
  249. package/src/lifecycle/release-publish.test.ts +43 -13
  250. package/src/lifecycle/release-publish.ts +38 -18
  251. package/src/lifecycle/release-store.ts +57 -13
  252. package/src/lifecycle/run-ledger.test.ts +13 -0
  253. package/src/lifecycle/run-ledger.ts +1 -0
  254. package/src/lint/rules/op/index.ts +4 -2
  255. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  256. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  257. package/src/op/activities/activity-contracts.ts +3 -0
  258. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  259. package/src/op/activities/shell.test.ts +44 -1
  260. package/src/op/activities/shell.ts +110 -3
  261. package/src/op/builders.ts +7 -0
  262. package/src/op/runtime.ts +2 -0
  263. package/src/op/step-console.test.ts +4 -2
  264. package/src/pr-loop.test.ts +29 -0
  265. package/src/pr-loop.ts +26 -7
  266. package/src/pr-report-url.ts +2 -2
  267. package/src/runner-start-bundle.test.ts +29 -0
  268. package/src/runner-start.test.ts +179 -0
  269. package/src/runner-start.ts +329 -0
  270. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  271. package/src/workspace/check-contract.test.ts +4 -2
  272. package/src/workspace/check.schema.json +88 -2
  273. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  274. package/src/workspace/checks/ci-workflow.ts +129 -0
  275. package/src/workspace/checks/security.test.ts +138 -0
  276. package/src/workspace/checks/security.ts +305 -0
  277. package/src/workspace/checks.ts +8 -0
  278. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  279. package/src/workspace/ci-builds.test.ts +174 -0
  280. package/src/workspace/ci-builds.ts +385 -0
  281. package/src/workspace/ci-green-builds.test.ts +180 -0
  282. package/src/workspace/ci-green-cli.test.ts +54 -0
  283. package/src/workspace/ci-green-cli.ts +26 -5
  284. package/src/workspace/ci-green-forge.ts +24 -0
  285. package/src/workspace/ci-green-workflow.test.ts +84 -1
  286. package/src/workspace/ci-green-workflow.ts +139 -2
  287. package/src/workspace/ci-green.test.ts +5 -2
  288. package/src/workspace/ci-green.ts +359 -27
  289. package/src/workspace/ci-init.test.ts +159 -0
  290. package/src/workspace/ci-init.ts +445 -0
  291. package/src/workspace/ci-last-green.schema.json +49 -0
  292. package/src/workspace/ci-list.schema.json +35 -1
  293. package/src/workspace/ci-report-url.test.ts +6 -1
  294. package/src/workspace/ci-run-index.schema.json +1 -1
  295. package/src/workspace/ci-run-index.ts +1 -1
  296. package/src/workspace/ci-run.test.ts +24 -7
  297. package/src/workspace/ci-run.ts +78 -16
  298. package/src/workspace/ci-runner-start.test.ts +151 -0
  299. package/src/workspace/ci-runner-start.ts +193 -0
  300. package/src/workspace/ci-serve-builds.test.ts +86 -0
  301. package/src/workspace/ci-serve.test.ts +59 -0
  302. package/src/workspace/ci-serve.ts +177 -22
  303. package/src/workspace/ci-workflow.ts +71 -0
  304. package/src/workspace/declaration.schema.json +51 -0
  305. package/src/workspace/declaration.test.ts +17 -0
  306. package/src/workspace/declaration.ts +53 -3
  307. package/src/workspace/init.ts +2 -2
  308. package/src/workspace/ls.schema.json +2 -1
  309. package/src/workspace/ls.ts +3 -2
  310. package/src/workspace/member-pipeline.ts +2 -1
  311. package/src/workspace/merge-record.schema.json +49 -0
  312. package/src/workspace/pr-report.schema.json +3 -3
  313. package/src/workspace/reason-codes.test.ts +4 -0
  314. package/src/workspace/reason-codes.ts +28 -0
  315. package/src/workspace/release-candidates.schema.json +353 -0
  316. package/src/workspace/release-candidates.test.ts +106 -0
  317. package/src/workspace/release-candidates.ts +315 -0
  318. package/src/workspace/release-workflow.ts +167 -0
  319. package/src/workspace/security.test.ts +70 -0
  320. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `chant lifecycle health <env> [lexicon] [--json]` (#4177): whether each
3
+ * declared resource that has a health read is up, separate from drift.
4
+ *
5
+ * It builds the project as `lifecycle diff` does, so the entity names are the
6
+ * graph's node ids, then asks each lexicon with a health read about the
7
+ * entities whose types it names. An environment's declared endpoint is applied
8
+ * for the read, as on the other live reads (#1166).
9
+ *
10
+ * The JSON is the {@link HEALTH_SCHEMA} shape. A single-stack project prints
11
+ * `lexicons` at the top level; a multi-stack one nests them under `stacks`,
12
+ * the same split `lifecycle diff --live --json` makes.
13
+ */
14
+
15
+ import { resolve } from "node:path";
16
+ import { build } from "../../build";
17
+ import { loadChantConfig } from "../../config";
18
+ import { isObservableDeclarable } from "../../declarable";
19
+ import { HEALTH_SCHEMA, healthApplicable, observeHealthFor, summarizeHealth, type HealthLexicon, type HealthReadResult } from "../../health";
20
+ import { applyLiveEndpoint } from "../../live-endpoint";
21
+ import type { SerializerResult } from "../../serializer";
22
+ import { CHANT_VERSION } from "../version";
23
+ import { commandBuildParams } from "../build-params-cli";
24
+ import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
25
+ import { formatBold, formatError, formatWarning } from "../format";
26
+ import type { CommandContext } from "../registry";
27
+ import { resolveStackTargets } from "./lifecycle";
28
+
29
+ type ByLexicon = Record<string, HealthReadResult>;
30
+
31
+ export async function runLifecycleHealth(ctx: CommandContext): Promise<number> {
32
+ const { args, plugins } = ctx;
33
+ const environment = args.extraPositional;
34
+ const lexiconFilter = args.extraPositional2;
35
+ if (!environment) {
36
+ console.error(formatError({ message: "Environment is required: chant lifecycle health <environment> [lexicon]" }));
37
+ return 1;
38
+ }
39
+
40
+ const readers = plugins.filter(
41
+ (p) => p.observeHealth && (p.healthTypes?.length ?? 0) > 0 && (!lexiconFilter || p.name === lexiconFilter),
42
+ );
43
+ if (readers.length === 0) {
44
+ console.error(formatWarning({
45
+ message: lexiconFilter
46
+ ? `${lexiconFilter}: no health read in this lexicon`
47
+ : "No configured lexicon has a health read (aws, azure and gcp do)",
48
+ }));
49
+ return 1;
50
+ }
51
+
52
+ const projectRoot = resolve(".");
53
+ const { config } = await loadChantConfig(projectRoot);
54
+ const declaredParams = await commandBuildParams(config.buildParams, args);
55
+ if (!declaredParams) return 1;
56
+ const targets = resolveStackTargets(args, config);
57
+ const buildOptions = resolveProjectBuildOptions({
58
+ config,
59
+ configDir: projectRoot,
60
+ plugins,
61
+ modes: resolveBuildModes(config, { fold: args.fold, sandbox: args.sandbox }),
62
+ buildParams: declaredParams,
63
+ });
64
+ const serializers = readers.map((p) => p.serializer);
65
+
66
+ const endpoint = applyLiveEndpoint(config.environments, environment, readers);
67
+ if (endpoint.notice) console.error(formatWarning({ message: endpoint.notice }));
68
+
69
+ const perStack: Record<string, ByLexicon> = {};
70
+ let single: ByLexicon | undefined;
71
+ let anyBuildError = false;
72
+ const observedAt = new Date().toISOString();
73
+
74
+ try {
75
+ for (const target of targets) {
76
+ const buildResult = await build(target.root, serializers, undefined, buildOptions);
77
+ if (buildResult.errors.length > 0) {
78
+ console.error(formatError({
79
+ message: `Build failed for ${target.stack ? `stack "${target.stack}"` : "project"}; fix errors before reading health`,
80
+ }));
81
+ anyBuildError = true;
82
+ continue;
83
+ }
84
+ const byLexicon: ByLexicon = {};
85
+ for (const plugin of readers) {
86
+ const declared = new Map<string, { entityType: string; props: Record<string, unknown> }>();
87
+ for (const [name, entity] of buildResult.entities) {
88
+ if (entity.lexicon === plugin.name && isObservableDeclarable(entity)) {
89
+ declared.set(name, {
90
+ entityType: entity.entityType,
91
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
92
+ });
93
+ }
94
+ }
95
+ const entities = healthApplicable(plugin as HealthLexicon, declared);
96
+ if (entities.size === 0) continue;
97
+ const raw = buildResult.outputs.get(plugin.name);
98
+ const buildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
99
+ byLexicon[plugin.name] = await observeHealthFor(plugin as HealthLexicon, {
100
+ environment,
101
+ buildOutput,
102
+ entities,
103
+ ...(target.stack ? { stack: target.stack } : {}),
104
+ ...(target.region ? { region: target.region } : {}),
105
+ });
106
+ }
107
+ if (target.stack) perStack[target.stack] = byLexicon;
108
+ else single = byLexicon;
109
+ }
110
+ } finally {
111
+ endpoint.restore();
112
+ }
113
+
114
+ const all = single ? [single] : Object.values(perStack);
115
+ const summary = summarizeHealth(all.flatMap((b) => Object.values(b)));
116
+
117
+ if (args.json) {
118
+ console.log(JSON.stringify({
119
+ schema: HEALTH_SCHEMA,
120
+ chantVersion: CHANT_VERSION,
121
+ environment,
122
+ observedAt,
123
+ ...(single ? { lexicons: single } : { stacks: perStack }),
124
+ summary,
125
+ }));
126
+ } else {
127
+ if (single) renderHealth(single);
128
+ else for (const [stack, byLexicon] of Object.entries(perStack)) {
129
+ console.log(`\n${formatBold(`■ stack ${stack}`)}`);
130
+ renderHealth(byLexicon);
131
+ }
132
+ const total = summary.healthy + summary.unhealthy + summary.unknown + summary.unobserved + summary.absent;
133
+ if (total === 0) {
134
+ console.error(formatWarning({ message: "No declared resource has a health read" }));
135
+ } else {
136
+ console.error(
137
+ `${summary.healthy} healthy, ${summary.unhealthy} unhealthy, ${summary.unknown} unknown` +
138
+ (summary.unobserved > 0 ? `, ${summary.unobserved} not observed` : "") +
139
+ (summary.absent > 0 ? `, ${summary.absent} not deployed` : ""),
140
+ );
141
+ }
142
+ }
143
+ return anyBuildError ? 1 : 0;
144
+ }
145
+
146
+ function renderHealth(byLexicon: ByLexicon): void {
147
+ for (const [lexicon, result] of Object.entries(byLexicon)) {
148
+ console.log(formatBold(lexicon));
149
+ for (const [name, entry] of Object.entries(result.entities)) {
150
+ console.log(` ${entry.state.padEnd(9)} ${name} (${entry.type}): ${entry.detail}`);
151
+ }
152
+ for (const [name, hole] of Object.entries(result.unobserved)) {
153
+ console.log(` ${"not read".padEnd(9)} ${name}${hole.type ? ` (${hole.type})` : ""}: ${hole.reason}${hole.detail ? `, ${hole.detail}` : ""}`);
154
+ }
155
+ for (const name of result.absent) console.log(` ${"absent".padEnd(9)} ${name}: not deployed`);
156
+ }
157
+ }
@@ -90,7 +90,7 @@ interface StackTarget {
90
90
  * observed as the stack named after the environment (unchanged behavior). An
91
91
  * explicit `--src` always wins and forces a single scoped target.
92
92
  */
93
- function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
93
+ export function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
94
94
  if (args.src) return [{ root: resolve(args.src) }];
95
95
  if (config.stacks && config.stacks.length > 0) {
96
96
  return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src), region: s.region }));
@@ -0,0 +1,209 @@
1
+ /**
2
+ * #4155: a pull request's approval binds the files a step without a `plan`
3
+ * deploys from. A chart changed after approval, or a function's sources
4
+ * beside a planning step, moves the digest and pr-apply exits 3. A build
5
+ * whose output differs every time does not.
6
+ */
7
+
8
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
12
+ import type { DriverComponent } from "../../components/driver";
13
+ import type { DeployContext } from "../../components/capability";
14
+ import type { ParsedArgs } from "../registry";
15
+
16
+ const HEAD = "a".repeat(40);
17
+ const BASE = "b".repeat(40);
18
+
19
+ vi.mock("../../components/changed-files", () => ({
20
+ resolveCommit: async () => HEAD,
21
+ resolveMergeBase: async () => BASE,
22
+ }));
23
+
24
+ vi.mock("../../config", async () => {
25
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
26
+ return { ...actual, loadChantConfig: async () => ({ config: {} }) };
27
+ });
28
+
29
+ vi.mock("./fan-out", () => ({
30
+ resolveChangedUnits: async () => ({ units: { changed: [ESTATE[0]!.name], indeterminate: [] } }),
31
+ }));
32
+
33
+ vi.mock("../../lifecycle/gate-change-set", () => ({
34
+ storeGateChangeSet: async () => ({ written: false }),
35
+ readGraphIds: async () => ({ ids: [] }),
36
+ setGraphIdReader: () => {},
37
+ gateGraphReader: () => async () => ({ ids: [] }),
38
+ }));
39
+ vi.mock("../../lifecycle/git", async () => {
40
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
41
+ return { ...actual, pushLifecycleReport: async () => { throw new Error("pr-deploy-inputs.test must not push chant/lifecycle"); } };
42
+ });
43
+
44
+ vi.mock("./run", () => ({
45
+ GATED_EXIT_CODE: 3,
46
+ autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
47
+ recordAutoReleasesForRun: async () => [],
48
+ }));
49
+
50
+ let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
51
+ vi.mock("../../op/gate", async () => {
52
+ const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
53
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
54
+ });
55
+
56
+ /** The components under test; each case sets its own. */
57
+ let ESTATE: DriverComponent[] = [];
58
+
59
+ vi.mock("../../components/fan-out-support", async () => {
60
+ const { CapabilityRegistry } = await import("../../components/capability");
61
+ const { planFanOut } = await import("../../components/fan-out");
62
+ const { computePlanDigest } = await import("../../lifecycle/plan-digest");
63
+ const registry = new CapabilityRegistry();
64
+ // A planning step, as cfn-deploy is.
65
+ registry.register({
66
+ kind: "fake-root",
67
+ async plan(_ctx: DeployContext, input: { root: string }) {
68
+ const planDigest = computePlanDigest("fake-root", { root: input.root });
69
+ return {
70
+ member: input.root,
71
+ planDigest,
72
+ artifact: { planDigest },
73
+ changeSet: { member: { member: input.root, lexicon: "aws", planner: "cfn", status: "planned", planDigest, holes: [] }, entries: [] },
74
+ };
75
+ },
76
+ async run() {
77
+ return { outputs: {} };
78
+ },
79
+ } as never);
80
+ // Steps with no plan, under the kinds whose fields name what they deploy.
81
+ for (const kind of ["helm-upgrade", "lambda-deploy", "shell"]) {
82
+ registry.register({ kind, async run() { return { outputs: {} }; } } as never);
83
+ }
84
+ // A build that is not reproducible: a new uri every run.
85
+ registry.register({ kind: "zip-package", async run() { return { outputs: { uri: `s3://bundles/${Math.random()}` } }; } } as never);
86
+ return {
87
+ fanOutRegistry: async () => registry,
88
+ deriveFanOut: async () => ({
89
+ success: true,
90
+ components: ESTATE,
91
+ plan: planFanOut({ components: ESTATE, changed: [ESTATE[0]!.name] }),
92
+ signal: { changed: [ESTATE[0]!.name], indeterminate: [], unclaimed: [] },
93
+ }),
94
+ };
95
+ });
96
+
97
+ const { memoryGateLedgerPort } = await import("../../op/gate");
98
+ const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
99
+
100
+ let dir: string;
101
+ let project: string;
102
+
103
+ const ctx = (a: Partial<ParsedArgs>) =>
104
+ ({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
105
+
106
+ beforeEach(async () => {
107
+ dir = await mkdtemp(join(tmpdir(), "pr-inputs-out-"));
108
+ project = await mkdtemp(join(tmpdir(), "pr-inputs-project-"));
109
+ gateLedger = memoryGateLedgerPort();
110
+ // The steps read their paths from the member's directory, which is the working directory.
111
+ vi.spyOn(process, "cwd").mockReturnValue(project);
112
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
113
+ vi.spyOn(console, "error").mockImplementation(() => {});
114
+ vi.spyOn(console, "log").mockImplementation(() => {});
115
+ });
116
+
117
+ afterEach(async () => {
118
+ vi.restoreAllMocks();
119
+ await rm(dir, { recursive: true, force: true });
120
+ await rm(project, { recursive: true, force: true });
121
+ });
122
+
123
+ /** Plan pull request 12 and approve the digest it printed, as a reviewer would. */
124
+ async function planAndApprove(): Promise<string> {
125
+ expect(await runComponentsPrPlan(ctx({ pr: 12 }))).toBe(0);
126
+ const digest = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")).digest as string;
127
+ gateLedger = memoryGateLedgerPort({
128
+ resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: "github:alice", timestamp: new Date().toISOString(), planDigest: digest }],
129
+ });
130
+ return digest;
131
+ }
132
+
133
+ async function write(path: string, content: string): Promise<void> {
134
+ await mkdir(join(project, path, ".."), { recursive: true });
135
+ await writeFile(join(project, path), content);
136
+ }
137
+
138
+ describe("a Helm chart changed after approval (#4155)", () => {
139
+ beforeEach(async () => {
140
+ ESTATE = [{ name: "web", dependsOn: [], deploy: [{ phase: "Deploy", steps: [{ kind: "helm-upgrade", release: "web", chart: "chart", values: ["values-prod.yaml"] }] }] }];
141
+ await write("chart/Chart.yaml", "name: web\nversion: 1.0.0\n");
142
+ await write("chart/templates/deployment.yaml", "replicas: 2\n");
143
+ await write("values-prod.yaml", "image: web\n");
144
+ });
145
+
146
+ test("applies under the approval when nothing changed", async () => {
147
+ await planAndApprove();
148
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
149
+ });
150
+
151
+ test("a file in the chart changed: pr-apply exits 3 and applies nothing", async () => {
152
+ await planAndApprove();
153
+ await write("chart/templates/deployment.yaml", "replicas: 20\n");
154
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
155
+ const report = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
156
+ expect(report.status).not.toBe("applied");
157
+ });
158
+
159
+ test("a file added to the chart, or a changed values file, does the same", async () => {
160
+ await planAndApprove();
161
+ await write("chart/templates/service.yaml", "port: 80\n");
162
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
163
+ await planAndApprove();
164
+ await write("values-prod.yaml", "image: other\n");
165
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
166
+ });
167
+ });
168
+
169
+ describe("a function's sources beside a planning step (#4155)", () => {
170
+ beforeEach(async () => {
171
+ ESTATE = [{
172
+ name: "api",
173
+ dependsOn: [],
174
+ deploy: [
175
+ { phase: "Build", steps: [{ kind: "zip-package", source: "fn" }] },
176
+ { phase: "Deploy", steps: [{ kind: "fake-root", root: "api-stack" }, { kind: "lambda-deploy", functionName: "api", codeRef: "@Build.uri", version: "1" }] },
177
+ ],
178
+ }];
179
+ await write("fn/index.js", "export const handler = () => 1;\n");
180
+ await write("fn/package-lock.json", "{}\n");
181
+ });
182
+
183
+ test("the plan has a member for the component's other steps", async () => {
184
+ await planAndApprove();
185
+ const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8"));
186
+ expect(JSON.stringify(plan)).toContain("api#inputs");
187
+ });
188
+
189
+ test("a changed source moves the digest: pr-apply exits 3", async () => {
190
+ await planAndApprove();
191
+ await write("fn/index.js", "export const handler = () => 2;\n");
192
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(3);
193
+ });
194
+
195
+ test("an unchanged rebuild whose output differs every run still applies under the approval", async () => {
196
+ await planAndApprove();
197
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
198
+ });
199
+ });
200
+
201
+ describe("a step chant can't bind (#4155)", () => {
202
+ test("is a hole on the plan, named unbound, and the plan still applies once approved", async () => {
203
+ ESTATE = [{ name: "ops", dependsOn: [], deploy: [{ phase: "Deploy", steps: [{ kind: "shell", cmd: "./deploy.sh", reason: "t" }] }] }];
204
+ await planAndApprove();
205
+ const plan = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8"));
206
+ expect(JSON.stringify(plan)).toMatch(/unbound: a shell step runs content chant can't name/);
207
+ expect(await runComponentsPrApply(ctx({ pr: 12 }))).toBe(0);
208
+ });
209
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `chant components pr-record` (#4168 step 1): the record stage a merge
3
+ * runs unless apply on merge is opted in. It plans the merged commit,
4
+ * decides the pull request's gate without writing to the ledger, writes the
5
+ * merge record and the report, and applies nothing. Git, the change signal,
6
+ * the derivation and the gate ledger are fakes, as in pr.test.ts.
7
+ */
8
+
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
14
+ import type { DriverComponent } from "../../components/driver";
15
+ import type { DeployContext } from "../../components/capability";
16
+ import type { ParsedArgs } from "../registry";
17
+ import { prStatusState } from "../../pr-loop";
18
+
19
+ const HEAD = "a".repeat(40);
20
+ const BASE = "b".repeat(40);
21
+
22
+ vi.mock("../../components/changed-files", () => ({
23
+ resolveCommit: async () => HEAD,
24
+ resolveMergeBase: async () => BASE,
25
+ }));
26
+
27
+ vi.mock("../../config", async () => {
28
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
29
+ return { ...actual, loadChantConfig: async () => ({ config: {} }) };
30
+ });
31
+
32
+ vi.mock("./fan-out", () => ({
33
+ resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
34
+ }));
35
+
36
+ // #4022: pr-apply stores the change set its gate binds on chant/lifecycle and
37
+ // pushes it. These tests run in the chant checkout, so the store is replaced:
38
+ // it must never write or push the real repository's lifecycle branch.
39
+ vi.mock("../../lifecycle/gate-change-set", () => ({
40
+ storeGateChangeSet: async () => ({ written: false }),
41
+ readGraphIds: async () => ({ ids: [] }),
42
+ setGraphIdReader: () => {},
43
+ gateGraphReader: () => async () => ({ ids: [] }),
44
+ }));
45
+ vi.mock("../../lifecycle/git", async () => {
46
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
47
+ return { ...actual, pushLifecycleReport: async () => { throw new Error("pr.test must not push chant/lifecycle"); } };
48
+ });
49
+
50
+ // #4168: the record job writes the merge record on chant/lifecycle. Captured here, never written.
51
+ const written: Array<Record<string, unknown>> = [];
52
+ vi.mock("../../lifecycle/merge-record", async () => {
53
+ const actual = await vi.importActual<typeof import("../../lifecycle/merge-record")>("../../lifecycle/merge-record");
54
+ return { ...actual, writeMergeRecord: async (r: Record<string, unknown>) => (written.push(r), { commit: null, written: false }) };
55
+ });
56
+
57
+ vi.mock("./run", () => ({
58
+ GATED_EXIT_CODE: 3,
59
+ autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
60
+ recordAutoReleasesForRun: async () => [],
61
+ }));
62
+
63
+ let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
64
+ vi.mock("../../op/gate", async () => {
65
+ const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
66
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
67
+ });
68
+
69
+ const failing = new Set<string>();
70
+ /** The error a root's apply throws, in place of applying (#3997). */
71
+ const refusal = new Map<string, Error>();
72
+ /** Called as a component's apply starts, before it can fail. */
73
+ let onRun: (root: string) => void = () => {};
74
+ vi.mock("../../components/fan-out-support", async () => {
75
+ const { CapabilityRegistry } = await import("../../components/capability");
76
+ const { planFanOut } = await import("../../components/fan-out");
77
+ const { computePlanDigest } = await import("../../lifecycle/plan-digest");
78
+ const registry = new CapabilityRegistry();
79
+ registry.register({
80
+ kind: "fake-root",
81
+ async plan(_ctx: DeployContext, input: { root: string }) {
82
+ const planDigest = computePlanDigest("fake-root", { root: input.root });
83
+ return {
84
+ member: input.root,
85
+ planDigest,
86
+ artifact: { planDigest },
87
+ changeSet: { member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] }, entries: [] },
88
+ };
89
+ },
90
+ async outputs() {
91
+ return {};
92
+ },
93
+ async run(_ctx: DeployContext, input: { root: string }) {
94
+ onRun(input.root);
95
+ if (refusal.has(input.root)) throw refusal.get(input.root);
96
+ if (failing.has(input.root)) throw new Error(`${input.root} failed`);
97
+ return { outputs: {} };
98
+ },
99
+ } as never);
100
+ return {
101
+ fanOutRegistry: async () => registry,
102
+ deriveFanOut: async () => ({
103
+ success: true,
104
+ components: ESTATE,
105
+ plan: planFanOut({ components: ESTATE, changed: ["net"] }),
106
+ signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
107
+ }),
108
+ };
109
+ });
110
+
111
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
112
+ name,
113
+ dependsOn,
114
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
115
+ });
116
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
117
+
118
+ const { memoryGateLedgerPort } = await import("../../op/gate");
119
+ const { runComponentsPrPlan, runComponentsPrRecord } = await import("./pr");
120
+ const { checkRunEventLines, stepTrail } = await import("../../workspace/__fixtures__/run-event-check");
121
+
122
+ let dir: string;
123
+ let errors: string[];
124
+ let applied: string[];
125
+
126
+ const ctx = (a: Partial<ParsedArgs>) =>
127
+ ({ args: { command: "components", path: "pr-record", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
128
+
129
+ beforeEach(async () => {
130
+ dir = await mkdtemp(join(tmpdir(), "pr-record-"));
131
+ errors = [];
132
+ applied = [];
133
+ written.length = 0;
134
+ failing.clear();
135
+ refusal.clear();
136
+ onRun = (r) => applied.push(r);
137
+ gateLedger = memoryGateLedgerPort();
138
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
139
+ vi.spyOn(console, "log").mockImplementation(() => {});
140
+ });
141
+
142
+ afterEach(async () => {
143
+ vi.restoreAllMocks();
144
+ await rm(dir, { recursive: true, force: true });
145
+ });
146
+
147
+ const report = (stage: string) => JSON.parse(readFileSync(join(dir, `pr-${stage}.json`), "utf8")) as Record<string, unknown> & { approval: { status: string }; digest: string };
148
+ const approve = (digest: string) =>
149
+ (gateLedger = memoryGateLedgerPort({
150
+ resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: "github:alice", timestamp: new Date().toISOString(), planDigest: digest }],
151
+ }));
152
+
153
+ describe("pr-record (#4168)", () => {
154
+ test("an unapproved merge is recorded, exits 0, writes nothing to the gate ledger and applies nothing", async () => {
155
+ expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
156
+ const r = report("record");
157
+ expect(r).toMatchObject({ stage: "record", status: "recorded", pr: 12, head: HEAD, base: BASE, approval: { status: "pending" } });
158
+ expect(gateLedger.appended).toEqual([]);
159
+ expect(applied).toEqual([]);
160
+ expect(written).toEqual([
161
+ expect.objectContaining({ commit: HEAD, base: BASE, env: "prod", source: { kind: "pull-request", pr: 12 }, op: "pr-12", gate: "pr-apply", digest: r.digest, plan: "unapproved", approvedBy: [], components: ["net", "app"] }),
162
+ ]);
163
+ expect(prStatusState(r as never)).toBe("success");
164
+ });
165
+
166
+ test("the plan a reviewer approved is recorded as approved, by whom", async () => {
167
+ await runComponentsPrPlan(ctx({ pr: 12 }));
168
+ approve(report("plan").digest);
169
+ expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
170
+ expect(report("record").approval).toEqual({ status: "approved", approvedBy: ["github:alice"] });
171
+ expect(written[0]).toMatchObject({ plan: "approved", approvedBy: ["github:alice"] });
172
+ expect(applied).toEqual([]);
173
+ });
174
+
175
+ test("a plan that moved since its approval is recorded, exits 0, and its status fails (decision 1)", async () => {
176
+ const other = `jcs1-sha256:${"9".repeat(64)}`;
177
+ approve(other);
178
+ expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(0);
179
+ const r = report("record");
180
+ expect(r.approval).toEqual({ status: "changed", approved: other });
181
+ expect(String(r.message)).toMatch(/The merge stands and nothing was applied/);
182
+ expect(prStatusState(r as never)).toBe("failure");
183
+ expect(written[0]).toMatchObject({ plan: "changed", approvedDigest: other, approvedBy: [] });
184
+ expect(applied).toEqual([]);
185
+ });
186
+
187
+ test("with no pull request the record names none and reads no gate", async () => {
188
+ approve(`jcs1-sha256:${"9".repeat(64)}`);
189
+ expect(await runComponentsPrRecord(ctx({}))).toBe(0);
190
+ expect(report("record")).toMatchObject({ pr: null, op: "pr-unknown", approval: { status: "pending" } });
191
+ expect(written[0]).toMatchObject({ source: { kind: "direct-push" }, op: null, plan: "unapproved" });
192
+ });
193
+
194
+ test("a member that fails to plan is recorded as plan-failed and exits 1", async () => {
195
+ const plan = vi.spyOn(await import("../../components/pr-run"), "planPrSet");
196
+ plan.mockImplementationOnce(async (input) => ({ ...(await (await vi.importActual<typeof import("../../components/pr-run")>("../../components/pr-run")).planPrSet(input)), failed: true }));
197
+ expect(await runComponentsPrRecord(ctx({ pr: 12 }))).toBe(1);
198
+ expect(report("record")).toMatchObject({ status: "plan-failed" });
199
+ expect(written[0]).toMatchObject({ plan: "plan-failed" });
200
+ });
201
+ });
@@ -19,6 +19,13 @@ import { forgePrincipalOf } from "../../pr-forge";
19
19
  const HEAD = "a".repeat(40);
20
20
  const BASE = "b".repeat(40);
21
21
 
22
+ /** The security groups pr-apply reads from the declaration (#4168); none unless a test sets them. */
23
+ let groups: string[] = [];
24
+ vi.mock("../../workspace/security", async (original) => ({
25
+ ...(await original<typeof import("../../workspace/security")>()),
26
+ securityGroupsAt: () => new Set(groups),
27
+ }));
28
+
22
29
  vi.mock("../../components/changed-files", () => ({
23
30
  resolveCommit: async () => HEAD,
24
31
  resolveMergeBase: async () => BASE,
@@ -126,6 +133,7 @@ beforeEach(async () => {
126
133
  failing.clear();
127
134
  refusal.clear();
128
135
  onRun = () => {};
136
+ groups = [];
129
137
  gateLedger = memoryGateLedgerPort();
130
138
  vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; });
131
139
  vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
@@ -345,6 +353,12 @@ describe("--events-json beside --json", () => {
345
353
  });
346
354
  });
347
355
 
356
+ test("under the review-rules security group pr-apply needs --forge, since it reads the pull request's reviews (#4168)", async () => {
357
+ groups = ["review-rules"];
358
+ expect(await runComponentsPrApply(ctx({ pr: 12, eventsJson: false }))).toBe(1);
359
+ expect(errors.join("\n")).toContain("the workspace opts into review-rules");
360
+ });
361
+
348
362
  describe("--require-review: whoever wrote the change never counts (#4000)", () => {
349
363
  type Kind = "github" | "forgejo" | "gitlab";
350
364
  const ENV: Record<Kind, Record<string, string>> = {
@@ -439,6 +453,14 @@ describe("--require-review: whoever wrote the change never counts (#4000)", () =
439
453
  expect(report.message).toContain(`approved by ${forgePrincipalOf(kind, HOST[kind], "dave")}, who wrote #12`);
440
454
  });
441
455
 
456
+ test("under the review-rules security group the author's approval is refused without --require-review (#4168)", async () => {
457
+ serve(kind, "carol");
458
+ await approveAs(kind, "carol");
459
+ groups = ["review-rules"];
460
+ expect(await runComponentsPrApply(ctx({ pr: 12, forge: kind, eventsJson: false }))).toBe(3);
461
+ expect(await applyReport()).toMatchObject({ status: "refused", refusal: "author-approved" });
462
+ });
463
+
442
464
  test("an outside reviewer approves: the plan applies", async () => {
443
465
  serve(kind, "erin");
444
466
  await approveAs(kind, "erin");