@intentius/chant 0.124.39 → 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 (337) 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/component-endpoint.d.ts +20 -0
  11. package/dist/cli/component-endpoint.d.ts.map +1 -0
  12. package/dist/cli/component-endpoint.js +36 -0
  13. package/dist/cli/handlers/approve.d.ts +2 -1
  14. package/dist/cli/handlers/approve.d.ts.map +1 -1
  15. package/dist/cli/handlers/approve.js +20 -6
  16. package/dist/cli/handlers/build.d.ts.map +1 -1
  17. package/dist/cli/handlers/build.js +15 -1
  18. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  19. package/dist/cli/handlers/fan-out.js +26 -4
  20. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  21. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  22. package/dist/cli/handlers/gate-resume.js +15 -2
  23. package/dist/cli/handlers/lock.d.ts +8 -0
  24. package/dist/cli/handlers/lock.d.ts.map +1 -0
  25. package/dist/cli/handlers/lock.js +197 -0
  26. package/dist/cli/handlers/pr-env.d.ts +34 -0
  27. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  28. package/dist/cli/handlers/pr-env.js +436 -0
  29. package/dist/cli/handlers/pr.d.ts +74 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +514 -58
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +16 -2
  34. package/dist/cli/handlers/query.d.ts +6 -0
  35. package/dist/cli/handlers/query.d.ts.map +1 -0
  36. package/dist/cli/handlers/query.js +80 -0
  37. package/dist/cli/handlers/run.d.ts +4 -1
  38. package/dist/cli/handlers/run.d.ts.map +1 -1
  39. package/dist/cli/handlers/run.js +17 -6
  40. package/dist/cli/main.d.ts.map +1 -1
  41. package/dist/cli/main.js +41 -2
  42. package/dist/cli/parse-args.d.ts.map +1 -1
  43. package/dist/cli/parse-args.js +31 -2
  44. package/dist/cli/registry.d.ts +12 -2
  45. package/dist/cli/registry.d.ts.map +1 -1
  46. package/dist/components/auto-release.d.ts +35 -3
  47. package/dist/components/auto-release.d.ts.map +1 -1
  48. package/dist/components/auto-release.js +29 -2
  49. package/dist/components/cli-support.d.ts +2 -0
  50. package/dist/components/cli-support.d.ts.map +1 -1
  51. package/dist/components/cli-support.js +2 -0
  52. package/dist/components/deploy-units.d.ts.map +1 -1
  53. package/dist/components/deploy-units.js +5 -0
  54. package/dist/components/fan-out-run.d.ts +28 -0
  55. package/dist/components/fan-out-run.d.ts.map +1 -1
  56. package/dist/components/fan-out-run.js +27 -3
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +72 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +96 -5
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts.map +1 -1
  70. package/dist/components/pr-run.js +5 -3
  71. package/dist/components/promote.d.ts.map +1 -1
  72. package/dist/components/promote.js +39 -16
  73. package/dist/gate-decision.d.ts +15 -2
  74. package/dist/gate-decision.d.ts.map +1 -1
  75. package/dist/gate-decision.js +17 -2
  76. package/dist/gate-policy.d.ts +140 -0
  77. package/dist/gate-policy.d.ts.map +1 -0
  78. package/dist/gate-policy.js +242 -0
  79. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  80. package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  86. package/dist/lifecycle/publish-declaration.js +4 -3
  87. package/dist/lifecycle/publish-npm.d.ts +2 -2
  88. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  89. package/dist/lifecycle/publish-npm.js +31 -8
  90. package/dist/lifecycle/query.d.ts +68 -0
  91. package/dist/lifecycle/query.d.ts.map +1 -0
  92. package/dist/lifecycle/query.js +246 -0
  93. package/dist/lifecycle/release-ledger.d.ts +31 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +19 -1
  96. package/dist/lifecycle/run-ledger.d.ts +13 -0
  97. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  98. package/dist/lifecycle/run-ledger.js +16 -0
  99. package/dist/lifecycle/status.d.ts +5 -1
  100. package/dist/lifecycle/status.d.ts.map +1 -1
  101. package/dist/lifecycle/status.js +10 -5
  102. package/dist/lifecycle/target-lock.d.ts +97 -0
  103. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  104. package/dist/lifecycle/target-lock.js +286 -0
  105. package/dist/live-endpoint.d.ts +18 -0
  106. package/dist/live-endpoint.d.ts.map +1 -1
  107. package/dist/live-endpoint.js +27 -0
  108. package/dist/op/activities/activity-contracts.d.ts +30 -0
  109. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  110. package/dist/op/activities/activity-contracts.js +26 -0
  111. package/dist/op/activities/index.d.ts +2 -0
  112. package/dist/op/activities/index.d.ts.map +1 -1
  113. package/dist/op/activities/index.js +4 -0
  114. package/dist/op/activities/review-agent.d.ts +137 -0
  115. package/dist/op/activities/review-agent.d.ts.map +1 -0
  116. package/dist/op/activities/review-agent.js +266 -0
  117. package/dist/op/builders.d.ts +9 -0
  118. package/dist/op/builders.d.ts.map +1 -1
  119. package/dist/op/builders.js +11 -0
  120. package/dist/op/cron.d.ts +8 -0
  121. package/dist/op/cron.d.ts.map +1 -1
  122. package/dist/op/cron.js +51 -0
  123. package/dist/op/gate-approval.d.ts +20 -13
  124. package/dist/op/gate-approval.d.ts.map +1 -1
  125. package/dist/op/gate-approval.js +13 -18
  126. package/dist/op/gate-quorum.d.ts +131 -0
  127. package/dist/op/gate-quorum.d.ts.map +1 -0
  128. package/dist/op/gate-quorum.js +183 -0
  129. package/dist/op/gate-resume.d.ts +4 -2
  130. package/dist/op/gate-resume.d.ts.map +1 -1
  131. package/dist/op/gate-resume.js +5 -4
  132. package/dist/op/gate-review.js +1 -1
  133. package/dist/op/gate.d.ts +32 -4
  134. package/dist/op/gate.d.ts.map +1 -1
  135. package/dist/op/gate.js +40 -24
  136. package/dist/op/index.d.ts +6 -4
  137. package/dist/op/index.d.ts.map +1 -1
  138. package/dist/op/index.js +4 -3
  139. package/dist/op/local-executor.d.ts.map +1 -1
  140. package/dist/op/local-executor.js +9 -3
  141. package/dist/op/op-waves-run.d.ts +62 -1
  142. package/dist/op/op-waves-run.d.ts.map +1 -1
  143. package/dist/op/op-waves-run.js +190 -11
  144. package/dist/op/op-waves.d.ts +55 -3
  145. package/dist/op/op-waves.d.ts.map +1 -1
  146. package/dist/op/op-waves.js +63 -8
  147. package/dist/op/schedule-overdue.d.ts +57 -0
  148. package/dist/op/schedule-overdue.d.ts.map +1 -0
  149. package/dist/op/schedule-overdue.js +138 -0
  150. package/dist/override-gate.d.ts +2 -2
  151. package/dist/override-gate.d.ts.map +1 -1
  152. package/dist/override-gate.js +1 -1
  153. package/dist/pr-forge.d.ts +36 -0
  154. package/dist/pr-forge.d.ts.map +1 -1
  155. package/dist/pr-forge.js +82 -0
  156. package/dist/pr-loop.d.ts +90 -1
  157. package/dist/pr-loop.d.ts.map +1 -1
  158. package/dist/pr-loop.js +77 -3
  159. package/dist/pr-post.d.ts +72 -0
  160. package/dist/pr-post.d.ts.map +1 -0
  161. package/dist/pr-post.js +134 -0
  162. package/dist/wave-blocked.d.ts +79 -0
  163. package/dist/wave-blocked.d.ts.map +1 -0
  164. package/dist/wave-blocked.js +245 -0
  165. package/dist/wave-engine.d.ts +6 -0
  166. package/dist/wave-engine.d.ts.map +1 -1
  167. package/dist/wave-engine.js +2 -0
  168. package/dist/wave-runtime.mjs +239 -239
  169. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  170. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  171. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-locks.d.ts +12 -0
  179. package/dist/workspace/ci-locks.d.ts.map +1 -0
  180. package/dist/workspace/ci-locks.js +6 -0
  181. package/dist/workspace/ci-run.d.ts +24 -1
  182. package/dist/workspace/ci-run.d.ts.map +1 -1
  183. package/dist/workspace/ci-run.js +85 -6
  184. package/dist/workspace/declaration.d.ts +63 -0
  185. package/dist/workspace/declaration.d.ts.map +1 -1
  186. package/dist/workspace/declaration.js +72 -1
  187. package/dist/workspace/declaration.schema.json +147 -0
  188. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  189. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  190. package/dist/workspace/identity-gate-rule.js +1 -1
  191. package/dist/workspace/identity.d.ts +1 -1
  192. package/dist/workspace/identity.d.ts.map +1 -1
  193. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  194. package/dist/workspace/lineage-upgrade.js +8 -5
  195. package/dist/workspace/member-pipeline.d.ts +6 -0
  196. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  197. package/dist/workspace/member-pipeline.js +23 -0
  198. package/dist/workspace/pr-report.schema.json +73 -2
  199. package/dist/workspace/security.d.ts.map +1 -1
  200. package/dist/workspace/security.js +4 -0
  201. package/dist/workspace/status-gates.d.ts +3 -2
  202. package/dist/workspace/status-gates.d.ts.map +1 -1
  203. package/dist/workspace/status-gates.js +8 -7
  204. package/dist/workspace/status.d.ts +3 -1
  205. package/dist/workspace/status.d.ts.map +1 -1
  206. package/dist/workspace/status.js +7 -3
  207. package/dist/workspace/status.schema.json +1 -0
  208. package/dist/workspace/trust/seal.d.ts +2 -2
  209. package/dist/workspace/trust/seal.d.ts.map +1 -1
  210. package/dist/workspace/trust/seal.js +2 -2
  211. package/package.json +1 -1
  212. package/src/__fixtures__/oidc-emulator.ts +118 -0
  213. package/src/ci-handover.test.ts +209 -0
  214. package/src/ci-handover.ts +237 -0
  215. package/src/ci-identity.test.ts +201 -0
  216. package/src/ci-identity.ts +310 -0
  217. package/src/cli/component-endpoint.ts +43 -0
  218. package/src/cli/handlers/approve.ts +18 -5
  219. package/src/cli/handlers/build.ts +15 -1
  220. package/src/cli/handlers/fan-out.test.ts +10 -0
  221. package/src/cli/handlers/fan-out.ts +29 -4
  222. package/src/cli/handlers/gate-resume.ts +16 -2
  223. package/src/cli/handlers/lock.test.ts +98 -0
  224. package/src/cli/handlers/lock.ts +202 -0
  225. package/src/cli/handlers/operator.test.ts +3 -1
  226. package/src/cli/handlers/pr-endpoint.test.ts +169 -0
  227. package/src/cli/handlers/pr-env.ts +462 -0
  228. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  229. package/src/cli/handlers/pr.test.ts +184 -0
  230. package/src/cli/handlers/pr.ts +508 -64
  231. package/src/cli/handlers/promote.ts +25 -2
  232. package/src/cli/handlers/query.ts +78 -0
  233. package/src/cli/handlers/run.test.ts +2 -1
  234. package/src/cli/handlers/run.ts +16 -5
  235. package/src/cli/main.ts +41 -2
  236. package/src/cli/parse-args.ts +23 -2
  237. package/src/cli/registry.ts +12 -2
  238. package/src/components/auto-release-infra.test.ts +131 -0
  239. package/src/components/auto-release.test.ts +40 -1
  240. package/src/components/auto-release.ts +50 -4
  241. package/src/components/cli-support.ts +4 -0
  242. package/src/components/deploy-units.ts +5 -0
  243. package/src/components/fan-out-run.ts +39 -2
  244. package/src/components/fan-out-waves.test.ts +45 -1
  245. package/src/components/pr-environment.test.ts +249 -0
  246. package/src/components/pr-environment.ts +538 -0
  247. package/src/components/pr-hooks.test.ts +118 -0
  248. package/src/components/pr-hooks.ts +157 -0
  249. package/src/components/pr-pipeline.ts +130 -5
  250. package/src/components/pr-policy.test.ts +145 -0
  251. package/src/components/pr-policy.ts +211 -0
  252. package/src/components/pr-run.ts +5 -2
  253. package/src/components/promote.test.ts +39 -0
  254. package/src/components/promote.ts +38 -14
  255. package/src/gate-decision-bundle.test.ts +4 -3
  256. package/src/gate-decision.ts +39 -2
  257. package/src/gate-policy-bundle.test.ts +28 -0
  258. package/src/gate-policy.test.ts +162 -0
  259. package/src/gate-policy.ts +298 -0
  260. package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
  261. package/src/lifecycle/gate-checkpoint.ts +1 -1
  262. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  263. package/src/lifecycle/publish-declaration.ts +4 -3
  264. package/src/lifecycle/publish-npm.ts +33 -8
  265. package/src/lifecycle/publish-targets.test.ts +36 -3
  266. package/src/lifecycle/query.test.ts +91 -0
  267. package/src/lifecycle/query.ts +315 -0
  268. package/src/lifecycle/release-ledger.ts +42 -1
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/status.test.ts +19 -0
  271. package/src/lifecycle/status.ts +14 -6
  272. package/src/lifecycle/target-lock.test.ts +195 -0
  273. package/src/lifecycle/target-lock.ts +347 -0
  274. package/src/live-endpoint.ts +31 -0
  275. package/src/op/activities/activity-contracts.ts +31 -0
  276. package/src/op/activities/index.ts +6 -0
  277. package/src/op/activities/review-agent.test.ts +240 -0
  278. package/src/op/activities/review-agent.ts +355 -0
  279. package/src/op/builders.ts +13 -0
  280. package/src/op/cron.ts +44 -0
  281. package/src/op/gate-alias.test.ts +2 -2
  282. package/src/op/gate-approval.test.ts +30 -9
  283. package/src/op/gate-approval.ts +23 -26
  284. package/src/op/gate-quorum.test.ts +139 -0
  285. package/src/op/gate-quorum.ts +248 -0
  286. package/src/op/gate-resume.ts +6 -3
  287. package/src/op/gate-review.ts +1 -1
  288. package/src/op/gate.ts +64 -23
  289. package/src/op/index.ts +13 -1
  290. package/src/op/local-executor.ts +9 -2
  291. package/src/op/op-waves-hook-targets.test.ts +126 -0
  292. package/src/op/op-waves-policy-hold.test.ts +66 -0
  293. package/src/op/op-waves-quorum.test.ts +101 -0
  294. package/src/op/op-waves-run.ts +233 -11
  295. package/src/op/op-waves.ts +83 -10
  296. package/src/op/schedule-overdue.test.ts +220 -0
  297. package/src/op/schedule-overdue.ts +161 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +114 -0
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +146 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/wave-blocked-bundle.test.ts +25 -0
  309. package/src/wave-blocked.test.ts +146 -0
  310. package/src/wave-blocked.ts +271 -0
  311. package/src/wave-engine.ts +8 -0
  312. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  313. package/src/workspace/ci-branches.test.ts +62 -0
  314. package/src/workspace/ci-branches.ts +79 -0
  315. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  316. package/src/workspace/ci-cloud-identity.ts +23 -0
  317. package/src/workspace/ci-locks.ts +16 -0
  318. package/src/workspace/ci-run-index.test.ts +2 -1
  319. package/src/workspace/ci-run.test.ts +133 -8
  320. package/src/workspace/ci-run.ts +92 -7
  321. package/src/workspace/declaration.schema.json +147 -0
  322. package/src/workspace/declaration.test.ts +76 -0
  323. package/src/workspace/declaration.ts +134 -2
  324. package/src/workspace/identity-gate-rule.ts +1 -1
  325. package/src/workspace/identity.ts +1 -1
  326. package/src/workspace/lineage-upgrade.ts +11 -8
  327. package/src/workspace/member-pipeline.test.ts +29 -0
  328. package/src/workspace/member-pipeline.ts +30 -0
  329. package/src/workspace/pr-report.schema.json +73 -2
  330. package/src/workspace/security.ts +4 -0
  331. package/src/workspace/status-gates.ts +9 -6
  332. package/src/workspace/status.schema.json +1 -0
  333. package/src/workspace/status.ts +11 -4
  334. package/src/workspace/trust/gate-seal.test.ts +1 -1
  335. package/src/workspace/trust/seal.ts +2 -2
  336. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  337. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -26,8 +26,10 @@
