@intentius/chant-lexicon-terraform 0.102.0 → 0.104.0

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 (270) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.d.ts +23 -0
  2. package/dist/__fixtures__/terragrunt/affected-cases.d.ts.map +1 -0
  3. package/dist/__fixtures__/terragrunt/record.d.ts +17 -0
  4. package/dist/__fixtures__/terragrunt/record.d.ts.map +1 -0
  5. package/dist/change-set.d.ts +67 -0
  6. package/dist/change-set.d.ts.map +1 -0
  7. package/dist/changed-roots.d.ts +30 -0
  8. package/dist/changed-roots.d.ts.map +1 -0
  9. package/dist/commands.d.ts +26 -0
  10. package/dist/commands.d.ts.map +1 -0
  11. package/dist/components/capability-plugin.d.ts +54 -0
  12. package/dist/components/capability-plugin.d.ts.map +1 -0
  13. package/dist/composites/terraform-pin-rollout-op.d.ts +44 -0
  14. package/dist/composites/terraform-pin-rollout-op.d.ts.map +1 -0
  15. package/dist/hcl/descend.d.ts +1 -1
  16. package/dist/hcl/descend.d.ts.map +1 -1
  17. package/dist/hcl/edges.d.ts +2 -2
  18. package/dist/hcl/edges.d.ts.map +1 -1
  19. package/dist/hcl/parse.d.ts +29 -1
  20. package/dist/hcl/parse.d.ts.map +1 -1
  21. package/dist/hcl/roots.d.ts +1 -1
  22. package/dist/hcl/roots.d.ts.map +1 -1
  23. package/dist/hcl/suppressions.d.ts.map +1 -1
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/integrity.json +13 -4
  27. package/dist/lint/audit-catalog.d.ts.map +1 -1
  28. package/dist/lint/post-synth/index.d.ts.map +1 -1
  29. package/dist/lint/post-synth/module-source.d.ts +27 -1
  30. package/dist/lint/post-synth/module-source.d.ts.map +1 -1
  31. package/dist/lint/post-synth/terragrunt.d.ts +37 -0
  32. package/dist/lint/post-synth/terragrunt.d.ts.map +1 -0
  33. package/dist/lint/post-synth/tf002.d.ts +3 -1
  34. package/dist/lint/post-synth/tf002.d.ts.map +1 -1
  35. package/dist/lint/post-synth/tf038.d.ts +22 -0
  36. package/dist/lint/post-synth/tf038.d.ts.map +1 -0
  37. package/dist/lint/post-synth/tf039.d.ts +23 -0
  38. package/dist/lint/post-synth/tf039.d.ts.map +1 -0
  39. package/dist/lint/post-synth/tf040.d.ts +30 -0
  40. package/dist/lint/post-synth/tf040.d.ts.map +1 -0
  41. package/dist/lint/post-synth/tf041.d.ts +20 -0
  42. package/dist/lint/post-synth/tf041.d.ts.map +1 -0
  43. package/dist/lint/post-synth/tf042.d.ts +18 -0
  44. package/dist/lint/post-synth/tf042.d.ts.map +1 -0
  45. package/dist/lint/post-synth/tf043.d.ts +17 -0
  46. package/dist/lint/post-synth/tf043.d.ts.map +1 -0
  47. package/dist/lint/post-synth/tf044.d.ts +17 -0
  48. package/dist/lint/post-synth/tf044.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf045.d.ts +22 -0
  50. package/dist/lint/post-synth/tf045.d.ts.map +1 -0
  51. package/dist/manifest.json +1 -1
  52. package/dist/okf/index.md +8 -0
  53. package/dist/okf/rules/TF038.md +11 -0
  54. package/dist/okf/rules/TF039.md +11 -0
  55. package/dist/okf/rules/TF040.md +11 -0
  56. package/dist/okf/rules/TF041.md +11 -0
  57. package/dist/okf/rules/TF042.md +11 -0
  58. package/dist/okf/rules/TF043.md +11 -0
  59. package/dist/okf/rules/TF044.md +11 -0
  60. package/dist/okf/rules/TF045.md +11 -0
  61. package/dist/op/activities/index.d.ts +4 -0
  62. package/dist/op/activities/index.d.ts.map +1 -1
  63. package/dist/op/activities/pin-rollout.d.ts +46 -0
  64. package/dist/op/activities/pin-rollout.d.ts.map +1 -0
  65. package/dist/op/activities/terraform.d.ts +24 -50
  66. package/dist/op/activities/terraform.d.ts.map +1 -1
  67. package/dist/op/activity-contracts.d.ts +48 -0
  68. package/dist/op/activity-contracts.d.ts.map +1 -1
  69. package/dist/op/builders.d.ts +9 -0
  70. package/dist/op/builders.d.ts.map +1 -1
  71. package/dist/pin/edit-ts.d.ts +21 -0
  72. package/dist/pin/edit-ts.d.ts.map +1 -0
  73. package/dist/pin/edit.d.ts +52 -0
  74. package/dist/pin/edit.d.ts.map +1 -0
  75. package/dist/pin/forge.d.ts +39 -0
  76. package/dist/pin/forge.d.ts.map +1 -0
  77. package/dist/pin/index.d.ts +22 -0
  78. package/dist/pin/index.d.ts.map +1 -0
  79. package/dist/pin/rollout.d.ts +116 -0
  80. package/dist/pin/rollout.d.ts.map +1 -0
  81. package/dist/pin/scan.d.ts +54 -0
  82. package/dist/pin/scan.d.ts.map +1 -0
  83. package/dist/pin/source.d.ts +93 -0
  84. package/dist/pin/source.d.ts.map +1 -0
  85. package/dist/pin/waves.d.ts +66 -0
  86. package/dist/pin/waves.d.ts.map +1 -0
  87. package/dist/plan-digest.d.ts +59 -0
  88. package/dist/plan-digest.d.ts.map +1 -0
  89. package/dist/plugin.d.ts.map +1 -1
  90. package/dist/rules/module-source.ts +56 -1
  91. package/dist/rules/terragrunt.ts +76 -0
  92. package/dist/rules/tf002.ts +1 -1
  93. package/dist/rules/tf038.ts +71 -0
  94. package/dist/rules/tf039.ts +64 -0
  95. package/dist/rules/tf040.ts +76 -0
  96. package/dist/rules/tf041.ts +56 -0
  97. package/dist/rules/tf042.ts +42 -0
  98. package/dist/rules/tf043.ts +49 -0
  99. package/dist/rules/tf044.ts +51 -0
  100. package/dist/rules/tf045.ts +47 -0
  101. package/dist/terragrunt/affected.d.ts +119 -0
  102. package/dist/terragrunt/affected.d.ts.map +1 -0
  103. package/dist/terragrunt/index.d.ts +11 -0
  104. package/dist/terragrunt/index.d.ts.map +1 -0
  105. package/dist/terragrunt/mocks.d.ts +147 -0
  106. package/dist/terragrunt/mocks.d.ts.map +1 -0
  107. package/dist/terragrunt/run.d.ts +130 -0
  108. package/dist/terragrunt/run.d.ts.map +1 -0
  109. package/dist/terragrunt/units.d.ts +103 -0
  110. package/dist/terragrunt/units.d.ts.map +1 -0
  111. package/dist/terragrunt/wave.d.ts +89 -0
  112. package/dist/terragrunt/wave.d.ts.map +1 -0
  113. package/package.json +12 -2
  114. package/src/__fixtures__/change-set/choudoufu-set-plan.change-set.golden.json +95 -0
  115. package/src/__fixtures__/change-set/choudoufu-set-plan.json +76 -0
  116. package/src/__fixtures__/change-set/choudoufu.part.golden.json +173 -0
  117. package/src/__fixtures__/change-set/choudoufu.plan.json +610 -0
  118. package/src/__fixtures__/change-set/terraform.import-forget.plan.json +81 -0
  119. package/src/__fixtures__/change-set/terraform.part.golden.json +173 -0
  120. package/src/__fixtures__/change-set/terraform.plan.json +589 -0
  121. package/src/__fixtures__/change-set/tofu.part.golden.json +173 -0
  122. package/src/__fixtures__/change-set/tofu.plan.json +588 -0
  123. package/src/__fixtures__/change-set/v1.tf.txt +21 -0
  124. package/src/__fixtures__/change-set/v2-module-app.tf.txt +4 -0
  125. package/src/__fixtures__/change-set/v2.tf.txt +21 -0
  126. package/src/__fixtures__/pin/choudoufu-live-waves-n5.json +83 -0
  127. package/src/__fixtures__/pin/largeset-oci-n5/apply-order.txt +5 -0
  128. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/estate.chdf.hcl +6 -0
  129. package/src/__fixtures__/pin/largeset-oci-n5/estates/e01/main.tf +33 -0
  130. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/estate.chdf.hcl +6 -0
  131. package/src/__fixtures__/pin/largeset-oci-n5/estates/e02/main.tf +45 -0
  132. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/estate.chdf.hcl +6 -0
  133. package/src/__fixtures__/pin/largeset-oci-n5/estates/e03/main.tf +47 -0
  134. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/estate.chdf.hcl +6 -0
  135. package/src/__fixtures__/pin/largeset-oci-n5/estates/e04/main.tf +31 -0
  136. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/estate.chdf.hcl +6 -0
  137. package/src/__fixtures__/pin/largeset-oci-n5/estates/e05/main.tf +27 -0
  138. package/src/__fixtures__/pin/largeset-oci-n5/fixture.json +77 -0
  139. package/src/__fixtures__/plan-summary/largeset-n5-bump.json +2071 -0
  140. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.json +95 -0
  141. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.md +22 -0
  142. package/src/__fixtures__/plan-summary/largeset-n5.summary.golden.txt +12 -0
  143. package/src/__fixtures__/terragrunt/affected/catalog/units/web/terragrunt.hcl +3 -0
  144. package/src/__fixtures__/terragrunt/affected/gitignore +3 -0
  145. package/src/__fixtures__/terragrunt/affected/live/dev/app/terragrunt.hcl +3 -0
  146. package/src/__fixtures__/terragrunt/affected/live/dev/vpc/terragrunt.hcl +3 -0
  147. package/src/__fixtures__/terragrunt/affected/live/prod/app/terragrunt.hcl +3 -0
  148. package/src/__fixtures__/terragrunt/affected/live/stg/terragrunt.stack.hcl +5 -0
  149. package/src/__fixtures__/terragrunt/affected/modules/app/main.tf +6 -0
  150. package/src/__fixtures__/terragrunt/affected/modules/app/policy.json +1 -0
  151. package/src/__fixtures__/terragrunt/affected/modules/net/main.tf +2 -0
  152. package/src/__fixtures__/terragrunt/affected/modules/thing/main.tf +3 -0
  153. package/src/__fixtures__/terragrunt/affected/root.hcl +5 -0
  154. package/src/__fixtures__/terragrunt/affected-cases.ts +126 -0
  155. package/src/__fixtures__/terragrunt/five-units/catalog/units/thing/terragrunt.hcl +3 -0
  156. package/src/__fixtures__/terragrunt/five-units/live/dev/app/terragrunt.hcl +13 -0
  157. package/src/__fixtures__/terragrunt/five-units/live/dev/db/terragrunt.hcl +8 -0
  158. package/src/__fixtures__/terragrunt/five-units/live/dev/vpc/terragrunt.hcl +3 -0
  159. package/src/__fixtures__/terragrunt/five-units/live/prod/app/terragrunt.hcl +8 -0
  160. package/src/__fixtures__/terragrunt/five-units/live/prod/vpc/terragrunt.hcl +3 -0
  161. package/src/__fixtures__/terragrunt/five-units/modules/thing/main.tf +7 -0
  162. package/src/__fixtures__/terragrunt/five-units/root.hcl +5 -0
  163. package/src/__fixtures__/terragrunt/record.ts +56 -0
  164. package/src/__fixtures__/terragrunt/recorded/affected-find.json +50 -0
  165. package/src/__fixtures__/terragrunt/recorded/failed-plan-report.json +36 -0
  166. package/src/__fixtures__/terragrunt/recorded/find.json +67 -0
  167. package/src/__fixtures__/terragrunt/recorded/version.txt +1 -0
  168. package/src/__fixtures__/terragrunt/recorded/wave-1/apply-report.json +22 -0
  169. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/dev/vpc/tfplan.json +1 -0
  170. package/src/__fixtures__/terragrunt/recorded/wave-1/json/live/prod/vpc/tfplan.json +1 -0
  171. package/src/__fixtures__/terragrunt/recorded/wave-1/plan-report.json +22 -0
  172. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/dev/db/tfplan.json +1 -0
  173. package/src/__fixtures__/terragrunt/recorded/wave-2/json/live/prod/app/tfplan.json +1 -0
  174. package/src/__fixtures__/terragrunt/recorded/wave-2/plan-report.json +22 -0
  175. package/src/change-set-combined-run.test.ts +156 -0
  176. package/src/change-set.test.ts +151 -0
  177. package/src/change-set.ts +273 -0
  178. package/src/changed-roots.test.ts +58 -0
  179. package/src/changed-roots.ts +102 -0
  180. package/src/commands.ts +112 -0
  181. package/src/components/capability-plugin.test.ts +91 -0
  182. package/src/components/capability-plugin.ts +131 -0
  183. package/src/components/pr-loop.tofu.test.ts +244 -0
  184. package/src/composites/terraform-pin-rollout-op.test.ts +69 -0
  185. package/src/composites/terraform-pin-rollout-op.ts +60 -0
  186. package/src/hcl/descend.ts +1 -1
  187. package/src/hcl/edges.ts +2 -2
  188. package/src/hcl/parse.test.ts +25 -0
  189. package/src/hcl/parse.ts +91 -6
  190. package/src/hcl/roots.ts +1 -1
  191. package/src/hcl/suppressions.ts +8 -2
  192. package/src/index.ts +18 -0
  193. package/src/lint/audit-catalog.ts +82 -0
  194. package/src/lint/audit.test.ts +3 -1
  195. package/src/lint/post-synth/fixtures/TF038/positive.tf +3 -0
  196. package/src/lint/post-synth/fixtures/TF039/positive.tf +4 -0
  197. package/src/lint/post-synth/fixtures/TF041/negative/terragrunt.hcl +12 -0
  198. package/src/lint/post-synth/fixtures/TF041/positive/terragrunt.hcl +7 -0
  199. package/src/lint/post-synth/fixtures/TF041/positive-apply/terragrunt.hcl +8 -0
  200. package/src/lint/post-synth/fixtures/TF042/negative/terragrunt.hcl +13 -0
  201. package/src/lint/post-synth/fixtures/TF042/positive/terragrunt.hcl +9 -0
  202. package/src/lint/post-synth/fixtures/TF043/negative/terragrunt.hcl +3 -0
  203. package/src/lint/post-synth/fixtures/TF043/negative-other/terragrunt.hcl +3 -0
  204. package/src/lint/post-synth/fixtures/TF043/positive-git/terragrunt.hcl +3 -0
  205. package/src/lint/post-synth/fixtures/TF043/positive-oci/terragrunt.hcl +3 -0
  206. package/src/lint/post-synth/fixtures/TF043/positive-tfr/terragrunt.hcl +3 -0
  207. package/src/lint/post-synth/fixtures/TF044/negative/terragrunt.hcl +9 -0
  208. package/src/lint/post-synth/fixtures/TF044/positive/terragrunt.hcl +7 -0
  209. package/src/lint/post-synth/fixtures/TF044/positive-generate/terragrunt.hcl +9 -0
  210. package/src/lint/post-synth/fixtures/TF045/negative/root.hcl +1 -0
  211. package/src/lint/post-synth/fixtures/TF045/negative/terragrunt.hcl +3 -0
  212. package/src/lint/post-synth/fixtures/TF045/positive/root.hcl +9 -0
  213. package/src/lint/post-synth/fixtures/TF045/positive/terragrunt.hcl +3 -0
  214. package/src/lint/post-synth/fixtures/TF045/unit-only/terragrunt.hcl +3 -0
  215. package/src/lint/post-synth/index.ts +16 -0
  216. package/src/lint/post-synth/module-source.test.ts +42 -1
  217. package/src/lint/post-synth/module-source.ts +56 -1
  218. package/src/lint/post-synth/terragrunt.ts +76 -0
  219. package/src/lint/post-synth/tf002.ts +1 -1
  220. package/src/lint/post-synth/tf038.test.ts +51 -0
  221. package/src/lint/post-synth/tf038.ts +71 -0
  222. package/src/lint/post-synth/tf039.test.ts +40 -0
  223. package/src/lint/post-synth/tf039.ts +64 -0
  224. package/src/lint/post-synth/tf040.test.ts +55 -0
  225. package/src/lint/post-synth/tf040.ts +76 -0
  226. package/src/lint/post-synth/tf041.test.ts +50 -0
  227. package/src/lint/post-synth/tf041.ts +56 -0
  228. package/src/lint/post-synth/tf042.test.ts +19 -0
  229. package/src/lint/post-synth/tf042.ts +42 -0
  230. package/src/lint/post-synth/tf043.test.ts +65 -0
  231. package/src/lint/post-synth/tf043.ts +49 -0
  232. package/src/lint/post-synth/tf044.test.ts +27 -0
  233. package/src/lint/post-synth/tf044.ts +51 -0
  234. package/src/lint/post-synth/tf045.test.ts +30 -0
  235. package/src/lint/post-synth/tf045.ts +47 -0
  236. package/src/op/activities/index.ts +4 -0
  237. package/src/op/activities/pin-rollout.ts +77 -0
  238. package/src/op/activities/terraform.test.ts +13 -0
  239. package/src/op/activities/terraform.ts +46 -86
  240. package/src/op/activity-contracts.test.ts +4 -1
  241. package/src/op/activity-contracts.ts +42 -0
  242. package/src/op/builders.ts +13 -0
  243. package/src/pin/bundle.test.ts +53 -0
  244. package/src/pin/edit-ts.test.ts +39 -0
  245. package/src/pin/edit-ts.ts +77 -0
  246. package/src/pin/edit.test.ts +199 -0
  247. package/src/pin/edit.ts +157 -0
  248. package/src/pin/forge.ts +85 -0
  249. package/src/pin/index.ts +22 -0
  250. package/src/pin/rollout.test.ts +232 -0
  251. package/src/pin/rollout.ts +451 -0
  252. package/src/pin/scan.ts +309 -0
  253. package/src/pin/source.ts +206 -0
  254. package/src/pin/waves.test.ts +101 -0
  255. package/src/pin/waves.ts +125 -0
  256. package/src/plan-digest.ts +99 -0
  257. package/src/plan-summary.test.ts +154 -0
  258. package/src/plugin.ts +25 -2
  259. package/src/terragrunt/affected.acceptance.test.ts +83 -0
  260. package/src/terragrunt/affected.test.ts +177 -0
  261. package/src/terragrunt/affected.ts +348 -0
  262. package/src/terragrunt/index.ts +11 -0
  263. package/src/terragrunt/mocks.acceptance.test.ts +105 -0
  264. package/src/terragrunt/mocks.test.ts +309 -0
  265. package/src/terragrunt/mocks.ts +324 -0
  266. package/src/terragrunt/run.ts +369 -0
  267. package/src/terragrunt/terragrunt.acceptance.test.ts +122 -0
  268. package/src/terragrunt/terragrunt.test.ts +332 -0
  269. package/src/terragrunt/units.ts +235 -0
  270. package/src/terragrunt/wave.ts +181 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Roll a module version out as pin-bump pull requests, one wave at a time
