@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,538 @@
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
+
38
+ import { environmentEndpoint, environmentName, isEnvironmentPattern, isOwnershipParamRef, matchesDeclaredEnvironment, resolveOwnershipStack, type ChantConfig, type EnvironmentDeclaration } from "../config";
39
+ import type { OwnershipMarker } from "../ownership";
40
+ import { executeTeardown, planTeardown, type TeardownPlan, type TeardownPlanEntry, type TeardownReport } from "../lifecycle/teardown";
41
+ import type { ObservationLexicon } from "../lexicon";
42
+ import { applyLiveEndpoint } from "../live-endpoint";
43
+
44
+ /** The gate a pull request's environment waits on, under the pull request's op `pr-<n>`. */
45
+ export const PR_ENV_GATE = "pr-env";
46
+
47
+ /** Where the records live on chant/lifecycle, under the member's ledger prefix. */
48
+ export const PR_ENV_LEDGER_DIR = "_pr-envs";
49
+ export const PR_ENV_LEDGER_FILE = "records.jsonl";
50
+
51
+ /** Where `pr-env` writes its report and note unless `--output` says. */
52
+ export { PR_ENV_REPORT_DIR } from "./pr-pipeline";
53
+
54
+ /** The file `pr-env up --no-push` writes its record to under `--output`, for `pr-env post` (#4295). */
55
+ export const PR_ENV_RECORD_FILE = "pr-env-record.json";
56
+
57
+ /** The commit status context the up and down jobs set. */
58
+ export const PR_ENV_STATUS_CONTEXT = "chant/pr-env";
59
+
60
+ /** The variable a component's build reads the template's name from. */
61
+ export const PR_ENV_TEMPLATE_VAR = "CHANT_ENV_TEMPLATE";
62
+
63
+ /** Exit codes: done, failed or refused, and waiting on an approval. */
64
+ export const PR_ENV_EXIT = { done: 0, failed: 1, waiting: 3 } as const;
65
+
66
+ /** The environment of pull request `pr`. */
67
+ export function prEnvironmentName(pr: number): string {
68
+ return `pr-${pr}`;
69
+ }
70
+
71
+ const PR_ENV_NAME = /^pr-[1-9][0-9]*$/;
72
+
73
+ /** Whether `name` is a pull request's environment name, `pr-<n>`. */
74
+ export function isPrEnvironmentName(name: string): boolean {
75
+ return PR_ENV_NAME.test(name);
76
+ }
77
+
78
+ /** The hidden line that marks the environment's note on the pull request. */
79
+ export function prEnvNoteMarker(env: string, member?: string): string {
80
+ return member ? `<!-- chant-pr-env:${env}:${member} -->` : `<!-- chant-pr-env:${env} -->`;
81
+ }
82
+
83
+ /** The status context, per member in a workspace. */
84
+ export function prEnvStatusContext(member?: string): string {
85
+ return member ? `${PR_ENV_STATUS_CONTEXT}/${member}` : PR_ENV_STATUS_CONTEXT;
86
+ }
87
+
88
+ /** The approve command for an environment's plan. */
89
+ export function prEnvApproveCommand(op: string, digest: string, approver = "<you>"): string {
90
+ return `chant approve ${op} ${PR_ENV_GATE} --plan ${digest} --approver ${approver} --sign`;
91
+ }
92
+
93
+ /**
94
+ * Why `env` cannot be a release's source or target, or undefined when it
95
+ * can. A pull request's environment never is (ws-104: releases come from
96
+ * green commits).
97
+ */
98
+ export function releaseTargetRefusal(env: string): string | undefined {
99
+ if (!isPrEnvironmentName(env)) return undefined;
100
+ 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`;
101
+ }
102
+
103
+ /** A TTL of the form `90m`, `72h` or `7d`, in milliseconds. Undefined for any other form. */
104
+ export function ttlMs(ttl: string): number | undefined {
105
+ const m = /^([1-9][0-9]*)(m|h|d)$/.exec(ttl);
106
+ if (!m) return undefined;
107
+ const unit = m[2] === "m" ? 60_000 : m[2] === "h" ? 3_600_000 : 86_400_000;
108
+ return Number(m[1]) * unit;
109
+ }
110
+
111
+ // ── deriving the environment ───────────────────────────────────────────────
112
+
113
+ /** What `pr-env up` derives from the template and the project's config, or why it cannot. */
114
+ export type PrEnvDerivation =
115
+ | {
116
+ env: string;
117
+ template: string;
118
+ stack: string;
119
+ /** The build parameter `ownership.env` reads, set to `env` for the build. */
120
+ param: string;
121
+ /** The config's environments with `env` declared outright, carrying the template's endpoint when it has none of its own. */
122
+ environments: EnvironmentDeclaration[] | undefined;
123
+ /** The endpoint the environment's plans and applies use, if any. */
124
+ endpoint?: string;
125
+ }
126
+ | { refusal: string };
127
+
128
+ /**
129
+ * Derive pull request `pr`'s environment from `template` and the project's
130
+ * config. Refused when the resources it creates could not be told apart
131
+ * from the template's: marking is off, or `ownership.env` is a literal (every
132
+ * resource would carry that literal) or unset. Refused too when the config
133
+ * would not take the name, or does not declare the template.
134
+ */
135
+ export function derivePrEnvironment(config: ChantConfig, template: string, pr: number): PrEnvDerivation {
136
+ const env = prEnvironmentName(pr);
137
+ const stack = resolveOwnershipStack(config);
138
+ if (stack === undefined) {
139
+ 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` };
140
+ }
141
+ const own = config.ownership?.env;
142
+ if (!isOwnershipParamRef(own)) {
143
+ return {
144
+ refusal: own === undefined
145
+ ? `${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" }`
146
+ : `${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`,
147
+ };
148
+ }
149
+ if (!config.buildParams?.[own.param]) {
150
+ return { refusal: `${env}: ownership.env reads build parameter "${own.param}", which chant.config.ts's buildParams does not declare` };
151
+ }
152
+ const declared = config.environments;
153
+ if (declared && declared.length > 0) {
154
+ if (!declared.some((e) => environmentName(e) === template)) {
155
+ return { refusal: `${env}: ci.prEnvironment.template is ${template}, which chant.config.ts's environments do not declare` };
156
+ }
157
+ if (!matchesDeclaredEnvironment(declared, env)) {
158
+ return { refusal: `${env}: chant.config.ts's environments do not cover ${env}; add "pr-*" to environments` };
159
+ }
160
+ // A literal pr-<n> is a declared environment of its own, maybe one a release targets.
161
+ if (declared.some((e) => environmentName(e) === env)) {
162
+ 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` };
163
+ }
164
+ }
165
+ const endpoint = environmentEndpoint(declared, env) ?? environmentEndpoint(declared, template);
166
+ const environments = declared ? [endpoint ? { name: env, endpoint } : env, ...declared] : undefined;
167
+ return { env, template, stack, param: own.param, environments, ...(endpoint ? { endpoint } : {}) };
168
+ }
169
+
170
+ // ── records ────────────────────────────────────────────────────────────────
171
+
172
+ /** One line of `_pr-envs/records.jsonl`: an apply, or a teardown. */
173
+ export interface PrEnvRecord {
174
+ version: 1;
175
+ kind: "up" | "down";
176
+ pr: number;
177
+ /** `pr-<n>`. */
178
+ env: string;
179
+ /** The workspace member whose environment it is, when in one. */
180
+ member?: string;
181
+ /** The template environment it was derived from. */
182
+ template: string;
183
+ /** The marker every resource of the environment carries. What teardown selects on. */
184
+ marker: { stack: string; env: string };
185
+ /** The commit applied, or the commit the teardown ran at. */
186
+ commit: string;
187
+ /** The components applied (up) or the lexicons torn down (down). */
188
+ components: string[];
189
+ /** The change-set digest applied; empty for a teardown. */
190
+ planDigest: string;
191
+ /** `incomplete`: a teardown that left something, so the environment stays live and the sweep tries again. */
192
+ result: "applied" | "failed" | "destroyed" | "incomplete";
193
+ by: string;
194
+ timestamp: string;
195
+ /** Up: when the sweep tears it down unless another apply comes first. */
196
+ expiresAt?: string;
197
+ approvedBy?: string[];
198
+ /** Down: why. */
199
+ reason?: "closed" | "expired";
200
+ /** Down: the teardown's outcome counts. */
201
+ outcomes?: { deleted: number; failed: number; retained: number; notPrunable: number; skipped: number; holes: number };
202
+ /** Always false: a pull request's environment is never a release target. */
203
+ releaseTarget: false;
204
+ runUrl?: string;
205
+ }
206
+
207
+ /** The records of a records.jsonl text, oldest first. Malformed lines are skipped. */
208
+ export function parsePrEnvRecords(text: string | null | undefined): PrEnvRecord[] {
209
+ const out: PrEnvRecord[] = [];
210
+ for (const line of (text ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
211
+ try {
212
+ const r = JSON.parse(line) as PrEnvRecord;
213
+ 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") out.push(r);
214
+ } catch {
215
+ continue;
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** A pull request's environment as its records say it stands. */
222
+ export interface LivePrEnvironment {
223
+ pr: number;
224
+ env: string;
225
+ member?: string;
226
+ template: string;
227
+ marker: { stack: string; env: string };
228
+ /** The commit last applied. */
229
+ commit: string;
230
+ /** Every component an apply of it ever touched. */
231
+ components: string[];
232
+ applied: string;
233
+ expiresAt: string;
234
+ /** Its last teardown left something, so the sweep tries again. */
235
+ teardownIncomplete?: boolean;
236
+ }
237
+
238
+ /**
239
+ * The environments that stand, by name, from the records in order: an up
240
+ * (applied or failed part way) makes or refreshes one, a down that
241
+ * destroyed everything ends it, and any other down leaves it live.
242
+ */
243
+ export function livePrEnvironments(records: readonly PrEnvRecord[]): Map<string, LivePrEnvironment> {
244
+ const live = new Map<string, LivePrEnvironment>();
245
+ for (const r of records) {
246
+ if (r.kind === "up") {
247
+ const was = live.get(r.env);
248
+ live.set(r.env, {
249
+ pr: r.pr,
250
+ env: r.env,
251
+ ...(r.member ? { member: r.member } : {}),
252
+ template: r.template,
253
+ marker: { ...r.marker },
254
+ commit: r.commit,
255
+ components: [...new Set([...(was?.components ?? []), ...r.components])],
256
+ applied: r.timestamp,
257
+ expiresAt: r.expiresAt ?? r.timestamp,
258
+ });
259
+ } else if (r.result === "destroyed") {
260
+ live.delete(r.env);
261
+ } else {
262
+ const was = live.get(r.env);
263
+ if (was) live.set(r.env, { ...was, teardownIncomplete: true });
264
+ }
265
+ }
266
+ return live;
267
+ }
268
+
269
+ /** Whether a live environment's TTL passed at `now`. */
270
+ export function expired(env: Pick<LivePrEnvironment, "expiresAt">, now: Date): boolean {
271
+ return Date.parse(env.expiresAt) <= now.getTime();
272
+ }
273
+
274
+ // ── the ownership check ────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Why pull request `pr`'s environment must not be torn down as `live`
278
+ * says, or undefined when it may. The record must name `pr-<n>` for this
279
+ * pull request, its marker's env must be that name, and the config must not
280
+ * declare the name outright (a declared environment belongs to the project,
281
+ * not to a pull request).
282
+ */
283
+ export function teardownRefusal(pr: number, live: LivePrEnvironment, declared: EnvironmentDeclaration[] | undefined): string | undefined {
284
+ const env = prEnvironmentName(pr);
285
+ if (live.env !== env || live.pr !== pr) return `the record for ${live.env} names pull request ${live.pr}, not ${pr}, so pr-env down --pr ${pr} does not tear it down`;
286
+ if (!isPrEnvironmentName(live.marker.env) || live.marker.env !== env) {
287
+ 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`;
288
+ }
289
+ const literal = (declared ?? []).find((e) => !isEnvironmentPattern(environmentName(e)) && environmentName(e) === env);
290
+ if (literal) 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`;
291
+ return undefined;
292
+ }
293
+
294
+ /**
295
+ * The entries of a teardown plan the environment owns: those whose marker
296
+ * is exactly `marker`. `planTeardown` already selects on the marker; this
297
+ * is the second check, so a lexicon that returned another's resources cannot
298
+ * widen what pr-env down deletes. `dropped` counts what was left out.
299
+ */
300
+ export function ownedEntries(plan: TeardownPlan, marker: OwnershipMarker): { plan: TeardownPlan; dropped: TeardownPlanEntry[] } {
301
+ const owns = (e: TeardownPlanEntry) => e.marker?.stack === marker.stack && e.marker?.env === marker.env;
302
+ return { plan: { ...plan, entries: plan.entries.filter(owns) }, dropped: plan.entries.filter((e) => !owns(e)) };
303
+ }
304
+
305
+ /** What {@link tearDownPrEnvironment} needs. */
306
+ export interface PrEnvTeardownInput {
307
+ pr: number;
308
+ live: LivePrEnvironment;
309
+ config: ChantConfig;
310
+ plugins: ObservationLexicon[];
311
+ warn?: (message: string) => void;
312
+ }
313
+
314
+ /**
315
+ * Tear down pull request `pr`'s environment with the ownership check: refused
316
+ * before any read when {@link teardownRefusal} says so; otherwise the live
317
+ * resources under the record's marker are planned, every entry whose marker
318
+ * is not exactly `{ stack, env: "pr-<n>" }` is dropped ({@link ownedEntries}),
319
+ * and only the rest go to each lexicon's teardown. The endpoint is the one
320
+ * the environment was applied at: its own `pr-*` entry's, else the template's.
321
+ */
322
+ export async function tearDownPrEnvironment(input: PrEnvTeardownInput): Promise<{ refusal: string } | { report: TeardownReport; dropped: number; marker: { stack: string; env: string } }> {
323
+ const { pr, live, config, plugins } = input;
324
+ const refusal = teardownRefusal(pr, live, config.environments);
325
+ if (refusal) return { refusal };
326
+ const env = prEnvironmentName(pr);
327
+ const marker = { stack: live.marker.stack, env };
328
+ const endpoint = environmentEndpoint(config.environments, env) ?? environmentEndpoint(config.environments, live.template);
329
+ const applied = applyLiveEndpoint(endpoint ? [{ name: env, endpoint }] : [], env, plugins.filter((x) => x.teardownOwned || x.describeResources || x.executeTeardown));
330
+ if (applied.notice) input.warn?.(applied.notice);
331
+ const deployedStacks = (config.stacks ?? []).map((s) => ({ name: s.name, ...(s.region ? { region: s.region } : {}) }));
332
+ try {
333
+ const planned = await planTeardown({ environment: env, stack: marker.stack, plugins, ...(deployedStacks.length > 0 ? { deployedStacks } : {}) });
334
+ const owned = ownedEntries(planned, marker);
335
+ const report = await executeTeardown({ environment: env, stack: marker.stack, plugins, plan: owned.plan, ...(deployedStacks.length > 0 ? { deployedStacks } : {}) });
336
+ return { report, dropped: owned.dropped.length, marker };
337
+ } finally {
338
+ applied.restore();
339
+ }
340
+ }
341
+
342
+ /** A teardown report's counts for the record. */
343
+ export function teardownCounts(report: TeardownReport): NonNullable<PrEnvRecord["outcomes"]> {
344
+ const n = (o: string) => report.outcomes.filter((x) => x.outcome === o).length;
345
+ return { deleted: n("deleted"), failed: n("failed"), retained: n("retained"), notPrunable: n("not-prunable"), skipped: n("skipped"), holes: report.plan.holes.length };
346
+ }
347
+
348
+ /**
349
+ * Whether a teardown left the environment clean: nothing failed, nothing was
350
+ * skipped for a lexicon with no teardown execution, and the plan had no
351
+ * holes. A retained resource (a lexicon's deliberate keep) does not count
352
+ * against it.
353
+ */
354
+ export function teardownComplete(report: TeardownReport): boolean {
355
+ const c = teardownCounts(report);
356
+ return c.failed === 0 && c.skipped === 0 && c.holes === 0;
357
+ }
358
+
359
+ // ── the handover (#4295) ─────────────────────────────────────────────────────
360
+
361
+ /** What {@link acceptHandedRecord} checks a handed-on record against: the job's own values and the settings at its commit. */
362
+ export interface HandedRecordCheck {
363
+ pr: number;
364
+ member?: string;
365
+ /** The commit the up job checked out, from the forge, not from the record. */
366
+ head: string;
367
+ /** `ci.prEnvironment` at the commit the post job runs at. */
368
+ template: string;
369
+ ttlMs: number;
370
+ now: Date;
371
+ by: string;
372
+ runUrl?: string;
373
+ }
374
+
375
+ /**
376
+ * The record `pr-env up --no-push` handed on, checked as data: the up job ran
377
+ * the change's code, so what it wrote is the change's. It must be an `up`
378
+ * line for this pull request's `pr-<n>`, with that marker env, the template
379
+ * the base declares, and string components. The commit, the actor, the run
380
+ * and the expiry are the post job's own, so a change cannot keep its
381
+ * environment past the TTL its base set. Refused otherwise, and kept nowhere.
382
+ */
383
+ export function acceptHandedRecord(raw: unknown, check: HandedRecordCheck): { record: PrEnvRecord } | { refusal: string } {
384
+ const env = prEnvironmentName(check.pr);
385
+ const [r] = parsePrEnvRecords(typeof raw === "object" && raw !== null ? JSON.stringify(raw) : "");
386
+ if (!r) return { refusal: "The env-up job handed on a record that does not read, so it was not kept." };
387
+ const why =
388
+ r.kind !== "up" ? `is a ${r.kind} record`
389
+ : r.pr !== check.pr || r.env !== env ? `names ${r.env} of pull request ${r.pr}, not ${env}`
390
+ : r.marker.env !== env ? `names the marker env ${JSON.stringify(r.marker.env)}, not ${env}`
391
+ : r.template !== check.template ? `names the template ${r.template}, and the base declares ${check.template}`
392
+ : (r.member ?? undefined) !== check.member ? `names the member ${r.member ?? "(none)"}`
393
+ : !Array.isArray(r.components) || r.components.some((c) => typeof c !== "string") ? "lists components that are not names"
394
+ : r.result !== "applied" && r.result !== "failed" ? `has the result ${r.result}`
395
+ : undefined;
396
+ if (why) return { refusal: `The env-up job handed on a record that ${why}, so it was not kept.` };
397
+ return {
398
+ record: {
399
+ version: 1,
400
+ kind: "up",
401
+ pr: check.pr,
402
+ env,
403
+ ...(check.member ? { member: check.member } : {}),
404
+ template: check.template,
405
+ marker: { stack: r.marker.stack, env },
406
+ commit: check.head,
407
+ components: [...r.components],
408
+ planDigest: typeof r.planDigest === "string" ? r.planDigest : "",
409
+ result: r.result,
410
+ by: check.by,
411
+ timestamp: check.now.toISOString(),
412
+ expiresAt: new Date(check.now.getTime() + check.ttlMs).toISOString(),
413
+ ...(Array.isArray(r.approvedBy) ? { approvedBy: r.approvedBy.filter((a): a is string => typeof a === "string") } : {}),
414
+ releaseTarget: false,
415
+ ...(check.runUrl ? { runUrl: check.runUrl } : {}),
416
+ },
417
+ };
418
+ }
419
+
420
+ /**
421
+ * The report `pr-env up` handed on, checked as data before the post job
422
+ * renders a note from it: an `up` report for this pull request's `pr-<n>`.
423
+ * Its head is the post job's own.
424
+ */
425
+ export function handedPrEnvReport(raw: unknown, check: { pr: number; head: string; member?: string }): { report: PrEnvReport } | { refusal: string } {
426
+ const env = prEnvironmentName(check.pr);
427
+ if (typeof raw !== "object" || raw === null) return { refusal: "The env-up job handed on no report." };
428
+ const r = raw as Partial<PrEnvReport>;
429
+ if (r.stage !== "up" || r.pr !== check.pr || r.env !== env) return { refusal: `The env-up job handed on a report that is not ${env}'s.` };
430
+ const statuses: PrEnvReport["status"][] = ["applied", "nothing", "waiting", "changed", "plan-failed", "failed", "refused"];
431
+ if (!r.status || !statuses.includes(r.status)) return { refusal: "The env-up job handed on a report with no status it could have." };
432
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
433
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
434
+ return {
435
+ report: {
436
+ stage: "up",
437
+ pr: check.pr,
438
+ env,
439
+ ...(check.member ? { member: check.member } : {}),
440
+ template: str(r.template),
441
+ head: check.head,
442
+ op: check.member ? `pr-${check.pr}-${check.member}` : `pr-${check.pr}`,
443
+ gate: PR_ENV_GATE,
444
+ digest: str(r.digest),
445
+ status: r.status,
446
+ ...(typeof r.message === "string" ? { message: r.message } : {}),
447
+ ...(typeof r.endpoint === "string" ? { endpoint: r.endpoint } : {}),
448
+ 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) })),
449
+ ...(Array.isArray(r.approvedBy) ? { approvedBy: r.approvedBy.filter((a): a is string => typeof a === "string") } : {}),
450
+ },
451
+ };
452
+ }
453
+
454
+ // ── the note ───────────────────────────────────────────────────────────────
455
+
456
+ /** What one `pr-env up` or `down` run came to, as `pr-env.json` holds it. */
457
+ export interface PrEnvReport {
458
+ stage: "up" | "down";
459
+ pr: number;
460
+ env: string;
461
+ member?: string;
462
+ template: string;
463
+ head: string;
464
+ op: string;
465
+ gate: string;
466
+ digest: string;
467
+ status: "applied" | "nothing" | "waiting" | "changed" | "plan-failed" | "failed" | "refused" | "destroyed" | "incomplete" | "absent";
468
+ message?: string;
469
+ endpoint?: string;
470
+ components: Array<{ component: string; status: string; create: number; update: number; replace: number; delete: number }>;
471
+ approvedBy?: string[];
472
+ expiresAt?: string;
473
+ outcomes?: PrEnvRecord["outcomes"];
474
+ }
475
+
476
+ const code = (s: string): string => "`" + s.replaceAll("`", "'") + "`";
477
+
478
+ /** The environment's note on the pull request: one per environment, updated in place. */
479
+ export function renderPrEnvNote(r: PrEnvReport, approver = "<you>"): string {
480
+ const out = [prEnvNoteMarker(r.env, r.member), `### chant pull request environment ${code(r.env)}${r.member ? ` in member ${code(r.member)}` : ""}`, ""];
481
+ const from = `derived from ${code(r.template)}${r.endpoint ? ` at ${code(r.endpoint)}` : ""}`;
482
+ switch (r.status) {
483
+ case "applied":
484
+ 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.`);
485
+ break;
486
+ case "nothing":
487
+ out.push(`Nothing this change touches is deployed by a component, so ${code(r.env)} gets nothing.`);
488
+ break;
489
+ case "waiting":
490
+ case "changed":
491
+ 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.`);
492
+ 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.");
493
+ break;
494
+ case "destroyed":
495
+ out.push(`Torn down: ${r.outcomes?.deleted ?? 0} resource${r.outcomes?.deleted === 1 ? "" : "s"} carrying the marker of ${code(r.env)} deleted.`);
496
+ break;
497
+ case "incomplete":
498
+ 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.`);
499
+ break;
500
+ case "absent":
501
+ out.push(`${code(r.env)} has no live record, so there is nothing to tear down.`);
502
+ break;
503
+ default:
504
+ out.push(`The ${r.stage} of ${code(r.env)} ${r.status === "plan-failed" ? "failed to plan" : r.status === "refused" ? "was refused" : "failed"}.`);
505
+ }
506
+ if (r.message) out.push("", `> ${r.message.split("\n").join("\n> ")}`);
507
+ if (r.components.length > 0) {
508
+ out.push("", "| Component | Status | Create | Update | Replace | Delete |", "|---|---|---:|---:|---:|---:|");
509
+ for (const c of r.components) out.push(`| ${code(c.component)} | ${c.status} | ${c.create} | ${c.update} | ${c.replace} | ${c.delete} |`);
510
+ }
511
+ if (r.digest) out.push("", `Plan digest: ${code(r.digest)}${r.approvedBy?.length ? `, approved by ${r.approvedBy.map(code).join(", ")}` : ""}`);
512
+ out.push("", `${code(r.env)} is not a release target: releases come from green commits.`);
513
+ return out.join("\n") + "\n";
514
+ }
515
+
516
+ /** The commit status a report sets: success, failure, or pending while it waits on an approval. */
517
+ export function prEnvStatusState(r: Pick<PrEnvReport, "status">): "success" | "failure" | "pending" {
518
+ if (r.status === "waiting") return "pending";
519
+ return r.status === "applied" || r.status === "nothing" || r.status === "destroyed" || r.status === "absent" ? "success" : "failure";
520
+ }
521
+
522
+ /** A commit status description, at most 140 characters. */
523
+ export function prEnvStatusDescription(r: PrEnvReport): string {
524
+ const what: Record<PrEnvReport["status"], string> = {
525
+ applied: `${r.env} applied, expires ${r.expiresAt ?? "?"}`,
526
+ nothing: `${r.env}: nothing to deploy`,
527
+ waiting: `${r.env} waits for an approval of its plan`,
528
+ changed: `${r.env}: the plan changed after its approval`,
529
+ "plan-failed": `${r.env}: a component failed to plan`,
530
+ failed: `${r.env}: the ${r.stage} failed`,
531
+ refused: `${r.env}: refused`,
532
+ destroyed: `${r.env} torn down`,
533
+ incomplete: `${r.env}: the teardown left something; the sweep tries again`,
534
+ absent: `${r.env}: nothing to tear down`,
535
+ };
536
+ const text = what[r.status];
537
+ return text.length > 140 ? text.slice(0, 137) + "..." : text;
538
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `ci.hooks` on the pull-request path (#4288): read from the declaration at
3
+ * base, the top level's then the member's, and run with targets.
4
+ */
5
+ import { afterAll, describe, expect, test } from "vitest";
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { parseDeclaration, WorkspaceReadError } from "../workspace/declaration";
11
+ import { prLoopHooksAt, prLoopHooksOf, runPrHooks } from "./pr-hooks";
12
+ import type { OpWaveExec } from "../op/op-waves-run";
13
+
14
+ const scratch: string[] = [];
15
+ afterAll(() => {
16
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
17
+ });
18
+ const tmp = (): string => {
19
+ const d = realpathSync(mkdtempSync(join(tmpdir(), "pr-hooks-")));
20
+ scratch.push(d);
21
+ return d;
22
+ };
23
+ const git = (cwd: string, ...args: string[]): string => execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
24
+
25
+ const declaration = (hooks: unknown, memberHooks?: unknown) => ({
26
+ name: "acme",
27
+ schema: 1,
28
+ members: [{ name: "infra", dir: "infra", kind: "other", because: "test", ...(memberHooks ? { ci: { hooks: memberHooks } } : {}) }],
29
+ ...(hooks ? { ci: { hooks } } : {}),
30
+ });
31
+
32
+ describe("ci.hooks in the declaration (#4288)", () => {
33
+ test("parses at the top level and on a member; the member's run after the top level's", () => {
34
+ const d = parseDeclaration(
35
+ JSON.stringify(declaration({ before: { observe: [{ name: "init", command: ["init"] }] } }, { before: { observe: [{ name: "member-init", command: ["mi"], targets: ["db*"] }] } })),
36
+ "chant.workspace.json",
37
+ "0.124.41",
38
+ );
39
+ expect(d.ci?.hooks).toEqual({ before: { observe: [{ name: "init", command: ["init"] }] } });
40
+ expect(prLoopHooksOf(d, "infra")["before-observe"]!.map((h) => h.name)).toEqual(["init", "member-init"]);
41
+ expect(prLoopHooksOf(d, null)["before-observe"]!.map((h) => h.name)).toEqual(["init"]);
42
+ });
43
+
44
+ test("refuses hooks a pull-request job cannot run", () => {
45
+ const read = (hooks: unknown) => () => parseDeclaration(JSON.stringify(declaration(hooks)), "chant.workspace.json", "0.124.41");
46
+ expect(read({ after: { gate: [{ command: ["x"] }] } })).toThrow(WorkspaceReadError);
47
+ expect(read({ before: { plan: [{ command: [] }] } })).toThrow(/must be a command as argv/);
48
+ expect(read({ apply: { runBy: "apply" } })).toThrow(/runBy is for an Op waves spec/);
49
+ });
50
+ });
51
+
52
+ describe("prLoopHooksAt (#4288)", () => {
53
+ test("reads the hooks at base: a change cannot add, edit or remove one its own job runs", () => {
54
+ const repo = tmp();
55
+ git(repo, "init", "-q", "-b", "main");
56
+ git(repo, "config", "user.email", "t@example.com");
57
+ git(repo, "config", "user.name", "t");
58
+ mkdirSync(join(repo, "infra"));
59
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ before: { plan: [{ name: "lint", command: ["lint"] }] } })));
60
+ git(repo, "add", ".");
61
+ git(repo, "commit", "-q", "-m", "base");
62
+ const base = git(repo, "rev-parse", "HEAD");
63
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ before: { plan: [{ name: "sneaky", command: ["curl"] }] } })));
64
+ const read = prLoopHooksAt(join(repo, "infra"), base);
65
+ expect(read.hooks["before-plan"]!.map((h) => h.name)).toEqual(["lint"]);
66
+ expect(read.base).toBe(base);
67
+ });
68
+
69
+ test("a base with no declaration runs no hook, and says so when the change declares some", () => {
70
+ const repo = tmp();
71
+ git(repo, "init", "-q", "-b", "main");
72
+ git(repo, "config", "user.email", "t@example.com");
73
+ git(repo, "config", "user.name", "t");
74
+ writeFileSync(join(repo, "README"), "x");
75
+ git(repo, "add", ".");
76
+ git(repo, "commit", "-q", "-m", "base");
77
+ const base = git(repo, "rev-parse", "HEAD");
78
+ expect(prLoopHooksAt(repo, base)).toEqual({ hooks: {}, base });
79
+ writeFileSync(join(repo, "chant.workspace.json"), JSON.stringify(declaration({ before: { plan: [{ command: ["lint"] }] } })));
80
+ const read = prLoopHooksAt(repo, base);
81
+ expect(read.hooks).toEqual({});
82
+ expect(read.note).toMatch(/has no workspace declaration, so the hooks this change declares do not run until it merges/);
83
+ });
84
+ });
85
+
86
+ describe("runPrHooks (#4288)", () => {
87
+ test("runs a moment for the selected components, writes the runs and plan files, and reports a failure", () => {
88
+ const cwd = tmp();
89
+ const calls: Array<{ argv: string; env?: Record<string, string> }> = [];
90
+ const exec: OpWaveExec = (argv, _dir, env) => {
91
+ calls.push({ argv: argv.join(" "), ...(env ? { env } : {}) });
92
+ return argv[1] === "web" ? 1 : 0;
93
+ };
94
+ const read = {
95
+ base: "b".repeat(40),
96
+ hooks: { "after-plan": [{ name: "check", command: ["check", "{target}", "{plan}"], perRun: true, targets: ["api", "web"] }] },
97
+ };
98
+ const ran = runPrHooks(read, "after-plan", { op: "pr-7", env: "prod", stage: "plan", dir: ".chant/pr", cwd, exec, log: () => {} }, ["api", "db", "web"], {
99
+ members: [{ member: "api", component: "api", planDigest: "sha256:1", status: "planned", counts: { create: 1, update: 0, replace: 0, delete: 0 } }],
100
+ plans: { api: { api: { file: "plan.bin" } } },
101
+ });
102
+ expect(calls.map((c) => c.argv)).toEqual(["check api .chant/pr/hooks/plans/api.json", "check web .chant/pr/hooks/plans/web.json"]);
103
+ expect(calls[0]!.env).toMatchObject({ CHANT_HOOK: "after-plan", CHANT_TARGET: "api", CHANT_ENV: "prod", CHANT_PR_STAGE: "plan", CHANT_HOOKS_BASE: "b".repeat(40) });
104
+ expect(JSON.parse(readFileSync(join(cwd, ".chant/pr/hooks/plans/api.json"), "utf-8"))).toMatchObject({ component: "api", plans: { api: { file: "plan.bin" } } });
105
+ expect(JSON.parse(readFileSync(join(cwd, ".chant/pr/hooks/runs.json"), "utf-8")).runs.map((r: { target: string }) => r.target)).toEqual(["api", "db", "web"]);
106
+ expect(ran.outcomes).toEqual([
107
+ { moment: "after-plan", name: "check", target: "api", exit: 0, outcome: "passed" },
108
+ { moment: "after-plan", name: "check", target: "web", exit: 1, outcome: "failed" },
109
+ ]);
110
+ expect(ran.failed).toBe("after-plan hook check (web) exited 1");
111
+ });
112
+
113
+ test("a moment with no hooks runs nothing and writes nothing", () => {
114
+ const cwd = tmp();
115
+ const ran = runPrHooks({ base: "b", hooks: {} }, "before-apply", { op: "pr-7", env: "prod", stage: "apply", dir: ".chant/pr", cwd, log: () => {} }, ["api"]);
116
+ expect(ran).toEqual({ outcomes: [], holds: [] });
117
+ });
118
+ });