@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
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * #4027: `chant release publish`'s contract. It refuses a release no cut
3
- * names, a revoked one, and one with no verified signature (#4049; under
4
- * identity.gates.release even with --allow-unsigned); it warns when the run has no OIDC token; and with no
5
- * publish target declared it has nothing to publish. With a target (#4047)
3
+ * names, a revoked one, and under identity.gates.release (even with
4
+ * --allow-unsigned) or the verified-releases security group (#4049, #4168)
5
+ * one with no verified signature; under supply-chain it warns when the run
6
+ * has no OIDC token. With no group a missing signature or OIDC token says
7
+ * nothing (#4168). With no publish target declared it has nothing to
8
+ * publish. With a target (#4047)
6
9
  * it reads them from release.publish and refuses a checkout that is not the
7
10
  * release's commit with a clean tree.
8
11
  */
@@ -76,26 +79,37 @@ describe("provenance", () => {
76
79
  });
77
80
 
78
81
  describe("chant release publish", () => {
79
- test("warns on a workstation that what it publishes carries no provenance, and has no target to publish to", async () => {
82
+ test("publishes an unsigned release on a workstation quietly, records it unsigned, and has no target to publish to (#4168)", async () => {
80
83
  const { r } = setup(null);
81
84
  await cut(r, "v1");
82
- const plan = await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION, allowUnsigned: true });
85
+ const plan = await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION });
83
86
  expect(refusal(plan)).toBeNull();
84
87
  const p = plan as PublishPlan;
85
88
  expect(p.label).toBe("v1");
86
89
  expect(p.signature.status).toBe("unsigned");
87
90
  expect(p.provenance.oidc).toBe(false);
91
+ expect(p.warning).toBeNull();
92
+ expect(p.signatureWarning).toBeNull();
93
+ expect(p.requireSbom).toBe(false);
94
+ expect(p.targets).toEqual([]);
95
+ expect(p.releaseTag).toBe("release/v1");
96
+ });
97
+
98
+ test("under supply-chain, warns on a workstation that what it publishes carries no provenance, and not in CI with OIDC (#4168)", async () => {
99
+ const { r } = setup({ security: ["supply-chain"] });
100
+ await cut(r, "v1");
101
+ const plan = await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION });
102
+ expect(refusal(plan)).toBeNull();
103
+ const p = plan as PublishPlan;
104
+ expect(p.requireSbom).toBe(true);
88
105
  expect(p.warning).toBe(
89
106
  "this publish runs without OIDC, so what it publishes carries no provenance: this run is not in CI, and a workstation has no OIDC token. Publish from CI to get provenance.",
90
107
  );
91
- expect(p.targets).toEqual([]);
92
- expect(p.releaseTag).toBe("release/v1");
93
108
 
94
109
  const inCi = (await preparePublish({
95
110
  cwd: r.dir,
96
111
  ref: p.release.releaseDigest,
97
112
  env: { GITHUB_ACTIONS: "true", ACTIONS_ID_TOKEN_REQUEST_URL: "https://x", ACTIONS_ID_TOKEN_REQUEST_TOKEN: "t" },
98
- allowUnsigned: true,
99
113
  })) as PublishPlan;
100
114
  expect(inCi.warning).toBeNull();
101
115
  expect(inCi.label).toBe("v1");
@@ -146,12 +160,14 @@ describe("chant release publish", () => {
146
160
  expect(refusal(await preparePublish({ cwd: r.dir, ref: "v2", env: WORKSTATION, allowUnsigned: true }))?.code).toBe("release-signature-unverified");
147
161
  });
148
162
 
149
- test("refuses an unsigned release with no release gate, and publishes it under --allow-unsigned with a warning", async () => {
150
- const { r } = setup(null);
163
+ test("under verified-releases, refuses an unsigned release with no release gate, and publishes it under --allow-unsigned with a warning", async () => {
164
+ const { r } = setup({ security: ["verified-releases"] });
151
165
  await cut(r, "v1");
152
166
  const refused = refusal(await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION }));
153
167
  expect(refused?.code).toBe("release-unsigned");
154
- expect(refused?.message).toBe("v1 has no signed cut, and chant release publish publishes only a release signed by a key in .chant/allowed_signers");
168
+ expect(refused?.message).toBe(
169
+ "v1 has no signed cut, and the workspace opts into verified-releases (security in chant.workspace.json), which publishes only a release signed by a key in .chant/allowed_signers",
170
+ );
155
171
  expect(refused?.fix).toContain("chant release cut v1 --sign --yes");
156
172
  expect(refused?.fix).toContain("--allow-unsigned");
157
173
 
@@ -161,8 +177,12 @@ describe("chant release publish", () => {
161
177
  expect(allowed.cut).toMatchObject({ actor: "alice", label: "v1", signature: null });
162
178
  });
163
179
 
164
- test.skipIf(!hasSshKeygen)("with no release gate, publishes a release signed by a listed key and refuses one signed by a key the signers file does not list", async () => {
165
- const { r, alice, bob } = setup(null, { signers: true });
180
+ test.skipIf(!hasSshKeygen)("under verified-releases with no release gate, publishes a release signed by a listed key and refuses one signed by a key the signers file does not list", async () => {
181
+ const { r, alice, bob } = setup({ security: ["verified-releases"] });
182
+ // Only alice is a signer.
183
+ r.write(".chant/allowed_signers", `alice ${alice.pub}\n`);
184
+ const head = r.commit("only alice signs");
185
+ r.git(["tag", `ci/green/${head}`]);
166
186
  await cut(r, "v1", alice);
167
187
  const signed = (await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION })) as PublishPlan;
168
188
  expect(refusal(signed)).toBeNull();
@@ -180,6 +200,16 @@ describe("chant release publish", () => {
180
200
  expect(unlisted?.message).toMatch(/bob has no key in \.chant\/allowed_signers at base/);
181
201
  });
182
202
 
203
+ test.skipIf(!hasSshKeygen)("with no security group, publishes a release whose signature does not verify, records it unverified, and says nothing (#4168)", async () => {
204
+ const { r, bob } = setup(null, { signers: true });
205
+ await cut(r, "v1", bob, "bob");
206
+ const p = (await preparePublish({ cwd: r.dir, ref: "v1", env: WORKSTATION })) as PublishPlan;
207
+ expect(refusal(p)).toBeNull();
208
+ expect(p.signature.status).toBe("unverified");
209
+ expect(p.signatureWarning).toBeNull();
210
+ expect(p.warning).toBeNull();
211
+ });
212
+
183
213
  test("--require-provenance refuses a run with no OIDC token and passes one in CI", async () => {
184
214
  const { r } = setup(null);
185
215
  await cut(r, "v1");
@@ -7,16 +7,23 @@
7
7
  *
8
8
  * - a release no cut names (`release-not-found`);
9
9
  * - a release cut from a revoked commit (`release-revoked`, #4033);
10
- * - a release none of whose cuts carries a signature that verifies for its
11
- * actor against `.chant/allowed_signers` at base (`release-unsigned` or
12
- * `release-signature-unverified`, #4049), and under an `identity.gates`
13
- * entry for `release` (#4031) one whose signer is not in the gate's class.
14
- * `--allow-unsigned` publishes one with no verified signature anyway, with
15
- * a warning, except under the release gate, which it never overrides.
10
+ * - under an `identity.gates` entry for `release` (#4031), a release with no
11
+ * cut signed by a signer in the gate's class (`release-unsigned` or
12
+ * `release-signature-unverified`);
13
+ * - when the workspace opts into `verified-releases` (`security` in
14
+ * chant.workspace.json, #4168), a release none of whose cuts carries a
15
+ * signature that verifies for its actor against `.chant/allowed_signers`
16
+ * at base (#4049). `--allow-unsigned` publishes it anyway, with a warning;
17
+ * it never overrides the release gate.
18
+ *
19
+ * With neither, an unsigned release publishes quietly, and the plan records
20
+ * its `signature` status (#4168: security features are opt-in).
16
21
  *
17
22
  * It then says whether the run has an OIDC token to sign provenance with
18
- * ({@link detectProvenance}), and warns when it has none, before any target
19
- * runs; `--require-provenance` refuses instead (`publish-provenance-missing`).
23
+ * ({@link detectProvenance}). Under `supply-chain` it warns when there is
24
+ * none, before any target runs; `--require-provenance` refuses instead
25
+ * (`publish-provenance-missing`). Without either, provenance is only the
26
+ * plan's `provenance` field.
20
27
  * The targets come from {@link releasePublishTargets}: the member declares them under `release.publish` in its `chant.config.ts` (#4047).
21
28
  * With a target, the checkout must be the release's commit with a clean
22
29
  * tree ({@link checkCheckoutIsRelease}), every target is planned before any
@@ -154,6 +161,12 @@ export interface PublishContext {
154
161
  redactor: Redactor;
155
162
  /** Print one line of progress. The line is redacted. */
156
163
  log(line: string): void;
164
+ /**
165
+ * Whether a target with no SBOM source refuses (`publish-sbom-missing`),
166
+ * as under the `supply-chain` group (#4168). False or absent: it publishes
167
+ * without an SBOM. A declared `sbom` file is attached either way.
168
+ */
169
+ requireSbom?: boolean;
157
170
  }
158
171
 
159
172
  /** A target refused or failed. The message never holds a credential. */
@@ -252,7 +265,7 @@ export interface PublishPlan {
252
265
  /** The verified cut's signature when one verifies, else the newest cut's. */
253
266
  signature: { status: "verified" | "unsigned" | "unverified"; actor: string | null; key: string | null; reason: string };
254
267
  provenance: ProvenanceCheck;
255
- /** The warning printed before publishing, when the run has no OIDC token. */
268
+ /** The warning printed before publishing, when the run has no OIDC token and the workspace opts into `supply-chain` (#4168). */
256
269
  warning: string | null;
257
270
  /** The warning printed before publishing a release with no verified signature under `--allow-unsigned` (#4049). */
258
271
  signatureWarning: string | null;
@@ -263,6 +276,8 @@ export interface PublishPlan {
263
276
  targets: PublishTarget[];
264
277
  /** Masks the run's credentials in what the publish prints. */
265
278
  redactor: Redactor;
279
+ /** Whether a target with no SBOM source is refused (`publish-sbom-missing`): the workspace opts into `supply-chain` (#4168). Otherwise it publishes without one. */
280
+ requireSbom: boolean;
266
281
  }
267
282
 
268
283
  /**
@@ -270,9 +285,10 @@ export interface PublishPlan {
270
285
  * whether it may be published, reading the local `chant/lifecycle` branch,
271
286
  * the local tags and the declaration at base. Fetches nothing.
272
287
  *
273
- * `allowUnsigned` publishes a release with no verified signature, which is
274
- * refused otherwise (#4049); it does not override the release gate.
275
- * `requireProvenance` refuses a run with no OIDC token, where it warns.
288
+ * `allowUnsigned` publishes a release with no verified signature, which
289
+ * `verified-releases` refuses otherwise (#4049, #4168); it does not override
290
+ * the release gate. `requireProvenance` refuses a run with no OIDC token,
291
+ * where `supply-chain` warns and no group says nothing.
276
292
  */
277
293
  export async function preparePublish(req: {
278
294
  cwd: string;
@@ -287,6 +303,7 @@ export async function preparePublish(req: {
287
303
  const { scopeSource } = await import("../workspace/write-scope");
288
304
  const { releaseRuleFrom } = await import("../workspace/identity");
289
305
  const { checkReleaseSignature } = await import("../workspace/identity-gate-rule");
306
+ const { securityGroupsIn } = await import("../workspace/security");
290
307
  const { cwd, ref } = req;
291
308
  const found = await resolveRelease(ref, { cwd });
292
309
  if (!found) return { refusal: { code: "release-not-found", message: `no release is labelled or digested "${ref}" in this member`, fix: "List them with: chant release list" } };
@@ -304,7 +321,9 @@ export async function preparePublish(req: {
304
321
  };
305
322
  }
306
323
 
307
- const rule = releaseRuleFrom(scopeSource(cwd));
324
+ const source = scopeSource(cwd);
325
+ const rule = releaseRuleFrom(source);
326
+ const groups = securityGroupsIn(source, cwd);
308
327
  const cuts = (await readCuts({ cwd })).cuts.filter((c) => c.releaseDigest === release.releaseDigest);
309
328
  const checks = cuts.map((c) => ({
310
329
  cut: c,
@@ -323,9 +342,9 @@ export async function preparePublish(req: {
323
342
  },
324
343
  };
325
344
  }
326
- // #4049: with or without the gate, a publish refuses a release whose signature does not verify, unless --allow-unsigned.
345
+ // #4049, #4168: under verified-releases a publish refuses a release whose signature does not verify, unless --allow-unsigned. Without it an unsigned release publishes quietly.
327
346
  let signatureWarning: string | null = null;
328
- if (!verified) {
347
+ if (!verified && groups.has("verified-releases")) {
329
348
  const why = unsigned
330
349
  ? `${name} has no signed cut`
331
350
  : `no cut of ${name} carries a signature that verifies against the signers file at base: ${checks.map((c) => c.check.reason).join("; ")}`;
@@ -333,7 +352,7 @@ export async function preparePublish(req: {
333
352
  return {
334
353
  refusal: {
335
354
  code: unsigned ? "release-unsigned" : "release-signature-unverified",
336
- message: `${why}, and chant release publish publishes only a release signed by a key in .chant/allowed_signers`,
355
+ message: `${why}, and the workspace opts into verified-releases (security in chant.workspace.json), which publishes only a release signed by a key in .chant/allowed_signers`,
337
356
  fix: `Cut it signed with a key .chant/allowed_signers lists for the chant-release namespace: chant release cut ${label ?? "<label>"} --sign --yes. To publish it unsigned anyway, pass --allow-unsigned.`,
338
357
  },
339
358
  };
@@ -371,7 +390,7 @@ export async function preparePublish(req: {
371
390
  },
372
391
  };
373
392
  }
374
- const warning = provenance.oidc
393
+ const warning = provenance.oidc || !groups.has("supply-chain")
375
394
  ? null
376
395
  : `this publish runs without OIDC, so what it publishes carries no provenance: ${provenance.reason}. Publish from CI to get provenance.`;
377
396
  const { resolveMemberLedger } = await import("./member-ledger");
@@ -380,7 +399,7 @@ export async function preparePublish(req: {
380
399
  const member = ledger.members.length > 0 ? ledger.members.join("/") : null;
381
400
  const releaseTag = label !== null ? releaseTagName(member, label) : null;
382
401
  const redactor = createRedactor(env, targets.flatMap((t) => t.credentialEnv));
383
- return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor };
402
+ return { release, label, signature, provenance, warning, signatureWarning, cut, releaseTag, targets, redactor, requireSbom: groups.has("supply-chain") };
384
403
  }
385
404
 
386
405
  /**
@@ -432,6 +451,7 @@ export function publishContext(plan: PublishPlan, req: { cwd: string; env?: Node
432
451
  env: req.env ?? process.env,
433
452
  redactor: plan.redactor,
434
453
  log: (line) => req.log(plan.redactor.redact(line)),
454
+ requireSbom: plan.requireSbom,
435
455
  };
436
456
  }
437
457
 
@@ -14,6 +14,7 @@
14
14
  * _releases/<releaseDigest>.json the release, written once and never rewritten
15
15
  * _releases/cuts.jsonl one line per cut, with the release's label
16
16
  * _releases/templates/<digest>.txt each template's bytes, by content digest
17
+ * _releases/templates/<digest>.b64 a file that is not UTF-8, base64 (#4155)
17
18
  *
18
19
  * `:` is written as `_` in file names, as `_plans/` and `_builds/` do. A
19
20
  * workspace member's copy sits under `_members/<member>/` through the same
@@ -24,6 +25,14 @@
24
25
  * (ws-104 question 5). They are named by content digest, so a template that
25
26
  * two environments or two releases share is stored once.
26
27
  *
28
+ * Since #4155 a cut records every local file a deploy step deploys from, not
29
+ * only `template`, `manifest` and `values` files: a chart, a kustomize
30
+ * directory, a site's tree. A directory is recorded as one template per file
31
+ * under it, and named in the target's `trees`, so a promote deploys the
32
+ * recorded tree. A target with `trees` is one such cut; a target without the
33
+ * field was cut before #4155, and a promote of it reads directories and the
34
+ * other fields from the checkout, as it did then.
35
+ *
27
36
  * `releaseDigest` is `computePlanDigest("release", subject)` over the member,
28
37
  * the commit, the artifact digests and each target's env, templates and the
29
38
  * name and value of each param. A param's `source`, the label, the actor, the
@@ -32,7 +41,7 @@
32
41
  */
33
42
 
34
43
  import { sortedJsonReplacer } from "../utils";
35
- import { contentDigest } from "../content-digest";
44
+ import { contentDigest, sha256Hex } from "../content-digest";
36
45
  import { computePlanDigest } from "./plan-digest";
37
46
  import {
38
47
  ledgerDir,
@@ -74,6 +83,15 @@ export interface ReleaseTemplate {
74
83
  path: string;
75
84
  /** `sha256:<hex>` of the file's bytes. */
76
85
  digest: string;
86
+ /** `base64` for a file that is not UTF-8 (#4155): its stored copy is base64. Absent for text. */
87
+ encoding?: "base64";
88
+ }
89
+
90
+ /** A directory a component's deploy names, whose files the target records as templates under its path (#4155). */
91
+ export interface ReleaseTree {
92
+ component: string | null;
93
+ /** The directory as the deploy step names it, relative to the member's directory. */
94
+ path: string;
77
95
  }
78
96
 
79
97
  /** One resolved build parameter of a target. */
@@ -89,6 +107,8 @@ export interface ReleaseTarget {
89
107
  env: string;
90
108
  templates: ReleaseTemplate[];
91
109
  params: ReleaseParam[];
110
+ /** The directories the target records (#4155). Absent on a target cut before #4155. */
111
+ trees?: ReleaseTree[];
92
112
  }
93
113
 
94
114
  /** The release file, `_releases/<releaseDigest>.json`. */
@@ -147,9 +167,12 @@ export function normalizeRelease<T extends ReleaseSubject>(release: T): T {
147
167
  .map((t) => ({
148
168
  env: t.env,
149
169
  templates: [...t.templates]
150
- .map(({ component, path, digest }) => ({ component, path, digest }))
170
+ .map(({ component, path, digest, encoding }) => ({ component, path, digest, ...(encoding ? { encoding } : {}) }))
151
171
  .sort((a, b) => byString(a.component ?? "", b.component ?? "") || byString(a.path, b.path)),
152
172
  params: [...t.params].map(({ name, value, source }) => ({ name, value, source })).sort((a, b) => byString(a.name, b.name)),
173
+ ...(t.trees
174
+ ? { trees: [...t.trees].map(({ component, path }) => ({ component, path })).sort((a, b) => byString(a.component ?? "", b.component ?? "") || byString(a.path, b.path)) }
175
+ : {}),
153
176
  }))
154
177
  .sort((a, b) => byString(a.env, b.env)),
155
178
  };
@@ -162,7 +185,12 @@ export function releaseHashInput(release: ReleaseSubject): unknown {
162
185
  member: n.member,
163
186
  commit: n.commit,
164
187
  artifactDigests: n.artifactDigests,
165
- targets: n.targets.map((t) => ({ env: t.env, templates: t.templates, params: t.params.map(({ name, value }) => ({ name, value })) })),
188
+ targets: n.targets.map((t) => ({
189
+ env: t.env,
190
+ templates: t.templates,
191
+ params: t.params.map(({ name, value }) => ({ name, value })),
192
+ ...(t.trees ? { trees: t.trees } : {}),
193
+ })),
166
194
  };
167
195
  }
168
196
 
@@ -184,7 +212,12 @@ export function verifyReleaseDigest(release: Release): boolean {
184
212
 
185
213
  const stem = (digest: string): string => digest.replace(/:/g, "_");
186
214
  const releaseFile = (digest: string): string => `${stem(digest)}.json`;
187
- const templateFile = (digest: string): string => `${stem(digest)}.txt`;
215
+ const templateFile = (digest: string, encoding?: "base64"): string => `${stem(digest)}.${encoding === "base64" ? "b64" : "txt"}`;
216
+
217
+ /** The digest of a template's bytes: its text, or what its base64 decodes to. */
218
+ export function templateBytesDigest(content: string, encoding?: "base64"): string {
219
+ return encoding === "base64" ? `sha256:${sha256Hex(Buffer.from(content, "base64"))}` : contentDigest(content);
220
+ }
188
221
 
189
222
  /** A label names a release in commands such as `chant release cut <label>`, so it is one plain word. */
190
223
  const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
@@ -209,7 +242,9 @@ export class ReleaseLabelError extends Error {
209
242
  /** A release's template bytes, as the cut read them. */
210
243
  export interface TemplateContent {
211
244
  digest: string;
245
+ /** The text, or the base64 of the bytes when `encoding` is `base64`. */
212
246
  content: string;
247
+ encoding?: "base64";
213
248
  }
214
249
 
215
250
  /**
@@ -226,7 +261,7 @@ export async function persistRelease(
226
261
  throw new Error(`release ${release.releaseDigest} does not hash to its own digest; refusing to store it`);
227
262
  }
228
263
  for (const t of templates) {
229
- if (contentDigest(t.content) !== t.digest) throw new Error(`template bytes do not hash to ${t.digest}; refusing to store them`);
264
+ if (templateBytesDigest(t.content, t.encoding) !== t.digest) throw new Error(`template bytes do not hash to ${t.digest}; refusing to store them`);
230
265
  }
231
266
  const dir = await ledgerDir(RELEASES_DIR, opts);
232
267
  const files: LedgerFileWrite[] = [];
@@ -235,10 +270,11 @@ export async function persistRelease(
235
270
  if (!exists) files.push({ path: releasePath, content: `${JSON.stringify(release, sortedJsonReplacer)}\n`, expectPriorSha: null });
236
271
  const seen = new Set<string>();
237
272
  for (const t of templates) {
238
- if (seen.has(t.digest)) continue;
239
- seen.add(t.digest);
240
- if ((await readPathSha(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, templateFile(t.digest), opts)) !== null) continue;
241
- files.push({ path: `${dir}/${TEMPLATES_SUBDIR}/${templateFile(t.digest)}`, content: t.content, expectPriorSha: null });
273
+ const file = templateFile(t.digest, t.encoding);
274
+ if (seen.has(file)) continue;
275
+ seen.add(file);
276
+ if ((await readPathSha(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, file, opts)) !== null) continue;
277
+ files.push({ path: `${dir}/${TEMPLATES_SUBDIR}/${file}`, content: t.content, expectPriorSha: null });
242
278
  }
243
279
  if (files.length === 0) return { commit: null, written: false };
244
280
  try {
@@ -274,7 +310,15 @@ export function parseRelease(text: string): Release | null {
274
310
  t &&
275
311
  typeof t.env === "string" &&
276
312
  Array.isArray(t.templates) &&
277
- t.templates.every((x) => x && (x.component === null || typeof x.component === "string") && typeof x.path === "string" && typeof x.digest === "string") &&
313
+ t.templates.every(
314
+ (x) =>
315
+ x &&
316
+ (x.component === null || typeof x.component === "string") &&
317
+ typeof x.path === "string" &&
318
+ typeof x.digest === "string" &&
319
+ (x.encoding === undefined || x.encoding === "base64"),
320
+ ) &&
321
+ (t.trees === undefined || (Array.isArray(t.trees) && t.trees.every((x) => x && (x.component === null || typeof x.component === "string") && typeof x.path === "string"))) &&
278
322
  Array.isArray(t.params) &&
279
323
  t.params.every((p) => p && typeof p.name === "string" && ["string", "number", "boolean"].includes(typeof p.value) && typeof p.source === "string"),
280
324
  );
@@ -414,9 +458,9 @@ export async function appendCut(
414
458
  throw lastErr;
415
459
  }
416
460
 
417
- /** A template's bytes by digest, or null when none is stored. */
418
- export async function readReleaseTemplate(digest: string, opts?: LedgerLocation): Promise<string | null> {
419
- return readBlobFromPath(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, templateFile(digest), opts);
461
+ /** A template's bytes by digest, or null when none is stored. Under `base64` (#4155), the stored base64 of the bytes. */
462
+ export async function readReleaseTemplate(digest: string, opts?: LedgerLocation, encoding?: "base64"): Promise<string | null> {
463
+ return readBlobFromPath(`${RELEASES_DIR}/${TEMPLATES_SUBDIR}`, templateFile(digest, encoding), opts);
420
464
  }
421
465
 
422
466
  /**
@@ -92,6 +92,19 @@ describe("buildRunRecord", () => {
92
92
  expect(input.phases[1].steps.map((s) => s.status)).toEqual(["ok", "skipped"]);
93
93
  });
94
94
 
95
+ test("keeps a shell step's image on its record (#4015), and nothing for a step without one", () => {
96
+ const image = `node:24@sha256:${"c".repeat(64)}`;
97
+ const input = buildRunRecord(
98
+ { name: "build" },
99
+ [
100
+ { phase: "Build", fn: "shellCmd", args: { cmd: "npm test", image }, status: "ok", durationMs: 3 },
101
+ { phase: "Build", fn: "shellCmd", args: { cmd: "echo hi" }, status: "ok", durationMs: 1 },
102
+ ],
103
+ { started: "2026-01-01T00:00:00.000Z", ended: "2026-01-01T00:00:01.000Z", status: "ok" },
104
+ );
105
+ expect(input.phases[0].steps.map((s) => s.image)).toEqual([image, undefined]);
106
+ });
107
+
95
108
  test("carries the commit and the CI run origin when given (#4020), and neither when not", () => {
96
109
  const times = { started: "2026-01-01T00:00:00.000Z", ended: "2026-01-01T00:00:01.000Z", status: "ok" as const };
97
110
  const withBoth = buildRunRecord({ name: "ship" }, [], { ...times, gitSha: "a".repeat(40), runOrigin: { forge: "github", repo: "acme/app" } });
@@ -119,6 +119,7 @@ export function buildRunRecord(
119
119
  ...(record.point ? { point: record.point } : {}),
120
120
  ...(record.gate ? { gate: record.gate } : {}),
121
121
  ...(record.resumed ? { resumed: record.resumed } : {}),
122
+ ...(record.fn === "shellCmd" && typeof record.args?.image === "string" ? { image: record.args.image } : {}),
122
123
  });
123
124
  if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
124
125
  }
@@ -16,6 +16,7 @@ import { ops014 } from "./ops014-converge-rule-refusals";
16
16
  import { ops015 } from "./ops015-gate-approval";
17
17
  import { ops016 } from "./ops016-gate-resume";
18
18
  import { ops017Check } from "./ops017-gate-alias";
19
+ import { ops018 } from "./ops018-shell-image-digest";
19
20
  import { sys010Check } from "./sys010-key-over-plain-http";
20
21
  import type { DecideBackend } from "../../../op/decide-config";
21
22
 
@@ -25,6 +26,7 @@ export { ops014 } from "./ops014-converge-rule-refusals";
25
26
  export { ops015 } from "./ops015-gate-approval";
26
27
  export { ops016 } from "./ops016-gate-resume";
27
28
  export { ops017, ops017Check, workspaceIdentityGates } from "./ops017-gate-alias";
29
+ export { ops018 } from "./ops018-shell-image-digest";
28
30
  export { sys010, sys010Check } from "./sys010-key-over-plain-http";
29
31
 
30
32
  export interface CoreOpCheckOptions {
@@ -36,9 +38,9 @@ export interface CoreOpCheckOptions {
36
38
 
37
39
  /**
38
40
  * Core's own post-synth checks over the Op model: OPS012, OPS013, OPS014,
39
- * OPS015, OPS016, OPS017 over the workspace's `identity.gates`, and SYS010
41
+ * OPS015, OPS016, OPS017 over the workspace's `identity.gates`, OPS018, and SYS010
40
42
  * over the `decide` steps and the configured decide backends.
41
43
  */
42
44
  export function coreOpChecks(opts: CoreOpCheckOptions = {}): PostSynthCheck[] {
43
- return [ops012, ops013, ops014, ops015, ops016, ops017Check(opts.identityGates), sys010Check(opts.decideBackends)];
45
+ return [ops012, ops013, ops014, ops015, ops016, ops017Check(opts.identityGates), ops018, sys010Check(opts.decideBackends)];
44
46
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * OPS018 (#4015): a shell step's image is pinned by digest.
3
+ */
4
+ import { describe, test, expect } from "vitest";
5
+ import type { PostSynthContext } from "../../post-synth";
6
+ import { DECLARABLE_MARKER } from "../../../declarable";
7
+ import { stepOutput } from "../../../op/step-output-ref";
8
+ import { ops018 } from "./ops018-shell-image-digest";
9
+ import { coreOpChecks } from "./index";
10
+
11
+ const PINNED = `node:24@sha256:${"d".repeat(64)}`;
12
+
13
+ function ctxWith(props: Record<string, unknown>): PostSynthContext {
14
+ const entities = new Map<string, unknown>([["build", {
15
+ [DECLARABLE_MARKER]: true,
16
+ entityType: "Chant::Op",
17
+ lexicon: "chant",
18
+ kind: "resource",
19
+ props: { name: "build", overview: "test", ...props },
20
+ attributes: {},
21
+ }]]);
22
+ return {
23
+ outputs: new Map([["chant", ""]]),
24
+ entities: entities as Map<string, never>,
25
+ buildResult: { outputs: new Map([["chant", ""]]), entities: entities as Map<string, never>, warnings: [], errors: [], sourceFileCount: 1 },
26
+ };
27
+ }
28
+
29
+ const sh = (args: Record<string, unknown>) => ({ kind: "activity", fn: "shellCmd", args: { cmd: "npm test", ...args } });
30
+
31
+ describe("OPS018", () => {
32
+ test("is one of core's Op checks", () => {
33
+ expect(coreOpChecks().map((c) => c.id)).toContain("OPS018");
34
+ });
35
+
36
+ test("a pinned image and a step with no image pass", () => {
37
+ expect(ops018.check(ctxWith({ phases: [{ name: "Build", steps: [sh({ image: PINNED }), sh({})] }] }))).toEqual([]);
38
+ });
39
+
40
+ test("a tag is refused, naming the Op, the phase and the fix", () => {
41
+ const [diag, ...rest] = ops018.check(ctxWith({ phases: [{ name: "Build", steps: [sh({ image: "node:24" })] }] }));
42
+ expect(rest).toEqual([]);
43
+ expect(diag?.checkId).toBe("OPS018");
44
+ expect(diag?.severity).toBe("error");
45
+ expect(diag?.message).toBe('Op "build", phase "Build", shellCmd: image "node:24" is not pinned by digest: name it as <ref>@sha256:<digest>');
46
+ });
47
+
48
+ test("an image read from another step is refused, since the record could not say what ran", () => {
49
+ const [diag] = ops018.check(ctxWith({ phases: [{ name: "Build", steps: [sh({ image: stepOutput("pick", "stdout") })] }] }));
50
+ expect(diag?.message).toContain("image must be a literal string");
51
+ });
52
+
53
+ test("onFailure and effect steps are checked too", () => {
54
+ const diags = ops018.check(ctxWith({
55
+ phases: [{ name: "Apply", steps: [{ kind: "effect", receipt: {}, steps: [sh({ image: "alpine" })] }] }],
56
+ onFailure: [{ name: "Rollback", steps: [sh({ image: "alpine:latest" })] }],
57
+ }));
58
+ expect(diags.map((d) => d.message.split(",")[1]?.trim())).toEqual(['phase "Apply"', 'phase "Rollback"']);
59
+ });
60
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * OPS018: a `shellCmd` step that runs in a container (#4015) names its image
3
+ * pinned by digest, `<ref>@sha256:<digest>`, as a literal string. A tag can
4
+ * move between two runs of the same Op, and then the step record's image no
5
+ * longer says what ran. Checked over the main phases, `onFailure`, and the
6
+ * steps inside an effect.
7
+ */
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
9
+ import type { OpConfig, PhaseDefinition, StepDefinition } from "../../../op";
10
+ import { imageProblem } from "../../../op/activities/shell";
11
+ import { isOpEntity } from "./support";
12
+
13
+ function activitySteps(steps: readonly StepDefinition[]): Array<{ fn: string; args?: Record<string, unknown> }> {
14
+ return steps.flatMap((step) =>
15
+ step.kind === "activity" ? [step] : step.kind === "effect" ? step.steps.filter((s) => s.kind === "activity") : [],
16
+ ) as Array<{ fn: string; args?: Record<string, unknown> }>;
17
+ }
18
+
19
+ export const ops018: PostSynthCheck = {
20
+ id: "OPS018",
21
+ description: "A shell step with an image names it pinned by digest, as a literal string",
22
+
23
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
24
+ const diagnostics: PostSynthDiagnostic[] = [];
25
+ for (const [entityKey, entity] of ctx.entities) {
26
+ if (!isOpEntity(entity)) continue;
27
+ const rec = entity as unknown as Record<string, unknown>;
28
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
29
+ if (typeof props.name !== "string" || !Array.isArray(props.phases)) continue;
30
+ const phases: PhaseDefinition[] = [...props.phases, ...(props.onFailure ?? [])];
31
+ for (const phase of phases) {
32
+ for (const step of activitySteps(phase.steps ?? [])) {
33
+ if (step.fn !== "shellCmd" || step.args?.image === undefined) continue;
34
+ const problem = imageProblem(step.args.image);
35
+ if (!problem) continue;
36
+ diagnostics.push({
37
+ checkId: "OPS018",
38
+ severity: "error",
39
+ message: `Op "${props.name}", phase "${phase.name}", shellCmd: ${problem}`,
40
+ entity: entityKey,
41
+ lexicon: typeof rec.lexicon === "string" ? rec.lexicon : undefined,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ return diagnostics;
47
+ },
48
+ };
@@ -59,6 +59,9 @@ export const shellCmdContract = activityContract(
59
59
  gatedExit: z.number().optional(),
60
60
  gate: z.strictObject({ op: z.string(), gate: z.string() }).optional(),
61
61
  json: z.boolean().optional(),
62
+ image: z.string().optional(),
63
+ mounts: z.strictObject({ workspace: z.enum(["ro", "rw"]).optional() }).optional(),
64
+ outputs: z.array(z.string()).optional(),
62
65
  }),
63
66
  // `json` is stdout parsed, when the step sets `json` (#2787): whatever the
64
67
  // command printed, so its shape is the author's and only the whole value is