@intentius/chant 0.124.32 → 0.124.33

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/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +16 -1
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +81 -21
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +23 -1
  24. package/dist/cli/registry.d.ts +18 -4
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/release-compile.d.ts +37 -2
  40. package/dist/components/release-compile.d.ts.map +1 -1
  41. package/dist/components/release-compile.js +64 -8
  42. package/dist/components/release-promote.d.ts +17 -3
  43. package/dist/components/release-promote.d.ts.map +1 -1
  44. package/dist/components/release-promote.js +79 -18
  45. package/dist/components/verbs/bom-writer.d.ts +1 -1
  46. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  47. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  48. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  49. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  50. package/dist/components/wave-plan.d.ts +10 -0
  51. package/dist/components/wave-plan.d.ts.map +1 -1
  52. package/dist/components/wave-plan.js +32 -4
  53. package/dist/graph-detail.d.ts.map +1 -1
  54. package/dist/graph-detail.js +32 -7
  55. package/dist/health.d.ts +110 -0
  56. package/dist/health.d.ts.map +1 -0
  57. package/dist/health.js +105 -0
  58. package/dist/health.schema.json +191 -0
  59. package/dist/lexicon.d.ts +27 -0
  60. package/dist/lexicon.d.ts.map +1 -1
  61. package/dist/lifecycle/merge-record.d.ts +104 -0
  62. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  63. package/dist/lifecycle/merge-record.js +102 -0
  64. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  65. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  66. package/dist/lifecycle/publish-attachments.js +27 -13
  67. package/dist/lifecycle/publish-npm.d.ts +3 -2
  68. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  69. package/dist/lifecycle/publish-npm.js +4 -3
  70. package/dist/lifecycle/release-at.d.ts +40 -0
  71. package/dist/lifecycle/release-at.d.ts.map +1 -0
  72. package/dist/lifecycle/release-at.js +83 -0
  73. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  74. package/dist/lifecycle/release-cut.js +23 -12
  75. package/dist/lifecycle/release-publish.d.ts +28 -12
  76. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  77. package/dist/lifecycle/release-publish.js +29 -17
  78. package/dist/lifecycle/release-store.d.ts +25 -2
  79. package/dist/lifecycle/release-store.d.ts.map +1 -1
  80. package/dist/lifecycle/release-store.js +40 -13
  81. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  82. package/dist/lifecycle/run-ledger.js +1 -0
  83. package/dist/lint/rules/op/index.d.ts +2 -1
  84. package/dist/lint/rules/op/index.d.ts.map +1 -1
  85. package/dist/lint/rules/op/index.js +4 -2
  86. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  87. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  88. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  89. package/dist/op/activities/activity-contracts.d.ts +5 -0
  90. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  91. package/dist/op/activities/activity-contracts.js +3 -0
  92. package/dist/op/activities/shell.d.ts +38 -0
  93. package/dist/op/activities/shell.d.ts.map +1 -1
  94. package/dist/op/activities/shell.js +89 -3
  95. package/dist/op/builders.d.ts +7 -0
  96. package/dist/op/builders.d.ts.map +1 -1
  97. package/dist/op/builders.js +7 -0
  98. package/dist/op/runtime.d.ts +2 -0
  99. package/dist/op/runtime.d.ts.map +1 -1
  100. package/dist/pr-loop.d.ts +14 -3
  101. package/dist/pr-loop.d.ts.map +1 -1
  102. package/dist/pr-loop.js +15 -5
  103. package/dist/pr-report-url.d.ts +2 -2
  104. package/dist/pr-report-url.d.ts.map +1 -1
  105. package/dist/runner-start.d.ts +179 -0
  106. package/dist/runner-start.d.ts.map +1 -0
  107. package/dist/runner-start.js +277 -0
  108. package/dist/wave-runtime.mjs +235 -228
  109. package/dist/workspace/check.schema.json +70 -2
  110. package/dist/workspace/checks/security.d.ts +35 -0
  111. package/dist/workspace/checks/security.d.ts.map +1 -0
  112. package/dist/workspace/checks/security.js +305 -0
  113. package/dist/workspace/checks.d.ts +2 -0
  114. package/dist/workspace/checks.d.ts.map +1 -1
  115. package/dist/workspace/checks.js +5 -0
  116. package/dist/workspace/ci-builds.d.ts +163 -0
  117. package/dist/workspace/ci-builds.d.ts.map +1 -0
  118. package/dist/workspace/ci-builds.js +315 -0
  119. package/dist/workspace/ci-green-cli.d.ts +2 -1
  120. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  121. package/dist/workspace/ci-green-cli.js +25 -5
  122. package/dist/workspace/ci-green-forge.d.ts +12 -0
  123. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  124. package/dist/workspace/ci-green-forge.js +12 -0
  125. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  126. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  127. package/dist/workspace/ci-green-workflow.js +128 -1
  128. package/dist/workspace/ci-green.d.ts +89 -4
  129. package/dist/workspace/ci-green.d.ts.map +1 -1
  130. package/dist/workspace/ci-green.js +310 -23
  131. package/dist/workspace/ci-last-green.schema.json +49 -0
  132. package/dist/workspace/ci-list.schema.json +35 -1
  133. package/dist/workspace/ci-run-index.d.ts +1 -1
  134. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  135. package/dist/workspace/ci-run-index.schema.json +1 -1
  136. package/dist/workspace/ci-run.d.ts +12 -6
  137. package/dist/workspace/ci-run.d.ts.map +1 -1
  138. package/dist/workspace/ci-run.js +72 -16
  139. package/dist/workspace/ci-runner-start.d.ts +77 -0
  140. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  141. package/dist/workspace/ci-runner-start.js +185 -0
  142. package/dist/workspace/ci-serve.d.ts +76 -7
  143. package/dist/workspace/ci-serve.d.ts.map +1 -1
  144. package/dist/workspace/ci-serve.js +153 -19
  145. package/dist/workspace/declaration.d.ts +17 -0
  146. package/dist/workspace/declaration.d.ts.map +1 -1
  147. package/dist/workspace/declaration.js +3 -0
  148. package/dist/workspace/declaration.schema.json +23 -0
  149. package/dist/workspace/ls.d.ts +2 -0
  150. package/dist/workspace/ls.d.ts.map +1 -1
  151. package/dist/workspace/ls.js +1 -1
  152. package/dist/workspace/ls.schema.json +2 -1
  153. package/dist/workspace/member-pipeline.d.ts +1 -0
  154. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  155. package/dist/workspace/member-pipeline.js +2 -0
  156. package/dist/workspace/merge-record.schema.json +49 -0
  157. package/dist/workspace/pr-report.schema.json +3 -3
  158. package/dist/workspace/reason-codes.d.ts +17 -0
  159. package/dist/workspace/reason-codes.d.ts.map +1 -1
  160. package/dist/workspace/reason-codes.js +18 -0
  161. package/dist/workspace/release-candidates.d.ts +129 -0
  162. package/dist/workspace/release-candidates.d.ts.map +1 -0
  163. package/dist/workspace/release-candidates.js +233 -0
  164. package/dist/workspace/release-candidates.schema.json +353 -0
  165. package/dist/workspace/security.d.ts +40 -0
  166. package/dist/workspace/security.d.ts.map +1 -0
  167. package/dist/workspace/security.js +99 -0
  168. package/package.json +8 -1
  169. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  170. package/src/ci-jobs.ts +11 -2
  171. package/src/cli/handlers/build.test.ts +36 -0
  172. package/src/cli/handlers/build.ts +18 -3
  173. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  174. package/src/cli/handlers/lifecycle-health.ts +157 -0
  175. package/src/cli/handlers/lifecycle.ts +1 -1
  176. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  177. package/src/cli/handlers/pr-record.test.ts +201 -0
  178. package/src/cli/handlers/pr.test.ts +22 -0
  179. package/src/cli/handlers/pr.ts +127 -4
  180. package/src/cli/handlers/promote.test.ts +17 -0
  181. package/src/cli/handlers/promote.ts +16 -1
  182. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  183. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  184. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  185. package/src/cli/handlers/release.e2e.test.ts +19 -28
  186. package/src/cli/handlers/release.ts +81 -11
  187. package/src/cli/main.ts +81 -21
  188. package/src/cli/parse-args.ts +17 -1
  189. package/src/cli/registry.ts +18 -4
  190. package/src/components/deploy-inputs.test.ts +108 -0
  191. package/src/components/deploy-inputs.ts +225 -0
  192. package/src/components/driver.ts +11 -0
  193. package/src/components/gate-plan.ts +9 -0
  194. package/src/components/pr-pipeline.ts +56 -15
  195. package/src/components/pr-run.ts +1 -1
  196. package/src/components/release-compile.test.ts +34 -0
  197. package/src/components/release-compile.ts +89 -8
  198. package/src/components/release-promote.test.ts +55 -2
  199. package/src/components/release-promote.ts +93 -23
  200. package/src/components/verbs/bom-writer.ts +1 -1
  201. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  202. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  203. package/src/components/wave-plan.ts +32 -4
  204. package/src/graph-detail.test.ts +57 -0
  205. package/src/graph-detail.ts +33 -7
  206. package/src/health.schema.json +191 -0
  207. package/src/health.test.ts +62 -0
  208. package/src/health.ts +178 -0
  209. package/src/lexicon.ts +41 -1
  210. package/src/lifecycle/merge-record.test.ts +111 -0
  211. package/src/lifecycle/merge-record.ts +154 -0
  212. package/src/lifecycle/publish-attachments.ts +25 -13
  213. package/src/lifecycle/publish-npm.ts +4 -3
  214. package/src/lifecycle/publish-targets.test.ts +34 -4
  215. package/src/lifecycle/release-at.test.ts +48 -0
  216. package/src/lifecycle/release-at.ts +100 -0
  217. package/src/lifecycle/release-cut.test.ts +41 -0
  218. package/src/lifecycle/release-cut.ts +22 -11
  219. package/src/lifecycle/release-publish.test.ts +43 -13
  220. package/src/lifecycle/release-publish.ts +38 -18
  221. package/src/lifecycle/release-store.ts +57 -13
  222. package/src/lifecycle/run-ledger.test.ts +13 -0
  223. package/src/lifecycle/run-ledger.ts +1 -0
  224. package/src/lint/rules/op/index.ts +4 -2
  225. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  226. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  227. package/src/op/activities/activity-contracts.ts +3 -0
  228. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  229. package/src/op/activities/shell.test.ts +44 -1
  230. package/src/op/activities/shell.ts +110 -3
  231. package/src/op/builders.ts +7 -0
  232. package/src/op/runtime.ts +2 -0
  233. package/src/op/step-console.test.ts +4 -2
  234. package/src/pr-loop.test.ts +29 -0
  235. package/src/pr-loop.ts +26 -7
  236. package/src/pr-report-url.ts +2 -2
  237. package/src/runner-start-bundle.test.ts +29 -0
  238. package/src/runner-start.test.ts +179 -0
  239. package/src/runner-start.ts +329 -0
  240. package/src/workspace/check-contract.test.ts +3 -2
  241. package/src/workspace/check.schema.json +70 -2
  242. package/src/workspace/checks/security.test.ts +138 -0
  243. package/src/workspace/checks/security.ts +305 -0
  244. package/src/workspace/checks.ts +5 -0
  245. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  246. package/src/workspace/ci-builds.test.ts +174 -0
  247. package/src/workspace/ci-builds.ts +385 -0
  248. package/src/workspace/ci-green-builds.test.ts +180 -0
  249. package/src/workspace/ci-green-cli.test.ts +54 -0
  250. package/src/workspace/ci-green-cli.ts +26 -5
  251. package/src/workspace/ci-green-forge.ts +24 -0
  252. package/src/workspace/ci-green-workflow.test.ts +84 -1
  253. package/src/workspace/ci-green-workflow.ts +139 -2
  254. package/src/workspace/ci-green.test.ts +5 -2
  255. package/src/workspace/ci-green.ts +359 -27
  256. package/src/workspace/ci-last-green.schema.json +49 -0
  257. package/src/workspace/ci-list.schema.json +35 -1
  258. package/src/workspace/ci-run-index.schema.json +1 -1
  259. package/src/workspace/ci-run-index.ts +1 -1
  260. package/src/workspace/ci-run.test.ts +24 -7
  261. package/src/workspace/ci-run.ts +78 -16
  262. package/src/workspace/ci-runner-start.test.ts +151 -0
  263. package/src/workspace/ci-runner-start.ts +193 -0
  264. package/src/workspace/ci-serve-builds.test.ts +86 -0
  265. package/src/workspace/ci-serve.test.ts +59 -0
  266. package/src/workspace/ci-serve.ts +177 -22
  267. package/src/workspace/declaration.schema.json +23 -0
  268. package/src/workspace/declaration.test.ts +17 -0
  269. package/src/workspace/declaration.ts +22 -1
  270. package/src/workspace/ls.schema.json +2 -1
  271. package/src/workspace/ls.ts +3 -2
  272. package/src/workspace/member-pipeline.ts +2 -1
  273. package/src/workspace/merge-record.schema.json +49 -0
  274. package/src/workspace/pr-report.schema.json +3 -3
  275. package/src/workspace/reason-codes.test.ts +2 -0
  276. package/src/workspace/reason-codes.ts +18 -0
  277. package/src/workspace/release-candidates.schema.json +353 -0
  278. package/src/workspace/release-candidates.test.ts +106 -0
  279. package/src/workspace/release-candidates.ts +315 -0
  280. package/src/workspace/security.test.ts +70 -0
  281. package/src/workspace/security.ts +118 -0
