@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.
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +4 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +27 -5
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +64 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +99 -0
- package/dist/cli/registry.d.ts +35 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +24 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +16 -0
- package/dist/components/pr-run.d.ts +20 -2
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +26 -13
- package/dist/components/wave-plan.d.ts +11 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +26 -0
- package/dist/lifecycle/release-ledger.d.ts +8 -3
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +2 -0
- package/dist/lifecycle/run-ledger.d.ts +1 -1
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +9 -0
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +1 -0
- package/dist/op/activities/shell.d.ts +6 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +1 -1
- package/dist/op/local-executor.d.ts +35 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +31 -6
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/runtime.d.ts +27 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtime.js +20 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/pr-forge.d.ts +40 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +56 -6
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +7 -0
- package/dist/pr-report-url.d.ts +8 -0
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +47 -1
- package/dist/run-event-stream.d.ts +2 -2
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +2 -2
- package/dist/run-event.d.ts +15 -0
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +171 -171
- package/dist/workspace/ci-green-forge.d.ts +2 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +2 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +4 -2
- package/dist/workspace/ci-run-index.d.ts +96 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +162 -0
- package/dist/workspace/ci-run-index.schema.json +50 -0
- package/dist/workspace/ci-run.d.ts +299 -0
- package/dist/workspace/ci-run.d.ts.map +1 -0
- package/dist/workspace/ci-run.js +691 -0
- package/dist/workspace/ci-runner-key.d.ts +120 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +217 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +227 -0
- package/dist/workspace/ci-serve.d.ts.map +1 -0
- package/dist/workspace/ci-serve.js +468 -0
- package/dist/workspace/pr-report.schema.json +5 -0
- package/dist/workspace/reason-codes.d.ts +6 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +7 -2
- package/dist/workspace/run-event.schema.json +14 -3
- package/dist/workspace/status-stewards.d.ts +2 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +1 -0
- package/dist/workspace/status.schema.json +12 -1
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/package.json +1 -1
- package/src/cli/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +4 -1
- package/src/cli/handlers/fan-out.test.ts +82 -0
- package/src/cli/handlers/fan-out.ts +34 -5
- package/src/cli/handlers/pr.ts +5 -0
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +64 -0
- package/src/cli/parse-args.ts +71 -0
- package/src/cli/registry.ts +35 -1
- package/src/components/pr-pipeline.ts +32 -0
- package/src/components/pr-run.test.ts +127 -0
- package/src/components/pr-run.ts +32 -12
- package/src/components/wave-plan.ts +30 -0
- package/src/lifecycle/release-ledger.ts +9 -3
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/shell.ts +7 -1
- package/src/op/local-executor.ts +58 -7
- package/src/op/redact.ts +93 -0
- package/src/op/runtime.ts +42 -0
- package/src/op/step-console.ts +7 -2
- package/src/pr-forge.test.ts +54 -0
- package/src/pr-forge.ts +92 -6
- package/src/pr-loop.test.ts +13 -1
- package/src/pr-loop.ts +16 -0
- package/src/pr-report-url.ts +53 -1
- package/src/run-event-stream.ts +4 -3
- package/src/run-event.ts +17 -0
- package/src/workspace/ci-green-forge.ts +2 -0
- package/src/workspace/ci-green.ts +6 -2
- package/src/workspace/ci-run-index.schema.json +50 -0
- package/src/workspace/ci-run-index.test.ts +353 -0
- package/src/workspace/ci-run-index.ts +192 -0
- package/src/workspace/ci-run.test.ts +716 -0
- package/src/workspace/ci-run.ts +833 -0
- package/src/workspace/ci-runner-key.test.ts +166 -0
- package/src/workspace/ci-runner-key.ts +250 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +367 -0
- package/src/workspace/ci-serve.ts +574 -0
- package/src/workspace/pr-report.schema.json +5 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +7 -2
- package/src/workspace/run-event.schema.json +14 -3
- package/src/workspace/status-stewards.ts +3 -0
- package/src/workspace/status.schema.json +12 -1
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
package/src/op/local-executor.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
? {
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/op/redact.ts
ADDED
|
@@ -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. */
|
package/src/op/step-console.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/src/pr-forge.test.ts
CHANGED
|
@@ -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
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
},
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -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
|
}
|
package/src/pr-report-url.ts
CHANGED
|
@@ -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>`. */
|