@intentius/chant 0.124.33 → 0.124.35

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 (218) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +284 -81
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +37 -2
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +24 -0
  16. package/dist/cli/registry.d.ts +17 -1
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote-rounds.d.ts +102 -0
  28. package/dist/components/promote-rounds.d.ts.map +1 -0
  29. package/dist/components/promote-rounds.js +174 -0
  30. package/dist/components/promote.d.ts +4 -1
  31. package/dist/components/promote.d.ts.map +1 -1
  32. package/dist/components/promote.js +6 -3
  33. package/dist/components/release-gate.d.ts +30 -0
  34. package/dist/components/release-gate.d.ts.map +1 -1
  35. package/dist/components/release-gate.js +92 -42
  36. package/dist/components/release-promote.d.ts +9 -0
  37. package/dist/components/release-promote.d.ts.map +1 -1
  38. package/dist/components/release-promote.js +15 -0
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/deep-observation.d.ts +11 -0
  42. package/dist/deep-observation.d.ts.map +1 -1
  43. package/dist/graph-declared.d.ts +9 -1
  44. package/dist/graph-declared.d.ts.map +1 -1
  45. package/dist/graph-declared.js +18 -3
  46. package/dist/graph-detail.d.ts.map +1 -1
  47. package/dist/graph-detail.js +30 -1
  48. package/dist/graph-ir.d.ts.map +1 -1
  49. package/dist/graph-ir.js +13 -2
  50. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  51. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  52. package/dist/lifecycle/compose-refs.d.ts +52 -0
  53. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  54. package/dist/lifecycle/compose-refs.js +115 -0
  55. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  56. package/dist/lifecycle/deep-observe.js +3 -2
  57. package/dist/lifecycle/green-apply.d.ts +39 -0
  58. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  59. package/dist/lifecycle/green-apply.js +51 -0
  60. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  61. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  62. package/dist/lifecycle/publish-declaration.js +15 -0
  63. package/dist/lifecycle/publish-npm.d.ts +7 -4
  64. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  65. package/dist/lifecycle/publish-npm.js +60 -13
  66. package/dist/lifecycle/release-compose.d.ts +91 -0
  67. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  68. package/dist/lifecycle/release-compose.js +235 -0
  69. package/dist/lifecycle/release-cut.d.ts +8 -0
  70. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  71. package/dist/lifecycle/release-cut.js +2 -0
  72. package/dist/lifecycle/release-publish.d.ts +2 -0
  73. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  74. package/dist/lifecycle/release-publish.js +2 -1
  75. package/dist/lifecycle/release-store.d.ts +15 -0
  76. package/dist/lifecycle/release-store.d.ts.map +1 -1
  77. package/dist/lifecycle/release-store.js +5 -1
  78. package/dist/wave-runtime.mjs +226 -224
  79. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  80. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  81. package/dist/workspace/check.schema.json +20 -2
  82. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  83. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  84. package/dist/workspace/checks/ci-workflow.js +130 -0
  85. package/dist/workspace/checks/security.d.ts +16 -1
  86. package/dist/workspace/checks/security.d.ts.map +1 -1
  87. package/dist/workspace/checks/security.js +4 -4
  88. package/dist/workspace/checks.d.ts +1 -1
  89. package/dist/workspace/checks.d.ts.map +1 -1
  90. package/dist/workspace/checks.js +4 -1
  91. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  92. package/dist/workspace/ci-green-cli.js +5 -3
  93. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  94. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  95. package/dist/workspace/ci-green-workflow.js +27 -5
  96. package/dist/workspace/ci-green.d.ts +31 -0
  97. package/dist/workspace/ci-green.d.ts.map +1 -1
  98. package/dist/workspace/ci-green.js +78 -4
  99. package/dist/workspace/ci-init.d.ts +100 -0
  100. package/dist/workspace/ci-init.d.ts.map +1 -0
  101. package/dist/workspace/ci-init.js +434 -0
  102. package/dist/workspace/ci-run.d.ts +6 -0
  103. package/dist/workspace/ci-run.d.ts.map +1 -1
  104. package/dist/workspace/ci-run.js +2 -1
  105. package/dist/workspace/ci-serve.d.ts +94 -13
  106. package/dist/workspace/ci-serve.d.ts.map +1 -1
  107. package/dist/workspace/ci-serve.js +248 -30
  108. package/dist/workspace/ci-workflow.d.ts +91 -0
  109. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  110. package/dist/workspace/ci-workflow.js +33 -0
  111. package/dist/workspace/declaration.d.ts +18 -0
  112. package/dist/workspace/declaration.d.ts.map +1 -1
  113. package/dist/workspace/declaration.js +9 -2
  114. package/dist/workspace/declaration.schema.json +28 -0
  115. package/dist/workspace/delivery-metrics.d.ts +3 -1
  116. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  117. package/dist/workspace/delivery-metrics.js +12 -3
  118. package/dist/workspace/init.js +2 -2
  119. package/dist/workspace/member-commands.d.ts.map +1 -1
  120. package/dist/workspace/member-commands.js +4 -0
  121. package/dist/workspace/nested-graph.d.ts.map +1 -1
  122. package/dist/workspace/nested-graph.js +1 -0
  123. package/dist/workspace/reason-codes.d.ts +9 -0
  124. package/dist/workspace/reason-codes.d.ts.map +1 -1
  125. package/dist/workspace/reason-codes.js +10 -0
  126. package/dist/workspace/release-candidates.d.ts +28 -0
  127. package/dist/workspace/release-candidates.d.ts.map +1 -1
  128. package/dist/workspace/release-candidates.js +38 -2
  129. package/dist/workspace/release-candidates.schema.json +51 -0
  130. package/dist/workspace/release-workflow.d.ts +31 -0
  131. package/dist/workspace/release-workflow.d.ts.map +1 -0
  132. package/dist/workspace/release-workflow.js +150 -0
  133. package/dist/workspace/releases.schema.json +30 -0
  134. package/package.json +1 -1
  135. package/src/cli/handlers/graph.ts +2 -2
  136. package/src/cli/handlers/pr-green.test.ts +230 -0
  137. package/src/cli/handlers/pr.ts +148 -66
  138. package/src/cli/handlers/promote.test.ts +34 -0
  139. package/src/cli/handlers/promote.ts +302 -91
  140. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  141. package/src/cli/handlers/release.ts +112 -10
  142. package/src/cli/handlers/run.ts +12 -1
  143. package/src/cli/main.ts +37 -2
  144. package/src/cli/parse-args.ts +18 -0
  145. package/src/cli/registry.ts +17 -1
  146. package/src/codegen/release-wiring.test.ts +56 -9
  147. package/src/components/auto-release.test.ts +22 -0
  148. package/src/components/auto-release.ts +9 -2
  149. package/src/components/green-cover.test.ts +170 -0
  150. package/src/components/green-cover.ts +186 -0
  151. package/src/components/pr-pipeline.test.ts +14 -4
  152. package/src/components/pr-pipeline.ts +56 -5
  153. package/src/components/promote-rounds.test.ts +220 -0
  154. package/src/components/promote-rounds.ts +232 -0
  155. package/src/components/promote.test.ts +7 -0
  156. package/src/components/promote.ts +9 -4
  157. package/src/components/release-gate.test.ts +47 -0
  158. package/src/components/release-gate.ts +111 -46
  159. package/src/components/release-promote.test.ts +24 -1
  160. package/src/components/release-promote.ts +15 -0
  161. package/src/deep-observation.ts +8 -0
  162. package/src/graph-declared-exports.test.ts +40 -0
  163. package/src/graph-declared.test.ts +18 -0
  164. package/src/graph-declared.ts +17 -2
  165. package/src/graph-detail.test.ts +68 -1
  166. package/src/graph-detail.ts +29 -1
  167. package/src/graph-ir.ts +12 -4
  168. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  169. package/src/lifecycle/compose-refs.ts +125 -0
  170. package/src/lifecycle/deep-observe.test.ts +20 -0
  171. package/src/lifecycle/deep-observe.ts +4 -2
  172. package/src/lifecycle/green-apply.ts +71 -0
  173. package/src/lifecycle/publish-declaration.ts +15 -0
  174. package/src/lifecycle/publish-npm.ts +55 -13
  175. package/src/lifecycle/publish-targets.test.ts +45 -0
  176. package/src/lifecycle/release-compose.test.ts +125 -0
  177. package/src/lifecycle/release-compose.ts +312 -0
  178. package/src/lifecycle/release-cut.ts +6 -0
  179. package/src/lifecycle/release-publish.ts +4 -1
  180. package/src/lifecycle/release-store.ts +16 -1
  181. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  182. package/src/workspace/check-contract.test.ts +3 -2
  183. package/src/workspace/check.schema.json +20 -2
  184. package/src/workspace/checks/ci-workflow.test.ts +160 -0
  185. package/src/workspace/checks/ci-workflow.ts +135 -0
  186. package/src/workspace/checks/security.ts +4 -4
  187. package/src/workspace/checks.ts +4 -1
  188. package/src/workspace/ci-green-cli.ts +5 -3
  189. package/src/workspace/ci-green-workflow.test.ts +52 -0
  190. package/src/workspace/ci-green-workflow.ts +34 -5
  191. package/src/workspace/ci-green.test.ts +33 -0
  192. package/src/workspace/ci-green.ts +85 -4
  193. package/src/workspace/ci-init.test.ts +159 -0
  194. package/src/workspace/ci-init.ts +445 -0
  195. package/src/workspace/ci-report-url.test.ts +6 -1
  196. package/src/workspace/ci-run.test.ts +18 -13
  197. package/src/workspace/ci-run.ts +8 -1
  198. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  199. package/src/workspace/ci-serve-land.test.ts +176 -0
  200. package/src/workspace/ci-serve.test.ts +77 -43
  201. package/src/workspace/ci-serve.ts +268 -34
  202. package/src/workspace/ci-workflow.ts +71 -0
  203. package/src/workspace/declaration.schema.json +28 -0
  204. package/src/workspace/declaration.ts +32 -3
  205. package/src/workspace/delivery-metrics.ts +12 -3
  206. package/src/workspace/graph-contract.test.ts +3 -2
  207. package/src/workspace/init.ts +2 -2
  208. package/src/workspace/member-commands.test.ts +11 -4
  209. package/src/workspace/member-commands.ts +3 -0
  210. package/src/workspace/nested-graph.test.ts +4 -0
  211. package/src/workspace/nested-graph.ts +1 -0
  212. package/src/workspace/reason-codes.test.ts +2 -0
  213. package/src/workspace/reason-codes.ts +10 -0
  214. package/src/workspace/release-candidates.schema.json +51 -0
  215. package/src/workspace/release-candidates.test.ts +30 -3
  216. package/src/workspace/release-candidates.ts +54 -2
  217. package/src/workspace/release-workflow.ts +167 -0
  218. package/src/workspace/releases.schema.json +30 -0