3
+ * (#3189, choudoufu#1749).
4
+ *
5
+ * Each run reads where the rollout stands and does at most one thing:
6
+ *
7
+ * 1. It checks out the base branch in a temporary worktree and reads every
8
+ * root's pin there: at the old version, at the new one, refused (a
9
+ * floating constraint, no pin, an expression), or pinned at something
10
+ * else. Only roots at the old or the new version are in the rollout, so
11
+ * the wave plan is the same on every run.
12
+ * 2. It splits those roots into waves (`./waves.ts`).
13
+ * 3. It walks the waves in order, reading each wave's pull request by its
14
+ * branch. A wave whose PR merged, and whose every moved root reports its
15
+ * apply check as passed on the merge commit, is done. The first wave that
16
+ * is not done decides the run:
17
+ * - no PR yet: open it (one branch, one PR, the pin moved for that wave's
18
+ * roots only), or in `report` mode say it would;
19
+ * - PR open, or merged with an apply still pending: report where it
20
+ * stands and open nothing;
21
+ * - PR closed without merging, or a root whose apply check failed: stop,
22
+ * naming it. The waves after it do not open.
23
+ *
24
+ * That is #2119's gate as a fact: nothing waits. The next run reads the
25
+ * forge again and either moves on or reports the same place.
26
+ *
27
+ * The PR's branch is built from the base branch in a worktree and pushed.
28
+ * The default branch, and whatever the caller has checked out, are never
29
+ * written, as with core's `proposeWorkspaceUpgrade` (#2550). Each PR changes
30
+ * only the files of its wave's roots, so a path-diff selection (GitLab's
31
+ * `rules: changes:`, #3183's affected members) plans exactly those roots.
32
+ *
33
+ * An "applied" root is one whose apply check, named by `appliedCheck`
34
+ * (default `apply/{root}`), passed on the merge commit. That is how a
35
+ * per-root apply job on the merged commit reports today; when the per-PR
36
+ * loop (#3183) lands, its per-member check name is the one to pass.
37
+ */
38
+
39
+ import { execFile } from "node:child_process";
40
+ import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs";
41
+ import { tmpdir } from "node:os";
42
+ import { join, posix } from "node:path";
43
+ import { promisify } from "node:util";
44
+ import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
45
+ import type { CommandRunner } from "@intentius/chant/op/activities/propose-upgrade";
46
+ import { editPins, type PinCallResult, type PinEditResult, type PinRequest } from "./edit";
47
+ import { checkPinRequest, moduleOf } from "./source";
48
+ import { planPinWaves, restrictWaves, terragruntDependencies, type PinRoot, type PinWave } from "./waves";
49
+ import { ghPinForge, type PinForge } from "./forge";
50
+
51
+ const execFileAsync = promisify(execFile);
52
+
53
+ /** Edits the module declaration in a generated root's TypeScript source (`./edit-ts.ts`). */
54
+ export type TsPinEditor = (text: string, file: string, request: PinRequest) => PinEditResult;
55
+
56
+ export type PinRolloutMode = "report" | "pull-request";
57
+
58
+ export interface PinRolloutOptions extends PinRequest {
59
+ /** A directory inside the repository. Default: the working directory. */
60
+ cwd?: string;
61
+ /** The roots to roll out. Default: every directory under the repository with a `.tf` file or a `terragrunt.hcl` that calls the module. */
62
+ roots?: PinRoot[];
63
+ /** Roots that form wave 1. */
64
+ canaries?: string[];
65
+ /** A wave plan to use instead of canaries and dependency order, such as {@link wavesFromChoudoufu}'s. */
66
+ waves?: PinWave[];
67
+ /** `report` (default) opens nothing; `pull-request` opens the next wave's PR when it is due. */
68
+ mode?: PinRolloutMode;
69
+ /** The branch PRs target. Default: the remote's default branch. */
70
+ base?: string;
71
+ /** Default `origin`. */
72
+ remote?: string;
73
+ /** The check each moved root's apply reports on the merge commit. `{root}` is the root's directory. Default `apply/{root}`. */
74
+ appliedCheck?: string;
75
+ /** The HCL reader, `loadHcl2json()` from `@intentius/chant/terraform/hcl2json`. */
76
+ parser: Hcl2Json;
77
+ /** The forge. Default: `gh`, through `run`. */
78
+ forge?: PinForge;
79
+ /** Runs `git` (and `gh`, for the default forge). For tests. */
80
+ run?: CommandRunner;
81
+ /** The TypeScript editor for generated roots. Without it, a root with `tsSource` is refused. */
82
+ editTs?: TsPinEditor;
83
+ /** Markdown appended to a wave's PR body, such as the grouped plan summary of #3188 once a caller has the wave's plans. */
84
+ planSummary?: (wave: PinWave) => Promise<string | undefined>;
85
+ }
86
+
87
+ /** Where one root's pin stood on the base branch. */
88
+ export interface PinRootState {
89
+ root: string;
90
+ /** `from`: at least one call moves; `to`: every call is already moved; the rest are not in the rollout. */
91
+ state: "from" | "to" | "refused" | "elsewhere" | "absent";
92
+ files: string[];
93
+ calls: PinCallResult[];
94
+ reason?: string;
95
+ }
96
+
97
+ export type PinWaveState =
98
+ | "applied"
99
+ | "nothing-to-move"
100
+ | "opened"
101
+ | "would-open"
102
+ | "open"
103
+ | "waiting-apply"
104
+ | "failed"
105
+ | "closed"
106
+ | "not-reached";
107
+
108
+ export interface PinWaveStatus extends PinWave {
109
+ branch: string;
110
+ state: PinWaveState;
111
+ pr?: string;
112
+ /** Roots whose apply check has not reported yet. */
113
+ pending?: string[];
114
+ /** Roots whose apply check failed. */
115
+ failed?: string[];
116
+ /** Files the wave's PR changes (`opened` and `would-open`). */
117
+ files?: string[];
118
+ }
119
+
120
+ export interface PinRolloutResult extends PinRequest {
121
+ mode: PinRolloutMode;
122
+ base: string;
123
+ /**
124
+ * `complete`: every wave applied. `opened`/`would-open`: the next wave's PR
125
+ * was opened, or would be. `waiting`: a PR is open or an apply has not
126
+ * reported. `stopped`: a root failed or a PR closed unmerged, and `stop` says
127
+ * which.
128
+ */
129
+ status: "complete" | "opened" | "would-open" | "waiting" | "stopped";
130
+ roots: PinRootState[];
131
+ waves: PinWaveStatus[];
132
+ stop?: string;
133
+ summary: string;
134
+ }
135
+
136
+ const defaultRun: CommandRunner = async (bin, args, cwd) => (await execFileAsync(bin, args, { cwd, maxBuffer: 64 * 1024 * 1024 })).stdout;
137
+
138
+ const SKIP_DIRS = new Set([".git", ".terraform", ".terragrunt-cache", "node_modules"]);
139
+
140
+ const NO_FORGE: PinForge = {
141
+ findPullRequest: async () => null,
142
+ createPullRequest: async () => {
143
+ throw new Error("pin rollout: no forge to open a pull request on");
144
+ },
145
+ commitChecks: async () => [],
146
+ };
147
+
148
+ /** The marker that ties a PR to its wave, and carries the roots it moved. */
149
+ const MARKER = "chant-pin-rollout";
150
+
151
+ /** A root directory as the rollout keys it: relative, `/`-separated, no `./` and no trailing slash. */
152
+ function dirOf(path: string): string {
153
+ return posix.normalize(path.replaceAll("\\", "/")).replace(/\/+$/, "") || ".";
154
+ }
155
+
156
+ function slug(value: string): string {
157
+ const s = value.replace(/^[a-z]+:\/\//, "").replace(/^sha256:([0-9a-f]{12})[0-9a-f]*$/i, "sha256-$1");
158
+ return s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
159
+ }
160
+
161
+ /** The branch a wave's PR is opened from. */
162
+ export function pinWaveBranch(request: PinRequest, wave: number): string {
163
+ return `chant/pin/${slug(request.module)}/${slug(request.to)}/wave-${wave}`;
164
+ }
165
+
166
+ /** The files the pin edit reads in a root directory: its `.tf` files and its `terragrunt.hcl`. */
167
+ function rootFiles(worktree: string, root: string): string[] {
168
+ const dir = join(worktree, root);
169
+ if (!existsSync(dir)) return [];
170
+ return readdirSync(dir)
171
+ .filter((f) => f.endsWith(".tf") || f === "terragrunt.hcl")
172
+ .sort()
173
+ .map((f) => posix.join(root, f));
174
+ }
175
+
176
+ /** Every directory under the worktree with a `.tf` file or a `terragrunt.hcl`. */
177
+ function candidateRoots(worktree: string): string[] {
178
+ const out: string[] = [];
179
+ const walk = (rel: string) => {
180
+ const entries = readdirSync(join(worktree, rel), { withFileTypes: true });
181
+ if (entries.some((e) => e.isFile() && (e.name.endsWith(".tf") || e.name === "terragrunt.hcl"))) out.push(rel || ".");
182
+ for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(rel ? posix.join(rel, e.name) : e.name);
183
+ };
184
+ walk("");
185
+ return out.sort();
186
+ }
187
+
188
+ async function editRoot(worktree: string, root: PinRoot, request: PinRequest, options: PinRolloutOptions): Promise<{ state: PinRootState; edits: Map<string, string> }> {
189
+ const edits = new Map<string, string>();
190
+ const calls: PinCallResult[] = [];
191
+ let files: string[];
192
+ if (root.tsSource) {
193
+ files = [root.tsSource];
194
+ if (!options.editTs) {
195
+ return { state: { root: root.root, state: "refused", files, calls, reason: `generated from ${root.tsSource}, and this caller has no TypeScript editor` }, edits };
196
+ }
197
+ } else {
198
+ files = rootFiles(worktree, root.root);
199
+ }
200
+ for (const file of files) {
201
+ const path = join(worktree, file);
202
+ if (!existsSync(path)) return { state: { root: root.root, state: "refused", files, calls, reason: `${file} does not exist on the base branch` }, edits };
203
+ const text = readFileSync(path, "utf-8");
204
+ const result = root.tsSource ? options.editTs!(text, file, request) : await editPins(text, file, request, options.parser);
205
+ calls.push(...result.calls);
206
+ if (result.content !== text) edits.set(file, result.content);
207
+ }
208
+ const refused = calls.filter((c) => c.outcome === "refused");
209
+ let state: PinRootState["state"];
210
+ let reason: string | undefined;
211
+ if (calls.length === 0) {
212
+ state = "absent";
213
+ reason = "no call of the module";
214
+ } else if (refused.length > 0) {
215
+ state = "refused";
216
+ reason = refused.map((c) => `${c.file} ${c.call}: ${"reason" in c ? c.reason : ""}`).join("; ");
217
+ } else if (calls.some((c) => c.outcome === "moved")) {
218
+ state = calls.some((c) => c.outcome === "elsewhere") ? "refused" : "from";
219
+ if (state === "refused") reason = "some calls are pinned at another version than the old one";
220
+ } else if (calls.every((c) => c.outcome === "already")) {
221
+ state = "to";
222
+ } else {
223
+ state = "elsewhere";
224
+ reason = calls.map((c) => ("pin" in c ? `${c.file} ${c.call}: pinned at ${c.pin}` : "")).filter(Boolean).join("; ");
225
+ }
226
+ return { state: { root: root.root, state, files, calls, ...(reason ? { reason } : {}) }, edits: state === "from" ? edits : new Map() };
227
+ }
228
+
229
+ function parseMarker(body: string): { roots?: string[] } | null {
230
+ const m = new RegExp(`<!-- ${MARKER} (\\{.*?\\}) -->`).exec(body);
231
+ if (!m) return null;
232
+ try {
233
+ return JSON.parse(m[1]!) as { roots?: string[] };
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ function prBody(request: PinRequest, wave: PinWave, total: number, moved: PinRootState[], refused: PinRootState[], checkName: (root: string) => string, summary?: string): string {
240
+ const marker = JSON.stringify({ module: request.module, from: request.from, to: request.to, wave: wave.wave, roots: moved.map((r) => r.root) });
241
+ return [
242
+ `<!-- ${MARKER} ${marker} -->`,
243
+ `## Pin bump, wave ${wave.wave} of ${total}${wave.canary ? " (canaries)" : ""}`,
244
+ "",
245
+ `\`${request.module}\` pin ${request.from} -> ${request.to}`,
246
+ "",
247
+ `Roots in this wave (${moved.length}):`,
248
+ "",
249
+ ...moved.map((r) => `- \`${r.root}\`: ${r.calls.filter((c) => c.outcome === "moved").map((c) => `${c.file} ${c.call}`).join(", ")}`),
250
+ "",
251
+ "This PR changes only these roots' files, so a path-diff selection plans exactly these roots.",
252
+ ...(refused.length > 0 ? ["", "Not moved by this rollout:", "", ...refused.map((r) => `- \`${r.root}\`: ${r.reason}`)] : []),
253
+ ...(summary ? ["", summary] : []),
254
+ "",
255
+ wave.wave < total
256
+ ? `Wave ${wave.wave + 1} opens on a later run, once this PR has merged and each root above reports its apply check (${moved.length > 0 ? `\`${checkName(moved[0]!.root)}\`` : "none"}${moved.length > 1 ? " and the rest" : ""}) as passed on the merge commit.`
257
+ : "This is the last wave.",
258
+ "",
259
+ "Opened by the terraform lexicon's pin rollout. It never writes the default branch.",
260
+ ].join("\n");
261
+ }
262
+
263
+ async function defaultBranch(run: CommandRunner, repo: string, remote: string): Promise<string | null> {
264
+ try {
265
+ const ref = (await run("git", ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], repo)).trim();
266
+ return ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /** Read where a rollout stands, and open the next wave's PR when it is due and `mode` is `pull-request`. */
273
+ export async function runPinRollout(options: PinRolloutOptions): Promise<PinRolloutResult> {
274
+ const request: PinRequest = { module: moduleOf(options.module), from: options.from, to: options.to };
275
+ checkPinRequest(request);
276
+ const mode = options.mode ?? "report";
277
+ const run = options.run ?? defaultRun;
278
+ const remote = options.remote ?? "origin";
279
+ const repo = (await run("git", ["rev-parse", "--show-toplevel"], options.cwd ?? process.cwd())).trim();
280
+ const checkName = (root: string) => (options.appliedCheck ?? "apply/{root}").replaceAll("{root}", root);
281
+
282
+ let hasRemote = true;
283
+ try {
284
+ await run("git", ["remote", "get-url", remote], repo);
285
+ } catch {
286
+ hasRemote = false;
287
+ }
288
+ if (mode === "pull-request" && !hasRemote) throw new Error(`pin rollout: no remote "${remote}" to open a pull request on`);
289
+ // With no remote there is no forge to have opened a PR on, so a report reads every wave as unopened.
290
+ const forge = options.forge ?? (hasRemote ? ghPinForge(run, repo) : NO_FORGE);
291
+ if (hasRemote) await run("git", ["fetch", "--quiet", remote], repo);
292
+ const base = options.base ?? (hasRemote ? await defaultBranch(run, repo, remote) : null) ?? (await run("git", ["symbolic-ref", "--short", "HEAD"], repo)).trim();
293
+ const baseRef = hasRemote ? `refs/remotes/${remote}/${base}` : `refs/heads/${base}`;
294
+
295
+ const worktree = mkdtempSync(join(tmpdir(), "chant-pin-"));
296
+ await run("git", ["worktree", "add", "--quiet", "--detach", worktree, baseRef], repo);
297
+ try {
298
+ // 1. Every root's pin on the base branch.
299
+ const declared = (options.roots ?? candidateRoots(worktree).map((root): PinRoot => ({ root }))).map((r) => ({
300
+ ...r,
301
+ root: dirOf(r.root),
302
+ ...(r.dependsOn ? { dependsOn: r.dependsOn.map(dirOf) } : {}),
303
+ }));
304
+ const roots: PinRootState[] = [];
305
+ const edits = new Map<string, Map<string, string>>();
306
+ const byRoot = new Map<string, PinRoot>();
307
+ for (const root of declared) {
308
+ const { state, edits: e } = await editRoot(worktree, root, request, options);
309
+ if (options.roots || state.state !== "absent") roots.push(state);
310
+ edits.set(root.root, e);
311
+ byRoot.set(root.root, root);
312
+ }
313
+ const inRollout = roots.filter((r) => r.state === "from" || r.state === "to");
314
+ const outside = roots.filter((r) => !(r.state === "from" || r.state === "to"));
315
+
316
+ // 2. The waves, over the roots at the old or the new pin.
317
+ const keep = new Set(inRollout.map((r) => r.root));
318
+ let waves: PinWave[];
319
+ if (options.waves) {
320
+ waves = restrictWaves(options.waves, keep);
321
+ const planned = new Set(waves.flatMap((w) => w.roots));
322
+ const missing = [...keep].filter((r) => !planned.has(r));
323
+ if (missing.length > 0) throw new Error(`pin rollout: the wave plan does not place ${missing.join(", ")}`);
324
+ } else {
325
+ const withDeps: PinRoot[] = [];
326
+ for (const r of inRollout) {
327
+ const declaredRoot = byRoot.get(r.root)!;
328
+ const tg = join(worktree, r.root, "terragrunt.hcl");
329
+ const fromTg = !declaredRoot.tsSource && existsSync(tg) && statSync(tg).isFile() ? await terragruntDependencies(r.root, readFileSync(tg, "utf-8"), options.parser) : [];
330
+ withDeps.push({ ...declaredRoot, dependsOn: [...new Set([...(declaredRoot.dependsOn ?? []), ...fromTg])] });
331
+ }
332
+ // A canary that is a root but out of the rollout (refused, say) is dropped; one that is no root at all is refused by the planner.
333
+ const canaries = (options.canaries ?? []).map(dirOf);
334
+ waves = planPinWaves(withDeps, canaries.filter((c) => keep.has(c) || !roots.some((r) => r.root === c)));
335
+ }
336
+
337
+ // 3. Walk the waves.
338
+ const statuses: PinWaveStatus[] = waves.map((w) => ({ ...w, branch: pinWaveBranch(request, w.wave), state: "not-reached" }));
339
+ let status: PinRolloutResult["status"] = "complete";
340
+ let stop: string | undefined;
341
+ for (const wave of statuses) {
342
+ if (wave.branch === base) throw new Error(`pin rollout will not write the base branch "${base}"`);
343
+ const pr = await forge.findPullRequest(wave.branch);
344
+ if (pr) wave.pr = pr.url;
345
+ if (pr?.state === "open") {
346
+ wave.state = "open";
347
+ status = "waiting";
348
+ break;
349
+ }
350
+ if (pr?.state === "closed") {
351
+ wave.state = "closed";
352
+ status = "stopped";
353
+ stop = `wave ${wave.wave}'s PR ${pr.url} was closed without merging`;
354
+ break;
355
+ }
356
+ if (pr?.state === "merged") {
357
+ const moved = parseMarker(pr.body)?.roots ?? wave.roots;
358
+ const checks = pr.mergeCommit ? await forge.commitChecks(pr.mergeCommit) : [];
359
+ const stateOf = (root: string) => checks.find((c) => c.name === checkName(root))?.state ?? "pending";
360
+ wave.failed = moved.filter((r) => stateOf(r) === "failure");
361
+ wave.pending = moved.filter((r) => stateOf(r) === "pending");
362
+ if (wave.failed.length > 0) {
363
+ wave.state = "failed";
364
+ status = "stopped";
365
+ stop = `wave ${wave.wave}: ${wave.failed.map((r) => `${r} failed its apply check ${checkName(r)}`).join(", ")}`;
366
+ break;
367
+ }
368
+ if (wave.pending.length > 0) {
369
+ wave.state = "waiting-apply";
370
+ status = "waiting";
371
+ break;
372
+ }
373
+ wave.state = "applied";
374
+ continue;
375
+ }
376
+ // No PR yet: this wave is next.
377
+ const moving = roots.filter((r) => wave.roots.includes(r.root) && r.state === "from");
378
+ if (moving.length === 0) {
379
+ wave.state = "nothing-to-move";
380
+ continue;
381
+ }
382
+ const files = moving.flatMap((r) => [...edits.get(r.root)!.keys()]).sort();
383
+ wave.files = files;
384
+ if (mode === "report") {
385
+ wave.state = "would-open";
386
+ status = "would-open";
387
+ break;
388
+ }
389
+ for (const r of moving) for (const [file, content] of edits.get(r.root)!) writeFileSync(join(worktree, file), content);
390
+ const title = `chore(pin): ${request.module} ${request.from} -> ${request.to}, wave ${wave.wave} of ${statuses.length}`;
391
+ const summary = options.planSummary ? await options.planSummary(wave) : undefined;
392
+ const body = prBody(request, wave, statuses.length, moving, outside, checkName, summary);
393
+ await run("git", ["add", "--", ...files], worktree);
394
+ const identity: string[] = [];
395
+ const has = async (key: string) => {
396
+ try {
397
+ return (await run("git", ["config", key], worktree)).trim().length > 0;
398
+ } catch {
399
+ return false;
400
+ }
401
+ };
402
+ if (!(await has("user.name"))) identity.push("-c", "user.name=chant");
403
+ if (!(await has("user.email"))) identity.push("-c", "user.email=chant@localhost");
404
+ await run("git", [...identity, "commit", "--quiet", "--no-verify", "-m", `${title}\n\nRoots: ${moving.map((r) => r.root).join(", ")}\n`], worktree);
405
+ // The branch is rebuilt from the base branch on every run that opens it, so it is replaced.
406
+ await run("git", ["push", "--quiet", "--force", remote, `HEAD:refs/heads/${wave.branch}`], worktree);
407
+ wave.pr = await forge.createPullRequest({ base, head: wave.branch, title, body });
408
+ wave.state = "opened";
409
+ status = "opened";
410
+ break;
411
+ }
412
+ if (statuses.length === 0) status = "complete";
413
+
414
+ const result: PinRolloutResult = { ...request, mode, base, status, roots, waves: statuses, ...(stop ? { stop } : {}), summary: "" };
415
+ result.summary = renderPinRollout(result);
416
+ return result;
417
+ } finally {
418
+ try {
419
+ await run("git", ["worktree", "remove", "--force", worktree], repo);
420
+ } catch {
421
+ rmSync(worktree, { recursive: true, force: true });
422
+ }
423
+ }
424
+ }
425
+
426
+ const WAVE_TEXT: Record<PinWaveState, string> = {
427
+ applied: "merged and applied",
428
+ "nothing-to-move": "nothing to move",
429
+ opened: "PR opened",
430
+ "would-open": "next; would open its PR",
431
+ open: "PR open, waiting for merge",
432
+ "waiting-apply": "merged, waiting for apply",
433
+ failed: "merged, apply failed",
434
+ closed: "PR closed without merging",
435
+ "not-reached": "not opened",
436
+ };
437
+
438
+ /** The rollout for a terminal or a run log. */
439
+ export function renderPinRollout(result: Omit<PinRolloutResult, "summary">): string {
440
+ const lines = [`${result.module} pin ${result.from} -> ${result.to} on ${result.base}: ${result.status}${result.stop ? ` (${result.stop})` : ""}`];
441
+ for (const w of result.waves) {
442
+ lines.push(` wave ${w.wave}${w.canary ? " (canaries)" : ""}: ${WAVE_TEXT[w.state]}${w.pr ? ` ${w.pr}` : ""}`);
443
+ lines.push(` roots: ${w.roots.join(", ")}`);
444
+ if (w.pending?.length) lines.push(` apply pending: ${w.pending.join(", ")}`);
445
+ if (w.failed?.length) lines.push(` apply failed: ${w.failed.join(", ")}`);
446
+ if (w.files?.length) lines.push(` files: ${w.files.join(", ")}`);
447
+ }
448
+ for (const r of result.roots.filter((r) => r.state !== "from" && r.state !== "to")) lines.push(` not in the rollout: ${r.root}: ${r.reason ?? r.state}`);
449
+ return lines.join("\n");
450
+ }
451
+