@intentius/chant 0.124.40 → 0.124.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +15 -1
  15. package/dist/cli/handlers/fan-out.js +16 -3
  16. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  17. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  18. package/dist/cli/handlers/gate-resume.js +15 -2
  19. package/dist/cli/handlers/lock.d.ts +8 -0
  20. package/dist/cli/handlers/lock.d.ts.map +1 -0
  21. package/dist/cli/handlers/lock.js +197 -0
  22. package/dist/cli/handlers/pr-env.d.ts +34 -0
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  24. package/dist/cli/handlers/pr-env.js +436 -0
  25. package/dist/cli/handlers/pr.d.ts +74 -0
  26. package/dist/cli/handlers/pr.d.ts.map +1 -1
  27. package/dist/cli/handlers/pr.js +503 -53
  28. package/dist/cli/handlers/promote.d.ts.map +1 -1
  29. package/dist/cli/handlers/promote.js +9 -0
  30. package/dist/cli/handlers/query.d.ts +6 -0
  31. package/dist/cli/handlers/query.d.ts.map +1 -0
  32. package/dist/cli/handlers/query.js +80 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/main.js +41 -2
  35. package/dist/cli/parse-args.d.ts.map +1 -1
  36. package/dist/cli/parse-args.js +31 -2
  37. package/dist/cli/registry.d.ts +12 -2
  38. package/dist/cli/registry.d.ts.map +1 -1
  39. package/dist/components/fan-out-run.d.ts +28 -0
  40. package/dist/components/fan-out-run.d.ts.map +1 -1
  41. package/dist/components/fan-out-run.js +27 -3
  42. package/dist/components/pr-environment.d.ts +298 -0
  43. package/dist/components/pr-environment.d.ts.map +1 -0
  44. package/dist/components/pr-environment.js +406 -0
  45. package/dist/components/pr-hooks.d.ts +66 -0
  46. package/dist/components/pr-hooks.d.ts.map +1 -0
  47. package/dist/components/pr-hooks.js +119 -0
  48. package/dist/components/pr-pipeline.d.ts +72 -2
  49. package/dist/components/pr-pipeline.d.ts.map +1 -1
  50. package/dist/components/pr-pipeline.js +96 -5
  51. package/dist/components/pr-policy.d.ts +109 -0
  52. package/dist/components/pr-policy.d.ts.map +1 -0
  53. package/dist/components/pr-policy.js +150 -0
  54. package/dist/components/pr-run.d.ts.map +1 -1
  55. package/dist/components/pr-run.js +5 -3
  56. package/dist/gate-decision.d.ts +15 -2
  57. package/dist/gate-decision.d.ts.map +1 -1
  58. package/dist/gate-decision.js +17 -2
  59. package/dist/gate-policy.d.ts +140 -0
  60. package/dist/gate-policy.d.ts.map +1 -0
  61. package/dist/gate-policy.js +242 -0
  62. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  63. package/dist/lifecycle/gate-checkpoint.js +1 -1
  64. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  65. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  66. package/dist/lifecycle/query.d.ts +68 -0
  67. package/dist/lifecycle/query.d.ts.map +1 -0
  68. package/dist/lifecycle/query.js +246 -0
  69. package/dist/lifecycle/run-ledger.d.ts +13 -0
  70. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  71. package/dist/lifecycle/run-ledger.js +16 -0
  72. package/dist/lifecycle/target-lock.d.ts +97 -0
  73. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  74. package/dist/lifecycle/target-lock.js +286 -0
  75. package/dist/op/activities/activity-contracts.d.ts +30 -0
  76. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  77. package/dist/op/activities/activity-contracts.js +26 -0
  78. package/dist/op/activities/index.d.ts +2 -0
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/op/activities/index.js +4 -0
  81. package/dist/op/activities/review-agent.d.ts +137 -0
  82. package/dist/op/activities/review-agent.d.ts.map +1 -0
  83. package/dist/op/activities/review-agent.js +266 -0
  84. package/dist/op/builders.d.ts +9 -0
  85. package/dist/op/builders.d.ts.map +1 -1
  86. package/dist/op/builders.js +11 -0
  87. package/dist/op/cron.d.ts +8 -0
  88. package/dist/op/cron.d.ts.map +1 -1
  89. package/dist/op/cron.js +51 -0
  90. package/dist/op/gate-approval.d.ts +20 -13
  91. package/dist/op/gate-approval.d.ts.map +1 -1
  92. package/dist/op/gate-approval.js +13 -18
  93. package/dist/op/gate-quorum.d.ts +131 -0
  94. package/dist/op/gate-quorum.d.ts.map +1 -0
  95. package/dist/op/gate-quorum.js +183 -0
  96. package/dist/op/gate-resume.d.ts +4 -2
  97. package/dist/op/gate-resume.d.ts.map +1 -1
  98. package/dist/op/gate-resume.js +5 -4
  99. package/dist/op/gate-review.js +1 -1
  100. package/dist/op/gate.d.ts +32 -4
  101. package/dist/op/gate.d.ts.map +1 -1
  102. package/dist/op/gate.js +40 -24
  103. package/dist/op/index.d.ts +6 -4
  104. package/dist/op/index.d.ts.map +1 -1
  105. package/dist/op/index.js +4 -3
  106. package/dist/op/local-executor.d.ts.map +1 -1
  107. package/dist/op/local-executor.js +9 -3
  108. package/dist/op/op-waves-run.d.ts +62 -1
  109. package/dist/op/op-waves-run.d.ts.map +1 -1
  110. package/dist/op/op-waves-run.js +190 -11
  111. package/dist/op/op-waves.d.ts +55 -3
  112. package/dist/op/op-waves.d.ts.map +1 -1
  113. package/dist/op/op-waves.js +63 -8
  114. package/dist/op/schedule-overdue.d.ts +57 -0
  115. package/dist/op/schedule-overdue.d.ts.map +1 -0
  116. package/dist/op/schedule-overdue.js +138 -0
  117. package/dist/override-gate.d.ts +2 -2
  118. package/dist/override-gate.d.ts.map +1 -1
  119. package/dist/override-gate.js +1 -1
  120. package/dist/pr-forge.d.ts +36 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +82 -0
  123. package/dist/pr-loop.d.ts +90 -1
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +77 -3
  126. package/dist/pr-post.d.ts +72 -0
  127. package/dist/pr-post.d.ts.map +1 -0
  128. package/dist/pr-post.js +134 -0
  129. package/dist/wave-blocked.d.ts +79 -0
  130. package/dist/wave-blocked.d.ts.map +1 -0
  131. package/dist/wave-blocked.js +245 -0
  132. package/dist/wave-engine.d.ts +6 -0
  133. package/dist/wave-engine.d.ts.map +1 -1
  134. package/dist/wave-engine.js +2 -0
  135. package/dist/wave-runtime.mjs +242 -242
  136. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  137. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  138. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  139. package/dist/workspace/ci-branches.d.ts +51 -0
  140. package/dist/workspace/ci-branches.d.ts.map +1 -0
  141. package/dist/workspace/ci-branches.js +69 -0
  142. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  143. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  144. package/dist/workspace/ci-cloud-identity.js +24 -0
  145. package/dist/workspace/ci-locks.d.ts +12 -0
  146. package/dist/workspace/ci-locks.d.ts.map +1 -0
  147. package/dist/workspace/ci-locks.js +6 -0
  148. package/dist/workspace/ci-run.d.ts +17 -1
  149. package/dist/workspace/ci-run.d.ts.map +1 -1
  150. package/dist/workspace/ci-run.js +64 -3
  151. package/dist/workspace/declaration.d.ts +63 -0
  152. package/dist/workspace/declaration.d.ts.map +1 -1
  153. package/dist/workspace/declaration.js +72 -1
  154. package/dist/workspace/declaration.schema.json +147 -0
  155. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  156. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  157. package/dist/workspace/identity-gate-rule.js +1 -1
  158. package/dist/workspace/identity.d.ts +1 -1
  159. package/dist/workspace/identity.d.ts.map +1 -1
  160. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  161. package/dist/workspace/lineage-upgrade.js +8 -5
  162. package/dist/workspace/member-pipeline.d.ts +6 -0
  163. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  164. package/dist/workspace/member-pipeline.js +23 -0
  165. package/dist/workspace/pr-report.schema.json +73 -2
  166. package/dist/workspace/security.d.ts.map +1 -1
  167. package/dist/workspace/security.js +4 -0
  168. package/dist/workspace/status-gates.d.ts +3 -2
  169. package/dist/workspace/status-gates.d.ts.map +1 -1
  170. package/dist/workspace/status-gates.js +8 -7
  171. package/dist/workspace/status.d.ts.map +1 -1
  172. package/dist/workspace/status.js +4 -1
  173. package/dist/workspace/trust/seal.d.ts +2 -2
  174. package/dist/workspace/trust/seal.d.ts.map +1 -1
  175. package/dist/workspace/trust/seal.js +2 -2
  176. package/package.json +1 -1
  177. package/src/__fixtures__/oidc-emulator.ts +118 -0
  178. package/src/ci-handover.test.ts +209 -0
  179. package/src/ci-handover.ts +237 -0
  180. package/src/ci-identity.test.ts +201 -0
  181. package/src/ci-identity.ts +310 -0
  182. package/src/cli/handlers/approve.ts +18 -5
  183. package/src/cli/handlers/build.ts +15 -1
  184. package/src/cli/handlers/fan-out.ts +17 -4
  185. package/src/cli/handlers/gate-resume.ts +16 -2
  186. package/src/cli/handlers/lock.test.ts +98 -0
  187. package/src/cli/handlers/lock.ts +202 -0
  188. package/src/cli/handlers/operator.test.ts +3 -1
  189. package/src/cli/handlers/pr-env.ts +462 -0
  190. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  191. package/src/cli/handlers/pr.test.ts +184 -0
  192. package/src/cli/handlers/pr.ts +496 -59
  193. package/src/cli/handlers/promote.ts +9 -0
  194. package/src/cli/handlers/query.ts +78 -0
  195. package/src/cli/main.ts +41 -2
  196. package/src/cli/parse-args.ts +23 -2
  197. package/src/cli/registry.ts +12 -2
  198. package/src/components/fan-out-run.ts +39 -2
  199. package/src/components/fan-out-waves.test.ts +45 -1
  200. package/src/components/pr-environment.test.ts +249 -0
  201. package/src/components/pr-environment.ts +538 -0
  202. package/src/components/pr-hooks.test.ts +118 -0
  203. package/src/components/pr-hooks.ts +157 -0
  204. package/src/components/pr-pipeline.ts +130 -5
  205. package/src/components/pr-policy.test.ts +145 -0
  206. package/src/components/pr-policy.ts +211 -0
  207. package/src/components/pr-run.ts +5 -2
  208. package/src/gate-decision-bundle.test.ts +4 -3
  209. package/src/gate-decision.ts +39 -2
  210. package/src/gate-policy-bundle.test.ts +28 -0
  211. package/src/gate-policy.test.ts +162 -0
  212. package/src/gate-policy.ts +298 -0
  213. package/src/lifecycle/gate-checkpoint.ts +1 -1
  214. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  215. package/src/lifecycle/query.test.ts +91 -0
  216. package/src/lifecycle/query.ts +315 -0
  217. package/src/lifecycle/run-ledger.ts +18 -0
  218. package/src/lifecycle/target-lock.test.ts +195 -0
  219. package/src/lifecycle/target-lock.ts +347 -0
  220. package/src/op/activities/activity-contracts.ts +31 -0
  221. package/src/op/activities/index.ts +6 -0
  222. package/src/op/activities/review-agent.test.ts +240 -0
  223. package/src/op/activities/review-agent.ts +355 -0
  224. package/src/op/builders.ts +13 -0
  225. package/src/op/cron.ts +44 -0
  226. package/src/op/gate-alias.test.ts +2 -2
  227. package/src/op/gate-approval.test.ts +30 -9
  228. package/src/op/gate-approval.ts +23 -26
  229. package/src/op/gate-quorum.test.ts +139 -0
  230. package/src/op/gate-quorum.ts +248 -0
  231. package/src/op/gate-resume.ts +6 -3
  232. package/src/op/gate-review.ts +1 -1
  233. package/src/op/gate.ts +64 -23
  234. package/src/op/index.ts +13 -1
  235. package/src/op/local-executor.ts +9 -2
  236. package/src/op/op-waves-hook-targets.test.ts +126 -0
  237. package/src/op/op-waves-policy-hold.test.ts +66 -0
  238. package/src/op/op-waves-quorum.test.ts +101 -0
  239. package/src/op/op-waves-run.ts +233 -11
  240. package/src/op/op-waves.ts +83 -10
  241. package/src/op/schedule-overdue.test.ts +220 -0
  242. package/src/op/schedule-overdue.ts +161 -0
  243. package/src/override-gate-bundle.test.ts +1 -1
  244. package/src/override-gate.ts +2 -2
  245. package/src/pr-forge-bundle.test.ts +2 -2
  246. package/src/pr-forge.test.ts +33 -0
  247. package/src/pr-forge.ts +114 -0
  248. package/src/pr-loop-bundle.test.ts +2 -2
  249. package/src/pr-loop.test.ts +58 -0
  250. package/src/pr-loop.ts +146 -4
  251. package/src/pr-post.test.ts +141 -0
  252. package/src/pr-post.ts +159 -0
  253. package/src/wave-blocked-bundle.test.ts +25 -0
  254. package/src/wave-blocked.test.ts +146 -0
  255. package/src/wave-blocked.ts +271 -0
  256. package/src/wave-engine.ts +8 -0
  257. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  258. package/src/workspace/ci-branches.test.ts +62 -0
  259. package/src/workspace/ci-branches.ts +79 -0
  260. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  261. package/src/workspace/ci-cloud-identity.ts +23 -0
  262. package/src/workspace/ci-locks.ts +16 -0
  263. package/src/workspace/ci-run-index.test.ts +2 -1
  264. package/src/workspace/ci-run.test.ts +107 -8
  265. package/src/workspace/ci-run.ts +72 -4
  266. package/src/workspace/declaration.schema.json +147 -0
  267. package/src/workspace/declaration.test.ts +76 -0
  268. package/src/workspace/declaration.ts +134 -2
  269. package/src/workspace/identity-gate-rule.ts +1 -1
  270. package/src/workspace/identity.ts +1 -1
  271. package/src/workspace/lineage-upgrade.ts +11 -8
  272. package/src/workspace/member-pipeline.test.ts +29 -0
  273. package/src/workspace/member-pipeline.ts +30 -0
  274. package/src/workspace/pr-report.schema.json +73 -2
  275. package/src/workspace/security.ts +4 -0
  276. package/src/workspace/status-gates.ts +9 -6
  277. package/src/workspace/status.ts +4 -1
  278. package/src/workspace/trust/gate-seal.test.ts +1 -1
  279. package/src/workspace/trust/seal.ts +2 -2
  280. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  281. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * An environment per open pull request (#4298): `ci.prEnvironment` in the
3
+ * workspace declaration names a template environment, and each open pull
4
+ * request gets `pr-<number>`, derived from it.
5
+ *
6
+ * chant components pr-env up --base <ref> --pr <n>
7
+ * From the pull request's job. Plans the components the change reaches
8
+ * into `pr-<n>` and applies them once an approval stands for that plan
9
+ * (`chant approve pr-<n> pr-env --plan <digest>`). The environment
10
+ * takes the template's declared endpoint unless a `pr-*` entry declares
11
+ * its own. Every resource it creates carries the ownership marker
12
+ * `{ stack, env: "pr-<n>" }`, since the build parameter that
13
+ * `ownership.env` reads is set to the environment's name. The record
14
+ * of the apply goes to `_pr-envs/records.jsonl` on chant/lifecycle.
15
+ * chant components pr-env down --pr <n> [--reason closed|expired]
16
+ * From the job a closed pull request starts. Tears down what carries
17
+ * the marker the record names, and nothing else (the ownership check,
18
+ * {@link teardownRefusal} and {@link ownedEntries}).
19
+ * chant components pr-env sweep
20
+ * From a schedule. Tears down each live environment whose TTL passed,
21
+ * or whose pull request the forge says is closed or merged. GitLab
22
+ * starts no pipeline when a merge request closes, so there the sweep is
23
+ * how a closed merge request loses its environment.
24
+ *
25
+ * The settings are read from the declaration at base, never from the pull
26
+ * request, so a change cannot name another template or a longer TTL.
27
+ *
28
+ * A pull request's environment is not a release target (ws-104, ws-105):
29
+ * releases come from green commits, and `components promote` refuses to
30
+ * promote to or from one ({@link releaseTargetRefusal}). Its record says
31
+ * `releaseTarget: false`.
32
+ *
33
+ * terragucci's `ephemeral.ts` gives each copy a suffixed Terraform state key.
34
+ * That part is Terraform's own and stays there; chant keys ownership on the
35
+ * marker its lexicons stamp and read back (`../lifecycle/teardown.ts`).
36
+ */
37
+ import { environmentEndpoint, environmentName, isEnvironmentPattern, isOwnershipParamRef, matchesDeclaredEnvironment, resolveOwnershipStack } from "../config.js";
38
+ import { executeTeardown, planTeardown } from "../lifecycle/teardown.js";
39
+ import { applyLiveEndpoint } from "../live-endpoint.js";
40
+ /** The gate a pull request's environment waits on, under the pull request's op `pr-<n>`. */
41
+ export const PR_ENV_GATE = "pr-env";
42
+ /** Where the records live on chant/lifecycle, under the member's ledger prefix. */
43
+ export const PR_ENV_LEDGER_DIR = "_pr-envs";
44
+ export const PR_ENV_LEDGER_FILE = "records.jsonl";
45
+ /** Where `pr-env` writes its report and note unless `--output` says. */
46
+ export { PR_ENV_REPORT_DIR } from "./pr-pipeline.js";
47
+ /** The file `pr-env up --no-push` writes its record to under `--output`, for `pr-env post` (#4295). */
48
+ export const PR_ENV_RECORD_FILE = "pr-env-record.json";
49
+ /** The commit status context the up and down jobs set. */
50
+ export const PR_ENV_STATUS_CONTEXT = "chant/pr-env";
51
+ /** The variable a component's build reads the template's name from. */
52
+ export const PR_ENV_TEMPLATE_VAR = "CHANT_ENV_TEMPLATE";
53
+ /** Exit codes: done, failed or refused, and waiting on an approval. */
54
+ export const PR_ENV_EXIT = { done: 0, failed: 1, waiting: 3 };
55
+ /** The environment of pull request `pr`. */
56
+ export function prEnvironmentName(pr) {
57
+ return `pr-${pr}`;
58
+ }
59
+ const PR_ENV_NAME = /^pr-[1-9][0-9]*$/;
60
+ /** Whether `name` is a pull request's environment name, `pr-<n>`. */
61
+ export function isPrEnvironmentName(name) {
62
+ return PR_ENV_NAME.test(name);
63
+ }
64
+ /** The hidden line that marks the environment's note on the pull request. */
65
+ export function prEnvNoteMarker(env, member) {
66
+ return member ? `<!-- chant-pr-env:${env}:${member} -->` : `<!-- chant-pr-env:${env} -->`;
67
+ }
68
+ /** The status context, per member in a workspace. */
69
+ export function prEnvStatusContext(member) {
70
+ return member ? `${PR_ENV_STATUS_CONTEXT}/${member}` : PR_ENV_STATUS_CONTEXT;
71
+ }
72
+ /** The approve command for an environment's plan. */
73
+ export function prEnvApproveCommand(op, digest, approver = "<you>") {
74
+ return `chant approve ${op} ${PR_ENV_GATE} --plan ${digest} --approver ${approver} --sign`;
75
+ }
76
+ /**
77
+ * Why `env` cannot be a release's source or target, or undefined when it
78
+ * can. A pull request's environment never is (ws-104: releases come from
79
+ * green commits).
80
+ */
81
+ export function releaseTargetRefusal(env) {
82
+ if (!isPrEnvironmentName(env))
83
+ return undefined;
84
+ return `${env} is a pull request's environment (ci.prEnvironment), which is never a release target: releases come from green commits (ws-104), and pr-env down removes it when the pull request closes`;
85
+ }
86
+ /** A TTL of the form `90m`, `72h` or `7d`, in milliseconds. Undefined for any other form. */
87
+ export function ttlMs(ttl) {
88
+ const m = /^([1-9][0-9]*)(m|h|d)$/.exec(ttl);
89
+ if (!m)
90
+ return undefined;
91
+ const unit = m[2] === "m" ? 60000 : m[2] === "h" ? 3600000 : 86400000;
92
+ return Number(m[1]) * unit;
93
+ }
94
+ /**
95
+ * Derive pull request `pr`'s environment from `template` and the project's
96
+ * config. Refused when the resources it creates could not be told apart
97
+ * from the template's: marking is off, or `ownership.env` is a literal (every
98
+ * resource would carry that literal) or unset. Refused too when the config
99
+ * would not take the name, or does not declare the template.
100
+ */
101
+ export function derivePrEnvironment(config, template, pr) {
102
+ const env = prEnvironmentName(pr);
103
+ const stack = resolveOwnershipStack(config);
104
+ if (stack === undefined) {
105
+ return { refusal: `${env}: the project declares no ownership.stack, so nothing it creates would carry a marker, and pr-env down could not tell its resources from any other; set ownership: { stack: "<name>", env: { param: "env" } } in chant.config.ts` };
106
+ }
107
+ const own = config.ownership?.env;
108
+ if (!isOwnershipParamRef(own)) {
109
+ return {
110
+ refusal: own === undefined
111
+ ? `${env}: ownership.env is unset, so the marker would name no environment and pr-env down could not select ${env}'s resources; set ownership.env to { param: "env" }`
112
+ : `${env}: ownership.env is the literal ${JSON.stringify(own)}, so ${env}'s resources would carry ${JSON.stringify(own)}'s marker; set ownership.env to { param: "env" } so the marker follows the environment`,
113
+ };
114
+ }
115
+ if (!config.buildParams?.[own.param]) {
116
+ return { refusal: `${env}: ownership.env reads build parameter "${own.param}", which chant.config.ts's buildParams does not declare` };
117
+ }
118
+ const declared = config.environments;
119
+ if (declared && declared.length > 0) {
120
+ if (!declared.some((e) => environmentName(e) === template)) {
121
+ return { refusal: `${env}: ci.prEnvironment.template is ${template}, which chant.config.ts's environments do not declare` };
122
+ }
123
+ if (!matchesDeclaredEnvironment(declared, env)) {
124
+ return { refusal: `${env}: chant.config.ts's environments do not cover ${env}; add "pr-*" to environments` };
125
+ }
126
+ // A literal pr-<n> is a declared environment of its own, maybe one a release targets.
127
+ if (declared.some((e) => environmentName(e) === env)) {
128
+ return { refusal: `${env} is declared outright in chant.config.ts's environments, so it is not a pull request's to create or tear down; declare "pr-*" instead` };
129
+ }
130
+ }
131
+ const endpoint = environmentEndpoint(declared, env) ?? environmentEndpoint(declared, template);
132
+ const environments = declared ? [endpoint ? { name: env, endpoint } : env, ...declared] : undefined;
133
+ return { env, template, stack, param: own.param, environments, ...(endpoint ? { endpoint } : {}) };
134
+ }
135
+ /** The records of a records.jsonl text, oldest first. Malformed lines are skipped. */
136
+ export function parsePrEnvRecords(text) {
137
+ const out = [];
138
+ for (const line of (text ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
139
+ try {
140
+ const r = JSON.parse(line);
141
+ if (r.version === 1 && (r.kind === "up" || r.kind === "down") && typeof r.pr === "number" && typeof r.env === "string" && r.marker && typeof r.marker.stack === "string")
142
+ out.push(r);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+ /**
151
+ * The environments that stand, by name, from the records in order: an up
152
+ * (applied or failed part way) makes or refreshes one, a down that
153
+ * destroyed everything ends it, and any other down leaves it live.
154
+ */
155
+ export function livePrEnvironments(records) {
156
+ const live = new Map();
157
+ for (const r of records) {
158
+ if (r.kind === "up") {
159
+ const was = live.get(r.env);
160
+ live.set(r.env, {
161
+ pr: r.pr,
162
+ env: r.env,
163
+ ...(r.member ? { member: r.member } : {}),
164
+ template: r.template,
165
+ marker: { ...r.marker },
166
+ commit: r.commit,
167
+ components: [...new Set([...(was?.components ?? []), ...r.components])],
168
+ applied: r.timestamp,
169
+ expiresAt: r.expiresAt ?? r.timestamp,
170
+ });
171
+ }
172
+ else if (r.result === "destroyed") {
173
+ live.delete(r.env);
174
+ }
175
+ else {
176
+ const was = live.get(r.env);
177
+ if (was)
178
+ live.set(r.env, { ...was, teardownIncomplete: true });
179
+ }
180
+ }
181
+ return live;
182
+ }
183
+ /** Whether a live environment's TTL passed at `now`. */
184
+ export function expired(env, now) {
185
+ return Date.parse(env.expiresAt) <= now.getTime();
186
+ }
187
+ // ── the ownership check ────────────────────────────────────────────────────
188
+ /**
189
+ * Why pull request `pr`'s environment must not be torn down as `live`
190
+ * says, or undefined when it may. The record must name `pr-<n>` for this
191
+ * pull request, its marker's env must be that name, and the config must not
192
+ * declare the name outright (a declared environment belongs to the project,
193
+ * not to a pull request).
194
+ */
195
+ export function teardownRefusal(pr, live, declared) {
196
+ const env = prEnvironmentName(pr);
197
+ if (live.env !== env || live.pr !== pr)
198
+ return `the record for ${live.env} names pull request ${live.pr}, not ${pr}, so pr-env down --pr ${pr} does not tear it down`;
199
+ if (!isPrEnvironmentName(live.marker.env) || live.marker.env !== env) {
200
+ return `the record for ${env} names the marker env ${JSON.stringify(live.marker.env)}, which is not ${env}, so its teardown would select another environment's resources; nothing was torn down`;
201
+ }
202
+ const literal = (declared ?? []).find((e) => !isEnvironmentPattern(environmentName(e)) && environmentName(e) === env);
203
+ if (literal)
204
+ return `${env} is declared outright in chant.config.ts's environments, so it is not a pull request's to tear down; nothing was torn down`;
205
+ return undefined;
206
+ }
207
+ /**
208
+ * The entries of a teardown plan the environment owns: those whose marker
209
+ * is exactly `marker`. `planTeardown` already selects on the marker; this
210
+ * is the second check, so a lexicon that returned another's resources cannot
211
+ * widen what pr-env down deletes. `dropped` counts what was left out.
212
+ */
213
+ export function ownedEntries(plan, marker) {
214
+ const owns = (e) => e.marker?.stack === marker.stack && e.marker?.env === marker.env;
215
+ return { plan: { ...plan, entries: plan.entries.filter(owns) }, dropped: plan.entries.filter((e) => !owns(e)) };
216
+ }
217
+ /**
218
+ * Tear down pull request `pr`'s environment with the ownership check: refused
219
+ * before any read when {@link teardownRefusal} says so; otherwise the live
220
+ * resources under the record's marker are planned, every entry whose marker
221
+ * is not exactly `{ stack, env: "pr-<n>" }` is dropped ({@link ownedEntries}),
222
+ * and only the rest go to each lexicon's teardown. The endpoint is the one
223
+ * the environment was applied at: its own `pr-*` entry's, else the template's.
224
+ */
225
+ export async function tearDownPrEnvironment(input) {
226
+ const { pr, live, config, plugins } = input;
227
+ const refusal = teardownRefusal(pr, live, config.environments);
228
+ if (refusal)
229
+ return { refusal };
230
+ const env = prEnvironmentName(pr);
231
+ const marker = { stack: live.marker.stack, env };
232
+ const endpoint = environmentEndpoint(config.environments, env) ?? environmentEndpoint(config.environments, live.template);
233
+ const applied = applyLiveEndpoint(endpoint ? [{ name: env, endpoint }] : [], env, plugins.filter((x) => x.teardownOwned || x.describeResources || x.executeTeardown));
234
+ if (applied.notice)
235
+ input.warn?.(applied.notice);
236
+ const deployedStacks = (config.stacks ?? []).map((s) => ({ name: s.name, ...(s.region ? { region: s.region } : {}) }));
237
+ try {
238
+ const planned = await planTeardown({ environment: env, stack: marker.stack, plugins, ...(deployedStacks.length > 0 ? { deployedStacks } : {}) });
239
+ const owned = ownedEntries(planned, marker);
240
+ const report = await executeTeardown({ environment: env, stack: marker.stack, plugins, plan: owned.plan, ...(deployedStacks.length > 0 ? { deployedStacks } : {}) });
241
+ return { report, dropped: owned.dropped.length, marker };
242
+ }
243
+ finally {
244
+ applied.restore();
245
+ }
246
+ }
247
+ /** A teardown report's counts for the record. */
248
+ export function teardownCounts(report) {
249
+ const n = (o) => report.outcomes.filter((x) => x.outcome === o).length;
250
+ return { deleted: n("deleted"), failed: n("failed"), retained: n("retained"), notPrunable: n("not-prunable"), skipped: n("skipped"), holes: report.plan.holes.length };
251
+ }
252
+ /**
253
+ * Whether a teardown left the environment clean: nothing failed, nothing was
254
+ * skipped for a lexicon with no teardown execution, and the plan had no
255
+ * holes. A retained resource (a lexicon's deliberate keep) does not count
256
+ * against it.
257
+ */
258
+ export function teardownComplete(report) {
259
+ const c = teardownCounts(report);
260
+ return c.failed === 0 && c.skipped === 0 && c.holes === 0;
261
+ }
262
+ /**
263
+ * The record `pr-env up --no-push` handed on, checked as data: the up job ran
264
+ * the change's code, so what it wrote is the change's. It must be an `up`
265
+ * line for this pull request's `pr-<n>`, with that marker env, the template
266
+ * the base declares, and string components. The commit, the actor, the run
267
+ * and the expiry are the post job's own, so a change cannot keep its
268
+ * environment past the TTL its base set. Refused otherwise, and kept nowhere.
269
+ */
270
+ export function acceptHandedRecord(raw, check) {
271
+ const env = prEnvironmentName(check.pr);
272
+ const [r] = parsePrEnvRecords(typeof raw === "object" && raw !== null ? JSON.stringify(raw) : "");
273
+ if (!r)
274
+ return { refusal: "The env-up job handed on a record that does not read, so it was not kept." };
275
+ const why = r.kind !== "up" ? `is a ${r.kind} record`
276
+ : r.pr !== check.pr || r.env !== env ? `names ${r.env} of pull request ${r.pr}, not ${env}`
277
+ : r.marker.env !== env ? `names the marker env ${JSON.stringify(r.marker.env)}, not ${env}`
278
+ : r.template !== check.template ? `names the template ${r.template}, and the base declares ${check.template}`
279
+ : (r.member ?? undefined) !== check.member ? `names the member ${r.member ?? "(none)"}`
280
+ : !Array.isArray(r.components) || r.components.some((c) => typeof c !== "string") ? "lists components that are not names"
281
+ : r.result !== "applied" && r.result !== "failed" ? `has the result ${r.result}`
282
+ : undefined;
283
+ if (why)
284
+ return { refusal: `The env-up job handed on a record that ${why}, so it was not kept.` };
285
+ return {
286
+ record: {
287
+ version: 1,
288
+ kind: "up",
289
+ pr: check.pr,
290
+ env,
291
+ ...(check.member ? { member: check.member } : {}),
292
+ template: check.template,
293
+ marker: { stack: r.marker.stack, env },
294
+ commit: check.head,
295
+ components: [...r.components],
296
+ planDigest: typeof r.planDigest === "string" ? r.planDigest : "",
297
+ result: r.result,
298
+ by: check.by,
299
+ timestamp: check.now.toISOString(),
300
+ expiresAt: new Date(check.now.getTime() + check.ttlMs).toISOString(),
301
+ ...(Array.isArray(r.approvedBy) ? { approvedBy: r.approvedBy.filter((a) => typeof a === "string") } : {}),
302
+ releaseTarget: false,
303
+ ...(check.runUrl ? { runUrl: check.runUrl } : {}),
304
+ },
305
+ };
306
+ }
307
+ /**
308
+ * The report `pr-env up` handed on, checked as data before the post job
309
+ * renders a note from it: an `up` report for this pull request's `pr-<n>`.
310
+ * Its head is the post job's own.
311
+ */
312
+ export function handedPrEnvReport(raw, check) {
313
+ const env = prEnvironmentName(check.pr);
314
+ if (typeof raw !== "object" || raw === null)
315
+ return { refusal: "The env-up job handed on no report." };
316
+ const r = raw;
317
+ if (r.stage !== "up" || r.pr !== check.pr || r.env !== env)
318
+ return { refusal: `The env-up job handed on a report that is not ${env}'s.` };
319
+ const statuses = ["applied", "nothing", "waiting", "changed", "plan-failed", "failed", "refused"];
320
+ if (!r.status || !statuses.includes(r.status))
321
+ return { refusal: "The env-up job handed on a report with no status it could have." };
322
+ const str = (v) => (typeof v === "string" ? v : "");
323
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
324
+ return {
325
+ report: {
326
+ stage: "up",
327
+ pr: check.pr,
328
+ env,
329
+ ...(check.member ? { member: check.member } : {}),
330
+ template: str(r.template),
331
+ head: check.head,
332
+ op: check.member ? `pr-${check.pr}-${check.member}` : `pr-${check.pr}`,
333
+ gate: PR_ENV_GATE,
334
+ digest: str(r.digest),
335
+ status: r.status,
336
+ ...(typeof r.message === "string" ? { message: r.message } : {}),
337
+ ...(typeof r.endpoint === "string" ? { endpoint: r.endpoint } : {}),
338
+ components: (Array.isArray(r.components) ? r.components : []).map((c) => ({ component: str(c?.component), status: str(c?.status), create: num(c?.create), update: num(c?.update), replace: num(c?.replace), delete: num(c?.delete) })),
339
+ ...(Array.isArray(r.approvedBy) ? { approvedBy: r.approvedBy.filter((a) => typeof a === "string") } : {}),
340
+ },
341
+ };
342
+ }
343
+ const code = (s) => "`" + s.replaceAll("`", "'") + "`";
344
+ /** The environment's note on the pull request: one per environment, updated in place. */
345
+ export function renderPrEnvNote(r, approver = "<you>") {
346
+ const out = [prEnvNoteMarker(r.env, r.member), `### chant pull request environment ${code(r.env)}${r.member ? ` in member ${code(r.member)}` : ""}`, ""];
347
+ const from = `derived from ${code(r.template)}${r.endpoint ? ` at ${code(r.endpoint)}` : ""}`;
348
+ switch (r.status) {
349
+ case "applied":
350
+ out.push(`Applied ${r.components.length} component${r.components.length === 1 ? "" : "s"} at ${code(r.head.slice(0, 12))}, ${from}. It expires ${r.expiresAt ?? "?"}, and closing the pull request tears it down before then.`);
351
+ break;
352
+ case "nothing":
353
+ out.push(`Nothing this change touches is deployed by a component, so ${code(r.env)} gets nothing.`);
354
+ break;
355
+ case "waiting":
356
+ case "changed":
357
+ out.push(r.status === "changed" ? `An approval stands for another plan of ${code(r.env)}; the plan changed since, so nothing applied.` : `Planned ${code(r.env)}, ${from}. It waits for an approval of its plan; nothing applied.`);
358
+ out.push("", "A reviewer who read the plan approves it with:", "", "```", prEnvApproveCommand(r.op, r.digest, approver), "```", "", "Then run the job again, or push to the pull request.");
359
+ break;
360
+ case "destroyed":
361
+ out.push(`Torn down: ${r.outcomes?.deleted ?? 0} resource${r.outcomes?.deleted === 1 ? "" : "s"} carrying the marker of ${code(r.env)} deleted.`);
362
+ break;
363
+ case "incomplete":
364
+ out.push(`The teardown of ${code(r.env)} left something (${r.outcomes?.failed ?? 0} failed, ${r.outcomes?.skipped ?? 0} skipped, ${r.outcomes?.holes ?? 0} unread). It stays live, and the next sweep tries again.`);
365
+ break;
366
+ case "absent":
367
+ out.push(`${code(r.env)} has no live record, so there is nothing to tear down.`);
368
+ break;
369
+ default:
370
+ out.push(`The ${r.stage} of ${code(r.env)} ${r.status === "plan-failed" ? "failed to plan" : r.status === "refused" ? "was refused" : "failed"}.`);
371
+ }
372
+ if (r.message)
373
+ out.push("", `> ${r.message.split("\n").join("\n> ")}`);
374
+ if (r.components.length > 0) {
375
+ out.push("", "| Component | Status | Create | Update | Replace | Delete |", "|---|---|---:|---:|---:|---:|");
376
+ for (const c of r.components)
377
+ out.push(`| ${code(c.component)} | ${c.status} | ${c.create} | ${c.update} | ${c.replace} | ${c.delete} |`);
378
+ }
379
+ if (r.digest)
380
+ out.push("", `Plan digest: ${code(r.digest)}${r.approvedBy?.length ? `, approved by ${r.approvedBy.map(code).join(", ")}` : ""}`);
381
+ out.push("", `${code(r.env)} is not a release target: releases come from green commits.`);
382
+ return out.join("\n") + "\n";
383
+ }
384
+ /** The commit status a report sets: success, failure, or pending while it waits on an approval. */
385
+ export function prEnvStatusState(r) {
386
+ if (r.status === "waiting")
387
+ return "pending";
388
+ return r.status === "applied" || r.status === "nothing" || r.status === "destroyed" || r.status === "absent" ? "success" : "failure";
389
+ }
390
+ /** A commit status description, at most 140 characters. */
391
+ export function prEnvStatusDescription(r) {
392
+ const what = {
393
+ applied: `${r.env} applied, expires ${r.expiresAt ?? "?"}`,
394
+ nothing: `${r.env}: nothing to deploy`,
395
+ waiting: `${r.env} waits for an approval of its plan`,
396
+ changed: `${r.env}: the plan changed after its approval`,
397
+ "plan-failed": `${r.env}: a component failed to plan`,
398
+ failed: `${r.env}: the ${r.stage} failed`,
399
+ refused: `${r.env}: refused`,
400
+ destroyed: `${r.env} torn down`,
401
+ incomplete: `${r.env}: the teardown left something; the sweep tries again`,
402
+ absent: `${r.env}: nothing to tear down`,
403
+ };
404
+ const text = what[r.status];
405
+ return text.length > 140 ? text.slice(0, 137) + "..." : text;
406
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Stage hooks on the pull-request path (#4288): `ci.hooks` in the workspace
3
+ * declaration, run by `chant components pr-plan`, `pr-record` and
4
+ * `pr-apply` with the runner an Op waves job uses (`runStageHooks`).
5
+ *
6
+ * The hooks are read from the declaration at the base the job measures the
7
+ * change from, never from the change itself, so a pull request cannot add,
8
+ * edit or remove a hook its own jobs run. The top level's hooks run first,
9
+ * then those of the member the job runs in. When the base has no workspace
10
+ * declaration no hook runs. When the declaration at base cannot be read and
11
+ * the working tree's names hooks, the job refuses to run rather than guess.
12
+ *
13
+ * The runs a hook sees are the components the change selects, in apply
14
+ * order: `{target}` and `CHANT_TARGET` name one, and `targets` match their
15
+ * names. After the plan, a `perRun` hook's `{plan}` is a JSON file with that
16
+ * component's members and the plans its steps hand the apply.
17
+ */
18
+ import type { OpWaveHook, OpWaveHookMoment } from "../op/op-waves.js";
19
+ import { type OpWaveExec } from "../op/op-waves-run.js";
20
+ import { type Declaration } from "../workspace/declaration.js";
21
+ import type { PrHookOutcome, PrMember } from "../pr-loop.js";
22
+ /** The hooks a pull-request job runs, by moment, and where they were read. */
23
+ export interface PrLoopHooks {
24
+ hooks: Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
25
+ /** The commit they were read at. */
26
+ base: string;
27
+ /** A line for the log when none run because the base has no declaration, or could not be read. */
28
+ note?: string;
29
+ }
30
+ /** The hooks `declaration` gives a job run in `cwd`: the top level's, then the member's. */
31
+ export declare function prLoopHooksOf(declaration: Pick<Declaration, "ci" | "members">, member: string | null): Partial<Record<OpWaveHookMoment, OpWaveHook[]>>;
32
+ /**
33
+ * The hooks a pull-request job in `cwd` runs, read from the workspace
34
+ * declaration at `base`. Throws when the declaration at base cannot be read
35
+ * and the working tree's names hooks.
36
+ */
37
+ export declare function prLoopHooksAt(cwd: string, base: string): PrLoopHooks;
38
+ /** Where a pull-request job's hooks run, and what they are told. */
39
+ export interface PrHookPlace {
40
+ /** The gate's op, `pr-<n>`: `{op}` and `CHANT_OP_WAVES`. */
41
+ op: string;
42
+ /** The environment: `{wave}`, `CHANT_WAVE` and `CHANT_ENV`. */
43
+ env: string;
44
+ stage: "plan" | "apply" | "record";
45
+ /** The report directory, relative to `cwd`; the runs and plan files go under `<dir>/hooks`. */
46
+ dir: string;
47
+ cwd: string;
48
+ exec?: OpWaveExec;
49
+ log?: (line: string) => void;
50
+ }
51
+ /** What one moment's hooks came to on the pull-request path. */
52
+ export interface PrHooksRun {
53
+ outcomes: PrHookOutcome[];
54
+ failed?: string;
55
+ holds: string[];
56
+ }
57
+ /**
58
+ * Run the hooks of `moment` for `components` (#4288). `plans`, after the
59
+ * plan, gives each component's members and step plans, written where a
60
+ * `perRun` hook's `{plan}` points.
61
+ */
62
+ export declare function runPrHooks(read: PrLoopHooks, moment: OpWaveHookMoment, place: PrHookPlace, components: readonly string[], planned?: {
63
+ members: readonly PrMember[];
64
+ plans: Record<string, Record<string, unknown>>;
65
+ }): PrHooksRun;
66
+ //# sourceMappingURL=pr-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pr-hooks.d.ts","sourceRoot":"","sources":["../../src/components/pr-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAe,MAAM,gBAAgB,CAAC;AAEhF,OAAO,EAAiB,KAAK,UAAU,EAA0C,MAAM,oBAAoB,CAAC;AAC5G,OAAO,EAAuC,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGjG,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE1D,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC;IACvD,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAQD,4FAA4F;AAC5F,wBAAgB,aAAa,CAAC,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,GAAG,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,UAAU,EAAE,CAAC,CAAC,CAQtJ;AAaD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAiBpE;AAED,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,EAAE,EAAE,MAAM,CAAC;IACX,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;IACnC,+FAA+F;IAC/F,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAED,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,gBAAgB,EACxB,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,SAAS,MAAM,EAAE,EAC7B,OAAO,CAAC,EAAE;IAAE,OAAO,EAAE,SAAS,QAAQ,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CAAE,GACzF,UAAU,CAmCZ"}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Stage hooks on the pull-request path (#4288): `ci.hooks` in the workspace
3
+ * declaration, run by `chant components pr-plan`, `pr-record` and
4
+ * `pr-apply` with the runner an Op waves job uses (`runStageHooks`).
5
+ *
6
+ * The hooks are read from the declaration at the base the job measures the
7
+ * change from, never from the change itself, so a pull request cannot add,
8
+ * edit or remove a hook its own jobs run. The top level's hooks run first,
9
+ * then those of the member the job runs in. When the base has no workspace
10
+ * declaration no hook runs. When the declaration at base cannot be read and
11
+ * the working tree's names hooks, the job refuses to run rather than guess.
12
+ *
13
+ * The runs a hook sees are the components the change selects, in apply
14
+ * order: `{target}` and `CHANT_TARGET` name one, and `targets` match their
15
+ * names. After the plan, a `perRun` hook's `{plan}` is a JSON file with that
16
+ * component's members and the plans its steps hand the apply.
17
+ */
18
+ import { mkdirSync, writeFileSync } from "node:fs";
19
+ import { join, relative, resolve } from "node:path";
20
+ import { PR_LOOP_HOOK_MOMENTS } from "../op/op-waves.js";
21
+ import { runStageHooks } from "../op/op-waves-run.js";
22
+ import { readDeclaration, WorkspaceReadError } from "../workspace/declaration.js";
23
+ import { memberAtCwd } from "../workspace/security.js";
24
+ import { locateWorkspace } from "../workspace/which-chant.js";
25
+ /** The hooks of one `ci.hooks` block at `moment`. */
26
+ function at(hooks, moment) {
27
+ const [side, stage] = moment.split("-");
28
+ return (hooks?.[side]?.[stage]) ?? [];
29
+ }
30
+ /** The hooks `declaration` gives a job run in `cwd`: the top level's, then the member's. */
31
+ export function prLoopHooksOf(declaration, member) {
32
+ const own = member === null ? null : (declaration.members.find((m) => m.name === member)?.ciHooks ?? null);
33
+ const out = {};
34
+ for (const moment of PR_LOOP_HOOK_MOMENTS) {
35
+ const list = [...at(declaration.ci?.hooks, moment), ...at(own, moment)];
36
+ if (list.length > 0)
37
+ out[moment] = list;
38
+ }
39
+ return out;
40
+ }
41
+ /** Whether the working tree's declaration names any `ci.hooks`. */
42
+ function checkoutNamesHooks(cwd) {
43
+ try {
44
+ const located = locateWorkspace(cwd);
45
+ const d = readDeclaration(located.tree);
46
+ return Object.keys(prLoopHooksOf(d, memberAtCwd({ declaration: d, rootOnDisk: located.rootOnDisk }, cwd))).length > 0;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * The hooks a pull-request job in `cwd` runs, read from the workspace
54
+ * declaration at `base`. Throws when the declaration at base cannot be read
55
+ * and the working tree's names hooks.
56
+ */
57
+ export function prLoopHooksAt(cwd, base) {
58
+ let declaration;
59
+ let rootOnDisk;
60
+ try {
61
+ const located = locateWorkspace(cwd, base);
62
+ declaration = readDeclaration(located.tree);
63
+ rootOnDisk = located.rootOnDisk;
64
+ }
65
+ catch (err) {
66
+ const missing = err instanceof WorkspaceReadError && (err.code === "declaration-missing" || err.code === "not-a-git-repository");
67
+ const why = err instanceof Error ? err.message : String(err);
68
+ if (checkoutNamesHooks(cwd)) {
69
+ if (missing)
70
+ return { hooks: {}, base, note: `ci.hooks: the base ${base.slice(0, 12)} has no workspace declaration, so the hooks this change declares do not run until it merges` };
71
+ throw new Error(`ci.hooks are read from the workspace declaration at ${base.slice(0, 12)}, and it could not be read (${why}), so no hook can be trusted to run`);
72
+ }
73
+ return missing ? { hooks: {}, base } : { hooks: {}, base, note: `ci.hooks: the declaration at ${base.slice(0, 12)} could not be read (${why}); this checkout names no hooks, so none run` };
74
+ }
75
+ return { hooks: prLoopHooksOf(declaration, memberAtCwd({ declaration, rootOnDisk }, cwd)), base };
76
+ }
77
+ /**
78
+ * Run the hooks of `moment` for `components` (#4288). `plans`, after the
79
+ * plan, gives each component's members and step plans, written where a
80
+ * `perRun` hook's `{plan}` points.
81
+ */
82
+ export function runPrHooks(read, moment, place, components, planned) {
83
+ const hooks = read.hooks[moment];
84
+ if (!hooks || hooks.length === 0)
85
+ return { outcomes: [], holds: [] };
86
+ // Relative to cwd, as an Op waves job's paths are, whether --output was given relative or absolute.
87
+ const dir = relative(place.cwd, resolve(place.cwd, place.dir, "hooks")) || ".";
88
+ mkdirSync(join(place.cwd, dir, "plans"), { recursive: true });
89
+ const runs = components.map((component) => {
90
+ if (!planned)
91
+ return { target: component, op: place.op };
92
+ const plan = join(dir, "plans", `${component.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
93
+ const body = { component, members: planned.members.filter((m) => m.component === component), plans: planned.plans[component] ?? {} };
94
+ writeFileSync(join(place.cwd, plan), JSON.stringify(body, null, 2) + "\n");
95
+ return { target: component, op: place.op, plan };
96
+ });
97
+ const runsFile = join(dir, "runs.json");
98
+ writeFileSync(join(place.cwd, runsFile), JSON.stringify({ version: 1, op: place.op, env: place.env, stage: place.stage, base: read.base, hook: moment, runs }, null, 2) + "\n");
99
+ const ran = runStageHooks(moment, hooks, {
100
+ name: place.op,
101
+ op: place.op,
102
+ wave: place.env,
103
+ runs,
104
+ runsFile,
105
+ holdFile: join(dir, "hold.json"),
106
+ env: { CHANT_ENV: place.env, CHANT_PR_STAGE: place.stage, CHANT_HOOKS_BASE: read.base },
107
+ cwd: place.cwd,
108
+ ...(place.exec ? { exec: place.exec } : {}),
109
+ ...(place.log ? { log: place.log } : {}),
110
+ });
111
+ const outcomes = ran.outcomes.map((o) => ({
112
+ moment: o.moment,
113
+ name: o.name,
114
+ ...(o.target ? { target: o.target } : {}),
115
+ exit: o.exit,
116
+ outcome: o.outcome === "failed" ? "failed" : o.outcome === "held" ? "held" : "passed",
117
+ }));
118
+ return { outcomes, ...(ran.failed ? { failed: ran.failed } : {}), holds: ran.holds };
119
+ }