@@ -196,10 +196,23 @@ export interface PrLoopJob {
196
196
  env: Record<string, string>;
197
197
  /** Shell lines to run before the command, to settle values the forge may leave unusable. */
198
198
  setup?: string[];
199
+ /**
200
+ * Shell lines a generated job runs first, before `setup`, to check out the
201
+ * commit it works on (#4168 step 9): the apply checks out the newest green
202
+ * commit. `chant ci run` and `ci serve` already run the job at that commit,
203
+ * so they skip these.
204
+ */
205
+ checkout?: string[];
199
206
  /** With `build` (#4088): the build line and the lint line, run in this order before the command. */
200
207
  build?: string[];
201
208
  /** With `waveGate` (#4003): the wave a generated apply job runs, 1-based. */
202
209
  wave?: number;
210
+ /**
211
+ * The branch an apply job takes its green commit from (#4168 step 9). A
212
+ * wave after the first checks out the commit wave 1 took, which the job
213
+ * receives as `GREEN_SHA` (#4221).
214
+ */
215
+ greenBranch?: string;
203
216
  /**
204
217
  * What the generated job runs in place of `command` (#4156): `chant ci run
205
218
  * <job> --in-place` with the same environment, gate, forge, events and
@@ -241,7 +254,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
241
254
  // #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
242
255
  const ciRun = (job: PrLoopJob["jobName"]): string[] => [
243
256
  "chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
244
- "--base", '"$BASE_SHA"', ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
257
+ ...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
245
258
  "--env", env, "--gate", gate, "--forge", forge,
246
259
  ...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
247
260
  ...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
@@ -254,6 +267,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
254
267
  const planEnv = github
255
268
  ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
256
269
  : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
270
+ const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
257
271
  const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
258
272
  return [
259
273
  {
@@ -265,17 +279,19 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
265
279
  run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
266
280
  },
267
281
  {
282
+ // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
268
283
  jobName: "apply",
269
284
  command: [
270
- "chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
285
+ "chant", "components", "pr-apply", "--green", ...common,
271
286
  ...(options.requireReview === true ? ["--require-review"] : []),
272
287
  ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
273
288
  "--resume", PR_APPLY_RECORD,
274
289
  ].join(" "),
275
- env: applyEnv,
276
- setup: [github ? githubBaseFallback(options.branch) : gitlabBaseFallback(options.branch)],
290
+ env: jobEnv,
291
+ checkout: [greenCheckout(greenBranch)],
292
+ greenBranch,
277
293
  ...build,
278
- run: { command: ciRun("apply").join(" "), env: runEnv(applyEnv) },
294
+ run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
279
295
  },
280
296
  {
281
297
  jobName: "record",
@@ -288,6 +304,15 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
288
304
  ];
289
305
  }
290
306
 
307
+ /**
308
+ * What starts the apply under apply on merge (#4168 step 9): on GitHub the
309
+ * tick's workflow finishing (`workflow_run`, since tags its token pushes start
310
+ * no workflow), elsewhere a pushed `ci/green/*` tag. The name is
311
+ * `CI_GREEN_WORKFLOW_NAME` (../workspace/ci-green-workflow.ts).
312
+ */
313
+ export const GREEN_TICK_WORKFLOW = "chant-ci-green";
314
+ export const GREEN_TAG_PATTERN = "ci/green/**";
315
+
291
316
  /** The job a push to the target branch runs (#4168): `record`, or `apply` when the pipeline opts into apply on merge. */
292
317
  export function prLoopMergeJob(jobs: readonly PrLoopJob[], options: PrLoopPipelineOptions = {}): PrLoopJob {
293
318
  return jobs.find((j) => j.jobName === (options.applyOnMerge ? "apply" : "record"))!;
@@ -326,6 +351,8 @@ export function prLoopWaveJobs(components: DriverComponent[], apply: PrLoopJob):
326
351
  ...apply,
327
352
  command: `${apply.command} --wave ${i + 1}`,
328
353
  wave: i + 1,
354
+ // #4221: every wave after the first applies the commit wave 1 took.
355
+ ...(i > 0 && apply.greenBranch !== undefined ? { checkout: [pinnedGreenCheckout(apply.greenBranch)] } : {}),
329
356
  // #4156: the job runs it through chant ci run, which appends the same --wave to pr-apply.
330
357
  run: { ...apply.run, command: `${apply.run.command} --wave ${i + 1}` },
331
358
  }));
