@intentius/chant 0.124.50 → 0.124.52
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/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +2 -1
- package/dist/components/capability.d.ts +9 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/covered-round.d.ts +173 -0
- package/dist/components/covered-round.d.ts.map +1 -0
- package/dist/components/covered-round.js +200 -0
- package/dist/components/driver.d.ts +8 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +8 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/pr-run.d.ts +21 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +147 -16
- package/dist/components/wave-plan.d.ts +2 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +4 -1
- package/dist/pr-loop.d.ts +36 -2
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +11 -2
- package/dist/wave-runtime.mjs +3 -3
- package/dist/workspace/pr-report.schema.json +25 -1
- package/package.json +1 -1
- package/src/cli/handlers/pr.ts +2 -1
- package/src/components/capability.ts +9 -0
- package/src/components/covered-round.test.ts +166 -0
- package/src/components/covered-round.ts +293 -0
- package/src/components/driver.ts +12 -0
- package/src/components/index.ts +17 -0
- package/src/components/pr-run.test.ts +212 -1
- package/src/components/pr-run.ts +166 -16
- package/src/components/wave-plan.ts +5 -2
- package/src/pr-loop.test.ts +14 -1
- package/src/pr-loop.ts +43 -4
- package/src/workspace/pr-report.schema.json +25 -1
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "./pr-run";
|
|
31
31
|
import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
|
|
32
32
|
import type { PrApplyRecord } from "./fan-out-record";
|
|
33
|
-
import { renderPrNote } from "../pr-loop";
|
|
33
|
+
import { renderPrNote, type PrApproval } from "../pr-loop";
|
|
34
34
|
import type { FanOutPlan } from "./fan-out";
|
|
35
35
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
36
36
|
import type { DriverComponent } from "./driver";
|
|
@@ -78,6 +78,8 @@ function world() {
|
|
|
78
78
|
predict: {} as Record<string, (() => PlannedOutputs | undefined) | undefined>,
|
|
79
79
|
/** What `a` writes when it applies, when not what its version gives. */
|
|
80
80
|
appliesAs: undefined as string | undefined,
|
|
81
|
+
/** `a` applying makes `app` plan a change its first plan did not show. */
|
|
82
|
+
bumpAppOnApply: false,
|
|
81
83
|
};
|
|
82
84
|
w.predict.net = () => ({ known: { ...(w.state.net ?? {}) }, unknown: [] });
|
|
83
85
|
w.predict.a = () => ({ known: { id: `a-${w.versions.a ?? "v1"}` }, unknown: [] });
|
|
@@ -122,6 +124,7 @@ function world() {
|
|
|
122
124
|
w.ranWith[input.root] = input.vars;
|
|
123
125
|
if (w.failing.has(input.root)) throw new Error(`${input.root} failed`);
|
|
124
126
|
w.applied.push(input.root);
|
|
127
|
+
if (input.root === "a" && w.bumpAppOnApply) w.versions.app = "v2";
|
|
125
128
|
if (input.root === "a") w.state.a = { id: w.appliesAs ?? `a-${w.versions.a ?? "v1"}` };
|
|
126
129
|
return { outputs: { ...(w.state[input.root] ?? {}) } };
|
|
127
130
|
},
|
|
@@ -404,6 +407,19 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
|
|
|
404
407
|
expect(w.applied).toEqual(["a", "app"]);
|
|
405
408
|
});
|
|
406
409
|
|
|
410
|
+
test("each member names every upstream output it reads, the known ones too, and the apply report keeps them", async () => {
|
|
411
|
+
const w = world();
|
|
412
|
+
w.versions.a = "v2";
|
|
413
|
+
const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
|
|
414
|
+
// a reads net, which is not in the change: a read of a deployed output is a read too.
|
|
415
|
+
expect(set.members.map((m) => [m.member, m.reads])).toEqual([
|
|
416
|
+
["a", [{ upstream: "net", outputs: ["cidr"] }]],
|
|
417
|
+
["app", [{ upstream: "a", outputs: ["id"] }]],
|
|
418
|
+
]);
|
|
419
|
+
const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
|
|
420
|
+
expect(applied.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"] }]);
|
|
421
|
+
});
|
|
422
|
+
|
|
407
423
|
test("an output the change moves: the dependent plans on the moved value, not the one standing now", async () => {
|
|
408
424
|
const w = world();
|
|
409
425
|
w.versions.a = "v2";
|
|
@@ -429,6 +445,7 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
|
|
|
429
445
|
["a", undefined],
|
|
430
446
|
["app", ["a.id"]],
|
|
431
447
|
]);
|
|
448
|
+
expect(set.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"], unknown: ["id"] }]);
|
|
432
449
|
|
|
433
450
|
// No approval of the plan made after a applied: a applies, app waits.
|
|
434
451
|
const waiting = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set, decideRound: decideWith(new Set()) });
|
|
@@ -457,6 +474,121 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
|
|
|
457
474
|
expect(applied.members.find((m) => m.member === "app")).toMatchObject({ status: "applied", round: 2 });
|
|
458
475
|
});
|
|
459
476
|
|
|
477
|
+
const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
|
|
478
|
+
|
|
479
|
+
test("the first approval covers the round that only fills in what was unknown: no gate of its own, recorded as covered (#4419)", async () => {
|
|
480
|
+
const w = world();
|
|
481
|
+
w.versions.a = "v2";
|
|
482
|
+
w.predict.a = () => ({ known: {}, unknown: ["id"] });
|
|
483
|
+
const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
|
|
484
|
+
let asked = 0;
|
|
485
|
+
const applied = await applyPrSet({
|
|
486
|
+
components: ESTATE,
|
|
487
|
+
plan: plan(),
|
|
488
|
+
registry: w.registry,
|
|
489
|
+
env: "prod",
|
|
490
|
+
set,
|
|
491
|
+
approval: approvedFirst,
|
|
492
|
+
decideRound: async () => {
|
|
493
|
+
asked++;
|
|
494
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
expect(asked).toBe(0);
|
|
498
|
+
expect(applied.status).toBe("applied");
|
|
499
|
+
expect(w.applied).toEqual(["a", "app"]);
|
|
500
|
+
expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
|
|
501
|
+
expect(applied.rounds).toHaveLength(1);
|
|
502
|
+
expect(applied.rounds![0]).toMatchObject({
|
|
503
|
+
round: 2,
|
|
504
|
+
status: "applied",
|
|
505
|
+
approval: approvedFirst,
|
|
506
|
+
covered: { digest: set.doc.digest, filledInputs: ["a.id"] },
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("a round that plans a change the first plan did not show asks for its own approval, with the reason (#4419)", async () => {
|
|
511
|
+
const w = world();
|
|
512
|
+
w.versions.a = "v2";
|
|
513
|
+
w.predict.a = () => ({ known: {}, unknown: ["id"] });
|
|
514
|
+
w.bumpAppOnApply = true;
|
|
515
|
+
const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
|
|
516
|
+
const seen: Array<string | undefined> = [];
|
|
517
|
+
const applied = await applyPrSet({
|
|
518
|
+
components: ESTATE,
|
|
519
|
+
plan: plan(),
|
|
520
|
+
registry: w.registry,
|
|
521
|
+
env: "prod",
|
|
522
|
+
set,
|
|
523
|
+
approval: approvedFirst,
|
|
524
|
+
decideRound: async ({ notCovered }) => {
|
|
525
|
+
seen.push(notCovered);
|
|
526
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
expect(applied.status).toBe("waiting");
|
|
530
|
+
expect(w.applied).toEqual(["a"]);
|
|
531
|
+
expect(seen).toEqual(["app now plans to update terraform_data.x, which the first plan did not"]);
|
|
532
|
+
expect(applied.rounds![0]).toMatchObject({ status: "waiting", notCovered: seen[0] });
|
|
533
|
+
expect(applied.rounds![0]!.covered).toBeUndefined();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a third member that reads a previewed member's known output is covered when the round plans that output as the same value (#4419)", async () => {
|
|
537
|
+
const chain: DriverComponent[] = [
|
|
538
|
+
root("x"),
|
|
539
|
+
root("y", ["x"], { id: { stackOutput: { stack: "x", name: "id" } } }),
|
|
540
|
+
root("z", ["y"], { port: { stackOutput: { stack: "y", name: "port" } } }),
|
|
541
|
+
];
|
|
542
|
+
const w = world();
|
|
543
|
+
w.state = { x: { id: "x-v1" }, y: { port: "8080" }, z: {} };
|
|
544
|
+
w.versions.x = "v2";
|
|
545
|
+
w.versions.y = "v2";
|
|
546
|
+
w.versions.z = "v2";
|
|
547
|
+
w.predict.x = () => ({ known: {}, unknown: ["id"] });
|
|
548
|
+
w.predict.y = () => ({ known: { port: "8080" }, unknown: [] });
|
|
549
|
+
w.predict.z = () => undefined;
|
|
550
|
+
const chainPlan = planFanOut({ components: chain, changed: ["x"] });
|
|
551
|
+
const set = await planPrSet({ components: chain, plan: chainPlan, registry: w.registry, env: "prod" });
|
|
552
|
+
const seen: Array<string | undefined> = [];
|
|
553
|
+
const applied = await applyPrSet({
|
|
554
|
+
components: chain,
|
|
555
|
+
plan: chainPlan,
|
|
556
|
+
registry: w.registry,
|
|
557
|
+
env: "prod",
|
|
558
|
+
set,
|
|
559
|
+
approval: approvedFirst,
|
|
560
|
+
decideRound: async ({ notCovered }) => {
|
|
561
|
+
seen.push(notCovered);
|
|
562
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
expect(seen).toEqual([]);
|
|
566
|
+
expect(applied.status).toBe("applied");
|
|
567
|
+
expect(w.applied).toEqual(["x", "y", "z"]);
|
|
568
|
+
expect(applied.rounds![0]).toMatchObject({ round: 2, components: ["y", "z"], covered: { digest: set.doc.digest, filledInputs: ["x.id"] } });
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("an input the first plan read with a value that moved is not covered (#4419)", async () => {
|
|
572
|
+
const w = world();
|
|
573
|
+
w.versions.a = "v2";
|
|
574
|
+
w.appliesAs = "a-v2-real";
|
|
575
|
+
const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
|
|
576
|
+
const seen: Array<string | undefined> = [];
|
|
577
|
+
await applyPrSet({
|
|
578
|
+
components: ESTATE,
|
|
579
|
+
plan: plan(),
|
|
580
|
+
registry: w.registry,
|
|
581
|
+
env: "prod",
|
|
582
|
+
set,
|
|
583
|
+
approval: approvedFirst,
|
|
584
|
+
decideRound: async ({ notCovered }) => {
|
|
585
|
+
seen.push(notCovered);
|
|
586
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
expect(seen).toEqual(["app reads a.id, which the first plan read as another value"]);
|
|
590
|
+
});
|
|
591
|
+
|
|
460
592
|
test("an upstream new in the same pull request plans, and so does its dependent", async () => {
|
|
461
593
|
const pair: DriverComponent[] = [root("vpc"), root("svc", ["vpc"], { vpc: { stackOutput: { stack: "vpc", name: "id" } } })];
|
|
462
594
|
const w = world();
|
|
@@ -889,6 +1021,85 @@ describe("planCoveredBy", () => {
|
|
|
889
1021
|
});
|
|
890
1022
|
});
|
|
891
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* One decision for a round, a resume and a wave (#4422). The same estate and
|
|
1026
|
+
* the same two outcomes go through `applyPrSet`'s round, `resumePrSet` and
|
|
1027
|
+
* `waveNotCovered` (what `--wave-gate` uses), and each path must give the same
|
|
1028
|
+
* verdict with the same reason.
|
|
1029
|
+
*/
|
|
1030
|
+
describe("one coverage decision on a round, a resume and a wave (#4422)", () => {
|
|
1031
|
+
const plan = () => planFanOut({ components: ESTATE, changed: ["a"] });
|
|
1032
|
+
const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
|
|
1033
|
+
/** `a` v2, whose id is known only after apply; `app` reads it. */
|
|
1034
|
+
async function first(w: ReturnType<typeof world>) {
|
|
1035
|
+
w.versions.a = "v2";
|
|
1036
|
+
w.predict.a = () => ({ known: {}, unknown: ["id"] });
|
|
1037
|
+
return planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const paths: Record<string, (extra: boolean) => Promise<string | undefined>> = {
|
|
1041
|
+
round: async (extra) => {
|
|
1042
|
+
const w = world();
|
|
1043
|
+
const set = await first(w);
|
|
1044
|
+
w.bumpAppOnApply = extra;
|
|
1045
|
+
let reason: string | undefined;
|
|
1046
|
+
const applied = await applyPrSet({
|
|
1047
|
+
components: ESTATE,
|
|
1048
|
+
plan: plan(),
|
|
1049
|
+
registry: w.registry,
|
|
1050
|
+
env: "prod",
|
|
1051
|
+
set,
|
|
1052
|
+
approval: approvedFirst,
|
|
1053
|
+
decideRound: async ({ notCovered }) => {
|
|
1054
|
+
reason = notCovered;
|
|
1055
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
expect(applied.status).toBe(extra ? "waiting" : "applied");
|
|
1059
|
+
return reason;
|
|
1060
|
+
},
|
|
1061
|
+
resume: async (extra) => {
|
|
1062
|
+
const w = world();
|
|
1063
|
+
const set = await first(w);
|
|
1064
|
+
w.state.a = { id: "a-v2" };
|
|
1065
|
+
if (extra) w.versions.app = "v2";
|
|
1066
|
+
const resumed = await resumePrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", record: recordOf(set), completed: ["a"] });
|
|
1067
|
+
return resumed.ok ? undefined : resumed.reasons[0];
|
|
1068
|
+
},
|
|
1069
|
+
wave: async (extra) => {
|
|
1070
|
+
const w = world();
|
|
1071
|
+
const set = await first(w);
|
|
1072
|
+
if (extra) w.versions.app = "v2";
|
|
1073
|
+
const wavePlan = prWavePlan(plan(), ESTATE, 2);
|
|
1074
|
+
const fresh = await planPrSet({ components: ESTATE, plan: wavePlan, registry: w.registry, env: "prod", seededOutputs: { ...set.outputs, a: { id: "a-v2" } } });
|
|
1075
|
+
return waveNotCovered(set.doc, fresh.doc, set.members)[0];
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
test.each([
|
|
1080
|
+
["only the previewed unknown filled in", false, undefined],
|
|
1081
|
+
["an entry the first plan did not show", true, "app now plans to update terraform_data.x, which the first plan did not"],
|
|
1082
|
+
] as const)("%s", async (_name, extra, expected) => {
|
|
1083
|
+
for (const [path, decide] of Object.entries(paths)) {
|
|
1084
|
+
expect({ path, reason: await decide(extra) }).toEqual({ path, reason: expected });
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("a member that was a hole with no preview is not covered by an approval that never saw its shape", () => {
|
|
1089
|
+
const part = (digest: string, entries: ChangeSetEntry[], holes: ChangeSetPart["member"]["holes"]): ChangeSetPart => ({
|
|
1090
|
+
member: { member: "svc", lexicon: "aws", planner: "chant", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes },
|
|
1091
|
+
entries,
|
|
1092
|
+
});
|
|
1093
|
+
const approved = composeChangeSet([part("1", [], [{ address: "svc", reason: "inputs from net; re-checked on apply" }])]);
|
|
1094
|
+
const entry: ChangeSetEntry = { member: "svc", lexicon: "aws", planner: "chant", address: "Queue", type: "AWS::SQS::Queue", action: "create", attributes: [] };
|
|
1095
|
+
const fresh = composeChangeSet([part("2", [entry], [])]);
|
|
1096
|
+
const members = [{ member: "svc", component: "svc", planDigest: null, status: "planned" as const, counts: { create: 0, update: 0, replace: 0, delete: 0 }, unknownInputs: ["net.id"] }];
|
|
1097
|
+
// Even with the input named unknown, nothing was previewed to cover a create.
|
|
1098
|
+
expect(waveNotCovered(approved, fresh, members)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
|
|
1099
|
+
expect(waveNotCovered(approved, fresh)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
892
1103
|
describe("a gate per wave on merge (#4003)", () => {
|
|
893
1104
|
const estate = [root("net"), root("a", ["net"]), root("b", ["net"]), root("app", ["a"])];
|
|
894
1105
|
const plan = planFanOut({ components: estate, changed: ["net"] });
|
package/src/components/pr-run.ts
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* component in the project and not the selection.
|
|
24
24
|
* - the upstream is in the change and its plan cannot say, or says the value
|
|
25
25
|
* is known only after apply: no value. The dependent's members list the
|
|
26
|
-
* reference under `unknownInputs
|
|
27
|
-
*
|
|
26
|
+
* reference under `unknownInputs`. The apply plans it again once the
|
|
27
|
+
* upstream applied, and the approval covers that round when it matches the
|
|
28
|
+
* preview (see `coveredRound`).
|
|
28
29
|
*
|
|
29
30
|
* `--seed-outputs` wins over both. The apply stage's plan reads the same
|
|
30
31
|
* values, so on the same estate it gets the digest the plan stage showed.
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
PR_REPORT_SCHEMA_ID,
|
|
79
80
|
type PrApproval,
|
|
80
81
|
type PrMember,
|
|
82
|
+
type PrMemberRead,
|
|
81
83
|
type PrRefusal,
|
|
82
84
|
type PrRound,
|
|
83
85
|
type PrReport,
|
|
@@ -88,12 +90,14 @@ import {
|
|
|
88
90
|
deployContext,
|
|
89
91
|
movedWiringReferences,
|
|
90
92
|
unresolvedWiringReferences,
|
|
93
|
+
wiringReads,
|
|
91
94
|
type DriverComponent,
|
|
92
95
|
type DriverComponentResult,
|
|
93
96
|
} from "./driver";
|
|
94
97
|
import { deployUnits } from "./deploy-units";
|
|
95
98
|
import { runFanOut, type FanOutRunResult } from "./fan-out-run";
|
|
96
99
|
import { remainingFanOut, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
|
|
100
|
+
import { coveredRound, type PlanShape, type RoundCoverage } from "./covered-round";
|
|
97
101
|
import type { PrApplyRecord } from "./fan-out-record";
|
|
98
102
|
import type { CapabilityRegistry, PlannedOutputs } from "./capability";
|
|
99
103
|
import type { RunProgressEvent } from "./run-progress";
|
|
@@ -145,6 +149,8 @@ export interface PrPlanSet {
|
|
|
145
149
|
* report's `plannedOutputs`.
|
|
146
150
|
*/
|
|
147
151
|
plannedOutputs?: Record<string, string[]>;
|
|
152
|
+
/** The shapes lexicons supplied with their plans (#4419), by member. */
|
|
153
|
+
shapes?: Record<string, PlanShape>;
|
|
148
154
|
/** Whether any component failed to plan. */
|
|
149
155
|
failed: boolean;
|
|
150
156
|
}
|
|
@@ -178,7 +184,9 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
178
184
|
const parts: ChangeSetPart[] = [];
|
|
179
185
|
const componentOf = new Map<string, string>();
|
|
180
186
|
const unknownOf = new Map<string, { unknown: string[]; build: string[] }>();
|
|
187
|
+
const readsBy = new Map<string, PrMemberRead[]>();
|
|
181
188
|
const plans: Record<string, Record<string, unknown>> = {};
|
|
189
|
+
const shapes: Record<string, PlanShape> = {};
|
|
182
190
|
let failed = false;
|
|
183
191
|
const emit = options.onRunEvent;
|
|
184
192
|
emit?.({ type: "phase-start", component: null, phase: PR_PLAN_PHASE });
|
|
@@ -223,6 +231,8 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
223
231
|
const component = byName.get(p.component)!;
|
|
224
232
|
const unknown = unknownInputsOf(component, outputs, inChange);
|
|
225
233
|
if (unknown.unknown.length > 0 || unknown.build.length > 0) unknownOf.set(p.component, unknown);
|
|
234
|
+
const reads = readsOf(component, outputs);
|
|
235
|
+
if (reads.length > 0) readsBy.set(p.component, reads);
|
|
226
236
|
if ("error" in p) {
|
|
227
237
|
failed = true;
|
|
228
238
|
plannedBy.set(p.component, undefined);
|
|
@@ -234,6 +244,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
234
244
|
}
|
|
235
245
|
plannedBy.set(p.component, p.outputs);
|
|
236
246
|
plans[p.component] = p.plans;
|
|
247
|
+
Object.assign(shapes, p.shapes);
|
|
237
248
|
for (const part of p.parts) {
|
|
238
249
|
parts.push(part);
|
|
239
250
|
componentOf.set(part.member.member, p.component);
|
|
@@ -246,6 +257,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
246
257
|
const members: PrMember[] = doc.members.map((m) => {
|
|
247
258
|
const component = componentOf.get(m.member) ?? m.member;
|
|
248
259
|
const unknown = unknownOf.get(component);
|
|
260
|
+
const reads = readsBy.get(component);
|
|
249
261
|
return {
|
|
250
262
|
member: m.member,
|
|
251
263
|
component,
|
|
@@ -255,6 +267,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
255
267
|
...(m.error ? { error: m.error } : {}),
|
|
256
268
|
...(unknown?.unknown.length ? { unknownInputs: unknown.unknown } : {}),
|
|
257
269
|
...(unknown?.build.length ? { buildInputs: unknown.build } : {}),
|
|
270
|
+
...(reads ? { reads } : {}),
|
|
258
271
|
};
|
|
259
272
|
});
|
|
260
273
|
const sorted = (r: Record<string, string[]>) => Object.fromEntries(Object.entries(r).sort(([a], [b]) => a.localeCompare(b)));
|
|
@@ -266,6 +279,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
|
|
|
266
279
|
plans,
|
|
267
280
|
outputs,
|
|
268
281
|
failed,
|
|
282
|
+
...(Object.keys(shapes).length > 0 ? { shapes } : {}),
|
|
269
283
|
...(Object.keys(deployedOutputs).length > 0 ? { deployedOutputs } : {}),
|
|
270
284
|
...(Object.keys(plannedOutputs).length > 0 ? { plannedOutputs } : {}),
|
|
271
285
|
};
|
|
@@ -295,6 +309,31 @@ function unknownInputsOf(
|
|
|
295
309
|
return { unknown: [...unknown].sort(), build: [...build].sort() };
|
|
296
310
|
}
|
|
297
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Every other component's output `component`'s steps read, one entry per
|
|
314
|
+
* upstream, sorted: what the report's `reads` says. A read whose value
|
|
315
|
+
* `outputs` holds is listed as well as one it lacks (`unknown`), so the
|
|
316
|
+
* report names each read between components whether or not the plan knew
|
|
317
|
+
* its value. A component's reads of its own outputs are left out.
|
|
318
|
+
*/
|
|
319
|
+
function readsOf(component: DriverComponent, outputs: Record<string, Record<string, unknown>>): PrMemberRead[] {
|
|
320
|
+
const by = new Map<string, { outputs: Set<string>; unknown: Set<string> }>();
|
|
321
|
+
for (const step of capabilitySteps(component.deploy)) {
|
|
322
|
+
const { kind: _kind, ...rest } = step;
|
|
323
|
+
for (const r of wiringReads(rest, outputs)) {
|
|
324
|
+
if (r.source === component.name) continue;
|
|
325
|
+
const name = r.ref.slice(r.source.length + 1);
|
|
326
|
+
const at = by.get(r.source) ?? { outputs: new Set<string>(), unknown: new Set<string>() };
|
|
327
|
+
by.set(r.source, at);
|
|
328
|
+
at.outputs.add(name);
|
|
329
|
+
if (!r.known) at.unknown.add(name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return [...by.entries()]
|
|
333
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
334
|
+
.map(([upstream, r]) => ({ upstream, outputs: [...r.outputs].sort(), ...(r.unknown.size > 0 ? { unknown: [...r.unknown].sort() } : {}) }));
|
|
335
|
+
}
|
|
336
|
+
|
|
298
337
|
/**
|
|
299
338
|
* Why `component` cannot apply the plan that read `read` now that `now`
|
|
300
339
|
* stands (#3999): each reference its steps make that resolves to another
|
|
@@ -409,13 +448,23 @@ export interface PrApplyOptions {
|
|
|
409
448
|
* The gate is {@link prRoundGate}. Without it, held members are left
|
|
410
449
|
* `waiting` and nothing is planned again.
|
|
411
450
|
*/
|
|
412
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Where the approval of `set` stands (#4419). When it is `approved`, a round
|
|
453
|
+
* whose fresh plan does only what `set` previewed, with only the inputs it
|
|
454
|
+
* marked unknown filled in, applies under that approval without a gate of
|
|
455
|
+
* its own (`coveredRound`), and the round records it (`PrRound.covered`).
|
|
456
|
+
* Without it every round decides its own gate.
|
|
457
|
+
*/
|
|
458
|
+
approval?: PrApproval;
|
|
459
|
+
decideRound?: (round: { round: number; gate: string; set: PrPlanSet; plan: FanOutPlan; notCovered?: string }) => Promise<PrRoundDecision>;
|
|
413
460
|
}
|
|
414
461
|
|
|
415
462
|
/** How a round's gate was decided: whether it applies, and where its approval stands. */
|
|
416
463
|
export interface PrRoundDecision {
|
|
417
464
|
satisfied: boolean;
|
|
418
465
|
approval: PrApproval;
|
|
466
|
+
/** Set when the first approval covered the round (#4419): no gate of its own was decided. */
|
|
467
|
+
covered?: PrRound["covered"];
|
|
419
468
|
/** Why it does not apply, when an approval stands but does not count (`--require-review`). */
|
|
420
469
|
message?: string;
|
|
421
470
|
}
|
|
@@ -430,6 +479,66 @@ export interface PrApplyResult {
|
|
|
430
479
|
rounds?: PrRound[];
|
|
431
480
|
}
|
|
432
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Whether the first approval covers a round's fresh plan (#4419). The
|
|
484
|
+
* approval binds `options.set.doc.digest`; the round's components and
|
|
485
|
+
* members must be ones that plan previewed, and what differs is only what
|
|
486
|
+
* it marked unknown.
|
|
487
|
+
*/
|
|
488
|
+
function roundCover(
|
|
489
|
+
options: PrApplyOptions,
|
|
490
|
+
fresh: PrPlanSet,
|
|
491
|
+
plan: FanOutPlan,
|
|
492
|
+
now: Record<string, Record<string, unknown>>,
|
|
493
|
+
inChange: ReadonlySet<string>,
|
|
494
|
+
byName: ReadonlyMap<string, DriverComponent>,
|
|
495
|
+
): RoundCoverage {
|
|
496
|
+
if (options.approval?.status !== "approved") return { covered: false, reason: "no approval of the first plan stands" };
|
|
497
|
+
// The values the round reads of a component it plans again are the ones
|
|
498
|
+
// that plan says it will expose: it has not applied yet, so the outputs
|
|
499
|
+
// standing say nothing of it. An output the first plan knew and the round's
|
|
500
|
+
// plan knows as the same value is not a changed input, whichever of the
|
|
501
|
+
// two applies first.
|
|
502
|
+
const reads: Record<string, Record<string, unknown>> = { ...now };
|
|
503
|
+
for (const name of plan.order) if (fresh.outputs[name]) reads[name] = fresh.outputs[name]!;
|
|
504
|
+
const changedKnown: Record<string, string[]> = {};
|
|
505
|
+
for (const name of plan.order) {
|
|
506
|
+
const component = byName.get(name);
|
|
507
|
+
if (!component) continue;
|
|
508
|
+
const refs = new Set<string>();
|
|
509
|
+
for (const step of capabilitySteps(component.deploy)) {
|
|
510
|
+
const { kind: _kind, ...rest } = step;
|
|
511
|
+
for (const r of movedWiringReferences(rest, options.set.outputs, reads)) if (!(r.publish && inChange.has(r.source))) refs.add(r.ref);
|
|
512
|
+
}
|
|
513
|
+
changedKnown[name] = [...refs].sort();
|
|
514
|
+
}
|
|
515
|
+
const approved = { doc: options.set.doc, members: options.set.members, ...(options.set.shapes ? { shapes: options.set.shapes } : {}) };
|
|
516
|
+
return coveredRound({
|
|
517
|
+
approved,
|
|
518
|
+
approvedDigest: options.set.doc.digest,
|
|
519
|
+
fresh: { doc: fresh.doc, members: fresh.members, ...(fresh.shapes ? { shapes: fresh.shapes } : {}) },
|
|
520
|
+
changedKnown,
|
|
521
|
+
attributes: (member) => memberAttributes(options.set.doc, fresh.doc, member, fresh.shapes?.[member] !== undefined),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The part of coverage that reads the change set's own attributes, shared by
|
|
527
|
+
* a round, a resume and a wave: a member whose planner describes no changes
|
|
528
|
+
* (and gave no shape) is covered only by the digest it was approved with, and
|
|
529
|
+
* anything else is {@link planCoveredBy}'s.
|
|
530
|
+
*/
|
|
531
|
+
function memberAttributes(approved: ChangeSetDocument, fresh: ChangeSetDocument, member: string, hasShape: boolean): string | undefined {
|
|
532
|
+
if (!hasShape) {
|
|
533
|
+
const now = fresh.members.find((m) => m.member === member);
|
|
534
|
+
const was = approved.members.find((m) => m.member === member);
|
|
535
|
+
if (now && was && now.planner === "chant" && now.lexicon === "chant" && now.status === "planned" && now.planDigest !== was.planDigest) {
|
|
536
|
+
return `${member} now plans to ${now.planDigest}, not the approved ${was.planDigest ?? "(none)"}, and its planner describes no changes to compare`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return planCoveredBy(approved, fresh, member);
|
|
540
|
+
}
|
|
541
|
+
|
|
433
542
|
/** Two runs of one apply as one: `b` ran after `a`, against the outputs `a` left. */
|
|
434
543
|
function joinRuns(a: FanOutRunResult, b: FanOutRunResult): FanOutRunResult {
|
|
435
544
|
const status: FanOutRunResult["status"] = a.status === "fail" || b.status === "fail" ? "fail" : a.status === "gated" || b.status === "gated" ? "gated" : "ok";
|
|
@@ -500,22 +609,37 @@ export async function applyPrSet(options: PrApplyOptions): Promise<PrApplyResult
|
|
|
500
609
|
...(options.targets ? { targets: options.targets } : {}),
|
|
501
610
|
});
|
|
502
611
|
for (const name of plan.order) roundOf.set(name, { round, set });
|
|
503
|
-
const
|
|
504
|
-
|
|
612
|
+
const cover = roundCover(options, set, plan, run.componentOutputs, inChange, byName);
|
|
613
|
+
const record = (status: PrRound["status"], approval: PrApproval, message?: string, covered?: PrRound["covered"]): void => {
|
|
614
|
+
rounds.push({
|
|
615
|
+
round,
|
|
616
|
+
gate,
|
|
617
|
+
digest: set.doc.digest,
|
|
618
|
+
components: plan.order,
|
|
619
|
+
approval,
|
|
620
|
+
status,
|
|
621
|
+
...(message ? { message } : {}),
|
|
622
|
+
...(covered ? { covered } : {}),
|
|
623
|
+
...(!covered && options.approval?.status === "approved" && cover.covered === false ? { notCovered: cover.reason } : {}),
|
|
624
|
+
changeSet: set.doc,
|
|
625
|
+
});
|
|
505
626
|
};
|
|
506
627
|
if (set.failed) {
|
|
507
628
|
record("plan-failed", { status: "pending" });
|
|
508
629
|
stopped = "plan-failed";
|
|
509
630
|
break;
|
|
510
631
|
}
|
|
511
|
-
const decision =
|
|
632
|
+
const decision: PrRoundDecision =
|
|
633
|
+
cover.covered && options.approval
|
|
634
|
+
? { satisfied: true, approval: options.approval, covered: { digest: cover.digest, filledInputs: cover.filledInputs } }
|
|
635
|
+
: await options.decideRound({ round, gate, set, plan, ...(options.approval?.status === "approved" && !cover.covered ? { notCovered: cover.reason } : {}) });
|
|
512
636
|
if (!decision.satisfied) {
|
|
513
637
|
record("waiting", decision.approval, decision.message);
|
|
514
638
|
stopped = "waiting";
|
|
515
639
|
break;
|
|
516
640
|
}
|
|
517
641
|
const next = await runRound(plan, set.plans, set.outputs, run.componentOutputs);
|
|
518
|
-
record(next.status === "fail" ? "failed" : "applied", decision.approval);
|
|
642
|
+
record(next.status === "fail" ? "failed" : "applied", decision.approval, undefined, decision.covered);
|
|
519
643
|
run = joinRuns(run, next);
|
|
520
644
|
}
|
|
521
645
|
|
|
@@ -688,9 +812,8 @@ export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
|
|
|
688
812
|
seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
|
|
689
813
|
...(options.targets ? { targets: options.targets } : {}),
|
|
690
814
|
});
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
.filter((r): r is string => r !== undefined);
|
|
815
|
+
// #4422: decided as a round decides (`coveredRound`), by the same function a wave uses.
|
|
816
|
+
const reasons = waveNotCovered(options.record.changeSet, fresh.doc, options.record.members);
|
|
694
817
|
if (reasons.length > 0) return { ok: false, reasons, fresh };
|
|
695
818
|
return {
|
|
696
819
|
ok: true,
|
|
@@ -731,20 +854,47 @@ export function prWavePlan(plan: FanOutPlan, components: DriverComponent[], wave
|
|
|
731
854
|
* returns no change set) is covered only by the digest it was approved
|
|
732
855
|
* with, since with no entries there is nothing to compare a moved plan by.
|
|
733
856
|
*/
|
|
734
|
-
export function waveNotCovered(
|
|
857
|
+
export function waveNotCovered(
|
|
858
|
+
approved: ChangeSetDocument,
|
|
859
|
+
fresh: ChangeSetDocument,
|
|
860
|
+
approvedMembers: readonly PrMember[] = [],
|
|
861
|
+
shapes: { approved?: Record<string, PlanShape>; fresh?: Record<string, PlanShape> } = {},
|
|
862
|
+
): string[] {
|
|
735
863
|
// #3999: a member whose approved plan waited only on an artifact a build in
|
|
736
864
|
// the change publishes is covered by that build's provenance, as it is
|
|
737
865
|
// without --wave-gate.
|
|
738
866
|
const byBuild = new Set(approvedMembers.filter((m) => m.buildInputs?.length && !m.unknownInputs?.length).map((m) => m.member));
|
|
867
|
+
const prMember = new Map(approvedMembers.map((m) => [m.member, m]));
|
|
868
|
+
// #4422: the same decision a round makes (`coveredRound`), so a resume, a
|
|
869
|
+
// wave and a round agree for equivalent inputs. A member the document names
|
|
870
|
+
// and the caller does not stands for a component of its own name.
|
|
871
|
+
const approvedSide = {
|
|
872
|
+
doc: approved,
|
|
873
|
+
members: approved.members.map((m) => ({
|
|
874
|
+
member: m.member,
|
|
875
|
+
component: prMember.get(m.member)?.component ?? m.member,
|
|
876
|
+
...(prMember.get(m.member)?.unknownInputs?.length ? { unknownInputs: prMember.get(m.member)!.unknownInputs } : {}),
|
|
877
|
+
})),
|
|
878
|
+
...(shapes.approved && shapes.fresh ? { shapes: shapes.approved } : {}),
|
|
879
|
+
};
|
|
739
880
|
return fresh.members
|
|
740
881
|
.map((m) => {
|
|
741
882
|
if (byBuild.has(m.member)) return undefined;
|
|
742
883
|
const was = approved.members.find((a) => a.member === m.member);
|
|
743
|
-
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
884
|
+
if (!was) return planCoveredBy(approved, fresh, m.member);
|
|
885
|
+
if (was.planDigest !== null && m.planDigest === was.planDigest) return undefined;
|
|
886
|
+
const hasShape = shapes.approved !== undefined && shapes.fresh?.[m.member] !== undefined;
|
|
887
|
+
const cover = coveredRound({
|
|
888
|
+
approved: approvedSide,
|
|
889
|
+
approvedDigest: approved.digest,
|
|
890
|
+
fresh: {
|
|
891
|
+
doc: fresh,
|
|
892
|
+
members: [{ member: m.member, component: prMember.get(m.member)?.component ?? m.member }],
|
|
893
|
+
...(hasShape ? { shapes: shapes.fresh! } : {}),
|
|
894
|
+
},
|
|
895
|
+
attributes: (member) => memberAttributes(approved, fresh, member, hasShape),
|
|
896
|
+
});
|
|
897
|
+
return cover.covered ? undefined : cover.reason;
|
|
748
898
|
})
|
|
749
899
|
.filter((r): r is string => r !== undefined);
|
|
750
900
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `@<component>.publish.*`) resolves.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import type { PlanShape } from "./covered-round";
|
|
30
31
|
import { assertTargetsAccepted, resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
|
|
31
32
|
import { componentPlanDigest } from "./gate-plan";
|
|
32
33
|
import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
|
|
@@ -44,7 +45,7 @@ import type { ChangeSetPart } from "../change-set";
|
|
|
44
45
|
* merges a run's outputs; absent when no step's plan says.
|
|
45
46
|
*/
|
|
46
47
|
export type WaveComponentPlan =
|
|
47
|
-
| { component: string; members: WaveMember[]; plans: Record<string, unknown>; parts: ChangeSetPart[]; outputs?: PlannedOutputs }
|
|
48
|
+
| { component: string; members: WaveMember[]; plans: Record<string, unknown>; parts: ChangeSetPart[]; outputs?: PlannedOutputs; shapes?: Record<string, PlanShape> }
|
|
48
49
|
| { component: string; error: string };
|
|
49
50
|
|
|
50
51
|
/** A part with no entries, for a member whose planner describes no changes. `unbound` steps become its holes (#4155). */
|
|
@@ -101,6 +102,7 @@ export async function planWaveComponent(
|
|
|
101
102
|
const plans: Record<string, unknown> = {};
|
|
102
103
|
const parts: ChangeSetPart[] = [];
|
|
103
104
|
let outputs: PlannedOutputs | undefined;
|
|
105
|
+
const shapes: Record<string, PlanShape> = {};
|
|
104
106
|
assertTargetsAccepted(component, registry, ctx);
|
|
105
107
|
for (const step of capabilitySteps(component.deploy)) {
|
|
106
108
|
const capability = registry.resolve(step.kind);
|
|
@@ -112,6 +114,7 @@ export async function planWaveComponent(
|
|
|
112
114
|
members.push(member);
|
|
113
115
|
parts.push(planned.changeSet ?? emptyPart(member));
|
|
114
116
|
if (planned.artifact !== undefined) plans[planned.member] = planned.artifact;
|
|
117
|
+
if (planned.shape) shapes[planned.member] = { ...planned.shape, member: planned.member };
|
|
115
118
|
if (planned.outputs) outputs = mergePlannedOutputs(outputs, planned.outputs);
|
|
116
119
|
}
|
|
117
120
|
// #4155: the steps without a plan, which no member above covers.
|
|
@@ -141,7 +144,7 @@ export async function planWaveComponent(
|
|
|
141
144
|
parts.push(emptyPart(member, read.unbound));
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
|
-
return { component: component.name, members, plans, parts, ...(outputs ? { outputs } : {}) };
|
|
147
|
+
return { component: component.name, members, plans, parts, ...(outputs ? { outputs } : {}), ...(Object.keys(shapes).length > 0 ? { shapes } : {}) };
|
|
145
148
|
} catch (err) {
|
|
146
149
|
return { component: component.name, error: err instanceof Error ? err.message : String(err) };
|
|
147
150
|
}
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -204,7 +204,8 @@ describe("the note", () => {
|
|
|
204
204
|
test("a plan names the outputs read from upstream plans and the ones known only after apply (#3999)", () => {
|
|
205
205
|
const note = renderPrNote(unknownReport);
|
|
206
206
|
expect(note).toContain("Planned against the outputs this change's plans say its upstreams will have: `a` (`arn`).");
|
|
207
|
-
expect(note).toContain("Known only after apply: `app` reads `a.id`.
|
|
207
|
+
expect(note).toContain("Known only after apply: `app` reads `a.id`. The apply plans those members again once their upstream applied.");
|
|
208
|
+
expect(note).toContain("anything else asks for an approval of its own");
|
|
208
209
|
expect(renderPrNote(planReport)).not.toContain("Known only after apply");
|
|
209
210
|
expect(note).toContain("From this pull request's build: `app` reads `img.publish.digest`. The image digest comes from this pull request's build");
|
|
210
211
|
expect(renderPrNote(planReport)).not.toContain("From this pull request's build");
|
|
@@ -212,6 +213,18 @@ describe("the note", () => {
|
|
|
212
213
|
expect(schema.required).not.toContain("plannedOutputs");
|
|
213
214
|
});
|
|
214
215
|
|
|
216
|
+
test("a covered round names the first digest and the inputs filled in; an uncovered one gives the reason (#4419)", () => {
|
|
217
|
+
const covered = renderPrNote({
|
|
218
|
+
...unknownReport,
|
|
219
|
+
status: "applied",
|
|
220
|
+
rounds: [{ ...waitingReport.rounds![0]!, status: "applied", approval: { status: "approved", approvedBy: ["github:rev"] }, covered: { digest: unknownReport.digest, filledInputs: ["a.id"] } }],
|
|
221
|
+
} as never);
|
|
222
|
+
expect(covered).toContain(`covered by the approval of \`${unknownReport.digest}\`, with \`a.id\` filled in, so it applied without a new approval`);
|
|
223
|
+
const not = renderPrNote({ ...unknownReport, rounds: [{ ...waitingReport.rounds![0]!, notCovered: "app now plans to create Bucket, which the first plan did not" }] } as never);
|
|
224
|
+
expect(not).toContain(`not covered by the approval of \`${unknownReport.digest}\`: app now plans to create Bucket, which the first plan did not.`);
|
|
225
|
+
expect(not).toContain("Not approved yet");
|
|
226
|
+
});
|
|
227
|
+
|
|
215
228
|
test("a waiting apply names the round's digest and the command that approves it (#3999)", () => {
|
|
216
229
|
const note = renderPrNote(waitingReport, { approver: "github:<your-login>" });
|
|
217
230
|
expect(note).toContain("Applied 1 of 2 members; 1 wait on an approval of the plan made after their upstream applied.");
|