@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
@@ -64,6 +64,7 @@
64
64
  * through `--forge`.
65
65
  */
66
66
 
67
+ import { spawnSync } from "node:child_process";
67
68
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
68
69
  import { join, resolve } from "node:path";
69
70
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
@@ -85,10 +86,11 @@ import {
85
86
  } from "../../components/pr-run";
86
87
  import { composeChangeSet, verifyChangeSetDigest } from "../../change-set";
87
88
  import { waveGateName } from "../../gated-waves";
89
+ import { blockedMembers, blockedWords, settleEarlierWaves, type WaveMemberOutcome } from "../../wave-blocked";
88
90
  import { readFanOutAttempt, writeFanOutAttempt, type FanOutAttempt, type PrApplyRecord } from "../../components/fan-out-record";
89
91
  import { componentInputDigests, progressThatCarries } from "../../components/fan-out-inputs";
90
92
  import { samePlanDigest } from "../../lifecycle/plan-digest";
91
- import { gitGateLedgerPort, type GateCheck } from "../../op/gate";
93
+ import { gateIsSealed, gitGateLedgerPort, type GateCheck } from "../../op/gate";
92
94
  import { emitGateStep, gateEventsOf, GATE_PHASE, type RunEventSink, type RunEventStream } from "../../run-event-stream";
93
95
  import type { RunEventBody, RunEventComponentSettle, RunEventSettleStatus } from "../../run-event";
94
96
  import { openCliRunEvents, refusesEventsWithJson } from "../run-events";
@@ -105,9 +107,11 @@ import {
105
107
  renderPrNote,
106
108
  type PrApproval,
107
109
  type PrMember,
110
+ type PrPolicyReport,
108
111
  type PrReport,
109
112
  type PrWave,
110
113
  type PrRound,
114
+ type PrOverdue,
111
115
  } from "../../pr-loop";
112
116
  import { FORGE_KINDS, forgeFromEnv, forgePrincipalOf, type ForgeKind, type PrForge } from "../../pr-forge";
113
117
  import { GITHUB_COMMENT_LIMIT, GITLAB_NOTE_LIMIT } from "../../plan-summary";
@@ -124,12 +128,16 @@ import { withComponentEndpoint } from "../component-endpoint";
124
128
  import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
125
129
  import { failedRunExitCode, type DriverComponent } from "../../components/driver";
126
130
  import type { CapabilityRegistry } from "../../components/capability";
131
+ import { prLoopHooksAt, runPrHooks, type PrHookPlace, type PrLoopHooks } from "../../components/pr-hooks";
132
+ import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrLoopPolicy } from "../../components/pr-policy";
133
+ import type { OpWaveHookMoment } from "../../op/op-waves";
134
+ import type { PrHookOutcome } from "../../pr-loop";
127
135
 
128
136
 
129
137
  /** Where the reports land unless `--output` says. */
130
138
  export const PR_REPORT_DIR = ".chant/pr";
131
139
 
132
- interface Prepared {
140
+ export interface Prepared {
133
141
  config: ChantConfig;
134
142
  env: string;
135
143
  /** The workspace member, with `--member` (#3465). */
@@ -148,6 +156,23 @@ interface Prepared {
148
156
  signal: ComponentChangeSignal;
149
157
  registry: CapabilityRegistry;
150
158
  seededOutputs: Record<string, Record<string, unknown>>;
159
+ /** `ci.hooks` from the declaration at base (#4288). */
160
+ hooks: PrLoopHooks;
161
+ /** Every hook the stage ran so far, for the report's `hooks`. */
162
+ hookOutcomes: PrHookOutcome[];
163
+ /** Set when a hook failed: the report's message. */
164
+ hookFailed?: string;
165
+ /** Where the stage's hooks run, once its op is known. */
166
+ hookPlace?: PrHookPlace;
167
+ /**
168
+ * Set when `ci.branches` or `ci.labels` (#4292) leave the member out of this
169
+ * stage: why. The plan is then empty, so the stage reports `nothing`.
170
+ */
171
+ routedAway?: string;
172
+ /** `ci.policy` from the declaration at base (#4294). */
173
+ policy: PrLoopPolicy;
174
+ /** What the policy said about the plan, for the report's `policy`. */
175
+ policyReport?: PrPolicyReport;
151
176
  }
152
177
 
153
178
  const fail = (message: string, hint?: string): number => {
@@ -155,8 +180,8 @@ const fail = (message: string, hint?: string): number => {
155
180
  return 1;
156
181
  };
157
182
 
158
- /** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
159
- async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"): Promise<Prepared | number> {
183
+ /** Everything both stages share: the change and the derivation. Each stage plans the members itself. `pr-env up` (#4298) prepares as the plan does. */
184
+ export async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"): Promise<Prepared | number> {
160
185
  const { args } = ctx;
161
186
  const projectPath = resolve(".");
162
187
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
@@ -236,8 +261,27 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"):
236
261
  }
237
262
  }
238
263
 
264
+ // #4288: the stage hooks, read from the declaration at base so the change cannot edit what its own jobs run.
265
+ let hooks: PrLoopHooks;
266
+ try {
267
+ hooks = prLoopHooksAt(projectPath, base);
268
+ } catch (err) {
269
+ return fail(err instanceof Error ? err.message : String(err));
270
+ }
271
+ if (hooks.note) console.error(formatInfo(hooks.note));
272
+ // #4294: the policy, read from the declaration at base like the hooks.
273
+ let policy: PrLoopPolicy;
274
+ try {
275
+ policy = prLoopPolicyAt(projectPath, base);
276
+ } catch (err) {
277
+ return fail(err instanceof Error ? err.message : String(err));
278
+ }
279
+ if (policy.note) console.error(formatInfo(policy.note));
280
+
239
281
  const env = args.env ?? "local";
240
282
  const registry = await fanOutRegistry(projectPath, config);
283
+ const routedAway = member ? await routing(ctx, stage, projectPath, member, base, forge) : undefined;
284
+ if (routedAway) console.error(formatInfo(routedAway));
241
285
  return {
242
286
  config,
243
287
  env,
@@ -247,13 +291,89 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "record"):
247
291
  gate: args.gate ?? PR_APPLY_GATE,
248
292
  ...(forge ? { forge } : {}),
249
293
  components: derived.components,
250
- plan: derived.plan,
294
+ plan: routedAway ? { ...derived.plan, order: [], waves: [], seeds: [], indeterminate: [] } : derived.plan,
251
295
  signal: derived.signal,
252
296
  registry,
253
297
  seededOutputs,
298
+ hooks,
299
+ hookOutcomes: [],
300
+ ...(routedAway ? { routedAway } : {}),
301
+ policy,
254
302
  };
255
303
  }
256
304
 
