@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
@@ -315,7 +315,7 @@ async function deployPlan(
315
315
  if (under) {
316
316
  const { pinReleaseTemplates } = await import("../../components/release-promote");
317
317
  const { readReleaseTemplate } = await import("../../lifecycle/release-store");
318
- const pinned = await pinReleaseTemplates(prepared, under.release, under.target, (digest) => readReleaseTemplate(digest));
318
+ const pinned = await pinReleaseTemplates(prepared, under.release, under.target, (digest, encoding) => readReleaseTemplate(digest, undefined, encoding));
319
319
  if ("errors" in pinned) {
320
320
  for (const message of pinned.errors) console.error(formatError({ message }));
321
321
  console.error(formatError({ message: `nothing was deployed` }));
@@ -595,6 +595,21 @@ async function resolveUnderRelease(ref: string, env: string, verb: "promote" | "
595
595
  console.error(formatError(revokedRefusal({ releaseDigest: found.release.releaseDigest, label: found.label }, revocation, await fix(found.release))));
596
596
  return 1;
597
597
  }
598
+ // #4168: under verified-releases a release deploys only once `chant release verify` passes for it.
599
+ const { securityGroupsAt } = await import("../../workspace/security");
600
+ if (securityGroupsAt(process.cwd()).has("verified-releases")) {
601
+ const { verifyRelease, verificationCode } = await import("../../lifecycle/release-verify");
602
+ const name = found.label ?? found.release.releaseDigest;
603
+ const v = await verifyRelease({ cwd: process.cwd(), ref: found.release.releaseDigest });
604
+ const code = "error" in v ? v.error.code : verificationCode(v);
605
+ if (code) {
606
+ console.error(formatError({
607
+ message: `${code}: release ${name} does not verify (${"error" in v ? v.error.message : v.reason}), and the workspace opts into verified-releases, so ${verb} deploys only a verified release`,
608
+ hint: `Check it with: chant release verify ${name}`,
609
+ }));
610
+ return 1;
611
+ }
612
+ }
598
613
  return { release: found.release, label: found.label, target: target.target };
599
614
  }
600
615
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `chant release cut --at <sha>` and `chant release candidates` end to end
3
+ * (#4168 4.2): a cut of a chosen green commit of trunk from a temporary
4
+ * worktree, while the checkout sits elsewhere with uncommitted changes, with
5
+ * the label reaching the build as CHANT_RELEASE_LABEL (decision 5). A temp
6
+ * repo with a temp bare remote; no cloud, no push of tags.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { spawnSync } from "node:child_process";
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { contract, REPO } from "../../workspace/__fixtures__/contract-repo";
16
+ import candidatesSchema from "../../workspace/release-candidates.schema.json";
17
+
18
+ const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
19
+ const CI_VARS = ["CI", "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_SHA", "GITHUB_ACTOR", "GITLAB_CI", "CI_PIPELINE_ID", "CHANT_ENV", "CHANT_RELEASE_LABEL"];
20
+ const { expectValid } = contract(candidatesSchema);
21
+
22
+ function chant(cwd: string, args: string[], extra: Record<string, string> = {}): { code: number; stdout: string; stderr: string } {
23
+ const env: NodeJS.ProcessEnv = { ...process.env, USER: "tester" };
24
+ for (const k of CI_VARS) delete env[k];
25
+ Object.assign(env, extra);
26
+ const r = spawnSync(CHANT[0], [...CHANT.slice(1), ...args], { cwd, encoding: "utf-8", env, timeout: 120_000 });
27
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
28
+ }
29
+
30
+ function git(cwd: string, ...args: string[]): string {
31
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
32
+ if (r.status !== 0) throw new Error(r.stderr);
33
+ return r.stdout.trim();
34
+ }
35
+
36
+ // The template records the environment, the label, and which commit's file the build read.
37
+ const BUILD = `mkdir -p dist && printf '{"env":"%s","label":"%s","marker":"%s"}' "$CHANT_ENV" "$CHANT_RELEASE_LABEL" "$(cat marker.txt)" > dist/api.template.json`;
38
+
39
+ describe("chant release cut --at and release candidates", () => {
40
+ test(
41
+ "cuts a chosen green commit from a temporary worktree with the label in the build, and lists candidates",
42
+ () => {
43
+ const root = mkdtempSync(join(tmpdir(), "chant-release-cut-at-"));
44
+ try {
45
+ const dir = join(root, "work");
46
+ const remote = join(root, "origin.git");
47
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
48
+ git(root, "init", "-q", "-b", "main", dir);
49
+ git(dir, "config", "user.email", "t@chant.dev");
50
+ git(dir, "config", "user.name", "T");
51
+ writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["dev", "prod"] };\n`);
52
+ writeFileSync(
53
+ join(dir, "api.component.ts"),
54
+ `export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
55
+ );
56
+ writeFileSync(join(dir, ".gitignore"), "dist/\n");
57
+ writeFileSync(join(dir, "marker.txt"), "one");
58
+ git(dir, "add", ".");
59
+ git(dir, "commit", "-q", "-m", "one (#11)");
60
+ const one = git(dir, "rev-parse", "HEAD");
61
+ writeFileSync(join(dir, "marker.txt"), "two");
62
+ git(dir, "commit", "-q", "-am", "two (#12)");
63
+ const two = git(dir, "rev-parse", "HEAD");
64
+ git(dir, "remote", "add", "origin", remote);
65
+ git(dir, "push", "-q", "origin", "main");
66
+ git(dir, "fetch", "-q", "origin");
67
+ git(dir, "tag", `ci/green/${one}`);
68
+ // The checkout is at two, not green, with an uncommitted change.
69
+ writeFileSync(join(dir, "marker.txt"), "dirty");
70
+
71
+ const unknown = chant(dir, ["release", "cut", "v1", "--at", "nope", "--build", BUILD]);
72
+ expect(unknown.code).toBe(1);
73
+ expect(unknown.stderr).toContain("[release-at-unknown]");
74
+ const notGreen = chant(dir, ["release", "cut", "v1", "--at", two, "--build", BUILD]);
75
+ expect(notGreen.code).toBe(1);
76
+ expect(notGreen.stderr).toContain("[release-commit-not-green]");
77
+
78
+ const candidates = chant(dir, ["release", "candidates", "--json"]);
79
+ expect(candidates.code, candidates.stderr).toBe(0);
80
+ const doc = JSON.parse(candidates.stdout);
81
+ expectValid(doc);
82
+ expect(doc.candidates.map((c: { commit: string }) => c.commit)).toEqual([one]);
83
+ expect(doc.candidates[0]).toMatchObject({ newFor: ["dev", "prod"], cut: [] });
84
+ expect(doc.candidates[0].changes[0]).toMatchObject({ commit: one, pr: 11 });
85
+ expect(doc.pending.map((c: { commit: string }) => c.commit)).toEqual([two]);
86
+
87
+ const dry = chant(dir, ["release", "cut", "v1", "--at", one, "--build", BUILD, "--json"]);
88
+ expect(dry.code, dry.stderr).toBe(0);
89
+ expect(JSON.parse(dry.stdout)).toMatchObject({ dryRun: true, commit: one, at: { ref: one, commit: one } });
90
+
91
+ const cut = chant(dir, ["release", "cut", "v1", "--at", one, "--build", BUILD, "--yes", "--json"]);
92
+ expect(cut.code, cut.stderr).toBe(0);
93
+ const result = JSON.parse(cut.stdout);
94
+ expect(result.release.commit).toBe(one);
95
+ expect(result.tag).toEqual({ name: "release/v1", created: true });
96
+ expect(git(dir, "rev-parse", "release/v1^{commit}")).toBe(one);
97
+
98
+ // The recorded template is commit one's, built with the label, and the checkout is untouched.
99
+ const out = join(root, "export");
100
+ const exported = chant(dir, ["release", "export", "v1", "--env", "prod", "-o", out]);
101
+ expect(exported.code, exported.stderr).toBe(0);
102
+ expect(JSON.parse(readFileSync(join(out, "dist", "api.template.json"), "utf-8"))).toEqual({ env: "prod", label: "v1", marker: "one" });
103
+ expect(readFileSync(join(dir, "marker.txt"), "utf-8")).toBe("dirty");
104
+ expect(git(dir, "rev-parse", "HEAD")).toBe(two);
105
+ expect(git(dir, "worktree", "list").split("\n")).toHaveLength(1);
106
+
107
+ const after = JSON.parse(chant(dir, ["release", "candidates", "--json"]).stdout);
108
+ expect(after.candidates[0].cut).toEqual(["v1"]);
109
+ } finally {
110
+ rmSync(root, { recursive: true, force: true });
111
+ }
112
+ },
113
+ 240_000,
114
+ );
115
+ });
@@ -199,10 +199,10 @@ describe("chant release publish with targets", () => {
199
199
  }
200
200
  expect(pub.stderr).toContain("npm notice token [redacted]");
201
201
 
202
- // From a workstation the publish warns that it carries no provenance, and the plan offers none.
202
+ // From a workstation the plan offers no provenance, and with no supply-chain group says nothing about it (#4168).
203
203
  const asJson = await chant(dir, ["release", "publish", "v1", "--json"], { ...path, ...secrets });
204
204
  expect(asJson.code, asJson.stderr).toBe(0);
205
- expect(asJson.stderr).toContain("this publish runs without OIDC");
205
+ expect(asJson.stderr).not.toContain("this publish runs without OIDC");
206
206
  const doc = JSON.parse(asJson.stdout);
207
207
  expect(doc).toMatchObject({ dryRun: true, label: "v1", provenance: { oidc: false } });
208
208
  expect(doc.targets.map((t: { kind: string }) => t.kind)).toEqual(["npm", "oci"]);
@@ -3,8 +3,10 @@
3
3
  * (#4049), in a temp repo with a temp bare remote and throwaway ssh keys. A
4
4
  * release cut signed with a key `.chant/allowed_signers` lists verifies, and a
5
5
  * publish takes it; an unsigned one and one signed by a key the file does not
6
- * list fail `release verify`, and a publish refuses them unless
7
- * `--allow-unsigned`. Nothing is published: the member declares no target.
6
+ * list fail `release verify`. With no release gate or verified-releases
7
+ * group a publish takes them and records their status (#4168);
8
+ * release-publish.test.ts covers the refusals under the group. Nothing is
9
+ * published: the member declares no target.
8
10
  */
9
11
 
10
12
  import { describe, expect, test } from "vitest";
@@ -42,7 +44,7 @@ const BUILD = `mkdir -p dist && printf '{"env":"%s"}' "$CHANT_ENV" > dist/api.te
42
44
 
43
45
  describe("chant release verify", () => {
44
46
  test.skipIf(!hasSshKeygen)(
45
- "passes for a signed release, fails for an unsigned one and for one signed by an unlisted key; a publish refuses both unless --allow-unsigned",
47
+ "passes for a signed release, fails for an unsigned one and for one signed by an unlisted key; a publish with no security group takes both and records their status",
46
48
  () => {
47
49
  const root = mkdtempSync(join(tmpdir(), "chant-release-verify-"));
48
50
  try {
@@ -89,10 +91,10 @@ describe("chant release verify", () => {
89
91
  expect(unsigned.code).toBe(1);
90
92
  expect(unsigned.stderr).toMatch(/v2.* does not verify: no cut of the release carries a signature \[release-unsigned\]/);
91
93
  expect(JSON.parse(chant(dir, ["release", "verify", "v2", "--json"]).stdout)).toMatchObject({ status: "unsigned", error: { code: "release-unsigned" } });
92
- const pubUnsigned = chant(dir, ["release", "publish", "v2"]);
93
- expect(pubUnsigned.code).toBe(1);
94
- expect(pubUnsigned.stderr).toMatch(/\[release-unsigned\]/);
95
- expect(chant(dir, ["release", "publish", "v2", "--allow-unsigned"]).code).toBe(0);
94
+ // #4168: with no release gate or verified-releases group, a publish takes it and records it unsigned.
95
+ const pubUnsigned = chant(dir, ["release", "publish", "v2", "--json"]);
96
+ expect(pubUnsigned.code, pubUnsigned.stderr).toBe(0);
97
+ expect(JSON.parse(pubUnsigned.stdout)).toMatchObject({ signature: { status: "unsigned" }, warnings: [] });
96
98
 
97
99
  // v3: signed by mallory, whom the signers file does not list.
98
100
  next("v3");
@@ -101,9 +103,9 @@ describe("chant release verify", () => {
101
103
  const unlisted = chant(dir, ["release", "verify", "v3"]);
102
104
  expect(unlisted.code).toBe(1);
103
105
  expect(unlisted.stderr).toMatch(/mallory has no key in \.chant\/allowed_signers at [0-9a-f]{12}.*\[release-signature-unverified\]/);
104
- const pubUnlisted = chant(dir, ["release", "publish", "v3"]);
105
- expect(pubUnlisted.code).toBe(1);
106
- expect(pubUnlisted.stderr).toMatch(/\[release-signature-unverified\]/);
106
+ const pubUnlisted = chant(dir, ["release", "publish", "v3", "--json"]);
107
+ expect(pubUnlisted.code, pubUnlisted.stderr).toBe(0);
108
+ expect(JSON.parse(pubUnlisted.stdout)).toMatchObject({ signature: { status: "unverified" }, warnings: [] });
107
109
 
108
110
  const missing = chant(dir, ["release", "verify", "v9"]);
109
111
  expect(missing.code).toBe(1);
@@ -128,8 +128,8 @@ describe("chant release cut, publish and list", () => {
128
128
  expect(result.release.releaseDigest).toBe(dryJson.releaseDigest);
129
129
  expect(result.release.commit).toBe(sha);
130
130
  expect(result.release.targets).toEqual([
131
- { env: "dev", params: [], templates: [{ component: "api", path: "dist/api.template.json", digest: contentDigest(`{"env":"dev"}`) }] },
132
- { env: "prod", params: [], templates: [{ component: "api", path: "dist/api.template.json", digest: contentDigest(`{"env":"prod"}`) }] },
131
+ { env: "dev", params: [], templates: [{ component: "api", path: "dist/api.template.json", digest: contentDigest(`{"env":"dev"}`) }], trees: [] },
132
+ { env: "prod", params: [], templates: [{ component: "api", path: "dist/api.template.json", digest: contentDigest(`{"env":"prod"}`) }], trees: [] },
133
133
  ]);
134
134
  expect(result.cut).toMatchObject({ actor: "t@chant.dev", runId: expect.stringMatching(/^local-\d+$/), runOrigin: { forge: "local" }, signature: null });
135
135
  expect(result.next).toEqual(["git push origin release/v1", "chant release publish v1", "chant components promote --release v1 --to dev"]);
@@ -151,41 +151,32 @@ describe("chant release cut, publish and list", () => {
151
151
  expect(doc.releases[0]).toMatchObject({ releaseDigest: result.release.releaseDigest, label: "v1", environments: [] });
152
152
  expect(doc.releases[0].cuts[0]).toMatchObject({ runId: result.cut.runId, runOrigin: { forge: "local" } });
153
153
 
154
- // #4049: the cut is not signed, so a publish refuses it unless --allow-unsigned.
155
- const unsignedPub = chant(dir, ["release", "publish", "v1"]);
156
- expect(unsignedPub.code).toBe(1);
157
- expect(unsignedPub.stderr).toMatch(/v1 has no signed cut.*\[release-unsigned\]/);
158
- expect(unsignedPub.stderr).toContain("pass --allow-unsigned");
159
-
154
+ // #4168: the cut is not signed, and with no release gate or verified-releases group a publish takes it quietly.
160
155
  // A publish is a dry run without --yes, as a cut is.
161
- const dryPub = chant(dir, ["release", "publish", "v1", "--allow-unsigned"]);
156
+ const dryPub = chant(dir, ["release", "publish", "v1"]);
162
157
  expect(dryPub.code, dryPub.stderr).toBe(0);
163
- expect(dryPub.stderr).toContain("v1 has no signed cut; publishing it anyway under --allow-unsigned");
158
+ expect(dryPub.stderr).not.toContain("release-unsigned");
159
+ expect(dryPub.stderr).not.toContain("no signed cut");
164
160
  expect(dryPub.stdout).toContain("What would be published where:\n nothing: this member declares no publish target (release.publish in chant.config.ts)");
165
- expect(dryPub.stderr).toContain("Dry run: nothing was published. Run again with --yes to publish: chant release publish v1 --allow-unsigned --yes");
161
+ expect(dryPub.stderr).toContain("Dry run: nothing was published. Run again with --yes to publish: chant release publish v1 --yes");
166
162
 
167
- // Publish from the workstation: the warning comes before anything else is said about publishing.
168
- const pub = chant(dir, ["release", "publish", "v1", "--allow-unsigned", "--yes"]);
163
+ // Publish from the workstation: with no supply-chain group there is no OIDC warning either.
164
+ const pub = chant(dir, ["release", "publish", "v1", "--yes"]);
169
165
  expect(pub.code, pub.stderr).toBe(0);
170
- const warning = pub.stderr.indexOf("this publish runs without OIDC, so what it publishes carries no provenance");
171
- const nothing = pub.stderr.indexOf("Nothing was published: this member declares no publish target (release.publish in chant.config.ts).");
172
- expect(warning).toBeGreaterThan(-1);
173
- expect(nothing).toBeGreaterThan(warning);
166
+ expect(pub.stderr).not.toContain("without OIDC");
167
+ expect(pub.stderr).toContain("Nothing was published: this member declares no publish target (release.publish in chant.config.ts).");
174
168
  expect(pub.stderr).toContain("chant components promote --release v1 --to dev");
175
169
 
176
- // In GitHub Actions with id-token: write there is no warning.
170
+ // The --json document records what is missing: no signature and no OIDC provenance.
171
+ const asJson = chant(dir, ["release", "publish", "v1", "--json"]);
172
+ expect(asJson.code, asJson.stderr).toBe(0);
173
+ expect(JSON.parse(asJson.stdout)).toMatchObject({ dryRun: true, label: "v1", signature: { status: "unsigned" }, provenance: { oidc: false }, warnings: [], targets: [], published: [] });
174
+
175
+ // In GitHub Actions with id-token: write the provenance is OIDC.
177
176
  const oidc = { GITHUB_ACTIONS: "true", ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.example", ACTIONS_ID_TOKEN_REQUEST_TOKEN: "t" };
178
- const fromCi = chant(dir, ["release", "publish", "v1", "--allow-unsigned", "--json"], oidc);
177
+ const fromCi = chant(dir, ["release", "publish", "v1", "--json"], oidc);
179
178
  expect(fromCi.code, fromCi.stderr).toBe(0);
180
- expect(fromCi.stderr).not.toContain("without OIDC");
181
- expect(JSON.parse(fromCi.stdout)).toMatchObject({
182
- dryRun: true,
183
- label: "v1",
184
- provenance: { oidc: true, ci: "github" },
185
- warnings: ["v1 has no signed cut; publishing it anyway under --allow-unsigned, so chant release verify v1 will fail for it"],
186
- targets: [],
187
- published: [],
188
- });
179
+ expect(JSON.parse(fromCi.stdout)).toMatchObject({ dryRun: true, label: "v1", provenance: { oidc: true, ci: "github" }, warnings: [], targets: [], published: [] });
189
180
 
190
181
  const unknown = chant(dir, ["release", "publish", "v9"]);
191
182
  expect(unknown.code).toBe(1);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `chant release` (#4026, ws-104): the release is what graduates.
3
3
  *
4
- * release cut [<label>] [--yes] [--build <command>] [--param k=v]... [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json]
4
+ * release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param k=v]... [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json]
5
5
  * a dry run of a cut of this member from the checkout's HEAD, which must be
6
6
  * clean, on trunk and pushed, and green (#4027); `--yes` cuts, records,
7
7
  * and tags the commit `release/<label>` locally;
@@ -17,6 +17,9 @@
17
17
  * release verify <label|digest> [--json]
18
18
  * check the release's signature offline against .chant/allowed_signers
19
19
  * at the release's commit (#4049)
20
+ * release candidates [--env <env>] [--json]
21
+ * the green commits of trunk a release can be cut from, each with what it
22
+ * would ship, and what each environment runs (#4168 4.2)
20
23
  * release list [--json]
21
24
  * every release of this member with its targets, its cuts and where it runs;
22
25
  * `--json` prints the read contract's `releases` document
@@ -39,13 +42,19 @@ import { parseParamFlags } from "../build-params-cli";
39
42
  import type { CommandContext } from "../registry";
40
43
 
41
44
  const USAGE =
42
- "chant release cut [<label>] [--yes] [--build <command>] [--param <k=v>] [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json] | release publish <label|digest> [--yes] [--allow-unsigned] [--require-provenance] [--json] | release verify <label|digest> [--json] | release list [--json] | release export <label|digest> --env <env> [-o <dir>] [--json]";
45
+ "chant release cut [<label>] [--at <sha>] [--yes] [--build <command>] [--param <k=v>] [--params-file <file>] [--src <dir>] [-o <file>] [--lexicon <name>] [--actor <name>] [--sign [<key file>]] [--json] | release publish <label|digest> [--yes] [--allow-unsigned] [--require-provenance] [--json] | release verify <label|digest> [--json] | release candidates [--env <env>] [--json] | release list [--json] | release export <label|digest> --env <env> [-o <dir>] [--json]";
43
46
 
44
47
  /** The build inputs `release cut` and `release compile` share. */
45
- function compileInputs(ctx: CommandContext, params: Record<string, string> | undefined): import("../../components/release-compile").CompileInputs {
48
+ function compileInputs(
49
+ ctx: CommandContext,
50
+ params: Record<string, string> | undefined,
51
+ projectDir: string = resolve("."),
52
+ releaseLabel?: string | null,
53
+ ): import("../../components/release-compile").CompileInputs {
46
54
  const { args } = ctx;
47
55
  return {
48
- projectDir: resolve("."),
56
+ projectDir,
57
+ ...(releaseLabel ? { releaseLabel } : {}),
49
58
  ...(args.buildCommand ? { buildCommand: args.buildCommand } : {}),
50
59
  ...(params ? { params } : {}),
51
60
  ...(args.paramsFile ? { paramsFile: args.paramsFile } : {}),
@@ -102,6 +111,11 @@ function describeActorSource(source: import("../../lifecycle/release-workstation
102
111
  * would record, build and tag, and what publishes where. With `--yes` it
103
112
  * cuts, signs with `--sign`, records the cut with its actor and run id,
104
113
  * tags the commit locally and prints the next command.
114
+ *
115
+ * `--at <sha>` (#4168 4.2) cuts that commit instead of `HEAD`, from a
116
+ * temporary worktree (../../lifecycle/release-at.ts), so the checkout need
117
+ * not be clean or at the commit. The label reaches every compile and
118
+ * artifact build as `CHANT_RELEASE_LABEL` (decision 5).
105
119
  */
106
120
  export async function runReleaseCut(ctx: CommandContext): Promise<number> {
107
121
  const { args } = ctx;
@@ -124,6 +138,8 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
124
138
  console.error(formatError({ message: `${message} [${code}]`, ...(fix ? { hint: fix } : {}) }));
125
139
  return 1;
126
140
  };
141
+ const at = await import("../../lifecycle/release-at");
142
+ let checkout: import("../../lifecycle/release-at").CommitCheckout | null = null;
127
143
 
128
144
  try {
129
145
  // The workstation checks come first, in the order #4027 lists them; the
@@ -139,9 +155,16 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
139
155
  let trunk: import("../../lifecycle/release-workstation").Trunk | null = null;
140
156
  let signers: readonly import("../../workspace/trust/trust-files").Signer[] = [];
141
157
  let member: string | null = null;
158
+ if (args.at !== undefined && !top) {
159
+ return fail("not-a-git-repository", "--at names a commit, and this directory is not in a git repository");
160
+ }
142
161
  if (top) {
143
- commit = await getHeadCommit({ cwd });
144
- ws.checkCleanTree(top, commit);
162
+ if (args.at !== undefined) {
163
+ commit = at.resolveCutCommit(top, args.at);
164
+ } else {
165
+ commit = await getHeadCommit({ cwd });
166
+ ws.checkCleanTree(top, commit);
167
+ }
145
168
  const { scopeSource } = await import("../../workspace/write-scope");
146
169
  const { releaseRuleFrom } = await import("../../workspace/identity");
147
170
  const source = scopeSource(cwd);
@@ -179,8 +202,11 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
179
202
  const tag = label !== null && commit !== null && top ? { name: ws.releaseTagName(member, label), state: ws.checkReleaseTag(top, ws.releaseTagName(member, label), commit) } : null;
180
203
  const ci = resolveCiRun(process.env);
181
204
  const params = parseParamFlags(args.param);
182
- const compiler = spawnTargetCompiler(compileInputs(ctx, params));
183
- const request = { cwd, compiler, label, actor, ...(args.sign !== undefined ? { sign: args.sign } : {}) };
205
+ // --at: the cut reads, compiles and builds the commit's tree, not the checkout's.
206
+ if (args.at !== undefined && top && commit) checkout = at.checkoutCommit(top, cwd, commit);
207
+ const cutCwd = checkout?.memberDir ?? cwd;
208
+ const compiler = spawnTargetCompiler(compileInputs(ctx, params, cutCwd, label));
209
+ const request = { cwd: cutCwd, compiler, label, actor, ...(args.sign !== undefined ? { sign: args.sign } : {}) };
184
210
 
185
211
  if (dryRun) {
186
212
  const plan = await planRelease(request);
@@ -195,7 +221,7 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
195
221
  const ref = plan.label ?? plan.alreadyCut ?? plan.releaseDigest;
196
222
  const provenance = detectProvenance();
197
223
  // The targets release publish would run, as the config here declares them; their files are read at publish.
198
- const publishTargets = await releasePublishTargets(cwd).then(
224
+ const publishTargets = await releasePublishTargets(cutCwd).then(
199
225
  (ts) => ts.map((t) => `${t.name} to ${t.destination}`),
200
226
  (err: unknown) => (err instanceof Error ? err : new Error(String(err))),
201
227
  );
@@ -207,6 +233,7 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
207
233
  actorSource: who.source,
208
234
  run: ci ? { runId: ci.runId, runOrigin: ci.runOrigin } : { runId: null, runOrigin: { forge: "local" as const } },
209
235
  trunk,
236
+ at: args.at !== undefined ? { ref: args.at, commit } : null,
210
237
  sign: args.sign !== undefined ? { key: publicKey !== null ? publicKey.split(" ")[0] : null, readable: publicKey !== null } : null,
211
238
  tag: tag ? { name: tag.name, exists: tag.state === "present" } : null,
212
239
  publish: { targets: Array.isArray(publishTargets) ? publishTargets : [], provenance },
@@ -294,7 +321,7 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
294
321
  if (tagWarning) console.error(formatWarning({ message: tagWarning }));
295
322
  return 0;
296
323
  } catch (err) {
297
- if (err instanceof ws.WorkstationCheckError || err instanceof ReleaseCutError) {
324
+ if (err instanceof ws.WorkstationCheckError || err instanceof ReleaseCutError || err instanceof at.CutAtError) {
298
325
  return fail(err.code, err.message, err.fix);
299
326
  }
300
327
  if (err instanceof ReleaseLabelError) return fail(err.code, err.message);
@@ -303,6 +330,8 @@ export async function runReleaseCut(ctx: CommandContext): Promise<number> {
303
330
  return 1;
304
331
  }
305
332
  throw err;
333
+ } finally {
334
+ checkout?.remove();
306
335
  }
307
336
  }
308
337
 
@@ -480,6 +509,47 @@ export async function runReleaseVerify(ctx: CommandContext): Promise<number> {
480
509
  return 0;
481
510
  }
482
511
 
512
+ /**
513
+ * `release candidates [--env <env>] [--json]` (#4168 4.2): the green commits
514
+ * of trunk a release can be cut from, newest first, each with what it would
515
+ * ship since the previous one, and what each environment runs. `--json`
516
+ * prints the read contract's `release-candidates` document.
517
+ */
518
+ export async function runReleaseCandidates(ctx: CommandContext): Promise<number> {
519
+ const { releaseCandidatesDocument } = await import("../../workspace/release-candidates");
520
+ const doc = await releaseCandidatesDocument({ cwd: resolve("."), ...(ctx.args.env ? { env: ctx.args.env } : {}) });
521
+ if (ctx.args.json) {
522
+ console.log(JSON.stringify(doc, null, 2));
523
+ return "error" in doc ? 1 : 0;
524
+ }
525
+ if ("error" in doc) {
526
+ console.error(formatError({ message: `${doc.error.message} [${doc.error.code}]`, hint: USAGE }));
527
+ return 1;
528
+ }
529
+ for (const e of doc.environments) {
530
+ const runs = e.running.map((r) => `${r.label ?? r.releaseDigest.slice(0, 19)}${r.commit ? ` at ${r.commit.slice(0, 12)}` : ""}`).join(", ");
531
+ console.log(`${e.env}: ${runs || "runs no release yet"}`);
532
+ }
533
+ if (!doc.tip) {
534
+ console.error(`No candidates: this clone knows no ${doc.branch}. Fetch it: git fetch origin ${doc.branch} --tags`);
535
+ return 0;
536
+ }
537
+ if (doc.pending.length > 0) console.log(`${doc.pending.length} commit(s) on ${doc.branch} after the newest candidate are not green yet`);
538
+ if (doc.candidates.length === 0) console.log(`No green commit on ${doc.branch}${doc.since ? ` since ${doc.since.slice(0, 12)}` : ""} to cut from.`);
539
+ for (const c of doc.candidates) {
540
+ const tags = [...(c.newFor.length > 0 ? [`new for ${c.newFor.join(", ")}`] : []), ...(c.cut.length > 0 ? [`cut as ${c.cut.join(", ")}`] : [])];
541
+ console.log(`${c.commit.slice(0, 12)} ${c.subject}${tags.length > 0 ? ` (${tags.join("; ")})` : ""}`);
542
+ for (const ch of c.changes) {
543
+ const links = [...(ch.pr !== null ? [`#${ch.pr}`] : []), ...ch.work.map((w) => `work:${w}`)];
544
+ console.log(` ${ch.commit.slice(0, 12)} ${ch.subject}${links.length > 0 ? ` [${links.join(", ")}]` : ""}`);
545
+ }
546
+ for (const f of c.flags) console.log(` flag ${f.id} (${f.member} ${f.param}) changed`);
547
+ }
548
+ for (const sk of doc.skipped) console.log(`skipped ${sk.commit.slice(0, 12)} ${sk.subject} (revoked)`);
549
+ if (doc.candidates.length > 0) console.error(`Cut one: chant release cut <label> --at <sha>`);
550
+ return 0;
551
+ }
552
+
483
553
  export async function runReleaseList(ctx: CommandContext): Promise<number> {
484
554
  const { releasesDocument } = await import("../../workspace/releases");
485
555
  const doc = await releasesDocument({ cwd: resolve(".") });
@@ -540,7 +610,7 @@ export async function runReleaseExport(ctx: CommandContext): Promise<number> {
540
610
  console.error(formatError({ message: target.error }));
541
611
  return 1;
542
612
  }
543
- const read = await targetFiles(found.release, target.target, (digest) => readReleaseTemplate(digest));
613
+ const read = await targetFiles(found.release, target.target, (digest, encoding) => readReleaseTemplate(digest, undefined, encoding));
544
614
  if ("errors" in read) {
545
615
  for (const e of read.errors) console.error(formatError({ message: e }));
546
616
  return 1;