@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
@@ -63,6 +63,7 @@
63
63
  * all it uses one for; pr-apply still reads the pull request and its reviews
64
64
  * through `--forge`.
65
65
  */
66
+ import { spawnSync } from "node:child_process";
66
67
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
67
68
  import { join, resolve } from "node:path";
68
69
  import { loadChantConfig, resolveAutoReleaseDisabled } from "../../config.js";
@@ -71,10 +72,11 @@ import { deriveFanOut, fanOutRegistry } from "../../components/fan-out-support.j
71
72
  import { applyPrSet, approvalOf, approversWhoAuthored, approversWithoutReview, decidePrGate, planPrSet, prReport, readOnlyLedger, prWavePlan, resumePrSet, waveNotCovered, } from "../../components/pr-run.js";
72
73
  import { composeChangeSet, verifyChangeSetDigest } from "../../change-set.js";
73
74
  import { waveGateName } from "../../gated-waves.js";
75
+ import { blockedMembers, blockedWords, settleEarlierWaves } from "../../wave-blocked.js";
74
76
  import { readFanOutAttempt, writeFanOutAttempt } from "../../components/fan-out-record.js";
75
77
  import { componentInputDigests, progressThatCarries } from "../../components/fan-out-inputs.js";
76
78
  import { samePlanDigest } from "../../lifecycle/plan-digest.js";
77
- import { gitGateLedgerPort } from "../../op/gate.js";
79
+ import { gateIsSealed, gitGateLedgerPort } from "../../op/gate.js";
78
80
  import { emitGateStep, gateEventsOf, GATE_PHASE } from "../../run-event-stream.js";
79
81
  import { openCliRunEvents, refusesEventsWithJson } from "../run-events.js";
80
82
  import { openPrReportUpload, parseReportUrl, reportKey } from "../../pr-report-url.js";
@@ -91,14 +93,16 @@ import { GATED_EXIT_CODE, autoReleaseRun, recordAutoReleasesForRun } from "./run
91
93
  import { appliedDigestOf } from "../../components/auto-release.js";
92
94
  import { withComponentEndpoint } from "../component-endpoint.js";
93
95
  import { failedRunExitCode } from "../../components/driver.js";
96
+ import { prLoopHooksAt, runPrHooks } from "../../components/pr-hooks.js";
97
+ import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride } from "../../components/pr-policy.js";
94
98
  /** Where the reports land unless `--output` says. */
95
99
  export const PR_REPORT_DIR = ".chant/pr";
96
100
  const fail = (message, hint) => {
97
101
  console.error(formatError({ message, ...(hint ? { hint } : {}) }));
98
102
  return 1;
99
103
  };
100
- /** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
101
- async function prepare(ctx, stage) {
104
+ /** Everything both stages share: the change and the derivation. Each stage plans the members itself. `pr-env up` (#4298) prepares as the plan does. */
105
+ export async function prepare(ctx, stage) {
102
106
  const { args } = ctx;
103
107
  const projectPath = resolve(".");
104
108
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} }));
@@ -178,8 +182,31 @@ async function prepare(ctx, stage) {
178
182
  return fail(`--seed-outputs: could not read "${file}": ${err instanceof Error ? err.message : String(err)}`);
179
183
  }
180
184
  }
185
+ // #4288: the stage hooks, read from the declaration at base so the change cannot edit what its own jobs run.
186
+ let hooks;
187
+ try {
188
+ hooks = prLoopHooksAt(projectPath, base);
189
+ }
190
+ catch (err) {
191
+ return fail(err instanceof Error ? err.message : String(err));
192
+ }
193
+ if (hooks.note)
194
+ console.error(formatInfo(hooks.note));
195
+ // #4294: the policy, read from the declaration at base like the hooks.
196
+ let policy;
197
+ try {
198
+ policy = prLoopPolicyAt(projectPath, base);
199
+ }
200
+ catch (err) {
201
+ return fail(err instanceof Error ? err.message : String(err));
202
+ }
203
+ if (policy.note)
204
+ console.error(formatInfo(policy.note));
181
205
  const env = args.env ?? "local";
182
206
  const registry = await fanOutRegistry(projectPath, config);
207
+ const routedAway = member ? await routing(ctx, stage, projectPath, member, base, forge) : undefined;
208
+ if (routedAway)
209
+ console.error(formatInfo(routedAway));
183
210
  return {
184
211
  config,
185
212
  env,
@@ -189,12 +216,97 @@ async function prepare(ctx, stage) {
189
216
  gate: args.gate ?? PR_APPLY_GATE,
190
217
  ...(forge ? { forge } : {}),
191
218
  components: derived.components,
192
- plan: derived.plan,
219
+ plan: routedAway ? { ...derived.plan, order: [], waves: [], seeds: [], indeterminate: [] } : derived.plan,
193
220
  signal: derived.signal,
194
221
  registry,
195
222
  seededOutputs,
223
+ hooks,
224
+ hookOutcomes: [],
225
+ ...(routedAway ? { routedAway } : {}),
226
+ policy,
196
227
  };
197
228
  }
