@intentius/chant 0.124.32 → 0.124.33

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/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 +16 -1
  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 +81 -21
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +23 -1
  24. package/dist/cli/registry.d.ts +18 -4
  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/release-compile.d.ts +37 -2
  40. package/dist/components/release-compile.d.ts.map +1 -1
  41. package/dist/components/release-compile.js +64 -8
  42. package/dist/components/release-promote.d.ts +17 -3
  43. package/dist/components/release-promote.d.ts.map +1 -1
  44. package/dist/components/release-promote.js +79 -18
  45. package/dist/components/verbs/bom-writer.d.ts +1 -1
  46. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  47. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  48. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  49. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  50. package/dist/components/wave-plan.d.ts +10 -0
  51. package/dist/components/wave-plan.d.ts.map +1 -1
  52. package/dist/components/wave-plan.js +32 -4
  53. package/dist/graph-detail.d.ts.map +1 -1
  54. package/dist/graph-detail.js +32 -7
  55. package/dist/health.d.ts +110 -0
  56. package/dist/health.d.ts.map +1 -0
  57. package/dist/health.js +105 -0
  58. package/dist/health.schema.json +191 -0
  59. package/dist/lexicon.d.ts +27 -0
  60. package/dist/lexicon.d.ts.map +1 -1
  61. package/dist/lifecycle/merge-record.d.ts +104 -0
  62. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  63. package/dist/lifecycle/merge-record.js +102 -0
  64. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  65. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  66. package/dist/lifecycle/publish-attachments.js +27 -13
  67. package/dist/lifecycle/publish-npm.d.ts +3 -2
  68. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  69. package/dist/lifecycle/publish-npm.js +4 -3
  70. package/dist/lifecycle/release-at.d.ts +40 -0
  71. package/dist/lifecycle/release-at.d.ts.map +1 -0
  72. package/dist/lifecycle/release-at.js +83 -0
  73. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  74. package/dist/lifecycle/release-cut.js +23 -12
  75. package/dist/lifecycle/release-publish.d.ts +28 -12
  76. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  77. package/dist/lifecycle/release-publish.js +29 -17
  78. package/dist/lifecycle/release-store.d.ts +25 -2
  79. package/dist/lifecycle/release-store.d.ts.map +1 -1
  80. package/dist/lifecycle/release-store.js +40 -13
  81. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  82. package/dist/lifecycle/run-ledger.js +1 -0
  83. package/dist/lint/rules/op/index.d.ts +2 -1
  84. package/dist/lint/rules/op/index.d.ts.map +1 -1
  85. package/dist/lint/rules/op/index.js +4 -2
  86. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  87. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  88. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  89. package/dist/op/activities/activity-contracts.d.ts +5 -0
  90. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  91. package/dist/op/activities/activity-contracts.js +3 -0
  92. package/dist/op/activities/shell.d.ts +38 -0
  93. package/dist/op/activities/shell.d.ts.map +1 -1
  94. package/dist/op/activities/shell.js +89 -3
  95. package/dist/op/builders.d.ts +7 -0
  96. package/dist/op/builders.d.ts.map +1 -1
  97. package/dist/op/builders.js +7 -0
  98. package/dist/op/runtime.d.ts +2 -0
  99. package/dist/op/runtime.d.ts.map +1 -1
  100. package/dist/pr-loop.d.ts +14 -3
  101. package/dist/pr-loop.d.ts.map +1 -1
  102. package/dist/pr-loop.js +15 -5
  103. package/dist/pr-report-url.d.ts +2 -2
  104. package/dist/pr-report-url.d.ts.map +1 -1
  105. package/dist/runner-start.d.ts +179 -0
  106. package/dist/runner-start.d.ts.map +1 -0
  107. package/dist/runner-start.js +277 -0
  108. package/dist/wave-runtime.mjs +235 -228
  109. package/dist/workspace/check.schema.json +70 -2
  110. package/dist/workspace/checks/security.d.ts +35 -0
  111. package/dist/workspace/checks/security.d.ts.map +1 -0
  112. package/dist/workspace/checks/security.js +305 -0
  113. package/dist/workspace/checks.d.ts +2 -0
  114. package/dist/workspace/checks.d.ts.map +1 -1
  115. package/dist/workspace/checks.js +5 -0
  116. package/dist/workspace/ci-builds.d.ts +163 -0
  117. package/dist/workspace/ci-builds.d.ts.map +1 -0
  118. package/dist/workspace/ci-builds.js +315 -0
  119. package/dist/workspace/ci-green-cli.d.ts +2 -1
  120. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  121. package/dist/workspace/ci-green-cli.js +25 -5
  122. package/dist/workspace/ci-green-forge.d.ts +12 -0
  123. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  124. package/dist/workspace/ci-green-forge.js +12 -0
  125. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  126. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  127. package/dist/workspace/ci-green-workflow.js +128 -1
  128. package/dist/workspace/ci-green.d.ts +89 -4
  129. package/dist/workspace/ci-green.d.ts.map +1 -1
  130. package/dist/workspace/ci-green.js +310 -23
  131. package/dist/workspace/ci-last-green.schema.json +49 -0
  132. package/dist/workspace/ci-list.schema.json +35 -1
  133. package/dist/workspace/ci-run-index.d.ts +1 -1
  134. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  135. package/dist/workspace/ci-run-index.schema.json +1 -1
  136. package/dist/workspace/ci-run.d.ts +12 -6
  137. package/dist/workspace/ci-run.d.ts.map +1 -1
  138. package/dist/workspace/ci-run.js +72 -16
  139. package/dist/workspace/ci-runner-start.d.ts +77 -0
  140. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  141. package/dist/workspace/ci-runner-start.js +185 -0
  142. package/dist/workspace/ci-serve.d.ts +76 -7
  143. package/dist/workspace/ci-serve.d.ts.map +1 -1
  144. package/dist/workspace/ci-serve.js +153 -19
  145. package/dist/workspace/declaration.d.ts +17 -0
  146. package/dist/workspace/declaration.d.ts.map +1 -1
  147. package/dist/workspace/declaration.js +3 -0
  148. package/dist/workspace/declaration.schema.json +23 -0
  149. package/dist/workspace/ls.d.ts +2 -0
  150. package/dist/workspace/ls.d.ts.map +1 -1
  151. package/dist/workspace/ls.js +1 -1
  152. package/dist/workspace/ls.schema.json +2 -1
  153. package/dist/workspace/member-pipeline.d.ts +1 -0
  154. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  155. package/dist/workspace/member-pipeline.js +2 -0
  156. package/dist/workspace/merge-record.schema.json +49 -0
  157. package/dist/workspace/pr-report.schema.json +3 -3
  158. package/dist/workspace/reason-codes.d.ts +17 -0
  159. package/dist/workspace/reason-codes.d.ts.map +1 -1
  160. package/dist/workspace/reason-codes.js +18 -0
  161. package/dist/workspace/release-candidates.d.ts +129 -0
  162. package/dist/workspace/release-candidates.d.ts.map +1 -0
  163. package/dist/workspace/release-candidates.js +233 -0
  164. package/dist/workspace/release-candidates.schema.json +353 -0
  165. package/dist/workspace/security.d.ts +40 -0
  166. package/dist/workspace/security.d.ts.map +1 -0
  167. package/dist/workspace/security.js +99 -0
  168. package/package.json +8 -1
  169. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  170. package/src/ci-jobs.ts +11 -2
  171. package/src/cli/handlers/build.test.ts +36 -0
  172. package/src/cli/handlers/build.ts +18 -3
  173. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  174. package/src/cli/handlers/lifecycle-health.ts +157 -0
  175. package/src/cli/handlers/lifecycle.ts +1 -1
  176. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  177. package/src/cli/handlers/pr-record.test.ts +201 -0
  178. package/src/cli/handlers/pr.test.ts +22 -0
  179. package/src/cli/handlers/pr.ts +127 -4
  180. package/src/cli/handlers/promote.test.ts +17 -0
  181. package/src/cli/handlers/promote.ts +16 -1
  182. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  183. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  184. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  185. package/src/cli/handlers/release.e2e.test.ts +19 -28
  186. package/src/cli/handlers/release.ts +81 -11
  187. package/src/cli/main.ts +81 -21
  188. package/src/cli/parse-args.ts +17 -1
  189. package/src/cli/registry.ts +18 -4
  190. package/src/components/deploy-inputs.test.ts +108 -0
  191. package/src/components/deploy-inputs.ts +225 -0
  192. package/src/components/driver.ts +11 -0
  193. package/src/components/gate-plan.ts +9 -0
  194. package/src/components/pr-pipeline.ts +56 -15
  195. package/src/components/pr-run.ts +1 -1
  196. package/src/components/release-compile.test.ts +34 -0
  197. package/src/components/release-compile.ts +89 -8
  198. package/src/components/release-promote.test.ts +55 -2
  199. package/src/components/release-promote.ts +93 -23
  200. package/src/components/verbs/bom-writer.ts +1 -1
  201. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  202. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  203. package/src/components/wave-plan.ts +32 -4
  204. package/src/graph-detail.test.ts +57 -0
  205. package/src/graph-detail.ts +33 -7
  206. package/src/health.schema.json +191 -0
  207. package/src/health.test.ts +62 -0
  208. package/src/health.ts +178 -0
  209. package/src/lexicon.ts +41 -1
  210. package/src/lifecycle/merge-record.test.ts +111 -0
  211. package/src/lifecycle/merge-record.ts +154 -0
  212. package/src/lifecycle/publish-attachments.ts +25 -13
  213. package/src/lifecycle/publish-npm.ts +4 -3
  214. package/src/lifecycle/publish-targets.test.ts +34 -4
  215. package/src/lifecycle/release-at.test.ts +48 -0
  216. package/src/lifecycle/release-at.ts +100 -0
  217. package/src/lifecycle/release-cut.test.ts +41 -0
  218. package/src/lifecycle/release-cut.ts +22 -11
  219. package/src/lifecycle/release-publish.test.ts +43 -13
  220. package/src/lifecycle/release-publish.ts +38 -18
  221. package/src/lifecycle/release-store.ts +57 -13
  222. package/src/lifecycle/run-ledger.test.ts +13 -0
  223. package/src/lifecycle/run-ledger.ts +1 -0
  224. package/src/lint/rules/op/index.ts +4 -2
  225. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  226. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  227. package/src/op/activities/activity-contracts.ts +3 -0
  228. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  229. package/src/op/activities/shell.test.ts +44 -1
  230. package/src/op/activities/shell.ts +110 -3
  231. package/src/op/builders.ts +7 -0
  232. package/src/op/runtime.ts +2 -0
  233. package/src/op/step-console.test.ts +4 -2
  234. package/src/pr-loop.test.ts +29 -0
  235. package/src/pr-loop.ts +26 -7
  236. package/src/pr-report-url.ts +2 -2
  237. package/src/runner-start-bundle.test.ts +29 -0
  238. package/src/runner-start.test.ts +179 -0
  239. package/src/runner-start.ts +329 -0
  240. package/src/workspace/check-contract.test.ts +3 -2
  241. package/src/workspace/check.schema.json +70 -2
  242. package/src/workspace/checks/security.test.ts +138 -0
  243. package/src/workspace/checks/security.ts +305 -0
  244. package/src/workspace/checks.ts +5 -0
  245. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  246. package/src/workspace/ci-builds.test.ts +174 -0
  247. package/src/workspace/ci-builds.ts +385 -0
  248. package/src/workspace/ci-green-builds.test.ts +180 -0
  249. package/src/workspace/ci-green-cli.test.ts +54 -0
  250. package/src/workspace/ci-green-cli.ts +26 -5
  251. package/src/workspace/ci-green-forge.ts +24 -0
  252. package/src/workspace/ci-green-workflow.test.ts +84 -1
  253. package/src/workspace/ci-green-workflow.ts +139 -2
  254. package/src/workspace/ci-green.test.ts +5 -2
  255. package/src/workspace/ci-green.ts +359 -27
  256. package/src/workspace/ci-last-green.schema.json +49 -0
  257. package/src/workspace/ci-list.schema.json +35 -1
  258. package/src/workspace/ci-run-index.schema.json +1 -1
  259. package/src/workspace/ci-run-index.ts +1 -1
  260. package/src/workspace/ci-run.test.ts +24 -7
  261. package/src/workspace/ci-run.ts +78 -16
  262. package/src/workspace/ci-runner-start.test.ts +151 -0
  263. package/src/workspace/ci-runner-start.ts +193 -0
  264. package/src/workspace/ci-serve-builds.test.ts +86 -0
  265. package/src/workspace/ci-serve.test.ts +59 -0
  266. package/src/workspace/ci-serve.ts +177 -22
  267. package/src/workspace/declaration.schema.json +23 -0
  268. package/src/workspace/declaration.test.ts +17 -0
  269. package/src/workspace/declaration.ts +22 -1
  270. package/src/workspace/ls.schema.json +2 -1
  271. package/src/workspace/ls.ts +3 -2
  272. package/src/workspace/member-pipeline.ts +2 -1
  273. package/src/workspace/merge-record.schema.json +49 -0
  274. package/src/workspace/pr-report.schema.json +3 -3
  275. package/src/workspace/reason-codes.test.ts +2 -0
  276. package/src/workspace/reason-codes.ts +18 -0
  277. package/src/workspace/release-candidates.schema.json +353 -0
  278. package/src/workspace/release-candidates.test.ts +106 -0
  279. package/src/workspace/release-candidates.ts +315 -0
  280. package/src/workspace/security.test.ts +70 -0
  281. package/src/workspace/security.ts +118 -0