@@ -381,6 +408,30 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
381
408
  );
382
409
  }
383
410
 
411
+ /**
412
+ * The line that checks out the newest green commit of `branch` (#4168 step
413
+ * 9), after fetching the branch and its tags, for the apply job. `chant ci
414
+ * last-green` exits 1 when no commit is green, which fails the job.
415
+ */
416
+ export function greenCheckout(branch: string): string {
417
+ return `${fetchBranch(branch)} && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"`;
418
+ }
419
+
420
+ /**
421
+ * The line a wave after the first runs in place of {@link greenCheckout}
422
+ * (#4221): it checks out `GREEN_SHA`, the commit wave 1 took, so a commit
423
+ * that goes green while the waves run waits for a run of its own. An empty
424
+ * `GREEN_SHA` fails the job.
425
+ */
426
+ export function pinnedGreenCheckout(branch: string): string {
427
+ return `test -n "$GREEN_SHA" && ${fetchBranch(branch)} && git checkout --quiet --detach "$GREEN_SHA"`;
428
+ }
429
+
430
+ function fetchBranch(branch: string): string {
431
+ const name = branch.replace(/^"|"$/g, "");
432
+ return `git fetch --quiet --tags origin "+refs/heads/${name}:refs/remotes/origin/${name}"`;
433
+ }
434
+
384
435
  /**
385
436
  * GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
386
437
  * The line replaces it with the merge request's diff base when the pipeline
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Rounds and per-wave gates at promote (#4168 step 4): a dependent planned
3
+ * against its upstream's planned outputs is planned again once the upstream
4
+ * applied as another value, and `--wave-gate` checks each wave.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { composeChangeSet, type ChangeSetDocument } from "../change-set";
9
+ import type { DriverComponent, DriverRunResult, ReleaseGateBinding } from "./driver";
10
+ import { movedInputs, renameGates, runPromotionInRounds } from "./promote-rounds";
11
+
12
+ const sha = (c: string) => `sha256:${c.repeat(64)}`;
13
+
14
+ const net: DriverComponent = { name: "net", deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "net" }] }] };
15
+ const svc: DriverComponent = {
16
+ name: "svc",
17
+ dependsOn: ["net"],
18
+ deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: "id" } } }] }],
19
+ };
20
+
21
+ /** A change set of one planned member per name, each at the digest given; `writes` adds an update of that member's `vpc`. */
22
+ const doc = (members: Record<string, string>, writes: Record<string, string> = {}): ChangeSetDocument =>
23
+ composeChangeSet(
24
+ Object.entries(members).map(([member, digest]) => ({
25
+ member: { member, lexicon: "terraform", planner: "terraform" as const, status: "planned" as const, planDigest: digest, holes: [] },
26
+ entries: writes[member]
27
+ ? [{ member, lexicon: "terraform", planner: "terraform" as const, address: `${member}.main`, type: "x", name: "main", action: "update" as const, attributes: [{ path: "vpc", after: writes[member] }] }]
28
+ : [],
29
+ })),
30
+ );
31
+
32
+ const approved = doc({ net: sha("1"), svc: sha("2") }, { svc: "vpc-new" });
33
+ const binding: ReleaseGateBinding = { op: "release", planDigest: approved.digest, releaseDigest: sha("r") };
34
+
35
+ /** A run that applies every component and leaves `exposes` as its outputs, recording what it was handed. */
36
+ function runner(exposes: Record<string, Record<string, unknown>>) {
37
+ const calls: Array<{ names: string[]; gates: string[]; binding: string; outputs: Record<string, Record<string, unknown>> }> = [];
38
+ const run = async (components: DriverComponent[], outputs: Record<string, Record<string, unknown>>, b: ReleaseGateBinding): Promise<DriverRunResult> => {
39
+ const gates = components.flatMap((c) => c.deploy.flatMap((p) => p.steps.filter((s) => (s as { kind?: string }).kind === "gate").map((s) => (s as { gate: string }).gate)));
40
+ calls.push({ names: components.map((c) => c.name), gates, binding: b.planDigest, outputs });
41
+ const out = { ...outputs };
42
+ for (const c of components) if (exposes[c.name]) out[c.name] = exposes[c.name];
43
+ return {
44
+ order: components.map((c) => c.name),
45
+ waves: [components.map((c) => c.name)],
46
+ results: components.map((c) => ({ component: c.name, ok: true, status: "ok" as const, records: [] })),
47
+ ok: true,
48
+ status: "ok",
49
+ componentOutputs: out,
50
+ };
51
+ };
52
+ return { run, calls };
53
+ }
54
+
55
+ const bindings: string[][] = [];
56
+ const bind = async (d: ChangeSetDocument, gates: string[]): Promise<ReleaseGateBinding> => {
57
+ bindings.push(gates);
58
+ return { op: "release", planDigest: d.digest, releaseDigest: sha("r") };
59
+ };
60
+
61
+ describe("movedInputs", () => {
62
+ test("names each reference that resolves to another value than its plan read", () => {
63
+ expect(movedInputs(svc, { net: { id: "vpc-new" } }, { net: { id: "vpc-new" } })).toEqual([]);
64
+ expect(movedInputs(svc, { net: { id: "vpc-new" } }, { net: { id: "vpc-other" } })).toEqual(["net.id"]);
65
+ expect(movedInputs(svc, undefined, { net: { id: "x" } })).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe("renameGates", () => {
70
+ test("renames every gate, nested phases included, and drops the old key", () => {
71
+ const c: DriverComponent = { name: "x", deploy: [{ phase: "A", steps: [{ phase: "B", steps: [{ kind: "gate", signalName: "old" }] }, { kind: "gate", gate: "g" }] }] };
72
+ const renamed = renameGates(c, (g) => `${g}-round-2`);
73
+ expect(JSON.stringify(renamed)).toContain('"gate":"old-round-2"');
74
+ expect(JSON.stringify(renamed)).toContain('"gate":"g-round-2"');
75
+ expect(JSON.stringify(renamed)).not.toContain("signalName");
76
+ });
77
+ });
78
+
79
+ describe("runPromotionInRounds", () => {
80
+ test("an upstream that applies as its plan said leaves the dependent under the approval it has", async () => {
81
+ const { run, calls } = runner({ net: { id: "vpc-new" } });
82
+ const result = await runPromotionInRounds({
83
+ components: [svc, net],
84
+ outputs: {},
85
+ binding,
86
+ approved,
87
+ reads: { svc: { net: { id: "vpc-new" } } },
88
+ run,
89
+ replan: async () => { throw new Error("not planned again"); },
90
+ bind,
91
+ });
92
+ expect(result.run.status).toBe("ok");
93
+ expect(result.stages).toEqual([]);
94
+ expect(calls.map((c) => [c.names, c.binding])).toEqual([[["net"], approved.digest], [["svc"], approved.digest]]);
95
+ expect(calls[1].outputs).toEqual({ net: { id: "vpc-new" } });
96
+ expect(result.completed).toEqual(["net", "svc"]);
97
+ });
98
+
99
+ test("an upstream that applies as another value plans the dependent again; a covered plan applies under the approval", async () => {
100
+ const { run, calls } = runner({ net: { id: "vpc-other" } });
101
+ const result = await runPromotionInRounds({
102
+ components: [net, svc],
103
+ outputs: {},
104
+ binding,
105
+ approved,
106
+ reads: { svc: { net: { id: "vpc-new" } } },
107
+ run,
108
+ replan: async () => doc({ svc: sha("2") }, { svc: "vpc-new" }),
109
+ bind,
110
+ });
111
+ expect(result.stages).toEqual([]);
112
+ expect(calls[1]).toMatchObject({ names: ["svc"], binding: approved.digest, gates: ["prod-release"] });
113
+ });
114
+
115
+ test("a moved plan the approval does not cover waits at <gate>-round-2, bound to the new digest", async () => {
116
+ const { run, calls } = runner({ net: { id: "vpc-other" } });
117
+ const fresh = doc({ svc: sha("9") }, { svc: "vpc-other" });
118
+ const result = await runPromotionInRounds({
119
+ components: [net, svc],
120
+ outputs: {},
121
+ binding,
122
+ approved,
123
+ reads: { svc: { net: { id: "vpc-new" } } },
124
+ run,
125
+ replan: async (only, outputs) => {
126
+ expect([...only]).toEqual(["svc"]);
127
+ expect(outputs).toEqual({ net: { id: "vpc-other" } });
128
+ return fresh;
129
+ },
130
+ bind,
131
+ });
132
+ expect(result.stages).toHaveLength(1);
133
+ expect(result.stages[0]).toMatchObject({ kind: "round", number: 2, components: ["svc"], digest: fresh.digest, status: "applied" });
134
+ expect(result.stages[0].notCovered[0]).toMatch(/svc/);
135
+ expect(calls[1]).toMatchObject({ names: ["svc"], binding: fresh.digest, gates: ["prod-release-round-2"] });
136
+ });
137
+
138
+ test("--wave-gate checks every wave after the first and names one gate per wave", async () => {
139
+ const { run, calls } = runner({ net: { id: "vpc-new" } });
140
+ const fresh = doc({ svc: sha("8") }, { svc: "vpc-other" });
141
+ const result = await runPromotionInRounds({
142
+ components: [net, svc],
143
+ outputs: {},
144
+ binding,
145
+ approved,
146
+ reads: { svc: { net: { id: "vpc-new" } } },
147
+ waveGate: "deploy",
148
+ run,
149
+ replan: async () => fresh,
150
+ bind,
151
+ });
152
+ expect(result.stages[0]).toMatchObject({ kind: "wave", number: 2, digest: fresh.digest });
153
+ expect(calls[1].gates).toEqual(["deploy-wave-2"]);
154
+ });
155
+
156
+ test("a stage whose components declare no gate stops with nothing more deployed", async () => {
157
+ const plain: DriverComponent = { ...svc, deploy: [{ phase: "Apply", steps: [{ kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: "id" } } }] }] };
158
+ const { run, calls } = runner({ net: { id: "vpc-other" } });
159
+ const result = await runPromotionInRounds({
160
+ components: [net, plain],
161
+ outputs: {},
162
+ binding,
163
+ approved,
164
+ reads: { svc: { net: { id: "vpc-new" } } },
165
+ run,
166
+ replan: async () => doc({ svc: sha("9") }, { svc: "vpc-other" }),
167
+ bind,
168
+ });
169
+ expect(result.run.status).toBe("fail");
170
+ expect(result.stages[0]).toMatchObject({ status: "failed" });
171
+ expect(result.stages[0].message).toMatch(/no gate/);
172
+ expect(calls.map((c) => c.names)).toEqual([["net"]]);
173
+ });
174
+
175
+ test("a resume skips what applied and starts from the outputs it kept", async () => {
176
+ const { run, calls } = runner({});
177
+ const result = await runPromotionInRounds({
178
+ components: [net, svc],
179
+ outputs: { net: { id: "vpc-new" } },
180
+ binding,
181
+ approved,
182
+ reads: { svc: { net: { id: "vpc-new" } } },
183
+ completed: ["net"],
184
+ run,
185
+ replan: async () => { throw new Error("not planned again"); },
186
+ bind,
187
+ });
188
+ expect(calls.map((c) => c.names)).toEqual([["svc"]]);
189
+ expect(result.completed).toEqual(["net", "svc"]);
190
+ });
191
+
192
+ test("a gated wave stops the run and leaves what applied", async () => {
193
+ const calls: string[][] = [];
194
+ const result = await runPromotionInRounds({
195
+ components: [net, svc],
196
+ outputs: {},
197
+ binding,
198
+ approved,
199
+ reads: {},
200
+ run: async (components, outputs) => {
201
+ calls.push(components.map((c) => c.name));
202
+ return {
203
+ order: ["net"],
204
+ waves: [["net"]],
205
+ results: [{ component: "net", ok: false, status: "gated", records: [] }],
206
+ ok: false,
207
+ status: "gated",
208
+ gatedComponent: "net",
209
+ componentOutputs: outputs,
210
+ };
211
+ },
212
+ replan: async () => approved,
213
+ bind,
214
+ });
215
+ expect(result.run.status).toBe("gated");
216
+ expect(result.run.gatedComponent).toBe("net");
217
+ expect(calls).toEqual([["net"]]);
218
+ expect(result.completed).toEqual([]);
219
+ });
220
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * A promote under a release that plans again once an upstream applied, and a
3
+ * gate per wave (#4168 step 4). Merging deploys nothing since step 1, so what
4
+ * `pr-apply` did on merge happens here, at promote:
5
+ *
6
+ * - **Rounds** (#3999's rule, `../pr-loop.ts`). The release gate approves a
7
+ * change set in which each dependent was planned against its upstream's
8
+ * planned outputs (`planReleaseSet` in ./release-gate.ts). The promote runs
9
+ * a wave at a time. Before a wave runs, a component whose plan read an
10
+ * upstream output that the run left as another value
11
+ * (`movedWiringReferences`) is planned again against what stands. When the
12
+ * approved change set covers the new plan (`waveNotCovered`, the test a
13
+ * pull request's resume uses), it applies under the approval it has.
14
+ * Otherwise its gates are decided as `<gate>-round-<n>` (`prRoundGate`),
15
+ * bound to the new plan's digest.
16
+ * - **A gate per wave** (`--wave-gate <gate>`, #4157's rule). Every wave
17
+ * after the first is planned again against what the waves before it left,
18
+ * and a wave the approved change set does not cover waits at
19
+ * `<gate>-wave-<k>` (`waveGateName`), one gate for the whole wave, bound to
20
+ * that wave's digest.
21
+ *
22
+ * A stage whose components declare no gate has nowhere to wait, so the
23
+ * promote stops there with nothing more deployed.
24
+ *
25
+ * A promote that stops at a round's or a wave's gate writes a resume record
26
+ * ({@link PromoteResume}): the components that applied and the outputs they
27
+ * left. The same promote run again with it skips what applied and carries on
28
+ * from the wave it stopped at, so an approval of the round or wave it waits on
29
+ * is decided against the same plan.
30
+ */
31
+
32
+ import { movedWiringReferences, type DriverComponent, type DriverGate, type DriverPhase, type DriverRunResult, type DriverStep, type ReleaseGateBinding } from "./driver";
33
+ import { resolveComponentGraph } from "./driver";
34
+ import { waveNotCovered } from "./pr-run";
35
+ import { prRoundGate } from "../pr-loop";
36
+ import { waveGateName } from "../gated-waves";
37
+ import { capabilitySteps } from "./wave-plan";
38
+ import type { ChangeSetDocument } from "../change-set";
39
+ import type { ReleasePlan } from "./release-gate";
40
+
41
+ export const PROMOTE_RESUME_SCHEMA = "chant-promote-resume/v1";
42
+
43
+ /** What a promote that stopped at a round's or a wave's gate leaves for the next attempt. */
44
+ export interface PromoteResume {
45
+ schema: typeof PROMOTE_RESUME_SCHEMA;
46
+ env: string;
47
+ releaseDigest: string;
48
+ /** The change set the release gate approved before anything applied. */
49
+ approvedDigest: string;
50
+ /** Components that applied, in run order. */
51
+ completed: string[];
52
+ /** The outputs standing after them. */
53
+ outputs: Record<string, Record<string, unknown>>;
54
+ /** ISO 8601. */
55
+ at: string;
56
+ }
57
+
58
+ /** One round or wave that needed its own approval. */
59
+ export interface PromoteStage {
60
+ kind: "round" | "wave";
61
+ /** The round number (2 and up) or the wave number (1-based). */
62
+ number: number;
63
+ components: string[];
64
+ /** The plan the stage's gates bind. */
65
+ digest: string;
66
+ /** Why the approved change set did not cover it, one line per member. */
67
+ notCovered: string[];
68
+ status: "applied" | "waiting" | "failed";
69
+ /** With `failed` before anything ran: why. */
70
+ message?: string;
71
+ }
72
+
73
+ export interface PromoteRoundsOptions {
74
+ /** The promoted compositions, in no particular order. */
75
+ components: DriverComponent[];
76
+ /** Outputs standing before the run: the seeds, and on a resume the record's. */
77
+ outputs: Record<string, Record<string, unknown>>;
78
+ /** The release gate the change set was approved under. */
79
+ binding: ReleaseGateBinding;
80
+ /** The approved change set, and what each component's plan read. */
81
+ approved: ChangeSetDocument;
82
+ reads: ReleasePlan["reads"];
83
+ /** Components an earlier attempt applied. Skipped. */
84
+ completed?: readonly string[];
85
+ /** `--wave-gate <gate>`: check every wave after the first, not only the components whose inputs moved, and name a wave's gate `<gate>-wave-<k>`. */
86
+ waveGate?: string;
87
+ /** Run one subset of the components with `outputs` standing (`runPromotion` with the binding). */
88
+ run: (components: DriverComponent[], outputs: Record<string, Record<string, unknown>>, binding: ReleaseGateBinding) => Promise<DriverRunResult>;
89
+ /** Plan `only` again with `outputs` standing (`planReleaseSet`). */
90
+ replan: (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => Promise<ChangeSetDocument>;
91
+ /** Store a stage's plan and bind its gates (empty resolutions included). */
92
+ bind: (doc: ChangeSetDocument, gates: string[]) => Promise<ReleaseGateBinding>;
93
+ }
94
+
95
+ export interface PromoteRoundsResult {
96
+ run: DriverRunResult;
97
+ /** The rounds and waves that needed their own approval. */
98
+ stages: PromoteStage[];
99
+ /** Every component that applied, earlier attempts included, in run order. */
100
+ completed: string[];
101
+ outputs: Record<string, Record<string, unknown>>;
102
+ }
103
+
104
+ function isGate(step: DriverStep | DriverGate | DriverPhase): step is DriverGate {
105
+ return (step as { kind?: unknown }).kind === "gate";
106
+ }
107
+
108
+ function isPhase(step: DriverStep | DriverGate | DriverPhase): step is DriverPhase {
109
+ return typeof (step as { phase?: unknown }).phase === "string" && Array.isArray((step as DriverPhase).steps);
110
+ }
111
+
112
+ /** `component` with each gate renamed by `rename`, nested phases included. */
113
+ export function renameGates(component: DriverComponent, rename: (gate: string) => string): DriverComponent {
114
+ const walk = (phase: DriverPhase): DriverPhase => ({
115
+ ...phase,
116
+ steps: phase.steps.map((step) => {
117
+ if (isPhase(step)) return walk(step);
118
+ if (!isGate(step)) return step;
119
+ const { signalName: _old, ...rest } = step;
120
+ return { ...rest, gate: rename(step.gate ?? step.signalName ?? "") };
121
+ }),
122
+ });
123
+ return { ...component, deploy: component.deploy.map(walk) };
124
+ }
125
+
126
+ /** The gate names `components` declare, sorted and distinct. */
127
+ function gatesOf(components: readonly DriverComponent[]): string[] {
128
+ const names = new Set<string>();
129
+ const walk = (steps: DriverPhase["steps"]): void => {
130
+ for (const step of steps) {
131
+ if (isPhase(step)) walk(step.steps);
132
+ else if (isGate(step)) names.add(step.gate ?? step.signalName ?? "");
133
+ }
134
+ };
135
+ for (const c of components) for (const phase of c.deploy) walk(phase.steps);
136
+ return [...names].sort();
137
+ }
138
+
139
+ /** The references `component`'s steps make that resolve to another value now than when its plan read them. */
140
+ export function movedInputs(component: DriverComponent, read: Record<string, Record<string, unknown>> | undefined, now: Record<string, Record<string, unknown>>): string[] {
141
+ if (!read) return [];
142
+ const moved = new Set<string>();
143
+ for (const step of capabilitySteps(component.deploy)) {
144
+ const { kind: _kind, ...rest } = step;
145
+ for (const r of movedWiringReferences(rest, read, now)) if (!r.publish) moved.add(r.ref);
146
+ }
147
+ return [...moved].sort();
148
+ }
149
+
150
+ function joinRuns(runs: DriverRunResult[], outputs: Record<string, Record<string, unknown>>): DriverRunResult {
151
+ const last = runs[runs.length - 1];
152
+ const base: DriverRunResult = {
153
+ order: runs.flatMap((r) => r.order),
154
+ waves: runs.flatMap((r) => r.waves),
155
+ results: runs.flatMap((r) => r.results),
156
+ ok: runs.every((r) => r.ok),
157
+ status: "ok",
158
+ componentOutputs: outputs,
159
+ };
160
+ if (!last || last.status === "ok") return base;
161
+ const { order: _o, waves: _w, results: _r, ok: _ok, componentOutputs: _c, ...stop } = last;
162
+ return { ...base, ...stop, ok: false };
163
+ }
164
+
165
+ /**
166
+ * Run the promote a wave at a time, planning again where an upstream applied
167
+ * as another value, and with `waveGate`, checking each wave. Stops at the
168
+ * first wave that fails or waits on a gate.
169
+ */
170
+ export async function runPromotionInRounds(options: PromoteRoundsOptions): Promise<PromoteRoundsResult> {
171
+ const byName = new Map(options.components.map((c) => [c.name, c]));
172
+ const { waves } = resolveComponentGraph(options.components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
173
+ const completed = [...(options.completed ?? [])];
174
+ const done = new Set(completed);
175
+ let outputs = { ...options.outputs };
176
+ const runs: DriverRunResult[] = [];
177
+ const stages: PromoteStage[] = [];
178
+ let round = 1;
179
+
180
+ for (const [index, wave] of waves.entries()) {
181
+ const waveNumber = index + 1;
182
+ const pending = wave.filter((name) => !done.has(name));
183
+ if (pending.length === 0) continue;
184
+ const check = options.waveGate !== undefined && waveNumber > 1
185
+ ? pending
186
+ : pending.filter((name) => movedInputs(byName.get(name)!, options.reads[name], outputs).length > 0);
187
+ const batches: Array<{ names: string[]; binding: ReleaseGateBinding; stage?: PromoteStage; rename?: (g: string) => string }> = [];
188
+ const rest = pending.filter((name) => !check.includes(name));
189
+ if (check.length > 0) {
190
+ const fresh = await options.replan(new Set(check), outputs);
191
+ const notCovered = waveNotCovered(options.approved, fresh);
192
+ if (notCovered.length === 0) {
193
+ rest.push(...check);
194
+ } else {
195
+ const kind: PromoteStage["kind"] = options.waveGate !== undefined ? "wave" : "round";
196
+ if (kind === "round") round += 1;
197
+ const number = kind === "wave" ? waveNumber : round;
198
+ const base = options.waveGate;
199
+ const rename = (g: string) => (base !== undefined ? waveGateName(base, number) : prRoundGate(g, number));
200
+ const renamed = check.map((name) => renameGates(byName.get(name)!, rename));
201
+ const gates = gatesOf(renamed);
202
+ const stage: PromoteStage = { kind, number, components: check, digest: fresh.digest, notCovered, status: "waiting" };
203
+ stages.push(stage);
204
+ if (gates.length === 0) {
205
+ stage.status = "failed";
206
+ stage.message = `${check.join(", ")} planned again past the approved change set and declare${check.length === 1 ? "s" : ""} no gate to approve the new plan on`;
207
+ const stop: DriverRunResult = { order: [], waves: [], results: [], ok: false, status: "fail", failedComponent: check[0], componentOutputs: outputs };
208
+ return { run: joinRuns([...runs, stop], outputs), stages, completed, outputs };
209
+ }
210
+ const binding = await options.bind(fresh, gates);
211
+ batches.push({ names: check, binding, stage, rename });
212
+ }
213
+ }
214
+ if (rest.length > 0) batches.unshift({ names: pending.filter((n) => rest.includes(n)), binding: options.binding });
215
+
216
+ for (const batch of batches) {
217
+ const components = batch.names.map((name) => (batch.rename ? renameGates(byName.get(name)!, batch.rename) : byName.get(name)!));
218
+ const run = await options.run(components, outputs, batch.binding);
219
+ runs.push(run);
220
+ outputs = { ...outputs, ...run.componentOutputs };
221
+ for (const r of run.results) {
222
+ if (r.status === "ok" && !done.has(r.component)) {
223
+ done.add(r.component);
224
+ completed.push(r.component);
225
+ }
226
+ }
227
+ if (batch.stage) batch.stage.status = run.status === "ok" ? "applied" : run.status === "gated" ? "waiting" : "failed";
228
+ if (run.status !== "ok") return { run: joinRuns(runs, outputs), stages, completed, outputs };
229
+ }
230
+ }
231
+ return { run: joinRuns(runs, outputs), stages, completed, outputs };
232
+ }
@@ -197,6 +197,13 @@ describe("taking the build out of a composition", () => {
197
197
  expect("error" in prepared && prepared.error).toMatch(/no publish step/);
198
198
  });
199
199
 
200
+ test("#4222: no publish step passes when a release records its templates, and says it is template-only", () => {
201
+ const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
202
+ const prepared = withoutBuildSteps(c, { templatesPinned: true });
203
+ expect("error" in prepared).toBe(false);
204
+ expect("templateOnly" in prepared && prepared.templateOnly).toBe(true);
205
+ });
206
+
200
207
  test("two publish steps are refused", () => {
201
208
  const c = service("api");
202
209
  c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * A composition the transform cannot pin to one recorded digest is refused
25
25
  * before anything runs: one with no publish step (nothing would carry the
26
- * digest), one with more than one (the ledger records one digest per
26
+ * digest) unless a release records its templates (#4222), one with more than one (the ledger records one digest per
27
27
  * component), and one whose remaining steps read an output of a removed build
28
28
  * step (it would resolve to nothing).
29
29
  *
@@ -402,6 +402,8 @@ export interface PromotableComponent {
402
402
  component: DriverComponent;
403
403
  /** The kinds removed, in composition order. */
404
404
  removed: string[];
405
+ /** #4222: the component has no publish step and deploys the templates a release records, so its record carries their digest. */
406
+ templateOnly?: boolean;
405
407
  }
406
408
 
407
409
  /**
@@ -433,7 +435,7 @@ export const recordedDigestCapability: Capability<{ digest: string }, { digest:
433
435
  */
434
436
  export function withoutBuildSteps(
435
437
  component: DriverComponent,
436
- opts: { pinDigest?: string; verb?: string } = {},
438
+ opts: { pinDigest?: string; verb?: string; templatesPinned?: boolean } = {},
437
439
  ): PromotableComponent | { error: string } {
438
440
  const verb = opts.verb ?? (opts.pinDigest === undefined ? "a promote" : "a rollback");
439
441
  const removed: string[] = [];
@@ -487,7 +489,10 @@ export function withoutBuildSteps(
487
489
 
488
490
  const deploy = component.deploy.map(strip).filter((p): p is DriverPhase => p !== undefined);
489
491
 
490
- if (publishSteps === 0) {
492
+ // #4222: under a release that records this component's templates, the
493
+ // deploy reads them from the release (./release-promote.ts), so a component
494
+ // with no artifact deploys the release's content, not its current source.
495
+ if (publishSteps === 0 && !opts.templatesPinned) {
491
496
  return {
492
497
  error: `component "${component.name}" has no publish step, so nothing in its deploy carries a recorded digest; ` +
493
498
  `${verb} would deploy whatever its current source produces`,
@@ -519,7 +524,7 @@ export function withoutBuildSteps(
519
524
  }
520
525
  }
521
526
  }
522
- return { component: { ...component, deploy }, removed };
527
+ return { component: { ...component, deploy }, removed, ...(publishSteps === 0 ? { templateOnly: true } : {}) };
523
528
  }
524
529
 
525
530
  /** Every same-project wiring reference anywhere in a step's fields (cross-stack `stackOutput` objects excluded). */