229
+ /** Set where `p`'s hooks run, once the stage knows its op (#4288). */
230
+ export function placeHooks(ctx, p, stage, op) {
231
+ p.hookPlace = { op, env: p.env, stage, dir: ctx.args.output ?? PR_REPORT_DIR, cwd: resolve("."), log: (line) => console.error(line) };
232
+ }
233
+ /**
234
+ * Run one moment's hooks for `components` (#4288), keeping their outcomes
235
+ * for the report. Returns the failure as the report's message, or undefined
236
+ * when no hook failed. A hold is logged: the pull request's gate already
237
+ * waits for a person's approval of the digest.
238
+ */
239
+ export function runStageHook(p, moment, components, planned, what = "nothing was applied") {
240
+ if (!p.hookPlace || components.length === 0)
241
+ return undefined;
242
+ const ran = runPrHooks(p.hooks, moment, p.hookPlace, components, planned ? { members: planned.members, plans: planned.plans } : undefined);
243
+ p.hookOutcomes.push(...ran.outcomes);
244
+ for (const hold of ran.holds)
245
+ console.error(formatInfo(`${hold} held, and the pull request's gate asks for an approval of the digest either way`));
246
+ if (!ran.failed)
247
+ return undefined;
248
+ p.hookFailed = `The ${ran.failed}, so ${what}.`;
249
+ return p.hookFailed;
250
+ }
251
+ const EMPTY_SET = () => ({ doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: true });
252
+ /**
253
+ * Plan with the plan-side hooks around it (#4288): before-observe and
254
+ * before-plan, then the plan, then after-plan. A failed hook gives a failed
255
+ * set, so the stage reports `plan-failed` and nothing applies.
256
+ */
257
+ async function hookedPlan(p, components, plan) {
258
+ if (runStageHook(p, "before-observe", components, undefined, "nothing was planned or applied"))
259
+ return EMPTY_SET();
260
+ if (runStageHook(p, "before-plan", components, undefined, "nothing was planned or applied"))
261
+ return EMPTY_SET();
262
+ const set = await plan();
263
+ if (set.failed)
264
+ return set;
265
+ if (runStageHook(p, "after-plan", components, set))
266
+ return { ...set, failed: true };
267
+ return set;
268
+ }
269
+ /**
270
+ * Whether `ci.branches` or `ci.labels` (#4292), read from the declaration at
271
+ * base, leave `member` out of this stage: the reason, or undefined when the
272
+ * member runs. The record and apply stages read `ci.branches` against the
273
+ * pushed branch (`--branch`, else the forge's variables); the plan stage
274
+ * reads `ci.labels` against the pull request's labels. A label only narrows
275
+ * what is planned: the record and apply stages never read labels.
276
+ */
277
+ async function routing(ctx, stage, projectPath, member, base, forge) {
278
+ const { scopeSource } = await import("../../workspace/write-scope.js");
279
+ const { appliesFrom, plansUnderLabels, pushedBranchFromEnv } = await import("../../workspace/ci-branches.js");
280
+ const ci = scopeSource(projectPath, base).declaration?.ci;
281
+ if (stage !== "plan") {
282
+ if (!ci?.branches)
283
+ return undefined;
284
+ const pushed = ctx.args.branch ?? pushedBranchFromEnv();
285
+ if (!pushed)
286
+ return undefined;
287
+ const route = appliesFrom(ci.branches, member, pushed);
288
+ if (!route.applies)
289
+ return route.note;
290
+ console.error(formatInfo(route.note));
291
+ return undefined;
292
+ }
293
+ if (!ci?.labels || ctx.args.pr === undefined || !forge?.labels)
294
+ return undefined;
295
+ let carried;
296
+ try {
297
+ carried = await forge.labels(ctx.args.pr);
298
+ }
299
+ catch (err) {
300
+ console.error(formatWarning({ message: `could not read the labels of #${ctx.args.pr}, so every member plans: ${err instanceof Error ? err.message : String(err)}` }));
301
+ return undefined;
302
+ }
303
+ const filter = plansUnderLabels(ci.labels, member, carried);
304
+ if (!filter.plans)
305
+ return filter.note;
306
+ if (filter.note)
307
+ console.error(formatInfo(filter.note));
308
+ return undefined;
309
+ }
198
310
  /**
199
311
  * The one `--member` name, checked against the workspace member that owns
200
312
  * `projectPath`: a pipeline copied into another member would otherwise
@@ -223,8 +335,11 @@ async function checkMember(projectPath, names) {
223
335
  }
224
336
  return name;
225
337
  }
226
- /** Plan every member the derivation selects. */
227
- function planAll(p, onRunEvent) {
338
+ /** Plan every member the derivation selects, between the plan-side hooks (#4288). */
339
+ export function planAll(p, onRunEvent) {
340
+ return hookedPlan(p, p.plan.order, () => planBare(p, onRunEvent));
341
+ }
342
+ export function planBare(p, onRunEvent) {
228
343
  return planPrSet({
229
344
  components: p.components,
230
345
  plan: p.plan,
@@ -286,7 +401,7 @@ async function publish(forge, report, note) {
286
401
  return;
287
402
  // A member the change does not reach has nothing to say: one note per
288
403
  // member would put a line on every pull request for each member it skips.
289
- if (report.member && report.status === "nothing")
404
+ if (report.member && report.status === "nothing" && !report.overdue?.length)
290
405
  return;
291
406
  try {
292
407
  await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
@@ -308,6 +423,15 @@ function printMembers(report) {
308
423
  }
309
424
  }
310
425
  async function finish(ctx, p, report, upload) {
426
+ // #4288: the hooks the stage ran, and a failed hook's message in place of the stage's own.
427
+ if (p.hookOutcomes.length > 0)
428
+ report = { ...report, hooks: [...p.hookOutcomes] };
429
+ if (p.hookFailed)
430
+ report = { ...report, message: p.hookFailed };
431
+ if (p.routedAway && report.status === "nothing" && !report.message)
432
+ report = { ...report, message: p.routedAway };
433
+ if (p.policyReport)
434
+ report = { ...report, policy: p.policyReport };
311
435
  const note = noteFor(report, p.forge);
312
436
  writeOutputs(ctx, report, note, upload);
313
437
  if (!ctx.args.noStatus)
@@ -354,6 +478,85 @@ function openUpload(events, stage, pr, p) {
354
478
  });
355
479
  return events.upload;
356
480
  }
481
+ /**
482
+ * Run `ci.policy` at base over the planned set (#4294) and keep what it said
483
+ * on `p` for the report. A denial is decided against the override ledger:
484
+ * read only at the plan stage, and recorded for an override at the apply.
485
+ */
486
+ async function judgePolicy(p, at) {
487
+ 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 });
488
+ if (!verdict)
489
+ return { held: false };
490
+ const report = prPolicyReport(verdict, p.policy.base);
491
+ p.policyReport = report;
492
+ for (const w of verdict.warnings)
493
+ console.error(formatWarning({ message: `policy warning: ${w}` }));
494
+ if (verdict.result === "error") {
495
+ 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}` } };
496
+ }
497
+ const held = verdict.holds.length > 0;
498
+ for (const h of verdict.holds)
499
+ console.error(formatInfo(`policy holds the plan for a person's approval (${h.rule}): ${h.message}`));
500
+ if (verdict.result !== "denied")
501
+ return { held };
502
+ const denied = verdict.denials.map((d) => `${d.rule}: ${d.message}`).join("; ");
503
+ let decided;
504
+ try {
505
+ // The plan stage records nothing, and reads the applied records so a spent override reads as spent.
506
+ const real = gitGateLedgerPort();
507
+ const port = at.stage === "plan" ? { ...readOnlyLedger(real), ...(real.readApplied ? { readApplied: real.readApplied.bind(real) } : {}) } : real;
508
+ decided = await decidePrPolicyOverride({
509
+ port,
510
+ subject: at.op,
511
+ digest: at.set.doc.digest,
512
+ rules: verdict.rules,
513
+ base: p.base,
514
+ cwd: resolve("."),
515
+ sealed: await gateIsSealed(p.gate),
516
+ record: at.stage === "apply",
517
+ description: `${at.op}: plan ${at.set.doc.digest} denied by ${verdict.rules.join(", ")}`,
518
+ });
519
+ }
520
+ catch (err) {
521
+ const why = err instanceof Error ? err.message : String(err);
522
+ if (at.stage === "plan") {
523
+ console.error(formatWarning({ message: `could not read the override ledger, so no override reads as standing: ${why}` }));
524
+ return { held, stop: { refusal: "policy-denied", code: 1, message: `The policy denied this plan: ${denied}.` } };
525
+ }
526
+ 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.` } };
527
+ }
528
+ if (decided.status === "overridden") {
529
+ report.override = decided.override;
530
+ console.error(formatInfo(`the policy denial (${verdict.rules.join(", ")}) is overridden by ${decided.override.by} at ${decided.override.at}: ${decided.override.reason}`));
531
+ return { held, override: decided.override };
532
+ }
533
+ report.overrideCommand = decided.command;
534
+ for (const why of decided.refusals)
535
+ console.error(formatInfo(`an override does not count: ${why}`));
536
+ const who = decided.overriders.length ? `${decided.overriders.join(", ")} may` : "nobody listed in .chant/overriders at base may";
537
+ const moved = decided.moved ? ` ${decided.moved.by} overrode an earlier plan or other rules, which counts for nothing here.` : "";
538
+ return {
539
+ held,
540
+ stop: {
541
+ refusal: "policy-denied",
542
+ code: at.stage === "plan" ? 1 : GATED_EXIT_CODE,
543
+ message: `The policy denied this plan: ${denied}.${moved} Nothing was applied. ${who} override it: ${decided.command}`,
544
+ },
545
+ };
546
+ }
547
+ /** Spend the override the apply runs under (#4294), before anything applies. The failure's message, or undefined. */
548
+ async function spendPolicy(op, judged, head) {
549
+ if (!judged.override)
550
+ return undefined;
551
+ try {
552
+ await spendPrPolicyOverride(gitGateLedgerPort(), op, judged.override, { commit: head });
553
+ }
554
+ catch (err) {
555
+ return `Could not record the override this apply runs under, so nothing was applied: ${err instanceof Error ? err.message : String(err)}`;
556
+ }
557
+ console.error(formatInfo(`applies under the override by ${judged.override.by}, which is now spent`));
558
+ return undefined;
559
+ }
357
560
  /** chant components pr-plan --base <ref> --pr <n> [--env <env>] [--gate <name>] [--forge <kind>] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
358
561
  export function runComponentsPrPlan(ctx) {
359
562
  if (refusesEventsWithJson(ctx.args, "the stage's report"))
@@ -368,12 +571,15 @@ async function prPlan(ctx, stage) {
368
571
  if (typeof p === "number")
369
572
  return p;
370
573
  const op = prOp(ctx.args.pr, p.member);
574
+ placeHooks(ctx, p, "plan", op);
371
575
  const upload = openUpload(stage, "plan", ctx.args.pr, p);
372
576
  // `--events-json` (#4041): planning, then where the approval stands.
373
577
  const events = await openCliRunEvents(ctx.args, { kind: "component", name: `pr-plan ${op}`, cwd: resolve(".") }, upload?.line);
374
578
  stage.stream = events;
375
579
  events?.emit({ type: "run-start", waves: p.plan.waves });
376
580
  const set = await planAll(p, events?.emit);
581
+ // #4294: the policy at base over the planned change set.
582
+ const judged = p.plan.order.length > 0 && !set.failed ? await judgePolicy(p, { stage: "plan", op, pr: ctx.args.pr, set }) : { held: false };
377
583
  let approval = { status: "pending" };
378
584
  if (p.plan.order.length > 0) {
379
585
  try {
@@ -385,7 +591,8 @@ async function prPlan(ctx, stage) {
385
591
  }
386
592
  }
387
593
  const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
388
- const report = prReport({
594
+ const overdue = await overdueOps(p.forge, p.member);
595
+ const planned = prReport({
389
596
  stage: "plan",
390
597
  pr: ctx.args.pr,
391
598
  env: p.env,
@@ -401,6 +608,7 @@ async function prPlan(ctx, stage) {
401
608
  status,
402
609
  ...(ctx.args.waveGate ? { waveGate: ctx.args.waveGate } : {}),
403
610
  });
611
+ const report = overdue.length > 0 ? { ...planned, overdue } : planned;
404
612
  await finish(ctx, p, report, upload);
405
613
  // The plan stage settles on its planning: an approval still to come is what it reports, not a stop.
406
614
  events?.settle(settleOf(status), status === "plan-failed" ? "a member failed to plan" : undefined);
@@ -410,7 +618,43 @@ async function prPlan(ctx, stage) {
410
618
  console.error(formatInfo(`plan : ${report.digest}`));
411
619
  console.error(formatInfo(approval.status === "approved" ? `approved by ${(approval.approvedBy ?? []).join(", ")}` : `approve : ${prApproveCommand(report)}`));
412
620
  }
413
- return status === "plan-failed" ? 1 : 0;
621
+ if (judged.stop)
622
+ console.error(formatWarning({ message: judged.stop.message }));
623
+ return status === "plan-failed" || judged.stop ? 1 : 0;
624
+ }
625
+ /**
626
+ * The scheduled watch and reconcile Ops that stopped running (#4289,
627
+ * `op/schedule-overdue.ts`), asked of the forge. Without a forge, or when
628
+ * the Ops cannot be read, nothing is checked and the plan goes on. The
629
+ * generated plan holds no forge token (#4295), so there the plan-note job's
630
+ * `pr-post` asks instead, reading the Ops at the target branch's commit.
631
+ */
632
+ async function overdueOps(forge, member) {
633
+ if (!forge?.scheduledRuns)
634
+ return [];
635
+ try {
636
+ const { discoverOps } = await import("../../op/discover.js");
637
+ const { scheduledWatches, checkOverdueOps } = await import("../../op/schedule-overdue.js");
638
+ const discovered = await discoverOps({ cwd: resolve(".") });
639
+ const watches = scheduledWatches([...discovered.ops.values()].map((d) => d.config));
640
+ if (watches.length === 0)
641
+ return [];
642
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" });
643
+ if (top.status !== 0)
644
+ return [];
645
+ return await checkOverdueOps({
646
+ watches,
647
+ forge,
648
+ repoRoot: top.stdout.trim(),
649
+ ...(member ? { member } : {}),
650
+ now: new Date(),
651
+ log: (line) => console.error(formatWarning({ message: line })),
652
+ });
653
+ }
654
+ catch (err) {
655
+ console.error(formatWarning({ message: `could not check the scheduled Ops, so none is reported overdue: ${err instanceof Error ? err.message : String(err)}` }));
656
+ return [];
657
+ }
414
658
  }
415
659
  /** chant components pr-apply --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--require-review] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
416
660
  export function runComponentsPrApply(ctx) {
@@ -426,6 +670,9 @@ async function prApply(ctx, stage) {
426
670
  return fail("the workspace opts into review-rules, which reads the pull request's reviews, so pr-apply needs --forge <kind>");
427
671
  if (reviewRules)
428
672
  ctx = { ...ctx, args: { ...ctx.args, requireReview: true } };
673
+ // #4293: ci.locks, read at base like the security groups, so the change cannot turn its own locks off.
674
+ const { ciLocksAt } = await import("../../workspace/ci-locks.js");
675
+ const locks = ciLocksAt(process.cwd(), ctx.args.base);
429
676
  const { args } = ctx;
430
677
  if (args.requireReview && !args.forge)
431
678
  return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
@@ -455,6 +702,7 @@ async function prApply(ctx, stage) {
455
702
  }
456
703
  }
457
704
  const op = green ? greenOp(green.commit, p.member) : pr === null ? "pr-unknown" : prOp(pr, p.member);
705
+ placeHooks(ctx, p, "apply", op);
458
706
  // The run id the release records carry, chosen before the run so that
459
707
  // `--events-json` (#4041, #4007) names the run by it too.
460
708
  const release = autoReleaseRun(`${op}-${Date.now()}`);
@@ -462,6 +710,15 @@ async function prApply(ctx, stage) {
462
710
  const events = await openCliRunEvents(args, { kind: "component", name: `pr-apply ${op}`, runId: release.runId, cwd: resolve(".") }, upload?.line);
463
711
  stage.stream = events;
464
712
  events?.emit({ type: "run-start", waves: p.plan.waves });
713
+ if (locks && p.plan.order.length > 0) {
714
+ const held = await heldLocksOf(p, pr);
715
+ if (typeof held === "number")
716
+ return held;
717
+ if (held) {
718
+ const set = { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
719
+ return stopper(ctx, p, events, upload)(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 }), GATED_EXIT_CODE);
720
+ }
721
+ }
465
722
  if (args.waveGate)
466
723
  return prApplyWaves(ctx, p, { pr, op, release, upload, events, ...(green ? { green } : {}) });
467
724
  // `--resume` (#3464): an earlier attempt at this change on this commit
@@ -521,11 +778,16 @@ async function prApply(ctx, stage) {
521
778
  await storeChangeSetForGate(ctx, p, set.doc);
522
779
  let approval;
523
780
  let via = "gate";
781
+ // #4294: the policy at base, before the gate. A resume runs under the decision of the attempt it finishes.
782
+ let judged = { held: false };
524
783
  if (resumed) {
525
784
  approval = resumed.approval;
526
785
  }
527
786
  else {
528
- const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
787
+ judged = await judgePolicy(p, { stage: "apply", op, pr, set });
788
+ if (judged.stop)
789
+ return stop(prReport({ ...base, approval: { status: "pending" }, status: "refused", refusal: judged.stop.refusal, message: judged.stop.message }), judged.stop.code);
790
+ const decided = await decideApplyGate(p, { op, pr, set, events, held: judged.held, ...(green ? { green } : {}) });
529
791
  approval = decided.approval;
530
792
  if (!decided.satisfied)
531
793
  return stop(prReport({ ...base, approval, status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
@@ -539,6 +801,9 @@ async function prApply(ctx, stage) {
539
801
  emitReviewStop(events, review.message);
540
802
  return stop(prReport({ ...base, approval, status: "refused", refusal: review.refusal, message: review.message }), GATED_EXIT_CODE);
541
803
  }
804
+ const unspent = await spendPolicy(op, judged, p.head);
805
+ if (unspent)
806
+ return stop(prReport({ ...base, approval, status: "refused", refusal: "policy-denied", message: unspent }), 1);
542
807
  // The record is written before the first component runs and again as each
543
808
  // one settles, so a run killed partway still knows what applied.
544
809
  const prior = resumed ? { completed: resumed.completed, outputs: resumed.outputs } : { completed: [], outputs: {} };
@@ -569,6 +834,11 @@ async function prApply(ctx, stage) {
569
834
  });
570
835
  };
571
836
  recordAttempt();
837
+ // #4288: the before-apply hooks, once the gate and the review let the apply through.
838
+ const toApply = (resumed?.plan ?? p.plan).order.filter((name) => !completedSoFar.has(name));
839
+ const beforeFailed = runStageHook(p, "before-apply", toApply);
840
+ if (beforeFailed)
841
+ return stop(prReport({ ...base, approval, status: "failed", message: beforeFailed }), 1);
572
842
  // #3999: a round planned after an upstream applied waits on a gate of its
573
843
  // own, under the same identity.gates rule and the same review checks.
574
844
  const roundLedger = gitGateLedgerPort();
@@ -625,11 +895,13 @@ async function prApply(ctx, stage) {
625
895
  : {}),
626
896
  });
627
897
  recordAttempt();
898
+ // #4288: the after-apply hooks, for the components that applied.
899
+ const afterFailed = runStageHook(p, "after-apply", toApply.filter((name) => applied.run.completed.includes(name)), undefined, "the apply counts as failed though its components applied");
628
900
  await recordAutoReleasesForRun(applied.run.results, p.env, release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
629
901
  const report = prReport({
630
902
  ...base,
631
903
  approval,
632
- status: applied.status,
904
+ status: afterFailed ? "failed" : applied.status,
633
905
  members: applied.members,
634
906
  ...(resumed ? { resumed: resumed.completed } : {}),
635
907
  ...(applied.rounds ? { rounds: applied.rounds } : {}),
@@ -638,14 +910,38 @@ async function prApply(ctx, stage) {
638
910
  // A component that stopped at a gate of its own leaves the apply waiting on
639
911
  // a person. A step that refused its plan because the target moved since
640
912
  // the approval exits 3, as a moved digest does before anything applies (#3997).
641
- const exit = applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
913
+ const exit = afterFailed ? 1 : applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
642
914
  if (green && applied.status === "applied")
643
915
  await recordGreenApplied(p, green, set.doc.digest, approval, via);
644
916
  const code = await stop(report, exit, applied.run.status === "gated" ? "gated" : undefined);
645
- if (applied.status === "applied")
917
+ if (applied.status === "applied" && !afterFailed)
646
918
  console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
647
919
  return code;
648
920
  }
921
+ /**
922
+ * Under `ci.locks` (#4293): the refusal when another open pull request holds
923
+ * a lock on a component this apply reaches, else null. The targets are the
924
+ * selected components, the names `chant lock` takes. A lock whose pull
925
+ * request the forge says merged or closed reads as released; with no
926
+ * `--forge` every lock counts. A lock file that cannot be read stops the
927
+ * apply, since nothing then says the targets are free.
928
+ */
929
+ async function heldLocksOf(p, pr) {
930
+ const { heldTargetLocks, describeHeldTargetLocks } = await import("../../lifecycle/target-lock.js");
931
+ const isOpen = p.forge?.pullRequestOpen?.bind(p.forge);
932
+ let held;
933
+ try {
934
+ held = await heldTargetLocks(p.gate, p.plan.order, pr, { ...(isOpen ? { isOpen } : {}) });
935
+ }
936
+ catch (err) {
937
+ return fail(`could not read the target locks of ${p.gate}, so nothing was applied: ${err instanceof Error ? err.message : String(err)}`);
938
+ }
939
+ if (held.length === 0)
940
+ return null;
941
+ const name = (n) => (p.forge?.kind === "gitlab" ? `merge request !${n}` : `pull request ${n}`);
942
+ const holders = [...new Set(held.map((h) => h.pr))].sort((a, b) => a - b);
943
+ 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"}.`;
944
+ }
649
945
  /** #4274: a component that published nothing is recorded by the plan digest its approval bound. */
650
946
  function prAppliedDigests(p, members) {
651
947
  return appliedDigestOf({ components: p.components, env: p.env, projectRoot: resolve("."), members });
@@ -681,6 +977,7 @@ async function prRecord(ctx, stage) {
681
977
  }
682
978
  }
683
979
  const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
980
+ placeHooks(ctx, p, "record", op);
684
981
  const upload = openUpload(stage, "record", pr, p);
685
982
  const events = await openCliRunEvents(args, { kind: "component", name: `pr-record ${op}`, cwd: resolve(".") }, upload?.line);
686
983
  stage.stream = events;
@@ -733,33 +1030,36 @@ async function prRecord(ctx, stage) {
733
1030
  });
734
1031
  const { mergeSourceOf, writeMergeRecord, MERGE_RECORD_SCHEMA_ID, MERGE_RECORD_CONTRACT } = await import("../../lifecycle/merge-record.js");
735
1032
  const { pushLifecycleReport } = await import("../../lifecycle/git.js");
736
- try {
737
- const written = await writeMergeRecord({
738
- $schema: MERGE_RECORD_SCHEMA_ID,
739
- contract: MERGE_RECORD_CONTRACT,
740
- commit: p.head,
741
- base: p.base,
742
- env: p.env,
743
- ...(p.member ? { member: p.member } : {}),
744
- source: mergeSourceOf(resolve("."), p.head, pr),
745
- op: pr === null ? null : op,
746
- gate: p.gate,
747
- digest: nothing ? null : set.doc.digest,
748
- plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
749
- ...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
750
- approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
751
- ...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
752
- components: p.plan.order,
753
- recordedAt: new Date().toISOString(),
754
- });
755
- if (written.written) {
756
- const pushed = await pushLifecycleReport();
757
- if (pushed.pushWarning)
758
- console.error(formatWarning({ message: pushed.pushWarning }));
1033
+ // #4292: a member another branch applies has no merge on this one, so no record says it planned nothing here.
1034
+ if (!p.routedAway) {
1035
+ try {
1036
+ const written = await writeMergeRecord({
1037
+ $schema: MERGE_RECORD_SCHEMA_ID,
1038
+ contract: MERGE_RECORD_CONTRACT,
1039
+ commit: p.head,
1040
+ base: p.base,
1041
+ env: p.env,
1042
+ ...(p.member ? { member: p.member } : {}),
1043
+ source: mergeSourceOf(resolve("."), p.head, pr),
1044
+ op: pr === null ? null : op,
1045
+ gate: p.gate,
1046
+ digest: nothing ? null : set.doc.digest,
1047
+ plan: nothing ? "nothing" : set.failed ? "plan-failed" : approval.status === "approved" ? "approved" : approval.status === "changed" ? "changed" : "unapproved",
1048
+ ...(approval.status === "changed" && approval.approved ? { approvedDigest: approval.approved } : {}),
1049
+ approvedBy: approval.status === "approved" ? (approval.approvedBy ?? []) : [],
1050
+ ...(authorApproved && authorApproved.length > 0 ? { authorApproved } : {}),
1051
+ components: p.plan.order,
1052
+ recordedAt: new Date().toISOString(),
1053
+ });
1054
+ if (written.written) {
1055
+ const pushed = await pushLifecycleReport();
1056
+ if (pushed.pushWarning)
1057
+ console.error(formatWarning({ message: pushed.pushWarning }));
1058
+ }
1059
+ }
1060
+ catch (err) {
1061
+ console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
759
1062
  }
760
- }
761
- catch (err) {
762
- console.error(formatWarning({ message: `could not write the merge record for ${p.head}: ${err instanceof Error ? err.message : String(err)}` }));
763
1063
  }
764
1064
  const stop = stopper(ctx, p, events, upload);
765
1065
  return stop(report, status === "plan-failed" ? 1 : 0);
@@ -800,7 +1100,12 @@ async function greenTargetOf(ctx) {
800
1100
  async function decideApplyGate(p, at) {
801
1101
  const { op, pr, set, events, green } = at;
802
1102
  let uncovered = [];
803
- if (green) {
1103
+ // #4294: a plan the policy holds needs an approval of its own digest, so the merges' approvals do not cover it.
1104
+ if (green && at.held) {
1105
+ 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`));
1106
+ uncovered = ["the policy holds this plan for an approval of its own digest"];
1107
+ }
1108
+ else if (green) {
804
1109
  const cover = coverByMerges(set.doc, green.approved);
805
1110
  if (cover.covered) {
806
1111
  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`));
@@ -1181,8 +1486,28 @@ async function prApplyWaves(ctx, p, at) {
1181
1486
  const completed = new Set(carry?.completed ?? []);
1182
1487
  const outputsSoFar = { ...(carry?.outputs ?? {}) };
1183
1488
  const inputsSoFar = Object.fromEntries([...completed].filter((n) => inputsNow[n] !== undefined).map((n) => [n, inputsNow[n]]));
1184
- const failedSoFar = new Set();
1489
+ // #4291: what failed in an earlier wave's job stays failed until it applies, so this job blocks its dependents and runs the rest.
1490
+ const failedSoFar = new Set((carry ? attempt?.failed ?? [] : []).filter((name) => !completed.has(name)));
1185
1491
  let waves = carry?.record.waves ?? [];
1492
+ const upstreams = new Map(p.components.map((c) => [c.name, c.dependsOn ?? []]));
1493
+ /** Each component's outcome so far, past applied (#4291): failed, waiting, not-applied, or blocked by a failure. */
1494
+ const outcomes = () => {
1495
+ const out = new Map();
1496
+ for (const name of completed)
1497
+ out.set(name, { status: "applied" });
1498
+ for (const name of failedSoFar)
1499
+ out.set(name, { status: "failed" });
1500
+ for (const w of waves) {
1501
+ if (w.status !== "waiting" && w.status !== "plan-failed" && w.status !== "failed")
1502
+ continue;
1503
+ for (const name of w.components)
1504
+ if (!out.has(name))
1505
+ out.set(name, { status: w.status === "waiting" ? "waiting" : "not-applied" });
1506
+ }
1507
+ return out;
1508
+ };
1509
+ /** Components a failure blocked (#4291), each with the component that failed. */
1510
+ const blockedSoFar = new Map();
1186
1511
  const after = new Map();
1187
1512
  const leftIn = (k) => (p.plan.waves[k - 1] ?? []).filter((name) => !completed.has(name));
1188
1513
  const firstLeft = () => p.plan.waves.findIndex((_, i) => leftIn(i + 1).length > 0) + 1;
@@ -1197,14 +1522,33 @@ async function prApplyWaves(ctx, p, at) {
1197
1522
  const members = set.members.map((m) => {
1198
1523
  const now = after.get(m.member);
1199
1524
  if (now)
1200
- return { ...m, status: now.status, ...(now.error ? { error: now.error } : {}) };
1201
- return completed.has(m.component) ? { ...m, status: "applied" } : m;
1525
+ return { ...m, status: now.status, ...(now.error ? { error: now.error } : {}), ...(now.blockedBy ? { blockedBy: now.blockedBy } : {}) };
1526
+ if (completed.has(m.component))
1527
+ return { ...m, status: "applied" };
1528
+ const by = blockedSoFar.get(m.component);
1529
+ if (by !== undefined)
1530
+ return { ...m, status: "blocked", blockedBy: by };
1531
+ return failedSoFar.has(m.component) ? { ...m, status: "failed" } : m;
1202
1532
  });
1203
1533
  return prReport({ ...reportBase, set, approval, members, waves, ...input });
1204
1534
  };
1205
1535
  // `--wave <k>` reached before an earlier wave applied: nothing runs, as `fan-out --wave` refuses.
1536
+ // #4291: an earlier member that failed, or was blocked by one that failed, counts as settled; it blocks its dependents here.
1206
1537
  const left = firstLeft();
1207
- if (only !== undefined && left > 0 && left < only) {
1538
+ const earlierWaiting = only !== undefined ? waves.find((w) => w.wave < only && w.status === "waiting" && w.components.some((name) => !completed.has(name))) : undefined;
1539
+ if (earlierWaiting) {
1540
+ return stop(report({
1541
+ status: "refused",
1542
+ refusal: "earlier-wave",
1543
+ 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.`,
1544
+ }), GATED_EXIT_CODE);
1545
+ }
1546
+ const settled = only !== undefined && left > 0 && left < only ? settleEarlierWaves(p.plan.waves, only, upstreams, outcomes()) : undefined;
1547
+ if (settled)
1548
+ for (const [name, o] of settled.outcomes)
1549
+ if (o.status === "blocked" && o.by)
1550
+ blockedSoFar.set(name, o.by);
1551
+ if (only !== undefined && left > 0 && left < only && (settled?.unsettled.length ?? 0) > 0) {
1208
1552
  return stop(report({
1209
1553
  status: "refused",
1210
1554
  refusal: "earlier-wave",
@@ -1213,6 +1557,7 @@ async function prApplyWaves(ctx, p, at) {
1213
1557
  }), 1);
1214
1558
  }
1215
1559
  let planned;
1560
+ let judged = { held: false };
1216
1561
  if (carry) {
1217
1562
  // What the earlier waves applied settles first (#4007), each in its own wave, as a resumed apply's does.
1218
1563
  p.plan.waves.forEach((names, i) => {
@@ -1231,7 +1576,11 @@ async function prApplyWaves(ctx, p, at) {
1231
1576
  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);
1232
1577
  }
1233
1578
  await storeChangeSetForGate(ctx, p, set.doc);
1234
- const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
1579
+ // #4294: the policy at base judges the whole change set, before the pull request's gate.
1580
+ judged = await judgePolicy(p, { stage: "apply", op, pr, set });
1581
+ if (judged.stop)
1582
+ return stop(report({ status: "refused", refusal: judged.stop.refusal, message: judged.stop.message }), judged.stop.code);
1583
+ const decided = await decideApplyGate(p, { op, pr, set, events, held: judged.held, ...(green ? { green } : {}) });
1235
1584
  approval = decided.approval;
1236
1585
  if (!decided.satisfied)
1237
1586
  return stop(report({ status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
@@ -1247,6 +1596,9 @@ async function prApplyWaves(ctx, p, at) {
1247
1596
  emitReviewStop(events, prReview.message);
1248
1597
  return stop(report({ status: "refused", refusal: prReview.refusal, message: prReview.message }), GATED_EXIT_CODE);
1249
1598
  }
1599
+ const unspent = await spendPolicy(op, judged, p.head);
1600
+ if (unspent)
1601
+ return stop(report({ status: "refused", refusal: "policy-denied", message: unspent }), 1);
1250
1602
  const recordAttempt = () => {
1251
1603
  if (!resumePath)
1252
1604
  return;
@@ -1276,10 +1628,31 @@ async function prApplyWaves(ctx, p, at) {
1276
1628
  recordAttempt();
1277
1629
  const from = only ?? (left || total + 1);
1278
1630
  const to = only ?? total;
1631
+ /** The last apply in this job that failed (#4291): its results give the exit code once every wave ran. */
1632
+ let lastFailure;
1633
+ let planFailedIn;
1279
1634
  for (let k = from; k <= to; k++) {
1280
- const wavePlan = prWavePlan(p.plan, p.components, k, [...completed]);
1281
- if (wavePlan.order.length === 0)
1635
+ // #4291: a component of this wave whose upstream failed, or was blocked by a failure, is blocked; the rest of the wave runs.
1636
+ const before = new Set(p.plan.waves.slice(0, k - 1).flat());
1637
+ const mine = leftIn(k);
1638
+ for (const name of mine) {
1639
+ failedSoFar.delete(name);
1640
+ blockedSoFar.delete(name);
1641
+ }
1642
+ const outcomesNow = outcomes();
1643
+ for (const [name, by] of blockedSoFar)
1644
+ outcomesNow.set(name, { status: "blocked", by });
1645
+ const blockedNow = blockedMembers(mine, upstreams, before, outcomesNow);
1646
+ for (const [name, by] of blockedNow)
1647
+ blockedSoFar.set(name, by);
1648
+ if (blockedNow.size > 0)
1649
+ 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." }));
1650
+ const wavePlan = prWavePlan(p.plan, p.components, k, [...completed, ...blockedNow.keys()]);
1651
+ if (wavePlan.order.length === 0) {
1652
+ if (blockedNow.size > 0)
1653
+ recordAttempt();
1282
1654
  continue;
1655
+ }
1283
1656
  const inThisWave = new Set(wavePlan.order);
1284
1657
  // Wave 1 of the apply that just planned every member applies those plans:
1285
1658
  // they are the approved set's. Every other wave is planned again, against
@@ -1296,19 +1669,25 @@ async function prApplyWaves(ctx, p, at) {
1296
1669
  }
1297
1670
  else {
1298
1671
  const notApplied = Object.fromEntries(Object.entries(approvedSet.outputs).filter(([name]) => !completed.has(name)));
1299
- const fresh = await planPrSet({
1672
+ const fresh = await hookedPlan(p, wavePlan.order, () => planPrSet({
1300
1673
  components: p.components,
1301
1674
  plan: wavePlan,
1302
1675
  registry: p.registry,
1303
1676
  env: p.env,
1304
1677
  seededOutputs: { ...p.seededOutputs, ...notApplied, ...outputsSoFar },
1305
1678
  ...(events ? { onRunEvent: (body) => events.emit(body) } : {}),
1306
- });
1679
+ }));
1307
1680
  if (fresh.failed) {
1308
- for (const m of fresh.members)
1681
+ for (const m of fresh.members) {
1309
1682
  after.set(m.member, m);
1683
+ if (m.status === "plan-failed")
1684
+ failedSoFar.add(m.component);
1685
+ }
1310
1686
  setWave({ wave: k, components: wavePlan.order, status: "plan-failed", approval: "pull-request", gate: p.gate, digest: approvedSet.doc.digest });
1311
- return stop(report({ status: "plan-failed", message: `A member of wave ${k} failed to plan, so nothing in wave ${k} was applied.` }), 1);
1687
+ // #4291: the later waves still run what does not depend on this wave.
1688
+ 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 + "." }));
1689
+ planFailedIn ?? (planFailedIn = k);
1690
+ continue;
1312
1691
  }
1313
1692
  const moved = waveNotCovered(approvedSet.doc, fresh.doc, approvedSet.members);
1314
1693
  set = fresh;
@@ -1357,6 +1736,12 @@ async function prApplyWaves(ctx, p, at) {
1357
1736
  entry = { wave: k, components: wavePlan.order, status: "applied", approval: "wave", gate, digest, moved, ...(waveApproval.approvedBy ? { approvedBy: waveApproval.approvedBy } : {}) };
1358
1737
  }
1359
1738
  }
1739
+ // #4288: the wave's apply-side hooks, around its apply.
1740
+ const waveBefore = runStageHook(p, "before-apply", wavePlan.order, undefined, `nothing in wave ${k} was applied`);
1741
+ if (waveBefore) {
1742
+ setWave({ ...entry, status: "failed" });
1743
+ return stop(report({ status: "failed", message: waveBefore }), 1);
1744
+ }
1360
1745
  const applied = await applyPrSet({
1361
1746
  components: p.components,
1362
1747
  plan: wavePlan,
@@ -1373,6 +1758,7 @@ async function prApplyWaves(ctx, p, at) {
1373
1758
  onComponentSettled: (settled, outputs) => {
1374
1759
  if (settled.status === "ok") {
1375
1760
  completed.add(settled.component);
1761
+ failedSoFar.delete(settled.component);
1376
1762
  if (inputsNow[settled.component] !== undefined)
1377
1763
  inputsSoFar[settled.component] = inputsNow[settled.component];
1378
1764
  if (outputs)
@@ -1392,13 +1778,28 @@ async function prApplyWaves(ctx, p, at) {
1392
1778
  if (outputs)
1393
1779
  outputsSoFar[name] = outputs;
1394
1780
  }
1395
- setWave({ ...entry, status: applied.status === "applied" ? "applied" : "failed" });
1781
+ 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`);
1782
+ setWave({ ...entry, status: applied.status === "applied" && !waveAfter ? "applied" : "failed" });
1396
1783
  await recordAutoReleasesForRun(applied.run.results, p.env, at.release.runId, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord), at.release.runOrigin, undefined, undefined, prAppliedDigests(p, applied.members));
1397
1784
  if (applied.status !== "applied") {
1398
- return stop(report({ status: "failed" }), failedRunExitCode(applied.run.results), applied.run.status === "gated" ? "gated" : undefined);
1785
+ // #4291: a failed member blocks only its dependents, so the later waves still run.
1786
+ lastFailure = { results: applied.run.results, gated: applied.run.status === "gated" };
1787
+ recordAttempt();
1788
+ continue;
1399
1789
  }
1790
+ if (waveAfter)
1791
+ return stop(report({ status: "failed", message: waveAfter }), 1);
1400
1792
  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(", ")}`));
1401
1793
  }
1794
+ // #4291: a failure, or a member it blocked, fails the job once every other member applied.
1795
+ const blockedHere = [...blockedSoFar.keys()].filter((name) => (only === undefined ? true : (p.plan.waves[only - 1] ?? []).includes(name)));
1796
+ if (lastFailure || planFailedIn !== undefined || blockedHere.length > 0) {
1797
+ const blockedLine = blockedSoFar.size > 0 ? ` Blocked: ${blockedWords(blockedSoFar)}.` : "";
1798
+ const message = planFailedIn !== undefined && !lastFailure
1799
+ ? `A member of wave ${planFailedIn} failed to plan, so nothing in wave ${planFailedIn} was applied.${blockedLine}`
1800
+ : blockedLine ? blockedLine.trim() : undefined;
1801
+ return stop(report({ status: "failed", ...(message ? { message } : {}) }), lastFailure ? failedRunExitCode(lastFailure.results) : 1, lastFailure?.gated ? "gated" : undefined);
1802
+ }
1402
1803
  const done = p.plan.order.every((name) => completed.has(name));
1403
1804
  if (green && done)
1404
1805
  await recordGreenApplied(p, green, approvedSet.doc.digest, approval, via);
@@ -1413,3 +1814,52 @@ function writeWaveChangeSet(ctx, wave, doc, upload) {
1413
1814
  writeFileSync(join(dir, name), body);
1414
1815
  upload?.file(name, body);
1415
1816
  }
1817
+ /**
1818
+ * chant components pr-post --pr <n> --head <sha> --env <env> --forge <kind> [--member <name>] [--output <dir>] [--result <result>] (#4295)
1819
+ *
1820
+ * Post the plan note and the `chant/plan` status from the report a plan job
1821
+ * wrote with `pr-plan --no-status`, in a job that runs none of the change's
1822
+ * code. The pull request, the head, the environment and the member come from
1823
+ * the flags, and a report that names others is refused (`../../pr-post.ts`).
1824
+ * It exits 0 once it has posted, whatever the plan said, so the plan job's
1825
+ * own result stays the one that fails the run.
1826
+ */
1827
+ export async function runComponentsPrPost(ctx) {
1828
+ const { args } = ctx;
1829
+ if (args.pr === undefined)
1830
+ return fail("pr-post needs the pull or merge request: --pr <n>");
1831
+ if (!args.head || !/^[0-9a-f]{40}$/.test(args.head))
1832
+ return fail("pr-post needs the full commit the plan job checked out: --head <sha>");
1833
+ if (!args.env)
1834
+ return fail("pr-post needs the environment the plan was for: --env <env>");
1835
+ if (!args.forge || !FORGE_KINDS.includes(args.forge))
1836
+ return fail(`pr-post posts to a forge: --forge ${FORGE_KINDS.join("|")}`);
1837
+ if (args.members && args.members.length > 1)
1838
+ return fail(`pr-post takes one --member, not ${args.members.join(", ")}`);
1839
+ let forge;
1840
+ try {
1841
+ forge = forgeFromEnv(args.forge);
1842
+ }
1843
+ catch (err) {
1844
+ return fail(err instanceof Error ? err.message : String(err));
1845
+ }
1846
+ const { postPlanReport } = await import("../../pr-post.js");
1847
+ const member = args.members?.[0];
1848
+ const posted = await postPlanReport({
1849
+ forge,
1850
+ dir: resolve(args.output ?? PR_REPORT_DIR),
1851
+ pr: args.pr,
1852
+ head: args.head,
1853
+ env: args.env,
1854
+ ...(member ? { member } : {}),
1855
+ ...(args.planResult ? { result: args.planResult } : {}),
1856
+ // #4289: the plan job could not ask the forge, so the note job does, from the Ops at the commit it checked out.
1857
+ overdue: () => overdueOps(forge, member),
1858
+ });
1859
+ for (const message of posted.warnings)
1860
+ console.error(formatWarning({ message }));
1861
+ if (posted.refused)
1862
+ console.error(formatWarning({ message: `the plan report was not used: ${posted.refused}` }));
1863
+ console.error(formatInfo(`pr-post: ${prStatusContext("plan", member)} ${posted.state}: ${posted.description}${posted.noted ? "; note posted" : ""}`));
1864
+ return 0;
1865
+ }