@intentius/chant 0.124.34 → 0.124.36

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 (171) 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 +23 -8
  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 +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  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.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
  45. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  46. package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
  47. package/dist/lifecycle/compose-refs.d.ts +52 -0
  48. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  49. package/dist/lifecycle/compose-refs.js +115 -0
  50. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  51. package/dist/lifecycle/deep-observe.js +3 -2
  52. package/dist/lifecycle/green-apply.d.ts +39 -0
  53. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  54. package/dist/lifecycle/green-apply.js +51 -0
  55. package/dist/lifecycle/publish-declaration.d.ts +6 -0
  56. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  57. package/dist/lifecycle/publish-declaration.js +21 -0
  58. package/dist/lifecycle/publish-npm.d.ts +11 -4
  59. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  60. package/dist/lifecycle/publish-npm.js +104 -14
  61. package/dist/lifecycle/release-compose.d.ts +91 -0
  62. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  63. package/dist/lifecycle/release-compose.js +235 -0
  64. package/dist/lifecycle/release-cut.d.ts +8 -0
  65. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  66. package/dist/lifecycle/release-cut.js +2 -0
  67. package/dist/lifecycle/release-publish.d.ts +6 -0
  68. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  69. package/dist/lifecycle/release-publish.js +2 -1
  70. package/dist/lifecycle/release-store.d.ts +15 -0
  71. package/dist/lifecycle/release-store.d.ts.map +1 -1
  72. package/dist/lifecycle/release-store.js +5 -1
  73. package/dist/wave-runtime.mjs +225 -223
  74. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  75. package/dist/workspace/checks/ci-workflow.js +8 -1
  76. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  77. package/dist/workspace/ci-green-cli.js +5 -3
  78. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  79. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  80. package/dist/workspace/ci-green-workflow.js +27 -5
  81. package/dist/workspace/ci-green.d.ts +31 -0
  82. package/dist/workspace/ci-green.d.ts.map +1 -1
  83. package/dist/workspace/ci-green.js +78 -4
  84. package/dist/workspace/ci-run.d.ts +6 -0
  85. package/dist/workspace/ci-run.d.ts.map +1 -1
  86. package/dist/workspace/ci-run.js +2 -1
  87. package/dist/workspace/ci-serve.d.ts +94 -13
  88. package/dist/workspace/ci-serve.d.ts.map +1 -1
  89. package/dist/workspace/ci-serve.js +248 -30
  90. package/dist/workspace/ci-workflow.d.ts +4 -4
  91. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  92. package/dist/workspace/ci-workflow.js +2 -2
  93. package/dist/workspace/delivery-metrics.d.ts +3 -1
  94. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  95. package/dist/workspace/delivery-metrics.js +12 -3
  96. package/dist/workspace/member-commands.d.ts.map +1 -1
  97. package/dist/workspace/member-commands.js +4 -0
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +1 -0
  100. package/dist/workspace/release-candidates.d.ts +28 -0
  101. package/dist/workspace/release-candidates.d.ts.map +1 -1
  102. package/dist/workspace/release-candidates.js +38 -2
  103. package/dist/workspace/release-candidates.schema.json +51 -0
  104. package/dist/workspace/releases.schema.json +30 -0
  105. package/package.json +1 -1
  106. package/src/cli/handlers/graph.ts +2 -2
  107. package/src/cli/handlers/pr-green.test.ts +230 -0
  108. package/src/cli/handlers/pr.ts +148 -66
  109. package/src/cli/handlers/promote.test.ts +34 -0
  110. package/src/cli/handlers/promote.ts +22 -8
  111. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  112. package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
  113. package/src/cli/handlers/release.ts +112 -10
  114. package/src/cli/handlers/run.ts +12 -1
  115. package/src/cli/main.ts +19 -1
  116. package/src/cli/parse-args.ts +13 -0
  117. package/src/cli/registry.ts +14 -0
  118. package/src/codegen/release-wiring.test.ts +56 -9
  119. package/src/components/auto-release.test.ts +22 -0
  120. package/src/components/auto-release.ts +9 -2
  121. package/src/components/green-cover.test.ts +170 -0
  122. package/src/components/green-cover.ts +186 -0
  123. package/src/components/pr-pipeline.test.ts +14 -4
  124. package/src/components/pr-pipeline.ts +56 -5
  125. package/src/components/promote.test.ts +7 -0
  126. package/src/components/promote.ts +9 -4
  127. package/src/components/release-promote.test.ts +24 -1
  128. package/src/components/release-promote.ts +15 -0
  129. package/src/deep-observation.ts +8 -0
  130. package/src/graph-declared.test.ts +18 -0
  131. package/src/graph-declared.ts +8 -1
  132. package/src/graph-detail.test.ts +68 -1
  133. package/src/graph-detail.ts +29 -1
  134. package/src/graph-ir.ts +12 -4
  135. package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
  136. package/src/lifecycle/compose-refs.ts +125 -0
  137. package/src/lifecycle/deep-observe.test.ts +20 -0
  138. package/src/lifecycle/deep-observe.ts +4 -2
  139. package/src/lifecycle/green-apply.ts +71 -0
  140. package/src/lifecycle/publish-declaration.ts +21 -0
  141. package/src/lifecycle/publish-npm.ts +100 -14
  142. package/src/lifecycle/publish-targets.test.ts +108 -0
  143. package/src/lifecycle/release-compose.test.ts +125 -0
  144. package/src/lifecycle/release-compose.ts +312 -0
  145. package/src/lifecycle/release-cut.ts +6 -0
  146. package/src/lifecycle/release-publish.ts +8 -1
  147. package/src/lifecycle/release-store.ts +16 -1
  148. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  149. package/src/workspace/checks/ci-workflow.ts +7 -1
  150. package/src/workspace/ci-green-cli.ts +5 -3
  151. package/src/workspace/ci-green-workflow.test.ts +52 -0
  152. package/src/workspace/ci-green-workflow.ts +34 -5
  153. package/src/workspace/ci-green.test.ts +33 -0
  154. package/src/workspace/ci-green.ts +85 -4
  155. package/src/workspace/ci-run.test.ts +18 -13
  156. package/src/workspace/ci-run.ts +8 -1
  157. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  158. package/src/workspace/ci-serve-land.test.ts +176 -0
  159. package/src/workspace/ci-serve.test.ts +77 -43
  160. package/src/workspace/ci-serve.ts +268 -34
  161. package/src/workspace/ci-workflow.ts +4 -4
  162. package/src/workspace/delivery-metrics.ts +12 -3
  163. package/src/workspace/graph-contract.test.ts +3 -2
  164. package/src/workspace/member-commands.test.ts +11 -4
  165. package/src/workspace/member-commands.ts +3 -0
  166. package/src/workspace/nested-graph.test.ts +4 -0
  167. package/src/workspace/nested-graph.ts +1 -0
  168. package/src/workspace/release-candidates.schema.json +51 -0
  169. package/src/workspace/release-candidates.test.ts +30 -3
  170. package/src/workspace/release-candidates.ts +54 -2
  171. package/src/workspace/releases.schema.json +30 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * `chant components pr-apply --green` (#4168 step 9): apply on merge applies
3
+ * a green commit, under the approved plans of the merges since the last
4
+ * applied one when they cover it, and otherwise at a gate of its own on the
5
+ * combined digest. Git, the green target, the derivation and the gate ledger
6
+ * are fakes; the planning, the coverage and the apply are the real ones.
7
+ */
8
+
9
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
13
+ import type { DriverComponent } from "../../components/driver";
14
+ import type { DeployContext } from "../../components/capability";
15
+ import type { ChangeSetDocument } from "../../change-set";
16
+ import type { GreenApplyRecord } from "../../lifecycle/green-apply";
17
+ import type { GreenTargetResult } from "../../components/green-cover";
18
+ import type { ParsedArgs } from "../registry";
19
+
20
+ const HEAD = "a".repeat(40);
21
+ const BASE = "b".repeat(40);
22
+
23
+ vi.mock("../../workspace/security", async (original) => ({
24
+ ...(await original<typeof import("../../workspace/security")>()),
25
+ securityGroupsAt: () => new Set<string>(),
26
+ }));
27
+ vi.mock("../../components/changed-files", () => ({
28
+ resolveCommit: async () => HEAD,
29
+ resolveMergeBase: async () => BASE,
30
+ }));
31
+ vi.mock("../../config", async () => {
32
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
33
+ return { ...actual, loadChantConfig: async () => ({ config: {} }) };
34
+ });
35
+ vi.mock("./fan-out", () => ({
36
+ resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
37
+ }));
38
+ // Nothing here may write or push the real repository's chant/lifecycle.
39
+ vi.mock("../../lifecycle/gate-change-set", () => ({
40
+ storeGateChangeSet: async () => ({ written: false }),
41
+ readGraphIds: async () => ({ ids: [] }),
42
+ setGraphIdReader: () => {},
43
+ gateGraphReader: () => async () => ({ ids: [] }),
44
+ }));
45
+ vi.mock("../../lifecycle/git", async () => {
46
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
47
+ return { ...actual, pushLifecycleReport: async () => ({}) };
48
+ });
49
+ const applied: GreenApplyRecord[] = [];
50
+ vi.mock("../../lifecycle/green-apply", async () => {
51
+ const actual = await vi.importActual<typeof import("../../lifecycle/green-apply")>("../../lifecycle/green-apply");
52
+ return { ...actual, writeGreenApply: async (r: GreenApplyRecord) => (applied.push(r), "ledger-commit") };
53
+ });
54
+ vi.mock("./run", () => ({
55
+ GATED_EXIT_CODE: 3,
56
+ autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
57
+ recordAutoReleasesForRun: async () => [],
58
+ }));
59
+
60
+ let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
61
+ vi.mock("../../op/gate", async () => {
62
+ const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
63
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
64
+ });
65
+
66
+ let target: GreenTargetResult;
67
+ let asked: Array<{ base?: string }> = [];
68
+ vi.mock("../../components/green-cover", async () => {
69
+ const actual = await vi.importActual<typeof import("../../components/green-cover")>("../../components/green-cover");
70
+ return { ...actual, resolveGreenTarget: async (req: { base?: string }) => (asked.push(req), target) };
71
+ });
72
+
73
+ /** What each root's plan writes, so a test can move it. */
74
+ const values = new Map<string, string>();
75
+ const ran: string[] = [];
76
+ vi.mock("../../components/fan-out-support", async () => {
77
+ const { CapabilityRegistry } = await import("../../components/capability");
78
+ const { planFanOut } = await import("../../components/fan-out");
79
+ const { computePlanDigest } = await import("../../lifecycle/plan-digest");
80
+ const registry = new CapabilityRegistry();
81
+ registry.register({
82
+ kind: "fake-root",
83
+ async plan(_ctx: DeployContext, input: { root: string }) {
84
+ const value = values.get(input.root) ?? "1";
85
+ const planDigest = computePlanDigest("fake-root", { root: input.root, value });
86
+ return {
87
+ member: input.root,
88
+ planDigest,
89
+ artifact: { planDigest },
90
+ changeSet: {
91
+ member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
92
+ entries: [{ member: input.root, lexicon: "terraform", planner: "tofu", address: `${input.root}.main`, type: "fake", action: "update", attributes: [{ path: "value", after: value }] }],
93
+ },
94
+ };
95
+ },
96
+ async outputs() {
97
+ return {};
98
+ },
99
+ async run(_ctx: DeployContext, input: { root: string }) {
100
+ ran.push(input.root);
101
+ return { outputs: {} };
102
+ },
103
+ } as never);
104
+ return {
105
+ fanOutRegistry: async () => registry,
106
+ deriveFanOut: async () => ({
107
+ success: true,
108
+ components: ESTATE,
109
+ plan: planFanOut({ components: ESTATE, changed: ["net"] }),
110
+ signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
111
+ }),
112
+ };
113
+ });
114
+
115
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
116
+ name,
117
+ dependsOn,
118
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
119
+ });
120
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
121
+
122
+ const { memoryGateLedgerPort } = await import("../../op/gate");
123
+ const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
124
+ const { composeChangeSet } = await import("../../change-set");
125
+
126
+ let dir: string;
127
+ let errors: string[];
128
+
129
+ const ctx = (a: Partial<ParsedArgs>) =>
130
+ ({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, ...a } as ParsedArgs, plugins: [] }) as never;
131
+
132
+ beforeEach(async () => {
133
+ dir = await mkdtemp(join(tmpdir(), "pr-green-"));
134
+ errors = [];
135
+ applied.length = 0;
136
+ ran.length = 0;
137
+ values.clear();
138
+ asked = [];
139
+ gateLedger = memoryGateLedgerPort();
140
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
141
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
142
+ vi.spyOn(console, "log").mockImplementation(() => {});
143
+ });
144
+
145
+ afterEach(async () => {
146
+ vi.restoreAllMocks();
147
+ await rm(dir, { recursive: true, force: true });
148
+ });
149
+
150
+ /** The change set a pull request's plan made with the values set now. */
151
+ async function planned(): Promise<ChangeSetDocument> {
152
+ await runComponentsPrPlan(ctx({ pr: 12, path: "pr-plan" }));
153
+ return JSON.parse(await readFile(join(dir, "change-set.json"), "utf8")) as ChangeSetDocument;
154
+ }
155
+
156
+ const greenTarget = (approved: Array<{ doc: ChangeSetDocument; pr: number; approvedBy: string[] }>): GreenTargetResult => ({
157
+ status: "target",
158
+ target: {
159
+ commit: HEAD,
160
+ base: BASE,
161
+ baseFrom: "applied",
162
+ merges: [BASE.replace(/b/g, "c"), HEAD],
163
+ prs: approved.map((m) => m.pr),
164
+ approved: approved.map((m, i) => ({ commit: i === approved.length - 1 ? HEAD : BASE.replace(/b/g, "c"), ...m })),
165
+ },
166
+ });
167
+ const OP = `green-${HEAD.slice(0, 12)}`;
168
+
169
+ describe("pr-apply --green (#4168 step 9)", () => {
170
+ test("the merges' approved plans cover the plan at the green commit: it applies under them, with no gate, and records the commit applied", async () => {
171
+ const doc = await planned();
172
+ target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
173
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
174
+ expect(ran).toEqual(["net", "app"]);
175
+ expect(gateLedger.appended).toEqual([]);
176
+ expect(applied).toEqual([expect.objectContaining({ env: "prod", commit: HEAD, base: BASE, approval: "merges", approvedBy: ["github:alice"], digest: doc.digest })]);
177
+ const report = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
178
+ expect(report).toMatchObject({ op: OP, pr: null, status: "applied", approval: { status: "approved", approvedBy: ["github:alice"] } });
179
+ });
180
+
181
+ test("two merges, each approving one member, cover the plan together (the case one pull request's apply stopped at plan-changed)", async () => {
182
+ values.set("app", "1");
183
+ const first = await planned();
184
+ values.set("app", "2");
185
+ const both = await planned();
186
+ // #13 touched app alone, so its plan holds app alone.
187
+ const second = composeChangeSet([{ member: both.members.find((m) => m.member === "app")!, entries: both.entries.filter((e) => e.member === "app") }]);
188
+ // #12 approved net and app at 1; #13 approved app at 2; the green commit plans net at 1 and app at 2.
189
+ target = greenTarget([
190
+ { doc: first, pr: 12, approvedBy: ["github:alice"] },
191
+ { doc: second, pr: 13, approvedBy: ["github:bob"] },
192
+ ]);
193
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
194
+ expect(ran).toEqual(["net", "app"]);
195
+ expect(applied[0]).toMatchObject({ approval: "merges", approvedBy: ["github:alice", "github:bob"] });
196
+ });
197
+
198
+ test("a plan the merges did not approve waits at the green commit's own gate on the combined digest, then applies once that is approved", async () => {
199
+ const doc = await planned();
200
+ values.set("net", "9");
201
+ target = greenTarget([{ doc, pr: 12, approvedBy: ["github:alice"] }]);
202
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(3);
203
+ expect(ran).toEqual([]);
204
+ expect(applied).toEqual([]);
205
+ const refused = JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8"));
206
+ expect(refused).toMatchObject({ op: OP, status: "refused", refusal: "not-approved" });
207
+ expect(refused.message).toContain("do not cover it: net now writes net.main.value to a different value than the approved plan (checked against #12)");
208
+ expect(refused.message).toContain(`chant approve ${OP} pr-apply --plan ${refused.digest}`);
209
+
210
+ gateLedger = memoryGateLedgerPort({
211
+ resolutions: [{ version: 1, kind: "resolution", op: OP, gate: "pr-apply", resolvedBy: "github:carol", timestamp: new Date().toISOString(), planDigest: refused.digest }],
212
+ });
213
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
214
+ expect(ran).toEqual(["net", "app"]);
215
+ expect(applied[0]).toMatchObject({ approval: "gate", approvedBy: ["github:carol"], digest: refused.digest });
216
+ });
217
+
218
+ test("the green commit last applied is nothing to do, and --green takes no --pr", async () => {
219
+ target = { status: "applied", commit: HEAD };
220
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(0);
221
+ expect(ran).toEqual([]);
222
+ expect(errors.join("\n")).toContain("is the green commit last applied to prod");
223
+ expect(await runComponentsPrApply(ctx({ green: true, pr: 12 }))).toBe(1);
224
+ target = { status: "error", message: "abc is not a green commit", hint: "check it out" };
225
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(1);
226
+ expect(errors.join("\n")).toContain("abc is not a green commit");
227
+ // --base reaches the target as the first apply's fallback.
228
+ expect(asked.at(-1)).toMatchObject({ base: "main" });
229
+ });
230
+ });
@@ -116,6 +116,7 @@ import { formatError, formatInfo, formatSuccess, formatWarning } from "../format
116
116
  import { reportUninstalledDependencies } from "../dependencies";
