@intentius/chant 0.124.32 → 0.124.34

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 (320) 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 +277 -74
  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 +99 -22
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +30 -1
  24. package/dist/cli/registry.d.ts +21 -5
  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/promote-rounds.d.ts +102 -0
  40. package/dist/components/promote-rounds.d.ts.map +1 -0
  41. package/dist/components/promote-rounds.js +174 -0
  42. package/dist/components/release-compile.d.ts +37 -2
  43. package/dist/components/release-compile.d.ts.map +1 -1
  44. package/dist/components/release-compile.js +64 -8
  45. package/dist/components/release-gate.d.ts +30 -0
  46. package/dist/components/release-gate.d.ts.map +1 -1
  47. package/dist/components/release-gate.js +92 -42
  48. package/dist/components/release-promote.d.ts +17 -3
  49. package/dist/components/release-promote.d.ts.map +1 -1
  50. package/dist/components/release-promote.js +79 -18
  51. package/dist/components/verbs/bom-writer.d.ts +1 -1
  52. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  53. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  54. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  56. package/dist/components/wave-plan.d.ts +10 -0
  57. package/dist/components/wave-plan.d.ts.map +1 -1
  58. package/dist/components/wave-plan.js +32 -4
  59. package/dist/graph-declared.d.ts +4 -0
  60. package/dist/graph-declared.d.ts.map +1 -1
  61. package/dist/graph-declared.js +11 -1
  62. package/dist/graph-detail.d.ts.map +1 -1
  63. package/dist/graph-detail.js +32 -7
  64. package/dist/health.d.ts +110 -0
  65. package/dist/health.d.ts.map +1 -0
  66. package/dist/health.js +105 -0
  67. package/dist/health.schema.json +191 -0
  68. package/dist/lexicon.d.ts +27 -0
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/merge-record.d.ts +104 -0
  71. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  72. package/dist/lifecycle/merge-record.js +102 -0
  73. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  74. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  75. package/dist/lifecycle/publish-attachments.js +27 -13
  76. package/dist/lifecycle/publish-npm.d.ts +3 -2
  77. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  78. package/dist/lifecycle/publish-npm.js +4 -3
  79. package/dist/lifecycle/release-at.d.ts +40 -0
  80. package/dist/lifecycle/release-at.d.ts.map +1 -0
  81. package/dist/lifecycle/release-at.js +83 -0
  82. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  83. package/dist/lifecycle/release-cut.js +23 -12
  84. package/dist/lifecycle/release-publish.d.ts +28 -12
  85. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  86. package/dist/lifecycle/release-publish.js +29 -17
  87. package/dist/lifecycle/release-store.d.ts +25 -2
  88. package/dist/lifecycle/release-store.d.ts.map +1 -1
  89. package/dist/lifecycle/release-store.js +40 -13
  90. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  91. package/dist/lifecycle/run-ledger.js +1 -0
  92. package/dist/lint/rules/op/index.d.ts +2 -1
  93. package/dist/lint/rules/op/index.d.ts.map +1 -1
  94. package/dist/lint/rules/op/index.js +4 -2
  95. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  96. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  97. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  98. package/dist/op/activities/activity-contracts.d.ts +5 -0
  99. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  100. package/dist/op/activities/activity-contracts.js +3 -0
  101. package/dist/op/activities/shell.d.ts +38 -0
  102. package/dist/op/activities/shell.d.ts.map +1 -1
  103. package/dist/op/activities/shell.js +89 -3
  104. package/dist/op/builders.d.ts +7 -0
  105. package/dist/op/builders.d.ts.map +1 -1
  106. package/dist/op/builders.js +7 -0
  107. package/dist/op/runtime.d.ts +2 -0
  108. package/dist/op/runtime.d.ts.map +1 -1
  109. package/dist/pr-loop.d.ts +14 -3
  110. package/dist/pr-loop.d.ts.map +1 -1
  111. package/dist/pr-loop.js +15 -5
  112. package/dist/pr-report-url.d.ts +2 -2
  113. package/dist/pr-report-url.d.ts.map +1 -1
  114. package/dist/runner-start.d.ts +179 -0
  115. package/dist/runner-start.d.ts.map +1 -0
  116. package/dist/runner-start.js +277 -0
  117. package/dist/wave-runtime.mjs +236 -229
  118. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  119. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  120. package/dist/workspace/check.schema.json +88 -2
  121. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  122. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  123. package/dist/workspace/checks/ci-workflow.js +123 -0
  124. package/dist/workspace/checks/security.d.ts +50 -0
  125. package/dist/workspace/checks/security.d.ts.map +1 -0
  126. package/dist/workspace/checks/security.js +305 -0
  127. package/dist/workspace/checks.d.ts +2 -0
  128. package/dist/workspace/checks.d.ts.map +1 -1
  129. package/dist/workspace/checks.js +8 -0
  130. package/dist/workspace/ci-builds.d.ts +163 -0
  131. package/dist/workspace/ci-builds.d.ts.map +1 -0
  132. package/dist/workspace/ci-builds.js +315 -0
  133. package/dist/workspace/ci-green-cli.d.ts +2 -1
  134. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  135. package/dist/workspace/ci-green-cli.js +25 -5
  136. package/dist/workspace/ci-green-forge.d.ts +12 -0
  137. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  138. package/dist/workspace/ci-green-forge.js +12 -0
  139. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  140. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  141. package/dist/workspace/ci-green-workflow.js +128 -1
  142. package/dist/workspace/ci-green.d.ts +89 -4
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +310 -23
  145. package/dist/workspace/ci-init.d.ts +100 -0
  146. package/dist/workspace/ci-init.d.ts.map +1 -0
  147. package/dist/workspace/ci-init.js +434 -0
  148. package/dist/workspace/ci-last-green.schema.json +49 -0
  149. package/dist/workspace/ci-list.schema.json +35 -1
  150. package/dist/workspace/ci-run-index.d.ts +1 -1
  151. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  152. package/dist/workspace/ci-run-index.schema.json +1 -1
  153. package/dist/workspace/ci-run.d.ts +12 -6
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +72 -16
  156. package/dist/workspace/ci-runner-start.d.ts +77 -0
  157. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  158. package/dist/workspace/ci-runner-start.js +185 -0
  159. package/dist/workspace/ci-serve.d.ts +76 -7
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +153 -19
  162. package/dist/workspace/ci-workflow.d.ts +91 -0
  163. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  164. package/dist/workspace/ci-workflow.js +33 -0
  165. package/dist/workspace/declaration.d.ts +35 -0
  166. package/dist/workspace/declaration.d.ts.map +1 -1
  167. package/dist/workspace/declaration.js +12 -2
  168. package/dist/workspace/declaration.schema.json +51 -0
  169. package/dist/workspace/init.js +2 -2
  170. package/dist/workspace/ls.d.ts +2 -0
  171. package/dist/workspace/ls.d.ts.map +1 -1
  172. package/dist/workspace/ls.js +1 -1
  173. package/dist/workspace/ls.schema.json +2 -1
  174. package/dist/workspace/member-pipeline.d.ts +1 -0
  175. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  176. package/dist/workspace/member-pipeline.js +2 -0
  177. package/dist/workspace/merge-record.schema.json +49 -0
  178. package/dist/workspace/pr-report.schema.json +3 -3
  179. package/dist/workspace/reason-codes.d.ts +26 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/reason-codes.js +28 -0
  182. package/dist/workspace/release-candidates.d.ts +129 -0
  183. package/dist/workspace/release-candidates.d.ts.map +1 -0
  184. package/dist/workspace/release-candidates.js +233 -0
  185. package/dist/workspace/release-candidates.schema.json +353 -0
  186. package/dist/workspace/release-workflow.d.ts +31 -0
  187. package/dist/workspace/release-workflow.d.ts.map +1 -0
  188. package/dist/workspace/release-workflow.js +150 -0
  189. package/dist/workspace/security.d.ts +40 -0
  190. package/dist/workspace/security.d.ts.map +1 -0
  191. package/dist/workspace/security.js +99 -0
  192. package/package.json +8 -1
  193. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  194. package/src/ci-jobs.ts +11 -2
  195. package/src/cli/handlers/build.test.ts +36 -0
  196. package/src/cli/handlers/build.ts +18 -3
  197. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  198. package/src/cli/handlers/lifecycle-health.ts +157 -0
  199. package/src/cli/handlers/lifecycle.ts +1 -1
  200. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  201. package/src/cli/handlers/pr-record.test.ts +201 -0
  202. package/src/cli/handlers/pr.test.ts +22 -0
  203. package/src/cli/handlers/pr.ts +127 -4
  204. package/src/cli/handlers/promote.test.ts +17 -0
  205. package/src/cli/handlers/promote.ts +296 -84
  206. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  207. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  208. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  209. package/src/cli/handlers/release.e2e.test.ts +19 -28
  210. package/src/cli/handlers/release.ts +81 -11
  211. package/src/cli/main.ts +99 -22
  212. package/src/cli/parse-args.ts +22 -1
  213. package/src/cli/registry.ts +21 -5
  214. package/src/components/deploy-inputs.test.ts +108 -0
  215. package/src/components/deploy-inputs.ts +225 -0
  216. package/src/components/driver.ts +11 -0
  217. package/src/components/gate-plan.ts +9 -0
  218. package/src/components/pr-pipeline.ts +56 -15
  219. package/src/components/pr-run.ts +1 -1
  220. package/src/components/promote-rounds.test.ts +220 -0
  221. package/src/components/promote-rounds.ts +232 -0
  222. package/src/components/release-compile.test.ts +34 -0
  223. package/src/components/release-compile.ts +89 -8
  224. package/src/components/release-gate.test.ts +47 -0
  225. package/src/components/release-gate.ts +111 -46
  226. package/src/components/release-promote.test.ts +55 -2
  227. package/src/components/release-promote.ts +93 -23
  228. package/src/components/verbs/bom-writer.ts +1 -1
  229. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  230. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  231. package/src/components/wave-plan.ts +32 -4
  232. package/src/graph-declared-exports.test.ts +40 -0
  233. package/src/graph-declared.ts +9 -1
  234. package/src/graph-detail.test.ts +57 -0
  235. package/src/graph-detail.ts +33 -7
  236. package/src/health.schema.json +191 -0
  237. package/src/health.test.ts +62 -0
  238. package/src/health.ts +178 -0
  239. package/src/lexicon.ts +41 -1
  240. package/src/lifecycle/merge-record.test.ts +111 -0
  241. package/src/lifecycle/merge-record.ts +154 -0
  242. package/src/lifecycle/publish-attachments.ts +25 -13
  243. package/src/lifecycle/publish-npm.ts +4 -3
  244. package/src/lifecycle/publish-targets.test.ts +34 -4
  245. package/src/lifecycle/release-at.test.ts +48 -0
  246. package/src/lifecycle/release-at.ts +100 -0
  247. package/src/lifecycle/release-cut.test.ts +41 -0
  248. package/src/lifecycle/release-cut.ts +22 -11
  249. package/src/lifecycle/release-publish.test.ts +43 -13
  250. package/src/lifecycle/release-publish.ts +38 -18
  251. package/src/lifecycle/release-store.ts +57 -13
  252. package/src/lifecycle/run-ledger.test.ts +13 -0
  253. package/src/lifecycle/run-ledger.ts +1 -0
  254. package/src/lint/rules/op/index.ts +4 -2
  255. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  256. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  257. package/src/op/activities/activity-contracts.ts +3 -0
  258. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  259. package/src/op/activities/shell.test.ts +44 -1
  260. package/src/op/activities/shell.ts +110 -3
  261. package/src/op/builders.ts +7 -0
  262. package/src/op/runtime.ts +2 -0
  263. package/src/op/step-console.test.ts +4 -2
  264. package/src/pr-loop.test.ts +29 -0
  265. package/src/pr-loop.ts +26 -7
  266. package/src/pr-report-url.ts +2 -2
  267. package/src/runner-start-bundle.test.ts +29 -0
  268. package/src/runner-start.test.ts +179 -0
  269. package/src/runner-start.ts +329 -0
  270. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  271. package/src/workspace/check-contract.test.ts +4 -2
  272. package/src/workspace/check.schema.json +88 -2
  273. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  274. package/src/workspace/checks/ci-workflow.ts +129 -0
  275. package/src/workspace/checks/security.test.ts +138 -0
  276. package/src/workspace/checks/security.ts +305 -0
  277. package/src/workspace/checks.ts +8 -0
  278. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  279. package/src/workspace/ci-builds.test.ts +174 -0
  280. package/src/workspace/ci-builds.ts +385 -0
  281. package/src/workspace/ci-green-builds.test.ts +180 -0
  282. package/src/workspace/ci-green-cli.test.ts +54 -0
  283. package/src/workspace/ci-green-cli.ts +26 -5
  284. package/src/workspace/ci-green-forge.ts +24 -0
  285. package/src/workspace/ci-green-workflow.test.ts +84 -1
  286. package/src/workspace/ci-green-workflow.ts +139 -2
  287. package/src/workspace/ci-green.test.ts +5 -2
  288. package/src/workspace/ci-green.ts +359 -27
  289. package/src/workspace/ci-init.test.ts +159 -0
  290. package/src/workspace/ci-init.ts +445 -0
  291. package/src/workspace/ci-last-green.schema.json +49 -0
  292. package/src/workspace/ci-list.schema.json +35 -1
  293. package/src/workspace/ci-report-url.test.ts +6 -1
  294. package/src/workspace/ci-run-index.schema.json +1 -1
  295. package/src/workspace/ci-run-index.ts +1 -1
  296. package/src/workspace/ci-run.test.ts +24 -7
  297. package/src/workspace/ci-run.ts +78 -16
  298. package/src/workspace/ci-runner-start.test.ts +151 -0
  299. package/src/workspace/ci-runner-start.ts +193 -0
  300. package/src/workspace/ci-serve-builds.test.ts +86 -0
  301. package/src/workspace/ci-serve.test.ts +59 -0
  302. package/src/workspace/ci-serve.ts +177 -22
  303. package/src/workspace/ci-workflow.ts +71 -0
  304. package/src/workspace/declaration.schema.json +51 -0
  305. package/src/workspace/declaration.test.ts +17 -0
  306. package/src/workspace/declaration.ts +53 -3
  307. package/src/workspace/init.ts +2 -2
  308. package/src/workspace/ls.schema.json +2 -1
  309. package/src/workspace/ls.ts +3 -2
  310. package/src/workspace/member-pipeline.ts +2 -1
  311. package/src/workspace/merge-record.schema.json +49 -0
  312. package/src/workspace/pr-report.schema.json +3 -3
  313. package/src/workspace/reason-codes.test.ts +4 -0
  314. package/src/workspace/reason-codes.ts +28 -0
  315. package/src/workspace/release-candidates.schema.json +353 -0
  316. package/src/workspace/release-candidates.test.ts +106 -0
  317. package/src/workspace/release-candidates.ts +315 -0
  318. package/src/workspace/release-workflow.ts +167 -0
  319. package/src/workspace/security.test.ts +70 -0
  320. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A `shell()` step in a container (#4015), run for real with `docker run
3
+ * --rm`. Skipped when Docker is not running. The containers are `--rm` and
4
+ * mount only host directories, so nothing is left behind.
5
+ */
6
+ import { execSync } from "node:child_process";
7
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import { shellCmd } from "./shell";
12
+
13
+ // alpine 3.21, pinned by its index digest.
14
+ const IMAGE = process.env.CHANT_SHELL_E2E_IMAGE ?? "alpine:3.21@sha256:ce64758a109eb420d874a118f87920e625e12d3634e03b4a5573fd9f6e5d3507";
15
+
16
+ function available(cmd: string): boolean {
17
+ try {
18
+ execSync(cmd, { stdio: "ignore" });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ const hasDocker = available("docker info");
26
+ const skipReason = hasDocker ? "" : " (skipped: Docker is not running)";
27
+
28
+ describe.skipIf(!hasDocker)(`shellCmd with an image${skipReason}`, () => {
29
+ let dir: string;
30
+
31
+ beforeAll(async () => {
32
+ dir = await mkdtemp(join(tmpdir(), "chant-shell-e2e-"));
33
+ await writeFile(join(dir, "input.txt"), "from the host\n");
34
+ execSync(`docker pull -q ${IMAGE}`, { stdio: "ignore" });
35
+ }, 120_000);
36
+
37
+ afterAll(async () => {
38
+ await rm(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ test("runs in the image, reads the workspace read-only, sees env by name, and writes its output directory on the host", async () => {
42
+ process.env.CHANT_SHELL_E2E_HOST_ONLY = "leaked";
43
+ try {
44
+ const result = await shellCmd({
45
+ cmd: 'cat /etc/alpine-release >/dev/null && echo "$GREETING:$CHANT_SHELL_E2E_HOST_ONLY" && cat input.txt > out/copy.txt; touch input.txt 2>/dev/null || echo read-only',
46
+ image: IMAGE,
47
+ cwd: dir,
48
+ env: { GREETING: "hi" },
49
+ outputs: ["out"],
50
+ });
51
+ expect(result.stdout.split("\n")).toEqual(["hi:", "read-only"]);
52
+ expect(await readFile(join(dir, "out", "copy.txt"), "utf8")).toBe("from the host\n");
53
+ } finally {
54
+ delete process.env.CHANT_SHELL_E2E_HOST_ONLY;
55
+ }
56
+ }, 60_000);
57
+
58
+ test("a declared output directory the command left empty fails the step", async () => {
59
+ await expect(shellCmd({ cmd: "true", image: IMAGE, cwd: dir, outputs: ["empty"] })).rejects.toThrow(/left it empty/);
60
+ }, 60_000);
61
+
62
+ test("keeps the command's exit status, and okExit still applies", async () => {
63
+ await expect(shellCmd({ cmd: "exit 4", image: IMAGE, cwd: dir })).rejects.toThrow(/command exited 4/);
64
+ const ok = await shellCmd({ cmd: "echo done; exit 1", image: IMAGE, cwd: dir, okExit: [0, 1] });
65
+ expect(ok).toMatchObject({ stdout: "done", exitCode: 1 });
66
+ }, 60_000);
67
+ });
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, test, expect } from "vitest";
12
12
  import { z } from "zod";
13
- import { shellCmd } from "./shell";
13
+ import { containerCommand, imageProblem, shellCmd } from "./shell";
14
14
  import { shellCmdContract } from "./activity-contracts";
15
15
  import { shell } from "../builders";
16
16
  import { activity, phase } from "../builders";
@@ -244,3 +244,46 @@ describe("a step's own timeout (#2787)", () => {
244
244
  }
245
245
  });
246
246
  });
247
+
248
+ describe("shellCmd in a container (#4015)", () => {
249
+ const PINNED = `alpine:3.21@sha256:${"e".repeat(64)}`;
250
+
251
+ test("builds a docker run --rm line: workspace read-only, env by name, the command under sh -c", () => {
252
+ const line = containerCommand({ cmd: "make test", image: PINNED, env: { TOKEN: "x", MODE: "ci" } }, { cwd: "/src/app" });
253
+ expect(line).toBe(`docker run --rm -v '/src/app:/workspace:ro' -w /workspace -e 'TOKEN' -e 'MODE' '${PINNED}' sh -c 'make test'`);
254
+ });
255
+
256
+ test("mounts each output directory writable over a read-only workspace, and none over a writable one", () => {
257
+ const ro = containerCommand({ cmd: "npm run build", image: PINNED, outputs: ["dist", "./reports/"] }, { cwd: "/src/app", cli: "podman", user: "501:20" });
258
+ expect(ro).toBe(
259
+ `podman run --rm --user 501:20 -v '/src/app:/workspace:ro' -v '/src/app/dist:/workspace/dist:rw' -v '/src/app/reports:/workspace/reports:rw' -w /workspace '${PINNED}' sh -c 'npm run build'`,
260
+ );
261
+ const rw = containerCommand({ cmd: "make", image: PINNED, mounts: { workspace: "rw" }, outputs: ["dist"] }, { cwd: "/src/app" });
262
+ expect(rw).toBe(`docker run --rm -v '/src/app:/workspace:rw' -w /workspace '${PINNED}' sh -c 'make'`);
263
+ });
264
+
265
+ test("an image is pinned by digest, as a literal string", () => {
266
+ expect(imageProblem(PINNED)).toBeUndefined();
267
+ expect(imageProblem("alpine:3.21")).toMatch(/not pinned by digest/);
268
+ expect(imageProblem(`alpine@sha256:${"E".repeat(64)}`)).toMatch(/not pinned by digest/);
269
+ expect(imageProblem(42)).toMatch(/literal string/);
270
+ });
271
+
272
+ test("refuses before running anything: a tag, an output outside the working directory, outputs without an image", async () => {
273
+ await expect(shellCmd({ cmd: "true", image: "alpine:3.21" })).rejects.toThrow(/not pinned by digest/);
274
+ await expect(shellCmd({ cmd: "true", image: PINNED, outputs: ["../escape"] })).rejects.toThrow(/inside the working directory/);
275
+ await expect(shellCmd({ cmd: "true", image: PINNED, outputs: ["/etc"] })).rejects.toThrow(/inside the working directory/);
276
+ await expect(shellCmd({ cmd: "true", outputs: ["dist"] })).rejects.toThrow(/give the step an image/);
277
+ });
278
+
279
+ test("the contract accepts image, mounts and outputs", () => {
280
+ expect(shellCmdContract.args.safeParse({ cmd: "x", image: PINNED, mounts: { workspace: "rw" }, outputs: ["dist"] }).success).toBe(true);
281
+ expect(shellCmdContract.args.safeParse({ cmd: "x", mounts: { workspace: "rwx" } }).success).toBe(false);
282
+ });
283
+
284
+ test("the shell() builder passes them through to the step", () => {
285
+ const step = shell("npm test", { image: PINNED, outputs: ["coverage"] });
286
+ expect(step.args).toEqual({ cmd: "npm test", image: PINNED, outputs: ["coverage"] });
287
+ });
288
+ });
289
+
@@ -1,4 +1,6 @@
1
1
  import { exec } from "node:child_process";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { isAbsolute, join, normalize, resolve } from "node:path";
2
4
  import { promisify } from "node:util";
3
5
  import { GateWait, asPendingGate } from "../gate-wait";
4
6
  import type { PendingGateRecord } from "../../lifecycle/gate-ledger";
@@ -56,6 +58,84 @@ export interface ShellCmdArgs {
56
58
  * something else. Empty stdout is `null`.
57
59
  */
58
60
  json?: boolean;
61
+ /**
62
+ * Run the command in a container (#4015) instead of on the host: an image
63
+ * pinned by digest, `<ref>@sha256:<64 hex>`, so the step names the
64
+ * toolchain it ran with. The step's working directory is mounted at
65
+ * `/workspace`, which is the command's working directory; the container
66
+ * sees `env` by name and nothing of this process's environment, whatever
67
+ * `inheritEnv` says. Run with `docker run --rm`, or the command
68
+ * `CHANT_CONTAINER_CLI` names (`podman`).
69
+ */
70
+ image?: string;
71
+ /** How the container mounts the working directory (#4015). Default `{ workspace: "ro" }`. */
72
+ mounts?: { workspace?: "ro" | "rw" };
73
+ /**
74
+ * Directories under the working directory the containerized command writes
75
+ * (#4015). Each is created on the host if missing and mounted writable at
76
+ * the same path, so what the command leaves there stays on the host. A
77
+ * directory still empty when the command ends fails a step that otherwise
78
+ * passed. Only with `image`.
79
+ */
80
+ outputs?: string[];
81
+ }
82
+
83
+ /** An image reference pinned by digest (#4015): anything, then `@sha256:` and 64 lowercase hex digits. */
84
+ export const PINNED_IMAGE = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
85
+
86
+ /** Why `image` can't run, or undefined when it is a reference pinned by digest. */
87
+ export function imageProblem(image: unknown): string | undefined {
88
+ if (typeof image !== "string") return "image must be a literal string, an image reference pinned by digest";
89
+ if (!PINNED_IMAGE.test(image)) return `image ${JSON.stringify(image)} is not pinned by digest: name it as <ref>@sha256:<digest>`;
90
+ return undefined;
91
+ }
92
+
93
+ /** Why an `outputs` path can't be copied back, or undefined when it is a relative path inside the working directory. */
94
+ function outputProblem(path: string): string | undefined {
95
+ const n = normalize(path);
96
+ if (path === "" || isAbsolute(path) || n === ".." || n.startsWith("../") || n === ".") return `output ${JSON.stringify(path)} must be a relative path inside the working directory`;
97
+ return undefined;
98
+ }
99
+
100
+ function shellQuote(s: string): string {
101
+ return `'${s.replace(/'/g, `'\\''`)}'`;
102
+ }
103
+
104
+ /** Where a containerized command sees the working directory. */
105
+ const WORKSPACE = "/workspace";
106
+
107
+ /**
108
+ * The `docker run --rm` line for a step with `image` (#4015). The working
109
+ * directory is mounted at `/workspace`, read-only by default, and each
110
+ * declared output directory is mounted writable on top of it.
111
+ */
112
+ export function containerCommand(args: ShellCmdArgs & { image: string }, where: { cwd: string; cli?: string; user?: string }): string {
113
+ const mode = args.mounts?.workspace ?? "ro";
114
+ const outputs = mode === "ro" ? (args.outputs ?? []).map((p) => normalize(p).replace(/\/+$/, "")) : [];
115
+ return [
116
+ where.cli ?? "docker",
117
+ "run",
118
+ "--rm",
119
+ ...(where.user ? ["--user", where.user] : []),
120
+ "-v",
121
+ shellQuote(`${where.cwd}:${WORKSPACE}:${mode}`),
122
+ ...outputs.flatMap((p) => ["-v", shellQuote(`${join(where.cwd, p)}:${WORKSPACE}/${p}:rw`)]),
123
+ "-w",
124
+ WORKSPACE,
125
+ ...Object.keys(args.env ?? {}).flatMap((name) => ["-e", shellQuote(name)]),
126
+ shellQuote(args.image),
127
+ "sh",
128
+ "-c",
129
+ shellQuote(args.cmd),
130
+ ].join(" ");
131
+ }
132
+
133
+ /** Throw when a declared output directory is still empty after a command that passed. */
134
+ async function checkOutputs(outputs: readonly string[], cwd: string): Promise<void> {
135
+ for (const p of outputs) {
136
+ const entries = await readdir(join(cwd, normalize(p))).catch(() => []);
137
+ if (entries.length === 0) throw new Error(`the step declares output ${JSON.stringify(p)}, and the command left it empty`);
138
+ }
59
139
  }
