@intentius/chant 0.124.29 → 0.124.30
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/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +4 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -5
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +30 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +49 -0
- package/dist/cli/registry.d.ts +17 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +24 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +16 -0
- package/dist/components/pr-run.d.ts +20 -2
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +26 -13
- package/dist/components/wave-plan.d.ts +11 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +26 -0
- package/dist/lifecycle/release-ledger.d.ts +8 -3
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +2 -0
- package/dist/lifecycle/run-ledger.d.ts +1 -1
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +9 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +1 -0
- package/dist/op/activities/shell.d.ts +6 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +1 -1
- package/dist/op/local-executor.d.ts +23 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/runtime.d.ts +27 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtime.js +20 -0
- package/dist/pr-forge.d.ts +23 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +44 -0
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/run-event-stream.d.ts +2 -2
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +2 -2
- package/dist/run-event.d.ts +15 -0
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +171 -171
- package/dist/workspace/ci-green-forge.d.ts +2 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +2 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +4 -2
- package/dist/workspace/ci-run.d.ts +222 -0
- package/dist/workspace/ci-run.d.ts.map +1 -0
- package/dist/workspace/ci-run.js +464 -0
- package/dist/workspace/ci-serve.d.ts +214 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -0
- package/dist/workspace/ci-serve.js +412 -0
- package/dist/workspace/pr-report.schema.json +5 -0
- package/dist/workspace/run-event.schema.json +14 -3
- package/dist/workspace/status-stewards.d.ts +2 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +1 -0
- package/dist/workspace/status.schema.json +12 -1
- package/package.json +1 -1
- package/src/cli/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +4 -1
- package/src/cli/handlers/fan-out.test.ts +82 -0
- package/src/cli/handlers/fan-out.ts +34 -5
- package/src/cli/handlers/pr.ts +5 -0
- package/src/cli/main.ts +30 -0
- package/src/cli/parse-args.ts +35 -0
- package/src/cli/registry.ts +17 -1
- package/src/components/pr-pipeline.ts +32 -0
- package/src/components/pr-run.test.ts +127 -0
- package/src/components/pr-run.ts +32 -12
- package/src/components/wave-plan.ts +30 -0
- package/src/lifecycle/release-ledger.ts +9 -3
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/shell.ts +7 -1
- package/src/op/local-executor.ts +27 -4
- package/src/op/runtime.ts +42 -0
- package/src/pr-forge.test.ts +25 -0
- package/src/pr-forge.ts +62 -0
- package/src/pr-loop.test.ts +13 -1
- package/src/pr-loop.ts +16 -0
- package/src/run-event-stream.ts +4 -3
- package/src/run-event.ts +17 -0
- package/src/workspace/ci-green-forge.ts +2 -0
- package/src/workspace/ci-green.ts +6 -2
- package/src/workspace/ci-run.test.ts +416 -0
- package/src/workspace/ci-run.ts +571 -0
- package/src/workspace/ci-serve.test.ts +268 -0
- package/src/workspace/ci-serve.ts +521 -0
- package/src/workspace/pr-report.schema.json +5 -0
- package/src/workspace/run-event.schema.json +14 -3
- package/src/workspace/status-stewards.ts +3 -0
- package/src/workspace/status.schema.json +12 -1
|
@@ -84,6 +84,13 @@ let inputs: Record<string, Record<string, unknown>> = {};
|
|
|
84
84
|
let stepOutputs: Record<string, Record<string, unknown>> = {};
|
|
85
85
|
/** Called as a component's step starts, before it applies. */
|
|
86
86
|
let onStart: (component: string) => void = () => {};
|
|
87
|
+
/**
|
|
88
|
+
* What each stack exposes in the deployed estate, read through the step's
|
|
89
|
+
* `outputs` as cfn-deploy's `describeStack` does (#4104). `undefined` leaves
|
|
90
|
+
* the capability with no `outputs`; an `Error` makes the read fail.
|
|
91
|
+
*/
|
|
92
|
+
let liveOutputs: Record<string, Record<string, unknown> | Error> | undefined;
|
|
93
|
+
let outputsRead: string[] = [];
|
|
87
94
|
|
|
88
95
|
function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
|
|
89
96
|
return {
|
|
@@ -114,6 +121,8 @@ beforeEach(() => {
|
|
|
114
121
|
inputs = {};
|
|
115
122
|
stepOutputs = {};
|
|
116
123
|
onStart = () => {};
|
|
124
|
+
liveOutputs = undefined;
|
|
125
|
+
outputsRead = [];
|
|
117
126
|
gateLedger = memoryGateLedgerPort();
|
|
118
127
|
stderrLines = [];
|
|
119
128
|
stdoutLines = [];
|
|
@@ -149,6 +158,16 @@ beforeEach(() => {
|
|
|
149
158
|
if (failing.includes(runCtx.component)) throw new Error(`${runCtx.component} failed`);
|
|
150
159
|
return { ok: true, ...(stepOutputs[runCtx.component] ? { outputs: stepOutputs[runCtx.component] } : {}) };
|
|
151
160
|
},
|
|
161
|
+
...(liveOutputs
|
|
162
|
+
? {
|
|
163
|
+
async outputs(readCtx: { component: string }) {
|
|
164
|
+
outputsRead.push(readCtx.component);
|
|
165
|
+
const live = liveOutputs![readCtx.component];
|
|
166
|
+
if (live instanceof Error) throw live;
|
|
167
|
+
return { ...(live ?? {}) };
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
152
171
|
} as never);
|
|
153
172
|
return Promise.resolve(registry);
|
|
154
173
|
});
|
|
@@ -849,3 +868,66 @@ describe("progress carries only for an unchanged input (#4081)", () => {
|
|
|
849
868
|
expect(stderr()).toContain("does not record what each component applied, so none of its progress carries");
|
|
850
869
|
});
|
|
851
870
|
});
|
|
871
|
+
|
|
872
|
+
describe("a seed's outputs, read from the deployed estate (#4104)", () => {
|
|
873
|
+
/** cluster-a reads net's TopicArn, and the change touches cluster-a alone. */
|
|
874
|
+
function wireClusterA(): void {
|
|
875
|
+
const wired: Component = {
|
|
876
|
+
name: "cluster-a",
|
|
877
|
+
dependsOn: ["net"],
|
|
878
|
+
deploy: [
|
|
879
|
+
{
|
|
880
|
+
phase: "Apply",
|
|
881
|
+
steps: [{ kind: "cfn-deploy", stack: "cluster-a-stack", inputs: { topic: { stackOutput: { stack: "net", name: "TopicArn" } } } }],
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
const estate = ESTATE.map((c) => (c.name === "cluster-a" ? wired : c));
|
|
886
|
+
discoverComponentsMock.mockResolvedValue({
|
|
887
|
+
components: new Map(estate.map((c) => [c.name, { component: c, exportName: c.name, filePath: `${c.name}.component.ts` }])),
|
|
888
|
+
sourceFiles: [],
|
|
889
|
+
errors: [],
|
|
890
|
+
});
|
|
891
|
+
affectedStacksMock.mockResolvedValue({ changed: ["cluster-a-stack"], dependents: [], indeterminate: [] });
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
test("a cluster-a-only change runs cluster-a with net's deployed outputs, and says it read them", async () => {
|
|
895
|
+
wireClusterA();
|
|
896
|
+
liveOutputs = { net: { TopicArn: "arn:aws:sns:us-east-1:000000000000:net", QueueUrl: "http://q" } };
|
|
897
|
+
expect(await runComponentsFanOut(ctx({ base: "main", env: "test" }))).toBe(0);
|
|
898
|
+
expect(ran).not.toContain("net");
|
|
899
|
+
expect(outputsRead).toEqual(["net"]);
|
|
900
|
+
expect(inputs["cluster-a"]).toEqual(expect.objectContaining({ inputs: { topic: "arn:aws:sns:us-east-1:000000000000:net" } }));
|
|
901
|
+
expect(stderr()).toContain("net: outputs read from the deployed estate (QueueUrl, TopicArn)");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("--seed-outputs wins: a seed it gives is not read", async () => {
|
|
905
|
+
wireClusterA();
|
|
906
|
+
liveOutputs = { net: { TopicArn: "arn:deployed" } };
|
|
907
|
+
const dir = await mkdtemp(join(tmpdir(), "fan-out-"));
|
|
908
|
+
try {
|
|
909
|
+
const seed = join(dir, "seed.json");
|
|
910
|
+
await writeFile(seed, JSON.stringify({ net: { TopicArn: "arn:seeded" } }));
|
|
911
|
+
expect(await runComponentsFanOut(ctx({ base: "main", env: "test", seedOutputs: [seed] }))).toBe(0);
|
|
912
|
+
expect(outputsRead).toEqual([]);
|
|
913
|
+
expect(inputs["cluster-a"]).toEqual(expect.objectContaining({ inputs: { topic: "arn:seeded" } }));
|
|
914
|
+
} finally {
|
|
915
|
+
await rm(dir, { recursive: true, force: true });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("a read that fails stops the fan-out before anything runs, naming the seed", async () => {
|
|
920
|
+
wireClusterA();
|
|
921
|
+
liveOutputs = { net: new Error("ExpiredToken") };
|
|
922
|
+
expect(await runComponentsFanOut(ctx({ base: "main", env: "test" }))).toBe(1);
|
|
923
|
+
expect(ran).toEqual([]);
|
|
924
|
+
expect(stderr()).toContain("could not read the deployed outputs of net: ExpiredToken");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("--dry-run reads nothing", async () => {
|
|
928
|
+
wireClusterA();
|
|
929
|
+
liveOutputs = { net: { TopicArn: "arn:deployed" } };
|
|
930
|
+
expect(await runComponentsFanOut(ctx({ base: "main", env: "test", dryRun: true }))).toBe(0);
|
|
931
|
+
expect(outputsRead).toEqual([]);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
@@ -67,6 +67,8 @@ import { summaryLedgerPrefix, writeGatedRunSummary } from "../../op/gate-summary
|
|
|
67
67
|
import { approveCommand, gateIsSealed } from "../../op/gate";
|
|
68
68
|
import { getHeadCommit } from "../../lifecycle/git";
|
|
69
69
|
import { remainingFanOut, type ChangedUnits, type FanOutProgress } from "../../components/fan-out";
|
|
70
|
+
import { readDeployedOutputs } from "../../components/wave-plan";
|
|
71
|
+
import { deployContext } from "../../components/driver";
|
|
70
72
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
71
73
|
import { formatError, formatInfo, formatWarning } from "../format";
|
|
72
74
|
import { FAN_OUT_GATE_OP } from "../../op/gate-name";
|
|
@@ -343,6 +345,36 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
|
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
const registry = await fanOutRegistry(projectPath, config);
|
|
348
|
+
const env = args.env ?? "local";
|
|
349
|
+
|
|
350
|
+
// A completed component's recorded outputs win over a `--seed-outputs`
|
|
351
|
+
// entry for the same name, as a fresh apply's outputs merge over a seed in
|
|
352
|
+
// the driver: the record holds what this fan-out itself applied. A seed
|
|
353
|
+
// neither gives is read from the deployed estate (#4104), through its
|
|
354
|
+
// capability's `outputs`, as pr-plan reads a dependency outside the change.
|
|
355
|
+
// One that cannot be read leaves its dependents' references unresolved.
|
|
356
|
+
const componentOutputs: Record<string, Record<string, unknown>> = { ...seededOutputs, ...priorOutputs };
|
|
357
|
+
const seeds = (progress ? remainingFanOut(derived.plan, derived.components, progress) : derived.plan).seeds;
|
|
358
|
+
let deployedSeeds: Record<string, string[]>;
|
|
359
|
+
try {
|
|
360
|
+
deployedSeeds = await readDeployedOutputs(
|
|
361
|
+
seeds,
|
|
362
|
+
new Map(derived.components.map((c) => [c.name, c])),
|
|
363
|
+
registry,
|
|
364
|
+
(name) => deployContext({ env }, name),
|
|
365
|
+
componentOutputs,
|
|
366
|
+
);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
console.error(formatError({
|
|
369
|
+
message: `could not read the deployed outputs of ${seeds.filter((s) => !(s in componentOutputs)).join(", ")}: ${err instanceof Error ? err.message : String(err)}`,
|
|
370
|
+
hint: "Nothing ran. --seed-outputs <file> gives a component's outputs instead of reading them.",
|
|
371
|
+
}));
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
for (const [name, keys] of Object.entries(deployedSeeds)) {
|
|
375
|
+
console.error(formatInfo(`${name}: outputs read from the deployed estate (${keys.length > 0 ? keys.join(", ") : "none yet"})`));
|
|
376
|
+
}
|
|
377
|
+
|
|
346
378
|
// #3061: the commit this run deploys, for the workloads' vcs.ref.head.revision.
|
|
347
379
|
const revision = await getHeadCommit().catch(() => undefined);
|
|
348
380
|
|
|
@@ -378,12 +410,9 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
|
|
|
378
410
|
let result;
|
|
379
411
|
try {
|
|
380
412
|
result = await runFanOut(derived.plan, derived.components, registry, {
|
|
381
|
-
env
|
|
413
|
+
env,
|
|
382
414
|
releaseIdentity: () => (revision ? { revision } : {}),
|
|
383
|
-
|
|
384
|
-
// entry for the same name, as a fresh apply's outputs merge over a seed in
|
|
385
|
-
// the driver: the record holds what this fan-out itself applied.
|
|
386
|
-
componentOutputs: { ...seededOutputs, ...priorOutputs },
|
|
415
|
+
componentOutputs,
|
|
387
416
|
...(gate ? { gate } : {}),
|
|
388
417
|
...(waveGate ? { waveGate } : {}),
|
|
389
418
|
...(waveGate
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -112,6 +112,11 @@ interface Prepared {
|
|
|
112
112
|
head: string;
|
|
113
113
|
gate: string;
|
|
114
114
|
forge?: PrForge;
|
|
115
|
+
/**
|
|
116
|
+
* Every component in the project, not the selection (`deriveFanOut`), so
|
|
117
|
+
* the plan reads the deployed outputs of a dependency the change did not
|
|
118
|
+
* select (#4104).
|
|
119
|
+
*/
|
|
115
120
|
components: DriverComponent[];
|
|
116
121
|
plan: FanOutPlan;
|
|
117
122
|
signal: ComponentChangeSignal;
|
package/src/cli/main.ts
CHANGED
|
@@ -249,6 +249,33 @@ CI:
|
|
|
249
249
|
chant/lifecycle that a runner key signed. Pushes the
|
|
250
250
|
tags. --key, or the PEM in CHANT_RUNNER_KEY, signs
|
|
251
251
|
each tag with a runner key.
|
|
252
|
+
ci run plan|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>]
|
|
253
|
+
[--forge github|gitlab|forgejo] [--gate <name>] [--branch <name>]
|
|
254
|
+
[--events-json] [--report-url <url>] [--image <image>]
|
|
255
|
+
[--before-script <line>]... [--pr-build]
|
|
256
|
+
[--pr-build-command <line>] [--chant <command>]
|
|
257
|
+
[--pass-env <NAME>]... [--key <runner key.pem>] [-o <dir>]
|
|
258
|
+
Run the pull-request pipeline's plan or apply job as
|
|
259
|
+
a pipeline Op in a fresh worktree of --commit (default
|
|
260
|
+
HEAD) with a clean environment. --base and --pr stand
|
|
261
|
+
in for the forge's event values. --pr-build runs
|
|
262
|
+
the job's build and lint lines first. Records the run as
|
|
263
|
+
pr-plan or pr-apply on chant/lifecycle, signed with
|
|
264
|
+
--key or CHANT_RUNNER_KEY, so ci tick from runs counts
|
|
265
|
+
it, and copies the report to -o (default .chant/pr).
|
|
266
|
+
ci serve --forge forgejo|github --repo <owner/repo> --env <env>
|
|
267
|
+
[--interval <s>] [--max-jobs <n>] [--state <dir>]
|
|
268
|
+
[--author <login>]... [--once] [--rerun-failed]
|
|
269
|
+
[ci run's job flags]
|
|
270
|
+
Poll the forge for open pull requests and the target
|
|
271
|
+
branch's head, and run ci run plan for each new pull
|
|
272
|
+
request head and ci run apply for each new commit on
|
|
273
|
+
the branch. Dedupes on job and commit in a state file
|
|
274
|
+
under --state, takes a lease per apply group, keeps
|
|
275
|
+
each apply's resume record there, and runs at most
|
|
276
|
+
--max-jobs (default 1) jobs at once. --once runs one
|
|
277
|
+
poll and waits for its jobs. --rerun-failed runs a
|
|
278
|
+
failed apply or plan again.
|
|
252
279
|
ci workflow [--workflow <name>]... [--output <file>] Write
|
|
253
280
|
[--chant <command>] [--install <command>]
|
|
254
281
|
[--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
|
|
@@ -1306,6 +1333,9 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1306
1333
|
{ name: "ci list", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiList(ctx) },
|
|
1307
1334
|
{ name: "ci tick", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiTick(ctx) },
|
|
1308
1335
|
{ name: "ci workflow", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-green-cli")).runCiWorkflow(ctx) },
|
|
1336
|
+
// #4094: one pull-request job as a pipeline Op in a fresh checkout, the chant runner's first slice.
|
|
1337
|
+
{ name: "ci run", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-run")).runCiRun(ctx) },
|
|
1338
|
+
{ name: "ci serve", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ci-serve")).runCiServe(ctx) },
|
|
1309
1339
|
// #4026: the release cut from a green commit (ws-104). Imported on first use.
|
|
1310
1340
|
{ name: "release cut", runsNoConfig: true, handler: async (ctx) => (await import("./handlers/release")).runReleaseCut(ctx) },
|
|
1311
1341
|
// #4027: publish a cut release, to the targets #4047 declares.
|
package/src/cli/parse-args.ts
CHANGED
|
@@ -42,6 +42,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
42
42
|
"--composites",
|
|
43
43
|
"--stacks",
|
|
44
44
|
"--components",
|
|
45
|
+
"--pr-build",
|
|
45
46
|
"--up",
|
|
46
47
|
"--down",
|
|
47
48
|
"--include-dependents",
|
|
@@ -64,6 +65,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
64
65
|
"--yes",
|
|
65
66
|
"--confirm-prod",
|
|
66
67
|
"--once",
|
|
68
|
+
"--rerun-failed",
|
|
67
69
|
"--expire",
|
|
68
70
|
"--check-live",
|
|
69
71
|
"--check-snapshot",
|
|
@@ -394,6 +396,13 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
394
396
|
result.requireProvenance = true;
|
|
395
397
|
} else if (arg === "--pr-loop") {
|
|
396
398
|
result.prLoop = true;
|
|
399
|
+
} else if (arg === "--pr-build") {
|
|
400
|
+
// `chant build --pr-loop --pr-build` (#4088), and `chant ci run --pr-build` (#4094).
|
|
401
|
+
result.prBuild = true;
|
|
402
|
+
} else if (arg === "--pr-build-command") {
|
|
403
|
+
const line = args[++i];
|
|
404
|
+
if (line === undefined || line === "") throw new Error("--pr-build-command needs a shell line: --pr-build-command '<line>'");
|
|
405
|
+
result.prBuildCommand = line;
|
|
397
406
|
} else if (arg === "--before-script") {
|
|
398
407
|
const line = args[++i];
|
|
399
408
|
if (line === undefined || line === "") throw new Error("--before-script needs a shell line: --before-script '<line>'");
|
|
@@ -518,6 +527,32 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
518
527
|
// `chant ci workflow --chant <command>` (#3573): what the tick step runs chant with.
|
|
519
528
|
result.ciChant = args[++i];
|
|
520
529
|
if (!result.ciChant || result.ciChant.startsWith("-")) throw new Error("--chant needs the command that runs chant, such as \"npx tsx packages/core/src/cli/main.ts\": --chant <command>");
|
|
530
|
+
} else if (arg === "--commit") {
|
|
531
|
+
// `chant ci run plan|apply --commit <sha>` (#4094): the commit the job runs at.
|
|
532
|
+
result.commit = args[++i];
|
|
533
|
+
if (!result.commit || result.commit.startsWith("-")) throw new Error("--commit needs the commit the job runs at: --commit <sha>");
|
|
534
|
+
} else if (arg === "--pass-env") {
|
|
535
|
+
// `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets.
|
|
536
|
+
const name = args[++i];
|
|
537
|
+
if (!name || name.startsWith("-")) throw new Error("--pass-env needs a variable's name, such as AWS_PROFILE: --pass-env <NAME>");
|
|
538
|
+
(result.passEnv ??= []).push(name);
|
|
539
|
+
} else if (arg === "--repo") {
|
|
540
|
+
// `chant ci serve --repo <owner/repo>` (#4095): the repository whose pull requests and target branch it polls.
|
|
541
|
+
result.ciRepo = args[++i];
|
|
542
|
+
if (!result.ciRepo || !/^[^/\s-][^/\s]*\/[^/\s]+$/.test(result.ciRepo)) throw new Error("--repo needs the repository as owner/name: --repo <owner/repo>");
|
|
543
|
+
} else if (arg === "--max-jobs") {
|
|
544
|
+
// `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once.
|
|
545
|
+
const n = Number(args[++i]);
|
|
546
|
+
if (!Number.isInteger(n) || n < 1) throw new Error("--max-jobs needs a whole number of jobs, 1 or more: --max-jobs <n>");
|
|
547
|
+
result.maxJobs = n;
|
|
548
|
+
} else if (arg === "--author") {
|
|
549
|
+
// `chant ci serve --author <login>`, repeatable (#4095): plan only pull requests these people opened.
|
|
550
|
+
const login = args[++i];
|
|
551
|
+
if (!login || login.startsWith("-")) throw new Error("--author needs a forge login: --author <login>");
|
|
552
|
+
(result.ciAuthors ??= []).push(login);
|
|
553
|
+
} else if (arg === "--rerun-failed") {
|
|
554
|
+
// `chant ci serve --rerun-failed` (#4095): run again the failed apply at the target branch's head, and failed plans of open heads.
|
|
555
|
+
result.rerunFailed = true;
|
|
521
556
|
} else if (arg === "--install") {
|
|
522
557
|
// `chant ci workflow --install <command>` (#3573): a step before the tick that installs what --chant runs.
|
|
523
558
|
result.ciInstall = args[++i];
|
package/src/cli/registry.ts
CHANGED
|
@@ -326,6 +326,10 @@ export interface ParsedArgs {
|
|
|
326
326
|
* merge bound to the approved digest.
|
|
327
327
|
*/
|
|
328
328
|
prLoop?: boolean;
|
|
329
|
+
/** `chant build --pr-loop --pr-build` (#4088) — build and lint before pr-plan and pr-apply. */
|
|
330
|
+
prBuild?: boolean;
|
|
331
|
+
/** `chant build --pr-loop --pr-build-command <line>` (#4088) — the build line to run in place of `chant build --env <env>`. */
|
|
332
|
+
prBuildCommand?: string;
|
|
329
333
|
/**
|
|
330
334
|
* `chant components fan-out --resume <file>` (#2420) — the attempt record
|
|
331
335
|
* this fan-out reads before it starts and writes when it finishes. Repeating
|
|
@@ -454,8 +458,20 @@ export interface ParsedArgs {
|
|
|
454
458
|
branch?: string;
|
|
455
459
|
/** `chant ci workflow --workflow <name>`, repeatable (#3573): a workflow whose completion triggers a tick, beside those the scan finds. */
|
|
456
460
|
ciWorkflows?: string[];
|
|
457
|
-
/** `chant ci
|
|
461
|
+
/** `chant ci run plan|apply --commit <sha>` (#4094): the commit the job runs at. Default `HEAD`. */
|
|
462
|
+
commit?: string;
|
|
463
|
+
/** `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets, beside the few it always gets. */
|
|
464
|
+
passEnv?: string[];
|
|
465
|
+
/** `chant ci workflow --chant <command>` (#3573): the command the tick step runs chant with, in place of the npx pin. Also `chant ci run --chant` (#4094): what runs chant in the job. */
|
|
458
466
|
ciChant?: string;
|
|
467
|
+
/** `chant ci serve --repo <owner/repo>` (#4095): the repository it polls. */
|
|
468
|
+
ciRepo?: string;
|
|
469
|
+
/** `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once. Default 1. */
|
|
470
|
+
maxJobs?: number;
|
|
471
|
+
/** `chant ci serve --author <login>`, repeatable (#4095): plan only pull requests these logins opened. */
|
|
472
|
+
ciAuthors?: string[];
|
|
473
|
+
/** `chant ci serve --rerun-failed` (#4095): run again the failed apply at the target's head and failed plans of open heads. */
|
|
474
|
+
rerunFailed?: boolean;
|
|
459
475
|
/** `chant ci workflow --install <command>` (#3573): a step before the tick that installs what `--chant` runs. */
|
|
460
476
|
ciInstall?: string;
|
|
461
477
|
/** `chant ci workflow --token-secret <NAME>` (#3573): the repository secret the tick's tag push authenticates with. */
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
* the change set and the events where a reader outside CI can fetch them;
|
|
37
37
|
* the job's own environment brings the credentials an `s3://` URL needs.
|
|
38
38
|
*
|
|
39
|
+
* With `build` (#4088) both jobs run the project's build and `chant lint`
|
|
40
|
+
* before their command, and fail on an error. A `cfn-deploy` member's plan
|
|
41
|
+
* and apply read the built template, so a project with one needs it. The
|
|
42
|
+
* build is `chant build --env <env>`, or `buildCommand` when the templates
|
|
43
|
+
* come from several builds (`npm run build`).
|
|
44
|
+
*
|
|
39
45
|
* In a workspace member (#3465) both commands run in the member's directory
|
|
40
46
|
* and pass `--member <name>`, so the member's gate (`pr-<number>-<member>`),
|
|
41
47
|
* note and statuses stay apart from every other member's on the same pull
|
|
@@ -105,6 +111,20 @@ export interface PrLoopPipelineOptions {
|
|
|
105
111
|
* such as `$CHANT_REPORT_URL`.
|
|
106
112
|
*/
|
|
107
113
|
reportUrl?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Build and lint before `pr-plan` and before `pr-apply` (#4088): the build
|
|
116
|
+
* line, then `chant lint`, each failing the job on an error. A `cfn-deploy`
|
|
117
|
+
* member reads the built template, so a project with one sets this. The
|
|
118
|
+
* build line is `chant build --env <env>` unless `buildCommand` names
|
|
119
|
+
* another. Default false.
|
|
120
|
+
*/
|
|
121
|
+
build?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The shell line that builds the project in place of `chant build --env
|
|
124
|
+
* <env>`, for a project whose templates come from several builds, such as
|
|
125
|
+
* `npm run build`. Setting it turns `build` on.
|
|
126
|
+
*/
|
|
127
|
+
buildCommand?: string;
|
|
108
128
|
/**
|
|
109
129
|
* Which GitHub-shaped forge the workflow is for. Set by the forgejo
|
|
110
130
|
* generator, which reuses the github one; a caller never needs to.
|
|
@@ -126,6 +146,14 @@ export interface PrLoopJob {
|
|
|
126
146
|
env: Record<string, string>;
|
|
127
147
|
/** Shell lines to run before the command, to settle values the forge may leave unusable. */
|
|
128
148
|
setup?: string[];
|
|
149
|
+
/** With `build` (#4088): the build line and the lint line, run in this order before the command. */
|
|
150
|
+
build?: string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The lines `build` (#4088) runs before a job's command: the build, then the lint. Empty without the option. */
|
|
154
|
+
export function prLoopBuildLines(env: string, options: PrLoopPipelineOptions = {}): string[] {
|
|
155
|
+
if (!options.build && !options.buildCommand) return [];
|
|
156
|
+
return [options.buildCommand ?? `chant build --env ${env}`, "chant lint"];
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
/**
|
|
@@ -142,6 +170,8 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
142
170
|
...(options.reportUrl ? ["--report-url", JSON.stringify(options.reportUrl)] : []),
|
|
143
171
|
];
|
|
144
172
|
const github = forge !== "gitlab";
|
|
173
|
+
const buildLines = prLoopBuildLines(env, options);
|
|
174
|
+
const build = buildLines.length > 0 ? { build: buildLines } : {};
|
|
145
175
|
const jobEnv: Record<string, string> = {
|
|
146
176
|
...(github ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}),
|
|
147
177
|
...(options.events ? { CHANT_RUN_EVENTS_DIR: PR_LOOP_EVENTS_DIR } : {}),
|
|
@@ -153,6 +183,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
153
183
|
env: github
|
|
154
184
|
? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
|
|
155
185
|
: { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv },
|
|
186
|
+
...build,
|
|
156
187
|
},
|
|
157
188
|
{
|
|
158
189
|
jobName: "apply",
|
|
@@ -163,6 +194,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
|
|
|
163
194
|
].join(" "),
|
|
164
195
|
env: github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv },
|
|
165
196
|
...(github ? {} : { setup: [gitlabBaseFallback(options.branch)] }),
|
|
197
|
+
...build,
|
|
166
198
|
},
|
|
167
199
|
];
|
|
168
200
|
}
|
|
@@ -60,6 +60,8 @@ function world() {
|
|
|
60
60
|
planned: [] as Array<{ root: string; vars?: Record<string, unknown> }>,
|
|
61
61
|
outputsRead: [] as string[],
|
|
62
62
|
applied: [] as string[],
|
|
63
|
+
/** The vars each root applied with, after wiring resolved. */
|
|
64
|
+
ranWith: {} as Record<string, Record<string, unknown> | undefined>,
|
|
63
65
|
fromPlan: {} as Record<string, boolean>,
|
|
64
66
|
failing: new Set<string>(),
|
|
65
67
|
planFailing: new Set<string>(),
|
|
@@ -100,6 +102,7 @@ function world() {
|
|
|
100
102
|
},
|
|
101
103
|
async run(ctx: DeployContext, input: RootInput) {
|
|
102
104
|
w.fromPlan[input.root] = ctx.plans?.[input.root] !== undefined;
|
|
105
|
+
w.ranWith[input.root] = input.vars;
|
|
103
106
|
if (w.failing.has(input.root)) throw new Error(`${input.root} failed`);
|
|
104
107
|
w.applied.push(input.root);
|
|
105
108
|
if (input.root === "a") w.state.a = { id: `a-${w.versions.a ?? "v1"}` };
|
|
@@ -166,6 +169,130 @@ describe("planPrSet", () => {
|
|
|
166
169
|
});
|
|
167
170
|
});
|
|
168
171
|
|
|
172
|
+
describe("a dependency outside the selection (#4104)", () => {
|
|
173
|
+
/** Two members: `up`, and `down` reading its `id`. The pull request changes `down` alone. */
|
|
174
|
+
const PAIR: DriverComponent[] = [root("up"), root("down", ["up"], { upstream: { stackOutput: { stack: "up", name: "id" } } })];
|
|
175
|
+
|
|
176
|
+
function pairWorld() {
|
|
177
|
+
const w = world();
|
|
178
|
+
w.state = { up: { id: "up-live", arn: "arn:up" } };
|
|
179
|
+
w.versions.down = "v2";
|
|
180
|
+
return w;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test("the dependent's plan resolves the upstream's outputs from its deployed outputs, and the report lists them", async () => {
|
|
184
|
+
const w = pairWorld();
|
|
185
|
+
const plan = planFanOut({ components: PAIR, changed: ["down"] });
|
|
186
|
+
expect(plan.order).toEqual(["down"]);
|
|
187
|
+
expect(plan.seeds).toEqual(["up"]);
|
|
188
|
+
|
|
189
|
+
const set = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod" });
|
|
190
|
+
expect(w.outputsRead).toEqual(["up"]);
|
|
191
|
+
expect(w.planned).toEqual([{ root: "down", vars: { upstream: "up-live" } }]);
|
|
192
|
+
expect(set.outputs).toEqual({ up: { id: "up-live", arn: "arn:up" } });
|
|
193
|
+
expect(set.deployedOutputs).toEqual({ up: ["arn", "id"] });
|
|
194
|
+
|
|
195
|
+
const report = prReport({
|
|
196
|
+
stage: "plan",
|
|
197
|
+
pr: 12,
|
|
198
|
+
env: "prod",
|
|
199
|
+
base: "1".repeat(40),
|
|
200
|
+
head: "2".repeat(40),
|
|
201
|
+
op: "pr-12",
|
|
202
|
+
gate: "pr-apply",
|
|
203
|
+
plan,
|
|
204
|
+
signal: { changed: ["down"], indeterminate: [], unclaimed: [] },
|
|
205
|
+
set,
|
|
206
|
+
approval: { status: "pending" },
|
|
207
|
+
status: "planned",
|
|
208
|
+
});
|
|
209
|
+
expect(report.deployedOutputs).toEqual({ up: ["arn", "id"] });
|
|
210
|
+
expect(report.selection).toMatchObject({ changed: ["down"], dependents: [] });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("the apply's plan reads the same outputs, gets the approved digest, and applies against them", async () => {
|
|
214
|
+
const w = pairWorld();
|
|
215
|
+
const plan = planFanOut({ components: PAIR, changed: ["down"] });
|
|
216
|
+
const approved = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod" });
|
|
217
|
+
|
|
218
|
+
// pr-apply plans the members again before anything applies.
|
|
219
|
+
w.outputsRead.length = 0;
|
|
220
|
+
const set = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod" });
|
|
221
|
+
expect(w.outputsRead).toEqual(["up"]);
|
|
222
|
+
expect(set.doc.digest).toBe(approved.doc.digest);
|
|
223
|
+
const check = await decidePrGate(memoryGateLedgerPort({ resolutions: [approval(approved.doc.digest)] }), {
|
|
224
|
+
op: "pr-12",
|
|
225
|
+
gate: "pr-apply",
|
|
226
|
+
digest: set.doc.digest,
|
|
227
|
+
now: NOW,
|
|
228
|
+
});
|
|
229
|
+
expect(check.satisfied).toBe(true);
|
|
230
|
+
|
|
231
|
+
const { members, status } = await applyPrSet({ components: PAIR, plan, registry: w.registry, env: "prod", set });
|
|
232
|
+
expect(status).toBe("applied");
|
|
233
|
+
expect(w.applied).toEqual(["down"]);
|
|
234
|
+
expect(w.ranWith.down).toEqual({ upstream: "up-live" });
|
|
235
|
+
expect(members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([["down", "applied", undefined]]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an upstream whose deployed output moved after approval moves the apply's digest", async () => {
|
|
239
|
+
const w = pairWorld();
|
|
240
|
+
const plan = planFanOut({ components: PAIR, changed: ["down"] });
|
|
241
|
+
const approved = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod" });
|
|
242
|
+
w.state.up = { id: "up-moved", arn: "arn:up" };
|
|
243
|
+
const set = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod" });
|
|
244
|
+
expect(set.doc.digest).not.toBe(approved.doc.digest);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("--seed-outputs wins: a seeded upstream is not read and not listed", async () => {
|
|
248
|
+
const w = pairWorld();
|
|
249
|
+
const plan = planFanOut({ components: PAIR, changed: ["down"] });
|
|
250
|
+
const set = await planPrSet({ components: PAIR, plan, registry: w.registry, env: "prod", seededOutputs: { up: { id: "up-seeded" } } });
|
|
251
|
+
expect(w.outputsRead).toEqual([]);
|
|
252
|
+
expect(w.planned).toEqual([{ root: "down", vars: { upstream: "up-seeded" } }]);
|
|
253
|
+
expect(set.deployedOutputs).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an upstream whose steps cannot say leaves the reference unresolved", async () => {
|
|
257
|
+
const w = pairWorld();
|
|
258
|
+
const silent: DriverComponent[] = [
|
|
259
|
+
{ name: "up", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "no-outputs" }] }] },
|
|
260
|
+
PAIR[1]!,
|
|
261
|
+
];
|
|
262
|
+
w.registry.register({ kind: "no-outputs", async run() { return {}; } } as never);
|
|
263
|
+
const plan = planFanOut({ components: silent, changed: ["down"] });
|
|
264
|
+
const set = await planPrSet({ components: silent, plan, registry: w.registry, env: "prod" });
|
|
265
|
+
expect(w.outputsRead).toEqual([]);
|
|
266
|
+
expect(set.outputs).toEqual({});
|
|
267
|
+
expect(set.deployedOutputs).toBeUndefined();
|
|
268
|
+
expect(w.planned).toHaveLength(1);
|
|
269
|
+
expect(w.planned[0]!.vars?.upstream).not.toBe("up-live");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the selection in place of the whole graph is refused, naming the dependency", async () => {
|
|
273
|
+
const w = pairWorld();
|
|
274
|
+
const plan = planFanOut({ components: PAIR, changed: ["down"] });
|
|
275
|
+
await expect(planPrSet({ components: [PAIR[1]!], plan, registry: w.registry, env: "prod" })).rejects.toThrow(/up, a dependency of a planned member/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a resume plans against the record's outputs and reads no dependency they cover", async () => {
|
|
279
|
+
const w = world();
|
|
280
|
+
w.versions.a = "v2";
|
|
281
|
+
const plan = planFanOut({ components: ESTATE, changed: ["a"] });
|
|
282
|
+
const { set, first, record } = await firstAttempt(w, plan, ["app"]);
|
|
283
|
+
expect(first.run.completed).toEqual(["a"]);
|
|
284
|
+
expect(set.deployedOutputs).toEqual({ a: ["id"], net: ["cidr"] });
|
|
285
|
+
|
|
286
|
+
w.outputsRead.length = 0;
|
|
287
|
+
w.state.net = { cidr: "10.9.0.0/16" };
|
|
288
|
+
const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: ["a"] });
|
|
289
|
+
if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
|
|
290
|
+
expect(w.outputsRead).toEqual([]);
|
|
291
|
+
expect(resumed.set.doc.digest).toBe(set.doc.digest);
|
|
292
|
+
expect(resumed.set.outputs).toBe(record.planOutputs);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
169
296
|
describe("the gate", () => {
|
|
170
297
|
test("pending with no approval, approved for the digest, changed for another one", async () => {
|
|
171
298
|
const w = world();
|
package/src/components/pr-run.ts
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
* its dependents in one rollout is what gated waves are for (#3049): each
|
|
20
20
|
* wave is planned after the one before it applied.
|
|
21
21
|
*
|
|
22
|
+
* A dependency the change did not select is read the same way (#4104), so
|
|
23
|
+
* a change to one cluster plans it against the network it sits on. That is
|
|
24
|
+
* why `components` is every component in the project and not the selection.
|
|
25
|
+
* `--seed-outputs` wins over a read, and a dependency whose steps cannot say
|
|
26
|
+
* leaves its dependents' references unresolved. The apply stage's plan reads
|
|
27
|
+
* the same dependencies, so on the same estate it gets the digest the plan
|
|
28
|
+
* stage showed, and it applies against the outputs its plans read
|
|
29
|
+
* (`set.outputs`). A resume plans against the outputs the approved plans
|
|
30
|
+
* read, from the record (`planOutputs`), and reads nothing those cover.
|
|
31
|
+
*
|
|
22
32
|
* ## Finishing an apply that failed partway
|
|
23
33
|
*
|
|
24
34
|
* {@link resumePrSet} (#3464) is fan-out's resume (#2417, #3049) for a pull
|
|
@@ -53,10 +63,10 @@ import type { PrApplyRecord } from "./fan-out-record";
|
|
|
53
63
|
import type { CapabilityRegistry } from "./capability";
|
|
54
64
|
import type { RunProgressEvent } from "./run-progress";
|
|
55
65
|
import type { RunEventSink } from "../run-event-stream";
|
|
56
|
-
import { planWaveComponent,
|
|
66
|
+
import { planWaveComponent, readDeployedOutputs } from "./wave-plan";
|
|
57
67
|
|
|
58
68
|
export interface PrPlanSetOptions {
|
|
59
|
-
/** Every component in the project. */
|
|
69
|
+
/** Every component in the project, not the selection: a planned member's dependency outside it is read too (#4104). */
|
|
60
70
|
components: DriverComponent[];
|
|
61
71
|
/** The fan-out the change derives (`planFanOut`). Its `order` is what is planned. */
|
|
62
72
|
plan: FanOutPlan;
|
|
@@ -85,6 +95,12 @@ export interface PrPlanSet {
|
|
|
85
95
|
plans: Record<string, Record<string, unknown>>;
|
|
86
96
|
/** The outputs the plans read: the seeds, and each dependency's outputs as they stood. */
|
|
87
97
|
outputs: Record<string, Record<string, unknown>>;
|
|
98
|
+
/**
|
|
99
|
+
* Per dependency read from the deployed estate (#4104), the names of the
|
|
100
|
+
* outputs the read gave, sorted. The seeds are not here. Absent when
|
|
101
|
+
* nothing was read. The report's `deployedOutputs`.
|
|
102
|
+
*/
|
|
103
|
+
deployedOutputs?: Record<string, string[]>;
|
|
88
104
|
/** Whether any component failed to plan. */
|
|
89
105
|
failed: boolean;
|
|
90
106
|
}
|
|
@@ -101,12 +117,15 @@ function failedParts(component: DriverComponent, error: string): ChangeSetPart[]
|
|
|
101
117
|
/**
|
|
102
118
|
* Plan every component in `plan.order`, a wave at a time, each against the
|
|
103
119
|
* outputs its dependencies have now. Never throws for a component that fails
|
|
104
|
-
* to plan: it becomes a failed member, and `failed` is set.
|
|
120
|
+
* to plan: it becomes a failed member, and `failed` is set. Throws when a
|
|
121
|
+
* planned member's dependency is not among `components`, which means the
|
|
122
|
+
* caller passed the selection where the whole graph belongs.
|
|
105
123
|
*/
|
|
106
124
|
export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
107
125
|
const byName = new Map(options.components.map((c) => [c.name, c]));
|
|
108
126
|
const outputs: Record<string, Record<string, unknown>> = { ...(options.seededOutputs ?? {}) };
|
|
109
127
|
const read = new Set(Object.keys(outputs));
|
|
128
|
+
const deployed: Record<string, string[]> = {};
|
|
110
129
|
const parts: ChangeSetPart[] = [];
|
|
111
130
|
const componentOf = new Map<string, string>();
|
|
112
131
|
const plans: Record<string, Record<string, unknown>> = {};
|
|
@@ -117,15 +136,14 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
117
136
|
for (const wave of options.plan.waves) {
|
|
118
137
|
// The dependencies' outputs first, each read once. A dependency that
|
|
119
138
|
// cannot say leaves its references unresolved, which its dependent's
|
|
120
|
-
// plan reports if it needs them.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (found) outputs[dep] = found;
|
|
127
|
-
}
|
|
139
|
+
// plan reports if it needs them. A dependency outside the selection is
|
|
140
|
+
// read the same way (#4104).
|
|
141
|
+
const deps = [...new Set(wave.flatMap((name) => byName.get(name)?.dependsOn ?? []))].filter((dep) => !read.has(dep));
|
|
142
|
+
for (const dep of deps) {
|
|
143
|
+
read.add(dep);
|
|
144
|
+
if (!byName.has(dep)) throw new Error(`planPrSet: ${dep}, a dependency of a planned member, is not among the components; pass every component in the project`);
|
|
128
145
|
}
|
|
146
|
+
Object.assign(deployed, await readDeployedOutputs(deps, byName, options.registry, (dep) => deployContext(options, dep), outputs));
|
|
129
147
|
const planned = await Promise.all(
|
|
130
148
|
wave.map(async (name) => {
|
|
131
149
|
const at = { component: name, phase: PR_PLAN_PHASE, step: "plan" };
|
|
@@ -170,7 +188,8 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
170
188
|
counts: memberCounts(doc, m.member),
|
|
171
189
|
...(m.error ? { error: m.error } : {}),
|
|
172
190
|
}));
|
|
173
|
-
|
|
191
|
+
const deployedOutputs = Object.fromEntries(Object.entries(deployed).sort(([a], [b]) => a.localeCompare(b)));
|
|
192
|
+
return { doc, members, plans, outputs, failed, ...(Object.keys(deployedOutputs).length > 0 ? { deployedOutputs } : {}) };
|
|
174
193
|
}
|
|
175
194
|
|
|
176
195
|
/**
|
|
@@ -488,6 +507,7 @@ export function prReport(input: PrReportInput): PrReport {
|
|
|
488
507
|
...(input.refusal ? { refusal: input.refusal } : {}),
|
|
489
508
|
...(input.message ? { message: input.message } : {}),
|
|
490
509
|
...(input.resumed ? { resumed: { applied: [...input.resumed].sort() } } : {}),
|
|
510
|
+
...(input.set.deployedOutputs ? { deployedOutputs: input.set.deployedOutputs } : {}),
|
|
491
511
|
approval: input.approval,
|
|
492
512
|
selection: {
|
|
493
513
|
changed: input.plan.order.filter((c) => changed.has(c)),
|