117
117
  import { findWorkspaceRoot } from "../../project-root";
118
118
  import { resolveChangedUnits } from "./fan-out";
119
+ import { coverByMerges, greenOp, type GreenTarget } from "../../components/green-cover";
119
120
  import { GATED_EXIT_CODE, autoReleaseRun, recordAutoReleasesForRun } from "./run";
120
121
  import type { CommandContext } from "../registry";
121
122
  import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
@@ -494,18 +495,26 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
494
495
  if (args.requireReview && !args.forge) return fail("--require-review reads the pull request's reviews, so it needs --forge <kind>");
495
496
  if (args.wave !== undefined && !args.waveGate) return fail("--wave runs one wave of a --wave-gate apply, so it needs --wave-gate <name>");
496
497
  if (args.wave !== undefined && args.wave < 1) return fail(`--wave counts from 1, not ${args.wave}`);
498
+ // #4168 step 9: apply on merge applies a green commit, measured from the one applied before it.
499
+ let green: GreenTarget | undefined;
500
+ if (args.green) {
501
+ const target = await greenTargetOf(ctx);
502
+ if (typeof target === "number") return target;
503
+ green = target;
504
+ ctx = { ...ctx, args: { ...ctx.args, base: green.base, head: green.commit } };
505
+ }
497
506
  const p = await prepare(ctx, "apply");
