@intentius/chant 0.124.29 → 0.124.31

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 (145) 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 +64 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +99 -0
  10. package/dist/cli/registry.d.ts +35 -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 +35 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +31 -6
  36. package/dist/op/redact.d.ts +34 -0
  37. package/dist/op/redact.d.ts.map +1 -0
  38. package/dist/op/redact.js +94 -0
  39. package/dist/op/runtime.d.ts +27 -0
  40. package/dist/op/runtime.d.ts.map +1 -1
  41. package/dist/op/runtime.js +20 -0
  42. package/dist/op/step-console.d.ts +2 -0
  43. package/dist/op/step-console.d.ts.map +1 -1
  44. package/dist/op/step-console.js +5 -2
  45. package/dist/pr-forge.d.ts +40 -0
  46. package/dist/pr-forge.d.ts.map +1 -1
  47. package/dist/pr-forge.js +56 -6
  48. package/dist/pr-loop.d.ts +10 -0
  49. package/dist/pr-loop.d.ts.map +1 -1
  50. package/dist/pr-loop.js +7 -0
  51. package/dist/pr-report-url.d.ts +8 -0
  52. package/dist/pr-report-url.d.ts.map +1 -1
  53. package/dist/pr-report-url.js +47 -1
  54. package/dist/run-event-stream.d.ts +2 -2
  55. package/dist/run-event-stream.d.ts.map +1 -1
  56. package/dist/run-event-stream.js +2 -2
  57. package/dist/run-event.d.ts +15 -0
  58. package/dist/run-event.d.ts.map +1 -1
  59. package/dist/run-event.js +2 -0
  60. package/dist/wave-runtime.mjs +171 -171
  61. package/dist/workspace/ci-green-forge.d.ts +2 -0
  62. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  63. package/dist/workspace/ci-green.d.ts +2 -0
  64. package/dist/workspace/ci-green.d.ts.map +1 -1
  65. package/dist/workspace/ci-green.js +4 -2
  66. package/dist/workspace/ci-run-index.d.ts +96 -0
  67. package/dist/workspace/ci-run-index.d.ts.map +1 -0
  68. package/dist/workspace/ci-run-index.js +162 -0
  69. package/dist/workspace/ci-run-index.schema.json +50 -0
  70. package/dist/workspace/ci-run.d.ts +299 -0
  71. package/dist/workspace/ci-run.d.ts.map +1 -0
  72. package/dist/workspace/ci-run.js +691 -0
  73. package/dist/workspace/ci-runner-key.d.ts +120 -0
  74. package/dist/workspace/ci-runner-key.d.ts.map +1 -0
  75. package/dist/workspace/ci-runner-key.js +217 -0
  76. package/dist/workspace/ci-secrets.d.ts +65 -0
  77. package/dist/workspace/ci-secrets.d.ts.map +1 -0
  78. package/dist/workspace/ci-secrets.js +211 -0
  79. package/dist/workspace/ci-serve.d.ts +227 -0
  80. package/dist/workspace/ci-serve.d.ts.map +1 -0
  81. package/dist/workspace/ci-serve.js +468 -0
  82. package/dist/workspace/pr-report.schema.json +5 -0
  83. package/dist/workspace/reason-codes.d.ts +6 -2
  84. package/dist/workspace/reason-codes.d.ts.map +1 -1
  85. package/dist/workspace/reason-codes.js +7 -2
  86. package/dist/workspace/run-event.schema.json +14 -3
  87. package/dist/workspace/status-stewards.d.ts +2 -0
  88. package/dist/workspace/status-stewards.d.ts.map +1 -1
  89. package/dist/workspace/status-stewards.js +1 -0
  90. package/dist/workspace/status.schema.json +12 -1
  91. package/dist/workspace/trust/trust-write.d.ts +109 -0
  92. package/dist/workspace/trust/trust-write.d.ts.map +1 -0
  93. package/dist/workspace/trust/trust-write.js +320 -0
  94. package/dist/workspace/trust-write.schema.json +265 -0
  95. package/package.json +1 -1
  96. package/src/cli/handlers/build.test.ts +15 -0
  97. package/src/cli/handlers/build.ts +4 -1
  98. package/src/cli/handlers/fan-out.test.ts +82 -0
  99. package/src/cli/handlers/fan-out.ts +34 -5
  100. package/src/cli/handlers/pr.ts +5 -0
  101. package/src/cli/main.test.ts +1 -0
  102. package/src/cli/main.ts +64 -0
  103. package/src/cli/parse-args.ts +71 -0
  104. package/src/cli/registry.ts +35 -1
  105. package/src/components/pr-pipeline.ts +32 -0
  106. package/src/components/pr-run.test.ts +127 -0
  107. package/src/components/pr-run.ts +32 -12
  108. package/src/components/wave-plan.ts +30 -0
  109. package/src/lifecycle/release-ledger.ts +9 -3
  110. package/src/lifecycle/run-ledger.ts +7 -1
  111. package/src/op/activities/activity-contracts.ts +1 -0
  112. package/src/op/activities/shell.ts +7 -1
  113. package/src/op/local-executor.ts +58 -7
  114. package/src/op/redact.ts +93 -0
  115. package/src/op/runtime.ts +42 -0
  116. package/src/op/step-console.ts +7 -2
  117. package/src/pr-forge.test.ts +54 -0
  118. package/src/pr-forge.ts +92 -6
  119. package/src/pr-loop.test.ts +13 -1
  120. package/src/pr-loop.ts +16 -0
  121. package/src/pr-report-url.ts +53 -1
  122. package/src/run-event-stream.ts +4 -3
  123. package/src/run-event.ts +17 -0
  124. package/src/workspace/ci-green-forge.ts +2 -0
  125. package/src/workspace/ci-green.ts +6 -2
  126. package/src/workspace/ci-run-index.schema.json +50 -0
  127. package/src/workspace/ci-run-index.test.ts +353 -0
  128. package/src/workspace/ci-run-index.ts +192 -0
  129. package/src/workspace/ci-run.test.ts +716 -0
  130. package/src/workspace/ci-run.ts +833 -0
  131. package/src/workspace/ci-runner-key.test.ts +166 -0
  132. package/src/workspace/ci-runner-key.ts +250 -0
  133. package/src/workspace/ci-secrets.test.ts +106 -0
  134. package/src/workspace/ci-secrets.ts +213 -0
  135. package/src/workspace/ci-serve.test.ts +367 -0
  136. package/src/workspace/ci-serve.ts +574 -0
  137. package/src/workspace/pr-report.schema.json +5 -0
  138. package/src/workspace/reason-codes.test.ts +2 -0
  139. package/src/workspace/reason-codes.ts +7 -2
  140. package/src/workspace/run-event.schema.json +14 -3
  141. package/src/workspace/status-stewards.ts +3 -0
  142. package/src/workspace/status.schema.json +12 -1
  143. package/src/workspace/trust/trust-write.test.ts +179 -0
  144. package/src/workspace/trust/trust-write.ts +377 -0
  145. package/src/workspace/trust-write.schema.json +265 -0
