@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.
Files changed (103) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +4 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -5
  5. package/dist/cli/handlers/pr.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +30 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +49 -0
  10. package/dist/cli/registry.d.ts +17 -1
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/pr-pipeline.d.ts +24 -0
  13. package/dist/components/pr-pipeline.d.ts.map +1 -1
  14. package/dist/components/pr-pipeline.js +16 -0
  15. package/dist/components/pr-run.d.ts +20 -2
  16. package/dist/components/pr-run.d.ts.map +1 -1
  17. package/dist/components/pr-run.js +26 -13
  18. package/dist/components/wave-plan.d.ts +11 -0
  19. package/dist/components/wave-plan.d.ts.map +1 -1
  20. package/dist/components/wave-plan.js +26 -0
  21. package/dist/lifecycle/release-ledger.d.ts +8 -3
  22. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.js +2 -0
  24. package/dist/lifecycle/run-ledger.d.ts +1 -1
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/lifecycle/run-ledger.js +9 -0
  27. package/dist/op/activities/activity-contracts.d.ts +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  29. package/dist/op/activities/activity-contracts.js +1 -0
  30. package/dist/op/activities/shell.d.ts +6 -0
  31. package/dist/op/activities/shell.d.ts.map +1 -1
  32. package/dist/op/activities/shell.js +1 -1
  33. package/dist/op/local-executor.d.ts +23 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +9 -3
  36. package/dist/op/runtime.d.ts +27 -0
  37. package/dist/op/runtime.d.ts.map +1 -1
  38. package/dist/op/runtime.js +20 -0
  39. package/dist/pr-forge.d.ts +23 -0
  40. package/dist/pr-forge.d.ts.map +1 -1
  41. package/dist/pr-forge.js +44 -0
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +7 -0
  45. package/dist/run-event-stream.d.ts +2 -2
  46. package/dist/run-event-stream.d.ts.map +1 -1
  47. package/dist/run-event-stream.js +2 -2
  48. package/dist/run-event.d.ts +15 -0
  49. package/dist/run-event.d.ts.map +1 -1
  50. package/dist/run-event.js +2 -0
  51. package/dist/wave-runtime.mjs +171 -171
  52. package/dist/workspace/ci-green-forge.d.ts +2 -0
  53. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  54. package/dist/workspace/ci-green.d.ts +2 -0
  55. package/dist/workspace/ci-green.d.ts.map +1 -1
  56. package/dist/workspace/ci-green.js +4 -2
  57. package/dist/workspace/ci-run.d.ts +222 -0
  58. package/dist/workspace/ci-run.d.ts.map +1 -0
  59. package/dist/workspace/ci-run.js +464 -0
  60. package/dist/workspace/ci-serve.d.ts +214 -0
  61. package/dist/workspace/ci-serve.d.ts.map +1 -0
  62. package/dist/workspace/ci-serve.js +412 -0
  63. package/dist/workspace/pr-report.schema.json +5 -0
  64. package/dist/workspace/run-event.schema.json +14 -3
  65. package/dist/workspace/status-stewards.d.ts +2 -0
  66. package/dist/workspace/status-stewards.d.ts.map +1 -1
  67. package/dist/workspace/status-stewards.js +1 -0
  68. package/dist/workspace/status.schema.json +12 -1
  69. package/package.json +1 -1
  70. package/src/cli/handlers/build.test.ts +15 -0
  71. package/src/cli/handlers/build.ts +4 -1
  72. package/src/cli/handlers/fan-out.test.ts +82 -0
  73. package/src/cli/handlers/fan-out.ts +34 -5
  74. package/src/cli/handlers/pr.ts +5 -0
  75. package/src/cli/main.ts +30 -0
  76. package/src/cli/parse-args.ts +35 -0
  77. package/src/cli/registry.ts +17 -1
  78. package/src/components/pr-pipeline.ts +32 -0
  79. package/src/components/pr-run.test.ts +127 -0
  80. package/src/components/pr-run.ts +32 -12
  81. package/src/components/wave-plan.ts +30 -0
  82. package/src/lifecycle/release-ledger.ts +9 -3
  83. package/src/lifecycle/run-ledger.ts +7 -1
  84. package/src/op/activities/activity-contracts.ts +1 -0
  85. package/src/op/activities/shell.ts +7 -1
  86. package/src/op/local-executor.ts +27 -4
  87. package/src/op/runtime.ts +42 -0
  88. package/src/pr-forge.test.ts +25 -0
  89. package/src/pr-forge.ts +62 -0
  90. package/src/pr-loop.test.ts +13 -1
  91. package/src/pr-loop.ts +16 -0
  92. package/src/run-event-stream.ts +4 -3
  93. package/src/run-event.ts +17 -0
  94. package/src/workspace/ci-green-forge.ts +2 -0
  95. package/src/workspace/ci-green.ts +6 -2
  96. package/src/workspace/ci-run.test.ts +416 -0
  97. package/src/workspace/ci-run.ts +571 -0
  98. package/src/workspace/ci-serve.test.ts +268 -0
  99. package/src/workspace/ci-serve.ts +521 -0
  100. package/src/workspace/pr-report.schema.json +5 -0
  101. package/src/workspace/run-event.schema.json +14 -3
  102. package/src/workspace/status-stewards.ts +3 -0
  103. 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: args.env ?? "local",
413
+ env,
382
414
  releaseIdentity: () => (revision ? { revision } : {}),
383
- // A completed component's recorded outputs win over a `--seed-outputs`
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
@@ -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.
@@ -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];
@@ -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 workflow --chant <command>` (#3573): the command the tick step runs chant with, in place of the npx pin. */
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();
@@ -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, readComponentOutputs } from "./wave-plan";
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
- for (const name of wave) {
122
- for (const dep of byName.get(name)?.dependsOn ?? []) {
123
- if (read.has(dep) || !byName.has(dep)) continue;
124
- read.add(dep);
125
- const found = await readComponentOutputs(byName.get(dep)!, options.registry, deployContext(options, dep), outputs);
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
- return { doc, members, plans, outputs, failed };
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)),