@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
package/src/cli/main.ts CHANGED
@@ -249,22 +249,28 @@ CI:
249
249
  chant/lifecycle; with ci.green.signedSince, the ones a
250
250
  runner key signed. Pushes the
251
251
  tags. --key, or the PEM in CHANT_RUNNER_KEY, signs
252
- each tag with a runner key.
252
+ each tag with a runner key. A cancelled run leaves a
253
+ commit unbuilt, not red, and a failed run is run
254
+ again once. A phase with build: newest leaves the
255
+ commits a newer run superseded unbuilt, and a red
256
+ one starts a bisect: a ci/build/<sha> tag the tick
257
+ starts the phase's workflow at. Each commit judged
258
+ gets a _ci/<sha>.json record on chant/lifecycle.
253
259
  ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>]
254
260
  [--forge github|gitlab|forgejo] [--gate <name>] [--wave-gate <name>]
255
261
  [--branch <name>] [--events-json] [--report-url <url>] [--image <image>]
256
262
  [--before-script <line>]... [--pr-build]
257
263
  [--pr-build-command <line>] [--chant <command>]
258
264
  [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>]
259
- [-o <dir>] [--wave <k>] [--no-require-review]
265
+ [-o <dir>] [--wave <k>] [--require-review]
260
266
  [--no-status] [--status-url <url template>]
261
- [--in-place | --local] [--no-push] [--verbose]
262
- Run the pull-request pipeline's plan or apply job as
267
+ [--in-place | --local] [--no-push] [--require-runner-start] [--verbose]
268
+ Run the pull-request pipeline's plan, record or apply job as
263
269
  a pipeline Op in a fresh worktree of --commit (default
264
270
  HEAD) with a clean environment. --base and --pr stand
265
271
  in for the forge's event values. --pr-build runs
266
272
  the job's build and lint lines first. Records the run as
267
- pr-plan or pr-apply on chant/lifecycle, which ci tick
273
+ pr-plan, pr-record or pr-apply on chant/lifecycle, which ci tick
268
274
  from runs counts, signed when --key or CHANT_RUNNER_KEY
269
275
  gives a runner key, and copies the report to -o
270
276
  (default .chant/pr). --in-place runs in this checkout
@@ -286,17 +292,39 @@ CI:
286
292
  kept on the local chant/lifecycle with runOrigin
287
293
  local and is never pushed, no status is set, nothing
288
294
  goes under a report URL, and ci tick does not count it.
295
+ --require-runner-start refuses the job before its
296
+ first step unless a runner-start statement, signed by
297
+ a runner key .chant/trust.json at --base lists, names
298
+ this forge job.
299
+ ci runner-start sign --key <runner key.pem> --principal <name> --job <json|@file> [-o <file>]
300
+ For the provisioner that starts a runner for one job:
301
+ sign the job's system, repo, run, attempt, job,
302
+ commit and runner name, and print the envelope.
303
+ ci runner-start verify [--base <rev>] [--commit <sha>] [--json]
304
+ In the job: check the statement in CHANT_RUNNER_START,
305
+ or the file CHANT_RUNNER_START_FILE names (default
306
+ /run/chant/runner-start.json), against the forge's
307
+ variables and .chant/trust.json at --base (default
308
+ origin/HEAD). Exits 1 unless it verifies.
309
+ ci run --at <sha> Ask for every required phase at a commit of the
310
+ green branch with a ci/build/<sha> tag, and start the
311
+ workflows holding them there on GitHub when a token
312
+ is set; otherwise the next tick, or ci serve
313
+ --build-command, does.
289
314
  ci runs [--report-url <url>] [--json]
