@intentius/chant 0.124.32 → 0.124.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +16 -1
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +81 -21
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +23 -1
  24. package/dist/cli/registry.d.ts +18 -4
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/release-compile.d.ts +37 -2
  40. package/dist/components/release-compile.d.ts.map +1 -1
  41. package/dist/components/release-compile.js +64 -8
  42. package/dist/components/release-promote.d.ts +17 -3
  43. package/dist/components/release-promote.d.ts.map +1 -1
  44. package/dist/components/release-promote.js +79 -18
  45. package/dist/components/verbs/bom-writer.d.ts +1 -1
  46. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  47. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  48. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  49. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  50. package/dist/components/wave-plan.d.ts +10 -0
  51. package/dist/components/wave-plan.d.ts.map +1 -1
  52. package/dist/components/wave-plan.js +32 -4
  53. package/dist/graph-detail.d.ts.map +1 -1
  54. package/dist/graph-detail.js +32 -7
  55. package/dist/health.d.ts +110 -0
  56. package/dist/health.d.ts.map +1 -0
  57. package/dist/health.js +105 -0
  58. package/dist/health.schema.json +191 -0
  59. package/dist/lexicon.d.ts +27 -0
  60. package/dist/lexicon.d.ts.map +1 -1
  61. package/dist/lifecycle/merge-record.d.ts +104 -0
  62. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  63. package/dist/lifecycle/merge-record.js +102 -0
  64. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  65. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  66. package/dist/lifecycle/publish-attachments.js +27 -13
  67. package/dist/lifecycle/publish-npm.d.ts +3 -2
  68. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  69. package/dist/lifecycle/publish-npm.js +4 -3
  70. package/dist/lifecycle/release-at.d.ts +40 -0
  71. package/dist/lifecycle/release-at.d.ts.map +1 -0
  72. package/dist/lifecycle/release-at.js +83 -0
  73. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  74. package/dist/lifecycle/release-cut.js +23 -12
  75. package/dist/lifecycle/release-publish.d.ts +28 -12
  76. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  77. package/dist/lifecycle/release-publish.js +29 -17
  78. package/dist/lifecycle/release-store.d.ts +25 -2
  79. package/dist/lifecycle/release-store.d.ts.map +1 -1
  80. package/dist/lifecycle/release-store.js +40 -13
  81. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  82. package/dist/lifecycle/run-ledger.js +1 -0
  83. package/dist/lint/rules/op/index.d.ts +2 -1
  84. package/dist/lint/rules/op/index.d.ts.map +1 -1
  85. package/dist/lint/rules/op/index.js +4 -2
  86. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  87. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  88. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  89. package/dist/op/activities/activity-contracts.d.ts +5 -0
  90. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  91. package/dist/op/activities/activity-contracts.js +3 -0
  92. package/dist/op/activities/shell.d.ts +38 -0
  93. package/dist/op/activities/shell.d.ts.map +1 -1
  94. package/dist/op/activities/shell.js +89 -3
  95. package/dist/op/builders.d.ts +7 -0
  96. package/dist/op/builders.d.ts.map +1 -1
  97. package/dist/op/builders.js +7 -0
  98. package/dist/op/runtime.d.ts +2 -0
  99. package/dist/op/runtime.d.ts.map +1 -1
  100. package/dist/pr-loop.d.ts +14 -3
  101. package/dist/pr-loop.d.ts.map +1 -1
  102. package/dist/pr-loop.js +15 -5
  103. package/dist/pr-report-url.d.ts +2 -2
  104. package/dist/pr-report-url.d.ts.map +1 -1
  105. package/dist/runner-start.d.ts +179 -0
  106. package/dist/runner-start.d.ts.map +1 -0
  107. package/dist/runner-start.js +277 -0
  108. package/dist/wave-runtime.mjs +235 -228
  109. package/dist/workspace/check.schema.json +70 -2
  110. package/dist/workspace/checks/security.d.ts +35 -0
  111. package/dist/workspace/checks/security.d.ts.map +1 -0
  112. package/dist/workspace/checks/security.js +305 -0
  113. package/dist/workspace/checks.d.ts +2 -0
  114. package/dist/workspace/checks.d.ts.map +1 -1
  115. package/dist/workspace/checks.js +5 -0
  116. package/dist/workspace/ci-builds.d.ts +163 -0
  117. package/dist/workspace/ci-builds.d.ts.map +1 -0
  118. package/dist/workspace/ci-builds.js +315 -0
  119. package/dist/workspace/ci-green-cli.d.ts +2 -1
  120. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  121. package/dist/workspace/ci-green-cli.js +25 -5
  122. package/dist/workspace/ci-green-forge.d.ts +12 -0
  123. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  124. package/dist/workspace/ci-green-forge.js +12 -0
  125. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  126. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  127. package/dist/workspace/ci-green-workflow.js +128 -1
  128. package/dist/workspace/ci-green.d.ts +89 -4
  129. package/dist/workspace/ci-green.d.ts.map +1 -1
  130. package/dist/workspace/ci-green.js +310 -23
  131. package/dist/workspace/ci-last-green.schema.json +49 -0
  132. package/dist/workspace/ci-list.schema.json +35 -1
  133. package/dist/workspace/ci-run-index.d.ts +1 -1
  134. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  135. package/dist/workspace/ci-run-index.schema.json +1 -1
  136. package/dist/workspace/ci-run.d.ts +12 -6
  137. package/dist/workspace/ci-run.d.ts.map +1 -1
  138. package/dist/workspace/ci-run.js +72 -16
  139. package/dist/workspace/ci-runner-start.d.ts +77 -0
  140. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  141. package/dist/workspace/ci-runner-start.js +185 -0
  142. package/dist/workspace/ci-serve.d.ts +76 -7
  143. package/dist/workspace/ci-serve.d.ts.map +1 -1
  144. package/dist/workspace/ci-serve.js +153 -19
  145. package/dist/workspace/declaration.d.ts +17 -0
  146. package/dist/workspace/declaration.d.ts.map +1 -1
  147. package/dist/workspace/declaration.js +3 -0
  148. package/dist/workspace/declaration.schema.json +23 -0
  149. package/dist/workspace/ls.d.ts +2 -0
  150. package/dist/workspace/ls.d.ts.map +1 -1
  151. package/dist/workspace/ls.js +1 -1
  152. package/dist/workspace/ls.schema.json +2 -1
  153. package/dist/workspace/member-pipeline.d.ts +1 -0
  154. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  155. package/dist/workspace/member-pipeline.js +2 -0
  156. package/dist/workspace/merge-record.schema.json +49 -0
  157. package/dist/workspace/pr-report.schema.json +3 -3
  158. package/dist/workspace/reason-codes.d.ts +17 -0
  159. package/dist/workspace/reason-codes.d.ts.map +1 -1
  160. package/dist/workspace/reason-codes.js +18 -0
  161. package/dist/workspace/release-candidates.d.ts +129 -0
  162. package/dist/workspace/release-candidates.d.ts.map +1 -0
  163. package/dist/workspace/release-candidates.js +233 -0
  164. package/dist/workspace/release-candidates.schema.json +353 -0
  165. package/dist/workspace/security.d.ts +40 -0
  166. package/dist/workspace/security.d.ts.map +1 -0
  167. package/dist/workspace/security.js +99 -0
  168. package/package.json +8 -1
  169. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  170. package/src/ci-jobs.ts +11 -2
  171. package/src/cli/handlers/build.test.ts +36 -0
  172. package/src/cli/handlers/build.ts +18 -3
  173. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  174. package/src/cli/handlers/lifecycle-health.ts +157 -0
  175. package/src/cli/handlers/lifecycle.ts +1 -1
  176. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  177. package/src/cli/handlers/pr-record.test.ts +201 -0
  178. package/src/cli/handlers/pr.test.ts +22 -0
  179. package/src/cli/handlers/pr.ts +127 -4
  180. package/src/cli/handlers/promote.test.ts +17 -0
  181. package/src/cli/handlers/promote.ts +16 -1
  182. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  183. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  184. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  185. package/src/cli/handlers/release.e2e.test.ts +19 -28
  186. package/src/cli/handlers/release.ts +81 -11
  187. package/src/cli/main.ts +81 -21
  188. package/src/cli/parse-args.ts +17 -1
  189. package/src/cli/registry.ts +18 -4
  190. package/src/components/deploy-inputs.test.ts +108 -0
  191. package/src/components/deploy-inputs.ts +225 -0
  192. package/src/components/driver.ts +11 -0
  193. package/src/components/gate-plan.ts +9 -0
  194. package/src/components/pr-pipeline.ts +56 -15
  195. package/src/components/pr-run.ts +1 -1
  196. package/src/components/release-compile.test.ts +34 -0
  197. package/src/components/release-compile.ts +89 -8
  198. package/src/components/release-promote.test.ts +55 -2
  199. package/src/components/release-promote.ts +93 -23
  200. package/src/components/verbs/bom-writer.ts +1 -1
  201. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  202. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  203. package/src/components/wave-plan.ts +32 -4
  204. package/src/graph-detail.test.ts +57 -0
  205. package/src/graph-detail.ts +33 -7
  206. package/src/health.schema.json +191 -0
  207. package/src/health.test.ts +62 -0
  208. package/src/health.ts +178 -0
  209. package/src/lexicon.ts +41 -1
  210. package/src/lifecycle/merge-record.test.ts +111 -0
  211. package/src/lifecycle/merge-record.ts +154 -0
  212. package/src/lifecycle/publish-attachments.ts +25 -13
  213. package/src/lifecycle/publish-npm.ts +4 -3
  214. package/src/lifecycle/publish-targets.test.ts +34 -4
  215. package/src/lifecycle/release-at.test.ts +48 -0
  216. package/src/lifecycle/release-at.ts +100 -0
  217. package/src/lifecycle/release-cut.test.ts +41 -0
  218. package/src/lifecycle/release-cut.ts +22 -11
  219. package/src/lifecycle/release-publish.test.ts +43 -13
  220. package/src/lifecycle/release-publish.ts +38 -18
  221. package/src/lifecycle/release-store.ts +57 -13
  222. package/src/lifecycle/run-ledger.test.ts +13 -0
  223. package/src/lifecycle/run-ledger.ts +1 -0
  224. package/src/lint/rules/op/index.ts +4 -2
  225. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  226. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  227. package/src/op/activities/activity-contracts.ts +3 -0
  228. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  229. package/src/op/activities/shell.test.ts +44 -1
  230. package/src/op/activities/shell.ts +110 -3
  231. package/src/op/builders.ts +7 -0
  232. package/src/op/runtime.ts +2 -0
  233. package/src/op/step-console.test.ts +4 -2
  234. package/src/pr-loop.test.ts +29 -0
  235. package/src/pr-loop.ts +26 -7
  236. package/src/pr-report-url.ts +2 -2
  237. package/src/runner-start-bundle.test.ts +29 -0
  238. package/src/runner-start.test.ts +179 -0
  239. package/src/runner-start.ts +329 -0
  240. package/src/workspace/check-contract.test.ts +3 -2
  241. package/src/workspace/check.schema.json +70 -2
  242. package/src/workspace/checks/security.test.ts +138 -0
  243. package/src/workspace/checks/security.ts +305 -0
  244. package/src/workspace/checks.ts +5 -0
  245. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  246. package/src/workspace/ci-builds.test.ts +174 -0
  247. package/src/workspace/ci-builds.ts +385 -0
  248. package/src/workspace/ci-green-builds.test.ts +180 -0
  249. package/src/workspace/ci-green-cli.test.ts +54 -0
  250. package/src/workspace/ci-green-cli.ts +26 -5
  251. package/src/workspace/ci-green-forge.ts +24 -0
  252. package/src/workspace/ci-green-workflow.test.ts +84 -1
  253. package/src/workspace/ci-green-workflow.ts +139 -2
  254. package/src/workspace/ci-green.test.ts +5 -2
  255. package/src/workspace/ci-green.ts +359 -27
  256. package/src/workspace/ci-last-green.schema.json +49 -0
  257. package/src/workspace/ci-list.schema.json +35 -1
  258. package/src/workspace/ci-run-index.schema.json +1 -1
  259. package/src/workspace/ci-run-index.ts +1 -1
  260. package/src/workspace/ci-run.test.ts +24 -7
  261. package/src/workspace/ci-run.ts +78 -16
  262. package/src/workspace/ci-runner-start.test.ts +151 -0
  263. package/src/workspace/ci-runner-start.ts +193 -0
  264. package/src/workspace/ci-serve-builds.test.ts +86 -0
  265. package/src/workspace/ci-serve.test.ts +59 -0
  266. package/src/workspace/ci-serve.ts +177 -22
  267. package/src/workspace/declaration.schema.json +23 -0
  268. package/src/workspace/declaration.test.ts +17 -0
  269. package/src/workspace/declaration.ts +22 -1
  270. package/src/workspace/ls.schema.json +2 -1
  271. package/src/workspace/ls.ts +3 -2
  272. package/src/workspace/member-pipeline.ts +2 -1
  273. package/src/workspace/merge-record.schema.json +49 -0
  274. package/src/workspace/pr-report.schema.json +3 -3
  275. package/src/workspace/reason-codes.test.ts +2 -0
  276. package/src/workspace/reason-codes.ts +18 -0
  277. package/src/workspace/release-candidates.schema.json +353 -0
  278. package/src/workspace/release-candidates.test.ts +106 -0
  279. package/src/workspace/release-candidates.ts +315 -0
  280. package/src/workspace/security.test.ts +70 -0
  281. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Merge records (#4168 step 1): what landed on the target branch, keyed by
3
+ * the commit, kept on `chant/lifecycle` so a release can say which changes
4
+ * it carries and whether each was approved.
5
+ *
6
+ * _merges/<commit>.json one record per commit, rewritten when the record job runs again
7
+ *
8
+ * A merge never deploys. The record job (`chant components pr-record`)
9
+ * plans the members the change reaches on the merged commit, stores the
10
+ * change set (../lifecycle/change-set-store.ts) and writes this record. It
11
+ * applies nothing and refuses nothing: a plan that differs from the
12
+ * approved one is recorded, and the job sets a failing `chant/record`
13
+ * status (#4168 decision 1).
14
+ *
15
+ * The record names where the change came from (#4168 4.4): the pull request
16
+ * that merged it, the work items its `Chant-Record: work:<id>` trailers name,
17
+ * or a direct push. A workspace member's record sits under
18
+ * `_members/<member>/_merges/` through the same `ledgerDir` prefix the gate
19
+ * ledger uses.
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { sortedJsonReplacer } from "../utils";
24
+ import { parseRecordRef, RECORD_TRAILER, type RecordRef } from "../workspace/trailers";
25
+ import { ledgerDir, readBlobFromPath, readPathSha, RefCASConflictError, writeLedgerFiles, type LedgerLocation } from "./git";
26
+
27
+ /** The directory on `chant/lifecycle` that holds merge records. */
28
+ export const MERGES_DIR = "_merges";
29
+
30
+ export const MERGE_RECORD_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/merge-record/v1/merge-record.schema.json";
31
+ export const MERGE_RECORD_CONTRACT = 1;
32
+
33
+ /** Where the change came from. */
34
+ export type MergeSource =
35
+ /** The pull or merge request that merged the commit. */
36
+ | { kind: "pull-request"; pr: number }
37
+ /** No pull request merged it; its `Chant-Record` trailers name the work items it carries out. */
38
+ | { kind: "work-item"; records: RecordRef[] }
39
+ /** No pull request merged it and its message names no work item. */
40
+ | { kind: "direct-push" };
41
+
42
+ /**
43
+ * How the planned change set compares with the approval. `approved`: an
44
+ * approval stands for this digest. `changed`: one stands for another digest,
45
+ * so the plan moved after review. `unapproved`: none stands. `nothing`: no
46
+ * component deploys what the change touches. `plan-failed`: a member failed
47
+ * to plan, so there is no digest to compare.
48
+ */
49
+ export type MergePlanStatus = "approved" | "changed" | "unapproved" | "nothing" | "plan-failed";
50
+
51
+ export interface MergeRecord {
52
+ $schema: typeof MERGE_RECORD_SCHEMA_ID;
53
+ contract: typeof MERGE_RECORD_CONTRACT;
54
+ /** The merged commit. */
55
+ commit: string;
56
+ /** The commit the change is measured from. */
57
+ base: string;
58
+ env: string;
59
+ /** The workspace member whose pipeline recorded it. */
60
+ member?: string;
61
+ source: MergeSource;
62
+ /** The pull request's gate op (`pr-<n>`), or null without a pull request. */
63
+ op: string | null;
64
+ gate: string;
65
+ /** The change-set digest planned on the merged commit, or null when nothing was planned. */
66
+ digest: string | null;
67
+ plan: MergePlanStatus;
68
+ /** With `changed`: the digest the approval stands for. */
69
+ approvedDigest?: string;
70
+ /** Who approved the planned digest, with `approved`. */
71
+ approvedBy: string[];
72
+ /**
73
+ * Approvers who wrote the change: the pull request's author, or an author
74
+ * or committer of one of its commits (#4000). Read only with a forge. An
75
+ * approval by its author counts toward nothing under the review-rules
76
+ * group; the record keeps it so a release gate can say so.
77
+ */
78
+ authorApproved?: string[];
79
+ /** The components the change selects, in plan order. */
80
+ components: string[];
81
+ /** ISO 8601. */
82
+ recordedAt: string;
83
+ }
84
+
85
+ /**
86
+ * Where `commit` came from: the pull request `pr` when one merged it, else
87
+ * the work items its `Chant-Record: work:<id>` trailers name, else a direct
88
+ * push. A commit git can't read names no work item.
89
+ */
90
+ export function mergeSourceOf(cwd: string, commit: string, pr: number | null): MergeSource {
91
+ if (pr !== null) return { kind: "pull-request", pr };
92
+ let text = "";
93
+ try {
94
+ text = execFileSync("git", ["log", "-1", `--format=%(trailers:key=${RECORD_TRAILER},valueonly,separator=%x1e)`, commit], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
95
+ } catch {
96
+ text = "";
97
+ }
98
+ const records: RecordRef[] = [];
99
+ for (const value of text.split("\x1e").map((v) => v.trim()).filter(Boolean)) {
100
+ const ref = parseRecordRef(value);
101
+ if (ref && ref.kind === "work" && !records.some((r) => r.id === ref.id)) records.push(ref);
102
+ }
103
+ return records.length > 0 ? { kind: "work-item", records } : { kind: "direct-push" };
104
+ }
105
+
106
+ /** The file a commit's record is stored in, inside {@link MERGES_DIR}. */
107
+ export function mergeRecordFile(commit: string): string {
108
+ return `${commit}.json`;
109
+ }
110
+
111
+ /**
112
+ * Write `record` under its commit and return whether anything changed. A
113
+ * record already there with the same content is left alone; another is
114
+ * replaced, since the record job may run again on the same commit. Does not
115
+ * push: call `pushLifecycleReport` (./git.ts) after.
116
+ */
117
+ export async function writeMergeRecord(record: MergeRecord, opts?: LedgerLocation): Promise<{ commit: string | null; written: boolean }> {
118
+ const file = mergeRecordFile(record.commit);
119
+ const content = `${JSON.stringify(record, sortedJsonReplacer)}\n`;
120
+ for (let attempt = 0; attempt < 3; attempt++) {
121
+ const prior = await readPathSha(MERGES_DIR, file, opts);
122
+ if (prior !== null) {
123
+ const text = await readBlobFromPath(MERGES_DIR, file, opts);
124
+ if (text !== null && sameRecord(text, record)) return { commit: null, written: false };
125
+ }
126
+ const dir = await ledgerDir(MERGES_DIR, opts);
127
+ try {
128
+ const commit = await writeLedgerFiles([{ path: `${dir}/${file}`, content, expectPriorSha: prior }], `Merge record ${record.commit}`, opts);
129
+ return { commit, written: true };
130
+ } catch (err) {
131
+ if (!(err instanceof RefCASConflictError)) throw err;
132
+ }
133
+ }
134
+ throw new Error(`could not write the merge record for ${record.commit}: chant/lifecycle kept moving`);
135
+ }
136
+
137
+ /** The same record, ignoring when it was written. */
138
+ function sameRecord(text: string, record: MergeRecord): boolean {
139
+ try {
140
+ const { recordedAt: _a, ...had } = JSON.parse(text) as MergeRecord;
141
+ const { recordedAt: _b, ...now } = record;
142
+ return JSON.stringify(had, sortedJsonReplacer) === JSON.stringify(now, sortedJsonReplacer);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** The record of `commit`, or null when none is stored. Reads the local `chant/lifecycle` and never fetches. */
149
+ export async function readMergeRecord(commit: string, opts?: LedgerLocation): Promise<MergeRecord | null> {
150
+ const text = await readBlobFromPath(MERGES_DIR, mergeRecordFile(commit), opts);
151
+ if (text === null) return null;
152
+ const doc = JSON.parse(text) as MergeRecord;
153
+ return doc?.$schema === MERGE_RECORD_SCHEMA_ID ? doc : null;
154
+ }
@@ -30,7 +30,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  import { sortedJsonReplacer } from "../utils";
32
32
  import { writeBom } from "../components/verbs/bom-writer";
33
- import { parseNpmPackageLock, parsePomXml } from "../components/verbs/lockfile-sbom-generator";
33
+ import { LOCKFILE_NAMES, LOCKFILE_PARSERS } from "../components/verbs/lockfile-sbom-generator";
34
34
  import { SBOM_MEDIA_TYPES } from "../components/verbs/sbom-generator";
35
35
  import { bytesDigest, checkReleaseDigest, memberFile } from "./publish-targets";
36
36
  import { PublishTargetError, type PublishContext } from "./release-publish";
@@ -104,18 +104,25 @@ function invalid(where: string): PublishTargetError {
104
104
  return new PublishTargetError("publish-sbom-invalid", `${where} is not an SPDX or CycloneDX JSON document`, "Point sbom at the SPDX or CycloneDX JSON the build writes.");
105
105
  }
106
106
 
107
- /** The lockfile nearest the member: `package-lock.json` or `pom.xml` in `cwd` or a directory above it inside its repository (only `cwd` outside one). */
108
- function nearestLockfile(cwd: string): { kind: "npm" | "maven"; path: string } | null {
107
+ /**
108
+ * The lockfile nearest the member: `package-lock.json`, `pom.xml` or
109
+ * `Cargo.lock` in `cwd` or a directory above it inside its repository (only
110
+ * `cwd` outside one). A member whose directory holds a `Cargo.toml` is a
111
+ * Rust member, so its `Cargo.lock` comes first (#4168 decision 12); for any
112
+ * other, the order is npm, Maven, Cargo.
113
+ */
114
+ function nearestLockfile(cwd: string): { kind: "npm" | "maven" | "cargo"; path: string } | null {
109
115
  let top: string | null = null;
110
116
  try {
111
117
  top = realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim());
112
118
  } catch {
113
119
  top = null;
114
120
  }
121
+ const order = existsSync(join(cwd, "Cargo.toml")) ? (["cargo", "npm", "maven"] as const) : (["npm", "maven", "cargo"] as const);
115
122
  let dir = realpathSync(cwd);
116
123
  for (;;) {
117
- for (const [kind, file] of [["npm", "package-lock.json"], ["maven", "pom.xml"]] as const) {
118
- if (existsSync(join(dir, file))) return { kind, path: join(dir, file) };
124
+ for (const kind of order) {
125
+ if (existsSync(join(dir, LOCKFILE_NAMES[kind]))) return { kind, path: join(dir, LOCKFILE_NAMES[kind]) };
119
126
  }
120
127
  const up = dirname(dir);
121
128
  if (top === null || dir === top || up === dir || !up.startsWith(top)) return null;
@@ -127,8 +134,9 @@ function nearestLockfile(cwd: string): { kind: "npm" | "maven"; path: string } |
127
134
  * The SBOM a target attaches, for an OCI image or a forge release: the file
128
135
  * `declared` names (relative to the member, checked against the release's
129
136
  * digest when the release records it), else one written from the member's
130
- * lockfile for `subject`. Null when `declared` is `false`. Throws
131
- * `publish-sbom-missing` when there is neither.
137
+ * lockfile for `subject`. Null when `declared` is `false`, and when there is
138
+ * neither and `ctx.requireSbom` is not set (#4168). Throws
139
+ * `publish-sbom-missing` when there is neither under `supply-chain`.
132
140
  */
133
141
  export function memberSbom(ctx: PublishContext, declared: string | false | undefined, subject: string, target: string): Attachment | null {
134
142
  if (declared === false) return null;
@@ -142,18 +150,19 @@ export function memberSbom(ctx: PublishContext, declared: string | false | undef
142
150
  }
143
151
  const lock = nearestLockfile(ctx.cwd);
144
152
  if (!lock) {
153
+ if (!ctx.requireSbom) return null;
145
154
  throw new PublishTargetError(
146
155
  "publish-sbom-missing",
147
- `${target} attaches an SBOM to what it publishes, and its declaration names none and there is no package-lock.json or pom.xml in ${ctx.cwd} or above it to write one from`,
156
+ `${target} attaches an SBOM to what it publishes, and its declaration names none and there is no package-lock.json, pom.xml or Cargo.lock in ${ctx.cwd} or above it to write one from`,
148
157
  "Set sbom in the declaration to the SPDX or CycloneDX file the build writes, or sbom: false to publish without one.",
149
158
  );
150
159
  }
151
160
  const content = readFileSync(lock.path, "utf-8");
152
- const packages = lock.kind === "npm" ? parseNpmPackageLock(content) : parsePomXml(content);
161
+ const packages = LOCKFILE_PARSERS[lock.kind](content);
153
162
  const at = new Date(ctx.cut?.timestamp ?? 0);
154
163
  const doc = writeBom(
155
164
  "spdx",
156
- { subjectName: subject, subjectId: ctx.release.releaseDigest, packages, generator: `chant-lockfile-sbom/${lock.kind === "npm" ? "package-lock.json" : "pom.xml"}` },
165
+ { subjectName: subject, subjectId: ctx.release.releaseDigest, packages, generator: `chant-lockfile-sbom/${LOCKFILE_NAMES[lock.kind]}` },
157
166
  () => (Number.isNaN(at.getTime()) ? new Date(0) : at),
158
167
  );
159
168
  return attachment("sbom", GENERATED_SBOM_FILE, doc.mediaType, new Uint8Array(Buffer.from(doc.bytes, "utf-8")));
@@ -162,11 +171,13 @@ export function memberSbom(ctx: PublishContext, declared: string | false | undef
162
171
  /**
163
172
  * The SBOM an npm package carries at its root: the file `declared` names, else
164
173
  * the first of {@link PACKAGE_SBOM_FILES} it holds, in a package directory or
165
- * under `package/` in a packed `.tgz`. Null when `declared` is `false`.
166
- * Throws `publish-sbom-missing` when the package has none, and
174
+ * under `package/` in a packed `.tgz`. Null when `declared` is `false`, and
175
+ * when the package has none and `required` is false (#4168). Throws
176
+ * `publish-sbom-missing` when it has none and `required` is set (the
177
+ * `supply-chain` group) or `declared` names a file, and
167
178
  * `publish-sbom-invalid` when it is not SPDX or CycloneDX JSON.
168
179
  */
169
- export function packageSbom(abs: string, isDirectory: boolean, path: string, name: string, declared: string | false | undefined): { file: string; digest: string } | null {
180
+ export function packageSbom(abs: string, isDirectory: boolean, path: string, name: string, declared: string | false | undefined, required = false): { file: string; digest: string } | null {
170
181
  if (declared === false) return null;
171
182
  const candidates = declared !== undefined ? [declared] : [...PACKAGE_SBOM_FILES];
172
183
  let listed: Set<string> | null = null;
@@ -188,6 +199,7 @@ export function packageSbom(abs: string, isDirectory: boolean, path: string, nam
188
199
  if (!sbomMediaType(bytes)) throw invalid(`${file} in ${path}`);
189
200
  return { file, digest: bytesDigest(bytes) };
190
201
  }
202
+ if (!required && declared === undefined) return null;
191
203
  throw new PublishTargetError(
192
204
  "publish-sbom-missing",
193
205
  `${name} (${path}) carries no SBOM: npm keeps no file beside a package, so its SBOM goes in it, and ${declared !== undefined ? declared : `none of ${PACKAGE_SBOM_FILES.join(", ")}`} is at its root`,
@@ -35,8 +35,9 @@
35
35
  * Each package carries its SBOM (#4049): npm keeps no file beside a package,
36
36
  * so the SBOM is a file at the package's root, the one `sbom` names or the
37
37
  * first of `sbom.spdx.json`, `sbom.cdx.json`, `sbom.cyclonedx.json` it holds,
38
- * such as `npm sbom` writes. A package with none is refused
39
- * (`publish-sbom-missing`) unless the declaration says `sbom: false`. Moving a dist-tag over
38
+ * such as `npm sbom` writes. A package with none publishes without one,
39
+ * except under the `supply-chain` group (#4168) or when `sbom` names a file,
40
+ * where it is refused (`publish-sbom-missing`). Moving a dist-tag over
40
41
  * OIDC needs "Allow npm dist-tag" on the package's trusted-publisher record.
41
42
  * npm makes the first version of a new package `latest` whatever tag it is
42
43
  * published under, so a package's first release is visible when it stages.
@@ -100,7 +101,7 @@ function readPackage(ctx: PublishContext, path: string, sbom: NpmPublishDeclarat
100
101
  if (pkg.private === true) {
101
102
  throw new PublishTargetError("publish-npm-private", `${pkg.name} (${path}) is private, and npm refuses to publish it`, "Remove it from release.publish, or remove private: true.");
102
103
  }
103
- const carried = packageSbom(abs, directory, path, pkg.name, sbom);
104
+ const carried = packageSbom(abs, directory, path, pkg.name, sbom, ctx.requireSbom === true);
104
105
  return { path, abs, name: pkg.name, version: pkg.version, ...(digest ? { digest } : {}), ...(carried ? { sbom: carried.file } : {}) };
105
106
  }
106
107
 
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { createRedactor, REDACTED } from "./publish-credentials";
18
18
  import { PublishDeclarationSchema } from "./publish-declaration";
19
19
  import { createPublishTargets } from "./publish-targets";
20
+ import { memberSbom } from "./publish-attachments";
20
21
  import { parseRepository } from "./publish-oci";
21
22
  import { PublishTargetError, type PublishContext, type PublishTarget, type PublishedTarget } from "./release-publish";
22
23
  import { makeRelease, type ReleaseArtifact } from "./release-store";
@@ -396,7 +397,7 @@ describe("forge release assets", () => {
396
397
  describe("SBOMs and provenance (#4049)", () => {
397
398
  const CUT = { actor: "alice", label: "v1.4.0", timestamp: "2026-10-17T10:00:00.000Z", runId: "local-1", signature: "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n" };
398
399
 
399
- test("npm: a package carries its SBOM, one with none or with one that is not SPDX or CycloneDX is refused, and sbom: false publishes without", async () => {
400
+ test("npm: a package carries its SBOM; under supply-chain one with none is refused; one that is not SPDX or CycloneDX is refused; sbom: false publishes without", async () => {
400
401
  const dir = temp();
401
402
  writePackage(join(dir, "with"), "@acme/with", "1.0.0");
402
403
  writePackage(join(dir, "without"), "@acme/without", "1.0.0", false);
@@ -406,9 +407,11 @@ describe("SBOMs and provenance (#4049)", () => {
406
407
  writeFileSync(join(dir, "cdx", "package", "bom.json"), JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5" }));
407
408
  execFileSync("tar", ["-czf", join(dir, "cdx.tgz"), "-C", join(dir, "cdx"), "package"], { stdio: "ignore" });
408
409
  const npm = writeFakeNpm(join(dir, "bin"), join(dir, "npm.log"));
409
- const plan = async (d: Record<string, unknown>) => (await createPublishTargets([decl({ type: "npm", ...d })], { npm }))[0]!.plan(context(dir));
410
+ const plan = async (d: Record<string, unknown>, requireSbom = true) => (await createPublishTargets([decl({ type: "npm", ...d })], { npm }))[0]!.plan(context(dir, { requireSbom }));
410
411
 
411
412
  expect((await plan({ packages: ["with"] }))[0]!.sbom).toBe("sbom.spdx.json");
413
+ // #4168: outside supply-chain a package with none publishes without one, quietly.
414
+ expect((await plan({ packages: ["without"] }, false))[0]!.sbom).toBeUndefined();
412
415
  const missing = await refused(plan({ packages: ["without"] }));
413
416
  expect(missing.code).toBe("publish-sbom-missing");
414
417
  expect(missing.message).toBe("@acme/without (without) carries no SBOM: npm keeps no file beside a package, so its SBOM goes in it, and none of sbom.spdx.json, sbom.cdx.json, sbom.cyclonedx.json is at its root");
@@ -471,14 +474,15 @@ describe("SBOMs and provenance (#4049)", () => {
471
474
  }
472
475
  });
473
476
 
474
- test("forge: with no declared SBOM and no lockfile the target is refused; a declared SBOM goes on the release by its own name", async () => {
477
+ test("forge: with no declared SBOM and no lockfile the target is refused under supply-chain and attaches none otherwise; a declared SBOM goes on the release by its own name", async () => {
475
478
  const dir = temp();
476
479
  writeFileSync(join(dir, "api.tar.gz"), "bytes");
477
480
  const api = "https://api.github.test";
478
481
  const forge = startFakeForge("github", api, "acme/api");
479
482
  const make = async (over: Record<string, unknown> = {}) => (await createPublishTargets([decl({ type: "forge", forge: "github", repo: "acme/api", api, assets: ["api.tar.gz"], ...over })], { fetch: forge.fetch }))[0]!;
480
483
  const ctx = context(dir, { env: { GITHUB_TOKEN: "ghs_x" }, cut: CUT });
481
- const err = await refused((await make()).plan(ctx));
484
+ expect((await (await make()).plan(ctx)).map((i) => i.attachment ?? null)).not.toContain("sbom");
485
+ const err = await refused((await make()).plan({ ...ctx, requireSbom: true }));
482
486
  expect(err.code).toBe("publish-sbom-missing");
483
487
  expect(err.fix).toContain("sbom: false");
484
488
 
@@ -492,3 +496,29 @@ describe("SBOMs and provenance (#4049)", () => {
492
496
  expect(statement.predicate.cut.signature).toBe(CUT.signature);
493
497
  });
494
498
  });
499
+
500
+ describe("the Cargo.lock SBOM source (#4168 decision 12)", () => {
501
+ const CARGO_LOCK = '# This file is automatically @generated by Cargo.\nversion = 4\n\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = [\n "serde",\n]\n\n[[package]]\nname = "serde"\nversion = "1.0.210"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "abc"\n';
502
+ const NPM_LOCK = JSON.stringify({ name: "web", lockfileVersion: 3, packages: { "": { name: "web" }, "node_modules/left-pad": { version: "1.3.0" } } });
503
+
504
+ test("a member with a Cargo.toml writes its SBOM from Cargo.lock, ahead of a package-lock.json beside it", () => {
505
+ const dir = temp();
506
+ writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"app\"\n");
507
+ writeFileSync(join(dir, "Cargo.lock"), CARGO_LOCK);
508
+ writeFileSync(join(dir, "package-lock.json"), NPM_LOCK);
509
+ const sbom = memberSbom(context(dir), undefined, "app", "forge release assets")!;
510
+ const text = Buffer.from(sbom.bytes).toString("utf-8");
511
+ expect(text).toContain("pkg:cargo/serde@1.0.210");
512
+ expect(text).toContain("chant-lockfile-sbom/Cargo.lock");
513
+ expect(text).not.toContain("left-pad");
514
+ });
515
+
516
+ test("any other member reads package-lock.json first", () => {
517
+ const dir = temp();
518
+ writeFileSync(join(dir, "Cargo.lock"), CARGO_LOCK);
519
+ writeFileSync(join(dir, "package-lock.json"), NPM_LOCK);
520
+ const text = Buffer.from(memberSbom(context(dir), undefined, "web", "forge release assets")!.bytes).toString("utf-8");
521
+ expect(text).toContain("left-pad");
522
+ expect(text).not.toContain("pkg:cargo/");
523
+ });
524
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `release cut --at` (#4168 4.2): the commit is checked out in a temporary
3
+ * worktree with the checkout's node_modules linked in, and removed after.
4
+ */
5
+
6
+ import { describe, test, expect } from "vitest";
7
+ import { withTestDir } from "@intentius/chant-test-utils";
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { checkoutCommit, CutAtError, resolveCutCommit } from "./release-at";
12
+
13
+ function git(dir: string, ...args: string[]): string {
14
+ const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
15
+ if (r.status !== 0) throw new Error(r.stderr);
16
+ return r.stdout.trim();
17
+ }
18
+
19
+ describe("checkoutCommit", () => {
20
+ test("checks the commit out detached, links node_modules at the top and the member, and removes it", async () => {
21
+ await withTestDir(async (dir) => {
22
+ for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"]]) git(dir, ...args);
23
+ mkdirSync(join(dir, "members", "api"), { recursive: true });
24
+ writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
25
+ writeFileSync(join(dir, "members", "api", "f.txt"), "old");
26
+ git(dir, "add", ".");
27
+ git(dir, "commit", "-q", "-m", "one");
28
+ const one = git(dir, "rev-parse", "HEAD");
29
+ writeFileSync(join(dir, "members", "api", "f.txt"), "new");
30
+ git(dir, "commit", "-q", "-am", "two");
31
+ mkdirSync(join(dir, "node_modules"));
32
+ mkdirSync(join(dir, "members", "api", "node_modules"));
33
+
34
+ expect(resolveCutCommit(dir, "HEAD~1")).toBe(one);
35
+ expect(() => resolveCutCommit(dir, "nope")).toThrow(CutAtError);
36
+
37
+ const co = checkoutCommit(dir, join(dir, "members", "api"), one);
38
+ expect(readFileSync(join(co.memberDir, "f.txt"), "utf-8")).toBe("old");
39
+ expect(lstatSync(join(co.dir, "node_modules")).isSymbolicLink()).toBe(true);
40
+ expect(lstatSync(join(co.memberDir, "node_modules")).isSymbolicLink()).toBe(true);
41
+ expect(git(co.dir, "rev-parse", "HEAD")).toBe(one);
42
+ co.remove();
43
+ co.remove();
44
+ expect(existsSync(co.dir)).toBe(false);
45
+ expect(git(dir, "worktree", "list").split("\n")).toHaveLength(1);
46
+ });
47
+ });
48
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `release cut --at <sha>` (#4168 4.2): cut from a chosen commit of trunk,
3
+ * not the checkout's `HEAD`. The commit is checked out in a temporary
4
+ * detached worktree, as `chant rollback` works in one (./rollback.ts), so the
5
+ * caller's branch and working tree are never touched and need not be clean.
6
+ * The cut then runs in the member's directory inside that worktree, with the
7
+ * same refusals a cut at `HEAD` has: a commit that is not green, a revoked
8
+ * one, and build steps that differ by environment.
9
+ *
10
+ * Refs are shared between worktrees, so the release and the cut the cut
11
+ * writes to `chant/lifecycle`, the tags it reads and the tag it makes are the
12
+ * repository's own. The checkout's `node_modules` are linked in, so the
13
+ * compile resolves the packages the checkout already installed; a commit
14
+ * whose lockfile differs from the checkout's would need its own install
15
+ * (inferred, as the cut can't tell).
16
+ */
17
+
18
+ import { execFileSync } from "node:child_process";
19
+ import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join, relative, resolve, sep } from "node:path";
22
+
23
+ export type CutAtCode = "release-at-unknown";
24
+
25
+ export class CutAtError extends Error {
26
+ constructor(
27
+ readonly code: CutAtCode,
28
+ message: string,
29
+ readonly fix?: string,
30
+ ) {
31
+ super(message);
32
+ this.name = "CutAtError";
33
+ }
34
+ }
35
+
36
+ function git(cwd: string, args: string[]): string {
37
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
38
+ }
39
+
40
+ /** The full sha `ref` names in the repository at `top`, or a {@link CutAtError}. */
41
+ export function resolveCutCommit(top: string, ref: string): string {
42
+ try {
43
+ return git(top, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
44
+ } catch {
45
+ throw new CutAtError(
46
+ "release-at-unknown",
47
+ `--at ${ref} names no commit this clone has`,
48
+ "Fetch it first (git fetch origin --tags), or name a sha `chant release candidates` lists.",
49
+ );
50
+ }
51
+ }
52
+
53
+ /** A checkout of one commit for the cut, and the member's directory inside it. */
54
+ export interface CommitCheckout {
55
+ /** The worktree's top. */
56
+ dir: string;
57
+ /** The member's directory inside it, the cut's `cwd`. */
58
+ memberDir: string;
59
+ /** Remove the worktree. Safe to call twice. */
60
+ remove(): void;
61
+ }
62
+
63
+ /**
64
+ * Check `commit` out in a temporary detached worktree of the repository at
65
+ * `top`, with the checkout's `node_modules` linked at the top and at each
66
+ * directory down to `cwd`, the member's directory in the caller's checkout.
67
+ */
68
+ export function checkoutCommit(top: string, cwd: string, commit: string): CommitCheckout {
69
+ const realTop = realpathSync(top);
70
+ const rel = relative(realTop, realpathSync(cwd));
71
+ const dir = mkdtempSync(join(tmpdir(), `chant-cut-${commit.slice(0, 12)}-`));
72
+ git(realTop, ["worktree", "add", "--detach", "--quiet", dir, commit]);
73
+ let removed = false;
74
+ const remove = (): void => {
75
+ if (removed) return;
76
+ removed = true;
77
+ try {
78
+ git(realTop, ["worktree", "remove", "--force", dir]);
79
+ } catch {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ try {
82
+ git(realTop, ["worktree", "prune"]);
83
+ } catch {
84
+ // Nothing left to prune.
85
+ }
86
+ }
87
+ };
88
+ try {
89
+ const levels = rel === "" ? [""] : ["", ...rel.split(sep).map((_, i, parts) => parts.slice(0, i + 1).join(sep))];
90
+ for (const level of levels) {
91
+ const from = join(realTop, level, "node_modules");
92
+ const to = join(dir, level, "node_modules");
93
+ if (existsSync(from) && !existsSync(to) && existsSync(dirname(to))) symlinkSync(from, to, "dir");
94
+ }
95
+ } catch (err) {
96
+ remove();
97
+ throw err;
98
+ }
99
+ return { dir, memberDir: resolve(dir, rel), remove };
100
+ }
@@ -20,6 +20,7 @@ import {
20
20
  readReleaseTemplate,
21
21
  ReleaseLabelError,
22
22
  resolveRelease,
23
+ templateBytesDigest,
23
24
  verifyReleaseDigest,
24
25
  type ReleaseArtifact,
25
26
  } from "./release-store";
@@ -114,6 +115,14 @@ describe("the release digest", () => {
114
115
  expect(computeReleaseDigest({ ...subject, member: "api" })).not.toBe(digest);
115
116
  });
116
117
 
118
+ test("a target without trees or encodings hashes as before #4155; trees and an encoding are in the digest", () => {
119
+ const digest = computeReleaseDigest(subject);
120
+ const t = subject.targets[0];
121
+ expect(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [] }] })).not.toBe(digest);
122
+ expect(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [{ component: "api", path: "chart" }] }] })).not.toBe(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [] }] }));
123
+ expect(computeReleaseDigest({ ...subject, targets: [{ ...t, templates: [{ ...t.templates[0], encoding: "base64" as const }] }] })).not.toBe(digest);
124
+ });
125
+
117
126
  test("verifyReleaseDigest catches a release whose content no longer matches its digest", () => {
118
127
  const release = makeRelease(subject);
119
128
  expect(verifyReleaseDigest(release)).toBe(true);
@@ -122,6 +131,38 @@ describe("the release digest", () => {
122
131
  });
123
132
 
124
133
  describe("cutRelease", () => {
134
+ test("records a directory's files and a file that is not UTF-8, which read back by digest (#4155)", async () => {
135
+ await withTestDir(async (dir) => {
136
+ const sha = initRepo(dir);
137
+ green(dir, sha);
138
+ const tgz = Buffer.from([0x1f, 0x8b, 0xff]).toString("base64");
139
+ const base = fixtureCompiler();
140
+ const compiler: TargetCompiler = {
141
+ ...base,
142
+ async compile(env) {
143
+ const t = await base.compile(env);
144
+ return {
145
+ ...t,
146
+ templates: [
147
+ ...t.templates,
148
+ { component: "api", path: "chart/Chart.yaml", content: "name: api\n" },
149
+ { component: "api", path: "chart/charts/dep.tgz", content: tgz, encoding: "base64" },
150
+ ],
151
+ trees: [{ component: "api", path: "chart" }],
152
+ };
153
+ },
154
+ };
155
+ const { release } = await cut(dir, compiler, null);
156
+ const prod = release.targets.find((t) => t.env === "prod")!;
157
+ expect(prod.trees).toEqual([{ component: "api", path: "chart" }]);
158
+ const bin = prod.templates.find((t) => t.path === "chart/charts/dep.tgz")!;
159
+ expect(bin).toMatchObject({ encoding: "base64", digest: templateBytesDigest(tgz, "base64") });
160
+ expect(await readReleaseTemplate(bin.digest, { cwd: dir }, "base64")).toBe(tgz);
161
+ expect(await readReleaseTemplate(bin.digest, { cwd: dir })).toBeNull();
162
+ expect((await readRelease(release.releaseDigest, { cwd: dir }))?.targets.find((t) => t.env === "prod")?.trees).toEqual([{ component: "api", path: "chart" }]);
163
+ });
164
+ });
165
+
125
166
  test("cuts a release from a green commit and stores it apart from the deploy records", async () => {
126
167
  await withTestDir(async (dir) => {
127
168
  const sha = initRepo(dir);
@@ -37,7 +37,6 @@ import { execFileSync } from "node:child_process";
37
37
  import { realpathSync } from "node:fs";
38
38
  import { relative, resolve } from "node:path";
39
39
  import { canonicalJson } from "../effect-receipt";
40
- import { contentDigest } from "../content-digest";
41
40
  import { getHeadCommit } from "./git";
42
41
  import { resolveMemberLedger } from "./member-ledger";
43
42
  import { resolveCiRun } from "./release-ledger";
@@ -55,6 +54,7 @@ import {
55
54
  type ReleaseArtifact,
56
55
  type ReleaseCut,
57
56
  type ReleaseTarget,
57
+ templateBytesDigest,
58
58
  type TemplateContent,
59
59
  } from "./release-store";
60
60
  import type { CompiledTarget, TargetCompiler } from "../components/release-compile";
@@ -322,18 +322,29 @@ async function prepare(req: CutRequest): Promise<Prepared> {
322
322
  };
323
323
  }
324
324
 
325
- /** The targets and template bytes of a compile, each template by its content digest. */
325
+ /**
326
+ * The targets and template bytes of a compile, each template by its content
327
+ * digest. A compile of components names its directories in `trees` (#4155),
328
+ * an empty list when it has none, which marks the target as one that records
329
+ * every deploy input. A project with no components has no deploy step to
330
+ * name one, and its targets have no `trees`.
331
+ */
326
332
  function targetsOf(compiled: readonly CompiledTarget[]): { targets: ReleaseTarget[]; templates: TemplateContent[] } {
327
333
  const templates: TemplateContent[] = [];
328
- const targets: ReleaseTarget[] = compiled.map((t) => ({
329
- env: t.env,
330
- params: t.params,
331
- templates: t.templates.map((tpl) => {
332
- const digest = contentDigest(tpl.content);
333
- templates.push({ digest, content: tpl.content });
334
- return { component: tpl.component, path: tpl.path, digest };
335
- }),
336
- }));
334
+ const targets: ReleaseTarget[] = compiled.map((t) => {
335
+ const seen = new Set<string>();
336
+ const recorded: ReleaseTarget["templates"] = [];
337
+ for (const tpl of t.templates) {
338
+ // A file named twice, as a values file inside a recorded chart, is recorded once.
339
+ const key = `${tpl.component ?? ""}\u0000${tpl.path}`;
340
+ if (seen.has(key)) continue;
341
+ seen.add(key);
342
+ const digest = templateBytesDigest(tpl.content, tpl.encoding);
343
+ templates.push({ digest, content: tpl.content, ...(tpl.encoding ? { encoding: tpl.encoding } : {}) });
344
+ recorded.push({ component: tpl.component, path: tpl.path, digest, ...(tpl.encoding ? { encoding: tpl.encoding } : {}) });
345
+ }
346
+ return { env: t.env, params: t.params, templates: recorded, ...(t.trees ? { trees: t.trees } : {}) };
347
+ });
337
348
  return { targets, templates };
338
349
  }
339
350