60
140
 
61
141
  /**
@@ -130,12 +210,20 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
130
210
  };
131
211
  const out = stepLineSplitter();
132
212
  const err = stepLineSplitter();
213
+ if (args.image === undefined && (args.outputs !== undefined || args.mounts !== undefined)) {
214
+ throw new Error("outputs and mounts apply only to a step that runs in a container: give the step an image");
215
+ }
216
+ const container = args.image !== undefined ? await prepareContainer(args) : undefined;
133
217
  try {
134
- const running = execAsync(args.cmd, {
218
+ const running = execAsync(container?.cmd ?? args.cmd, {
135
219
  cwd: args.cwd,
136
220
  // In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
137
- // file the command appends activity lines to (../run-live.ts).
138
- env: { ...(args.inheritEnv === false ? {} : process.env), ...liveRunEnv(), ...args.env },
221
+ // file the command appends activity lines to (../run-live.ts). Under
222
+ // `image` the container CLI gets this process's environment, so it can
223
+ // reach its daemon, and the container gets `env` alone, by name.
224
+ env: container
225
+ ? { ...process.env, ...args.env }
226
+ : { ...(args.inheritEnv === false ? {} : process.env), ...liveRunEnv(), ...args.env },
139
227
  maxBuffer: MAX_STDOUT_BYTES,
140
228
  signal,
141
229
  });
@@ -152,6 +240,7 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
152
240
  }
153
241
  const { stdout, stderr } = settled;
154
242
  printStderr(stderr);
243
+ if (container) await checkOutputs(args.outputs ?? [], container.cwd);
155
244
  return withJson({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }, args);
156
245
  } catch (caught) {
157
246
  const failure = caught as ExecFailure;
@@ -179,6 +268,24 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
179
268
  }
180
269
  }
181
270
 
271
+ /** The container line for a step with `image` (#4015), checked first, with each output directory made on the host. */
272
+ async function prepareContainer(args: ShellCmdArgs): Promise<{ cmd: string; cwd: string }> {
273
+ const problem = imageProblem(args.image) ?? (args.outputs ?? []).map(outputProblem).find((p) => p !== undefined);
274
+ if (problem) throw new Error(problem);
275
+ const cwd = resolve(args.cwd ?? process.cwd());
276
+ // A writable mount inside a read-only one needs its mountpoint to exist already.
277
+ for (const p of args.outputs ?? []) await mkdir(join(cwd, normalize(p)), { recursive: true });
278
+ // The host's uid and gid, so what the command writes belongs to whoever
279
+ // runs chant rather than to the image's root.
280
+ const user = typeof process.getuid === "function" && typeof process.getgid === "function" ? `${process.getuid()}:${process.getgid()}` : undefined;
281
+ const cmd = containerCommand(args as ShellCmdArgs & { image: string }, {
282
+ cwd,
283
+ ...(process.env.CHANT_CONTAINER_CLI ? { cli: process.env.CHANT_CONTAINER_CLI } : {}),
284
+ ...(user ? { user } : {}),
285
+ });
286
+ return { cmd, cwd };
287
+ }
288
+
182
289
  /** The pending fact a command's stdout carries: the whole of it as JSON, or its last line. */