26
26
  * env vars, timestamp is a real `new Date()` taken at record time).
27
27
  */
28
28
  import { getHeadCommit, pushLifecycle, StaleLifecycleBranchError } from "../lifecycle/git";
29
- import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseAdmission, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
30
- import type { DriverStepRecord } from "./driver";
29
+ import { appendReleaseRecord, InvalidReleaseRecordError, type ReleaseAdmission, type ReleaseDigestKind, type ReleaseRecord, type RunOrigin } from "../lifecycle/release-ledger";
30
+ import { changeSetDigest } from "../change-set";
31
+ import type { DriverComponent, DriverStepRecord } from "./driver";
32
+ import { componentInputDigests } from "./fan-out-inputs";
31
33
 
32
34
  /**
33
35
  * A step output that represents a *promoted* artifact — a publish-family result
@@ -68,6 +70,32 @@ export function extractRunDigest(records: DriverStepRecord[]): string | undefine
68
70
  return found;
69
71
  }
70
72
 
73
+ /**
74
+ * What each component of a run applied, for the ones that publish no artifact
75
+ * (#4274). A component with planned members (`pr-apply`'s change set) gets its
76
+ * plan digest: the one member's `planDigest`, or with several the
77
+ * change-set digest over them, the way a set's digest is taken. Any other gets
78
+ * its deploy-input digest from `componentInputDigests`, computed on first use.
79
+ */
80
+ export function appliedDigestOf(input: {
81
+ /** Every component of the project, so a component's upstream digests count. */
82
+ components: DriverComponent[];
83
+ env: string;
84
+ projectRoot: string;
85
+ /** The planned members a pull request applied, when there were any. */
86
+ members?: ReadonlyArray<{ member: string; component: string; planDigest: string | null }>;
87
+ }): (component: string) => AppliedDigest | null {
88
+ let inputs: Record<string, string> | undefined;
89
+ return (component) => {
90
+ const planned = (input.members ?? []).filter((m): m is typeof m & { planDigest: string } => m.component === component && m.planDigest !== null);
91
+ if (planned.length === 1) return { digest: planned[0]!.planDigest, kind: "plan" };
92
+ if (planned.length > 1) return { digest: changeSetDigest(planned), kind: "plan" };
93
+ inputs ??= componentInputDigests(input.components, { projectRoot: input.projectRoot, env: input.env });
94
+ const digest = inputs[component];
95
+ return digest === undefined ? null : { digest, kind: "deploy-input" };
96
+ };
97
+ }
98
+
71
99
  /** Explicit reasons `maybeRecordAutoRelease` declined to write a record — never a thrown error, since "nothing to record" (or "opted out") is an expected, common outcome. */