305
+ /** Set where `p`'s hooks run, once the stage knows its op (#4288). */
306
+ export function placeHooks(ctx: CommandContext, p: Prepared, stage: PrHookPlace["stage"], op: string): void {
307
+ p.hookPlace = { op, env: p.env, stage, dir: ctx.args.output ?? PR_REPORT_DIR, cwd: resolve("."), log: (line) => console.error(line) };
308
+ }
309
+
310
+ /**
311
+ * Run one moment's hooks for `components` (#4288), keeping their outcomes
312
+ * for the report. Returns the failure as the report's message, or undefined
313
+ * when no hook failed. A hold is logged: the pull request's gate already
314
+ * waits for a person's approval of the digest.
315
+ */
316
+ export function runStageHook(p: Prepared, moment: OpWaveHookMoment, components: readonly string[], planned?: PrPlanSet, what = "nothing was applied"): string | undefined {
317
+ if (!p.hookPlace || components.length === 0) return undefined;
318
+ const ran = runPrHooks(p.hooks, moment, p.hookPlace, components, planned ? { members: planned.members, plans: planned.plans } : undefined);
319
+ p.hookOutcomes.push(...ran.outcomes);
320
+ for (const hold of ran.holds) console.error(formatInfo(`${hold} held, and the pull request's gate asks for an approval of the digest either way`));
321
+ if (!ran.failed) return undefined;
322
+ p.hookFailed = `The ${ran.failed}, so ${what}.`;
323
+ return p.hookFailed;
324
+ }
325
+
326
+ const EMPTY_SET = (): PrPlanSet => ({ doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: true });
327
+
328
+ /**
329
+ * Plan with the plan-side hooks around it (#4288): before-observe and
330
+ * before-plan, then the plan, then after-plan. A failed hook gives a failed
331
+ * set, so the stage reports `plan-failed` and nothing applies.
332
+ */
333
+ async function hookedPlan(p: Prepared, components: readonly string[], plan: () => Promise<PrPlanSet>): Promise<PrPlanSet> {
334
+ if (runStageHook(p, "before-observe", components, undefined, "nothing was planned or applied")) return EMPTY_SET();
335
+ if (runStageHook(p, "before-plan", components, undefined, "nothing was planned or applied")) return EMPTY_SET();
336
+ const set = await plan();
337
+ if (set.failed) return set;
338
+ if (runStageHook(p, "after-plan", components, set)) return { ...set, failed: true };
339
+ return set;
340
+ }
341
+
342
+ /**
343
+ * Whether `ci.branches` or `ci.labels` (#4292), read from the declaration at
344
+ * base, leave `member` out of this stage: the reason, or undefined when the
345
+ * member runs. The record and apply stages read `ci.branches` against the
346
+ * pushed branch (`--branch`, else the forge's variables); the plan stage
347
+ * reads `ci.labels` against the pull request's labels. A label only narrows
348
+ * what is planned: the record and apply stages never read labels.
349
+ */
350
+ async function routing(ctx: CommandContext, stage: "plan" | "apply" | "record", projectPath: string, member: string, base: string, forge: PrForge | undefined): Promise<string | undefined> {
351
+ const { scopeSource } = await import("../../workspace/write-scope");
352
+ const { appliesFrom, plansUnderLabels, pushedBranchFromEnv } = await import("../../workspace/ci-branches");
353
+ const ci = scopeSource(projectPath, base).declaration?.ci;
354
+ if (stage !== "plan") {
355
+ if (!ci?.branches) return undefined;
356
+ const pushed = ctx.args.branch ?? pushedBranchFromEnv();
357
+ if (!pushed) return undefined;
358
+ const route = appliesFrom(ci.branches, member, pushed);
359
+ if (!route.applies) return route.note;
360
+ console.error(formatInfo(route.note));
361
+ return undefined;
362
+ }
363
+ if (!ci?.labels || ctx.args.pr === undefined || !forge?.labels) return undefined;
364
+ let carried: string[];
365
+ try {
366
+ carried = await forge.labels(ctx.args.pr);
367
+ } catch (err) {
368
+ console.error(formatWarning({ message: `could not read the labels of #${ctx.args.pr}, so every member plans: ${err instanceof Error ? err.message : String(err)}` }));
369
+ return undefined;
370
+ }
371
+ const filter = plansUnderLabels(ci.labels, member, carried);
372
+ if (!filter.plans) return filter.note;
373
+ if (filter.note) console.error(formatInfo(filter.note));
374
+ return undefined;
375
+ }
376
+
257
377
  /**
258
378
  * The one `--member` name, checked against the workspace member that owns
259
379
  * `projectPath`: a pipeline copied into another member would otherwise
@@ -280,8 +400,12 @@ async function checkMember(projectPath: string, names: string[]): Promise<string
280
400
  return name;
281
401
  }
282
402
 
283
- /** Plan every member the derivation selects. */
284
- function planAll(p: Prepared, onRunEvent?: RunEventSink): Promise<PrPlanSet> {
403
+ /** Plan every member the derivation selects, between the plan-side hooks (#4288). */
404
+ export function planAll(p: Prepared, onRunEvent?: RunEventSink): Promise<PrPlanSet> {
405
+ return hookedPlan(p, p.plan.order, () => planBare(p, onRunEvent));
406
+ }
407
+
408
+ export function planBare(p: Prepared, onRunEvent?: RunEventSink): Promise<PrPlanSet> {
285
409
  return planPrSet({
286
410
  components: p.components,
287
411
  plan: p.plan,
@@ -343,7 +467,7 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
343
467
  if (report.pr === null) return;
344
468
  // A member the change does not reach has nothing to say: one note per
345
469
  // member would put a line on every pull request for each member it skips.
346
- if (report.member && report.status === "nothing") return;
470
+ if (report.member && report.status === "nothing" && !report.overdue?.length) return;
347
471
  try {
348
472
  await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
349
473
  } catch (err) {
@@ -366,6 +490,11 @@ function printMembers(report: PrReport): void {
366
490
  }
367
491
 
368
492
  async function finish(ctx: CommandContext, p: Prepared, report: PrReport, upload?: PrReportUpload): Promise<void> {
493
+ // #4288: the hooks the stage ran, and a failed hook's message in place of the stage's own.
494
+ if (p.hookOutcomes.length > 0) report = { ...report, hooks: [...p.hookOutcomes] };
495
+ if (p.hookFailed) report = { ...report, message: p.hookFailed };
496
+ if (p.routedAway && report.status === "nothing" && !report.message) report = { ...report, message: p.routedAway };
497
+ if (p.policyReport) report = { ...report, policy: p.policyReport };
369
498
  const note = noteFor(report, p.forge);
370
499
  writeOutputs(ctx, report, note, upload);
371
500
  if (!ctx.args.noStatus) await publish(p.forge, report, note);
@@ -420,6 +549,86 @@ function openUpload(events: StageEvents, stage: "plan" | "apply" | "record", pr:
420
549
  return events.upload;
421
550
  }
422
551
 
552
+ /** What the policy step decided (#4294): a stop, or whether the plan is held and the override it applies under. */
553
+ interface PolicyJudged {
554
+ stop?: { refusal: "policy-denied" | "policy-error"; message: string; code: number };
555
+ held: boolean;
556
+ override?: NonNullable<PrPolicyReport["override"]>;
557
+ }
558
+
559
+ /**
560
+ * Run `ci.policy` at base over the planned set (#4294) and keep what it said
561
+ * on `p` for the report. A denial is decided against the override ledger:
562
+ * read only at the plan stage, and recorded for an override at the apply.
563
+ */
564
+ async function judgePolicy(p: Prepared, at: { stage: "plan" | "apply"; op: string; pr: number | null; set: PrPlanSet }): Promise<PolicyJudged> {
565
+ const verdict = runPrPolicy(p.policy, { version: 1, stage: at.stage, op: at.op, gate: p.gate, env: p.env, pr: at.pr, base: p.base, head: p.head, changeSet: at.set.doc });
566
+ if (!verdict) return { held: false };
567
+ const report = prPolicyReport(verdict, p.policy.base);
568
+ p.policyReport = report;
569
+ for (const w of verdict.warnings) console.error(formatWarning({ message: `policy warning: ${w}` }));
570
+ if (verdict.result === "error") {
571
+ return { held: false, stop: { refusal: "policy-error", code: 1, message: `The policy at ${p.policy.base.slice(0, 12)} could not be checked, so nothing was applied: ${verdict.error}` } };
572
+ }
573
+ const held = verdict.holds.length > 0;
574
+ for (const h of verdict.holds) console.error(formatInfo(`policy holds the plan for a person's approval (${h.rule}): ${h.message}`));
575
+ if (verdict.result !== "denied") return { held };
576
+ const denied = verdict.denials.map((d) => `${d.rule}: ${d.message}`).join("; ");
577
+ let decided: Awaited<ReturnType<typeof decidePrPolicyOverride>>;
578
+ try {
579
+ // The plan stage records nothing, and reads the applied records so a spent override reads as spent.
580
+ const real = gitGateLedgerPort();
581
+ const port = at.stage === "plan" ? { ...readOnlyLedger(real), ...(real.readApplied ? { readApplied: real.readApplied.bind(real) } : {}) } : real;
582
+ decided = await decidePrPolicyOverride({
583
+ port,
584
+ subject: at.op,
585
+ digest: at.set.doc.digest,
586
+ rules: verdict.rules,
587
+ base: p.base,
588
+ cwd: resolve("."),
589
+ sealed: await gateIsSealed(p.gate),
590
+ record: at.stage === "apply",
591
+ description: `${at.op}: plan ${at.set.doc.digest} denied by ${verdict.rules.join(", ")}`,
592
+ });
593
+ } catch (err) {
594
+ const why = err instanceof Error ? err.message : String(err);
595
+ if (at.stage === "plan") {
596
+ console.error(formatWarning({ message: `could not read the override ledger, so no override reads as standing: ${why}` }));
597
+ return { held, stop: { refusal: "policy-denied", code: 1, message: `The policy denied this plan: ${denied}.` } };
598
+ }
599
+ return { held, stop: { refusal: "policy-denied", code: GATED_EXIT_CODE, message: `The policy denied this plan (${denied}), and the override ledger could not be read or written: ${why}. Nothing was applied.` } };
600
+ }
601
+ if (decided.status === "overridden") {
602
+ report.override = decided.override;
603
+ console.error(formatInfo(`the policy denial (${verdict.rules.join(", ")}) is overridden by ${decided.override.by} at ${decided.override.at}: ${decided.override.reason}`));
604
+ return { held, override: decided.override };
605
+ }
606
+ report.overrideCommand = decided.command;
607
+ for (const why of decided.refusals) console.error(formatInfo(`an override does not count: ${why}`));
608
+ const who = decided.overriders.length ? `${decided.overriders.join(", ")} may` : "nobody listed in .chant/overriders at base may";
609
+ const moved = decided.moved ? ` ${decided.moved.by} overrode an earlier plan or other rules, which counts for nothing here.` : "";
610
+ return {
611
+ held,
612
+ stop: {
613
+ refusal: "policy-denied",
614
+ code: at.stage === "plan" ? 1 : GATED_EXIT_CODE,
615
+ message: `The policy denied this plan: ${denied}.${moved} Nothing was applied. ${who} override it: ${decided.command}`,
616
+ },
617
+ };
618
+ }
619
+
620
+ /** Spend the override the apply runs under (#4294), before anything applies. The failure's message, or undefined. */
621
+ async function spendPolicy(op: string, judged: PolicyJudged, head: string): Promise<string | undefined> {
622
+ if (!judged.override) return undefined;
623
+ try {
624
+ await spendPrPolicyOverride(gitGateLedgerPort(), op, judged.override, { commit: head });
625
+ } catch (err) {
626
+ return `Could not record the override this apply runs under, so nothing was applied: ${err instanceof Error ? err.message : String(err)}`;
627
+ }
628
+ console.error(formatInfo(`applies under the override by ${judged.override.by}, which is now spent`));
629
+ return undefined;
630
+ }
631
+
423
632
  /** chant components pr-plan --base <ref> --pr <n> [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
424
633
  export function runComponentsPrPlan(ctx: CommandContext): Promise<number> {
425
634
  if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
@@ -433,12 +642,15 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
433
642
  const p = await prepare(ctx, "plan");
434
643
  if (typeof p === "number") return p;
435
644
  const op = prOp(ctx.args.pr, p.member);
645
+ placeHooks(ctx, p, "plan", op);
436
646
  const upload = openUpload(stage, "plan", ctx.args.pr, p);
437
647
  // `--events-json` (#4041): planning, then where the approval stands.
438
648
  const events = await openCliRunEvents(ctx.args, { kind: "component", name: `pr-plan ${op}`, cwd: resolve(".") }, upload?.line);
439
649
  stage.stream = events;
440
650
  events?.emit({ type: "run-start", waves: p.plan.waves });
441
651
  const set = await planAll(p, events?.emit);
652
+ // #4294: the policy at base over the planned change set.
653
+ const judged = p.plan.order.length > 0 && !set.failed ? await judgePolicy(p, { stage: "plan", op, pr: ctx.args.pr, set }) : { held: false };
442
654
 
443
655
  let approval: PrApproval = { status: "pending" };
444
656
  if (p.plan.order.length > 0) {
@@ -452,7 +664,8 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
452
664
  }
453
665
  }
454
666
  const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
455
- const report = prReport({
667
+ const overdue = await overdueOps(p.forge, p.member);
668
+ const planned = prReport({
456
669
  stage: "plan",
457
670
  pr: ctx.args.pr,
458
671
  env: p.env,
@@ -468,6 +681,7 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
468
681
  status,
469
682
  ...(ctx.args.waveGate ? { waveGate: ctx.args.waveGate } : {}),
470
683
  });
684
+ const report: PrReport = overdue.length > 0 ? { ...planned, overdue } : planned;
471
685
  await finish(ctx, p, report, upload);
472
686
  // The plan stage settles on its planning: an approval still to come is what it reports, not a stop.
473
687
  events?.settle(settleOf(status), status === "plan-failed" ? "a member failed to plan" : undefined);
@@ -478,7 +692,39 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
478
692
  console.error(formatInfo(`plan : ${report.digest}`));
479
693
  console.error(formatInfo(approval.status === "approved" ? `approved by ${(approval.approvedBy ?? []).join(", ")}` : `approve : ${prApproveCommand(report)}`));
480
694
  }
481
- return status === "plan-failed" ? 1 : 0;
695
+ if (judged.stop) console.error(formatWarning({ message: judged.stop.message }));
696
+ return status === "plan-failed" || judged.stop ? 1 : 0;
697
+ }
698
+
699
+ /**
700
+ * The scheduled watch and reconcile Ops that stopped running (#4289,
701
+ * `op/schedule-overdue.ts`), asked of the forge. Without a forge, or when
702
+ * the Ops cannot be read, nothing is checked and the plan goes on. The
703
+ * generated plan holds no forge token (#4295), so there the plan-note job's
704
+ * `pr-post` asks instead, reading the Ops at the target branch's commit.
705
+ */
706
+ async function overdueOps(forge: PrForge | undefined, member: string | undefined): Promise<PrOverdue[]> {
707
+ if (!forge?.scheduledRuns) return [];
708
+ try {
709
+ const { discoverOps } = await import("../../op/discover");
710
+ const { scheduledWatches, checkOverdueOps } = await import("../../op/schedule-overdue");
711
+ const discovered = await discoverOps({ cwd: resolve(".") });
712
+ const watches = scheduledWatches([...discovered.ops.values()].map((d) => d.config));
713
+ if (watches.length === 0) return [];
714
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" });
715
+ if (top.status !== 0) return [];
716
+ return await checkOverdueOps({
717
+ watches,
718
+ forge,
719
+ repoRoot: top.stdout.trim(),
720
+ ...(member ? { member } : {}),
721
+ now: new Date(),
722
+ log: (line) => console.error(formatWarning({ message: line })),
723
+ });
724
+ } catch (err) {
725
+ console.error(formatWarning({ message: `could not check the scheduled Ops, so none is reported overdue: ${err instanceof Error ? err.message : String(err)}` }));
726
+ return [];
727
+ }
482
728
  }
483
729
 
484
730
  /** chant components pr-apply --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--require-review] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
@@ -493,6 +739,9 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
493
739
  const reviewRules = !ctx.args.requireReview && securityGroupsAt(process.cwd(), ctx.args.base).has("review-rules");
494
740
  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>");
495
741
  if (reviewRules) ctx = { ...ctx, args: { ...ctx.args, requireReview: true } };
742
+ // #4293: ci.locks, read at base like the security groups, so the change cannot turn its own locks off.
743
+ const { ciLocksAt } = await import("../../workspace/ci-locks");
744
+ const locks = ciLocksAt(process.cwd(), ctx.args.base);
496
745
  const { args } = ctx;
497
746
  if (args.requireReview && !args.forge) return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
498
747
  if (args.wave !== undefined && !args.waveGate) return fail("--wave runs one wave of a --wave-gate apply, so it needs --wave-gate <name>");
@@ -517,6 +766,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
517
766
  }
518
767
  }
519
768
  const op = green ? greenOp(green.commit, p.member) : pr === null ? "pr-unknown" : prOp(pr, p.member);
769
+ placeHooks(ctx, p, "apply", op);
520
770
  // The run id the release records carry, chosen before the run so that
521
771
  // `--events-json` (#4041, #4007) names the run by it too.
522
772
  const release = autoReleaseRun(`${op}-${Date.now()}`);
@@ -524,6 +774,17 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
524
774
  const events = await openCliRunEvents(args, { kind: "component", name: `pr-apply ${op}`, runId: release.runId, cwd: resolve(".") }, upload?.line);
525
775
  stage.stream = events;
526
776
  events?.emit({ type: "run-start", waves: p.plan.waves });
777
+ if (locks && p.plan.order.length > 0) {
778
+ const held = await heldLocksOf(p, pr);
779
+ if (typeof held === "number") return held;
780
+ if (held) {
781
+ const set: PrPlanSet = { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
782
+ return stopper(ctx, p, events, upload)(
783
+ prReport({ stage: "apply", pr, env: p.env, ...(p.member ? { member: p.member } : {}), base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, set, approval: { status: "pending" }, status: "refused", refusal: "locked", message: held }),
784
+ GATED_EXIT_CODE,
785
+ );
786
+ }
787
+ }
527
788
  if (args.waveGate) return prApplyWaves(ctx, p, { pr, op, release, upload, events, ...(green ? { green } : {}) });
528
789
 
529
790
  // `--resume` (#3464): an earlier attempt at this change on this commit
@@ -593,10 +854,14 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
593
854
 
594
855
  let approval: PrApproval;
595
856
  let via: "merges" | "gate" = "gate";
857
+ // #4294: the policy at base, before the gate. A resume runs under the decision of the attempt it finishes.
858
+ let judged: PolicyJudged = { held: false };
596
859
  if (resumed) {
597
860
  approval = resumed.approval;
598
861
  } else {
599
- const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
862
+ judged = await judgePolicy(p, { stage: "apply", op, pr, set });
863
+ if (judged.stop) return stop(prReport({ ...base, approval: { status: "pending" }, status: "refused", refusal: judged.stop.refusal, message: judged.stop.message }), judged.stop.code);
864
+ const decided = await decideApplyGate(p, { op, pr, set, events, held: judged.held, ...(green ? { green } : {}) });
600
865
  approval = decided.approval;
601
866
  if (!decided.satisfied) return stop(prReport({ ...base, approval, status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
602
867
  via = decided.via;
@@ -609,6 +874,8 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
609
874
  emitReviewStop(events, review.message);
610
875
  return stop(prReport({ ...base, approval, status: "refused", refusal: review.refusal, message: review.message }), GATED_EXIT_CODE);
611
876
  }
877
+ const unspent = await spendPolicy(op, judged, p.head);
878
+ if (unspent) return stop(prReport({ ...base, approval, status: "refused", refusal: "policy-denied", message: unspent }), 1);
612
879
 
613
880
  // The record is written before the first component runs and again as each
614
881
  // one settles, so a run killed partway still knows what applied.
@@ -642,6 +909,11 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
642
909
  };
643
910
  recordAttempt();
644
911
 
912
+ // #4288: the before-apply hooks, once the gate and the review let the apply through.
913
+ const toApply = (resumed?.plan ?? p.plan).order.filter((name) => !completedSoFar.has(name));
914
+ const beforeFailed = runStageHook(p, "before-apply", toApply);
915
+ if (beforeFailed) return stop(prReport({ ...base, approval, status: "failed", message: beforeFailed }), 1);
916
+
645
917
  // #3999: a round planned after an upstream applied waits on a gate of its
646
918
  // own, under the same identity.gates rule and the same review checks.
647
919
  const roundLedger = gitGateLedgerPort();
@@ -697,11 +969,13 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
697
969
  : {}),
698
970
  });
699
971
  recordAttempt();
972
+ // #4288: the after-apply hooks, for the components that applied.
973
+ const afterFailed = runStageHook(p, "after-apply", toApply.filter((name) => applied.run.completed.includes(name)), undefined, "the apply counts as failed though its components applied");
700
974
  await recordAutoReleasesForRun(applied.run.results, p.env, release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
701
975
  const report = prReport({
702
976
  ...base,
703
977
  approval,
704
- status: applied.status,
978
+ status: afterFailed ? "failed" : applied.status,
705
979
  members: applied.members,
706
980
  ...(resumed ? { resumed: resumed.completed } : {}),
707
981
  ...(applied.rounds ? { rounds: applied.rounds } : {}),
@@ -710,13 +984,36 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
710
984
  // A component that stopped at a gate of its own leaves the apply waiting on
711
985
  // a person. A step that refused its plan because the target moved since
712
986
  // the approval exits 3, as a moved digest does before anything applies (#3997).
713
- const exit = applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
987
+ const exit = afterFailed ? 1 : applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
714
988
  if (green && applied.status === "applied") await recordGreenApplied(p, green, set.doc.digest, approval, via);
715
989
  const code = await stop(report, exit, applied.run.status === "gated" ? "gated" : undefined);
716
- if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
990
+ if (applied.status === "applied" && !afterFailed) console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
717
991
  return code;
718
992
  }
719
993
 
994
+ /**
995
+ * Under `ci.locks` (#4293): the refusal when another open pull request holds
996
+ * a lock on a component this apply reaches, else null. The targets are the
997
+ * selected components, the names `chant lock` takes. A lock whose pull
998
+ * request the forge says merged or closed reads as released; with no
999
+ * `--forge` every lock counts. A lock file that cannot be read stops the
1000
+ * apply, since nothing then says the targets are free.
1001
+ */
1002
+ async function heldLocksOf(p: Prepared, pr: number | null): Promise<string | null | number> {
1003
+ const { heldTargetLocks, describeHeldTargetLocks } = await import("../../lifecycle/target-lock");
1004
+ const isOpen = p.forge?.pullRequestOpen?.bind(p.forge);
1005
+ let held;
1006
+ try {
1007
+ held = await heldTargetLocks(p.gate, p.plan.order, pr, { ...(isOpen ? { isOpen } : {}) });
1008
+ } catch (err) {
1009
+ return fail(`could not read the target locks of ${p.gate}, so nothing was applied: ${err instanceof Error ? err.message : String(err)}`);
1010
+ }
1011
+ if (held.length === 0) return null;
1012
+ const name = (n: number) => (p.forge?.kind === "gitlab" ? `merge request !${n}` : `pull request ${n}`);
1013
+ const holders = [...new Set(held.map((h) => h.pr))].sort((a, b) => a - b);
1014
+ return `${describeHeldTargetLocks(held, name)}, so nothing was applied. Apply again once ${holders.length === 1 ? "it merges or closes" : "they merge or close"}, or after \`chant unlock --pr ${holders[0]}${p.gate === PR_APPLY_GATE ? "" : ` --gate ${p.gate}`}\`${isOpen ? "" : ". Without --forge every lock counts as held"}.`;
1015
+ }
1016
+
720
1017
  /** #4274: a component that published nothing is recorded by the plan digest its approval bound. */
721
1018
  function prAppliedDigests(p: Prepared, members: PrMember[]) {
722
1019
  return appliedDigestOf({ components: p.components, env: p.env, projectRoot: resolve("."), members });
@@ -752,6 +1049,7 @@ async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number
752
1049
  }
753
1050
  }
754
1051
  const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
1052
+ placeHooks(ctx, p, "record", op);
755
1053
  const upload = openUpload(stage, "record", pr, p);
756
1054
  const events = await openCliRunEvents(args, { kind: "component", name: `pr-record ${op}`, cwd: resolve(".") }, upload?.line);
757
1055
  stage.stream = events;
@@ -808,31 +1106,34 @@ async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number
808
1106
 
809
1107
  const { mergeSourceOf, writeMergeRecord, MERGE_RECORD_SCHEMA_ID, MERGE_RECORD_CONTRACT } = await import("../../lifecycle/merge-record");
810
1108
  const { pushLifecycleReport } = await import("../../lifecycle/git");
811
- try {
812
- const written = await writeMergeRecord({
813
- $schema: MERGE_RECORD_SCHEMA_ID,
814
- contract: MERGE_RECORD_CONTRACT,
815
- commit: p.head,
816
- base: p.base,
817
- env: p.env,
818
- ...(p.member ? { member: p.member } : {}),
819
- source: mergeSourceOf(resolve("."), p.head, pr),
820
- op: pr === null ? null : op,
821
- gate: p.gate,
822
- digest: nothing ? null : set.doc.digest,
823
- plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
824
- ...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
825
- approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
826
- ...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
827
- components: p.plan.order,
828
- recordedAt: new Date().toISOString(),
829
- });
830
- if (written.written) {
831
- const pushed = await pushLifecycleReport();
832
- if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
1109
+ // #4292: a member another branch applies has no merge on this one, so no record says it planned nothing here.
1110
+ if (!p.routedAway) {
1111
+ try {
1112
+ const written = await writeMergeRecord({
1113
+ $schema: MERGE_RECORD_SCHEMA_ID,
1114
+ contract: MERGE_RECORD_CONTRACT,
1115
+ commit: p.head,
1116
+ base: p.base,
1117
+ env: p.env,
1118
+ ...(p.member ? { member: p.member } : {}),
1119
+ source: mergeSourceOf(resolve("."), p.head, pr),
1120
+ op: pr === null ? null : op,
1121
+ gate: p.gate,
1122
+ digest: nothing ? null : set.doc.digest,
1123
+ plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
1124
+ ...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
1125
+ approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
1126
+ ...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
1127
+ components: p.plan.order,
1128
+ recordedAt: new Date().toISOString(),
1129
+ });
1130
+ if (written.written) {
1131
+ const pushed = await pushLifecycleReport();
1132
+ if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
1133
+ }
1134
+ } catch (err) {
1135
+ console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
833
1136
  }
834
- } catch (err) {
835
- console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
836
1137
  }
837
1138
 
838
1139
  const stop = stopper(ctx, p, events, upload);
@@ -874,11 +1175,15 @@ async function greenTargetOf(ctx: CommandContext): Promise<GreenTarget | number>
874
1175
  */
875
1176
  async function decideApplyGate(
876
1177
  p: Prepared,
877
- at: { op: string; pr: number | null; set: PrPlanSet; events?: RunEventStream; green?: GreenTarget },
1178
+ at: { op: string; pr: number | null; set: PrPlanSet; events?: RunEventStream; green?: GreenTarget; held?: boolean },
878
1179
  ): Promise<{ approval: PrApproval; satisfied: true; via: "merges" | "gate" } | { approval: PrApproval; satisfied: false; refusal: "plan-changed" | "not-approved"; message: string }> {
879
1180
  const { op, pr, set, events, green } = at;
880
1181
  let uncovered: string[] = [];
881
- if (green) {
1182
+ // #4294: a plan the policy holds needs an approval of its own digest, so the merges' approvals do not cover it.
1183
+ if (green && at.held) {
1184
+ console.error(formatInfo(`the policy holds the plan at ${green.commit.slice(0, 12)}, so it needs an approval of its own digest and does not apply under the merges' approvals`));
1185
+ uncovered = ["the policy holds this plan for an approval of its own digest"];
1186
+ } else if (green) {
882
1187
  const cover = coverByMerges(set.doc, green.approved);
883
1188
  if (cover.covered) {
884
1189
  console.error(formatInfo(`the approved plans of ${cover.coveredBy.length} merge${cover.coveredBy.length === 1 ? "" : "s"} cover the plan at ${green.commit.slice(0, 12)}, so it applies under them`));
@@ -1277,8 +1582,23 @@ async function prApplyWaves(
1277
1582
  const completed = new Set(carry?.completed ?? []);
1278
1583
  const outputsSoFar: Record<string, Record<string, unknown>> = { ...(carry?.outputs ?? {}) };
1279
1584
  const inputsSoFar: Record<string, string> = Object.fromEntries([...completed].filter((n) => inputsNow[n] !== undefined).map((n) => [n, inputsNow[n]!]));
1280
- const failedSoFar = new Set<string>();
1585
+ // #4291: what failed in an earlier wave's job stays failed until it applies, so this job blocks its dependents and runs the rest.
1586
+ const failedSoFar = new Set<string>((carry ? attempt?.failed ?? [] : []).filter((name) => !completed.has(name)));
1281
1587
  let waves: PrWave[] = carry?.record.waves ?? [];
1588
+ const upstreams = new Map(p.components.map((c) => [c.name, c.dependsOn ?? []]));
1589
+ /** Each component's outcome so far, past applied (#4291): failed, waiting, not-applied, or blocked by a failure. */
1590
+ const outcomes = (): Map<string, WaveMemberOutcome> => {
1591
+ const out = new Map<string, WaveMemberOutcome>();
1592
+ for (const name of completed) out.set(name, { status: "applied" });
1593
+ for (const name of failedSoFar) out.set(name, { status: "failed" });
1594
+ for (const w of waves) {
1595
+ if (w.status !== "waiting" && w.status !== "plan-failed" && w.status !== "failed") continue;
1596
+ for (const name of w.components) if (!out.has(name)) out.set(name, { status: w.status === "waiting" ? "waiting" : "not-applied" });
1597
+ }
1598
+ return out;
1599
+ };
1600
+ /** Components a failure blocked (#4291), each with the component that failed. */
1601
+ const blockedSoFar = new Map<string, string>();
1282
1602
  const after = new Map<string, PrMember>();
1283
1603
  const leftIn = (k: number): string[] => (p.plan.waves[k - 1] ?? []).filter((name) => !completed.has(name));
1284
1604
  const firstLeft = (): number => p.plan.waves.findIndex((_, i) => leftIn(i + 1).length > 0) + 1;
@@ -1293,15 +1613,32 @@ async function prApplyWaves(
1293
1613
  const set = approved ?? { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
1294
1614
  const members = set.members.map((m): PrMember => {
1295
1615
  const now = after.get(m.member);
1296
- if (now) return { ...m, status: now.status, ...(now.error ? { error: now.error } : {}) };
1297
- return completed.has(m.component) ? { ...m, status: "applied" } : m;
1616
+ if (now) return { ...m, status: now.status, ...(now.error ? { error: now.error } : {}), ...(now.blockedBy ? { blockedBy: now.blockedBy } : {}) };
1617
+ if (completed.has(m.component)) return { ...m, status: "applied" };
1618
+ const by = blockedSoFar.get(m.component);
1619
+ if (by !== undefined) return { ...m, status: "blocked", blockedBy: by };
1620
+ return failedSoFar.has(m.component) ? { ...m, status: "failed" } : m;
1298
1621
  });
1299
1622
  return prReport({ ...reportBase, set, approval, members, waves, ...input });
1300
1623
  };
1301
1624
 
1302
1625
  // `--wave <k>` reached before an earlier wave applied: nothing runs, as `fan-out --wave` refuses.
1626
+ // #4291: an earlier member that failed, or was blocked by one that failed, counts as settled; it blocks its dependents here.
1303
1627
  const left = firstLeft();
1304
- if (only !== undefined && left > 0 && left < only) {
1628
+ const earlierWaiting = only !== undefined ? waves.find((w) => w.wave < only && w.status === "waiting" && w.components.some((name) => !completed.has(name))) : undefined;
1629
+ if (earlierWaiting) {
1630
+ return stop(
1631
+ report({
1632
+ status: "refused",
1633
+ refusal: "earlier-wave",
1634
+ message: `Wave ${only} does not run yet: wave ${earlierWaiting.wave} waits at its gate ${earlierWaiting.gate}. Nothing was applied. Approve that wave and run its job again, then this one.`,
1635
+ }),
1636
+ GATED_EXIT_CODE,
1637
+ );
1638
+ }
1639
+ const settled = only !== undefined && left > 0 && left < only ? settleEarlierWaves(p.plan.waves, only, upstreams, outcomes()) : undefined;
1640
+ if (settled) for (const [name, o] of settled.outcomes) if (o.status === "blocked" && o.by) blockedSoFar.set(name, o.by);
1641
+ if (only !== undefined && left > 0 && left < only && (settled?.unsettled.length ?? 0) > 0) {
1305
1642
  return stop(
1306
1643
  report({
1307
1644
  status: "refused",
@@ -1315,6 +1652,7 @@ async function prApplyWaves(
1315
1652
  }
1316
1653
 
1317
1654
  let planned: PrPlanSet | undefined;
1655
+ let judged: PolicyJudged = { held: false };
1318
1656
  if (carry) {
1319
1657
  // What the earlier waves applied settles first (#4007), each in its own wave, as a resumed apply's does.
1320
1658
  p.plan.waves.forEach((names, i) => {
@@ -1332,7 +1670,10 @@ async function prApplyWaves(
1332
1670
  return stop(report({ status: "plan-failed", message: "A member failed to plan, so there is no plan to compare with the approved one. Nothing was applied." }), 1);
1333
1671
  }
1334
1672
  await storeChangeSetForGate(ctx, p, set.doc);
1335
- const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
1673
+ // #4294: the policy at base judges the whole change set, before the pull request's gate.
1674
+ judged = await judgePolicy(p, { stage: "apply", op, pr, set });
1675
+ if (judged.stop) return stop(report({ status: "refused", refusal: judged.stop.refusal, message: judged.stop.message }), judged.stop.code);
1676
+ const decided = await decideApplyGate(p, { op, pr, set, events, held: judged.held, ...(green ? { green } : {}) });
1336
1677
  approval = decided.approval;
1337
1678
  if (!decided.satisfied) return stop(report({ status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
1338
1679
  via = decided.via;
@@ -1347,6 +1688,8 @@ async function prApplyWaves(
1347
1688
  emitReviewStop(events, prReview.message);
1348
1689
  return stop(report({ status: "refused", refusal: prReview.refusal, message: prReview.message }), GATED_EXIT_CODE);
1349
1690
  }
1691
+ const unspent = await spendPolicy(op, judged, p.head);
1692
+ if (unspent) return stop(report({ status: "refused", refusal: "policy-denied", message: unspent }), 1);
1350
1693
 
1351
1694
  const recordAttempt = (): void => {
1352
1695
  if (!resumePath) return;
@@ -1377,9 +1720,27 @@ async function prApplyWaves(
1377
1720
 
1378
1721
  const from = only ?? (left || total + 1);
1379
1722
  const to = only ?? total;
1723
+ /** The last apply in this job that failed (#4291): its results give the exit code once every wave ran. */
1724
+ let lastFailure: { results: Parameters<typeof failedRunExitCode>[0]; gated: boolean } | undefined;
1725
+ let planFailedIn: number | undefined;
1380
1726
  for (let k = from; k <= to; k++) {
1381
- const wavePlan = prWavePlan(p.plan, p.components, k, [...completed]);
1382
- if (wavePlan.order.length === 0) continue;
1727
+ // #4291: a component of this wave whose upstream failed, or was blocked by a failure, is blocked; the rest of the wave runs.
1728
+ const before = new Set(p.plan.waves.slice(0, k - 1).flat());
1729
+ const mine = leftIn(k);
1730
+ for (const name of mine) {
1731
+ failedSoFar.delete(name);
1732
+ blockedSoFar.delete(name);
1733
+ }
1734
+ const outcomesNow = outcomes();
1735
+ for (const [name, by] of blockedSoFar) outcomesNow.set(name, { status: "blocked", by });
1736
+ const blockedNow = blockedMembers(mine, upstreams, before, outcomesNow);
1737
+ for (const [name, by] of blockedNow) blockedSoFar.set(name, by);
1738
+ if (blockedNow.size > 0) console.error(formatWarning({ message: `wave ${k}: ${blockedWords(blockedNow)}, so not planned or applied`, hint: "A failure blocks only the members that depend on it; the rest of the wave goes on." }));
1739
+ const wavePlan = prWavePlan(p.plan, p.components, k, [...completed, ...blockedNow.keys()]);
1740
+ if (wavePlan.order.length === 0) {
1741
+ if (blockedNow.size > 0) recordAttempt();
1742
+ continue;
1743
+ }
1383
1744
  const inThisWave = new Set(wavePlan.order);
1384
1745
 
1385
1746
  // Wave 1 of the apply that just planned every member applies those plans:
@@ -1396,18 +1757,26 @@ async function prApplyWaves(
1396
1757
  entry = { wave: k, components: wavePlan.order, status: "applied", approval: "pull-request", gate: p.gate, digest: approvedSet.doc.digest, ...(approval.approvedBy ? { approvedBy: approval.approvedBy } : {}) };
1397
1758
  } else {
1398
1759
  const notApplied = Object.fromEntries(Object.entries(approvedSet.outputs).filter(([name]) => !completed.has(name)));
1399
- const fresh = await planPrSet({
1400
- components: p.components,
1401
- plan: wavePlan,
1402
- registry: p.registry,
1403
- env: p.env,
1404
- seededOutputs: { ...p.seededOutputs, ...notApplied, ...outputsSoFar },
1405
- ...(events ? { onRunEvent: (body: RunEventBody) => events.emit(body) } : {}),
1406
- });
1760
+ const fresh = await hookedPlan(p, wavePlan.order, () =>
1761
+ planPrSet({
1762
+ components: p.components,
1763
+ plan: wavePlan,
1764
+ registry: p.registry,
1765
+ env: p.env,
1766
+ seededOutputs: { ...p.seededOutputs, ...notApplied, ...outputsSoFar },
1767
+ ...(events ? { onRunEvent: (body: RunEventBody) => events.emit(body) } : {}),
1768
+ }),
1769
+ );
1407
1770
  if (fresh.failed) {
1408
- for (const m of fresh.members) after.set(m.member, m);
1771
+ for (const m of fresh.members) {
1772
+ after.set(m.member, m);
1773
+ if (m.status === "plan-failed") failedSoFar.add(m.component);
1774
+ }
1409
1775
  setWave({ wave: k, components: wavePlan.order, status: "plan-failed", approval: "pull-request", gate: p.gate, digest: approvedSet.doc.digest });
1410
- return stop(report({ status: "plan-failed", message: `A member of wave ${k} failed to plan, so nothing in wave ${k} was applied.` }), 1);
1776
+ // #4291: the later waves still run what does not depend on this wave.
1777
+ console.error(formatWarning({ message: `A member of wave ${k} failed to plan, so nothing in wave ${k} was applied.`, hint: "The waves after it apply what does not depend on wave " + k + "." }));
1778
+ planFailedIn ??= k;
1779
+ continue;
1411
1780
  }
1412
1781
  const moved = waveNotCovered(approvedSet.doc, fresh.doc, approvedSet.members);
1413
1782
  set = fresh;
@@ -1462,6 +1831,12 @@ async function prApplyWaves(
1462
1831
  }
1463
1832
  }
1464
1833
 
1834
+ // #4288: the wave's apply-side hooks, around its apply.
1835
+ const waveBefore = runStageHook(p, "before-apply", wavePlan.order, undefined, `nothing in wave ${k} was applied`);
1836
+ if (waveBefore) {
1837
+ setWave({ ...entry, status: "failed" });
1838
+ return stop(report({ status: "failed", message: waveBefore }), 1);
1839
+ }
1465
1840
  const applied = await applyPrSet({
1466
1841
  components: p.components,
1467
1842
  plan: wavePlan,
@@ -1477,6 +1852,7 @@ async function prApplyWaves(
1477
1852
  onComponentSettled: (settled, outputs) => {
1478
1853
  if (settled.status === "ok") {
1479
1854
  completed.add(settled.component);
1855
+ failedSoFar.delete(settled.component);
1480
1856
  if (inputsNow[settled.component] !== undefined) inputsSoFar[settled.component] = inputsNow[settled.component]!;
1481
1857
  if (outputs) outputsSoFar[settled.component] = outputs;
1482
1858
  } else if (settled.status === "fail") {
@@ -1491,14 +1867,33 @@ async function prApplyWaves(
1491
1867
  const outputs = applied.run.componentOutputs[name];
1492
1868
  if (outputs) outputsSoFar[name] = outputs;
1493
1869
  }
1494
- setWave({ ...entry, status: applied.status === "applied" ? "applied" : "failed" });
1870
+ const waveAfter = runStageHook(p, "after-apply", wavePlan.order.filter((name) => applied.run.completed.includes(name)), undefined, `wave ${k} counts as failed though its components applied`);
1871
+ setWave({ ...entry, status: applied.status === "applied" && !waveAfter ? "applied" : "failed" });
1495
1872
  await recordAutoReleasesForRun(applied.run.results, p.env, at.release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), at.release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
1496
1873
  if (applied.status !== "applied") {
1497
- return stop(report({ status: "failed" }), failedRunExitCode(applied.run.results), applied.run.status === "gated" ? "gated" : undefined);
1874
+ // #4291: a failed member blocks only its dependents, so the later waves still run.
1875
+ lastFailure = { results: applied.run.results, gated: applied.run.status === "gated" };
1876
+ recordAttempt();
1877
+ continue;
1498
1878
  }
1879
+ if (waveAfter) return stop(report({ status: "failed", message: waveAfter }), 1);
1499
1880
  console.error(formatSuccess(`applied wave ${k} of ${total} under ${entry.approval === "pull-request" ? `the pull request's approval of ${approvedSet.doc.digest}` : `${entry.gate}, approved for ${entry.digest}`} by ${(entry.approvedBy ?? []).join(", ")}`));
1500
1881
  }
1501
1882
 
1883
+ // #4291: a failure, or a member it blocked, fails the job once every other member applied.
1884
+ const blockedHere = [...blockedSoFar.keys()].filter((name) => (only === undefined ? true : (p.plan.waves[only - 1] ?? []).includes(name)));
1885
+ if (lastFailure || planFailedIn !== undefined || blockedHere.length > 0) {
1886
+ const blockedLine = blockedSoFar.size > 0 ? ` Blocked: ${blockedWords(blockedSoFar)}.` : "";
1887
+ const message = planFailedIn !== undefined && !lastFailure
1888
+ ? `A member of wave ${planFailedIn} failed to plan, so nothing in wave ${planFailedIn} was applied.${blockedLine}`
1889
+ : blockedLine ? blockedLine.trim() : undefined;
1890
+ return stop(
1891
+ report({ status: "failed", ...(message ? { message } : {}) }),
1892
+ lastFailure ? failedRunExitCode(lastFailure.results) : 1,
1893
+ lastFailure?.gated ? "gated" : undefined,
1894
+ );
1895
+ }
1896
+
1502
1897
  const done = p.plan.order.every((name) => completed.has(name));
1503
1898
  if (green && done) await recordGreenApplied(p, green, approvedSet.doc.digest, approval, via);
1504
1899
  return stop(report({ status: done ? "applied" : "wave-applied" }), 0);
@@ -1513,3 +1908,45 @@ function writeWaveChangeSet(ctx: CommandContext, wave: number, doc: PrPlanSet["d
1513
1908
  writeFileSync(join(dir, name), body);
1514
1909
  upload?.file(name, body);
1515
1910
  }
1911
+
1912
+ /**
1913
+ * chant components pr-post --pr <n> --head <sha> --env <env> --forge <kind> [--member <name>] [--output <dir>] [--result <result>] (#4295)
1914
+ *
1915
+ * Post the plan note and the `chant/plan` status from the report a plan job
1916
+ * wrote with `pr-plan --no-status`, in a job that runs none of the change's
1917
+ * code. The pull request, the head, the environment and the member come from
1918
+ * the flags, and a report that names others is refused (`../../pr-post.ts`).
1919
+ * It exits 0 once it has posted, whatever the plan said, so the plan job's
1920
+ * own result stays the one that fails the run.
1921
+ */
1922
+ export async function runComponentsPrPost(ctx: CommandContext): Promise<number> {
1923
+ const { args } = ctx;
1924
+ if (args.pr === undefined) return fail("pr-post needs the pull or merge request: --pr <n>");
1925
+ if (!args.head || !/^[0-9a-f]{40}$/.test(args.head)) return fail("pr-post needs the full commit the plan job checked out: --head <sha>");
1926
+ if (!args.env) return fail("pr-post needs the environment the plan was for: --env <env>");
1927
+ if (!args.forge || !(FORGE_KINDS as readonly string[]).includes(args.forge)) return fail(`pr-post posts to a forge: --forge ${FORGE_KINDS.join("|")}`);
1928
+ if (args.members && args.members.length > 1) return fail(`pr-post takes one --member, not ${args.members.join(", ")}`);
1929
+ let forge: PrForge;
1930
+ try {
1931
+ forge = forgeFromEnv(args.forge as ForgeKind);
1932
+ } catch (err) {
1933
+ return fail(err instanceof Error ? err.message : String(err));
1934
+ }
1935
+ const { postPlanReport } = await import("../../pr-post");
1936
+ const member = args.members?.[0];
1937
+ const posted = await postPlanReport({
1938
+ forge,
1939
+ dir: resolve(args.output ?? PR_REPORT_DIR),
1940
+ pr: args.pr,
1941
+ head: args.head,
1942
+ env: args.env,
1943
+ ...(member ? { member } : {}),
1944
+ ...(args.planResult ? { result: args.planResult } : {}),
1945
+ // #4289: the plan job could not ask the forge, so the note job does, from the Ops at the commit it checked out.
1946
+ overdue: () => overdueOps(forge, member),
1947
+ });
1948
+ for (const message of posted.warnings) console.error(formatWarning({ message }));
1949
+ if (posted.refused) console.error(formatWarning({ message: `the plan report was not used: ${posted.refused}` }));
1950
+ console.error(formatInfo(`pr-post: ${prStatusContext("plan", member)} ${posted.state}: ${posted.description}${posted.noted ? "; note posted" : ""}`));
1951
+ return 0;
1952
+ }