@intentius/chant 0.105.1 → 0.106.1
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/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +60 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +92 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +19 -2
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +29 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +14 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/change-set.test.ts +7 -8
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
- package/src/cli/handlers/build.ts +65 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +220 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +164 -1
- package/src/cli/handlers/run.ts +22 -11
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +74 -0
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +89 -6
- package/src/components/pr-run.test.ts +300 -3
- package/src/components/pr-run.ts +196 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +20 -2
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +38 -8
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +20 -3
- package/src/workspace/pr-report.schema.json +14 -1
|
@@ -45,8 +45,9 @@ vi.mock("../../components/capability-plugin-loader", () => ({
|
|
|
45
45
|
*/
|
|
46
46
|
vi.mock("../../op/gate", async () => {
|
|
47
47
|
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
48
|
-
return { ...actual, gitGateLedgerPort: () => gateLedger };
|
|
48
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
|
|
49
49
|
});
|
|
50
|
+
const sealedGates = new Set<string>();
|
|
50
51
|
|
|
51
52
|
const { CapabilityRegistry } = await import("../../components/capability");
|
|
52
53
|
const { memoryGateLedgerPort } = await import("../../op/gate");
|
|
@@ -105,6 +106,7 @@ let errSpy: ReturnType<typeof vi.spyOn>;
|
|
|
105
106
|
let outSpy: ReturnType<typeof vi.spyOn>;
|
|
106
107
|
|
|
107
108
|
beforeEach(() => {
|
|
109
|
+
sealedGates.clear();
|
|
108
110
|
vi.clearAllMocks();
|
|
109
111
|
ran = [];
|
|
110
112
|
failing = [];
|
|
@@ -409,6 +411,14 @@ describe("proof: killed mid-fan-out, a re-run finishes without hand repair", ()
|
|
|
409
411
|
});
|
|
410
412
|
|
|
411
413
|
describe("proof: one approval covers the set, and the printed digest is what approves it", () => {
|
|
414
|
+
test("a sealed gate's approve line carries --sign after --plan (#3521)", async () => {
|
|
415
|
+
sealedGates.add("release");
|
|
416
|
+
const exit = await runComponentsFanOut(ctx({ base: "main", env: "test", gate: "release" }));
|
|
417
|
+
|
|
418
|
+
expect(exit).toBe(3);
|
|
419
|
+
expect(stderr()).toMatch(/approve : chant approve fan-out release --plan jcs1-sha256:[0-9a-f]{64} --sign/);
|
|
420
|
+
});
|
|
421
|
+
|
|
412
422
|
test("an unapproved gate stops the whole fan-out before anything runs", async () => {
|
|
413
423
|
const exit = await runComponentsFanOut(ctx({ base: "main", env: "test", gate: "release" }));
|
|
414
424
|
|
|
@@ -417,6 +427,7 @@ describe("proof: one approval covers the set, and the printed digest is what app
|
|
|
417
427
|
const out = stderr();
|
|
418
428
|
expect(out).toContain("gated: nothing ran.");
|
|
419
429
|
expect(out).toMatch(/approve : chant approve fan-out release --plan jcs1-sha256:[0-9a-f]{64}/);
|
|
430
|
+
expect(out).not.toContain("--sign");
|
|
420
431
|
// One pending fact for the whole set, not one per component.
|
|
421
432
|
expect(gateLedger.appended).toHaveLength(1);
|
|
422
433
|
expect(gateLedger.appended[0].planDigest).toBe(/plan: (jcs1-sha256:[0-9a-f]{64})/.exec(out)?.[1]);
|
|
@@ -53,7 +53,7 @@ import { describeChangedWave, withWaveRecord, type WaveRecord } from "../../gate
|
|
|
53
53
|
import { renderFanOutHuman, renderFanOutJson, renderFanOutPlan } from "../../components/fan-out-output";
|
|
54
54
|
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
55
55
|
import { summaryLedgerPrefix, writeGatedRunSummary } from "../../op/gate-summary";
|
|
56
|
-
import { approveCommand } from "../../op/gate";
|
|
56
|
+
import { approveCommand, gateIsSealed } from "../../op/gate";
|
|
57
57
|
import { getHeadCommit } from "../../lifecycle/git";
|
|
58
58
|
import { remainingFanOut, type ChangedUnits, type FanOutProgress } from "../../components/fan-out";
|
|
59
59
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
@@ -405,7 +405,9 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
|
|
|
405
405
|
// (#3049), the #2300 refusal on a set. Printed on stderr even under --json,
|
|
406
406
|
// since it is the line a CI log reader is looking for.
|
|
407
407
|
for (const wave of result.waves ?? []) {
|
|
408
|
-
if (wave.status === "gated" && wave.approved)
|
|
408
|
+
if (wave.status === "gated" && wave.approved) {
|
|
409
|
+
console.error(formatWarning({ message: describeChangedWave(wave, await gateIsSealed(wave.gate)) }));
|
|
410
|
+
}
|
|
409
411
|
}
|
|
410
412
|
|
|
411
413
|
// The same durable trace `chant run --components` leaves (#597), for the
|
|
@@ -429,10 +431,12 @@ export async function runComponentsFanOut(ctx: CommandContext): Promise<number>
|
|
|
429
431
|
const pending = result.gate;
|
|
430
432
|
// The pending fact's own plan: the fan-out's digest for the gate over the
|
|
431
433
|
// set, or a component's plan and environment for a gate inside one (#2574).
|
|
434
|
+
const sealed = await gateIsSealed(pending.gate);
|
|
432
435
|
console.error(formatInfo(
|
|
433
|
-
`approve : ${approveCommand(pending.op, pending.gate, pending.environment
|
|
436
|
+
`approve : ${approveCommand(pending.op, pending.gate, pending.environment, pending.planDigest ?? result.plan.digest, sealed)}`,
|
|
434
437
|
));
|
|
435
438
|
writeGatedRunSummary({
|
|
439
|
+
sealed,
|
|
436
440
|
op: pending.op,
|
|
437
441
|
gate: pending.gate,
|
|
438
442
|
...(pending.description ? { description: pending.description } : {}),
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "../../op/gate-approval";
|
|
50
50
|
import { approverOf, tallyGateApprovals } from "../../op/gate";
|
|
51
51
|
import { FAN_OUT_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
|
|
52
|
+
import { PR_GATE_OP } from "../../pr-loop";
|
|
52
53
|
import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
|
|
53
54
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
54
55
|
import type { CommandContext } from "../registry";
|
|
@@ -134,8 +135,6 @@ async function collectOperatorSubscribers(
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
/** A pull request's gate op, `pr-<number>` (#3183, `prOp` in ../../pr-loop.ts). */
|
|
138
|
-
const PR_GATE_OP = /^pr-\d+$/;
|
|
139
138
|
|
|
140
139
|
// ── chant operator ──────────────────────────────────────────────────────────
|
|
141
140
|
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* ```
|
|
6
6
|
* chant components pr-plan --base <ref> --pr <n> --env <env> [--forge github|gitlab|forgejo]
|
|
7
7
|
* chant approve pr-<n> pr-apply --plan <digest> --approver github:<login> --sign
|
|
8
|
-
* chant components pr-apply --base <ref> --env <env> [--pr <n>] [--forge <kind>] [--require-review]
|
|
8
|
+
* chant components pr-apply --base <ref> --env <env> [--pr <n>] [--forge <kind>] [--require-review] [--resume <file>]
|
|
9
9
|
* ```
|
|
10
10
|
*
|
|
11
11
|
* Both measure the change from the merge base of `--base` and `HEAD`, select
|
|
@@ -18,8 +18,22 @@
|
|
|
18
18
|
* applies only when an approval stands for that digest. A moved plan stops
|
|
19
19
|
* it with both digests named and nothing applied (exit 3).
|
|
20
20
|
*
|
|
21
|
+
* `--resume <file>` (#3464) finishes an apply that failed partway, the way
|
|
22
|
+
* `components fan-out --resume` does (#2417, #3049). The apply writes the
|
|
23
|
+
* attempt record as each component settles: the change set the gate
|
|
24
|
+
* approved and the components that applied. Repeated on the same commit
|
|
25
|
+
* with the same record, it plans only what is left, checks each member
|
|
26
|
+
* against the approved set (`resumePrSet`) and, when all are covered and the
|
|
27
|
+
* approval still stands, applies the rest without a fresh approval. When
|
|
28
|
+
* they are not covered it says why and runs as it would without a record.
|
|
29
|
+
*
|
|
21
30
|
* Every run writes `pr-plan.json` or `pr-apply.json` (`pr-report.schema.json`),
|
|
22
|
-
* `change-set.json` and `pr-note.md` under `--output` (default `.chant/pr`).
|
|
31
|
+
* `change-set.json` and `pr-note.md` under `--output` (default `.chant/pr`). *
|
|
32
|
+
* `--member <name>` (#3465) runs a workspace member's loop: the member's
|
|
33
|
+
* generated pipeline passes it from the member's directory. The gate is
|
|
34
|
+
* recorded under `pr-<n>-<member>`, and the note and the statuses carry the
|
|
35
|
+
* member's name, so each member's loop on one pull request stands apart.
|
|
36
|
+
* The name must be the member that owns the current directory.
|
|
23
37
|
*/
|
|
24
38
|
|
|
25
39
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -35,17 +49,20 @@ import {
|
|
|
35
49
|
planPrSet,
|
|
36
50
|
prReport,
|
|
37
51
|
readOnlyLedger,
|
|
52
|
+
resumePrSet,
|
|
38
53
|
type PrPlanSet,
|
|
39
54
|
} from "../../components/pr-run";
|
|
55
|
+
import { readFanOutAttempt, writeFanOutAttempt, type FanOutAttempt, type PrApplyRecord } from "../../components/fan-out-record";
|
|
56
|
+
import { samePlanDigest } from "../../lifecycle/plan-digest";
|
|
40
57
|
import { gitGateLedgerPort } from "../../op/gate";
|
|
41
58
|
import {
|
|
42
59
|
describePlanChanged,
|
|
43
60
|
PR_APPLY_GATE,
|
|
44
|
-
PR_STATUS_CONTEXTS,
|
|
45
61
|
prApproveCommand,
|
|
46
62
|
prNoteMarker,
|
|
47
63
|
prOp,
|
|
48
64
|
prStatusDescription,
|
|
65
|
+
prStatusContext,
|
|
49
66
|
prStatusState,
|
|
50
67
|
renderPrNote,
|
|
51
68
|
type PrApproval,
|
|
@@ -55,6 +72,7 @@ import { FORGE_KINDS, forgeFromEnv, forgePrincipalOf, type ForgeKind, type PrFor
|
|
|
55
72
|
import { GITHUB_COMMENT_LIMIT, GITLAB_NOTE_LIMIT } from "../../plan-summary";
|
|
56
73
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
57
74
|
import { formatError, formatInfo, formatSuccess, formatWarning } from "../format";
|
|
75
|
+
import { findWorkspaceRoot } from "../../project-root";
|
|
58
76
|
import { resolveChangedUnits } from "./fan-out";
|
|
59
77
|
import { GATED_EXIT_CODE, recordAutoReleasesForRun } from "./run";
|
|
60
78
|
import type { CommandContext } from "../registry";
|
|
@@ -69,6 +87,8 @@ export const PR_REPORT_DIR = ".chant/pr";
|
|
|
69
87
|
interface Prepared {
|
|
70
88
|
config: ChantConfig;
|
|
71
89
|
env: string;
|
|
90
|
+
/** The workspace member, with `--member` (#3465). */
|
|
91
|
+
member?: string;
|
|
72
92
|
base: string;
|
|
73
93
|
head: string;
|
|
74
94
|
gate: string;
|
|
@@ -77,7 +97,7 @@ interface Prepared {
|
|
|
77
97
|
plan: FanOutPlan;
|
|
78
98
|
signal: ComponentChangeSignal;
|
|
79
99
|
registry: CapabilityRegistry;
|
|
80
|
-
|
|
100
|
+
seededOutputs: Record<string, Record<string, unknown>>;
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
const fail = (message: string, hint?: string): number => {
|
|
@@ -85,7 +105,7 @@ const fail = (message: string, hint?: string): number => {
|
|
|
85
105
|
return 1;
|
|
86
106
|
};
|
|
87
107
|
|
|
88
|
-
/** Everything both stages share: the change
|
|
108
|
+
/** Everything both stages share: the change and the derivation. Each stage plans the members itself. */
|
|
89
109
|
async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Prepared | number> {
|
|
90
110
|
const { args } = ctx;
|
|
91
111
|
const projectPath = resolve(".");
|
|
@@ -106,6 +126,12 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
|
|
|
106
126
|
);
|
|
107
127
|
}
|
|
108
128
|
if (args.fromAffected) return fail(`pr-${stage} derives the change itself; drop --from-affected`);
|
|
129
|
+
let member: string | undefined;
|
|
130
|
+
if (args.members?.length) {
|
|
131
|
+
const checked = await checkMember(projectPath, args.members);
|
|
132
|
+
if (typeof checked !== "string") return fail(checked.error, checked.hint);
|
|
133
|
+
member = checked;
|
|
134
|
+
}
|
|
109
135
|
if (args.forge !== undefined && !(FORGE_KINDS as readonly string[]).includes(args.forge)) {
|
|
110
136
|
return fail(`--forge ${args.forge} is not one of ${FORGE_KINDS.join(", ")}`);
|
|
111
137
|
}
|
|
@@ -156,10 +182,10 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
|
|
|
156
182
|
|
|
157
183
|
const env = args.env ?? "local";
|
|
158
184
|
const registry = await fanOutRegistry(projectPath, config);
|
|
159
|
-
const set = await planPrSet({ components: derived.components, plan: derived.plan, registry, env, seededOutputs });
|
|
160
185
|
return {
|
|
161
186
|
config,
|
|
162
187
|
env,
|
|
188
|
+
...(member ? { member } : {}),
|
|
163
189
|
base,
|
|
164
190
|
head,
|
|
165
191
|
gate: args.gate ?? PR_APPLY_GATE,
|
|
@@ -168,10 +194,41 @@ async function prepare(ctx: CommandContext, stage: "plan" | "apply"): Promise<Pr
|
|
|
168
194
|
plan: derived.plan,
|
|
169
195
|
signal: derived.signal,
|
|
170
196
|
registry,
|
|
171
|
-
|
|
197
|
+
seededOutputs,
|
|
172
198
|
};
|
|
173
199
|
}
|
|
174
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The one `--member` name, checked against the workspace member that owns
|
|
203
|
+
* `projectPath`: a pipeline copied into another member would otherwise
|
|
204
|
+
* record its gate under the wrong name.
|
|
205
|
+
*/
|
|
206
|
+
async function checkMember(projectPath: string, names: string[]): Promise<string | { error: string; hint?: string }> {
|
|
207
|
+
if (names.length !== 1) return { error: `pr-plan and pr-apply take one --member, not ${names.join(", ")}` };
|
|
208
|
+
const name = names[0];
|
|
209
|
+
const found = findWorkspaceRoot(projectPath);
|
|
210
|
+
if (!found) return { error: `--member ${name}: no chant.workspace.json above ${projectPath}`, hint: "Drop --member outside a workspace." };
|
|
211
|
+
const { resolveMemberContext } = await import("../../workspace/member-pipeline");
|
|
212
|
+
let owner: string | undefined;
|
|
213
|
+
try {
|
|
214
|
+
owner = resolveMemberContext(projectPath, found)?.member.name;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
217
|
+
}
|
|
218
|
+
if (owner !== name) {
|
|
219
|
+
return {
|
|
220
|
+
error: `--member ${name}: ${owner ? `this directory belongs to member ${owner}` : "no workspace member owns this directory"}`,
|
|
221
|
+
hint: "Run the command in the member's directory, the way its generated pipeline does.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return name;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Plan every member the derivation selects. */
|
|
228
|
+
function planAll(p: Prepared): Promise<PrPlanSet> {
|
|
229
|
+
return planPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: p.env, seededOutputs: p.seededOutputs });
|
|
230
|
+
}
|
|
231
|
+
|
|
175
232
|
/** Write the report, the change set and the note; print the note's path. */
|
|
176
233
|
function writeOutputs(ctx: CommandContext, report: PrReport, note: string): void {
|
|
177
234
|
const dir = resolve(ctx.args.output ?? PR_REPORT_DIR);
|
|
@@ -185,7 +242,7 @@ function writeOutputs(ctx: CommandContext, report: PrReport, note: string): void
|
|
|
185
242
|
async function publish(forge: PrForge | undefined, report: PrReport, note: string): Promise<void> {
|
|
186
243
|
if (!forge) return;
|
|
187
244
|
const status = {
|
|
188
|
-
context:
|
|
245
|
+
context: prStatusContext(report.stage, report.member),
|
|
189
246
|
state: prStatusState(report),
|
|
190
247
|
description: prStatusDescription(report),
|
|
191
248
|
...(forge.runUrl ? { url: forge.runUrl } : {}),
|
|
@@ -197,7 +254,7 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
|
|
|
197
254
|
}
|
|
198
255
|
if (report.pr === null) return;
|
|
199
256
|
try {
|
|
200
|
-
await forge.upsertNote(report.pr, prNoteMarker(report.env), note);
|
|
257
|
+
await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
|
|
201
258
|
} catch (err) {
|
|
202
259
|
console.error(formatWarning({ message: `could not post the note on #${report.pr}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
203
260
|
}
|
|
@@ -231,28 +288,30 @@ export async function runComponentsPrPlan(ctx: CommandContext): Promise<number>
|
|
|
231
288
|
}
|
|
232
289
|
const p = await prepare(ctx, "plan");
|
|
233
290
|
if (typeof p === "number") return p;
|
|
234
|
-
const
|
|
291
|
+
const set = await planAll(p);
|
|
292
|
+
const op = prOp(ctx.args.pr, p.member);
|
|
235
293
|
|
|
236
294
|
let approval: PrApproval = { status: "pending" };
|
|
237
295
|
if (p.plan.order.length > 0) {
|
|
238
296
|
try {
|
|
239
|
-
approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest:
|
|
297
|
+
approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: set.doc.digest }));
|
|
240
298
|
} catch (err) {
|
|
241
299
|
console.error(formatWarning({ message: `could not read the gate ledger, so the approval reads as pending: ${err instanceof Error ? err.message : String(err)}` }));
|
|
242
300
|
}
|
|
243
301
|
}
|
|
244
|
-
const status = p.plan.order.length === 0 ? "nothing" :
|
|
302
|
+
const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
|
|
245
303
|
const report = prReport({
|
|
246
304
|
stage: "plan",
|
|
247
305
|
pr: ctx.args.pr,
|
|
248
306
|
env: p.env,
|
|
307
|
+
...(p.member ? { member: p.member } : {}),
|
|
249
308
|
base: p.base,
|
|
250
309
|
head: p.head,
|
|
251
310
|
op,
|
|
252
311
|
gate: p.gate,
|
|
253
312
|
plan: p.plan,
|
|
254
313
|
signal: p.signal,
|
|
255
|
-
set
|
|
314
|
+
set,
|
|
256
315
|
approval,
|
|
257
316
|
status,
|
|
258
317
|
});
|
|
@@ -282,8 +341,24 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
282
341
|
return fail(`could not ask ${p.forge.kind} which pull request merged ${p.head}: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
342
|
}
|
|
284
343
|
}
|
|
285
|
-
const op = pr === null ? "pr-unknown" : prOp(pr);
|
|
286
|
-
|
|
344
|
+
const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
|
|
345
|
+
|
|
346
|
+
// `--resume` (#3464): an earlier attempt at this change on this commit
|
|
347
|
+
// applied part of an approved set. When the members left are covered by
|
|
348
|
+
// it and its approval still stands, finish it under that approval.
|
|
349
|
+
const resumePath = args.resume ? resolve(args.resume) : undefined;
|
|
350
|
+
let attempt: FanOutAttempt | undefined;
|
|
351
|
+
if (resumePath) {
|
|
352
|
+
try {
|
|
353
|
+
attempt = readFanOutAttempt(resumePath);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return fail(`--resume: could not read "${args.resume}": ${err instanceof Error ? err.message : String(err)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const resumed = pr !== null && p.plan.order.length > 0 && attempt ? await tryResume(p, attempt, args.resume!, op) : undefined;
|
|
359
|
+
|
|
360
|
+
const set = resumed?.set ?? (await planAll(p));
|
|
361
|
+
const base = { stage: "apply" as const, pr, env: p.env, ...(p.member ? { member: p.member } : {}), base: p.base, head: p.head, op, gate: p.gate, plan: p.plan, signal: p.signal, set };
|
|
287
362
|
|
|
288
363
|
const stop = async (report: PrReport, code: number): Promise<number> => {
|
|
289
364
|
await finish(ctx, p, report);
|
|
@@ -308,7 +383,7 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
308
383
|
GATED_EXIT_CODE,
|
|
309
384
|
);
|
|
310
385
|
}
|
|
311
|
-
if (
|
|
386
|
+
if (set.failed) {
|
|
312
387
|
return stop(
|
|
313
388
|
prReport({
|
|
314
389
|
...base,
|
|
@@ -320,23 +395,28 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
320
395
|
);
|
|
321
396
|
}
|
|
322
397
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
398
|
+
let approval: PrApproval;
|
|
399
|
+
if (resumed) {
|
|
400
|
+
approval = resumed.approval;
|
|
401
|
+
} else {
|
|
402
|
+
const check = await decidePrGate(gitGateLedgerPort(), {
|
|
403
|
+
op,
|
|
404
|
+
gate: p.gate,
|
|
405
|
+
digest: set.doc.digest,
|
|
406
|
+
description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
|
|
407
|
+
});
|
|
408
|
+
approval = approvalOf(check);
|
|
409
|
+
if (!check.satisfied) {
|
|
410
|
+
const partial = { ...base, approval, status: "refused" as const };
|
|
411
|
+
const report = approval.status === "changed"
|
|
412
|
+
? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
|
|
413
|
+
: prReport({
|
|
414
|
+
...partial,
|
|
415
|
+
refusal: "not-approved",
|
|
416
|
+
message: `No approval stands for this plan, so nothing was applied. Approve it: ${prApproveCommand({ op, gate: p.gate, digest: set.doc.digest })}; then run the apply again.`,
|
|
417
|
+
});
|
|
418
|
+
return stop(report, GATED_EXIT_CODE);
|
|
419
|
+
}
|
|
340
420
|
}
|
|
341
421
|
|
|
342
422
|
if (args.requireReview && p.forge) {
|
|
@@ -364,10 +444,115 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
364
444
|
}
|
|
365
445
|
}
|
|
366
446
|
|
|
367
|
-
|
|
447
|
+
// The record is written before the first component runs and again as each
|
|
448
|
+
// one settles, so a run killed partway still knows what applied.
|
|
449
|
+
const prior = resumed ? { completed: resumed.completed, outputs: resumed.outputs } : { completed: [], outputs: {} };
|
|
450
|
+
const completedSoFar = new Set<string>(prior.completed);
|
|
451
|
+
const failedSoFar: string[] = [];
|
|
452
|
+
const outputsSoFar: Record<string, Record<string, unknown>> = { ...prior.outputs };
|
|
453
|
+
const record: PrApplyRecord = {
|
|
454
|
+
op,
|
|
455
|
+
gate: p.gate,
|
|
456
|
+
head: p.head,
|
|
457
|
+
digest: set.doc.digest,
|
|
458
|
+
approvedBy: approval.approvedBy ?? [],
|
|
459
|
+
members: set.members,
|
|
460
|
+
changeSet: set.doc,
|
|
461
|
+
planOutputs: set.outputs,
|
|
462
|
+
};
|
|
463
|
+
const recordAttempt = (): void => {
|
|
464
|
+
if (!resumePath) return;
|
|
465
|
+
writeFanOutAttempt(resumePath, {
|
|
466
|
+
digest: p.plan.digest,
|
|
467
|
+
completed: [...completedSoFar].sort(),
|
|
468
|
+
failed: [...failedSoFar].sort(),
|
|
469
|
+
outputs: outputsSoFar,
|
|
470
|
+
prApply: record,
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
recordAttempt();
|
|
474
|
+
|
|
475
|
+
const applied = await applyPrSet({
|
|
476
|
+
components: p.components,
|
|
477
|
+
plan: resumed?.plan ?? p.plan,
|
|
478
|
+
registry: p.registry,
|
|
479
|
+
env: p.env,
|
|
480
|
+
set,
|
|
481
|
+
...(resumed ? { resumed: prior } : {}),
|
|
482
|
+
...(resumePath
|
|
483
|
+
? {
|
|
484
|
+
onComponentSettled: (settled, outputs) => {
|
|
485
|
+
if (settled.status === "ok") {
|
|
486
|
+
completedSoFar.add(settled.component);
|
|
487
|
+
if (outputs) outputsSoFar[settled.component] = outputs;
|
|
488
|
+
} else if (settled.status === "fail") {
|
|
489
|
+
failedSoFar.push(settled.component);
|
|
490
|
+
}
|
|
491
|
+
recordAttempt();
|
|
492
|
+
},
|
|
493
|
+
}
|
|
494
|
+
: {}),
|
|
495
|
+
});
|
|
496
|
+
recordAttempt();
|
|
368
497
|
await recordAutoReleasesForRun(applied.run.results, p.env, `${op}-${Date.now()}`, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord));
|
|
369
|
-
const report = prReport({
|
|
498
|
+
const report = prReport({
|
|
499
|
+
...base,
|
|
500
|
+
approval,
|
|
501
|
+
status: applied.status,
|
|
502
|
+
members: applied.members,
|
|
503
|
+
...(resumed ? { resumed: resumed.completed } : {}),
|
|
504
|
+
});
|
|
370
505
|
const code = await stop(report, applied.status === "applied" ? 0 : 1);
|
|
371
506
|
if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
|
|
372
507
|
return code;
|
|
373
508
|
}
|
|
509
|
+
|
|
510
|
+
interface Resumed {
|
|
511
|
+
plan: FanOutPlan;
|
|
512
|
+
set: PrPlanSet;
|
|
513
|
+
approval: PrApproval;
|
|
514
|
+
completed: string[];
|
|
515
|
+
outputs: Record<string, Record<string, unknown>>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Whether `attempt` finishes here (#3464). It carries only when it was made
|
|
520
|
+
* by an apply of this pull request's gate, on this commit, for this
|
|
521
|
+
* derivation; when its approval still stands; and when every member left is
|
|
522
|
+
* covered by the approved set. Otherwise it says why on stderr and the apply
|
|
523
|
+
* runs as it would with no record.
|
|
524
|
+
*/
|
|
525
|
+
async function tryResume(p: Prepared, attempt: FanOutAttempt, file: string, op: string): Promise<Resumed | undefined> {
|
|
526
|
+
const skip = (why: string): undefined => {
|
|
527
|
+
console.error(formatWarning({ message: `--resume: "${file}" does not carry: ${why}`, hint: "Every member is planned again, and the plan needs an approval of its own." }));
|
|
528
|
+
return undefined;
|
|
529
|
+
};
|
|
530
|
+
const record = attempt.prApply;
|
|
531
|
+
if (!record) return skip("it was not written by pr-apply");
|
|
532
|
+
if (record.op !== op || record.gate !== p.gate) return skip(`it records ${record.op} ${record.gate}, not ${op} ${p.gate}`);
|
|
533
|
+
if (record.head !== p.head) return skip(`it records an apply of ${record.head.slice(0, 12)}, not ${p.head.slice(0, 12)}`);
|
|
534
|
+
if (!samePlanDigest(attempt.digest, p.plan.digest)) return skip("the change selects other components than it did");
|
|
535
|
+
if (attempt.completed.length === 0) return undefined;
|
|
536
|
+
|
|
537
|
+
let check;
|
|
538
|
+
try {
|
|
539
|
+
check = await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: record.digest });
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return skip(`could not read the gate ledger: ${err instanceof Error ? err.message : String(err)}`);
|
|
542
|
+
}
|
|
543
|
+
if (!check.satisfied) return skip(`no approval stands for the plan it applied (${record.digest}) any more`);
|
|
544
|
+
|
|
545
|
+
const outputs = Object.fromEntries(Object.entries(attempt.outputs).filter(([name]) => attempt.completed.includes(name)));
|
|
546
|
+
const resumed = await resumePrSet({
|
|
547
|
+
components: p.components,
|
|
548
|
+
plan: p.plan,
|
|
549
|
+
registry: p.registry,
|
|
550
|
+
env: p.env,
|
|
551
|
+
seededOutputs: p.seededOutputs,
|
|
552
|
+
record,
|
|
553
|
+
completed: attempt.completed,
|
|
554
|
+
});
|
|
555
|
+
if (!resumed.ok) return skip(`what is left is not what was approved. ${resumed.reasons.join("; ")}`);
|
|
556
|
+
console.error(formatInfo(`resume : ${attempt.completed.join(", ")} applied in an earlier attempt; applying ${resumed.plan.order.join(", ") || "nothing more"} under the approval of ${record.digest}`));
|
|
557
|
+
return { plan: resumed.plan, set: resumed.set, approval: approvalOf(check), completed: attempt.completed, outputs };
|
|
558
|
+
}
|
|
@@ -61,8 +61,9 @@ vi.mock("../../components/fan-out-support", () => ({
|
|
|
61
61
|
vi.mock("../../op/gate", async () => {
|
|
62
62
|
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
63
63
|
const port = actual.memoryGateLedgerPort();
|
|
64
|
-
return { ...actual, gitGateLedgerPort: () => port };
|
|
64
|
+
return { ...actual, gitGateLedgerPort: () => port, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
|
|
65
65
|
});
|
|
66
|
+
const sealedGates = new Set<string>();
|
|
66
67
|
|
|
67
68
|
// The run handler pulls in every runtime; the promote needs only its exit code.
|
|
68
69
|
vi.mock("./run", () => ({ GATED_EXIT_CODE: 3 }));
|
|
@@ -113,6 +114,7 @@ let errors: string[];
|
|
|
113
114
|
let logs: string[];
|
|
114
115
|
|
|
115
116
|
beforeEach(() => {
|
|
117
|
+
sealedGates.clear();
|
|
116
118
|
ran.length = 0;
|
|
117
119
|
publishedDigest = "sha256:api";
|
|
118
120
|
failApplyFor = undefined;
|
|
@@ -186,6 +188,18 @@ describe("chant components promote", () => {
|
|
|
186
188
|
expect(ran).not.toContain("prod:apply");
|
|
187
189
|
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
188
190
|
expect(errors.join("\n")).toMatch(/chant approve api prod-release/);
|
|
191
|
+
expect(errors.join("\n")).not.toContain("--sign");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a sealed gate's approve line carries --sign (#3521)", async () => {
|
|
195
|
+
sealedGates.add("prod-release");
|
|
196
|
+
const gated: DriverComponent = {
|
|
197
|
+
...api,
|
|
198
|
+
deploy: [...api.deploy.slice(0, 2), { phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "apply" }] }],
|
|
199
|
+
};
|
|
200
|
+
resolveTargetsMock.mockResolvedValue({ success: true, targets: [gated] });
|
|
201
|
+
expect(await runComponentsPromote(ctx({}))).toBe(3);
|
|
202
|
+
expect(errors.join("\n")).toMatch(/approve : chant approve api prod-release.* --sign/);
|
|
189
203
|
});
|
|
190
204
|
|
|
191
205
|
test("a component that deployed is recorded even when another one failed", async () => {
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
} from "../../components/promote";
|
|
48
48
|
import type { DriverComponent } from "../../components/driver";
|
|
49
49
|
import { summaryLedgerPrefix, writeGatedRunSummary } from "../../op/gate-summary";
|
|
50
|
-
import { approveCommand, describeGateMismatch } from "../../op/gate";
|
|
50
|
+
import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
|
|
51
51
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
52
52
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
53
53
|
import { GATED_EXIT_CODE } from "./run";
|
|
@@ -248,12 +248,14 @@ async function deployPlan(
|
|
|
248
248
|
const pending = run.gate;
|
|
249
249
|
console.error(formatWarning({ message: `component "${run.gatedComponent}" is gated on "${pending.gate}" — pending approval` }));
|
|
250
250
|
// #2574: an approval stands, but for another environment, plan or none.
|
|
251
|
+
const sealed = await gateIsSealed(pending.gate);
|
|
251
252
|
if (run.gateMismatch) {
|
|
252
|
-
console.error(formatWarning({ message: describeGateMismatch(pending.op, pending.gate, run.gateMismatch) }));
|
|
253
|
+
console.error(formatWarning({ message: describeGateMismatch(pending.op, pending.gate, run.gateMismatch, sealed) }));
|
|
253
254
|
}
|
|
254
|
-
console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate, pending.environment)}`));
|
|
255
|
+
console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate, pending.environment, undefined, sealed)}`));
|
|
255
256
|
console.error(formatInfo(`then run the same ${verb} again`));
|
|
256
257
|
writeGatedRunSummary({
|
|
258
|
+
sealed,
|
|
257
259
|
op: pending.op,
|
|
258
260
|
gate: pending.gate,
|
|
259
261
|
...(pending.description ? { description: pending.description } : {}),
|