@intentius/chant 0.124.40 → 0.124.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Stage hooks on the pull-request path (#4288): `ci.hooks` in the workspace
3
+ * declaration, run by `chant components pr-plan`, `pr-record` and
4
+ * `pr-apply` with the runner an Op waves job uses (`runStageHooks`).
5
+ *
6
+ * The hooks are read from the declaration at the base the job measures the
7
+ * change from, never from the change itself, so a pull request cannot add,
8
+ * edit or remove a hook its own jobs run. The top level's hooks run first,
9
+ * then those of the member the job runs in. When the base has no workspace
10
+ * declaration no hook runs. When the declaration at base cannot be read and
11
+ * the working tree's names hooks, the job refuses to run rather than guess.
12
+ *
13
+ * The runs a hook sees are the components the change selects, in apply
14
+ * order: `{target}` and `CHANT_TARGET` name one, and `targets` match their
15
+ * names. After the plan, a `perRun` hook's `{plan}` is a JSON file with that
16
+ * component's members and the plans its steps hand the apply.
17
+ */
18
+
19
+ import { mkdirSync, writeFileSync } from "node:fs";
20
+ import { join, relative, resolve } from "node:path";
21
+ import type { OpWaveHook, OpWaveHookMoment, OpWaveHooks } from "../op/op-waves";
22
+ import { PR_LOOP_HOOK_MOMENTS } from "../op/op-waves";
23
+ import { runStageHooks, type OpWaveExec, type OpWaveHookRun, type StageHooksRun } from "../op/op-waves-run";
24
+ import { readDeclaration, WorkspaceReadError, type Declaration } from "../workspace/declaration";
25
+ import { memberAtCwd } from "../workspace/security";
26
+ import { locateWorkspace } from "../workspace/which-chant";
27
+ import type { PrHookOutcome, PrMember } from "../pr-loop";
28
+
29
+ /** The hooks a pull-request job runs, by moment, and where they were read. */
30
+ export interface PrLoopHooks {
31
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
32
+ /** The commit they were read at. */
33
+ base: string;
34
+ /** A line for the log when none run because the base has no declaration, or could not be read. */
35
+ note?: string;
36
+ }
37
+
38
+ /** The hooks of one `ci.hooks` block at `moment`. */
39
+ function at(hooks: OpWaveHooks | null | undefined, moment: OpWaveHookMoment): OpWaveHook[] {
40
+ const [side, stage] = moment.split("-") as ["before" | "after", string];
41
+ return ((hooks?.[side] as Record<string, OpWaveHook[] | undefined> | undefined)?.[stage]) ?? [];
42
+ }
43
+
44
+ /** The hooks `declaration` gives a job run in `cwd`: the top level's, then the member's. */
45
+ export function prLoopHooksOf(declaration: Pick<Declaration, "ci" | "members">, member: string | null): Partial<Record<OpWaveHookMoment, OpWaveHook[]>> {
46
+ const own = member === null ? null : (declaration.members.find((m) => m.name === member)?.ciHooks ?? null);
47
+ const out: Partial<Record<OpWaveHookMoment, OpWaveHook[]>> = {};
48
+ for (const moment of PR_LOOP_HOOK_MOMENTS) {
49
+ const list = [...at(declaration.ci?.hooks, moment), ...at(own, moment)];
50
+ if (list.length > 0) out[moment] = list;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Whether the working tree's declaration names any `ci.hooks`. */
56
+ function checkoutNamesHooks(cwd: string): boolean {
57
+ try {
58
+ const located = locateWorkspace(cwd);
59
+ const d = readDeclaration(located.tree);
60
+ return Object.keys(prLoopHooksOf(d, memberAtCwd({ declaration: d, rootOnDisk: located.rootOnDisk }, cwd))).length > 0;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The hooks a pull-request job in `cwd` runs, read from the workspace
68
+ * declaration at `base`. Throws when the declaration at base cannot be read
69
+ * and the working tree's names hooks.
70
+ */
71
+ export function prLoopHooksAt(cwd: string, base: string): PrLoopHooks {
72
+ let declaration: Declaration;
73
+ let rootOnDisk: string;
74
+ try {
75
+ const located = locateWorkspace(cwd, base);
76
+ declaration = readDeclaration(located.tree);
77
+ rootOnDisk = located.rootOnDisk;
78
+ } catch (err) {
79
+ const missing = err instanceof WorkspaceReadError && (err.code === "declaration-missing" || err.code === "not-a-git-repository");
80
+ const why = err instanceof Error ? err.message : String(err);
81
+ if (checkoutNamesHooks(cwd)) {
82
+ if (missing) return { hooks: {}, base, note: `ci.hooks: the base ${base.slice(0, 12)} has no workspace declaration, so the hooks this change declares do not run until it merges` };
83
+ throw new Error(`ci.hooks are read from the workspace declaration at ${base.slice(0, 12)}, and it could not be read (${why}), so no hook can be trusted to run`);
84
+ }
85
+ return missing ? { hooks: {}, base } : { hooks: {}, base, note: `ci.hooks: the declaration at ${base.slice(0, 12)} could not be read (${why}); this checkout names no hooks, so none run` };
86
+ }
87
+ return { hooks: prLoopHooksOf(declaration, memberAtCwd({ declaration, rootOnDisk }, cwd)), base };
88
+ }
89
+
90
+ /** Where a pull-request job's hooks run, and what they are told. */
91
+ export interface PrHookPlace {
92
+ /** The gate's op, `pr-<n>`: `{op}` and `CHANT_OP_WAVES`. */
93
+ op: string;
94
+ /** The environment: `{wave}`, `CHANT_WAVE` and `CHANT_ENV`. */
95
+ env: string;
96
+ stage: "plan" | "apply" | "record";
97
+ /** The report directory, relative to `cwd`; the runs and plan files go under `<dir>/hooks`. */
98
+ dir: string;
99
+ cwd: string;
100
+ exec?: OpWaveExec;
101
+ log?: (line: string) => void;
102
+ }
103
+
104
+ /** What one moment's hooks came to on the pull-request path. */
105
+ export interface PrHooksRun {
106
+ outcomes: PrHookOutcome[];
107
+ failed?: string;
108
+ holds: string[];
109
+ }
110
+
111
+ /**
112
+ * Run the hooks of `moment` for `components` (#4288). `plans`, after the
113
+ * plan, gives each component's members and step plans, written where a
114
+ * `perRun` hook's `{plan}` points.
115
+ */
116
+ export function runPrHooks(
117
+ read: PrLoopHooks,
118
+ moment: OpWaveHookMoment,
119
+ place: PrHookPlace,
120
+ components: readonly string[],
121
+ planned?: { members: readonly PrMember[]; plans: Record<string, Record<string, unknown>> },
122
+ ): PrHooksRun {
123
+ const hooks = read.hooks[moment];
124
+ if (!hooks || hooks.length === 0) return { outcomes: [], holds: [] };
125
+ // Relative to cwd, as an Op waves job's paths are, whether --output was given relative or absolute.
126
+ const dir = relative(place.cwd, resolve(place.cwd, place.dir, "hooks")) || ".";
127
+ mkdirSync(join(place.cwd, dir, "plans"), { recursive: true });
128
+ const runs: OpWaveHookRun[] = components.map((component) => {
129
+ if (!planned) return { target: component, op: place.op };
130
+ const plan = join(dir, "plans", `${component.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
131
+ const body = { component, members: planned.members.filter((m) => m.component === component), plans: planned.plans[component] ?? {} };
132
+ writeFileSync(join(place.cwd, plan), JSON.stringify(body, null, 2) + "\n");
133
+ return { target: component, op: place.op, plan };
134
+ });
135
+ const runsFile = join(dir, "runs.json");
136
+ writeFileSync(join(place.cwd, runsFile), JSON.stringify({ version: 1, op: place.op, env: place.env, stage: place.stage, base: read.base, hook: moment, runs }, null, 2) + "\n");
137
+ const ran: StageHooksRun = runStageHooks(moment, hooks, {
138
+ name: place.op,
139
+ op: place.op,
140
+ wave: place.env,
141
+ runs,
142
+ runsFile,
143
+ holdFile: join(dir, "hold.json"),
144
+ env: { CHANT_ENV: place.env, CHANT_PR_STAGE: place.stage, CHANT_HOOKS_BASE: read.base },
145
+ cwd: place.cwd,
146
+ ...(place.exec ? { exec: place.exec } : {}),
147
+ ...(place.log ? { log: place.log } : {}),
148
+ });
149
+ const outcomes = ran.outcomes.map((o): PrHookOutcome => ({
150
+ moment: o.moment,
151
+ name: o.name,
152
+ ...(o.target ? { target: o.target } : {}),
153
+ exit: o.exit,
154
+ outcome: o.outcome === "failed" ? "failed" : o.outcome === "held" ? "held" : "passed",
155
+ }));
156
+ return { outcomes, ...(ran.failed ? { failed: ran.failed } : {}), holds: ran.holds };
157
+ }
@@ -8,8 +8,11 @@
8
8
  * values they read from the forge live here so the three cannot disagree.
9
9
  *
10
10
  * - The plan job runs `chant components pr-plan` against the pull request's
11
- * target commit. It posts the note and the `chant/plan` status and writes
12
- * nothing to `chant/lifecycle`, since it runs the pull request's code.
11
+ * target commit. It writes nothing to `chant/lifecycle` and holds no forge
12
+ * token, since it runs the pull request's code (#4295): it writes the note
13
+ * and the status into its report with `--no-status`, and the `plan-note`
14
+ * job after it posts them with `chant components pr-post`
15
+ * ({@link prLoopPostJob}, ../ci-handover.ts).
13
16
  * - The record job (#4168 step 1, the default) runs `chant components
14
17
  * pr-record` against the commit the push replaced. It plans the same
15
18
  * members on the merged commit, stores the change set and writes the merge
@@ -81,10 +84,12 @@
81
84
  import type { ForgeKind } from "../pr-forge";
82
85
  import { PR_APPLY_GATE } from "../pr-loop";
83
86
  import { CI_IMAGE, GITHUB_JOB_TOKEN } from "../ci-jobs";
87
+ import { withoutForgeTokens } from "../ci-handover";
84
88
  import { EMPTY_TREE_SHA } from "./changed-files";
85
89
  import { layerWaves } from "../gated-waves";
86
90
  import type { DriverComponent } from "./driver";
87
91
  import type { GatedWavePipelineOptions } from "./gated-wave-pipeline";
92
+ import type { PipelineCloudIdentity } from "../ci-identity";
88
93
 
89
94
  /** The variable `chant ci run` reads its runner key from (`RUNNER_KEY_ENV` in ../workspace/ci-green.ts). */
90
95
  const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
@@ -175,11 +180,27 @@ export interface PrLoopPipelineOptions {
175
180
  * wave. Set from `--wave-gate` beside `--pr-loop` ({@link prLoopWithWaves}).
176
181
  */
177
182
  waveGate?: string;
183
+ /**
184
+ * The plan and apply cloud identities (#4296), from `ci.identity.cloud`
185
+ * resolved for the pipeline's environment and member
186
+ * (`resolveCloudIdentity` in ../ci-identity.ts). The plan job trades its
187
+ * OIDC token for the plan identity before the checkout, the record job
188
+ * for the plan identity, and the apply jobs for the apply identity.
189
+ * Absent: no job requests an OIDC token.
190
+ */
191
+ identity?: PipelineCloudIdentity;
178
192
  /**
179
193
  * Which GitHub-shaped forge the workflow is for. Set by the forgejo
180
194
  * generator, which reuses the github one; a caller never needs to.
181
195
  */
182
196
  forge?: "github" | "forgejo";
197
+ /**
198
+ * An environment per open pull request (#4298, `ci.prEnvironment`): an
199
+ * `env-up` job on each opened, updated or reopened pull request, an
200
+ * `env-down` job when it closes, and an `env-sweep` job on a schedule
201
+ * ({@link prEnvJobs}). Set from `--pr-env` beside `--pr-loop`. Default false.
202
+ */
203
+ prEnvironment?: boolean;
183
204
  }
184
205
 
185
206
  /** One job's command and the forge values it reads, as environment variables for its step. */
@@ -264,16 +285,18 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
264
285
  ...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
265
286
  ];
266
287
  const runEnv = (env: Record<string, string>): Record<string, string> => (github ? { ...env, [RUNNER_KEY_ENV]: `\${{ secrets.${RUNNER_KEY_ENV} }}` } : env);
288
+ // #4295: the plan runs the pull request's code, so it gets no forge token. It writes the note and the
289
+ // status into its report (--no-status), and the plan-note job posts them (prLoopPostJob).
267
290
  const planEnv = github
268
- ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
269
- : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
291
+ ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...withoutForgeTokens(jobEnv) }
292
+ : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...withoutForgeTokens(jobEnv) };
270
293
  const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
271
294
  const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
272
295
  return [
273
296
  {
274
297
  jobName: "plan",
275
298
  // With `waveGate` the plan's note says the later waves may stop at their own gates (#4003).
276
- command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : [])].join(" "),
299
+ command: ["chant", "components", "pr-plan", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common, ...(options.waveGate ? ["--wave-gate", options.waveGate] : []), "--no-status"].join(" "),
277
300
  env: planEnv,
278
301
  ...build,
279
302
  run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
@@ -304,6 +327,35 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
304
327
  ];
305
328
  }
306
329
 
330
+ /** The name of the job that posts the plan's note and status (#4295). */
331
+ export const PR_PLAN_NOTE_JOB = "plan-note";
332
+
333
+ /**
334
+ * The plan-note job's command and the values it reads (#4295). It runs in a
335
+ * fresh job after the plan, at the target branch's commit, and posts the
336
+ * note and the `chant/plan` status from the report the plan job handed on,
337
+ * with the forge token the plan job never held. `HEAD_SHA` is the commit
338
+ * the plan job checked out, `PR_NUMBER` the pull request, and on GitHub and
339
+ * Forgejo `PLAN_RESULT` the plan job's result, which fails the status when
340
+ * the plan job failed. `BASE_SHA` is the commit the note job checks out on
341
+ * GitLab, whose runner checks out the pipeline's own commit.
342
+ */
343
+ export function prLoopPostJob(forge: ForgeKind, env: string, member?: string): { command: string; env: Record<string, string> } {
344
+ const github = forge !== "gitlab";
345
+ const command = [
346
+ "chant", "components", "pr-post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"',
347
+ "--env", env, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
348
+ ...(member ? ["--member", member] : []),
349
+ ...(github ? ["--result", '"$PLAN_RESULT"'] : []),
350
+ ].join(" ");
351
+ return {
352
+ command,
353
+ env: github
354
+ ? { PR_NUMBER: "${{ github.event.pull_request.number }}", HEAD_SHA: "${{ github.sha }}", PLAN_RESULT: "${{ needs.plan.result }}", GITHUB_TOKEN: GITHUB_JOB_TOKEN }
355
+ : { PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
356
+ };
357
+ }
358
+
307
359
  /**
308
360
  * What starts the apply under apply on merge (#4168 step 9): on GitHub the
309
361
  * tick's workflow finishing (`workflow_run`, since tags its token pushes start
@@ -450,3 +502,76 @@ export function gitlabBaseFallback(branch?: string): string {
450
502
  export function githubBaseFallback(branch?: string): string {
451
503
  return pushBaseFallback(JSON.stringify(branch ?? "main"), "$GITHUB_SHA");
452
504
  }
505
+
506
+ /** Where the pull request environment jobs (#4298) write their reports, kept as a CI artifact. */
507
+ export const PR_ENV_REPORT_DIR = ".chant/pr-env";
508
+
509
+ /** The cron the generated `env-sweep` job runs on (#4298): hourly, at a minute off the hour. */
510
+ export const PR_ENV_SWEEP_CRON = "17 * * * *";
511
+
512
+ /** One job of a pull request's environment (#4298): its command and the forge values it reads. */
513
+ export interface PrEnvJob {
514
+ jobName: "env-up" | "env-post" | "env-down" | "env-sweep";
515
+ /** The command line. `env-up` reads `$BASE_SHA` and `$PR_NUMBER`, `env-down` `$PR_NUMBER`. */
516
+ command: string;
517
+ /** Values the command reads, as the forge spells them. `CHANT_ENV` is the pull request's environment. */
518
+ env: Record<string, string>;
519
+ /** With `build`: the build and lint lines, run before the command. */
520
+ build?: string[];
521
+ }
522
+
523
+ /** The name of the job that keeps an environment's record and posts its note (#4298, #4295). */
524
+ export const PR_ENV_POST_JOB = "env-post";
525
+
526
+ /**
527
+ * The jobs of a pull request's environment (#4298), when `prEnvironment` is
528
+ * on, in this order:
529
+ *
530
+ * - `env-up` runs `chant components pr-env up --no-push`. It runs the
531
+ * change's code, so it gets no forge token (#4295): it passes no
532
+ * `--forge`, and it hands its report and its record on.
533
+ * - `env-post` runs `pr-env post` in a fresh job at the target branch's
534
+ * commit, with the token: it keeps the record on chant/lifecycle and posts
535
+ * the note and the `chant/pr-env` status. On GitHub and Forgejo it reads
536
+ * the up job's result as `UP_RESULT`.
537
+ * - `env-down` runs `pr-env down --reason closed`, and `env-sweep` runs
538
+ * `pr-env sweep`. Neither builds the change's code: a teardown reads the
539
+ * markers off the live resources.
540
+ *
541
+ * Each writes its report under {@link PR_ENV_REPORT_DIR}.
542
+ */
543
+ export function prEnvJobs(forge: ForgeKind, options: PrLoopPipelineOptions = {}, member?: string): PrEnvJob[] {
544
+ const github = forge !== "gitlab";
545
+ const out = ["--output", PR_ENV_REPORT_DIR, ...(member ? ["--member", member] : [])];
546
+ const common = ["--forge", forge, ...out];
547
+ const token: Record<string, string> = github ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {};
548
+ const prNumber = github ? "${{ github.event.pull_request.number }}" : "$CI_MERGE_REQUEST_IID";
549
+ const prEnv = { CHANT_ENV: `pr-${prNumber}` };
550
+ const buildLines = prLoopBuildLines('"$CHANT_ENV"', options);
551
+ return [
552
+ {
553
+ jobName: "env-up",
554
+ // #4295: no --forge and no token; the record stays in the report directory for env-post.
555
+ command: ["chant", "components", "pr-env", "up", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...out, "--no-push"].join(" "),
556
+ env: { BASE_SHA: github ? "${{ github.event.pull_request.base.sha }}" : "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: prNumber, ...prEnv },
557
+ ...(buildLines.length > 0 ? { build: buildLines } : {}),
558
+ },
559
+ {
560
+ jobName: PR_ENV_POST_JOB,
561
+ command: ["chant", "components", "pr-env", "post", "--pr", '"$PR_NUMBER"', "--head", '"$HEAD_SHA"', ...common, ...(github ? ["--result", '"$UP_RESULT"'] : [])].join(" "),
562
+ env: github
563
+ ? { PR_NUMBER: prNumber, HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}", ...token }
564
+ : { PR_NUMBER: prNumber, HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" },
565
+ },
566
+ {
567
+ jobName: "env-down",
568
+ command: ["chant", "components", "pr-env", "down", "--pr", '"$PR_NUMBER"', "--reason", "closed", ...common].join(" "),
569
+ env: { PR_NUMBER: prNumber, ...prEnv, ...token },
570
+ },
571
+ {
572
+ jobName: "env-sweep",
573
+ command: ["chant", "components", "pr-env", "sweep", ...common].join(" "),
574
+ env: { ...token },
575
+ },
576
+ ];
577
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `ci.policy` on the pull-request path (#4294): the settings and the policy
3
+ * read at base, the engine run over the change set, and a denial decided
4
+ * against the override ledger. The engine is a fake that prints opa's
5
+ * result shape; nothing reaches a network.
6
+ */
7
+ import { afterAll, describe, expect, test } from "vitest";
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { parseDeclaration, WorkspaceReadError } from "../workspace/declaration";
13
+ import { composeChangeSet } from "../change-set";
14
+ import { memoryGateLedgerPort } from "../op/gate";
15
+ import { overrideDigest } from "../override-gate";
16
+ import type { GatePolicyExec } from "../gate-policy";
17
+ import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrPolicyInput } from "./pr-policy";
18
+
19
+ const scratch: string[] = [];
20
+ afterAll(() => {
21
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
22
+ });
23
+ const tmp = (): string => {
24
+ const d = realpathSync(mkdtempSync(join(tmpdir(), "pr-policy-")));
25
+ scratch.push(d);
26
+ return d;
27
+ };
28
+ const git = (cwd: string, ...args: string[]): string => execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
29
+
30
+ const declaration = (policy: unknown) => ({
31
+ name: "acme",
32
+ schema: 1,
33
+ members: [{ name: "infra", dir: "infra", kind: "other", because: "test" }],
34
+ ...(policy ? { ci: { policy } } : {}),
35
+ });
36
+
37
+ /** A repo whose base declares `ci.policy` with an opa policy and one overrider. */
38
+ function repoWithPolicy(): { repo: string; base: string } {
39
+ const repo = tmp();
40
+ git(repo, "init", "-q", "-b", "main");
41
+ git(repo, "config", "user.email", "t@example.com");
42
+ git(repo, "config", "user.name", "t");
43
+ mkdirSync(join(repo, "infra"));
44
+ mkdirSync(join(repo, "policy"));
45
+ mkdirSync(join(repo, ".chant"));
46
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy" })));
47
+ writeFileSync(join(repo, "policy", "main.rego"), "package main\n# base\n");
48
+ writeFileSync(join(repo, ".chant", "overriders"), "github:carol\n");
49
+ git(repo, "add", ".");
50
+ git(repo, "commit", "-q", "-m", "base");
51
+ return { repo, base: git(repo, "rev-parse", "HEAD") };
52
+ }
53
+
54
+ const doc = composeChangeSet([]);
55
+ const input = (base: string): PrPolicyInput => ({ version: 1, stage: "apply", op: "pr-7", gate: "pr-apply", env: "prod", pr: 7, base, head: "h".repeat(40), changeSet: doc });
56
+ const opaOut = (value: unknown): string => JSON.stringify({ result: [{ expressions: [{ value }] }] });
57
+
58
+ describe("ci.policy in the declaration (#4294)", () => {
59
+ test("parses at the top level, and refuses an unattended outcome", () => {
60
+ const read = (policy: unknown) => parseDeclaration(JSON.stringify(declaration(policy)), "chant.workspace.json", "0.124.41");
61
+ expect(read({ engine: "opa", path: "rules" }).ci?.policy).toEqual({ engine: "opa", path: "rules" });
62
+ expect(() => read({ outcomes: ["allow_unattended"] })).toThrow(WorkspaceReadError);
63
+ expect(() => read({ engine: "rego" })).toThrow(/conftest or opa/);
64
+ });
65
+ });
66
+
67
+ describe("prLoopPolicyAt and runPrPolicy (#4294)", () => {
68
+ test("the settings and the policy come from the base: the change's edits to either do not run", () => {
69
+ const { repo, base } = repoWithPolicy();
70
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa", path: "policy", command: ["true", "{input}"] })));
71
+ writeFileSync(join(repo, "policy", "main.rego"), "package main\n# the change loosens it\n");
72
+ const read = prLoopPolicyAt(join(repo, "infra"), base);
73
+ expect(read.settings).toEqual({ engine: "opa", path: "policy" });
74
+ let seen = "";
75
+ let fed: unknown;
76
+ const exec: GatePolicyExec = (argv) => {
77
+ seen = readFileSync(join(argv[argv.indexOf("--data") + 1]!, "main.rego"), "utf-8");
78
+ fed = JSON.parse(readFileSync(argv[argv.indexOf("--input") + 1]!, "utf-8"));
79
+ return { status: 0, stdout: opaOut({ deny: ["public bucket"] }), stderr: "" };
80
+ };
81
+ const verdict = runPrPolicy(read, input(base), exec)!;
82
+ expect(seen).toBe("package main\n# base\n");
83
+ expect(fed).toMatchObject({ op: "pr-7", stage: "apply", changeSet: { digest: doc.digest } });
84
+ expect(prPolicyReport(verdict, base)).toEqual({ result: "denied", engine: "opa", base, denials: [{ rule: "main.deny", message: "public bucket" }], rules: ["main.deny"], holds: [], warnings: [] });
85
+ });
86
+
87
+ test("no policy at base runs none, and says so when the change declares one", () => {
88
+ const repo = tmp();
89
+ git(repo, "init", "-q", "-b", "main");
90
+ git(repo, "config", "user.email", "t@example.com");
91
+ git(repo, "config", "user.name", "t");
92
+ writeFileSync(join(repo, "README"), "x");
93
+ git(repo, "add", ".");
94
+ git(repo, "commit", "-q", "-m", "base");
95
+ const base = git(repo, "rev-parse", "HEAD");
96
+ expect(prLoopPolicyAt(repo, base)).toEqual({ base });
97
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ engine: "opa" })));
98
+ const read = prLoopPolicyAt(repo, base);
99
+ expect(read.settings).toBeUndefined();
100
+ expect(read.note).toMatch(/runs once it merges/);
101
+ expect(runPrPolicy(read, input(base))).toBeUndefined();
102
+ });
103
+
104
+ test("a policy directory the base lacks is an error, which stops the apply", () => {
105
+ const { repo, base } = repoWithPolicy();
106
+ const read = { ...prLoopPolicyAt(repo, base), settings: { engine: "opa" as const, path: "missing" } };
107
+ expect(runPrPolicy(read, input(base), () => ({ status: 0, stdout: opaOut({}), stderr: "" }))).toMatchObject({ result: "error", error: "the policy directory missing does not exist at base" });
108
+ });
109
+ });
110
+
111
+ describe("a denial and its override (#4294)", () => {
112
+ test("no override records the denial once; a listed overrider's override counts; spent, it counts no more", async () => {
113
+ const { repo, base } = repoWithPolicy();
114
+ const port = memoryGateLedgerPort();
115
+ const ask = (record: boolean) =>
116
+ decidePrPolicyOverride({ port, subject: "pr-7", digest: doc.digest, rules: ["main.deny"], base, cwd: repo, sealed: false, record, now: "2026-10-22T10:00:00Z" });
117
+
118
+ const plan = await ask(false);
119
+ expect(plan).toMatchObject({ status: "none", recorded: false, overriders: ["github:carol"], command: 'chant approve policy-override pr-7 --rule main.deny --reason "<why>"' });
120
+ expect(port.appended).toHaveLength(0);
121
+
122
+ expect(await ask(true)).toMatchObject({ status: "none", recorded: true });
123
+ expect(port.appended[0]).toMatchObject({ op: "policy-override", gate: "pr-7", plan: doc.digest, rules: ["main.deny"] });
124
+ // The denial stands, so a second refusal records no other.
125
+ expect(await ask(true)).toMatchObject({ status: "none", recorded: false });
126
+
127
+ const od = overrideDigest("pr-7", doc.digest, ["main.deny"]);
128
+ await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:dave", timestamp: "2026-10-22T11:00:00Z", planDigest: od, note: "not listed" });
129
+ expect(await ask(true)).toMatchObject({ status: "none", refusals: [expect.stringContaining("github:dave is not listed")] });
130
+
131
+ await port.appendResolution!({ op: "policy-override", gate: "pr-7", resolvedBy: "github:carol", timestamp: "2026-10-22T12:00:00Z", planDigest: od, note: "the bucket is the public docs site" });
132
+ const counted = await ask(true);
133
+ expect(counted).toEqual({ status: "overridden", override: { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "the bucket is the public docs site", digest: od } });
134
+
135
+ if (counted.status !== "overridden") throw new Error("expected an override");
136
+ await spendPrPolicyOverride(port, "pr-7", counted.override, { now: "2026-10-22T12:30:00Z", commit: "c".repeat(40) });
137
+ expect(port.applied).toEqual([expect.objectContaining({ op: "policy-override", gate: "pr-7", planDigest: od, approvedBy: "github:carol", commit: "c".repeat(40) })]);
138
+ expect(await ask(true)).toMatchObject({ status: "none", refusals: expect.arrayContaining([expect.stringContaining("is spent")]) });
139
+ });
140
+
141
+ test("an applied record that does not reach the remote throws, so nothing applies", async () => {
142
+ const port = { ...memoryGateLedgerPort(), appendApplied: async () => ({ pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' }) };
143
+ await expect(spendPrPolicyOverride(port, "pr-7", { by: "github:carol", at: "2026-10-22T12:00:00Z", reason: "ok", digest: "sha256:1" })).rejects.toThrow(/did not reach the remote/);
144
+ });
145
+ });