290
315
  List the runs in the index at the report URL (default
291
316
  the workspace's ci.reportUrl), newest first.
292
317
  ci serve --forge forgejo|github --repo <owner/repo> --env <env>
293
318
  [--interval <s>] [--max-jobs <n>] [--state <dir>]
294
319
  [--author <login>]... [--once] [--rerun-failed] [--unsigned]
295
- [ci run's job flags]
320
+ [--apply-on-merge] [--build-command <line>] [ci run's job flags]
296
321
  Poll the forge for open pull requests and the target
297
322
  branch's head, and run ci run plan for each new pull
298
- request head and ci run apply for each new commit on
299
- the branch. Dedupes on job and commit in a state file
323
+ request head and ci run record for each new commit on
324
+ the branch, then ci tick from the runs when the
325
+ workspace declares ci.green. A merge deploys nothing;
326
+ --apply-on-merge runs ci run apply in place of the
327
+ record, and --wave-gate needs it. Dedupes on job and commit in a state file
300
328
  under --state, takes a lease per apply group, keeps
301
329
  each apply's resume record there, and runs at most
302
330
  --max-jobs (default 1) jobs at once. --once runs one
@@ -304,7 +332,10 @@ CI:
304
332
  failed apply or plan again. Signs runs with a runner
305
333
  key when .chant/trust.json at the target's head lists
306
334
  it, and serves unsigned otherwise; with
307
- ci.green.signedSince it refuses to start without one
335
+ ci.green.signedSince it refuses to start without one.
336
+ --build-command runs <line> once per ci/build/<sha>
337
+ tag, in a fresh worktree of the commit, with
338
+ CHANT_BUILD_COMMIT and CHANT_BUILD_PHASES set
308
339
  ci serve|run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]
309
340
  Make the host's runner key (Ed25519, mode 0600) in the
310
341
  state directory when there is none and print its public
@@ -322,10 +353,14 @@ CI:
322
353
  or a GitHub App's id and key, pushes the tags with a
323
354
  token that may tag commits changing workflow files.
324
355
  --runner-key-secret hands the tick the runner key it
325
- signs its tags with.
356
+ signs its tags with. For each phase that sets build,
357
+ it also writes the triggers and concurrency of the
358
+ workflows holding it: newest builds on pushes and by
359
+ hand, one run at a time, and every gives each commit
360
+ a run of its own.
326
361
 
327
362
  Releases:
328
- release cut [<label>] [--yes] [--build <command>] [--param <k=v>]
363
+ release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>]
329
364
  [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>]
330
365
  [--actor <name>] [--sign [<key file>]] [--json]
331
366
  A dry run of a cut of this member from HEAD, which
@@ -344,19 +379,29 @@ Releases:
344
379
  requires it. A project with no components releases
345
380
  what chant build --env <env> writes: --src <dir> is
346
381
  the build root, -o <file> the output (dist/build.yaml),
347
- --lexicon narrows it.
382
+ --lexicon narrows it. --at <sha> cuts that commit of
383
+ trunk from a temporary worktree instead of HEAD, so
384
+ the checkout need not be clean. The label reaches
385
+ every compile and build as CHANT_RELEASE_LABEL.
386
+ release candidates [--env <env>] [--json]
387
+ The green commits of trunk a release can be cut from,
388
+ newest first, each with the commits, work items, pull
389
+ requests and flags it would ship, and what each
390
+ environment runs.
348
391
  release publish <label|digest> [--yes] [--allow-unsigned]
349
392
  [--require-provenance] [--json]
350
393
  Publish a cut release to the targets release.publish
351
394
  in chant.config.ts declares (npm, OCI registries,
352
- forge release assets), each with an SBOM and the
353
- release's provenance. A dry run without --yes.
354
- Refuses a revoked release, one whose signature does
355
- not verify against .chant/allowed_signers unless
356
- --allow-unsigned, and with a target a checkout that
357
- is not the release's commit. Warns first when the run
358
- has no OIDC token, so what it publishes carries no
359
- OIDC provenance; --require-provenance refuses instead.
395
+ forge release assets), each with the release's
396
+ provenance and an SBOM when it has a source. A dry
397
+ run without --yes. Refuses a revoked release, and
398
+ with a target a checkout that is not the release's
399
+ commit. Under security verified-releases it refuses
400
+ one whose signature does not verify against
401
+ .chant/allowed_signers unless --allow-unsigned; under
402
+ supply-chain it warns when the run has no OIDC token
403
+ and refuses a target with no SBOM source.
404
+ --require-provenance refuses a run with no OIDC.
360
405
  release verify <label|digest> [--json]
361
406
  Check, offline, that the release was signed by a key
362
407
  .chant/allowed_signers lists at the release's commit.