183
290
  function pendingFromStdout(stdout: string): PendingGateRecord | undefined {
184
291
  const lines = stdout.split("\n");
@@ -391,6 +391,13 @@ export const lifecycleSnapshot = (env: string, opts?: { id?: string }): NamedAct
391
391
  * twenty minutes, and keeps the profile's retries: `shell("./build.sh", {
392
392
  * timeout: "45m" })`. At most six hours (`MAX_STEP_TIMEOUT`).
393
393
  *
394
+ * `image` runs the command in a container instead of on the host (#4015),
395
+ * pinned by digest, with the working directory mounted read-only at
396
+ * `/workspace` unless `mounts` says `rw`, and each `outputs` directory
397
+ * mounted writable:
398
+ * `shell("npm run build", { image: "node:24@sha256:<digest>", outputs: ["dist"] })`.
399
+ * OPS018 refuses an image that is not pinned by digest.
400
+ *
394
401
  * `cmd` stays a plain `string` and takes no references. A value spliced into
395
402
  * a command line is a quoting decision chant would then be making on the
396
403
  * author's behalf, and `env` carries the same value into the same command
package/src/op/runtime.ts CHANGED
@@ -62,6 +62,8 @@ export interface OpRunStepRecord {
62
62
  gate?: { op: string; gate: string };
63
63
  /** The run whose checkpoint supplied this step's output, for a step a resumed run skipped (#3947). */
64
64
  resumed?: { run: string; gate: string };
65
+ /** The image a `shellCmd` step ran in (#4015), pinned by digest, for a step that ran in a container. */
66
+ image?: string;
65
67
  }
66
68
 
67
69
  /** One phase's steps and the verdict they add up to. */
@@ -118,13 +118,15 @@ describe("a quiet step says it is still running (#3956)", () => {
118
118
 
119
119
  test("a step printing output is not interrupted by the still-running line", async () => {
120
120
  const out = recorder();
121
- const chatty = `node -e "let n = 0; const t = setInterval(() => { console.log('tick ' + n); if (++n === 6) clearInterval(t); }, 40)"`;
121
+ // sh, not node: node's start can outlast the heartbeat on a loaded runner,
122
+ // and the gap before the first line is what the heartbeat measures.
123
+ const chatty = `sh -c 'for n in 0 1 2 3 4 5; do echo "tick $n"; sleep 0.04; done'`;
122
124
  await runOpLocally(
123
125
  { name: "chatty", overview: "", phases: [phase("Go", [shell(chatty, { id: "ticks" })])] },
124
126
  new Map([["shellCmd", shellFn]]),
125
127
  ACTIVITY_PROFILES,
126
128
  undefined,
127
- { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 100 },
129
+ { gates: memoryGateLedgerPort(), stepOutput: out.write, stepHeartbeatMs: 400 },
128
130
  );
129
131
  expect(out.lines.filter((l) => l.text.includes("still running"))).toEqual([]);
130
132
  expect(out.lines.map((l) => l.text)).toContain("[Go] ticks | tick 5");
@@ -229,6 +229,35 @@ describe("the note", () => {
229
229
  });
230
230
  });
231
231
 
232
+ describe("the record stage (#4168)", () => {
233
+ const recorded: PrReport = { ...planReport, stage: "record", status: "recorded", approval: { status: "approved", approvedBy: ["github:rev"] } };
234
+ const moved: PrReport = { ...recorded, approval: { status: "changed", approved: changedDigest } };
235
+ const unapproved: PrReport = { ...recorded, approval: { status: "pending" } };
236
+
237
+ test("its reports validate", () => {
238
+ for (const report of [recorded, moved, unapproved]) expectValid(report);
239
+ });
240
+
241
+ test("a plan that differs from the approved one fails the chant/record status; an approved or unapproved one passes", () => {
242
+ expect(prStatusContext("record")).toBe("chant/record");
243
+ expect(prStatusContext("record", "net")).toBe("chant/record/net");
244
+ expect(prStatusState(recorded)).toBe("success");
245
+ expect(prStatusState(unapproved)).toBe("success");
246
+ expect(prStatusState(moved)).toBe("failure");
247
+ expect(prStatusDescription(recorded)).toMatch(/^recorded 2 members: the approved plan; plan [0-9a-f]{12}$/);
248
+ expect(prStatusDescription(moved)).toMatch(/^recorded 2 members: the plan differs from the approved one/);
249
+ expect(prStatusDescription(unapproved)).toMatch(/^recorded 2 members: no approval for this plan/);
250
+ });
251
+
252
+ test("the note says nothing applied and a release deploys it", () => {
253
+ const note = renderPrNote(unapproved);
254
+ expect(note).toContain("### chant record for `prod`");
255
+ expect(note).toContain("Recorded the plan of 2 members on merge. Nothing applied: a release that carries this commit deploys it.");
256
+ expect(note).toContain("No approval stands for this plan. The release gate decides before it deploys.");
257
+ expect(renderPrNote(moved)).toContain("the plan changed since it was approved");
258
+ });
259
+ });
260
+
232
261
  describe("the status", () => {
233
262
  test("describes each stage in at most 140 characters", () => {
234
263
  expect(prStatusDescription(planReport)).toMatch(/^2 members: 1 to change, 0 to destroy or replace; plan [0-9a-f]{12}$/);
package/src/pr-loop.ts CHANGED
@@ -68,6 +68,9 @@ export const PR_REPORT_WAVES_FLOOR = "0.124.32";
68
68
  */
69
69
  export const PR_REPORT_PLANNED_OUTPUTS_FLOOR = "0.124.32";
70
70
 
71
+ /** The chant that writes the `record` stage and the `recorded` status (#4168 step 1). */
72
+ export const PR_REPORT_RECORD_FLOOR = "0.124.33";
73
+
71
74
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
72
75
  export const PR_APPLY_GATE = "pr-apply";
73
76
 
@@ -97,11 +100,14 @@ export function prNoteMarker(env: string, member?: string): string {
97
100
  return member ? `<!-- chant-pr:${env}:${member} -->` : `<!-- chant-pr:${env} -->`;
98
101
  }
99
102
 
100
- /** The status contexts the two stages set on the commit. */
101
- export const PR_STATUS_CONTEXTS = { plan: "chant/plan", apply: "chant/apply" } as const;
103
+ /** The status contexts the stages set on the commit. */
104
+ export const PR_STATUS_CONTEXTS = { plan: "chant/plan", apply: "chant/apply", record: "chant/record" } as const;
105
+
106
+ /** A pull-request loop stage: plan on the pull request, then record (#4168) or, opted in, apply on merge. */
107
+ export type PrStage = keyof typeof PR_STATUS_CONTEXTS;
102
108
 
103
109
  /** A stage's status context: `chant/plan`, or `chant/plan/<member>` for a workspace member's pipeline. */
104
- export function prStatusContext(stage: "plan" | "apply", member?: string): string {
110
+ export function prStatusContext(stage: PrStage, member?: string): string {
105
111
  return member ? `${PR_STATUS_CONTEXTS[stage]}/${member}` : PR_STATUS_CONTEXTS[stage];
106
112
  }
107
113
 
@@ -207,6 +213,12 @@ export interface PrApproval {
207
213
  export type PrStatus =
208
214
  /** Plan stage: every member planned. */
209
215
  | "planned"
216
+ /**
217
+ * Record stage (#4168): every member planned on the merged commit and the
218
+ * plan is kept with the merge record. Nothing applied; `approval` says
219
+ * whether the plan is the one approved.
220
+ */
221
+ | "recorded"
210
222
  /** Plan stage: a member failed to plan. Apply stage: the same, so nothing applied. */
211
223
  | "plan-failed"
212
224
  /** Nothing the change touches is deployed by a component. */
@@ -284,7 +296,7 @@ export interface PrWave {
284
296
  export interface PrReport {
285
297
  $schema: typeof PR_REPORT_SCHEMA_ID;
286
298
  contract: typeof PR_REPORT_CONTRACT;
287
- stage: "plan" | "apply";
299
+ stage: PrStage;
288
300
  /** The pull or merge request number, when known. */
289
301
  pr: number | null;
290
302
  env: string;
@@ -406,6 +418,8 @@ function headline(report: PrReport): string {
406
418
  return "Nothing to plan: no component deploys what this change touches.";
407
419
  case "planned":
408
420
  return `Planned ${members}.`;
421
+ case "recorded":
422
+ return `Recorded the plan of ${members} on merge. Nothing applied: a release that carries this commit deploys it.`;
409
423
  case "plan-failed":
410
424
  return `Planning failed for ${report.members.filter((m) => m.status === "plan-failed").length} of ${members}, so nothing can apply.`;
411
425
  case "applied":
@@ -468,7 +482,7 @@ function approvalLines(report: PrReport, approver: string): string[] {
468
482
  const lines =
469
483
  a.status === "changed"
470
484
  ? [`An approval stands for ${code(a.approved ?? "(none)")}, not for this digest: the plan changed since it was approved.`]
471
- : ["Not approved yet."];
485
+ : [report.stage === "record" ? "No approval stands for this plan. The release gate decides before it deploys." : "Not approved yet."];
472
486
  if (report.stage === "plan" && report.status === "planned") {
473
487
  lines.push(
474
488
  "",
@@ -478,7 +492,7 @@ function approvalLines(report: PrReport, approver: string): string[] {
478
492
  prApproveCommand(report, approver),
479
493
  "```",