498
507
  if (typeof p === "number") return p;
499
508
 
500
509
  let pr: number | null = args.pr ?? null;
501
- if (pr === null && p.forge && p.plan.order.length > 0) {
510
+ if (pr === null && !green && p.forge && p.plan.order.length > 0) {
502
511
  try {
503
512
  pr = await p.forge.pullRequestFor(p.head);
504
513
  } catch (err) {
505
514
  return fail(`could not ask ${p.forge.kind} which pull request merged ${p.head}: ${err instanceof Error ? err.message : String(err)}`);
506
515
  }
507
516
  }
508
- const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
517
+ const op = green ? greenOp(green.commit, p.member) : pr === null ? "pr-unknown" : prOp(pr, p.member);
509
518
  // The run id the release records carry, chosen before the run so that
510
519
  // `--events-json` (#4041, #4007) names the run by it too.
511
520
  const release = autoReleaseRun(`${op}-${Date.now()}`);
@@ -513,7 +522,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
513
522
  const events = await openCliRunEvents(args, { kind: "component", name: `pr-apply ${op}`, runId: release.runId, cwd: resolve(".") }, upload?.line);
514
523
  stage.stream = events;
515
524
  events?.emit({ type: "run-start", waves: p.plan.waves });
516
- if (args.waveGate) return prApplyWaves(ctx, p, { pr, op, release, upload, events });
525
+ if (args.waveGate) return prApplyWaves(ctx, p, { pr, op, release, upload, events, ...(green ? { green } : {}) });
517
526
 
518
527
  // `--resume` (#3464): an earlier attempt at this change on this commit
519
528
  // applied part of an approved set. When the members left are covered by
@@ -551,7 +560,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
551
560
  if (p.plan.order.length === 0) {
552
561
  return stop(prReport({ ...base, approval: { status: "pending" }, status: "nothing" }), 0);
553
562
  }
554
- if (pr === null) {
563
+ if (pr === null && !green) {
555
564
  return stop(
556
565
  prReport({
557
566
  ...base,
@@ -581,41 +590,17 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
581
590
  await storeChangeSetForGate(ctx, p, set.doc);
582
591
 
583
592
  let approval: PrApproval;
593
+ let via: "merges" | "gate" = "gate";
584
594
  if (resumed) {
585
595
  approval = resumed.approval;
586
596
  } else {
587
- const check = await emitGateStep(
588
- events?.emit,
589
- { op, gate: p.gate },
590
- () =>
591
- decidePrGate(gitGateLedgerPort(), {
592
- op,
593
- gate: p.gate,
594
- digest: set.doc.digest,
595
- description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
596
- }),
597
- (unapproved) => {
598
- const a = approvalOf(unapproved);
599
- return a.status === "changed"
600
- ? describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval: a })
601
- : `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}`;
602
- },
603
- );
604
- approval = approvalOf(check);
605
- if (!check.satisfied) {
606
- const partial = { ...base, approval, status: "refused" as const };
607
- const report = approval.status === "changed"
608
- ? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
609
- : prReport({
610
- ...partial,
611
- refusal: "not-approved",
612
- message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
613
- });
614
- return stop(report, GATED_EXIT_CODE);
615
- }
597
+ const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
598
+ approval = decided.approval;
599
+ if (!decided.satisfied) return stop(prReport({ ...base, approval, status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
600
+ via = decided.via;
616
601
  }
617
602
 
618
- const reviewOf = reviewCheck(args, p, pr);
603
+ const reviewOf = reviewFor(args, p, pr, green);
619
604
  const review = await reviewOf(approval.approvedBy ?? [], "The plan");
620
605
  if (review && "error" in review) return fail(review.error);
621
606
  if (review) {
@@ -666,7 +651,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
666
651
  ...(roundLedger.approvalRule ? { approvalRule: (g: string, at?: { base?: string }) => roundLedger.approvalRule!(g === gate ? p.gate : g, at) } : {}),
667
652
  };
668
653
  const check = await emitGateStep(events?.emit, { op, gate }, () =>
669
- decidePrGate(port, { op, gate, digest: planned.doc.digest, description: `pull request #${pr}, round ${round}: planned again after an upstream applied` }),
654
+ decidePrGate(port, { op, gate, digest: planned.doc.digest, description: `${changeLabel(pr, green)}, round ${round}: planned again after an upstream applied` }),
670
655
  );
671
656
  const roundApproval = approvalOf(check);
672
657
  if (!check.satisfied) return { satisfied: false, approval: roundApproval };
@@ -724,6 +709,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
724
709
  // a person. A step that refused its plan because the target moved since
725
710
  // the approval exits 3, as a moved digest does before anything applies (#3997).
726
711
  const exit = applied.status === "applied" ? 0 : applied.status === "waiting" ? GATED_EXIT_CODE : failedRunExitCode(applied.run.results);
712
+ if (green && applied.status === "applied") await recordGreenApplied(p, green, set.doc.digest, approval, via);
727
713
  const code = await stop(report, exit, applied.run.status === "gated" ? "gated" : undefined);
728
714
  if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
729
715
  return code;
@@ -846,6 +832,120 @@ async function prRecord(ctx: CommandContext, stage: StageEvents): Promise<number
846
832
  return stop(report, status === "plan-failed" ? 1 : 0);
847
833
  }
848
834
 
835
+ /** What a gate's description and messages call the change: the pull request, or the green commit (#4168 step 9). */
836
+ function changeLabel(pr: number | null, green: GreenTarget | undefined): string {
837
+ return green ? `green ${green.commit.slice(0, 12)}` : `pull request #${pr}`;
838
+ }
839
+
840
+ /**
841
+ * `pr-apply --green` (#4168 step 9): the green commit at HEAD, the last one
842
+ * applied to the environment, and the merges between with their approvals.
843
+ * Exits 0 when HEAD is the green commit last applied.
844
+ */
845
+ async function greenTargetOf(ctx: CommandContext): Promise<GreenTarget | number> {
846
+ const { args } = ctx;
847
+ if (args.pr !== undefined) return fail("--green applies a green commit and every merge since the last one applied, so it takes no --pr; the merge records name the pull requests");
848
+ const { resolveGreenTarget } = await import("../../components/green-cover");
849
+ const env = args.env ?? "local";
850
+ const resolved = await resolveGreenTarget({ cwd: resolve("."), env, ...(args.base ? { base: args.base } : {}), gate: args.gate ?? PR_APPLY_GATE });
851
+ if (resolved.status === "error") return fail(resolved.message, resolved.hint);
852
+ if (resolved.status === "applied") {
853
+ console.error(formatInfo(`${resolved.commit.slice(0, 12)} is the green commit last applied to ${env}, so there is nothing to apply`));
854
+ return 0;
855
+ }
856
+ const t = resolved.target;
857
+ const from = { applied: "the green commit last applied", flag: "--base", "previous-green": "the green commit before it", parent: "its parent" }[t.baseFrom];
858
+ console.error(formatInfo(`applying green ${t.commit.slice(0, 12)}, measured from ${t.base.slice(0, 12)} (${from}): ${t.merges.length} commit${t.merges.length === 1 ? "" : "s"}, ${t.approved.length} with an approved plan`));
859
+ return t;
860
+ }
861
+
862
+ /**
863
+ * The first plan's gate. For one pull request, its gate on the plan's digest.
864
+ * For a green commit (#4168 step 9), the approved plans of the merges since
865
+ * the last applied green commit when they cover every member, and otherwise
866
+ * a gate of the green commit's own on the combined digest.
867
+ */
868
+ async function decideApplyGate(
869
+ p: Prepared,
870
+ at: { op: string; pr: number | null; set: PrPlanSet; events?: RunEventStream; green?: GreenTarget },
871
+ ): Promise<{ approval: PrApproval; satisfied: true; via: "merges" | "gate" } | { approval: PrApproval; satisfied: false; refusal: "plan-changed" | "not-approved"; message: string }> {
872
+ const { op, pr, set, events, green } = at;
873
+ let uncovered: string[] = [];
874
+ if (green) {
875
+ const cover = coverByMerges(set.doc, green.approved);
876
+ if (cover.covered) {
877
+ console.error(formatInfo(`the approved plans of ${cover.coveredBy.length} merge${cover.coveredBy.length === 1 ? "" : "s"} cover the plan at ${green.commit.slice(0, 12)}, so it applies under them`));
878
+ return { approval: { status: "approved", approvedBy: cover.approvedBy }, satisfied: true, via: "merges" };
879
+ }
880
+ uncovered = cover.uncovered;
881
+ console.error(formatInfo(`the merged changes' approvals do not cover the plan at ${green.commit.slice(0, 12)}: ${uncovered.join("; ")}`));
882
+ }
883
+ const digest = set.doc.digest;
884
+ const check = await emitGateStep(
885
+ events?.emit,
886
+ { op, gate: p.gate },
887
+ () => decidePrGate(gitGateLedgerPort(), { op, gate: p.gate, digest, description: `${changeLabel(pr, green)}: ${p.plan.order.join(", ")}` }),
888
+ (unapproved) => {
889
+ const a = approvalOf(unapproved);
890
+ return a.status === "changed"
891
+ ? describePlanChanged({ op, gate: p.gate, digest, approval: a })
892
+ : `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest })}`;
893
+ },
894
+ );
895
+ const approval = approvalOf(check);
896
+ if (check.satisfied) return { approval, satisfied: true, via: "gate" };
897
+ if (approval.status === "changed") return { approval, satisfied: false, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest, approval }) };
898
+ const why = uncovered.length > 0 ? ` The approvals of the merges since the last applied green commit do not cover it: ${uncovered.join("; ")}.` : "";
899
+ return {
900
+ approval,
901
+ satisfied: false,
902
+ refusal: "not-approved",
903
+ message: `No approval stands for this plan, so nothing was applied.${why} Approve it: ${prApproveCommand({ op, gate: p.gate, digest })}; then run the apply again.`,
904
+ };
905
+ }
906
+
907
+ /**
908
+ * The `--require-review` check for the apply. A green commit (#4168 step 9)
909
+ * has no pull request of its own, so its approvers are checked against every
910
+ * pull request merged since the last applied green commit for authorship
911
+ * only: whoever wrote one of them never counts.
912
+ */
913
+ function reviewFor(args: CommandContext["args"], p: Prepared, pr: number | null, green: GreenTarget | undefined): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
914
+ if (!green) return reviewCheck(args, p, pr!);
915
+ const checks = green.prs.map((n) => reviewCheck(args, p, n, true));
916
+ return async (approvedBy, what) => {
917
+ for (const check of checks) {
918
+ const stop = await check(approvedBy, what);
919
+ if (stop) return stop;
920
+ }
921
+ return undefined;
922
+ };
923
+ }
924
+
925
+ /** Record the green commit as applied to the environment (#4168 step 9), and push `chant/lifecycle`. A failure warns. */
926
+ async function recordGreenApplied(p: Prepared, green: GreenTarget, digest: string, approval: PrApproval, via: "merges" | "gate"): Promise<void> {
927
+ const { writeGreenApply, GREEN_APPLY_CONTRACT } = await import("../../lifecycle/green-apply");
928
+ const { pushLifecycleReport } = await import("../../lifecycle/git");
929
+ try {
930
+ await writeGreenApply({
931
+ contract: GREEN_APPLY_CONTRACT,
932
+ env: p.env,
933
+ ...(p.member ? { member: p.member } : {}),
934
+ commit: green.commit,
935
+ base: green.base,
936
+ digest,
937
+ approval: via,
938
+ approvedBy: approval.approvedBy ?? [],
939
+ merges: green.merges,
940
+ appliedAt: new Date().toISOString(),
941
+ });
942
+ const pushed = await pushLifecycleReport();
943
+ if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
944
+ } catch (err) {
945
+ console.error(formatWarning({ message: `could not record ${green.commit.slice(0, 12)} as applied to ${p.env}: ${err instanceof Error ? err.message : String(err)}` }));
946
+ }
947
+ }
948
+
849
949
  /** How a stage stops: settle its events, write and publish the report, say how it ended, and return `code`. */
850
950
  function stopper(ctx: CommandContext, p: Prepared, events: RunEventStream | undefined, upload: PrReportUpload | undefined) {
851
951
  return async (report: PrReport, code: number, settle?: RunEventSettleStatus): Promise<number> => {
@@ -867,7 +967,7 @@ type ReviewStop = { refusal: "author-approved" | "not-a-reviewer"; message: stri
867
967
  * authors are read once, at the first call. Without `--require-review` every
868
968
  * approval counts.
869
969
  */
870
- function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
970
+ function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number, authorsOnly = false): (approvedBy: readonly string[], what: string) => Promise<ReviewStop> {
871
971
  let read: Promise<{ reviewers: string[]; authors: string[] }> | undefined;
872
972
  return async (approvedBy, what) => {
873
973
  const forge = p.forge;
@@ -895,7 +995,7 @@ function reviewCheck(args: CommandContext["args"], p: Prepared, pr: number): (ap
895
995
  `Have someone else approve the pull request and the plan, then run the apply again.`,
896
996
  };
897
997
  }
898
- const strangers = approversWithoutReview(approvedBy, reviewers);
998
+ const strangers = authorsOnly ? [] : approversWithoutReview(approvedBy, reviewers);
899
999
  if (strangers.length > 0) {
900
1000
  return {
901
1001
  refusal: "not-a-reviewer",
@@ -1127,10 +1227,10 @@ function inWave(body: RunEventBody, wave: number): RunEventBody {
1127
1227
  async function prApplyWaves(
1128
1228
  ctx: CommandContext,
1129
1229
  p: Prepared,
1130
- at: { pr: number | null; op: string; release: ReturnType<typeof autoReleaseRun>; upload?: PrReportUpload; events?: RunEventStream },
1230
+ at: { pr: number | null; op: string; release: ReturnType<typeof autoReleaseRun>; upload?: PrReportUpload; events?: RunEventStream; green?: GreenTarget },
1131
1231
  ): Promise<number> {
1132
1232
  const { args } = ctx;
1133
- const { pr, op, events, upload } = at;
1233
+ const { pr, op, events, upload, green } = at;
1134
1234
  const waveGate = args.waveGate!;
1135
1235
  const only = args.wave;
1136
1236
  const total = p.plan.waves.length;
@@ -1139,7 +1239,7 @@ async function prApplyWaves(
1139
1239
  log({ type: "run-start", waves: p.plan.waves });
1140
1240
  const reportBase = { stage: "apply" as const, pr, env: p.env, ...(p.member ? { member: p.member } : {}), base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, waveGate };
1141
1241
 
1142
- if (p.plan.order.length === 0 || pr === null) {
1242
+ if (p.plan.order.length === 0 || (pr === null && !green)) {
1143
1243
  const set = await planAll(p, events?.emit);
1144
1244
  if (p.plan.order.length === 0) return stop(prReport({ ...reportBase, set, approval: { status: "pending" }, status: "nothing" }), 0);
1145
1245
  return stop(
@@ -1181,6 +1281,7 @@ async function prApplyWaves(
1181
1281
  ? { doc: carry.record.changeSet, members: carry.record.members, plans: {}, outputs: carry.record.planOutputs, failed: false }
1182
1282
  : undefined;
1183
1283
  let approval: PrApproval = carry?.approval ?? { status: "pending" };
1284
+ let via: "merges" | "gate" = "gate";
1184
1285
  const report = (input: { status: PrReport["status"]; refusal?: PrReport["refusal"]; message?: string }) => {
1185
1286
  const set = approved ?? { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
1186
1287
  const members = set.members.map((m): PrMember => {
@@ -1224,35 +1325,15 @@ async function prApplyWaves(
1224
1325
  return stop(report({ status: "plan-failed", message: "A member failed to plan, so there is no plan to compare with the approved one. Nothing was applied." }), 1);
1225
1326
  }
1226
1327
  await storeChangeSetForGate(ctx, p, set.doc);
1227
- const check = await emitGateStep(
1228
- events?.emit,
1229
- { op, gate: p.gate },
1230
- () => decidePrGate(gitGateLedgerPort(), { op, gate: p.gate, digest: set.doc.digest, description: `pull request #${pr}: ${p.plan.order.join(", ")}` }),
1231
- (unapproved) => {
1232
- const a = approvalOf(unapproved);
1233
- return a.status === "changed"
1234
- ? describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval: a })
1235
- : `No approval stands for this plan. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}`;
1236
- },
1237
- );
1238
- approval = approvalOf(check);
1239
- if (!check.satisfied) {
1240
- return stop(
1241
- approval.status === "changed"
1242
- ? report({ status: "refused", refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
1243
- : report({
1244
- status: "refused",
1245
- refusal: "not-approved",
1246
- message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
1247
- }),
1248
- GATED_EXIT_CODE,
1249
- );
1250
- }
1328
+ const decided = await decideApplyGate(p, { op, pr, set, events, ...(green ? { green } : {}) });
1329
+ approval = decided.approval;
1330
+ if (!decided.satisfied) return stop(report({ status: "refused", refusal: decided.refusal, message: decided.message }), GATED_EXIT_CODE);
1331
+ via = decided.via;
1251
1332
  planned = set;
1252
1333
  }
1253
1334
  const approvedSet = approved!;
1254
1335
 
1255
- const review = reviewCheck(args, p, pr);
1336
+ const review = reviewFor(args, p, pr, green);
1256
1337
  const prReview = await review(approval.approvedBy ?? [], "The plan");
1257
1338
  if (prReview && "error" in prReview) return fail(prReview.error);
1258
1339
  if (prReview) {
@@ -1336,7 +1417,7 @@ async function prApplyWaves(
1336
1417
  const check = await emitGateStep(
1337
1418
  events?.emit,
1338
1419
  { op, gate },
1339
- () => decidePrGate(gitGateLedgerPort(), { op, gate, digest, description: `pull request #${pr}, wave ${k} of ${total}: ${wavePlan.order.join(", ")}` }),
1420
+ () => decidePrGate(gitGateLedgerPort(), { op, gate, digest, description: `${changeLabel(pr, green)}, wave ${k} of ${total}: ${wavePlan.order.join(", ")}` }),
1340
1421
  (unapproved) =>
1341
1422
  unapproved.mismatch
1342
1423
  ? `wave ${k}'s plan changed after its approval. Approve the plan now: ${approveLine}`
@@ -1412,6 +1493,7 @@ async function prApplyWaves(
1412
1493
  }
1413
1494
 
1414
1495
  const done = p.plan.order.every((name) => completed.has(name));
1496
+ if (green && done) await recordGreenApplied(p, green, approvedSet.doc.digest, approval, via);
1415
1497
  return stop(report({ status: done ? "applied" : "wave-applied" }), 0);
1416
1498
  }
1417
1499
 
@@ -11,6 +11,7 @@ import type { ReleaseRecord } from "../../lifecycle/release-ledger";
11
11
  import type { ParsedArgs } from "../registry";
12
12
  import { makeRelease } from "../../lifecycle/release-store";
13
13
  import { contentDigest } from "../../content-digest";
14
+ import { releaseTemplatesDigest } from "../../components/release-promote";
14
15
 
15
16
  const appendReleaseRecordMock = vi.fn();
16
17
  const readReleaseLedgerMock = vi.fn();
@@ -245,6 +246,8 @@ beforeEach(() => {
245
246
 
246
247
  afterEach(() => {
247
248
  vi.restoreAllMocks();
249
+ // Promote, rollback and redeploy set CHANT_ENV to the environment they deploy (#4222).
250
+ delete process.env.CHANT_ENV;
248
251
  });
249
252
 
250
253
  describe("chant components promote", () => {
@@ -475,6 +478,37 @@ describe("chant components promote --release (#4026)", () => {
475
478
  expect(ran).toEqual([]);
476
479
  });
477
480
 
481
+ test("#4222: a component with no publish step deploys the release's template and records its templates digest", async () => {
482
+ const templateOnly: DriverComponent = { ...api, deploy: [{ phase: "Apply", steps: [{ kind: "apply", template: "dist/api.template.json" }] }] };
483
+ resolveTargetsMock.mockResolvedValue({ success: true, targets: [templateOnly] });
484
+ expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(0);
485
+ expect(ran).toEqual(["prod:apply"]);
486
+ const prodTarget = release.targets.find((t) => t.env === "prod")!;
487
+ expect(appendReleaseRecordMock.mock.calls[0][0]).toMatchObject({
488
+ env: "prod",
489
+ digest: releaseTemplatesDigest(prodTarget, "api"),
490
+ releaseDigest: release.releaseDigest,
491
+ });
492
+ });
493
+
494
+ test("#4222: without --release a component with no publish step is still refused", async () => {
495
+ const templateOnly: DriverComponent = { ...api, deploy: [{ phase: "Apply", steps: [{ kind: "apply", template: "dist/api.template.json" }] }] };
496
+ resolveTargetsMock.mockResolvedValue({ success: true, targets: [templateOnly] });
497
+ expect(await runComponentsPromote(ctx({}))).toBe(1);
498
+ expect(errors.join("\n")).toMatch(/no publish step/);
499
+ expect(ran).toEqual([]);
500
+ });
501
+
502
+ test("#4222: the components load with CHANT_ENV set to --to", async () => {
503
+ let seen: string | undefined;
504
+ resolveTargetsMock.mockImplementation(async () => {
505
+ seen = process.env.CHANT_ENV;
506
+ return { success: true, targets: [withTemplate] };
507
+ });
508
+ expect(await runComponentsPromote(ctx({ release: "v1.0.0" }))).toBe(0);
509
+ expect(seen).toBe("prod");
510
+ });
511
+
478
512
  test("a promote without --release reads the checkout's template as before", async () => {
479
513
  expect(await runComponentsPromote(ctx({}))).toBe(0);
480
514
  expect(stepInputs.find((s) => s.kind === "apply")?.input).toMatchObject({ template: "dist/api.template.json" });