@@ -887,6 +932,10 @@ Lifecycle (alias: lc):
887
932
  estate size rather than stack count (#2405)
888
933
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
889
934
  --json: emit the ChangeSet as JSON
935
+ lifecycle health <env> Whether each declared service is up, separate from
936
+ drift: ECS running vs desired, Route 53 health
937
+ checks, Container Apps revisions, Cloud Run Ready.
938
+ A failed read is "not read", never unhealthy (--json)
890
939
  lifecycle whoami <env> Who chant would act as in each configured lexicon,
891
940
  and what that principal is scoped to — read-only,
892
941
  before anything acts (--json, --strict)
@@ -931,7 +980,12 @@ Component release ledger + status:
931
980
  report and note (--base <ref> --pr <n>
932
981
  [--forge github|gitlab|forgejo] posts them);
933
982
  applies nothing
934
- components pr-apply On merge: plan the same members again and apply
983
+ components pr-record On merge (#4168): plan the same members again,
984
+ store the change set and write the merge record
985
+ _merges/<commit>.json; applies and refuses
986
+ nothing, and a plan that differs from the
987
+ approved one sets a failing chant/record status
988
+ components pr-apply With --apply-on-merge: plan the same members again and apply
935
989
  them only if an approval stands for that digest
936
990
  (chant approve pr-<n> pr-apply --plan <digest>);
937
991
  a moved plan refuses with exit 3
@@ -1386,6 +1440,8 @@ export const commandRegistry: CommandDef[] = [
1386
1440
  // #4094: one pull-request job as a pipeline Op in a fresh checkout, the chant runner's first slice.
1387
1441
  { name: "ci run", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run")).runCiRun(ctx) },
1388
1442
  // #4098: the run index at the report URL.
1443
+ // #4147: a provisioner's signed word that it started a runner for one job, signed and checked.
1444
+ { name: "ci runner-start", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-runner-start")).runCiRunnerStart(ctx) },
1389
1445
  { name: "ci runs", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run-index")).runCiRuns(ctx) },
1390
1446
  { name: "ci serve", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-serve")).runCiServe(ctx) },
1391
1447
  // #4026: the release cut from a green commit (ws-104). Imported on first use.
@@ -1394,6 +1450,7 @@ export const commandRegistry: CommandDef[] = [
1394
1450
  { name: "release publish", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleasePublish(ctx) },
1395
1451
  // #4049: check a release's signature offline.
1396
1452
  { name: "release verify", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseVerify(ctx) },
1453
+ { name: "release candidates", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCandidates(ctx) },
1397
1454
  { name: "release list", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseList(ctx) },
1398
1455
  { name: "release export", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseExport(ctx) },
1399
1456
  { name: "release compile", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCompile(ctx) },
@@ -1504,6 +1561,8 @@ export const commandRegistry: CommandDef[] = [
1504
1561
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
1505
1562
  { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
1506
1563
  { name: "lifecycle whoami", requiresPlugins: true, handler: runLifecycleWhoami },
1564
+ // #4177 — runtime health, separate from drift. Imported on first use.
1565
+ { name: "lifecycle health", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/lifecycle-health")).runLifecycleHealth(ctx) },
1507
1566
  { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
1508
1567
  { name: "lifecycle log", handler: runLifecycleLog },
1509
1568
  // #4036 — retention's garbage collection. Imported on first use.
@@ -1519,6 +1578,7 @@ export const commandRegistry: CommandDef[] = [
1519
1578
  // #3183 — the pull-request loop the generated CI runs. Imported on first use.
1520
1579
  { name: "components pr-plan", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/pr")).runComponentsPrPlan(ctx) },
1521
1580
  { name: "components pr-apply", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/pr")).runComponentsPrApply(ctx) },
1581
+ { name: "components pr-record", requiresPlugins: true, handler: async (ctx) => (await import("./handlers/pr")).runComponentsPrRecord(ctx) },
1522
1582
  { name: "components status", requiresPlugins: true, handler: runComponentsStatus },
1523
1583
  { name: "components release", handler: runComponentsReleaseRecord },
1524
1584
  { name: "components export", handler: runComponentsExport },
@@ -70,6 +70,7 @@ const BOOLEAN_FLAGS = new Set([
70
70
  "--trust",
71
71
  "--unsigned",
72
72
  "--in-place",
73
+ "--require-runner-start",
73
74
  "--no-push",
74
75
  "--no-require-review",
75
76
  "--expire",
@@ -409,9 +410,17 @@ export function parseArgs(args: string[]): ParsedArgs {
409
410
  result.requireProvenance = true;
410
411
  } else if (arg === "--pr-loop") {
411
412
  result.prLoop = true;
413
+ } else if (arg === "--apply-on-merge") {
414
+ // `chant build --pr-loop --apply-on-merge` and `chant ci serve --apply-on-merge` (#4168): opt into applying on merge.
415
+ result.applyOnMerge = true;
412
416
  } else if (arg === "--pr-build") {
413
417
  // `chant build --pr-loop --pr-build` (#4088), and `chant ci run --pr-build` (#4094).
414
418
  result.prBuild = true;
419
+ } else if (arg === "--build-command") {
420
+ // `chant ci serve --build-command <line>` (#4169): the line that builds a requested commit.
421
+ const line = args[++i];
422
+ if (line === undefined || line === "") throw new Error("--build-command needs a shell line: --build-command '<line>'");
423
+ result.ciBuildCommand = line;
415
424
  } else if (arg === "--pr-build-command") {
416
425
  const line = args[++i];
417
426
  if (line === undefined || line === "") throw new Error("--pr-build-command needs a shell line: --pr-build-command '<line>'");
@@ -783,11 +792,18 @@ export function parseArgs(args: string[]): ParsedArgs {
783
792
  } else if (arg === "--in-place") {
784
793
  // `chant ci run --in-place` (#4156): run the job in this checkout, as a generated forge job does.
785
794
  result.ciInPlace = true;
795
+ } else if (arg === "--require-runner-start") {
796
+ // `chant ci run --require-runner-start` (#4147): refuse the job unless a provisioner's runner-start statement names it.
797
+ result.requireRunnerStart = true;
798
+ } else if (arg === "--job") {
799
+ // `chant ci runner-start sign --job <json|@file>` (#4147): the CI job a runner was started for.
800
+ result.ciJob = args[++i];
801
+ if (!result.ciJob || result.ciJob.startsWith("-")) throw new Error("--job needs the job as JSON or @<file>: --job @job.json");
786
802
  } else if (arg === "--no-push") {
787
803
  // `chant ci run --no-push` (#4156): keep the run record on the local chant/lifecycle.
788
804
  result.noPush = true;
789
805
  } else if (arg === "--no-require-review") {
790
- // `chant ci run apply --no-require-review` (#4156): the apply without pr-apply's --require-review.
806
+ // `chant ci run apply --no-require-review` (#4156): the apply without pr-apply's --require-review, the default since #4168.
791
807
  result.requireReview = false;
792
808
  } else if (arg === "--unsigned") {
793
809
  // `chant ci serve --unsigned` (#4099): serve without a runner key listed at the target's head.
@@ -311,8 +311,10 @@ export interface ParsedArgs {
311
311
  /**
312
312
  * `chant components pr-apply --require-review` (#3183) — count an approval
313
313
  * only when its approver has a standing approving review on the pull
314
- * request. Needs `--forge`. `chant ci run apply --no-require-review`
315
- * (#4156) sets it false, for a pipeline generated with `requireReview: false`.
314
+ * request. Needs `--forge`. `chant ci run apply --require-review`
315
+ * passes it on, and `chant build --pr-loop --require-review` generates a
316
+ * pipeline that does (#4168); `--no-require-review` (#4156) sets it false,
317
+ * which is the default.
316
318
  */
317
319
  requireReview?: boolean;
318
320
  /**
@@ -323,12 +325,20 @@ export interface ParsedArgs {
323
325
  reportUrl?: string;
324
326
  /**
325
327
  * `chant build --components --generate <lexicon> --pr-loop` (#3183) —
326
- * generate the pull-request pipeline: plan on the pull request, apply on
327
- * merge bound to the approved digest.
328
+ * generate the pull-request pipeline: plan on the pull request, and on
329
+ * merge record the plan (#4168), or apply it with `--apply-on-merge`.
328
330
  */
329
331
  prLoop?: boolean;
332
+ /**
333
+ * `chant build --pr-loop --apply-on-merge` and `chant ci serve
334
+ * --apply-on-merge` (#4168): on merge apply the approved plan, as before
335
+ * #4168, in place of recording it. Opt-in: a merge deploys nothing without it.
336
+ */
337
+ applyOnMerge?: boolean;
330
338
  /** `chant build --pr-loop --pr-build` (#4088) — build and lint before pr-plan and pr-apply. */
331
339
  prBuild?: boolean;
340
+ /** `chant ci serve --build-command <line>` (#4169) — the line that builds a commit a `ci/build/<sha>` tag asks for, in a worktree of it. */
341
+ ciBuildCommand?: string;
332
342
  /** `chant build --pr-loop --pr-build-command <line>` (#4088) — the build line to run in place of `chant build --env <env>`. */
333
343
  prBuildCommand?: string;
334
344
  /**
@@ -511,6 +521,10 @@ export interface ParsedArgs {
511
521
  unsigned?: boolean;
512
522
  /** `chant ci run --in-place` (#4156): run the job in this checkout at HEAD, as a generated forge job does. */
513
523
  ciInPlace?: boolean;
524
+ /** `chant ci run --require-runner-start` (#4147): refuse the job unless a runner-start statement names it. */
525
+ requireRunnerStart?: boolean;
526
+ /** `chant ci runner-start sign --job <json|@file>` (#4147): the CI job a runner was started for. */
527
+ ciJob?: string;
514
528
  /** `chant ci run --no-push` (#4156): write the run record on the local chant/lifecycle and push nothing. */
515
529
  noPush?: boolean;
516
530
  /** `chant ci serve|run --setup-key --trust --principal <name>` (#4099): the principal the runner key is listed for. */
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { componentDeployInputs, directoryDigest, directoryFiles, stepInputRefs } from "./deploy-inputs";
7
+ import { componentPlanDigest } from "./gate-plan";
8
+ import type { DriverComponent, DriverPhase } from "./driver";
9
+
10
+ let root: string;
11
+
12
+ const write = (path: string, content: string): void => {
13
+ mkdirSync(dirname(join(root, path)), { recursive: true });
14
+ writeFileSync(join(root, path), content);
15
+ };
16
+ const deploy = (...steps: Array<Record<string, unknown>>): DriverPhase[] => [{ phase: "Deploy", steps: steps as never }];
17
+
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), "deploy-inputs-"));
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(root, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("stepInputRefs", () => {
27
+ test("a kind's own fields and the template fields, literal paths only", () => {
28
+ const refs = stepInputRefs("Deploy", { kind: "helm-upgrade", release: "web", chart: "chart", values: ["a.yaml", "@Cfg.values"] } as never);
29
+ expect(refs.map((r) => [r.field, r.path])).toEqual([["values", "a.yaml"], ["chart", "chart"]]);
30
+ });
31
+
32
+ test("an archive path or a wired value is a build output and is not named", () => {
33
+ expect(stepInputRefs("Deploy", { kind: "cfn-deploy", template: "archive:stack.json" } as never)).toEqual([]);
34
+ expect(stepInputRefs("Deploy", { kind: "s3-sync", from: "@Build.dir", to: "s3://b" } as never)).toEqual([]);
35
+ });
36
+
37
+ test("a Docker build's Dockerfile is named under its context", () => {
38
+ const refs = stepInputRefs("Build", { kind: "docker-build", context: "app", dockerfile: "Dockerfile.prod" } as never);
39
+ expect(refs.map((r) => r.path)).toEqual(["app", "app/Dockerfile.prod"]);
40
+ });
41
+ });
42
+
43
+ describe("componentDeployInputs", () => {
44
+ test("a file by its bytes, a directory by its files; a missing path is left out", () => {
45
+ write("values.yaml", "a: 1\n");
46
+ write("chart/Chart.yaml", "name: c\n");
47
+ const read = componentDeployInputs(deploy({ kind: "helm-upgrade", chart: "chart", values: ["values.yaml", "gone.yaml"] }), root);
48
+ expect(read.inputs.map((i) => i.path)).toEqual(["chart", "values.yaml"]);
49
+ expect(read.inputs.find((i) => i.path === "values.yaml")?.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
50
+ expect(read.unbound).toEqual([]);
51
+ });
52
+
53
+ test("a directory's digest moves with a file's bytes, a new file and a renamed one", () => {
54
+ write("site/index.html", "<h1>a</h1>");
55
+ const before = directoryDigest(root, "site");
56
+ write("site/index.html", "<h1>b</h1>");
57
+ const edited = directoryDigest(root, "site");
58
+ expect(edited).not.toBe(before);
59
+ write("site/about.html", "x");
60
+ expect(directoryDigest(root, "site")).not.toBe(edited);
61
+ });
62
+
63
+ test("the same files under another parent hash the same: the digest reads paths from the directory", () => {
64
+ write("a/chart/Chart.yaml", "name: c\n");
65
+ write("b/chart/Chart.yaml", "name: c\n");
66
+ expect(directoryDigest(root, "a/chart")).toBe(directoryDigest(root, "b/chart"));
67
+ });
68
+
69
+ test("in a git repository, a directory leaves out what git ignores, and a directory git ignores is a build output", () => {
70
+ execFileSync("git", ["init", "-q"], { cwd: root });
71
+ write(".gitignore", "node_modules/\ndist/\n");
72
+ write("app/server.js", "1");
73
+ write("app/node_modules/dep/index.js", "1");
74
+ write("dist/index.html", "1");
75
+ expect(directoryFiles(root, "app")).toEqual(["app/server.js"]);
76
+ const read = componentDeployInputs(deploy({ kind: "docker-build", context: "app" }, { kind: "s3-sync", from: "dist", to: "s3://b" }), root);
77
+ expect(read.inputs.map((i) => i.path)).toEqual(["app"]);
78
+ expect(read.unbound).toEqual([{ phase: "Deploy", kind: "s3-sync", reason: expect.stringMatching(/"dist" is a directory git ignores, so a build output/) }]);
79
+ });
80
+
81
+ test("a file git ignores is still hashed when a step names it, as a template chant build writes", () => {
82
+ execFileSync("git", ["init", "-q"], { cwd: root });
83
+ write(".gitignore", "dist/\n");
84
+ write("dist/stack.json", "{}");
85
+ expect(componentDeployInputs(deploy({ kind: "cfn-deploy", template: "dist/stack.json" }), root).inputs.map((i) => i.path)).toEqual(["dist/stack.json"]);
86
+ });
87
+
88
+ test("a shell step is unbound; `include` limits the steps read", () => {
89
+ write("chart/Chart.yaml", "x");
90
+ const phases = deploy({ kind: "shell", cmd: "./go.sh", reason: "t" }, { kind: "helm-upgrade", chart: "chart" });
91
+ expect(componentDeployInputs(phases, root).unbound.map((u) => u.kind)).toEqual(["shell"]);
92
+ expect(componentDeployInputs(phases, root, (s) => s.kind !== "helm-upgrade").inputs).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("componentPlanDigest with inputs", () => {
97
+ const component: DriverComponent = { name: "web", dependsOn: [], deploy: deploy({ kind: "helm-upgrade", chart: "chart" }) };
98
+
99
+ test("no inputs keeps the digest a composition had before #4155", () => {
100
+ expect(componentPlanDigest({ environment: "prod", component, inputs: [] })).toBe(componentPlanDigest({ environment: "prod", component }));
101
+ });
102
+
103
+ test("an input's digest moves the plan digest", () => {
104
+ const a = componentPlanDigest({ environment: "prod", component, inputs: [{ path: "chart", digest: "sha256:aa" }] });
105
+ const b = componentPlanDigest({ environment: "prod", component, inputs: [{ path: "chart", digest: "sha256:bb" }] });
106
+ expect(a).not.toBe(b);
107
+ });
108
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The local files a component's steps deploy from, and their content digests
3
+ * (#4155).
4
+ *
5
+ * A component gate approves `componentPlanDigest` (./gate-plan.ts). A step
6
+ * whose capability has a `plan` is bound by what it plans. Every other step
7
+ * was bound only by its input as written, so a Helm chart, a kustomize
8
+ * directory or a static site's tree could change after review without moving
9
+ * the digest. This module names those files and hashes them, so the digest
10
+ * moves when they do.
11
+ *
12
+ * Which fields name a path is data, as the template fields are in
13
+ * ./release-compile.ts and the unit fields in ./deploy-units.ts: a kind's
14
+ * fields in {@link DEPLOY_INPUT_FIELDS}, plus `template`, `manifest` and
15
+ * `values` on any step ({@link TEMPLATE_FIELDS}). Only a literal path counts:
16
+ * a value wired from another step (`@Build.uri`) or an `archive:` path is a
17
+ * build output, and binding it would make an approval unusable by a build
18
+ * that is not reproducible. The build steps' own sources are bound instead.
19
+ *
20
+ * - A file is hashed by its bytes.
21
+ * - A directory is hashed over the files under it that git does not ignore
22
+ * (`git ls-files --cached --others --exclude-standard`), each by its path
23
+ * and bytes, so `node_modules` and a build's `dist` inside a Docker context
24
+ * are left out. Outside a git repository every file is hashed except under
25
+ * `.git` and `node_modules`.
26
+ * - A directory git ignores as a whole is a build output. It is not hashed;
27
+ * the step is listed as unbound, with that reason.
28
+ * - A path that does not exist is left out. One that appears later moves the
29
+ * digest.
30
+ *
31
+ * A step whose kind runs content chant can't name, such as `shell`, is listed
32
+ * as unbound ({@link UNBOUND_KINDS}), so the plan note can show a reviewer
33
+ * what the approval does not cover.
34
+ */
35
+
36
+ import { spawnSync } from "node:child_process";
37
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
38
+ import { join, relative, resolve, sep } from "node:path";
39
+ import { sha256Hex } from "../content-digest";
40
+ import { computePlanDigest } from "../lifecycle/plan-digest";
41
+ import type { DriverGate, DriverPhase, DriverStep } from "./driver";
42
+
43
+ /** The fields that name every step's deployed file, whatever its kind (as ./release-compile.ts reads them). */
44
+ export const TEMPLATE_FIELDS: readonly string[] = ["template", "manifest", "values"];
45
+
46
+ /** Per kind, the fields beyond {@link TEMPLATE_FIELDS} that name a local file or directory the step deploys or builds from. */
47
+ export const DEPLOY_INPUT_FIELDS: Readonly<Record<string, readonly string[]>> = {
48
+ "helm-upgrade": ["chart"],
49
+ "kustomize-apply": ["dir"],
50
+ "fly-release": ["plan"],
51
+ "fly-rollback": ["plan"],
52
+ "s3-sync": ["from"],
53
+ "r2-sync": ["from"],
54
+ "copy-to-host": ["from"],
55
+ "wrangler-deploy": ["config"],
56
+ "wrangler-versions-promote": ["config"],
57
+ "docker-build": ["context"],
58
+ "zip-package": ["source"],
59
+ "jvm-build": ["path"],
60
+ };
61
+
62
+ /** Kinds that deploy content chant can't name from their input: a command, an agent's work. */
63
+ export const UNBOUND_KINDS: readonly string[] = ["shell", "run-agent"];
64
+
65
+ /** One path a step names. */
66
+ export interface DeployInputRef {
67
+ phase: string;
68
+ kind: string;
69
+ field: string;
70
+ /** Relative to the member's directory, as written. */
71
+ path: string;
72
+ }
73
+
74
+ /** A named path and the digest of what is there. */
75
+ export interface DeployInputDigest {
76
+ path: string;
77
+ /** `sha256:<hex>` of a file's bytes, or a plan digest over a directory's files. */
78
+ digest: string;
79
+ }
80
+
81
+ /** A step the digest does not cover, and why. */
82
+ export interface UnboundStep {
83
+ phase: string;
84
+ kind: string;
85
+ reason: string;
86
+ }
87
+
88
+ /** What a component's steps deploy from: the digests, and the steps they can't cover. */
89
+ export interface ComponentDeployInputs {
90
+ inputs: DeployInputDigest[];
91
+ unbound: UnboundStep[];
92
+ }
93
+
94
+ function isGate(step: DriverStep | DriverGate | DriverPhase): step is DriverGate {
95
+ return (step as { kind?: unknown }).kind === "gate";
96
+ }
97
+
98
+ function isPhase(step: DriverStep | DriverGate | DriverPhase): step is DriverPhase {
99
+ return typeof (step as { phase?: unknown }).phase === "string" && Array.isArray((step as DriverPhase).steps);
100
+ }
101
+
102
+ const isLiteralPath = (v: unknown): v is string =>
103
+ typeof v === "string" && v !== "" && !v.startsWith("@") && !v.startsWith("$") && !v.startsWith("archive:");
104
+
105
+ /** Every capability step of a composition with its phase, nested phases included, gates left out. */
106
+ export function stepsWithPhase(phases: readonly DriverPhase[]): Array<{ phase: string; step: DriverStep }> {
107
+ const out: Array<{ phase: string; step: DriverStep }> = [];
108
+ const walk = (phase: DriverPhase): void => {
109
+ for (const step of phase.steps) {
110
+ if (isPhase(step)) walk(step);
111
+ else if (!isGate(step)) out.push({ phase: phase.phase, step });
112
+ }
113
+ };
114
+ phases.forEach(walk);
115
+ return out;
116
+ }
117
+
118
+ /** The literal paths one step names, in field order. A Docker build's Dockerfile is named under its context. */
119
+ export function stepInputRefs(phase: string, step: DriverStep): DeployInputRef[] {
120
+ const out: DeployInputRef[] = [];
121
+ for (const field of [...TEMPLATE_FIELDS, ...(DEPLOY_INPUT_FIELDS[step.kind] ?? [])]) {
122
+ const raw = step[field];
123
+ for (const value of Array.isArray(raw) ? raw : [raw]) {
124
+ if (isLiteralPath(value)) out.push({ phase, kind: step.kind, field, path: value });
125
+ }
126
+ }
127
+ if (step.kind === "docker-build" && isLiteralPath(step.context) && isLiteralPath(step.dockerfile)) {
128
+ out.push({ phase, kind: step.kind, field: "dockerfile", path: join(step.context, step.dockerfile) });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function git(root: string, args: string[]): { ok: boolean; out: string } {
134
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf-8", maxBuffer: 256 * 1024 * 1024 });
135
+ return { ok: r.status === 0, out: r.stdout ?? "" };
136
+ }
137
+
138
+ /** The files under `dir` (relative to `root`) a directory digest covers, relative to `root`, sorted. */
139
+ export function directoryFiles(root: string, dir: string): string[] {
140
+ const listed = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", dir]);
141
+ if (listed.ok) {
142
+ return [...new Set(listed.out.split("\0").filter((p) => p !== ""))]
143
+ .filter((p) => {
144
+ const full = resolve(root, p);
145
+ return existsSync(full) && statSync(full).isFile();
146
+ })
147
+ .sort();
148
+ }
149
+ return walkFiles(root, dir);
150
+ }
151
+
152
+ /** Every file under `dir`, relative to `root`, sorted, leaving out `.git` and `node_modules`. What a cut records of a directory git ignores. */
153
+ export function walkFiles(root: string, dir: string): string[] {
154
+ const out: string[] = [];
155
+ const walk = (abs: string): void => {
156
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
157
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
158
+ const full = join(abs, entry.name);
159
+ if (entry.isDirectory()) walk(full);
160
+ else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
161
+ }
162
+ };
163
+ walk(resolve(root, dir));
164
+ return out.sort();
165
+ }
166
+
167
+ /** Whether git ignores `path` (relative to `root`). False outside a git repository. */
168
+ export function ignoredByGit(root: string, path: string): boolean {
169
+ return git(root, ["check-ignore", "-q", "--", path]).ok;
170
+ }
171
+
172
+ /** `sha256:<hex>` of a file's bytes. */
173
+ export function fileDigest(file: string): string {
174
+ return `sha256:${sha256Hex(readFileSync(file))}`;
175
+ }
176
+
177
+ /** The digest of the files under `dir`: a plan digest over each file's path (from `dir`) and bytes. */
178
+ export function directoryDigest(root: string, dir: string): string {
179
+ const base = resolve(root, dir);
180
+ const files = directoryFiles(root, dir).map((p) => ({
181
+ path: relative(base, resolve(root, p)).split(sep).join("/"),
182
+ digest: fileDigest(resolve(root, p)),
183
+ }));
184
+ return computePlanDigest("directory", files);
185
+ }
186
+
187
+ /**
188
+ * What a component's steps deploy from, read under `root` (the member's
189
+ * directory). `include` picks the steps to read, all of them by default; the
190
+ * wave planner passes the steps without a `plan`, which their plan does not
191
+ * already cover.
192
+ */
193
+ export function componentDeployInputs(
194
+ phases: readonly DriverPhase[],
195
+ root: string,
196
+ include: (step: DriverStep) => boolean = () => true,
197
+ ): ComponentDeployInputs {
198
+ const inputs = new Map<string, string>();
199
+ const unbound: UnboundStep[] = [];
200
+ for (const { phase, step } of stepsWithPhase(phases)) {
201
+ if (!include(step)) continue;
202
+ if (UNBOUND_KINDS.includes(step.kind)) {
203
+ unbound.push({ phase, kind: step.kind, reason: `a ${step.kind} step runs content chant can't name from its input` });
204
+ continue;
205
+ }
206
+ for (const ref of stepInputRefs(phase, step)) {
207
+ if (inputs.has(ref.path)) continue;
208
+ const full = resolve(root, ref.path);
209
+ if (!existsSync(full)) continue;
210
+ if (statSync(full).isDirectory()) {
211
+ if (ignoredByGit(root, ref.path)) {
212
+ unbound.push({ phase, kind: step.kind, reason: `its ${ref.field} "${ref.path}" is a directory git ignores, so a build output; bind its sources through the step that builds it` });
213
+ continue;
214
+ }
215
+ inputs.set(ref.path, directoryDigest(root, ref.path));
216
+ } else {
217
+ inputs.set(ref.path, fileDigest(full));
218
+ }
219
+ }
220
+ }
221
+ return {
222
+ inputs: [...inputs].map(([path, digest]) => ({ path, digest })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
223
+ unbound,
224
+ };
225
+ }