72
100
  export type AutoReleaseSkipReason =
73
101
  | "opted-out"
@@ -109,6 +137,19 @@ export interface AutoReleaseRunInfo {
109
137
  * when the run published no artifact. Set only with `releaseDigest`.
110
138
  */
111
139
  templatesDigest?: string;
140
+ /**
141
+ * What the run applied, for a component that published no artifact (#4274):
142
+ * its plan digest, or its deploy-input digest when nothing planned. Recorded
143
+ * as `digest` with `digestKind`, after an artifact digest and
144
+ * `templatesDigest`, so an infrastructure-only component has a record too.
145
+ */
146
+ appliedDigest?: AppliedDigest;
147
+ }
148
+
149
+ /** A digest naming what a component applied, not an artifact (#4274). */
150
+ export interface AppliedDigest {
151
+ digest: string;
152
+ kind: Extract<ReleaseDigestKind, "plan" | "deploy-input">;
112
153
  }
113
154
 
114
155
  /** Opt-out + field-override knobs for auto-release recording, threaded from CLI flags/config (#597: "opt-out-able (flag/config), documented default"). */
@@ -142,7 +183,8 @@ function resolveActor(override?: string): string | undefined {
142
183
  * - `run.success` false — defensive; callers should not reach here on failure.
143
184
  * - no digest found in the run's records — a component with no publish step
144
185
  * has nothing to bind a release record to, unless the run was made under a
145
- * release that records its templates (`templatesDigest`, #4222).
186
+ * release that records its templates (`templatesDigest`, #4222) or the
187
+ * caller names what it applied (`appliedDigest`, #4274).
146
188
  * - no resolvable actor — mirrors `runComponentsReleaseRecord`'s hard
147
189
  * requirement; an unattributed record defeats the ledger's purpose.
148
190
  *
@@ -166,7 +208,10 @@ export async function maybeRecordAutoRelease(
166
208
  return { recorded: false, reason: "run-not-successful" };
167
209
  }
168
210
 
169
- const digest = run.digest ?? extractRunDigest(run.records ?? []) ?? (run.releaseDigest ? run.templatesDigest : undefined);
211
+ const artifact = run.digest ?? extractRunDigest(run.records ?? []);
212
+ const templates = artifact === undefined && run.releaseDigest ? run.templatesDigest : undefined;
213
+ const digest = artifact ?? templates ?? run.appliedDigest?.digest;
214
+ const digestKind: ReleaseDigestKind | undefined = artifact !== undefined ? undefined : templates !== undefined ? "templates" : run.appliedDigest?.kind;
170
215
  if (!digest) {
171
216
  return { recorded: false, reason: "no-digest", detail: `component "${run.component}" published no digest-bearing artifact` };
172
217
  }
@@ -201,6 +246,7 @@ export async function maybeRecordAutoRelease(
201
246
  ...(run.profileOverride ? { profileOverride: run.profileOverride } : {}),
202
247
  ...(run.inputDigest ? { inputDigest: run.inputDigest } : {}),
203
248
  ...(run.releaseDigest ? { releaseDigest: run.releaseDigest } : {}),
249
+ ...(digestKind ? { digestKind } : {}),
204
250
  },
205
251
  { cwd: options.cwd },
206
252
  );
@@ -489,6 +489,8 @@ export interface RunComponentsResult {
489
489
  run?: DriverRunResult;
490
490
  /** Component names actually dispatched to the driver, in run order. */
491
491
  selected: string[];
492
+ /** The components dispatched, as they ran (#4274): what a component's deploy-input digest is taken over. */
493
+ components?: DriverComponent[];
492
494
  error?: string;
493
495
  /** Set when the run stopped at a `gate` nobody has approved (#2119): the component it stopped at and the pending fact recorded for it. */
494
496
  gated?: {
@@ -718,6 +720,7 @@ export async function runComponents(
718
720
  success: run.status === "ok",
719
721
  run,
720
722
  selected,
723
+ components: resolvedTargets,
721
724
  buildParams: options.buildParams,
722
725
  ...(run.status === "gated" && run.gate
723
726
  ? {
@@ -785,6 +788,7 @@ export async function runComponents(
785
788
  success: componentResult.ok,
786
789
  run,
787
790
  selected,
791
+ components: resolvedTargets,
788
792
  buildParams: options.buildParams,
789
793
  ...(componentResult.status === "gated" && componentResult.gate
790
794
  ? {
@@ -59,6 +59,11 @@ export const DEPLOY_UNIT_RULES: readonly DeployUnitRule[] = [
59
59
  // A Terraform-family root (#3049). Its unit is the root's name in
60
60
  // `terraform.roots`, which is also the member a gated wave's digest names.
61
61
  { kind: "terraform-apply", field: "root", lexicon: "terraform" },
62
+ // An ARM template applied into one resource group (#4273). Its unit is the group.
63
+ { kind: "arm-deploy", field: "resourceGroup", lexicon: "azure" },
64
+ // A Config Connector manifest applied over REST (#4273). Its unit is the
65
+ // step's `stack`, the member its plan names.
66
+ { kind: "gcp-apply", field: "stack", lexicon: "gcp" },
62
67
  ];
63
68
 
64
69
  /** One resolved unit a component's deploy composition targets. */
@@ -129,6 +129,19 @@ export interface FanOutWaveGate {
129
129
  * do not exist yet. A later wave is left for its own job.
130
130
  */
131
131
  only?: number;
132
+ /**
133
+ * With `only` (#4291): members of earlier waves the record says failed to
134
+ * plan or to apply. Each blocks its dependents in this wave and names
135
+ * itself as what blocked them; the rest of the wave runs. An earlier
136
+ * member that neither applied nor failed still refuses the run.
137
+ */
138
+ failedEarlier?: readonly string[];
139
+ /**
140
+ * With `only` (#4291): earlier waves the record says stopped at their gate.
141
+ * A member left in one of them stops this wave too ({@link EarlierWaveWaitingError}),
142
+ * since a gate nobody approved stops every wave after it.
143
+ */
144
+ waitingEarlier?: ReadonlyArray<{ wave: number; gate: string; components: readonly string[] }>;
132
145
  }
133
146
 
134
147
  /** Thrown when `FanOutWaveGate.only` names a wave whose predecessors have not all applied. */
@@ -146,6 +159,22 @@ export class EarlierWaveNotAppliedError extends Error {
146
159
  }
147
160
  }
148
161
 
162
+ /**
163
+ * Thrown when `FanOutWaveGate.only` names a wave after one that waits at its
164
+ * gate (#4291): nothing in this wave runs until that gate is approved and
165
+ * its job runs again. The CLI exits 3, as the waiting wave's job does.
166
+ */
167
+ export class EarlierWaveWaitingError extends Error {
168
+ constructor(
169
+ readonly wave: number,
170
+ readonly earlier: number,
171
+ readonly gate: string,
172
+ ) {
173
+ super(`wave ${wave} does not run yet: wave ${earlier} waits at its gate ${gate}. Approve it and run wave ${earlier} again, then this wave.`);
174
+ this.name = "EarlierWaveWaitingError";
175
+ }
176
+ }
177
+
149
178
  /** The single gate over a whole fan-out. Omit it to run without one. */
150
179
  export interface FanOutGate {
151
180
  /** The name `chant approve <op> <gate>` takes — the fan-out's name, not a component's. */
@@ -518,7 +547,13 @@ async function runGatedWaves(
518
547
  if (!wave) return { end: "stop" };
519
548
  const runnable = wave.filter((name) => left.has(name) && !blockedBy.has(name));
520
549
  if (waveGate.only !== undefined && waveNum < waveGate.only) {
521
- if (runnable.length > 0) throw new EarlierWaveNotAppliedError(waveGate.only, waveNum, runnable);
550
+ // #4291: an earlier member that failed blocks only its dependents; one that waits at its gate stops this wave.
551
+ const failedBefore = new Set(waveGate.failedEarlier ?? []);
552
+ const open = runnable.filter((name) => !failedBefore.has(name));
553
+ const waiting = waveGate.waitingEarlier?.find((w) => w.wave === waveNum && open.some((name) => w.components.includes(name)));
554
+ if (waiting) throw new EarlierWaveWaitingError(waveGate.only, waveNum, waiting.gate);
555
+ if (open.length > 0) throw new EarlierWaveNotAppliedError(waveGate.only, waveNum, open);
556
+ for (const name of runnable) markBlocked(name);
522
557
  return { end: "next" };
523
558
  }
524
559
  if (waveGate.only !== undefined && waveNum > waveGate.only) return { end: "stop" };
@@ -664,7 +699,9 @@ async function runGatedWaves(
664
699
  .sort(([a], [b]) => a.localeCompare(b))
665
700
  .map(([component, by]) => ({ component, reason: "blocked" as const, blockedBy: by }));
666
701
 
667
- const status: FanOutRunResult["status"] = failed.length > 0 ? "fail" : gatedFact ? "gated" : "ok";
702
+ // #4291: a member of this job's wave that an earlier job's failure blocked fails the job, though the rest of the wave applied.
703
+ const blockedHere = waveGate.only !== undefined && (plan.waves[waveGate.only - 1] ?? []).some((name) => blockedBy.has(name));
704
+ const status: FanOutRunResult["status"] = failed.length > 0 || blockedHere ? "fail" : gatedFact ? "gated" : "ok";
668
705
  emit?.({ type: "run-settle", status: status === "fail" ? "failed" : status, durationMs: null });
669
706
 
670
707
  return {
@@ -11,7 +11,7 @@ import { describe, expect, test } from "vitest";
11
11
  import { CapabilityRegistry, type DeployContext } from "./capability";
12
12
  import { memoryGateLedgerPort } from "../op/gate";
13
13
  import { planFanOut } from "./fan-out";
14
- import { EarlierWaveNotAppliedError, runFanOut, type FanOutRunOptions } from "./fan-out-run";
14
+ import { EarlierWaveNotAppliedError, EarlierWaveWaitingError, runFanOut, type FanOutRunOptions } from "./fan-out-run";
15
15
  import { computePlanDigest } from "../lifecycle/plan-digest";
16
16
  import type { DriverComponent } from "./driver";
17
17
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
@@ -354,6 +354,50 @@ describe("one wave per CI job", () => {
354
354
  });
355
355
  });
356
356
 
357
+ describe("a failed member blocks only its dependents across wave jobs (#4291)", () => {
358
+ /** net, then a and b, then app (reads a) and web (reads b). */
359
+ const WIDE: DriverComponent[] = [...ESTATE, root("web", ["b"])];
360
+
361
+ test("wave 3's job blocks what an earlier job's failure holds, and plans the rest", async () => {
362
+ const w = world();
363
+ const plan = planFanOut({ components: WIDE, changed: ["net"] });
364
+ const result = await runFanOut(plan, WIDE, w.registry, {
365
+ ...waveOptions(ledger()),
366
+ waveGate: { op: "fan-out", gate: "release", only: 3, failedEarlier: ["a"] },
367
+ progress: { completed: ["net", "b"] },
368
+ });
369
+ expect(result.blocked).toEqual([{ component: "app", reason: "blocked", blockedBy: "a" }]);
370
+ expect(w.planned.map((p) => p.root)).toEqual(["web"]);
371
+ // A member of the job's own wave was blocked, so the job fails though web went on to its gate.
372
+ expect(result.status).toBe("fail");
373
+ });
374
+
375
+ test("an earlier member that neither applied nor failed still refuses the job", async () => {
376
+ const w = world();
377
+ const plan = planFanOut({ components: WIDE, changed: ["net"] });
378
+ await expect(
379
+ runFanOut(plan, WIDE, w.registry, {
380
+ ...waveOptions(ledger()),
381
+ waveGate: { op: "fan-out", gate: "release", only: 3, failedEarlier: ["a"] },
382
+ progress: { completed: ["net"] },
383
+ }),
384
+ ).rejects.toThrow(EarlierWaveNotAppliedError);
385
+ });
386
+
387
+ test("an earlier wave that waits at its gate stops the job", async () => {
388
+ const w = world();
389
+ const plan = planFanOut({ components: WIDE, changed: ["net"] });
390
+ await expect(
391
+ runFanOut(plan, WIDE, w.registry, {
392
+ ...waveOptions(ledger()),
393
+ waveGate: { op: "fan-out", gate: "release", only: 3, waitingEarlier: [{ wave: 2, gate: "release-wave-2", components: ["a", "b"] }] },
394
+ progress: { completed: ["net"] },
395
+ }),
396
+ ).rejects.toThrow(EarlierWaveWaitingError);
397
+ expect(w.planned).toEqual([]);
398
+ });
399
+ });
400
+
357
401
  describe("both modes", () => {
358
402
  test("a run takes one gate over the set or one per wave, not both", async () => {
359
403
  const w = world();
@@ -0,0 +1,249 @@
1
+ /**
2
+ * A pull request's environment (#4298): what it derives from the template,
3
+ * the records that say which environments stand, and the ownership check a
4
+ * teardown runs before it deletes anything.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import type { ChantConfig } from "../config";
9
+ import type { TeardownPlan, TeardownReport } from "../lifecycle/teardown";
10
+ import type { ObservationLexicon, TeardownCandidate } from "../lexicon";
11
+ import {
12
+ acceptHandedRecord,
13
+ derivePrEnvironment,
14
+ handedPrEnvReport,
15
+ expired,
16
+ isPrEnvironmentName,
17
+ livePrEnvironments,
18
+ ownedEntries,
19
+ parsePrEnvRecords,
20
+ prEnvStatusState,
21
+ releaseTargetRefusal,
22
+ renderPrEnvNote,
23
+ teardownComplete,
24
+ tearDownPrEnvironment,
25
+ teardownRefusal,
26
+ ttlMs,
27
+ type PrEnvRecord,
28
+ } from "./pr-environment";
29
+
30
+ const CONFIG: ChantConfig = {
31
+ environments: ["local", { name: "staging", endpoint: "http://localhost:4566" }, "prod", "pr-*"],
32
+ ownership: { stack: "shop", env: { param: "env" } },
33
+ buildParams: { env: { type: "string", default: "local" } },
34
+ } as ChantConfig;
35
+
36
+ function up(over: Partial<PrEnvRecord> = {}): PrEnvRecord {
37
+ return {
38
+ version: 1,
39
+ kind: "up",
40
+ pr: 7,
41
+ env: "pr-7",
42
+ template: "staging",
43
+ marker: { stack: "shop", env: "pr-7" },
44
+ commit: "a".repeat(40),
45
+ components: ["app"],
46
+ planDigest: "sha256:aa",
47
+ result: "applied",
48
+ by: "octo",
49
+ timestamp: "2026-10-22T10:00:00.000Z",
50
+ expiresAt: "2026-10-25T10:00:00.000Z",
51
+ releaseTarget: false,
52
+ ...over,
53
+ };
54
+ }
55
+
56
+ describe("deriving a pull request's environment", () => {
57
+ test("pr-<n> takes the template's endpoint and the marker env follows the build parameter", () => {
58
+ const d = derivePrEnvironment(CONFIG, "staging", 7);
59
+ expect(d).toMatchObject({ env: "pr-7", template: "staging", stack: "shop", param: "env", endpoint: "http://localhost:4566" });
60
+ if ("refusal" in d) throw new Error(d.refusal);
61
+ expect(d.environments?.[0]).toEqual({ name: "pr-7", endpoint: "http://localhost:4566" });
62
+ });
63
+
64
+ test("a pr-* entry's own endpoint wins over the template's", () => {
65
+ const config = { ...CONFIG, environments: ["staging", { name: "pr-*", endpoint: "http://localhost:9000" }] } as ChantConfig;
66
+ expect(derivePrEnvironment(config, "staging", 7)).toMatchObject({ endpoint: "http://localhost:9000" });
67
+ });
68
+
69
+ test("refuses when the resources could not be told apart from the template's", () => {
70
+ const off = derivePrEnvironment({ ...CONFIG, ownership: undefined } as ChantConfig, "staging", 7);
71
+ expect(off).toMatchObject({ refusal: expect.stringContaining("no ownership.stack") });
72
+ const literal = derivePrEnvironment({ ...CONFIG, ownership: { stack: "shop", env: "staging" } } as ChantConfig, "staging", 7);
73
+ expect(literal).toMatchObject({ refusal: expect.stringContaining('the literal "staging"') });
74
+ const unset = derivePrEnvironment({ ...CONFIG, ownership: { stack: "shop" } } as ChantConfig, "staging", 7);
75
+ expect(unset).toMatchObject({ refusal: expect.stringContaining("ownership.env is unset") });
76
+ });
77
+
78
+ test("refuses a template the config does not declare, and a name it would not take", () => {
79
+ expect(derivePrEnvironment(CONFIG, "qa", 7)).toMatchObject({ refusal: expect.stringContaining("do not declare") });
80
+ const noPattern = { ...CONFIG, environments: ["staging", "prod"] } as ChantConfig;
81
+ expect(derivePrEnvironment(noPattern, "staging", 7)).toMatchObject({ refusal: expect.stringContaining('add "pr-*"') });
82
+ const literal = { ...CONFIG, environments: ["staging", "pr-7", "pr-*"] } as ChantConfig;
83
+ expect(derivePrEnvironment(literal, "staging", 7)).toMatchObject({ refusal: expect.stringContaining("declared outright") });
84
+ });
85
+ });
86
+
87
+ describe("the records", () => {
88
+ test("an up makes an environment live, a destroying down ends it, an incomplete down leaves it", () => {
89
+ const records = [up(), up({ pr: 8, env: "pr-8", marker: { stack: "shop", env: "pr-8" } }), { ...up(), kind: "down" as const, result: "destroyed" as const }];
90
+ const live = livePrEnvironments(records);
91
+ expect([...live.keys()]).toEqual(["pr-8"]);
92
+ const incomplete = livePrEnvironments([up(), { ...up(), kind: "down", result: "incomplete" }]);
93
+ expect(incomplete.get("pr-7")?.teardownIncomplete).toBe(true);
94
+ });
95
+
96
+ test("a later up refreshes the expiry and keeps every component it ever applied", () => {
97
+ const live = livePrEnvironments([up(), up({ components: ["db"], expiresAt: "2026-10-26T10:00:00.000Z" })]).get("pr-7")!;
98
+ expect(live.components).toEqual(["app", "db"]);
99
+ expect(live.expiresAt).toBe("2026-10-26T10:00:00.000Z");
100
+ expect(expired(live, new Date("2026-10-26T10:00:00.000Z"))).toBe(true);
101
+ expect(expired(live, new Date("2026-10-25T10:00:00.000Z"))).toBe(false);
102
+ });
103
+
104
+ test("malformed lines are skipped", () => {
105
+ expect(parsePrEnvRecords(`not json\n${JSON.stringify(up())}\n{"version":2}\n`)).toHaveLength(1);
106
+ });
107
+ });
108
+
109
+ describe("the ownership check on teardown", () => {
110
+ const live = livePrEnvironments([up()]).get("pr-7")!;
111
+
112
+ test("passes for the pull request's own environment", () => {
113
+ expect(teardownRefusal(7, live, CONFIG.environments)).toBeUndefined();
114
+ });
115
+
116
+ test("refuses another pull request's record, a marker of another environment, and a declared environment", () => {
117
+ expect(teardownRefusal(8, live, CONFIG.environments)).toContain("not 8");
118
+ expect(teardownRefusal(7, { ...live, marker: { stack: "shop", env: "staging" } }, CONFIG.environments)).toContain("another environment's resources");
119
+ expect(teardownRefusal(7, live, ["staging", "pr-7"])).toContain("declared outright");
120
+ });
121
+
122
+ test("drops every planned entry whose marker is not exactly the environment's", () => {
123
+ const plan: TeardownPlan = {
124
+ environment: "pr-7",
125
+ stack: "shop",
126
+ entries: [
127
+ { lexicon: "k8s", name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "pr-7" } },
128
+ { lexicon: "k8s", name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "staging" } },
129
+ { lexicon: "k8s", name: "other", type: "K8s::Core::Service", marker: { stack: "other", env: "pr-7" } },
130
+ ],
131
+ holes: [],
132
+ skipped: [],
133
+ };
134
+ const owned = ownedEntries(plan, { stack: "shop", env: "pr-7" });
135
+ expect(owned.plan.entries.map((e) => e.marker)).toEqual([{ stack: "shop", env: "pr-7" }]);
136
+ expect(owned.dropped).toHaveLength(2);
137
+ });
138
+
139
+ test("a teardown with a failure, a skip or a hole is not complete", () => {
140
+ const plan: TeardownPlan = { environment: "pr-7", stack: "shop", entries: [], holes: [], skipped: [] };
141
+ const report = (outcome: TeardownReport["outcomes"][number]["outcome"]): TeardownReport => ({
142
+ environment: "pr-7",
143
+ stack: "shop",
144
+ plan,
145
+ outcomes: [{ lexicon: "k8s", name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "pr-7" }, outcome }],
146
+ unimplemented: [],
147
+ });
148
+ expect(teardownComplete(report("deleted"))).toBe(true);
149
+ expect(teardownComplete(report("retained"))).toBe(true);
150
+ expect(teardownComplete(report("failed"))).toBe(false);
151
+ expect(teardownComplete(report("skipped"))).toBe(false);
152
+ expect(teardownComplete({ ...report("deleted"), plan: { ...plan, holes: [{ lexicon: "k8s", name: "*", reason: "read-failed" }] } })).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe("tearing down through the lexicons", () => {
157
+ const live = livePrEnvironments([up()]).get("pr-7")!;
158
+
159
+ /** A lexicon whose enumeration also returns another environment's resource, and records what it was asked to delete. */
160
+ function lexicon(): { plugin: ObservationLexicon; asked: { environment?: string; deleted: TeardownCandidate[] } } {
161
+ const asked: { environment?: string; deleted: TeardownCandidate[] } = { deleted: [] };
162
+ const plugin = {
163
+ name: "k8s",
164
+ async teardownOwned({ environment }: { environment: string }) {
165
+ asked.environment = environment;
166
+ return {
167
+ candidates: [
168
+ { name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "pr-7" } },
169
+ { name: "web", type: "K8s::Apps::Deployment", marker: { stack: "shop", env: "staging" } },
170
+ ],
171
+ };
172
+ },
173
+ async executeTeardown({ candidates }: { candidates: TeardownCandidate[] }) {
174
+ asked.deleted.push(...candidates);
175
+ return { outcomes: candidates.map((c) => ({ name: c.name, outcome: "deleted" as const })) };
176
+ },
177
+ } as unknown as ObservationLexicon;
178
+ return { plugin, asked };
179
+ }
180
+
181
+ test("deletes only what carries pr-<n>'s marker", async () => {
182
+ const { plugin, asked } = lexicon();
183
+ const torn = await tearDownPrEnvironment({ pr: 7, live, config: CONFIG, plugins: [plugin] });
184
+ if ("refusal" in torn) throw new Error(torn.refusal);
185
+ expect(asked.environment).toBe("pr-7");
186
+ expect(asked.deleted.map((c) => c.marker)).toEqual([{ stack: "shop", env: "pr-7" }]);
187
+ expect(teardownComplete(torn.report)).toBe(true);
188
+ });
189
+
190
+ test("a refused teardown reads and deletes nothing", async () => {
191
+ const { plugin, asked } = lexicon();
192
+ const torn = await tearDownPrEnvironment({ pr: 7, live: { ...live, marker: { stack: "shop", env: "staging" } }, config: CONFIG, plugins: [plugin] });
193
+ expect(torn).toMatchObject({ refusal: expect.stringContaining("nothing was torn down") });
194
+ expect(asked.environment).toBeUndefined();
195
+ expect(asked.deleted).toEqual([]);
196
+ });
197
+ });
198
+
199
+ describe("not a release target", () => {
200
+ test("a pr-<n> name is refused as a release's source or target, and nothing else is", () => {
201
+ expect(isPrEnvironmentName("pr-42")).toBe(true);
202
+ expect(isPrEnvironmentName("pr-")).toBe(false);
203
+ expect(isPrEnvironmentName("prod")).toBe(false);
204
+ expect(releaseTargetRefusal("pr-42")).toContain("never a release target");
205
+ expect(releaseTargetRefusal("staging")).toBeUndefined();
206
+ });
207
+
208
+ test("the note says so", () => {
209
+ const note = renderPrEnvNote({ stage: "up", pr: 7, env: "pr-7", template: "staging", head: "a".repeat(40), op: "pr-7", gate: "pr-env", digest: "sha256:aa", status: "waiting", components: [] });
210
+ expect(note).toContain("<!-- chant-pr-env:pr-7 -->");
211
+ expect(note).toContain("chant approve pr-7 pr-env --plan sha256:aa");
212
+ expect(note).toContain("is not a release target");
213
+ expect(prEnvStatusState({ status: "waiting" })).toBe("pending");
214
+ });
215
+ });
216
+
217
+ test("ttlMs reads minutes, hours and days", () => {
218
+ expect(ttlMs("90m")).toBe(5_400_000);
219
+ expect(ttlMs("72h")).toBe(259_200_000);
220
+ expect(ttlMs("7d")).toBe(604_800_000);
221
+ expect(ttlMs("1w")).toBeUndefined();
222
+ });
223
+
224
+ describe("what env-up hands on is checked as data (#4295)", () => {
225
+ const check = { pr: 7, head: "c".repeat(40), template: "staging", ttlMs: 3_600_000, now: new Date("2026-10-22T12:00:00.000Z"), by: "github-actions" };
226
+
227
+ test("a record for this pull request is kept with the post job's own commit, actor and expiry", () => {
228
+ const accepted = acceptHandedRecord(up({ expiresAt: "2099-01-01T00:00:00.000Z", commit: "d".repeat(40), by: "mallory" }), check);
229
+ if ("refusal" in accepted) throw new Error(accepted.refusal);
230
+ expect(accepted.record).toMatchObject({ pr: 7, env: "pr-7", commit: check.head, by: "github-actions", expiresAt: "2026-10-22T13:00:00.000Z", marker: { stack: "shop", env: "pr-7" }, releaseTarget: false });
231
+ });
232
+
233
+ test("a record for another environment, marker, template or member is refused", () => {
234
+ expect(acceptHandedRecord(up({ pr: 8, env: "pr-8" }), check)).toMatchObject({ refusal: expect.stringContaining("not pr-7") });
235
+ expect(acceptHandedRecord(up({ marker: { stack: "shop", env: "staging" } }), check)).toMatchObject({ refusal: expect.stringContaining("marker env") });
236
+ expect(acceptHandedRecord(up({ template: "prod" }), check)).toMatchObject({ refusal: expect.stringContaining("template") });
237
+ expect(acceptHandedRecord(up({ member: "api" }), check)).toMatchObject({ refusal: expect.stringContaining("member") });
238
+ expect(acceptHandedRecord({ ...up(), kind: "down", result: "destroyed" }, check)).toMatchObject({ refusal: expect.stringContaining("down record") });
239
+ expect(acceptHandedRecord("nonsense", check)).toMatchObject({ refusal: expect.stringContaining("does not read") });
240
+ });
241
+
242
+ test("a report for another environment is refused, and a kept one takes the post job's head and op", () => {
243
+ expect(handedPrEnvReport({ stage: "up", pr: 8, env: "pr-8", status: "applied" }, { pr: 7, head: "h" })).toMatchObject({ refusal: expect.any(String) });
244
+ expect(handedPrEnvReport(undefined, { pr: 7, head: "h" })).toMatchObject({ refusal: "The env-up job handed on no report." });
245
+ const kept = handedPrEnvReport({ stage: "up", pr: 7, env: "pr-7", status: "waiting", op: "pr-99", head: "x", digest: "sha256:aa", components: [{ component: "app", status: "planned", create: 1 }] }, { pr: 7, head: "h" });
246
+ if ("refusal" in kept) throw new Error(kept.refusal);
247
+ expect(kept.report).toMatchObject({ head: "h", op: "pr-7", status: "waiting", components: [{ component: "app", create: 1, delete: 0 }] });
248
+ });
249
+ });