@@ -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");
@@ -153,7 +153,7 @@ const fail = (message: string, hint?: string): number => {
153
153
  };
154
154
 
155
155
  /** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
156
- async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Prepared | number> {
156
+ async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"): Promise<Prepared | number> {
157
157
  const { args } = ctx;
158
158
  const projectPath = resolve(".");
159
159
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
@@ -297,6 +297,7 @@ function settleOf(status: PrReport["status"]): RunEventSettleStatus {
297
297
  switch (status) {
298
298
  case "nothing":
299
299
  case "planned":
300
+ case "recorded":
300
301
  case "applied":
301
302
  return "ok";
302
303
  case "refused":
@@ -405,7 +406,7 @@ async function withStageEvents(stage: string, ctx: CommandContext, run: (events:
405
406
  * Open the stage's `--report-url` upload under its pull request and commit,
406
407
  * when the flag was given. Its events are the lines `openCliRunEvents` writes.
407
408
  */
408
- function openUpload(events: StageEvents, stage: "plan" | "apply", pr: number | null, p: Prepared): PrReportUpload | undefined {
409
+ function openUpload(events: StageEvents, stage: "plan" | "apply" | "record", pr: number | null, p: Prepared): PrReportUpload | undefined {
409
410
  if (!events.target) return undefined;
410
411
  events.upload = openPrReportUpload({
411
412
  target: events.target,
@@ -484,6 +485,11 @@ export function runComponentsPrApply(ctx: CommandContext): Promise<number> {
484
485
  }
485
486
 
486
487
  async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number> {
488
+ // #4168: the review-rules security group turns --require-review on, read from the declaration at base.
489
+ const { securityGroupsAt } = await import("../../workspace/security");
490
+ const reviewRules = !ctx.args.requireReview && securityGroupsAt(process.cwd(), ctx.args.base).has("review-rules");
491
+ if (reviewRules && !ctx.args.forge) return fail("the workspace opts into review-rules, which reads the pull request's reviews, so pr-apply needs --forge <kind>");
492
+ if (reviewRules) ctx = { ...ctx, args: { ...ctx.args, requireReview: true } };
487
493
  const { args } = ctx;
488
494
  if (args.requireReview && !args.forge) return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
489
495
  if (args.wave !== undefined && !args.waveGate) return fail("--wave runs one wave of a --wave-gate apply, so it needs --wave-gate <name>");
@@ -723,12 +729,129 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
723
729
  return code;
724
730
  }
725
731
 
732
+ /** chant components pr-record --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
733
+ export function runComponentsPrRecord(ctx: CommandContext): Promise<number> {
734
+ if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
735
+ return withStageEvents("pr-record", ctx, (events) => prRecord(ctx, events));
736
+ }
737
+
738
+ /**
739
+ * The record stage (#4168 step 1): what a merge does unless apply on merge
740
+ * is opted in. It plans the members the change reaches on the merged
741
+ * commit, stores the change set, decides the pull request's gate against
742
+ * it without writing to the ledger, and writes the merge record
743
+ * (`../../lifecycle/merge-record.ts`). It applies nothing and refuses
744
+ * nothing. A plan that differs from the approved one is recorded, and the
745
+ * `chant/record` status fails (decision 1). It exits 1 only when a member
746
+ * failed to plan.
747
+ */
748
+ async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number> {
749
+ const { args } = ctx;
750
+ const p = await prepare(ctx, "record");
751
+ if (typeof p === "number") return p;
752
+
753
+ let pr: number | null = args.pr ?? null;
754
+ if (pr === null && p.forge) {
755
+ try {
756
+ pr = await p.forge.pullRequestFor(p.head);
757
+ } catch (err) {
758
+ console.error(formatWarning({ message: `could not ask ${p.forge.kind} which pull request merged ${p.head}, so the record names none: ${err instanceof Error ? err.message : String(err)}` }));
759
+ }
760
+ }
761
+ const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
762
+ const upload = openUpload(stage, "record", pr, p);
763
+ const events = await openCliRunEvents(args, { kind: "component", name: `pr-record ${op}`, cwd: resolve(".") }, upload?.line);
764
+ stage.stream = events;
765
+ events?.emit({ type: "run-start", waves: p.plan.waves });
766
+ const set = await planAll(p, events?.emit);
767
+ const nothing = p.plan.order.length === 0;
768
+
769
+ let approval: PrApproval = { status: "pending" };
770
+ if (!nothing && !set.failed) {
771
+ await storeChangeSetForGate(ctx, p, set.doc);
772
+ if (pr !== null) {
773
+ try {
774
+ const check = await emitGateStep(events?.emit, { op, gate: p.gate }, () =>
775
+ decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: set.doc.digest }),
776
+ );
777
+ approval = approvalOf(check);
778
+ } catch (err) {
779
+ console.error(formatWarning({ message: `could not read the gate ledger, so the record says no approval: ${err instanceof Error ? err.message : String(err)}` }));
780
+ }
781
+ }
782
+ }
783
+
784
+ // #4000: the record keeps an approval by someone who wrote the change, which review rules would not count.
785
+ let authorApproved: string[] | undefined;
786
+ if (pr !== null && p.forge && approval.status === "approved") {
787
+ try {
788
+ const authors = (await p.forge.authors(pr)).map((login) => p.forge!.principalOf(login));
789
+ authorApproved = approversWhoAuthored(approval.approvedBy ?? [], authors);
790
+ } catch (err) {
791
+ console.error(formatWarning({ message: `could not read the authors of #${pr}: ${err instanceof Error ? err.message : String(err)}` }));
792
+ }
793
+ }
794
+
795
+ const status = nothing ? "nothing" : set.failed ? "plan-failed" : "recorded";
796
+ const report = prReport({
797
+ stage: "record",
798
+ pr,
799
+ env: p.env,
800
+ ...(p.member ? { member: p.member } : {}),
801
+ base: p.base,
802
+ head: p.head,
803
+ op,
804
+ gate: p.gate,
805
+ plan: p.plan,
806
+ signal: p.signal,
807
+ set,
808
+ approval,
809
+ status,
810
+ ...(status === "plan-failed" ? { message: "A member failed to plan on the merged commit, so the record has no plan to compare with the approved one. Nothing was applied." } : {}),
811
+ ...(status === "recorded" && approval.status === "changed"
812
+ ? { message: `${describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval })} The merge stands and nothing was applied; the release gate decides before this deploys.` }
813
+ : {}),
814
+ });
815
+
816
+ const { mergeSourceOf, writeMergeRecord, MERGE_RECORD_SCHEMA_ID, MERGE_RECORD_CONTRACT } = await import("../../lifecycle/merge-record");
817
+ const { pushLifecycleReport } = await import("../../lifecycle/git");
818
+ try {
819
+ const written = await writeMergeRecord({
820
+ $schema: MERGE_RECORD_SCHEMA_ID,
821
+ contract: MERGE_RECORD_CONTRACT,
822
+ commit: p.head,
823
+ base: p.base,
824
+ env: p.env,
825
+ ...(p.member ? { member: p.member } : {}),
826
+ source: mergeSourceOf(resolve("."), p.head, pr),
827
+ op: pr === null ? null : op,
828
+ gate: p.gate,
829
+ digest: nothing ? null : set.doc.digest,
830
+ plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
831
+ ...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
832
+ approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
833
+ ...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
834
+ components: p.plan.order,
835
+ recordedAt: new Date().toISOString(),
836
+ });
837
+ if (written.written) {
838
+ const pushed = await pushLifecycleReport();
839
+ if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
840
+ }
841
+ } catch (err) {
842
+ console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
843
+ }
844
+
845
+ const stop = stopper(ctx, p, events, upload);
846
+ return stop(report, status === "plan-failed" ? 1 : 0);
847
+ }
848
+
726
849
  /** How a stage stops: settle its events, write and publish the report, say how it ended, and return `code`. */
727
850
  function stopper(ctx: CommandContext, p: Prepared, events: RunEventStream | undefined, upload: PrReportUpload | undefined) {
728
851
  return async (report: PrReport, code: number, settle?: RunEventSettleStatus): Promise<number> => {
729
- events?.settle(settle ?? settleOf(report.status), report.status === "plan-failed" || report.status === "failed" ? report.message ?? `pr-apply ${report.status}` : undefined);
852
+ events?.settle(settle ?? settleOf(report.status), report.status === "plan-failed" || report.status === "failed" ? report.message ?? `pr-${report.stage} ${report.status}` : undefined);
730
853
  await finish(ctx, p, report, upload);
731
- console.error((code === 0 ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-apply: ${report.status}${report.refusal ? ` (${report.refusal})` : ""}`));
854
+ console.error((code === 0 ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-${report.stage}: ${report.status}${report.refusal ? ` (${report.refusal})` : ""}`));
732
855
  if (report.message) console.error(report.message);
733
856
  printMembers(report);
734
857
  return code;
@@ -156,6 +156,12 @@ vi.mock("../../lifecycle/release-revocation", async () => {
156
156
  return { ...actual, readRevokedCommits: async () => revokedCommits };
157
157
  });
158
158
 
159
+ // #4168: the security groups and the release verification, set per test.
160
+ let groups: string[] = [];
161
+ let verification: { status: "verified" | "unsigned" | "unverified"; digestMatches: boolean; reason: string } = { status: "unsigned", digestMatches: true, reason: "no cut of the release carries a signature" };
162
+ vi.mock("../../workspace/security", async (original) => ({ ...(await original<typeof import("../../workspace/security")>()), securityGroupsAt: () => new Set(groups) }));
163
+ vi.mock("../../lifecycle/release-verify", async (original) => ({ ...(await original<typeof import("../../lifecycle/release-verify")>()), verifyRelease: async () => verification }));
164
+
159
165
  // The run handler pulls in every runtime; the promote needs only its exit code.
160
166
  vi.mock("./run", () => ({ GATED_EXIT_CODE: 3 }));
161
167
 
@@ -214,6 +220,8 @@ beforeEach(() => {
214
220
  knownLabels.clear();
215
221
  outcomes.length = 0;
216
222
  revokedCommits.clear();
223
+ groups = [];
224
+ verification = { status: "unsigned", digestMatches: true, reason: "no cut of the release carries a signature" };
217
225
  removedTemplates.length = 0;
218
226
  ran.length = 0;
219
227
  stepInputs.length = 0;
@@ -571,6 +579,15 @@ describe("chant components promote --release binds its gate (#4045)", () => {
571
579
  expect(appendReleaseRecordMock).not.toHaveBeenCalled();
572
580
  });
573
581
 
582
+ test("under verified-releases a release that does not verify is refused before anything runs, and one that does goes on to its gate (#4168)", async () => {
583
+ groups = ["verified-releases"];
584
+ expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(1);
585
+ expect(errors.join("\n")).toMatch(/release-unsigned: release v1\.0\.0 does not verify \(no cut of the release carries a signature\), and the workspace opts into verified-releases/);
586
+ expect(ran).toEqual([]);
587
+ verification = { status: "verified", digestMatches: true, reason: "signed by alice" };
588
+ expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(3);
589
+ });
590
+
574
591
  test("an approval for one release can't be used by another", async () => {
575
592
  expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(3);
576
593
  const pending = releaseGatePort!.appended[0];