@@ -52,6 +52,7 @@ import { evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedg
52
52
  import { gateName } from "../op/gate-name";
53
53
  import { GateNotRecordableError } from "../lifecycle/no-record";
54
54
  import { componentPlanDigest } from "./gate-plan";
55
+ import { componentDeployInputs } from "./deploy-inputs";
55
56
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
56
57
  import type { CapabilityRegistry, DeployContext } from "./capability";
57
58
  import { isPlanMovedError, PLAN_MOVED_EXIT_CODE } from "./capability";
@@ -853,6 +854,13 @@ async function rollbackExecuted(
853
854
 
854
855
  // ── Per-component deploy ─────────────────────────────────────────────────
855
856
 
857
+ /** Whether a component's composition has a gate step anywhere, nested phases and `rollback` included. */
858
+ function hasGateStep(component: DriverComponent): boolean {
859
+ const walk = (steps: DriverPhase["steps"]): boolean =>
860
+ steps.some((step) => isGateStep(step) || (Array.isArray((step as DriverPhase).steps) && walk((step as DriverPhase).steps)));
861
+ return [...component.deploy, ...(component.rollback ?? [])].some((p) => walk(p.steps));
862
+ }
863
+
856
864
  /**
857
865
  * Run one component's `deploy` composition. On terminal failure: unwind every
858
866
  * executed step in reverse via its capability's `rollback` (saga
@@ -900,6 +908,9 @@ export async function runComponentDeploy(
900
908
  component,
901
909
  ...(ctx.vars ? { vars: ctx.vars } : {}),
902
910
  ...(release !== undefined ? { release } : {}),
911
+ // #4155: the files the steps deploy from, read where the steps read
912
+ // them. Only a gate reads the digest, so a run with none skips the hashing.
913
+ ...(hasGateStep(component) ? { inputs: componentDeployInputs(component.deploy, process.cwd()).inputs } : {}),
903
914
  }),
904
915
  };
905
916
  const records: DriverStepRecord[] = [];
@@ -17,6 +17,11 @@
17
17
  * its publish output against a recorded digest that is not in the
18
18
  * composition, so it passes that digest here.
19
19
  * - `vars`, the environment config the caller resolved ahead of the run.
20
+ * - The content digests of the local files the steps deploy from (#4155,
21
+ * ./deploy-inputs.ts): a chart, a manifest, a site's tree, a build step's
22
+ * sources. A file changed after review moves the digest. The key is left
23
+ * out when no step names an existing file, so a composition that deploys
24
+ * none keeps the digest it had before #4155.
20
25
  *
21
26
  * ## What it leaves out
22
27
  *
@@ -28,6 +33,7 @@
28
33
  */
29
34
 
30
35
  import { computePlanDigest } from "../lifecycle/plan-digest";
36
+ import type { DeployInputDigest } from "./deploy-inputs";
31
37
  import type { DriverComponent } from "./driver";
32
38
 
33
39
  /** What a component gate's plan digest is taken over. */
@@ -37,6 +43,8 @@ export interface ComponentGatePlan {
37
43
  vars?: Record<string, unknown>;
38
44
  /** The recorded digest a promote or rollback deploys, when the caller has one. */
39
45
  release?: string;
46
+ /** What the steps deploy from (`componentDeployInputs`), when the caller read it. */
47
+ inputs?: readonly DeployInputDigest[];
40
48
  }
41
49
 
42
50
  /** The plan digest a component gate records and approves. */
@@ -49,5 +57,6 @@ export function componentPlanDigest(plan: ComponentGatePlan): string {
49
57
  rollback: component.rollback ?? null,
50
58
  vars: plan.vars ?? null,
51
59
  release: plan.release ?? null,
60
+ ...(plan.inputs && plan.inputs.length > 0 ? { inputs: plan.inputs } : {}),
52
61
  });
53
62
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The jobs of a generated pull-request pipeline (#3183): a plan job on every
3
- * pull request and an apply job on every push to the target branch.
3
+ * pull request and, on every push to the target branch, a record job, or an
4
+ * apply job when the pipeline opts into apply on merge (#4168).
4
5
  *
5
6
  * Each forge's generator (the github, gitlab and forgejo lexicons) adds its
6
7
  * own triggers, permissions and artifact handling; the commands and the
@@ -9,11 +10,19 @@
9
10
  * - The plan job runs `chant components pr-plan` against the pull request's
10
11
  * target commit. It posts the note and the `chant/plan` status and writes
11
12
  * nothing to `chant/lifecycle`, since it runs the pull request's code.
12
- * - The apply job runs `chant components pr-apply` against the commit the
13
+ * - The record job (#4168 step 1, the default) runs `chant components
14
+ * pr-record` against the commit the push replaced. It plans the same
15
+ * members on the merged commit, stores the change set and writes the merge
16
+ * record, `_merges/<commit>.json` on `chant/lifecycle`. It applies nothing:
17
+ * a release that carries the commit deploys it. It needs write access to
18
+ * `chant/lifecycle`. One record runs at a time per environment.
19
+ * - With `applyOnMerge` the apply job takes the record job's place. It runs
20
+ * `chant components pr-apply` against the commit the
13
21
  * push replaced, finds the pull request that merged the pushed commit
14
22
  * through the forge, and applies only when an approval stands for the
15
- * digest it plans. With `requireReview` (the default) the approver must
16
- * also have approved the pull request on the forge. It needs write access
23
+ * digest it plans. With `requireReview` (off by default since #4168, on
24
+ * under the `review-rules` security group) the approver must also have
25
+ * approved the pull request on the forge. It needs write access
17
26
  * to `chant/lifecycle`, to record the pending fact when the gate stops it.
18
27
  * One apply runs at a time per environment.
19
28
  *
@@ -35,6 +44,7 @@
35
44
  * it too. With `reportUrl` both pass `--report-url`, which copies the report,
36
45
  * the change set and the events where a reader outside CI can fetch them;
37
46
  * the job's own environment brings the credentials an `s3://` URL needs.
47
+ * The record job passes the same flags as the apply.
38
48
  *
39
49
  * With `build` (#4088) both jobs run the project's build and `chant lint`
40
50
  * before their command, and fail on an error. A `cfn-deploy` member's plan
@@ -123,8 +133,16 @@ export interface PrLoopPipelineOptions {
123
133
  * GitHub and Forgejo; on GitLab, the project's default branch.
124
134
  */
125
135
  branch?: string;
126
- /** Count an approval only from someone who approved the pull request on the forge. Default true. */
136
+ /** Count an approval only from someone who approved the pull request on the forge. Default false (#4168): review rules are opt-in. */
127
137
  requireReview?: boolean;
138
+ /** The job on merge (apply, or record since #4168) has `chant ci run` refuse to start without a runner-start statement for it (`--require-runner-start`, #4147), as under the `isolated-runners` security group (#4168). Default false. */
139
+ requireRunnerStart?: boolean;
140
+ /**
141
+ * Apply the approved plan on merge (#4168), as every pull-request pipeline
142
+ * did before #4168: the apply job in place of the record job. Default
143
+ * false, so a merge deploys nothing. `waveGate` needs it.
144
+ */
145
+ applyOnMerge?: boolean;
128
146
  /**
129
147
  * Stream each job's run events (#4007): `--events-json` on both commands,
130
148
  * and `CHANT_RUN_EVENTS_DIR` set to {@link PR_LOOP_EVENTS_DIR}. Default false.
@@ -166,8 +184,8 @@ export interface PrLoopPipelineOptions {
166
184
 
167
185
  /** One job's command and the forge values it reads, as environment variables for its step. */
168
186
  export interface PrLoopJob {
169
- /** `plan` or `apply`. */
170
- jobName: "plan" | "apply";
187
+ /** `plan`, `apply` or `record` (#4168). */
188
+ jobName: "plan" | "apply" | "record";
171
189
  /** The command line. Reads `$BASE_SHA` and, for the plan, `$PR_NUMBER`. */
172
190
  command: string;
173
191
  /**
@@ -200,9 +218,10 @@ export function prLoopBuildLines(env: string, options: PrLoopPipelineOptions = {
200
218
  }
201
219
 
202
220
  /**
203
- * The two jobs for `forge`. `member` is the workspace member the pipeline is
204
- * generated for; the commands then run in its directory, so `--output` is
205
- * relative to it.
221
+ * The jobs for `forge`: plan, apply and record, in that order. A generator
222
+ * writes the plan and, on merge, {@link prLoopMergeJob}'s choice of the
223
+ * other two. `member` is the workspace member the pipeline is generated for;
224
+ * the commands then run in its directory, so `--output` is relative to it.
206
225
  */
207
226
  export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
208
227
  const gate = options.gate ?? PR_APPLY_GATE;
@@ -220,13 +239,14 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
220
239
  ...(options.events ? { CHANT_RUN_EVENTS_DIR: PR_LOOP_EVENTS_DIR } : {}),
221
240
  };
222
241
  // #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
223
- const ciRun = (job: "plan" | "apply"): string[] => [
242
+ const ciRun = (job: PrLoopJob["jobName"]): string[] => [
224
243
  "chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
225
244
  "--base", '"$BASE_SHA"', ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
226
245
  "--env", env, "--gate", gate, "--forge", forge,
227
- ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
228
- ...(job === "apply" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
229
- ...(job === "apply" && options.requireReview === false ? ["--no-require-review"] : []),
246
+ ...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
247
+ ...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
248
+ ...(job === "apply" && options.requireReview === true ? ["--require-review"] : []),
249
+ ...(job !== "plan" && options.requireRunnerStart === true ? ["--require-runner-start"] : []),
230
250
  ...(options.events ? ["--events-json"] : []),
231
251
  ...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
232
252
  ];
@@ -248,7 +268,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
248
268
  jobName: "apply",
249
269
  command: [
250
270
  "chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
251
- ...(options.requireReview === false ? [] : ["--require-review"]),
271
+ ...(options.requireReview === true ? ["--require-review"] : []),
252
272
  ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
253
273
  "--resume", PR_APPLY_RECORD,
254
274
  ].join(" "),
@@ -257,9 +277,22 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
257
277
  ...build,
258
278
  run: { command: ciRun("apply").join(" "), env: runEnv(applyEnv) },
259
279
  },
280
+ {
281
+ jobName: "record",
282
+ command: ["chant", "components", "pr-record", "--base", '"$BASE_SHA"', ...common].join(" "),
283
+ env: applyEnv,
284
+ setup: [github ? githubBaseFallback(options.branch) : gitlabBaseFallback(options.branch)],
285
+ ...build,
286
+ run: { command: ciRun("record").join(" "), env: runEnv(applyEnv) },
287
+ },
260
288
  ];
261
289
  }
262
290
 
291
+ /** The job a push to the target branch runs (#4168): `record`, or `apply` when the pipeline opts into apply on merge. */
292
+ export function prLoopMergeJob(jobs: readonly PrLoopJob[], options: PrLoopPipelineOptions = {}): PrLoopJob {
293
+ return jobs.find((j) => j.jobName === (options.applyOnMerge ? "apply" : "record"))!;
294
+ }
295
+
263
296
  /**
264
297
  * The pull-request options with `--wave-gate` beside `--pr-loop` (#4003):
265
298
  * the apply gets a gate per wave. A canary or a fixed base is refused, since
@@ -268,6 +301,9 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
268
301
  */
269
302
  export function prLoopWithWaves(prLoop: PrLoopPipelineOptions, gatedWaves?: GatedWavePipelineOptions): PrLoopPipelineOptions {
270
303
  if (!gatedWaves) return prLoop;
304
+ if (!prLoop.applyOnMerge) {
305
+ throw new Error("--wave-gate beside --pr-loop gates the apply on merge, so it needs --apply-on-merge (#4168); without it a merge records the plan and a release deploys it");
306
+ }
271
307
  if (gatedWaves.canary?.length) throw new Error("a pull-request pipeline's waves are the change's derivation, so it takes no --canary; drop --canary");
272
308
  if (gatedWaves.base !== undefined) throw new Error("a pull-request pipeline measures each push from the commit before it, so it takes no --base; drop --base");
273
309
  return { ...prLoop, waveGate: gatedWaves.gate };
@@ -310,6 +346,11 @@ export function prApplyGroup(env: string, member?: string): string {
310
346
  return member ? `chant-apply.${member}.${env}` : `chant-apply-${env}`;
311
347
  }
312
348
 
349
+ /** The group that keeps one record job at a time per environment and member (#4168), named as {@link prApplyGroup} names the apply's. */
350
+ export function prRecordGroup(env: string, member?: string): string {
351
+ return member ? `chant-record.${member}.${env}` : `chant-record-${env}`;
352
+ }
353
+
313
354
  /**
314
355
  * The cache key the apply job keeps its attempt record under (#3543), with
315
356
  * `commit` the forge's expression for the pushed commit. A push of another
@@ -737,7 +737,7 @@ export function waveNotCovered(approved: ChangeSetDocument, fresh: ChangeSetDocu
737
737
  }
738
738
 
739
739
  export interface PrReportInput {
740
- stage: "plan" | "apply";
740
+ stage: "plan" | "apply" | "record";
741
741
  pr: number | null;
742
742
  env: string;
743
743
  /** The workspace member whose pipeline runs the stage (#3465). */
@@ -13,6 +13,7 @@ import {
13
13
  buildReleaseArtifacts,
14
14
  buildStepsOf,
15
15
  compileEnvironment,
16
+ recordedInputRefs,
16
17
  ReleaseCompileError,
17
18
  templateRefs,
18
19
  withOnlyArchiveSteps,
@@ -49,6 +50,18 @@ describe("templateRefs", () => {
49
50
  });
50
51
  });
51
52
 
53
+ describe("recordedInputRefs (#4155)", () => {
54
+ test("adds each kind's own fields to the template fields: a chart; a missing one need not exist", () => {
55
+ expect(recordedInputRefs(api).map((r) => [r.kind, r.field, r.path, r.required])).toEqual([
56
+ ["cfn-deploy", "template", "dist/api.template.json", true],
57
+ ["cfn-deploy", "template", "dist/edge.template.json", true],
58
+ ["helm-upgrade", "values", "values/base.yaml", true],
59
+ ["helm-upgrade", "chart", "charts/api", false],
60
+ ["kubectl-apply", "manifest", "k8s/api.yaml", true],
61
+ ]);
62
+ });
63
+ });
64
+
52
65
  describe("withOnlyArchiveSteps", () => {
53
66
  test("keeps the archive steps alone, drops gates and every other step, and the dependencies", () => {
54
67
  expect(withOnlyArchiveSteps(api)).toEqual({
@@ -100,6 +113,27 @@ describe("compileEnvironment", () => {
100
113
  });
101
114
  });
102
115
 
116
+ test("records a chart directory file by file and names it in trees; a file that is not UTF-8 as base64 (#4155)", async () => {
117
+ await withTestDir(async (dir) => {
118
+ writeFileSync(
119
+ join(dir, "web.component.ts"),
120
+ `export const web = { name: "web", dependsOn: [], deploy: [{ phase: "Deploy", steps: [
121
+ { kind: "helm-upgrade", release: "web", chart: "chart" },
122
+ { kind: "helm-upgrade", release: "remote", chart: "bitnami/nginx", repo: "https://charts.example" },
123
+ ] }] };`,
124
+ );
125
+ mkdirSync(join(dir, "chart", "charts"), { recursive: true });
126
+ writeFileSync(join(dir, "chart", "Chart.yaml"), "name: web\n");
127
+ writeFileSync(join(dir, "chart", "charts", "dep.tgz"), Buffer.from([0x1f, 0x8b, 0xff, 0x00]));
128
+ const target = await compileEnvironment({ projectDir: dir }, "prod");
129
+ expect(target.trees).toEqual([{ component: "web", path: "chart" }]);
130
+ expect(target.templates).toEqual([
131
+ { component: "web", path: "chart/Chart.yaml", content: "name: web\n" },
132
+ { component: "web", path: "chart/charts/dep.tgz", content: Buffer.from([0x1f, 0x8b, 0xff, 0x00]).toString("base64"), encoding: "base64" },
133
+ ]);
134
+ });
135
+ });
136
+
103
137
  test("refuses when a template a deploy names does not exist after the build", async () => {
104
138
  await withTestDir(async (dir) => {
105
139
  project(dir);
@@ -15,7 +15,14 @@
15
15
  * ./promote.ts: a literal path in a deploy step's `template`, `manifest` or
16
16
  * `values` field ({@link TEMPLATE_FIELDS}), read after the environment's build
17
17
  * command has run. A path with the `archive:` prefix names the file after it.
18
- * A path that names a directory, such as a chart, is not a template.
18
+ *
19
+ * Since #4155 a cut also records the other local paths a deploy step names
20
+ * ({@link recordedInputRefs}: a Helm chart, a kustomize directory, a sync's
21
+ * source tree, the fields `./deploy-inputs.ts` lists). A directory is
22
+ * recorded file by file and named in the target's `trees`; a file that is not
23
+ * UTF-8 is recorded as base64. Such a path that does not exist after the
24
+ * build, as a chart named from a repository, is not recorded, and the
25
+ * promote leaves it as written.
19
26
  *
20
27
  * A project with no components (#4046) is compiled the way `chant build --env
21
28
  * <env> -o <output>` builds it ({@link compileBuildOutput}): every file that
@@ -25,6 +32,7 @@
25
32
  */
26
33
 
27
34
  import { spawn, spawnSync } from "node:child_process";
35
+ import { isUtf8 } from "node:buffer";
28
36
  import { existsSync, readFileSync, statSync } from "node:fs";
29
37
  import { isAbsolute, relative, resolve } from "node:path";
30
38
  import { loadChantConfig, type ChantConfig } from "../config";
@@ -40,7 +48,8 @@ import { memoryGateLedgerPort } from "../op/gate";
40
48
  import type { ReleaseArtifact, ReleaseParam } from "../lifecycle/release-store";
41
49
 
42
50
  /** The deploy step fields whose literal file paths are a component's templates. */
43
- export const TEMPLATE_FIELDS: readonly string[] = ["template", "manifest", "values"];
51
+ export { TEMPLATE_FIELDS } from "./deploy-inputs";
52
+ import { DEPLOY_INPUT_FIELDS, directoryFiles, ignoredByGit, TEMPLATE_FIELDS, walkFiles } from "./deploy-inputs";
44
53
 
45
54
  /** The prefix a step may write before an archive-relative template path. */
46
55
  const ARCHIVE_PREFIX = "archive:";
@@ -61,7 +70,9 @@ export interface TemplateRef {
61
70
  export interface CompiledTemplate {
62
71
  component: string | null;
63
72
  path: string;
73
+ /** The text, or the base64 of the bytes under `encoding: "base64"`. */
64
74
  content: string;
75
+ encoding?: "base64";
65
76
  }
66
77
 
67
78
  /** What one environment compiles to. */
@@ -84,6 +95,8 @@ export interface CompiledTarget {
84
95
  /** For each component, the file that declares it, relative to the member's directory. */
85
96
  componentFiles: Record<string, string>;
86
97
  templates: CompiledTemplate[];
98
+ /** The directories recorded file by file in `templates` (#4155). Absent from a compiler written before #4155. */
99
+ trees?: Array<{ component: string | null; path: string }>;
87
100
  }
88
101
 
89
102
  /** Compiles a member for one environment, and builds its artifacts. The cut talks to a compiler only, so a test can hand it fixtures. */
@@ -134,6 +147,40 @@ export function templateRefs(component: DriverComponent): TemplateRef[] {
134
147
  return out;
135
148
  }
136
149
 
150
+ /** A path a deploy step names that a cut records (#4155): a {@link TemplateRef}, and whether its field is a template field, which must exist. */
151
+ export interface RecordedInputRef extends TemplateRef {
152
+ /** True for `template`, `manifest` and `values`, whose path must exist after the build. */
153
+ required: boolean;
154
+ }
155
+
156
+ /** Every literal path a component's deploy names that a cut records: {@link templateRefs}, plus each kind's own fields in `DEPLOY_INPUT_FIELDS`. */
157
+ export function recordedInputRefs(component: DriverComponent): RecordedInputRef[] {
158
+ const out: RecordedInputRef[] = [];
159
+ for (const { phase, step } of stepsOf(component.deploy)) {
160
+ if (BUILD_STEP_KINDS.includes(step.kind) || PUBLISH_STEP_KINDS.includes(step.kind)) continue;
161
+ for (const field of recordedFields(step.kind)) {
162
+ const raw = step[field];
163
+ for (const value of Array.isArray(raw) ? raw : [raw]) {
164
+ if (!isLiteralPath(value)) continue;
165
+ const path = value.startsWith(ARCHIVE_PREFIX) ? value.slice(ARCHIVE_PREFIX.length) : value;
166
+ out.push({ component: component.name, phase, kind: step.kind, field, value, path, required: TEMPLATE_FIELDS.includes(field) });
167
+ }
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ /** The fields of a step of `kind` whose paths a cut records and a promote points at the recorded copy. */
174
+ export function recordedFields(kind: string): string[] {
175
+ return [...TEMPLATE_FIELDS, ...(DEPLOY_INPUT_FIELDS[kind] ?? [])];
176
+ }
177
+
178
+ /** A file's bytes as a template: its text when it is UTF-8, else base64. */
179
+ export function templateOf(component: string | null, path: string, file: string): CompiledTemplate {
180
+ const bytes = readFileSync(file);
181
+ return isUtf8(bytes) ? { component, path, content: bytes.toString("utf-8") } : { component, path, content: bytes.toString("base64"), encoding: "base64" };
182
+ }
183
+
137
184
  /** A component's build-time steps ({@link BUILD_STEP_KINDS}), each as declared, in composition order. */
138
185
  export function buildStepsOf(component: DriverComponent): DriverStep[] {
139
186
  return stepsOf(component.deploy).map((s) => s.step).filter((s) => BUILD_STEP_KINDS.includes(s.kind));
@@ -191,8 +238,19 @@ export interface CompileInputs {
191
238
  output?: string;
192
239
  /** For a project with no components: build only this lexicon, as `chant build --lexicon` does. */
193
240
  lexicon?: string;
241
+ /**
242
+ * The release's label (#4168, decision 5), passed to every compile and
243
+ * artifact build as `CHANT_RELEASE_LABEL`, so a version reaches the build
244
+ * without a version-bump commit. A build parameter reads it by declaring
245
+ * `env: "CHANT_RELEASE_LABEL"`, and a build step's own tool (cargo, npm)
246
+ * reads the variable. Unset for a cut with no label.
247
+ */
248
+ releaseLabel?: string;
194
249
  }
195
250
 
251
+ /** The variable a cut's label reaches the build through (#4168). */
252
+ export const RELEASE_LABEL_ENV = "CHANT_RELEASE_LABEL";
253
+
196
254
  /**
197
255
  * Where a project with no components writes its build output when the cut
198
256
  * names no `-o`. YAML, since the k8s and helm serializers write YAML whatever
@@ -258,21 +316,33 @@ export async function compileEnvironment(inputs: CompileInputs, env: string): Pr
258
316
  if (declared === 0) return compileBuildOutput(inputs, env, config);
259
317
  const rel = (p: string): string => (isAbsolute(p) ? relative(inputs.projectDir, p) : p);
260
318
  const templates: CompiledTemplate[] = [];
319
+ const trees: Array<{ component: string; path: string }> = [];
261
320
  const seen = new Set<string>();
262
321
  for (const { component } of components) {
263
- for (const ref of templateRefs(component)) {
322
+ for (const ref of recordedInputRefs(component)) {
264
323
  const key = `${ref.component}\u0000${ref.path}`;
265
324
  if (seen.has(key)) continue;
266
325
  seen.add(key);
267
326
  const file = resolve(inputs.projectDir, ref.path);
268
327
  if (!existsSync(file)) {
328
+ if (!ref.required) continue;
269
329
  throw new ReleaseCompileError(
270
330
  `component "${ref.component}": its ${ref.kind} step in phase "${ref.phase}" reads ${ref.field} "${ref.value}", ` +
271
331
  `and no such file exists after the build for "${env}"`,
272
332
  );
273
333
  }
274
- if (!statSync(file).isFile()) continue;
275
- templates.push({ component: ref.component, path: ref.path, content: readFileSync(file, "utf-8") });
334
+ if (statSync(file).isFile()) {
335
+ templates.push(templateOf(ref.component, ref.path, file));
336
+ continue;
337
+ }
338
+ // #4155: a directory, file by file. One git ignores is the build's output, recorded whole.
339
+ const files = ignoredByGit(inputs.projectDir, ref.path) ? walkFiles(inputs.projectDir, ref.path) : directoryFiles(inputs.projectDir, ref.path);
340
+ const dir = ref.path.replace(/\/+$/, "");
341
+ for (const f of files) {
342
+ const under = relative(resolve(inputs.projectDir, dir), resolve(inputs.projectDir, f)).split("\\").join("/");
343
+ templates.push(templateOf(ref.component, `${dir}/${under}`, resolve(inputs.projectDir, f)));
344
+ }
345
+ trees.push({ component: ref.component, path: dir });
276
346
  }
277
347
  }
278
348
  return {
@@ -284,6 +354,7 @@ export async function compileEnvironment(inputs: CompileInputs, env: string): Pr
284
354
  buildInputs: Object.fromEntries(components.map(({ component }) => [component.name, buildInputPaths(component)])),
285
355
  componentFiles: Object.fromEntries(components.map(({ component, file }) => [component.name, rel(file)])),
286
356
  templates,
357
+ trees,
287
358
  };
288
359
  }
289
360
 
@@ -408,10 +479,20 @@ export function ownChantCommand(): string[] {
408
479
  return [process.execPath, ...process.execArgv, main];
409
480
  }
410
481
 
411
- function runChild(command: readonly string[], args: readonly string[], cwd: string, env: string): Promise<{ code: number; stdout: string; stderr: string }> {
482
+ function runChild(
483
+ command: readonly string[],
484
+ args: readonly string[],
485
+ cwd: string,
486
+ env: string,
487
+ releaseLabel?: string,
488
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
412
489
  return new Promise((resolvePromise, reject) => {
413
490
  const [bin, ...lead] = command;
414
- const child = spawn(bin, [...lead, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, CHANT_ENV: env } });
491
+ const childEnv: NodeJS.ProcessEnv = { ...process.env, CHANT_ENV: env };
492
+ // A label never leaks in from the caller's shell: only the cut sets it.
493
+ delete childEnv[RELEASE_LABEL_ENV];
494
+ if (releaseLabel !== undefined) childEnv[RELEASE_LABEL_ENV] = releaseLabel;
495
+ const child = spawn(bin, [...lead, ...args], { cwd, stdio: ["ignore", "pipe", "pipe"], env: childEnv });
415
496
  const out: Buffer[] = [];
416
497
  const err: Buffer[] = [];
417
498
  child.stdout.on("data", (c: Buffer) => out.push(c));
@@ -440,7 +521,7 @@ export function spawnTargetCompiler(inputs: CompileInputs, command: readonly str
440
521
  return args;
441
522
  };
442
523
  const call = async (env: string, extra: string[]): Promise<unknown> => {
443
- const run = await runChild(command, ["release", "compile", "--env", env, ...common(), ...extra, "--json"], inputs.projectDir, env);
524
+ const run = await runChild(command, ["release", "compile", "--env", env, ...common(), ...extra, "--json"], inputs.projectDir, env, inputs.releaseLabel);
444
525
  if (run.code !== 0) throw new ReleaseCompileError(`compiling "${env}" failed (exit ${run.code})`);
445
526
  try {
446
527
  return JSON.parse(run.stdout);
@@ -5,13 +5,13 @@
5
5
 
6
6
  import { describe, test, expect } from "vitest";
7
7
  import { contentDigest } from "../content-digest";
8
- import { makeRelease } from "../lifecycle/release-store";
8
+ import { makeRelease, templateBytesDigest } from "../lifecycle/release-store";
9
9
  import type { DriverComponent } from "./driver";
10
10
  import { isImplicitRelease, pinReleaseTemplates, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
11
11
  import type { ReleaseRecord } from "../lifecycle/release-ledger";
12
12
  import type { BuildArchiveManifest } from "./verbs/build-archive";
13
13
  import { withTestDir } from "@intentius/chant-test-utils";
14
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
 
17
17
  const prodTemplate = `{"env":"prod"}\n`;
@@ -63,6 +63,59 @@ describe("pinReleaseTemplates", () => {
63
63
  expect("errors" in absent && absent.errors[0]).toMatch(/are not on the chant\/lifecycle branch/);
64
64
  });
65
65
 
66
+ test("a target cut since #4155 points a chart at its recorded tree, which a reader of the release can trust", async () => {
67
+ const chartFile = "name: web\n";
68
+ const tgz = Buffer.from([0x1f, 0x8b, 0xff]).toString("base64");
69
+ const tgzDigest = templateBytesDigest(tgz, "base64");
70
+ const cut = makeRelease({
71
+ member: null,
72
+ commit: "c".repeat(40),
73
+ artifactDigests: [],
74
+ targets: [{
75
+ env: "prod",
76
+ params: [],
77
+ templates: [
78
+ { component: "web", path: "chart/Chart.yaml", digest: contentDigest(chartFile) },
79
+ { component: "web", path: "chart/charts/dep.tgz", digest: tgzDigest, encoding: "base64" },
80
+ ],
81
+ trees: [{ component: "web", path: "chart" }],
82
+ }],
83
+ });
84
+ const web: DriverComponent = {
85
+ name: "web",
86
+ deploy: [{ phase: "Deploy", steps: [
87
+ { kind: "helm-upgrade", release: "web", chart: "chart" },
88
+ { kind: "helm-upgrade", release: "remote", chart: "bitnami/nginx" },
89
+ ] }],
90
+ };
91
+ const bytes: Record<string, string> = { [contentDigest(chartFile)]: chartFile, [tgzDigest]: tgz };
92
+ const pinned = await pinReleaseTemplates([web], cut, cut.targets[0]!, async (d, encoding) => (encoding === "base64" && d !== tgzDigest ? null : bytes[d] ?? null));
93
+ if ("errors" in pinned) throw new Error(pinned.errors.join("\n"));
94
+ const step = pinned.components[0]!.deploy[0]!.steps[0] as unknown as { chart: string };
95
+ expect(step.chart).toMatch(/^\.chant\/release-templates\/[^/]+\/chart$/);
96
+ expect(pinned.files.map((f) => f.path.slice(step.chart.length + 1)).sort()).toEqual(["Chart.yaml", "charts/dep.tgz"]);
97
+ expect(pinned.files.find((f) => f.path.endsWith("dep.tgz"))).toMatchObject({ encoding: "base64", content: tgz });
98
+ // A chart named from a repository is not a local path the release records, and is left as written.
99
+ expect(pinned.components[0]!.deploy[0]!.steps[1]).toEqual(web.deploy[0]!.steps[1]);
100
+
101
+ await withTestDir(async (dir) => {
102
+ writePinnedTemplates(dir, pinned.files);
103
+ expect(readFileSync(join(dir, step.chart, "charts", "dep.tgz"))).toEqual(Buffer.from([0x1f, 0x8b, 0xff]));
104
+ removePinnedTemplates(dir, pinned.files);
105
+ expect(existsSync(join(dir, RELEASE_TEMPLATES_DIR))).toBe(false);
106
+ });
107
+
108
+ const edited = await pinReleaseTemplates([web], cut, cut.targets[0]!, async (d) => (d === contentDigest(chartFile) ? "name: edited\n" : bytes[d] ?? null));
109
+ expect("errors" in edited && edited.errors.join("\n")).toMatch(/template "chart\/Chart.yaml" hashes to .* refusing to deploy it/);
110
+ });
111
+
112
+ test("a target cut before #4155 (no trees) is pinned as then: the chart is read from the checkout", async () => {
113
+ const web: DriverComponent = { name: "api", deploy: [{ phase: "Deploy", steps: [{ kind: "helm-upgrade", release: "web", chart: "chart" }, ...api.deploy[1]!.steps] }] };
114
+ const pinned = await pinReleaseTemplates([web], release, prod, store(prodTemplate));
115
+ if ("errors" in pinned) throw new Error(pinned.errors.join("\n"));
116
+ expect(pinned.components[0]!.deploy[0]!.steps[0]).toEqual({ kind: "helm-upgrade", release: "web", chart: "chart" });
117
+ });
118
+
66
119
  test("targetFor names the environments a release was compiled for", () => {
67
120
  expect(targetFor(release, "prod")).toEqual({ target: prod });
68
121
  expect(targetFor(release, "qa")).toEqual({ error: expect.stringMatching(/has no target for "qa"; it was compiled for prod, staging/) });