@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
@@ -56,7 +56,8 @@ import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
56
56
  import { runEnvOf } from "../lifecycle/run-ledger";
57
57
  import { resolveCommitSha } from "../lifecycle/commit-sha";
58
58
  import { resolveCiRun, type RunOrigin } from "../lifecycle/release-ledger";
59
- import { runEventsOfStepRecord, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
59
+ import { redactDeep, redactText, type Redact } from "./redact";
60
+ import { runEventsOfStepRecord, type RunEventKind, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
60
61
  import { openRunEventStore, teeLines } from "../run-event-store";
61
62
  import { openRunEventStream, runEventRunOf, type RunEventSink, type RunEventStream } from "../run-event-stream";
62
63
  import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
@@ -1300,6 +1301,24 @@ export interface RunOpOptions {
1300
1301
  * turn and `local` otherwise. Omitted, nothing is written.
1301
1302
  */
1302
1303
  runEvents?: { write: (line: string) => void; runtime?: RunEventRuntime; runOrigin?: RunOrigin };
1304
+ /**
1305
+ * What ran the Op, when a runner says so itself (#4094): `chant ci run`
1306
+ * runs a pull-request job as an Op and passes `kind: "pipeline"`,
1307
+ * `runtime: "ci"` and `runOrigin: { forge: "chant" }`. The run's events
1308
+ * carry all three, kept on this host as well as written to
1309
+ * {@link runEvents}, and the run record carries `runOrigin` in place of
1310
+ * the CI run the environment names. Omitted, the run is an `op` run named
1311
+ * as described on {@link runEvents}.
1312
+ */
1313
+ runner?: { kind: RunEventKind; runtime: RunEventRuntime; runOrigin: RunOrigin };
1314
+ /**
1315
+ * Says why a failed run failed (#4094), given the run's step records once
1316
+ * it has settled `fail`. What it returns is stored on the record as
1317
+ * `failure`, before the record is signed, and carried on the `run-settle`
1318
+ * event. `chant ci run` reads the job's pull-request report here, while
1319
+ * the checkout it ran in still exists.
1320
+ */
1321
+ describeFailure?: (records: readonly StepRecord[]) => OpRunRecord["failure"];
1303
1322
  /**
1304
1323
  * Append the run's {@link OpRunRecord} to the run ledger
1305
1324
  * (`../lifecycle/run-ledger.ts`) when the run settles (#2118). Opt-in on
@@ -1359,6 +1378,17 @@ export interface RunOpOptions {
1359
1378
  * needs one. The local runtime passes the configured lexicons' contracts.
1360
1379
  */
1361
1380
  contracts?: ReadonlyMap<string, ActivityContract>;
1381
+ /**
1382
+ * Redacts secret values (#4096) from what the run writes about itself:
1383
+ * each {@link runEvents} line and each line kept on this host, each
1384
+ * {@link stepOutput} line and step label, and the run record, its step
1385
+ * records and its `failure`, before {@link signRecord} signs it and the
1386
+ * ledger stores it. The step records on the result are redacted too. This
1387
+ * is the one place a run's writes pass through, so a writer added here
1388
+ * later is redacted with the rest. `chant ci run` passes one built from
1389
+ * the host's secrets file (../workspace/ci-secrets.ts).
1390
+ */
1391
+ redact?: Redact;
1362
1392
  }
1363
1393
 
1364
1394
  /**
@@ -1419,7 +1449,11 @@ export async function runOpLocally(
1419
1449
  const events: { stream?: RunEventStream } = {};
1420
1450
  try {
1421
1451
  const stepConsole = options.stepOutput
1422
- ? { write: options.stepOutput, ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}) }
1452
+ ? {
1453
+ write: options.stepOutput,
1454
+ ...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}),
1455
+ ...(options.redact ? { redact: options.redact } : {}),
1456
+ }
1423
1457
  : undefined;
1424
1458
  return await withLiveRun(live, () =>
1425
1459
  withRunConsole(stepConsole, () =>
@@ -1460,11 +1494,14 @@ async function runOpInTurn(
1460
1494
  const store = options.ledger
1461
1495
  ? await openRunEventStore(options.ledger.cwd ?? options.cwd ?? process.cwd(), runId)
1462
1496
  : undefined;
1463
- const eventsWrite = teeLines(options.runEvents?.write, store?.write.bind(store));
1497
+ const teed = teeLines(options.runEvents?.write, store?.write.bind(store));
1498
+ // #4096: every event line, to the caller and to this host's store alike, is redacted once, here.
1499
+ const redact = options.redact;
1500
+ const eventsWrite = teed && redact ? (line: string) => teed(redactText(line, redact)) : teed;
1464
1501
  const stream = eventsWrite
1465
1502
  ? openRunEventStream(
1466
1503
  runEventRunOf({
1467
- kind: "op",
1504
+ kind: options.runner?.kind ?? "op",
1468
1505
  name: config.name,
1469
1506
  runId,
1470
1507
  ...(commit ? { gitSha: commit } : {}),
@@ -1472,6 +1509,7 @@ async function runOpInTurn(
1472
1509
  steward: options.steward !== undefined,
1473
1510
  ...(options.runEvents?.runtime ? { runtime: options.runEvents.runtime } : {}),
1474
1511
  ...(options.runEvents?.runOrigin ? { runOrigin: options.runEvents.runOrigin } : {}),
1512
+ ...(options.runner ? { runtime: options.runner.runtime, runOrigin: options.runner.runOrigin } : {}),
1475
1513
  }),
1476
1514
  eventsWrite,
1477
1515
  )
@@ -1601,7 +1639,7 @@ async function runOpInTurn(
1601
1639
  point?: WaitingPoint,
1602
1640
  ): Promise<OpRunRecord> => {
1603
1641
  const ended = options.now ?? new Date().toISOString();
1604
- const input = buildRunRecord(config, settled, {
1642
+ let input = buildRunRecord(config, settled, {
1605
1643
  started: startedAt,
1606
1644
  ended,
1607
1645
  status,
@@ -1612,9 +1650,22 @@ async function runOpInTurn(
1612
1650
  ...(options.steward ? { steward: options.steward } : {}),
1613
1651
  ...(resumed ? { resumed } : {}),
1614
1652
  ...(commit ? { gitSha: commit } : {}),
1615
- ...(ciRun ? { runOrigin: ciRun.runOrigin } : {}),
1653
+ ...(options.runner ? { runOrigin: options.runner.runOrigin } : ciRun ? { runOrigin: ciRun.runOrigin } : {}),
1616
1654
  phaseDurations,
1617
1655
  });
1656
+ if (status === "fail" && options.describeFailure) {
1657
+ const failure = options.describeFailure(settled);
1658
+ if (failure) input.failure = failure;
1659
+ }
1660
+ // #4096: the record is redacted before it is signed, so the stored record is the one the statement covers.
1661
+ // The step records on the result are redacted in place, so a caller printing them prints no secret.
1662
+ if (redact) {
1663
+ input = redactDeep(input, redact);
1664
+ for (const r of settled) {
1665
+ if (r.error !== undefined) r.error = redact(r.error);
1666
+ if (r.args) r.args = redactDeep(r.args, redact);
1667
+ }
1668
+ }
1618
1669
  let record: OpRunRecord = { version: 1, ...input, id: runId };
1619
1670
  if (options.signRecord) {
1620
1671
  try {
@@ -1627,7 +1678,7 @@ async function runOpInTurn(
1627
1678
  }
1628
1679
  // The stream settles as the run does, with the first failing step's error.
1629
1680
  const firstError = status === "fail" ? settled.find((r) => r.status === "fail")?.error : undefined;
1630
- stream?.settle(status === "fail" ? "failed" : status, firstError);
1681
+ stream?.settle(status === "fail" ? "failed" : status, firstError, input.failure);
1631
1682
  if (!options.ledger) return record;
1632
1683
  try {
1633
1684
  return (await appendRunRecord(input, options.ledger)).record;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Redaction of secret values (#4096): what a run writes about itself, with
3
+ * each secret value replaced by `***`.
4
+ *
5
+ * The executor (./local-executor.ts) takes a {@link Redact} and applies it
6
+ * where a run writes: each run-event line, each line a step prints, and the
7
+ * run record before it is signed and stored. `chant ci run` builds one from
8
+ * the host's secrets file and the job's secret-looking variables
9
+ * (../workspace/ci-secrets.ts).
10
+ *
11
+ * A value shorter than {@link REDACT_MIN_LENGTH} characters is not
12
+ * redacted: a two- or three-character value matches too much ordinary text.
13
+ */
14
+
15
+ /** What every redacted value is replaced with. */
16
+ export const REDACTED = "***";
17
+
18
+ /** The shortest value redacted. Shorter values are left as they are. */
19
+ export const REDACT_MIN_LENGTH = 4;
20
+
21
+ /** A text with every secret value it contains replaced by {@link REDACTED}. */
22
+ export type Redact = (text: string) => string;
23
+
24
+ const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+
26
+ /**
27
+ * The redactor for `values`, or undefined when none is long enough to
28
+ * redact. A value that spans lines is also redacted line by line, since
29
+ * step output reaches a reader one line at a time.
30
+ */
31
+ export function redactorOf(values: Iterable<string>): Redact | undefined {
32
+ const all = new Set<string>();
33
+ const add = (v: string): void => {
34
+ if (v.length < REDACT_MIN_LENGTH) return;
35
+ all.add(v);
36
+ // As a JSON string spells it, for a value with a quote, a backslash or a control character in it.
37
+ all.add(JSON.stringify(v).slice(1, -1));
38
+ };
39
+ for (const value of values) {
40
+ add(value);
41
+ if (value.includes("\n")) for (const line of value.split(/\r?\n/)) add(line.trim());
42
+ }
43
+ if (all.size === 0) return undefined;
44
+ // Longest first, so a value that contains another is replaced whole.
45
+ const pattern = new RegExp([...all].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "g");
46
+ return (text) => (typeof text === "string" && text.length >= REDACT_MIN_LENGTH ? text.replace(pattern, REDACTED) : text);
47
+ }
48
+
49
+ /** `value` with `redact` applied to every string in it, keys included. Numbers, booleans and nulls are kept. */
50
+ export function redactDeep<T>(value: T, redact: Redact): T {
51
+ if (typeof value === "string") return redact(value) as T;
52
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, redact)) as T;
53
+ if (value !== null && typeof value === "object") {
54
+ const out: Record<string, unknown> = {};
55
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[redact(k)] = redactDeep(v, redact);
56
+ return out as T;
57
+ }
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * A JSON value's text, as `JSON.stringify` wrote it, with `redact` applied to
63
+ * its strings: parsed, redacted and written again, so a short value such as
64
+ * `true` that is also a secret doesn't break the document. Undefined when
65
+ * `text` is not JSON.
66
+ */
67
+ function redactJsonText(text: string, redact: Redact): string | undefined {
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(text);
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ if (parsed === null || typeof parsed !== "object") return undefined;
75
+ const indent = /^[[{]\r?\n(\s+)/.exec(text)?.[1]?.length;
76
+ const trailing = /\s*$/.exec(text)?.[0] ?? "";
77
+ return `${JSON.stringify(redactDeep(parsed, redact), null, indent)}${trailing}`;
78
+ }
79
+
80
+ /**
81
+ * A file's or line's text redacted: a JSON document as JSON, each line of a
82
+ * JSON Lines text as JSON, and any other text, or line, as text. The text
83
+ * comes back unchanged when it holds no secret.
84
+ */
85
+ export function redactText(text: string, redact: Redact): string {
86
+ if (redact(text) === text) return text;
87
+ const whole = redactJsonText(text, redact);
88
+ if (whole !== undefined) return whole;
89
+ return text
90
+ .split("\n")
91
+ .map((line) => (redact(line) === line ? line : (redactJsonText(line, redact) ?? redact(line))))
92
+ .join("\n");
93
+ }
package/src/op/runtime.ts CHANGED
@@ -183,6 +183,48 @@ export interface OpRunRecord {
183
183
  * written before 0.124.28.
184
184
  */
185
185
  statement?: { payloadType: string; payload: string; signatures: { keyid: string; sig: string }[] };
186
+ /**
187
+ * Why a failed run failed (#4094), for a reader that acts on it without
188
+ * reading the log: a short `code`, a one-line `reason`, the phase that
189
+ * failed and the exit code of the command that failed. `chant ci run`
190
+ * writes it on every failed job, from the job's pull-request report when
191
+ * it wrote one (`not-a-reviewer`, `plan-failed`, `failed` and the other
192
+ * refusals and statuses), else `command-failed`, `setup-failed` or
193
+ * `build-failed`. Optional within contract 1, written by chant
194
+ * {@link OP_RUN_FAILURE_FLOOR} and newer; absent on a run that passed and
195
+ * on a run nothing explained.
196
+ */
197
+ failure?: OpRunFailureReason;
198
+ }
199
+
200
+ /** Why a run failed, on {@link OpRunRecord.failure} and on the run's `run-settle` event (#4094). */
201
+ export interface OpRunFailureReason {
202
+ /** A short code: a pull-request report's refusal or status, or `command-failed`, `setup-failed`, `build-failed`. */
203
+ code: string;
204
+ /** One line a person reads. */
205
+ reason: string;
206
+ /** The phase that failed, when one did. */
207
+ phase?: string;
208
+ /** The exit code of the command that failed, when it exited with one. */
209
+ exitCode?: number;
210
+ }
211
+
212
+ /** The first chant that writes {@link OpRunRecord.failure} and `failure` on `run-settle` (#4094). */
213
+ export const OP_RUN_FAILURE_FLOOR = "0.124.30";
214
+
215
+ /** `value` as an {@link OpRunFailureReason}, or undefined when it is not one, so a reader drops a malformed field and keeps the record. */
216
+ export function opRunFailureOf(value: unknown): OpRunFailureReason | undefined {
217
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
218
+ const v = value as Record<string, unknown>;
219
+ if (typeof v.code !== "string" || v.code === "" || typeof v.reason !== "string") return undefined;
220
+ if (v.phase !== undefined && typeof v.phase !== "string") return undefined;
221
+ if (v.exitCode !== undefined && !Number.isInteger(v.exitCode)) return undefined;
222
+ return {
223
+ code: v.code,
224
+ reason: v.reason,
225
+ ...(typeof v.phase === "string" ? { phase: v.phase } : {}),
226
+ ...(typeof v.exitCode === "number" ? { exitCode: v.exitCode } : {}),
227
+ };
186
228
  }
187
229
 
188
230
  /** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
@@ -41,6 +41,8 @@ interface StepConsole {
41
41
  export interface RunConsole {
42
42
  write: (line: string) => void;
43
43
  heartbeatMs?: number;
44
+ /** Applied to each line before it is written, and to a step's call before it is cut to a label (#4096). */
45
+ redact?: (text: string) => string;
44
46
  }
45
47
 
46
48
  const store = new AsyncLocalStorage<StepConsole>();
@@ -60,7 +62,10 @@ function formatArgs(args: Record<string, unknown>): string {
60
62
  /** The name a step's lines carry: its id, else `fn(args)` on one line, cut to {@link MAX_LABEL}. */
61
63
  export function stepLabel(fn: string, args: Record<string, unknown>, id?: string): string {
62
64
  if (id) return id;
63
- const call = `${fn}(${formatArgs(args)})`.replace(/\s+/g, " ");
65
+ // A secret in the call is redacted before the label is cut, so a cut never leaves part of one.
66
+ const redact = runStore.getStore()?.redact;
67
+ const raw = `${fn}(${formatArgs(args)})`;
68
+ const call = (redact ? redact(raw) : raw).replace(/\s+/g, " ");
64
69
  return call.length > MAX_LABEL ? `${call.slice(0, MAX_LABEL - 4)}...)` : call;
65
70
  }
66
71
 
@@ -72,7 +77,7 @@ export function stepLabel(fn: string, args: Record<string, unknown>, id?: string
72
77
  export async function withStepConsole<T>(opts: { phase: string; label: string }, fn: () => Promise<T>): Promise<T> {
73
78
  const run = runStore.getStore();
74
79
  if (!run) return fn();
75
- const { write } = run;
80
+ const write = run.redact ? (line: string) => run.write(run.redact!(line)) : run.write;
76
81
  const prefix = `[${opts.phase}] ${opts.label}`;
77
82
  const started = Date.now();
78
83
  let spoke = false;
@@ -782,6 +782,31 @@ describe("pull requests over REST (#3798)", () => {
782
782
  expect((calls[1].body as { target_branch: string }).target_branch).toBe("main");
783
783
  });
784
784
 
785
+ test("openPullRequests lists the heads into a base and marks forks; branchHead reads a branch's commit (#4095)", async () => {
786
+ const f = fakeFetch({
787
+ "GET /api/v1/repos/acme/infra/pulls": [
788
+ { number: 12, head: { ref: "x", sha: "c".repeat(40), repo: { full_name: "mallory/infra" } }, base: { ref: "main", sha: "b".repeat(40) }, user: { login: "mallory" } },
789
+ { number: 4, head: { ref: "y", sha: "d".repeat(40), repo: { full_name: "acme/infra" } }, base: { ref: "main", sha: "b".repeat(40) }, user: { login: "driver" } },
790
+ { number: 5, head: { ref: "z", sha: "e".repeat(40) }, base: { ref: "release" } },
791
+ ],
792
+ "GET /api/v1/repos/acme/infra/branches/main": { name: "main", commit: { id: "f".repeat(40) } },
793
+ });
794
+ const forge = forgejoForge({ ...fjOpts, fetch: f.fetch });
795
+ expect(await forge.openPullRequests!("main")).toEqual([
796
+ { number: 4, head: "y", headSha: "d".repeat(40), base: "main", baseSha: "b".repeat(40), author: "driver", fork: false },
797
+ { number: 12, head: "x", headSha: "c".repeat(40), base: "main", baseSha: "b".repeat(40), author: "mallory", fork: true },
798
+ ]);
799
+ expect(f.calls[0].url).toBe("https://forge.example/api/v1/repos/acme/infra/pulls?state=open&limit=50&page=1");
800
+ expect(await forge.branchHead!("main")).toBe("f".repeat(40));
801
+ expect(await forge.branchHead!("gone")).toBeUndefined();
802
+
803
+ const g = fakeFetch({ "GET /repos/acme/infra/pulls": [], "GET /repos/acme/infra/branches/main": { commit: { sha: SHA } } });
804
+ const github = githubForge({ ...gh, fetch: g.fetch });
805
+ expect(await github.openPullRequests!("main")).toEqual([]);
806
+ expect(g.calls[0].url).toBe("https://api.github.com/repos/acme/infra/pulls?state=open&base=main&per_page=100&page=1");
807
+ expect(await github.branchHead!("main")).toBe(SHA);
808
+ });
809
+
785
810
  test("findPullRequest takes the oldest open one from this repository, not a fork's", async () => {
786
811
  const f = fakeFetch({
787
812
  "GET /api/v1/repos/acme/infra/pulls": [
@@ -887,3 +912,32 @@ describe("pull requests over REST (#3798)", () => {
887
912
  await expect(forgejoForge({ ...fjOpts, fetch }).openPullRequest({ head: "h", title: "t", body: "b" })).rejects.toThrow(/no default branch.*pass base/);
888
913
  });
889
914
  });
915
+
916
+ describe("setCommitStatus, a runner job's status (#4097)", () => {
917
+ const long = "x".repeat(300);
918
+
919
+ test("GitHub posts state, context, a description cut to 140 and target_url", async () => {
920
+ const { fetch, calls } = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: {} });
921
+ const forge = githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t0k", fetch });
922
+ await forge.setCommitStatus!(SHA, { context: "chant/pr-plan", state: "failure", description: long, targetUrl: "https://host/reports/7/abc/" });
923
+ expect(calls).toHaveLength(1);
924
+ expect(calls[0]).toMatchObject({ method: "POST", url: `https://api.github.com/repos/acme/infra/statuses/${SHA}` });
925
+ expect(calls[0].headers.Authorization).toBe("Bearer t0k");
926
+ expect(calls[0].body).toEqual({ state: "failure", context: "chant/pr-plan", description: "x".repeat(140), target_url: "https://host/reports/7/abc/" });
927
+ });
928
+
929
+ test("Forgejo posts to /repos/{owner}/{repo}/statuses/{sha} under /api/v1, and omits target_url when there is none", async () => {
930
+ const { fetch, calls } = fakeFetch({ [`POST /api/v1/repos/acme/infra/statuses/${SHA}`]: {} });
931
+ const forge = forgejoForge({ apiBase: "https://codeberg.org/api/v1", repo: "acme/infra", token: "t0k", host: "codeberg.org", fetch });
932
+ await forge.setCommitStatus!(SHA, { context: "chant/pr-apply", state: "pending", description: "pr-apply running" });
933
+ expect(calls[0]).toMatchObject({ method: "POST", url: `https://codeberg.org/api/v1/repos/acme/infra/statuses/${SHA}` });
934
+ expect(calls[0].headers.Authorization).toBe("token t0k");
935
+ expect(calls[0].body).toEqual({ state: "pending", context: "chant/pr-apply", description: "pr-apply running" });
936
+ });
937
+
938
+ test("a refusal throws, and GitLab's client has none", async () => {
939
+ const { fetch } = fakeFetch({ [`POST /repos/acme/infra/statuses/${SHA}`]: 403 });
940
+ await expect(githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch }).setCommitStatus!(SHA, { context: "c", state: "success", description: "d" })).rejects.toBeInstanceOf(ForgeApiError);
941
+ expect(gitlabForge({ apiBase: "https://gl/api/v4", project: "42", token: "t", fetch }).setCommitStatus).toBeUndefined();
942
+ });
943
+ });
package/src/pr-forge.ts CHANGED
@@ -61,6 +61,16 @@ export interface ForgeStatus {
61
61
  pipelineId?: number;
62
62
  }
63
63
 
64
+ /** A runner job's commit status (#4097): the states GitHub and Forgejo both keep. */
65
+ export interface CommitStatus {
66
+ context: string;
67
+ state: "pending" | "success" | "failure" | "error";
68
+ /** Cut to 140 characters. */
69
+ description: string;
70
+ /** Where the status links to. Omitted from the request when absent. */
71
+ targetUrl?: string;
72
+ }
73
+
64
74
  /** A commit status's state, as a caller names it. */
65
75
  export type ForgeStatusState = "pending" | "running" | "success" | "failed" | "failure" | "canceled" | "error";
66
76
 
@@ -212,6 +222,23 @@ export interface PullRequest {
212
222
  base?: string;
213
223
  }
214
224
 
225
+ /** An open pull request as `chant ci serve` polls it (#4095). */
226
+ export interface OpenPullRequest {
227
+ number: number;
228
+ /** Its head branch. */
229
+ head: string;
230
+ /** The commit its head branch points at. */
231
+ headSha: string;
232
+ /** The branch it merges into. */
233
+ base: string;
234
+ /** The base branch's commit as the forge last measured the pull request against it. */
235
+ baseSha?: string;
236
+ /** Who opened it. */
237
+ author?: string;
238
+ /** Its head branch lives in another repository, a fork. */
239
+ fork: boolean;
240
+ }
241
+
215
242
  /** What {@link PrForge.mergePullRequest} merges, and how. */
216
243
  export interface MergeOptions {
217
244
  /** The head commit the caller reviewed. The merge happens only while the pull request's head is still this commit. */
@@ -292,6 +319,14 @@ export interface PrForge {
292
319
  */
293
320
  addComment(pr: number, body: string): Promise<AddedComment>;
294
321
  setStatus(sha: string, status: ForgeStatus): Promise<void>;
322
+ /**
323
+ * GitHub and Forgejo (#4097): set a commit status from a runner's job, one
324
+ * of `pending`, `success`, `failure` or `error`. The request is the one
325
+ * {@link setStatus} sends (`POST /repos/{owner}/{repo}/statuses/{sha}`), with
326
+ * the description cut to 140 characters and `target_url` only when given.
327
+ * `chant ci run` posts a job's progress through it.
328
+ */
329
+ setCommitStatus?(sha: string, status: CommitStatus): Promise<void>;
295
330
  /**
296
331
  * Keep one OPEN tracking issue for `marker` in line with `finding`: open
297
332
  * it, update its title and body, or, when `finding` is `null` and
@@ -339,6 +374,13 @@ export interface PrForge {
339
374
  * still throw.
340
375
  */
341
376
  mergePullRequest(pr: number, opts: MergeOptions): Promise<MergeResult>;
377
+ /**
378
+ * GitHub and Forgejo (#4095): every open pull request into `base`, with
379
+ * its head commit, oldest first. `chant ci serve` polls it.
380
+ */
381
+ openPullRequests?(base: string): Promise<OpenPullRequest[]>;
382
+ /** GitHub and Forgejo (#4095): the commit `branch` points at, or undefined when there is no such branch. */
383
+ branchHead?(branch: string): Promise<string | undefined>;
342
384
  }
343
385
 
344
386
  /** A GitHub or Forgejo client. */
@@ -590,6 +632,14 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
590
632
  await api.call("PATCH", `${repo}/issues/${n}`, { state: "closed" });
591
633
  },
592
634
  };
635
+ const postStatus = async (sha: string, state: ForgeStatusState, context: string, description: string, url?: string): Promise<void> => {
636
+ await api.call("POST", `${repo}/statuses/${sha}`, {
637
+ state: statusStateFor(kind, state),
638
+ context,
639
+ description: description.slice(0, GITHUB_DESCRIPTION),
640
+ ...(url ? { target_url: url } : {}),
641
+ });
642
+ };
593
643
  return {
594
644
  kind,
595
645
  host,
@@ -597,12 +647,10 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
597
647
  principalOf: (login) => forgePrincipalOf(kind, host, login),
598
648
  ...threadCalls(t),
599
649
  async setStatus(sha, status) {
600
- await api.call("POST", `${repo}/statuses/${sha}`, {
601
- state: statusStateFor(kind, status.state),
602
- context: status.context,
603
- description: status.description.slice(0, GITHUB_DESCRIPTION),
604
- ...(status.url ? { target_url: status.url } : {}),
605
- });
650
+ await postStatus(sha, status.state, status.context, status.description, status.url);
651
+ },
652
+ async setCommitStatus(sha, status) {
653
+ await postStatus(sha, status.state, status.context, status.description, status.targetUrl);
606
654
  },
607
655
  async approvers(pr) {
608
656
  // A reviewer's newest deciding review is their standing one. A comment
@@ -667,6 +715,44 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
667
715
  const open = listed.filter((p) => fromHere(p, head));
668
716
  return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
669
717
  },
718
+ async openPullRequests(base) {
719
+ // GitHub filters by base server-side; Forgejo's list is matched here.
720
+ const query = kind === "github" ? `?state=open&base=${encodeURIComponent(base)}` : "?state=open";
721
+ let listed: unknown[];
722
+ try {
723
+ listed = await api.list(`${repo}/pulls${query}`, pageParams, pageSize);
724
+ } catch (err) {
725
+ // Forgejo answers 404 while it counts the repository as empty, as findPullRequest notes.
726
+ if (kind !== "forgejo" || !(err instanceof ForgeApiError) || err.status !== 404) throw err;
727
+ await api.call("GET", repo);
728
+ return [];
729
+ }
730
+ const out: OpenPullRequest[] = [];
731
+ for (const v of listed) {
732
+ if (!isObject(v) || !isObject(v.head) || !isObject(v.base)) continue;
733
+ const number = num(v.number);
734
+ const head = str(v.head.ref);
735
+ const headSha = str(v.head.sha);
736
+ const into = str(v.base.ref);
737
+ if (number === undefined || !head || !headSha || into !== base) continue;
738
+ const baseSha = str(v.base.sha);
739
+ const author = isObject(v.user) ? str(v.user.login) : undefined;
740
+ out.push({ number, head, headSha, base: into, ...(baseSha ? { baseSha } : {}), ...(author ? { author } : {}), fork: !fromHere(v, head) });
741
+ }
742
+ return out.sort((a, b) => a.number - b.number);
743
+ },
744
+ async branchHead(branch) {
745
+ let b: unknown;
746
+ try {
747
+ b = await api.call("GET", `${repo}/branches/${branch.split("/").map(encodeURIComponent).join("/")}`);
748
+ } catch (err) {
749
+ if (err instanceof ForgeApiError && err.status === 404) return undefined;
750
+ throw err;
751
+ }
752
+ // GitHub names the commit `sha`, Forgejo `id`.
753
+ const commit = isObject(b) && isObject(b.commit) ? b.commit : undefined;
754
+ return commit ? str(commit.sha) ?? str(commit.id) : undefined;
755
+ },
670
756
  async editPullRequest(pr, change) {
671
757
  return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
672
758
  },
@@ -13,6 +13,7 @@ import { composeChangeSet, type ChangeSetPart } from "./change-set";
13
13
  import {
14
14
  describePlanChanged,
15
15
  memberCounts,
16
+ PR_REPORT_DEPLOYED_OUTPUTS_FLOOR,
16
17
  PR_REPORT_SCHEMA_ID,
17
18
  prApproveCommand,
18
19
  prNoteMarker,
@@ -109,6 +110,9 @@ const resumedReport: PrReport = {
109
110
  resumed: { applied: ["a"] },
110
111
  };
111
112
 
113
+ /** A plan whose members read `net` and `a` from the deployed estate (#4104). */
114
+ const deployedReport: PrReport = { ...planReport, deployedOutputs: { a: ["id"], net: ["cidr"], dns: [] } };
115
+
112
116
  describe("the pr-report schema", () => {
113
117
  test("is a valid schema and names this module's id", () => {
114
118
  expect(validSchema(schema)).toBe(true);
@@ -116,7 +120,7 @@ describe("the pr-report schema", () => {
116
120
  });
117
121
 
118
122
  test("every stage's report validates, and its change set validates against the change-set schema", () => {
119
- for (const report of [planReport, refusedReport, appliedReport, resumedReport]) {
123
+ for (const report of [planReport, refusedReport, appliedReport, resumedReport, deployedReport]) {
120
124
  expectValid(report);
121
125
  expectValidChangeSet(report.changeSet);
122
126
  expect(report.changeSet.digest).toBe(report.digest);
@@ -158,6 +162,14 @@ describe("the note", () => {
158
162
  expect(renderPrNote(appliedReport)).not.toContain("Resumed");
159
163
  });
160
164
 
165
+ test("a plan that read dependencies from the deployed estate names each and its outputs (#4104)", () => {
166
+ const note = renderPrNote(deployedReport);
167
+ expect(note).toContain("Planned against outputs read from the deployed estate: `a` (`id`); `net` (`cidr`); `dns` (none yet).");
168
+ expect(renderPrNote(planReport)).not.toContain("deployed estate");
169
+ expect(PR_REPORT_DEPLOYED_OUTPUTS_FLOOR).toBe("0.124.30");
170
+ expect(schema.required).not.toContain("deployedOutputs");
171
+ });
172
+
161
173
  test("a small limit leaves the summary out rather than cutting the table", () => {
162
174
  const note = renderPrNote(planReport, { limit: 800 });
163
175
  expect(note).toContain("| `app` |");
package/src/pr-loop.ts CHANGED
@@ -46,6 +46,9 @@ export const PR_REPORT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace
46
46
  /** The read-contract version the report follows. */
47
47
  export const PR_REPORT_CONTRACT = 1;
48
48
 
49
+ /** The first chant whose report can carry `deployedOutputs` (#4104). */
50
+ export const PR_REPORT_DEPLOYED_OUTPUTS_FLOOR = "0.124.30";
51
+
49
52
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
50
53
  export const PR_APPLY_GATE = "pr-apply";
51
54
 
@@ -183,6 +186,14 @@ export interface PrReport {
183
186
  * the rest under the same approval and did not run them again.
184
187
  */
185
188
  resumed?: { applied: string[] };
189
+ /**
190
+ * Per dependency a member was planned against, the names of the outputs
191
+ * read from the deployed estate (#4104), sorted. A dependency outside the
192
+ * change is here too. Seeded outputs (`--seed-outputs`, a resume record)
193
+ * are not. Absent when nothing was read. Added within contract 1 by chant
194
+ * {@link PR_REPORT_DEPLOYED_OUTPUTS_FLOOR}.
195
+ */
196
+ deployedOutputs?: Record<string, string[]>;
186
197
  approval: PrApproval;
187
198
  /** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
188
199
  selection: {
@@ -324,6 +335,11 @@ export function renderPrNote(report: PrReport, options: { limit?: number; approv
324
335
  if (report.selection.unclaimed.length > 0) {
325
336
  out.push("", `Changed, and deployed by no component: ${report.selection.unclaimed.map(code).join(", ")}.`);
326
337
  }
338
+ const deployed = Object.entries(report.deployedOutputs ?? {});
339
+ if (deployed.length > 0) {
340
+ const each = deployed.map(([dep, names]) => `${code(dep)} (${names.length > 0 ? names.map(code).join(", ") : "none yet"})`);
341
+ out.push("", `Planned against outputs read from the deployed estate: ${each.join("; ")}.`);
342
+ }
327
343
  if (report.status !== "nothing") {
328
344
  out.push("", `Plan digest: ${code(report.digest)}`, "", ...approvalLines(report, options.approver ?? "<you>"));
329
345
  }
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { spawn } from "node:child_process";
38
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
38
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
39
39
  import { dirname, isAbsolute, join } from "node:path";
40
40
  import { fileURLToPath } from "node:url";
41
41
 
@@ -120,6 +120,58 @@ export function reportPut(target: ReportTarget, aws: AwsCli = realAwsCli): Repor
120
120
  };
121
121
  }
122
122
 
123
+ /** Reads one object: `key` below the URL. Resolves null for an object that is not there, and rejects with a line a person can read for any other failure. */
124
+ export type ReportGet = (key: string) => Promise<string | null>;
125
+
126
+ /** Runs the AWS CLI with `args` and resolves its stdout, rejecting with its error line on a non-zero exit. */
127
+ export type AwsRead = (args: string[]) => Promise<string>;
128
+
129
+ /** The AWS CLI on PATH, read for the body `aws s3 cp <s3 url> -` prints. */
130
+ export const realAwsRead: AwsRead = (args) =>
131
+ new Promise((resolveRead, reject) => {
132
+ const child = spawn(AWS_BINARY, args, { stdio: ["ignore", "pipe", "pipe"] });
133
+ const out: Buffer[] = [];
134
+ let stderr = "";
135
+ child.stdout.on("data", (chunk: Buffer) => out.push(chunk));
136
+ child.stderr.on("data", (chunk: Buffer) => {
137
+ if (stderr.length < 4096) stderr += chunk.toString("utf8");
138
+ });
139
+ child.on("error", (err: NodeJS.ErrnoException) =>
140
+ reject(new Error(err.code === "ENOENT" ? "the AWS CLI (aws) is not on PATH; an s3 report URL is read with it" : err.message)),
141
+ );
142
+ child.on("close", (code) => {
143
+ if (code === 0) return resolveRead(Buffer.concat(out).toString("utf8"));
144
+ const line = stderr.trim().split("\n").filter(Boolean).at(-1) ?? "";
145
+ reject(new Error(`aws s3 cp exited ${code}${line ? `: ${line.slice(0, 300)}` : ""}`));
146
+ });
147
+ });
148
+
149
+ /** What the AWS CLI says about an object that does not exist. */
150
+ const S3_ABSENT = /\(404\)|Not Found|NoSuchKey|does not exist/i;
151
+
152
+ /** A get for `target`. `aws` replaces the AWS CLI in a test. */
153
+ export function reportGet(target: ReportTarget, aws: AwsRead = realAwsRead): ReportGet {
154
+ if (target.scheme === "file") {
155
+ return async (key) => {
156
+ try {
157
+ return readFileSync(join(target.dir, ...key.split("/")), "utf-8");
158
+ } catch (err) {
159
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
160
+ throw err;
161
+ }
162
+ };
163
+ }
164
+ return async (key) => {
165
+ const object = `s3://${target.bucket}/${target.prefix ? `${target.prefix}/` : ""}${key}`;
166
+ try {
167
+ return await aws(["s3", "cp", object, "-", "--only-show-errors"]);
168
+ } catch (err) {
169
+ if (S3_ABSENT.test(messageOf(err))) return null;
170
+ throw err;
171
+ }
172
+ };
173
+ }
174
+
123
175
  /** A stage's upload: its events as they are written, and its files at the end. */
124
176
  export interface PrReportUpload {
125
177
  /** The folder the stage's files go to: `<url>/<key>`. */