480
494
  "",
481
- "It applies on merge if the plan is still this one. If it moved, the apply refuses and names both digests.",
495
+ "On merge the plan is recorded, and a release deploys it. With apply on merge it applies if the plan is still this one; if it moved, the apply refuses and names both digests.",
482
496
  );
483
497
  }
484
498
  return lines;
@@ -494,7 +508,7 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
494
508
  const limit = options.limit ?? GITHUB_COMMENT_LIMIT;
495
509
  const out: string[] = [
496
510
  prNoteMarker(report.env, report.member),
497
- `### chant ${report.stage === "plan" ? "plan" : "apply"} for ${code(report.env)}${report.member ? ` in member ${code(report.member)}` : ""}`,
511
+ `### chant ${report.stage} for ${code(report.env)}${report.member ? ` in member ${code(report.member)}` : ""}`,
498
512
  "",
499
513
  `${headline(report)} Head ${code(short(report.head))}, measured from ${code(short(report.base))}.`,
500
514
  ];
@@ -594,6 +608,9 @@ export function prStatusDescription(report: PrReport): string {
594
608
  case "plan-failed":
595
609
  text = `a member failed to plan${digest}`;
596
610
  break;
611
+ case "recorded":
612
+ text = `recorded ${n} member${n === 1 ? "" : "s"}: ${report.approval.status === "approved" ? "the approved plan" : report.approval.status === "changed" ? "the plan differs from the approved one" : "no approval for this plan"}${digest}`;
613
+ break;
597
614
  case "applied":
598
615
  text = `applied ${n} member${n === 1 ? "" : "s"}${digest}`;
599
616
  break;
@@ -627,6 +644,8 @@ export function prStatusDescription(report: PrReport): string {
627
644
 
628
645
  /** The commit status state for a report: success, failure, or pending while it waits on approval. */
629
646
  export function prStatusState(report: PrReport): "success" | "failure" | "pending" {
647
+ // #4168 decision 1: a merge whose plan differs from the approved one is recorded, and its status fails.
648
+ if (report.status === "recorded") return report.approval.status === "changed" ? "failure" : "success";
630
649
  if (report.status === "planned" || report.status === "applied" || report.status === "wave-applied" || report.status === "nothing") return "success";
631
650
  if (report.status === "refused" && (report.refusal === "not-approved" || report.refusal === "wave-not-approved")) return "pending";
632
651
  if (report.status === "waiting") return "pending";
@@ -71,7 +71,7 @@ export function reportKey(pr: number | null, commit: string, member?: string): s
71
71
  }
72
72
 
73
73
  /** The events file of a stage. */
74
- export const reportEventsFile = (stage: "plan" | "apply"): string => `pr-${stage}.events.jsonl`;
74
+ export const reportEventsFile = (stage: "plan" | "apply" | "record"): string => `pr-${stage}.events.jsonl`;
75
75
 
76
76
  /** Puts one object: `key` below the URL. Rejects with a line a person can read. */
77
77
  export type ReportPut = (key: string, body: string, contentType: string) => Promise<void>;
@@ -194,7 +194,7 @@ const messageOf = (err: unknown): string => (err instanceof Error ? err.message
194
194
  export function openPrReportUpload(input: {
195
195
  target: ReportTarget;
196
196
  key: string;
197
- stage: "plan" | "apply";
197
+ stage: "plan" | "apply" | "record";
198
198
  warn: (message: string) => void;
199
199
  put?: ReportPut;
200
200
  }): PrReportUpload {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `@intentius/chant/runner-start` bundles small (#4147): a stage that runs
3
+ * as one bundled file checks its runner before it trades for credentials,
4
+ * so the verifier reaches nothing but itself, the DSSE helpers and
5
+ * `node:crypto` and `node:fs`.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { build } from "esbuild";
10
+ import { expect, test } from "vitest";
11
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
12
+
13
+ test("the runner-start verifier holds itself and the DSSE helpers, and imports only node:crypto and node:fs", async () => {
14
+ const result = await build({
15
+ entryPoints: [join(REPO, "packages", "core", "src", "runner-start.ts")],
16
+ bundle: true,
17
+ platform: "node",
18
+ format: "esm",
19
+ write: false,
20
+ metafile: true,
21
+ outdir: "out",
22
+ logLevel: "silent",
23
+ });
24
+ const inputs = Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort();
25
+ const imports = [...new Set(Object.values(result.metafile.outputs).flatMap((o) => o.imports.map((i) => i.path)))].sort();
26
+ expect(inputs).toEqual(["packages/core/src/runner-start.ts", "packages/core/src/workspace/trust/dsse.ts"]);
27
+ expect(imports).toEqual(["node:crypto", "node:fs"]);
28
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(24 * 1024);
29
+ });