@@ -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,10 @@ const BOOLEAN_FLAGS = new Set([
64
65
  "--yes",
65
66
  "--confirm-prod",
66
67
  "--once",
68
+ "--rerun-failed",
69
+ "--setup-key",
70
+ "--trust",
71
+ "--unsigned",
67
72
  "--expire",
68
73
  "--check-live",
69
74
  "--check-snapshot",
@@ -386,6 +391,13 @@ export function parseArgs(args: string[]): ParsedArgs {
386
391
  const url = args[++i];
387
392
  if (!url || url.startsWith("-")) throw new Error("--report-url needs a URL: --report-url s3://<bucket>/<prefix> or file://<directory>");
388
393
  result.reportUrl = url;
394
+ } else if (arg === "--no-status") {
395
+ // `chant ci run|serve --no-status` (#4097): post no commit status for the job.
396
+ result.noStatus = true;
397
+ } else if (arg === "--status-url") {
398
+ // `chant ci run|serve --status-url <template>` (#4097): the link a job's status carries, in place of an https --report-url.
399
+ result.statusUrl = args[++i];
400
+ if (!result.statusUrl || result.statusUrl.startsWith("-")) throw new Error("--status-url needs a URL template: --status-url https://host/reports/{key}");
389
401
  } else if (arg === "--allow-unsigned") {
390
402
  // `chant release publish --allow-unsigned` (#4049).
391
403
  result.allowUnsigned = true;
@@ -394,6 +406,13 @@ export function parseArgs(args: string[]): ParsedArgs {
394
406
  result.requireProvenance = true;
395
407
  } else if (arg === "--pr-loop") {
396
408
  result.prLoop = true;
409
+ } else if (arg === "--pr-build") {
410
+ // `chant build --pr-loop --pr-build` (#4088), and `chant ci run --pr-build` (#4094).
411
+ result.prBuild = true;
412
+ } else if (arg === "--pr-build-command") {
413
+ const line = args[++i];
414
+ if (line === undefined || line === "") throw new Error("--pr-build-command needs a shell line: --pr-build-command '<line>'");
415
+ result.prBuildCommand = line;
397
416
  } else if (arg === "--before-script") {
398
417
  const line = args[++i];
399
418
  if (line === undefined || line === "") throw new Error("--before-script needs a shell line: --before-script '<line>'");
@@ -518,6 +537,41 @@ export function parseArgs(args: string[]): ParsedArgs {
518
537
  // `chant ci workflow --chant <command>` (#3573): what the tick step runs chant with.
519
538
  result.ciChant = args[++i];
520
539
  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>");
540
+ } else if (arg === "--commit") {
541
+ // `chant ci run plan|apply --commit <sha>` (#4094): the commit the job runs at.
542
+ result.commit = args[++i];
543
+ if (!result.commit || result.commit.startsWith("-")) throw new Error("--commit needs the commit the job runs at: --commit <sha>");
544
+ } else if (arg === "--pass-env") {
545
+ // `chant ci run --pass-env <NAME>`, repeatable (#4094): a variable of the runner's environment the job gets.
546
+ const name = args[++i];
547
+ if (!name || name.startsWith("-")) throw new Error("--pass-env needs a variable's name, such as AWS_PROFILE: --pass-env <NAME>");
548
+ (result.passEnv ??= []).push(name);
549
+ } else if (arg === "--secrets") {
550
+ // `chant ci run|serve --secrets <file>` (#4096): the host's secrets file, readable only by its owner.
551
+ result.ciSecrets = args[++i];
552
+ if (!result.ciSecrets || result.ciSecrets.startsWith("-")) throw new Error("--secrets needs the host's secrets file, a dotenv or JSON file only its owner can read: --secrets <file>");
553
+ } else if (arg === "--repo") {
554
+ // `chant ci serve --repo <owner/repo>` (#4095): the repository whose pull requests and target branch it polls.
555
+ result.ciRepo = args[++i];
556
+ if (!result.ciRepo || !/^[^/\s-][^/\s]*\/[^/\s]+$/.test(result.ciRepo)) throw new Error("--repo needs the repository as owner/name: --repo <owner/repo>");
557
+ } else if (arg === "--run-index-limit") {
558
+ // `chant ci run --run-index-limit <n>` (#4098): how many runs the index at the report URL keeps.
559
+ const n = Number(args[++i]);
560
+ if (!Number.isInteger(n) || n < 1) throw new Error("--run-index-limit needs a whole number of runs, 1 or more: --run-index-limit <n>");
561
+ result.runIndexLimit = n;
562
+ } else if (arg === "--max-jobs") {
563
+ // `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once.
564
+ const n = Number(args[++i]);
565
+ if (!Number.isInteger(n) || n < 1) throw new Error("--max-jobs needs a whole number of jobs, 1 or more: --max-jobs <n>");
566
+ result.maxJobs = n;
567
+ } else if (arg === "--author") {
568
+ // `chant ci serve --author <login>`, repeatable (#4095): plan only pull requests these people opened.
569
+ const login = args[++i];
570
+ if (!login || login.startsWith("-")) throw new Error("--author needs a forge login: --author <login>");
571
+ (result.ciAuthors ??= []).push(login);
572
+ } else if (arg === "--rerun-failed") {
573
+ // `chant ci serve --rerun-failed` (#4095): run again the failed apply at the target branch's head, and failed plans of open heads.
574
+ result.rerunFailed = true;
521
575
  } else if (arg === "--install") {
522
576
  // `chant ci workflow --install <command>` (#3573): a step before the tick that installs what --chant runs.
523
577
  result.ciInstall = args[++i];
@@ -541,6 +595,10 @@ export function parseArgs(args: string[]): ParsedArgs {
541
595
  // `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
542
596
  result.verdict = args[++i];
543
597
  if (!result.verdict || result.verdict.startsWith("-")) throw new Error("--verdict needs agree, dissent or abstain");
598
+ } else if (arg === "--class") {
599
+ // `chant workspace trust runner add <principal> --class runner|service` (#3510).
600
+ result.runnerClass = args[++i];
601
+ if (!result.runnerClass || result.runnerClass.startsWith("-")) throw new Error("--class needs runner or service: --class <class>");
544
602
  } else if (arg === "--by") {
545
603
  // `chant workspace records review <id> --by <principal>` (#2670): whoever the caller says.
546
604
  result.by = args[++i];
@@ -713,6 +771,19 @@ export function parseArgs(args: string[]): ParsedArgs {
713
771
  }
714
772
  } else if (arg === "--once") {
715
773
  result.once = true;
774
+ } else if (arg === "--setup-key") {
775
+ // `chant ci serve|run --setup-key` (#4099): make the host's runner key and print its public half.
776
+ result.setupKey = true;
777
+ } else if (arg === "--trust") {
778
+ // `chant ci serve|run --setup-key --trust` (#4099): list the key in .chant/trust.json on a branch.
779
+ result.trustKey = true;
780
+ } else if (arg === "--unsigned") {
781
+ // `chant ci serve --unsigned` (#4099): serve without a runner key listed at the target's head.
782
+ result.unsigned = true;
783
+ } else if (arg === "--principal") {
784
+ // `chant ci serve|run --setup-key --trust --principal <name>` (#4099): the runner's principal.
785
+ result.ciPrincipal = args[++i];
786
+ if (!result.ciPrincipal || result.ciPrincipal.startsWith("-")) throw new Error("--principal needs the runner's principal: --principal <name>");
716
787
  } else if (arg === "--work") {
717
788
  // `chant run <op> --work <id>` (#2748)
718
789
  const value = 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,28 @@ 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 run|serve --secrets <file>` (#4096): the host's secrets file. Default the file `CHANT_CI_SECRETS` names. */
466
+ ciSecrets?: string;
467
+ /** `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
468
  ciChant?: string;
469
+ /** `chant ci run|serve --no-status` (#4097): post no commit status for the job. */
470
+ noStatus?: boolean;
471
+ /** `chant ci run|serve --status-url <template>` (#4097): the link a job's status carries. `{key}` is `<pr>/<commit>[/<member>]`, as `--report-url` keys it. */
472
+ statusUrl?: string;
473
+ /** `chant ci run --run-index-limit <n>` (#4098): how many runs the index at the report URL keeps. Default 200. */
474
+ runIndexLimit?: number;
475
+ /** `chant ci serve --repo <owner/repo>` (#4095): the repository it polls. */
476
+ ciRepo?: string;
477
+ /** `chant ci serve --max-jobs <n>` (#4095): how many jobs run at once. Default 1. */
478
+ maxJobs?: number;
479
+ /** `chant ci serve --author <login>`, repeatable (#4095): plan only pull requests these logins opened. */
480
+ ciAuthors?: string[];
481
+ /** `chant ci serve --rerun-failed` (#4095): run again the failed apply at the target's head and failed plans of open heads. */
482
+ rerunFailed?: boolean;
459
483
  /** `chant ci workflow --install <command>` (#3573): a step before the tick that installs what `--chant` runs. */
460
484
  ciInstall?: string;
461
485
  /** `chant ci workflow --token-secret <NAME>` (#3573): the repository secret the tick's tag push authenticates with. */
@@ -474,6 +498,16 @@ export interface ParsedArgs {
474
498
  * `records-write.ts`.
475
499
  */
476
500
  by?: string;
501
+ /** `chant ci serve|run --setup-key` (#4099): make the host's runner key in the state directory and print its public half. */
502
+ setupKey?: boolean;
503
+ /** `chant ci serve|run --setup-key --trust` (#4099): list the runner key in .chant/trust.json on a branch pushed for review. */
504
+ trustKey?: boolean;
505
+ /** `chant ci serve --unsigned` (#4099): start without a runner key listed in .chant/trust.json at the target's head. */
506
+ unsigned?: boolean;
507
+ /** `chant ci serve|run --setup-key --trust --principal <name>` (#4099): the principal the runner key is listed for. */
508
+ ciPrincipal?: string;
509
+ /** `chant workspace trust runner add <principal> --class runner|service` (#3510): the runner entry's class. */
510
+ runnerClass?: string;
477
511
  /**
478
512
  * `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey.
479
513
  * On `records new` and `records amend`, the key that seals the record's author (#2688).
@@ -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)),
@@ -135,3 +135,33 @@ export async function readComponentOutputs(
135
135
  }
136
136
  return found;
137
137
  }
138
+
139
+ /**
140
+ * Read the deployed outputs of each of `names` that `outputs` holds nothing
141
+ * for yet (#4104), through {@link readComponentOutputs}, and add them to
142
+ * `outputs`. A name that is not among `components`, or whose steps cannot
143
+ * say, is left out, so its dependents' references stay unresolved. Returns,
144
+ * per component read, the names of the outputs the read gave, sorted.
145
+ *
146
+ * `components` must be the whole graph, not the selection: the dependency a
147
+ * selected member reads is often one the change did not select.
148
+ */
149
+ export async function readDeployedOutputs(
150
+ names: readonly string[],
151
+ components: ReadonlyMap<string, DriverComponent>,
152
+ registry: CapabilityRegistry,
153
+ ctxOf: (component: string) => DeployContext,
154
+ outputs: Record<string, Record<string, unknown>>,
155
+ ): Promise<Record<string, string[]>> {
156
+ const read: Record<string, string[]> = {};
157
+ for (const name of names) {
158
+ if (name in outputs || name in read) continue;
159
+ const component = components.get(name);
160
+ if (!component) continue;
161
+ const found = await readComponentOutputs(component, registry, ctxOf(name), outputs);
162
+ if (!found) continue;
163
+ outputs[name] = found;
164
+ read[name] = Object.keys(found).sort();
165
+ }
166
+ return read;
167
+ }
@@ -47,16 +47,22 @@ export interface RunOrigin {
47
47
  * Which id space minted `runId`: `github` (a GitHub Actions run id — also
48
48
  * the spelling Forgejo Actions uses, since it implements the same env
49
49
  * contract; `url` disambiguates the actual host), `gitlab` (a CI pipeline
50
- * id), `op` (an orchestrator/Op run id), or `local` (minted on the machine
51
- * that ran the deploy, resolvable nowhere).
50
+ * id), `op` (an orchestrator/Op run id), `local` (minted on the machine
51
+ * that ran the deploy, resolvable nowhere), or `chant` (a job the chant
52
+ * runner ran, `chant ci run`, #4094, named by its Op run id). `chant` was
53
+ * added within contract 1 by {@link RUN_ORIGIN_CHANT_FLOOR}, so a reader
54
+ * that switches on `forge` keeps a case for a value it does not know.
52
55
  */
53
- forge: "github" | "gitlab" | "op" | "local";
56
+ forge: "github" | "gitlab" | "op" | "local" | "chant";
54
57
  /** Repo/project slug on the forge (`GITHUB_REPOSITORY` / `CI_PROJECT_PATH`), when the environment named one. */
55
58
  repo?: string;
56
59
  /** Resolved link to the run, when the environment provided enough to build one. */
57
60
  url?: string;
58
61
  }
59
62
 
63
+ /** The first chant that writes `runOrigin.forge: "chant"`, for a job `chant ci run` ran (#4094). */
64
+ export const RUN_ORIGIN_CHANT_FLOOR = "0.124.30";
65
+
60
66
  /**
61
67
  * The gate resolution that admitted a deploy (#4020): the gate's address in the
62
68
  * gate ledger (`_gates/<op>.jsonl`, `op` and `gate`) and when the resolution
@@ -38,7 +38,7 @@ import { randomUUID } from "node:crypto";
38
38
  import { sortedJsonReplacer } from "../utils";
39
39
  import type { OpConfig } from "../op/types";
40
40
  import type { StepRecord } from "../op/local-executor";
41
- import type { OpRunRecord, OpRunRecordInput, OpRunPhaseRecord, OpRunStepRecord } from "../op/runtime";
41
+ import { opRunFailureOf, type OpRunRecord, type OpRunRecordInput, type OpRunPhaseRecord, type OpRunStepRecord } from "../op/runtime";
42
42
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
43
43
 
44
44
  /** Prefix folding the per-op run ledgers into one flat name under `<env>/`. See the module doc. */
@@ -215,6 +215,12 @@ export async function readRunLedger(
215
215
  malformed++;
216
216
  continue;
217
217
  }
218
+ // A `failure` (#4094) that is not one is dropped and the line kept, as a gate line's wrong-typed `gitSha` is.
219
+ if ("failure" in parsed) {
220
+ const failure = opRunFailureOf(parsed.failure);
221
+ if (failure) parsed.failure = failure;
222
+ else delete parsed.failure;
223
+ }
218
224
  records.push(parsed as OpRunRecord);
219
225
  } catch {
220
226
  malformed++;
@@ -54,6 +54,7 @@ export const shellCmdContract = activityContract(
54
54
  cmd: z.string(),
55
55
  env: z.record(z.string(), z.string()).optional(),
56
56
  cwd: z.string().optional(),
57
+ inheritEnv: z.boolean().optional(),
57
58
  okExit: z.array(z.number()).optional(),
58
59
  gatedExit: z.number().optional(),
59
60
  gate: z.strictObject({ op: z.string(), gate: z.string() }).optional(),
@@ -13,6 +13,12 @@ export interface ShellCmdArgs {
13
13
  env?: Record<string, string>;
14
14
  /** Working directory. Default: process.cwd(). */
15
15
  cwd?: string;
16
+ /**
17
+ * Whether the command sees this process's environment. Default true. With
18
+ * false it sees `env` alone, plus the in-flight record's variable, so a job
19
+ * the chant runner runs (`chant ci run`, #4094) gets a clean environment.
20
+ */
21
+ inheritEnv?: boolean;
16
22
  /**
17
23
  * Exit codes that count as success. Default `[0]`.
18
24
  *
@@ -129,7 +135,7 @@ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promis
129
135
  cwd: args.cwd,
130
136
  // In a run that keeps an in-flight record, CHANT_RUN_ACTIVITY names the
131
137
  // file the command appends activity lines to (../run-live.ts).
132
- env: { ...process.env, ...liveRunEnv(), ...args.env },
138
+ env: { ...(args.inheritEnv === false ? {} : process.env), ...liveRunEnv(), ...args.env },
133
139
  maxBuffer: MAX_STDOUT_BYTES,
134
140
  signal,
135
141
  });