@intentius/chant 0.70.0 → 0.71.0
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.
- package/dist/cli/handlers/fan-out.d.ts +45 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +35 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +23 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +12 -2
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +70 -0
- package/dist/components/fan-out-output.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +80 -0
- package/dist/components/fan-out-run.d.ts.map +1 -0
- package/dist/components/fan-out-support.d.ts +65 -0
- package/dist/components/fan-out-support.d.ts.map +1 -0
- package/dist/components/fan-out.d.ts +194 -0
- package/dist/components/fan-out.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +96 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +22 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts +26 -0
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +16 -1
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +31 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +19 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +21 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +10 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +25 -8
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/fan-out.test.ts +394 -0
- package/src/cli/handlers/fan-out.ts +336 -0
- package/src/cli/handlers/lifecycle.test.ts +74 -1
- package/src/cli/handlers/lifecycle.ts +29 -1
- package/src/cli/handlers/operator.test.ts +22 -0
- package/src/cli/handlers/operator.ts +11 -3
- package/src/cli/handlers/run.ts +7 -1
- package/src/cli/main.ts +25 -3
- package/src/cli/registry.ts +23 -0
- package/src/components/deploy-units.ts +14 -4
- package/src/components/fan-out-output.test.ts +216 -0
- package/src/components/fan-out-output.ts +162 -0
- package/src/components/fan-out-run.test.ts +194 -0
- package/src/components/fan-out-run.ts +221 -0
- package/src/components/fan-out-support.test.ts +125 -0
- package/src/components/fan-out-support.ts +95 -0
- package/src/components/fan-out.test.ts +284 -0
- package/src/components/fan-out.ts +421 -0
- package/src/components/index.ts +33 -0
- package/src/discovery/fold-import.ts +195 -5
- package/src/discovery/index.ts +13 -5
- package/src/fold/subset-public-export.test.ts +31 -0
- package/src/fold/subset.ts +23 -0
- package/src/index.ts +18 -0
- package/src/lifecycle/affected.test.ts +118 -0
- package/src/lifecycle/affected.ts +118 -14
- package/src/op/activities/activity-contracts.ts +17 -1
- package/src/op/activities/index.ts +1 -1
- package/src/op/activities/shell.test.ts +156 -0
- package/src/op/activities/shell.ts +84 -9
- package/src/op/activity-profiles.test.ts +16 -2
- package/src/op/activity-profiles.ts +18 -0
- package/src/op/builders.ts +22 -4
- package/src/op/gate-name.ts +11 -0
- package/src/op/op-ir.test.ts +4 -1
- package/src/op/op.test.ts +7 -2
- package/src/op/step-output-ref.ts +29 -8
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components fan-out` (#2420) — run a change out across the components
|
|
3
|
+
* downstream of it, in an order chant derives from the source.
|
|
4
|
+
*
|
|
5
|
+
* #2417 built the four pieces this command joins (`componentsForUnits`,
|
|
6
|
+
* `planFanOut`, `remainingFanOut`, `runFanOut`, all in ../../components/) and
|
|
7
|
+
* left them reachable only from their own tests. `chant lifecycle affected`
|
|
8
|
+
* produces the stack-level change signal at one end; `runFanOut` consumes a
|
|
9
|
+
* plan at the other; this is the line between them.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a sibling command rather than a third `--components` selector
|
|
12
|
+
*
|
|
13
|
+
* `chant run --components all` stops the whole run at the first failed
|
|
14
|
+
* component, and it should: there the user asked for everything, so a failure
|
|
15
|
+
* means the estate is in a state nobody described. A fan-out deliberately does
|
|
16
|
+
* the opposite, skipping the failure's own subtree and letting independent
|
|
17
|
+
* branches finish (#2419 added a second runner for exactly that reason). Two
|
|
18
|
+
* opposite failure semantics behind one flag's third value would be a trap. The
|
|
19
|
+
* flags differ too: a fan-out is parameterised by a change signal, which
|
|
20
|
+
* `chant run` has no business growing a `--base` for.
|
|
21
|
+
*
|
|
22
|
+
* ## The loop
|
|
23
|
+
*
|
|
24
|
+
* ```
|
|
25
|
+
* chant components fan-out --base main --env prod --dry-run
|
|
26
|
+
* chant components fan-out --base main --env prod --gate release --resume .chant/fan-out.json
|
|
27
|
+
* chant approve fan-out release --plan sha256:...
|
|
28
|
+
* chant components fan-out --base main --env prod --gate release --resume .chant/fan-out.json
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* The last two lines are the same command twice, which is the point: an attempt
|
|
32
|
+
* that stopped is finished by repeating it, not by hand-picking what is left.
|
|
33
|
+
* `--resume` carries the earlier attempt's progress, `remainingFanOut` narrows
|
|
34
|
+
* the plan before the gate is decided, and the digest is carried rather than
|
|
35
|
+
* recomputed, so the approval still stands.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { resolve, dirname } from "node:path";
|
|
39
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
40
|
+
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
41
|
+
import { affectedStacks } from "../../lifecycle/affected";
|
|
42
|
+
import { deriveFanOut, fanOutRegistry } from "../../components/fan-out-support";
|
|
43
|
+
import { runFanOut } from "../../components/fan-out-run";
|
|
44
|
+
import { renderFanOutHuman, renderFanOutJson, renderFanOutPlan } from "../../components/fan-out-output";
|
|
45
|
+
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
46
|
+
import { writeGatedRunSummary } from "../../op/gate-summary";
|
|
47
|
+
import { remainingFanOut, type ChangedUnits, type FanOutProgress } from "../../components/fan-out";
|
|
48
|
+
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
49
|
+
import { formatError, formatInfo, formatWarning } from "../format";
|
|
50
|
+
import { FAN_OUT_GATE_OP } from "../../op/gate-name";
|
|
51
|
+
import { resolveBuildRoot } from "./lifecycle";
|
|
52
|
+
import { GATED_EXIT_CODE, recordAutoReleasesForRun } from "./run";
|
|
53
|
+
import type { CommandContext } from "../registry";
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What an attempt left behind, and what the next one reads (`--resume`).
|
|
59
|
+
*
|
|
60
|
+
* The digest is stored alongside the progress because progress is only
|
|
61
|
+
* meaningful for the plan it was made against. A fan-out derived from different
|
|
62
|
+
* source is a different fan-out, and carrying "cluster-a already applied" into
|
|
63
|
+
* it would be a claim about work nobody did.
|
|
64
|
+
*/
|
|
65
|
+
interface FanOutAttempt {
|
|
66
|
+
digest: string;
|
|
67
|
+
completed: string[];
|
|
68
|
+
failed: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readAttempt(path: string): FanOutAttempt | undefined {
|
|
72
|
+
if (!existsSync(path)) return undefined;
|
|
73
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<FanOutAttempt>;
|
|
74
|
+
return {
|
|
75
|
+
digest: typeof parsed.digest === "string" ? parsed.digest : "",
|
|
76
|
+
completed: Array.isArray(parsed.completed) ? parsed.completed.filter((n): n is string => typeof n === "string") : [],
|
|
77
|
+
failed: Array.isArray(parsed.failed) ? parsed.failed.filter((n): n is string => typeof n === "string") : [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeAttempt(path: string, attempt: FanOutAttempt): void {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
writeFileSync(path, JSON.stringify(attempt, null, 2) + "\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The stack-level change signal, from whichever end the invocation supplied.
|
|
88
|
+
*
|
|
89
|
+
* `--base` re-derives it here, which is the one-command shape a developer
|
|
90
|
+
* wants. `--from-affected` reads what `chant lifecycle affected --json` already
|
|
91
|
+
* wrote, which is the shape a CI job wants when an earlier step has run the
|
|
92
|
+
* diff and there is no reason to build twice. Exactly one, because defaulting
|
|
93
|
+
* either way would let a stale file silently beat a fresh `--base` or the other
|
|
94
|
+
* way round.
|
|
95
|
+
*
|
|
96
|
+
* `dependents` is folded into `changed` when it is present. It is only present
|
|
97
|
+
* when somebody asked for it (`--include-dependents`), and a stack that
|
|
98
|
+
* consumes a changed export is affected at stack granularity in the same way a
|
|
99
|
+
* changed component is at component granularity.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveChangedUnits(
|
|
102
|
+
ctx: CommandContext,
|
|
103
|
+
config: ChantConfig,
|
|
104
|
+
): Promise<{ units: ChangedUnits } | { error: string; hint?: string }> {
|
|
105
|
+
const { args } = ctx;
|
|
106
|
+
const fromFile = args.fromAffected;
|
|
107
|
+
if (args.base && fromFile) {
|
|
108
|
+
return {
|
|
109
|
+
error: "--base and --from-affected both name a change signal",
|
|
110
|
+
hint: "Pass --base <ref> to derive it here, or --from-affected <file> to read one `chant lifecycle affected --json` already wrote.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (fromFile) {
|
|
115
|
+
let parsed: { changed?: unknown; dependents?: unknown; indeterminate?: unknown };
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(readFileSync(resolve(fromFile), "utf8"));
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { error: `--from-affected: could not read "${fromFile}": ${err instanceof Error ? err.message : String(err)}` };
|
|
120
|
+
}
|
|
121
|
+
const names = (value: unknown): string[] =>
|
|
122
|
+
Array.isArray(value) ? value.filter((n): n is string => typeof n === "string") : [];
|
|
123
|
+
if (!Array.isArray(parsed.changed)) {
|
|
124
|
+
return {
|
|
125
|
+
error: `--from-affected: "${fromFile}" has no "changed" array`,
|
|
126
|
+
hint: "It should be the output of `chant lifecycle affected --base <ref> --json`.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
units: {
|
|
131
|
+
changed: [...new Set([...names(parsed.changed), ...names(parsed.dependents)])].sort(),
|
|
132
|
+
indeterminate: names(parsed.indeterminate),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!args.base) {
|
|
138
|
+
return {
|
|
139
|
+
error: "A change signal is required: chant components fan-out --base <ref> [--head <ref>]",
|
|
140
|
+
hint: "Or pass --from-affected <file> with the JSON `chant lifecycle affected --base <ref> --json` wrote.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await affectedStacks({
|
|
146
|
+
// Components are discovered from the current directory, the convention
|
|
147
|
+
// every component command follows. In a multi-stack project the diff
|
|
148
|
+
// reads the same root, because `stacks[].src` is written relative to it.
|
|
149
|
+
projectPath: config.stacks?.length ? resolve(".") : resolveBuildRoot(args, config),
|
|
150
|
+
serializers: ctx.plugins.map((p) => p.serializer),
|
|
151
|
+
baseRef: args.base,
|
|
152
|
+
headRef: args.head,
|
|
153
|
+
includeDependents: args.includeDependents,
|
|
154
|
+
// A project that declares its stacks gets an answer keyed by stack name,
|
|
155
|
+
// which is the name a component's deploy step uses. Without this the diff
|
|
156
|
+
// answers by lexicon partition and the join below claims none of it.
|
|
157
|
+
...(config.stacks ? { stacks: config.stacks } : {}),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
units: {
|
|
161
|
+
changed: [...new Set([...result.changed, ...result.dependents])].sort(),
|
|
162
|
+
indeterminate: result.indeterminate,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* chant components fan-out --base <ref> | --from-affected <file>
|
|
172
|
+
* [--head <ref>] [--include-dependents] [--env <env>] [--gate <name>]
|
|
173
|
+
* [--dry-run] [--json] [--resume <file>] [--seed-outputs <file>]
|
|
174
|
+
* [--dump-outputs <file>] [--progress-json]
|
|
175
|
+
*/
|
|
176
|
+
export async function runComponentsFanOut(ctx: CommandContext): Promise<number> {
|
|
177
|
+
const { args } = ctx;
|
|
178
|
+
const projectPath = resolve(".");
|
|
179
|
+
const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
|
|
180
|
+
|
|
181
|
+
const paramsResolution = resolveCliBuildParams(config.buildParams, {
|
|
182
|
+
cli: parseParamFlags(args.param),
|
|
183
|
+
paramsFile: args.paramsFile,
|
|
184
|
+
verbose: args.verbose,
|
|
185
|
+
});
|
|
186
|
+
if (!paramsResolution.success) {
|
|
187
|
+
for (const message of paramsResolution.errors) console.error(message);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const signal = await resolveChangedUnits(ctx, config);
|
|
192
|
+
if ("error" in signal) {
|
|
193
|
+
console.error(formatError({ message: signal.error, ...(signal.hint ? { hint: signal.hint } : {}) }));
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let derived;
|
|
198
|
+
try {
|
|
199
|
+
derived = await deriveFanOut({
|
|
200
|
+
path: projectPath,
|
|
201
|
+
units: signal.units,
|
|
202
|
+
sandbox: args.sandbox,
|
|
203
|
+
buildParams: paramsResolution.provenance,
|
|
204
|
+
config,
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// A cycle or an unknown `dependsOn` refuses here, over the whole graph and
|
|
208
|
+
// before anything is selected (#2417). The message names the members.
|
|
209
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
if (!derived.success) {
|
|
213
|
+
console.error(formatError({ message: derived.error ?? "Could not discover components" }));
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A changed stack no component deploys is a hole in this fan-out's coverage.
|
|
218
|
+
// Reporting it is the whole reason `componentsForUnits` returns it.
|
|
219
|
+
if (derived.signal.unclaimed.length > 0) {
|
|
220
|
+
console.error(formatWarning({
|
|
221
|
+
message: `no component deploys these changed unit(s): ${derived.signal.unclaimed.join(", ")}`,
|
|
222
|
+
hint: "They are outside this fan-out. Deploy them another way, or give a component a step that names them.",
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const gate = args.gate ? { op: FAN_OUT_GATE_OP, gate: args.gate } : undefined;
|
|
227
|
+
|
|
228
|
+
// `--resume`: what an earlier attempt at this same plan finished. Read before
|
|
229
|
+
// the dry-run branch so a plan-only invocation shows what is actually left
|
|
230
|
+
// rather than what the fan-out looked like the first time. Narrowing for the
|
|
231
|
+
// real run happens inside `runFanOut`, before the gate is decided, so an
|
|
232
|
+
// approval survives the resume rather than being re-asked for.
|
|
233
|
+
const resumePath = args.resume ? resolve(args.resume) : undefined;
|
|
234
|
+
let priorCompleted: string[] = [];
|
|
235
|
+
let progress: FanOutProgress | undefined;
|
|
236
|
+
if (resumePath) {
|
|
237
|
+
let attempt: FanOutAttempt | undefined;
|
|
238
|
+
try {
|
|
239
|
+
attempt = readAttempt(resumePath);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(formatError({ message: `--resume: could not read "${args.resume}": ${err instanceof Error ? err.message : String(err)}` }));
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
if (attempt && attempt.digest !== derived.plan.digest) {
|
|
245
|
+
console.error(formatWarning({
|
|
246
|
+
message: `--resume: "${args.resume}" records a different fan-out (${attempt.digest || "no digest"}), so its progress does not apply here`,
|
|
247
|
+
hint: "The derivation changed, which makes this a different change. Everything selected will run.",
|
|
248
|
+
}));
|
|
249
|
+
} else if (attempt) {
|
|
250
|
+
// Only `completed` is carried forward. The earlier attempt's failures are
|
|
251
|
+
// recorded in the file for whoever has to read it, but feeding them back
|
|
252
|
+
// as progress would block their subtrees again on the very run that
|
|
253
|
+
// exists to retry them — the operator repeated the command precisely
|
|
254
|
+
// because the thing that failed is now expected to work.
|
|
255
|
+
priorCompleted = attempt.completed;
|
|
256
|
+
progress = { completed: attempt.completed };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (args.dryRun) {
|
|
261
|
+
// `remainingFanOut` carries the digest rather than minting a new one, so
|
|
262
|
+
// the digest printed here is the same string that approves the real run.
|
|
263
|
+
const plan = progress ? remainingFanOut(derived.plan, derived.components, progress) : derived.plan;
|
|
264
|
+
if (args.json) renderFanOutJson(plan);
|
|
265
|
+
else renderFanOutPlan(plan, { ...(gate ? { gate } : {}) });
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const seededOutputs: Record<string, Record<string, unknown>> = {};
|
|
270
|
+
for (const file of args.seedOutputs ?? []) {
|
|
271
|
+
try {
|
|
272
|
+
Object.assign(seededOutputs, JSON.parse(readFileSync(resolve(file), "utf8")));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(formatError({ message: `--seed-outputs: could not read "${file}": ${err instanceof Error ? err.message : String(err)}` }));
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const registry = await fanOutRegistry(projectPath, config);
|
|
280
|
+
const result = await runFanOut(derived.plan, derived.components, registry, {
|
|
281
|
+
env: args.env ?? "local",
|
|
282
|
+
componentOutputs: seededOutputs,
|
|
283
|
+
...(gate ? { gate } : {}),
|
|
284
|
+
...(progress ? { progress } : {}),
|
|
285
|
+
...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (args.dumpOutputs) {
|
|
289
|
+
const dumpPath = resolve(args.dumpOutputs);
|
|
290
|
+
mkdirSync(dirname(dumpPath), { recursive: true });
|
|
291
|
+
writeFileSync(dumpPath, JSON.stringify(result.componentOutputs, null, 2));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Written even on a failure and even on a gate: the next attempt needs to
|
|
295
|
+
// know what this one got through, and a gated attempt got through nothing,
|
|
296
|
+
// which is itself worth recording against this plan's digest.
|
|
297
|
+
if (resumePath) {
|
|
298
|
+
writeAttempt(resumePath, {
|
|
299
|
+
digest: result.plan.digest,
|
|
300
|
+
completed: [...new Set([...priorCompleted, ...result.completed])].sort(),
|
|
301
|
+
failed: result.failed,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (args.json) renderFanOutJson(result);
|
|
306
|
+
else renderFanOutHuman(result, { ...(gate ? { gate } : {}) });
|
|
307
|
+
|
|
308
|
+
// The same durable trace `chant run --components` leaves (#597), for the
|
|
309
|
+
// components this fan-out actually applied. A partial fan-out records the
|
|
310
|
+
// branches that finished and nothing else, which is the whole reason the
|
|
311
|
+
// runner reports `completed` separately from `failed`.
|
|
312
|
+
if (result.status !== "gated") {
|
|
313
|
+
await recordAutoReleasesForRun(
|
|
314
|
+
result.results,
|
|
315
|
+
args.env ?? "local",
|
|
316
|
+
`fan-out-${Date.now()}`,
|
|
317
|
+
resolveAutoReleaseDisabled(config, args.noReleaseRecord),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (result.status === "gated" && result.gate) {
|
|
322
|
+
const pending = result.gate;
|
|
323
|
+
console.error(formatInfo(`approve : chant approve ${pending.op} ${pending.gate} --plan ${result.plan.digest}`));
|
|
324
|
+
writeGatedRunSummary({
|
|
325
|
+
op: pending.op,
|
|
326
|
+
gate: pending.gate,
|
|
327
|
+
...(pending.description ? { description: pending.description } : {}),
|
|
328
|
+
expiresAt: pending.expiresAt,
|
|
329
|
+
...(pending.url ? { url: pending.url } : {}),
|
|
330
|
+
...(pending.planDigest ? { planDigest: pending.planDigest } : {}),
|
|
331
|
+
});
|
|
332
|
+
return GATED_EXIT_CODE;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return result.status === "ok" ? 0 : 1;
|
|
336
|
+
}
|
|
@@ -29,6 +29,7 @@ const loadChantConfigMock = vi.fn();
|
|
|
29
29
|
const pushLifecycleMock = vi.fn();
|
|
30
30
|
const readBlobFromPathMock = vi.fn();
|
|
31
31
|
const writeBlobToPathMock = vi.fn();
|
|
32
|
+
const affectedStacksMock = vi.fn();
|
|
32
33
|
|
|
33
34
|
vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
|
|
34
35
|
vi.mock("../../lifecycle/git", () => ({
|
|
@@ -54,7 +55,11 @@ vi.mock("../../config", async () => {
|
|
|
54
55
|
};
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
vi.mock("../../lifecycle/affected", () => ({
|
|
59
|
+
affectedStacks: (...args: unknown[]) => affectedStacksMock(...args),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown, runLifecycleAffected } = await import("./lifecycle");
|
|
58
63
|
|
|
59
64
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
60
65
|
return {
|
|
@@ -2107,3 +2112,71 @@ describe("runLifecycleWhoami (#1982)", () => {
|
|
|
2107
2112
|
}
|
|
2108
2113
|
});
|
|
2109
2114
|
});
|
|
2115
|
+
|
|
2116
|
+
describe("runLifecycleAffected (#2420)", () => {
|
|
2117
|
+
let stdoutBuf: string[];
|
|
2118
|
+
let stderrBuf: string[];
|
|
2119
|
+
|
|
2120
|
+
beforeEach(() => {
|
|
2121
|
+
stdoutBuf = [];
|
|
2122
|
+
stderrBuf = [];
|
|
2123
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
|
|
2124
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
|
|
2125
|
+
loadChantConfigMock.mockReset();
|
|
2126
|
+
affectedStacksMock.mockReset();
|
|
2127
|
+
affectedStacksMock.mockResolvedValue({ changed: ["api-stack"], dependents: [], indeterminate: [] });
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
afterEach(() => vi.restoreAllMocks());
|
|
2131
|
+
|
|
2132
|
+
const multiStack = [
|
|
2133
|
+
{ name: "api-stack", src: "stacks/api" },
|
|
2134
|
+
{ name: "worker-stack", src: "stacks/worker" },
|
|
2135
|
+
];
|
|
2136
|
+
|
|
2137
|
+
function affectedCtx(overrides: Partial<ParsedArgs> = {}) {
|
|
2138
|
+
return {
|
|
2139
|
+
args: makeArgs({ command: "lifecycle", path: "affected", base: "main", ...overrides }),
|
|
2140
|
+
plugins: [], serializers: [],
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
test("passes config.stacks through, so the answer is keyed by stack name", async () => {
|
|
2145
|
+
loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
|
|
2146
|
+
expect(await runLifecycleAffected(affectedCtx())).toBe(0);
|
|
2147
|
+
expect(affectedStacksMock).toHaveBeenCalledWith(expect.objectContaining({ stacks: multiStack }));
|
|
2148
|
+
expect(stdoutBuf.join("\n")).toContain("api-stack");
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
test("a project with no stacks declared passes none — same single-root build", async () => {
|
|
2152
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
2153
|
+
expect(await runLifecycleAffected(affectedCtx())).toBe(0);
|
|
2154
|
+
expect(affectedStacksMock.mock.calls[0][0].stacks).toBeUndefined();
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
test("multi-stack + --include-dependents says where the downstream relation lives", async () => {
|
|
2158
|
+
loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
|
|
2159
|
+
expect(await runLifecycleAffected(affectedCtx({ includeDependents: true }))).toBe(0);
|
|
2160
|
+
const stderr = stderrBuf.join("\n");
|
|
2161
|
+
expect(stderr).toContain("dependsOn");
|
|
2162
|
+
expect(stderr).toContain("chant components fan-out");
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
test("no note for a single-root project, even with --include-dependents", async () => {
|
|
2166
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
2167
|
+
expect(await runLifecycleAffected(affectedCtx({ includeDependents: true }))).toBe(0);
|
|
2168
|
+
expect(stderrBuf.join("\n")).not.toContain("chant components fan-out");
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
test("no note for a multi-stack project when dependents were not asked for", async () => {
|
|
2172
|
+
loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
|
|
2173
|
+
expect(await runLifecycleAffected(affectedCtx())).toBe(0);
|
|
2174
|
+
expect(stderrBuf.join("\n")).not.toContain("chant components fan-out");
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
test("--base is required", async () => {
|
|
2178
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
2179
|
+
expect(await runLifecycleAffected(affectedCtx({ base: undefined }))).toBe(1);
|
|
2180
|
+
expect(affectedStacksMock).not.toHaveBeenCalled();
|
|
2181
|
+
});
|
|
2182
|
+
});
|
|
@@ -1871,6 +1871,19 @@ function printSnapshotTable(snapshot: LifecycleSnapshot): void {
|
|
|
1871
1871
|
* Read-only: report which stacks a change affects (directly-changed via artifact
|
|
1872
1872
|
* diff, dependents via the cross-stack graph, external-input as indeterminate).
|
|
1873
1873
|
* Returns the set; fanning plan/apply over it is an Op the user composes.
|
|
1874
|
+
*
|
|
1875
|
+
* `config.stacks` is passed through, so on a multi-stack project the answer is
|
|
1876
|
+
* keyed by deployed **stack name** — the name a component's `cfn-deploy` step
|
|
1877
|
+
* uses — instead of by lexicon partition. That is what lets `chant components
|
|
1878
|
+
* fan-out` join a change set to the components that deploy it; an aws-only
|
|
1879
|
+
* estate of fifteen stacks used to answer "aws" no matter which one moved. A
|
|
1880
|
+
* project with no `stacks` declared is untouched: one build of the source root,
|
|
1881
|
+
* one lexicon-keyed answer.
|
|
1882
|
+
*
|
|
1883
|
+
* The cost of per-stack mode is that `dependents` comes back empty (the build
|
|
1884
|
+
* carries cross-lexicon edges, not stack ones), so `--include-dependents` prints
|
|
1885
|
+
* a note saying where the downstream relation actually lives rather than showing
|
|
1886
|
+
* an empty list as if it meant "nothing downstream".
|
|
1874
1887
|
*/
|
|
1875
1888
|
export async function runLifecycleAffected(ctx: CommandContext): Promise<number> {
|
|
1876
1889
|
const { args, plugins } = ctx;
|
|
@@ -1882,13 +1895,17 @@ export async function runLifecycleAffected(ctx: CommandContext): Promise<number>
|
|
|
1882
1895
|
}
|
|
1883
1896
|
|
|
1884
1897
|
const { config } = await loadChantConfig(resolve("."));
|
|
1885
|
-
|
|
1898
|
+
// A multi-stack project's `stacks[].src` entries are written relative to the
|
|
1899
|
+
// project root, and they are themselves the scoping, so `--src`/`sourceDir`
|
|
1900
|
+
// does not narrow the root a second time (see `AffectedStacksOptions.stacks`).
|
|
1901
|
+
const projectPath = config.stacks?.length ? resolve(".") : resolveBuildRoot(args, config);
|
|
1886
1902
|
|
|
1887
1903
|
let result;
|
|
1888
1904
|
try {
|
|
1889
1905
|
result = await affectedStacks({
|
|
1890
1906
|
projectPath,
|
|
1891
1907
|
serializers: plugins.map((p) => p.serializer),
|
|
1908
|
+
stacks: config.stacks,
|
|
1892
1909
|
baseRef: args.base,
|
|
1893
1910
|
headRef: args.head,
|
|
1894
1911
|
includeDependents: args.includeDependents,
|
|
@@ -1898,6 +1915,17 @@ export async function runLifecycleAffected(ctx: CommandContext): Promise<number>
|
|
|
1898
1915
|
return 1;
|
|
1899
1916
|
}
|
|
1900
1917
|
|
|
1918
|
+
// Per-stack mode has no stack graph to walk, so an empty `dependents` here
|
|
1919
|
+
// means "not computed", not "nothing downstream". Say so on stderr — silently
|
|
1920
|
+
// handing back an empty list is the failure this command exists to avoid.
|
|
1921
|
+
const multiStack = (config.stacks?.length ?? 0) > 0;
|
|
1922
|
+
if (multiStack && args.includeDependents) {
|
|
1923
|
+
console.error(formatWarning({
|
|
1924
|
+
message: "Dependents are not computed for a multi-stack project: the downstream relation between deployed stacks is stated by the components' dependsOn, not by the build.",
|
|
1925
|
+
hint: "Run chant components fan-out to walk it.",
|
|
1926
|
+
}));
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1901
1929
|
if (args.json) {
|
|
1902
1930
|
console.log(JSON.stringify(result, null, 2));
|
|
1903
1931
|
return 0;
|
|
@@ -513,6 +513,28 @@ describe("runApprove", () => {
|
|
|
513
513
|
errSpy.mockRestore();
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
test("the fan-out op is not reported as a missing *.op.ts, and the hint names the right command (#2420)", async () => {
|
|
517
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
518
|
+
seedPending("fan-out", "release", PLAN_A);
|
|
519
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
520
|
+
commit: "sha",
|
|
521
|
+
record: { version: 1, op: "fan-out", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
|
|
522
|
+
});
|
|
523
|
+
const lines: string[] = [];
|
|
524
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
|
|
525
|
+
lines.push(a.map(String).join(" "));
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const code = await runApprove(ctx({ path: "fan-out", extraPositional: "release", actor: "alex" }));
|
|
529
|
+
|
|
530
|
+
expect(code).toBe(0);
|
|
531
|
+
const out = lines.join("\n");
|
|
532
|
+
expect(out).not.toContain("was not found among discovered");
|
|
533
|
+
expect(out).toContain("Repeat the `chant components fan-out` command");
|
|
534
|
+
expect(out).not.toContain("chant run fan-out");
|
|
535
|
+
errSpy.mockRestore();
|
|
536
|
+
});
|
|
537
|
+
|
|
516
538
|
test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
|
|
517
539
|
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
518
540
|
seedPending("unknown-op", "g", PLAN_A);
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
resolveApprovalUrl, isApprovalUrl,
|
|
36
36
|
} from "../../lifecycle/gate-ledger";
|
|
37
37
|
import { isPlanDigest } from "../../lifecycle/plan-digest";
|
|
38
|
+
import { FAN_OUT_GATE_OP } from "../../op/gate-name";
|
|
38
39
|
import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
|
|
39
40
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
40
41
|
import type { CommandContext } from "../registry";
|
|
@@ -677,7 +678,9 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
677
678
|
(pushed ? "" : " (local only — the push did not land)"),
|
|
678
679
|
));
|
|
679
680
|
console.error(formatInfo(
|
|
680
|
-
|
|
681
|
+
opName === FAN_OUT_GATE_OP
|
|
682
|
+
? `The next \`chant components fan-out\` decides this gate from scratch and records a fresh pending fact.`
|
|
683
|
+
: `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
|
|
681
684
|
));
|
|
682
685
|
return 0;
|
|
683
686
|
}
|
|
@@ -696,7 +699,9 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
696
699
|
|
|
697
700
|
console.error(formatInfo(
|
|
698
701
|
`This records the resolution as a fact; it does not itself re-run anything. ` +
|
|
699
|
-
|
|
702
|
+
(opName === FAN_OUT_GATE_OP
|
|
703
|
+
? `Repeat the \`chant components fan-out\` command and it walks through gate "${gate}".`
|
|
704
|
+
: `Run \`chant run ${opName}\` and it walks through gate "${gate}".`),
|
|
700
705
|
));
|
|
701
706
|
return 0;
|
|
702
707
|
}
|
|
@@ -742,7 +747,10 @@ export async function recordGateApproval(
|
|
|
742
747
|
opts: GateApprovalOptions,
|
|
743
748
|
): Promise<GateApprovalOutcome> {
|
|
744
749
|
const { ops } = await discoverOps();
|
|
745
|
-
|
|
750
|
+
// `fan-out` is the op name every `chant components fan-out` gate is recorded
|
|
751
|
+
// under (../handlers/fan-out.ts). It is a command rather than a declaration,
|
|
752
|
+
// so there is no `*.op.ts` to find and nothing is wrong when none is there.
|
|
753
|
+
if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP) {
|
|
746
754
|
console.error(formatWarning({
|
|
747
755
|
message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
|
|
748
756
|
}));
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -570,8 +570,14 @@ export async function runOp(ctx: CommandContext): Promise<number> {
|
|
|
570
570
|
* failure (`reason: "error"`) is surfaced, as a warning — never a nonzero
|
|
571
571
|
* exit, since the deploy itself already succeeded and a ledger-write hiccup
|
|
572
572
|
* must not retroactively fail it.
|
|
573
|
+
*
|
|
574
|
+
* Exported for `chant components fan-out` (#2420), which applies components
|
|
575
|
+
* through a different runner and would otherwise leave no trace in the release
|
|
576
|
+
* ledger for work `chant run --components` records. Each result is filtered on
|
|
577
|
+
* its own `ok`, so a fan-out that partly succeeded records exactly the
|
|
578
|
+
* components that did.
|
|
573
579
|
*/
|
|
574
|
-
async function recordAutoReleasesForRun(
|
|
580
|
+
export async function recordAutoReleasesForRun(
|
|
575
581
|
results: DriverComponentResult[],
|
|
576
582
|
env: string,
|
|
577
583
|
runId: string,
|
package/src/cli/main.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { runCarveApply } from "./handlers/carve-apply";
|
|
|
26
26
|
import { runCarveStatus } from "./handlers/carve-status";
|
|
27
27
|
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
28
28
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
|
|
29
|
+
import { runComponentsFanOut } from "./handlers/fan-out";
|
|
29
30
|
import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
|
|
30
31
|
import { runGraph } from "./handlers/graph";
|
|
31
32
|
import { runExplain } from "./handlers/explain";
|
|
@@ -301,6 +302,12 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
301
302
|
result.head = args[++i];
|
|
302
303
|
} else if (arg === "--include-dependents") {
|
|
303
304
|
result.includeDependents = true;
|
|
305
|
+
} else if (arg === "--from-affected") {
|
|
306
|
+
result.fromAffected = args[++i];
|
|
307
|
+
} else if (arg === "--gate") {
|
|
308
|
+
result.gate = args[++i];
|
|
309
|
+
} else if (arg === "--resume") {
|
|
310
|
+
result.resume = args[++i];
|
|
304
311
|
} else if (arg === "--local") {
|
|
305
312
|
result.local = true;
|
|
306
313
|
} else if (arg === "--json") {
|
|
@@ -621,6 +628,13 @@ Component release ledger + status:
|
|
|
621
628
|
--json: stable machine-readable contract;
|
|
622
629
|
--compare-to <env>: cross-check the same
|
|
623
630
|
component's recorded digest against another env)
|
|
631
|
+
components fan-out Run a change out across the components downstream
|
|
632
|
+
of it, in an order derived from the source
|
|
633
|
+
(--base <ref> [--head <ref>] [--include-dependents],
|
|
634
|
+
or --from-affected <file>; --dry-run prints the
|
|
635
|
+
derivation and dispatches nothing; --gate <name>
|
|
636
|
+
puts one approval over the whole set; --resume
|
|
637
|
+
<file> finishes an attempt that stopped)
|
|
624
638
|
components release <env> Append one immutable release record
|
|
625
639
|
(--component <name> --digest <sha256:...>
|
|
626
640
|
[--git-sha <sha>] [--run-id <id>] [--actor <name>])
|
|
@@ -736,7 +750,8 @@ Options:
|
|
|
736
750
|
project source; network egress is NOT blocked (see
|
|
737
751
|
docs). Default: off (also settable via
|
|
738
752
|
chant.config.ts's build.sandbox: true; #1045)
|
|
739
|
-
--param <name=value> (build, graph, run --components
|
|
753
|
+
--param <name=value> (build, graph, run --components, components fan-out)
|
|
754
|
+
Bind a declared
|
|
740
755
|
build-time parameter (chant.config.ts's buildParams)
|
|
741
756
|
to a value, for source to read as params.<name>
|
|
742
757
|
(#1064) instead of process.env — repeatable.
|
|
@@ -744,7 +759,8 @@ Options:
|
|
|
744
759
|
Parameter(): this resolves before synthesis, so it
|
|
745
760
|
can change which resources are produced at all.
|
|
746
761
|
Highest precedence.
|
|
747
|
-
--params-file <path> (build, graph, run --components
|
|
762
|
+
--params-file <path> (build, graph, run --components, components fan-out)
|
|
763
|
+
JSON file of
|
|
748
764
|
{ "name": value } build-time parameter values
|
|
749
765
|
(#1064). Second precedence, after --param.
|
|
750
766
|
|
|
@@ -967,6 +983,7 @@ export const commandRegistry: CommandDef[] = [
|
|
|
967
983
|
{ name: "scenario check", requiresPlugins: true, handler: runScenarioCheck },
|
|
968
984
|
|
|
969
985
|
// Component release ledger + status surface (#568, epic #551)
|
|
986
|
+
{ name: "components fan-out", requiresPlugins: true, handler: runComponentsFanOut },
|
|
970
987
|
{ name: "components status", requiresPlugins: true, handler: runComponentsStatus },
|
|
971
988
|
{ name: "components release", handler: runComponentsReleaseRecord },
|
|
972
989
|
{ name: "components export", handler: runComponentsExport },
|
|
@@ -1102,12 +1119,17 @@ async function main(): Promise<void> {
|
|
|
1102
1119
|
// missing plugins there is "no live evidence" (a warning), not a hard exit.
|
|
1103
1120
|
const isGenerateComponents = match.def.name === "build" && args.components && !!args.generate;
|
|
1104
1121
|
const isComponentsStatus = match.def.name === "components status";
|
|
1122
|
+
// `components fan-out` (#2420) needs serializers only when it derives the
|
|
1123
|
+
// change signal itself (`--base` builds both refs). Reading one somebody
|
|
1124
|
+
// else already produced (`--from-affected`) touches no lexicon at all, so a
|
|
1125
|
+
// components-only project is not turned away for a step it will not run.
|
|
1126
|
+
const isFanOutFromFile = match.def.name === "components fan-out" && !args.base;
|
|
1105
1127
|
// `emulator` (#920) is a property of the *configured* lexicons, not of any infra
|
|
1106
1128
|
// file — a fresh/local project with no declarables still boots Floci. Load from
|
|
1107
1129
|
// chant.config best-effort, like components status, rather than detectLexicon.
|
|
1108
1130
|
const isEmulator = match.def.name === "emulator" || match.def.name.startsWith("emulator ");
|
|
1109
1131
|
const plugins = match.def.requiresPlugins
|
|
1110
|
-
? isGenerateComponents || isComponentsStatus || isEmulator
|
|
1132
|
+
? isGenerateComponents || isComponentsStatus || isEmulator || isFanOutFromFile
|
|
1111
1133
|
? await loadPlugins(await resolveProjectLexicons(resolve(projectPath)).catch(() => [])).catch(() => [])
|
|
1112
1134
|
: await loadPluginsOrExit(projectPath)
|
|
1113
1135
|
: [];
|