@intentius/chant 0.105.1 → 0.106.3
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/change-set.d.ts +30 -8
- package/dist/change-set.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +11 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- 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/mcp/op-tools.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 +67 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +94 -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 +29 -4
- 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 +32 -6
- 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 +15 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.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/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +2 -2
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -1
- package/src/change-set.test.ts +42 -12
- package/src/change-set.ts +50 -11
- package/src/cli/commands/build.ts +33 -14
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +102 -0
- package/src/cli/handlers/build.ts +68 -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 +223 -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 +215 -1
- package/src/cli/handlers/run.ts +36 -15
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +120 -0
- package/src/cli/mcp/op-approve-origin.test.ts +1 -1
- package/src/cli/mcp/op-tools.ts +14 -2
- package/src/cli/mcp/server.test.ts +2 -1
- 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 +96 -6
- package/src/components/pr-run.test.ts +311 -4
- package/src/components/pr-run.ts +198 -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 +31 -4
- 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 +41 -10
- package/src/workspace/change-set.schema.json +1 -1
- 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 +21 -3
- package/src/workspace/pr-report.schema.json +14 -1
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 } : {}),
|
|
@@ -196,8 +253,11 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
|
|
|
196
253
|
console.error(formatWarning({ message: `could not set the ${status.context} status: ${err instanceof Error ? err.message : String(err)}` }));
|
|
197
254
|
}
|
|
198
255
|
if (report.pr === null) return;
|
|
256
|
+
// A member the change does not reach has nothing to say: one note per
|
|
257
|
+
// member would put a line on every pull request for each member it skips.
|
|
258
|
+
if (report.member && report.status === "nothing") return;
|
|
199
259
|
try {
|
|
200
|
-
await forge.upsertNote(report.pr, prNoteMarker(report.env), note);
|
|
260
|
+
await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
|
|
201
261
|
} catch (err) {
|
|
202
262
|
console.error(formatWarning({ message: `could not post the note on #${report.pr}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
203
263
|
}
|
|
@@ -231,28 +291,30 @@ export async function runComponentsPrPlan(ctx: CommandContext): Promise<number>
|
|
|
231
291
|
}
|
|
232
292
|
const p = await prepare(ctx, "plan");
|
|
233
293
|
if (typeof p === "number") return p;
|
|
234
|
-
const
|
|
294
|
+
const set = await planAll(p);
|
|
295
|
+
const op = prOp(ctx.args.pr, p.member);
|
|
235
296
|
|
|
236
297
|
let approval: PrApproval = { status: "pending" };
|
|
237
298
|
if (p.plan.order.length > 0) {
|
|
238
299
|
try {
|
|
239
|
-
approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest:
|
|
300
|
+
approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: set.doc.digest }));
|
|
240
301
|
} catch (err) {
|
|
241
302
|
console.error(formatWarning({ message: `could not read the gate ledger, so the approval reads as pending: ${err instanceof Error ? err.message : String(err)}` }));
|
|
242
303
|
}
|
|
243
304
|
}
|
|
244
|
-
const status = p.plan.order.length === 0 ? "nothing" :
|
|
305
|
+
const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
|
|
245
306
|
const report = prReport({
|
|
246
307
|
stage: "plan",
|
|
247
308
|
pr: ctx.args.pr,
|
|
248
309
|
env: p.env,
|
|
310
|
+
...(p.member ? { member: p.member } : {}),
|
|
249
311
|
base: p.base,
|
|
250
312
|
head: p.head,
|
|
251
313
|
op,
|
|
252
314
|
gate: p.gate,
|
|
253
315
|
plan: p.plan,
|
|
254
316
|
signal: p.signal,
|
|
255
|
-
set
|
|
317
|
+
set,
|
|
256
318
|
approval,
|
|
257
319
|
status,
|
|
258
320
|
});
|
|
@@ -282,8 +344,24 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
282
344
|
return fail(`could not ask ${p.forge.kind} which pull request merged ${p.head}: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
345
|
}
|
|
284
346
|
}
|
|
285
|
-
const op = pr === null ? "pr-unknown" : prOp(pr);
|
|
286
|
-
|
|
347
|
+
const op = pr === null ? "pr-unknown" : prOp(pr, p.member);
|
|
348
|
+
|
|
349
|
+
// `--resume` (#3464): an earlier attempt at this change on this commit
|
|
350
|
+
// applied part of an approved set. When the members left are covered by
|
|
351
|
+
// it and its approval still stands, finish it under that approval.
|
|
352
|
+
const resumePath = args.resume ? resolve(args.resume) : undefined;
|
|
353
|
+
let attempt: FanOutAttempt | undefined;
|
|
354
|
+
if (resumePath) {
|
|
355
|
+
try {
|
|
356
|
+
attempt = readFanOutAttempt(resumePath);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return fail(`--resume: could not read "${args.resume}": ${err instanceof Error ? err.message : String(err)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const resumed = pr !== null && p.plan.order.length > 0 && attempt ? await tryResume(p, attempt, args.resume!, op) : undefined;
|
|
362
|
+
|
|
363
|
+
const set = resumed?.set ?? (await planAll(p));
|
|
364
|
+
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
365
|
|
|
288
366
|
const stop = async (report: PrReport, code: number): Promise<number> => {
|
|
289
367
|
await finish(ctx, p, report);
|
|
@@ -308,7 +386,7 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
308
386
|
GATED_EXIT_CODE,
|
|
309
387
|
);
|
|
310
388
|
}
|
|
311
|
-
if (
|
|
389
|
+
if (set.failed) {
|
|
312
390
|
return stop(
|
|
313
391
|
prReport({
|
|
314
392
|
...base,
|
|
@@ -320,23 +398,28 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
320
398
|
);
|
|
321
399
|
}
|
|
322
400
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
401
|
+
let approval: PrApproval;
|
|
402
|
+
if (resumed) {
|
|
403
|
+
approval = resumed.approval;
|
|
404
|
+
} else {
|
|
405
|
+
const check = await decidePrGate(gitGateLedgerPort(), {
|
|
406
|
+
op,
|
|
407
|
+
gate: p.gate,
|
|
408
|
+
digest: set.doc.digest,
|
|
409
|
+
description: `pull request #${pr}: ${p.plan.order.join(", ")}`,
|
|
410
|
+
});
|
|
411
|
+
approval = approvalOf(check);
|
|
412
|
+
if (!check.satisfied) {
|
|
413
|
+
const partial = { ...base, approval, status: "refused" as const };
|
|
414
|
+
const report = approval.status === "changed"
|
|
415
|
+
? prReport({ ...partial, refusal: "plan-changed", message: describePlanChanged({ op, gate: p.gate, digest: set.doc.digest, approval }) })
|
|
416
|
+
: prReport({
|
|
417
|
+
...partial,
|
|
418
|
+
refusal: "not-approved",
|
|
419
|
+
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.`,
|
|
420
|
+
});
|
|
421
|
+
return stop(report, GATED_EXIT_CODE);
|
|
422
|
+
}
|
|
340
423
|
}
|
|
341
424
|
|
|
342
425
|
if (args.requireReview && p.forge) {
|
|
@@ -364,10 +447,115 @@ export async function runComponentsPrApply(ctx: CommandContext): Promise<number>
|
|
|
364
447
|
}
|
|
365
448
|
}
|
|
366
449
|
|
|
367
|
-
|
|
450
|
+
// The record is written before the first component runs and again as each
|
|
451
|
+
// one settles, so a run killed partway still knows what applied.
|
|
452
|
+
const prior = resumed ? { completed: resumed.completed, outputs: resumed.outputs } : { completed: [], outputs: {} };
|
|
453
|
+
const completedSoFar = new Set<string>(prior.completed);
|
|
454
|
+
const failedSoFar: string[] = [];
|
|
455
|
+
const outputsSoFar: Record<string, Record<string, unknown>> = { ...prior.outputs };
|
|
456
|
+
const record: PrApplyRecord = {
|
|
457
|
+
op,
|
|
458
|
+
gate: p.gate,
|
|
459
|
+
head: p.head,
|
|
460
|
+
digest: set.doc.digest,
|
|
461
|
+
approvedBy: approval.approvedBy ?? [],
|
|
462
|
+
members: set.members,
|
|
463
|
+
changeSet: set.doc,
|
|
464
|
+
planOutputs: set.outputs,
|
|
465
|
+
};
|
|
466
|
+
const recordAttempt = (): void => {
|
|
467
|
+
if (!resumePath) return;
|
|
468
|
+
writeFanOutAttempt(resumePath, {
|
|
469
|
+
digest: p.plan.digest,
|
|
470
|
+
completed: [...completedSoFar].sort(),
|
|
471
|
+
failed: [...failedSoFar].sort(),
|
|
472
|
+
outputs: outputsSoFar,
|
|
473
|
+
prApply: record,
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
recordAttempt();
|
|
477
|
+
|
|
478
|
+
const applied = await applyPrSet({
|
|
479
|
+
components: p.components,
|
|
480
|
+
plan: resumed?.plan ?? p.plan,
|
|
481
|
+
registry: p.registry,
|
|
482
|
+
env: p.env,
|
|
483
|
+
set,
|
|
484
|
+
...(resumed ? { resumed: prior } : {}),
|
|
485
|
+
...(resumePath
|
|
486
|
+
? {
|
|
487
|
+
onComponentSettled: (settled, outputs) => {
|
|
488
|
+
if (settled.status === "ok") {
|
|
489
|
+
completedSoFar.add(settled.component);
|
|
490
|
+
if (outputs) outputsSoFar[settled.component] = outputs;
|
|
491
|
+
} else if (settled.status === "fail") {
|
|
492
|
+
failedSoFar.push(settled.component);
|
|
493
|
+
}
|
|
494
|
+
recordAttempt();
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
: {}),
|
|
498
|
+
});
|
|
499
|
+
recordAttempt();
|
|
368
500
|
await recordAutoReleasesForRun(applied.run.results, p.env, `${op}-${Date.now()}`, resolveAutoReleaseDisabled(p.config, args.noReleaseRecord));
|
|
369
|
-
const report = prReport({
|
|
501
|
+
const report = prReport({
|
|
502
|
+
...base,
|
|
503
|
+
approval,
|
|
504
|
+
status: applied.status,
|
|
505
|
+
members: applied.members,
|
|
506
|
+
...(resumed ? { resumed: resumed.completed } : {}),
|
|
507
|
+
});
|
|
370
508
|
const code = await stop(report, applied.status === "applied" ? 0 : 1);
|
|
371
509
|
if (applied.status === "applied") console.error(formatSuccess(`applied the plan ${report.digest} approved by ${(approval.approvedBy ?? []).join(", ")}`));
|
|
372
510
|
return code;
|
|
373
511
|
}
|
|
512
|
+
|
|
513
|
+
interface Resumed {
|
|
514
|
+
plan: FanOutPlan;
|
|
515
|
+
set: PrPlanSet;
|
|
516
|
+
approval: PrApproval;
|
|
517
|
+
completed: string[];
|
|
518
|
+
outputs: Record<string, Record<string, unknown>>;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Whether `attempt` finishes here (#3464). It carries only when it was made
|
|
523
|
+
* by an apply of this pull request's gate, on this commit, for this
|
|
524
|
+
* derivation; when its approval still stands; and when every member left is
|
|
525
|
+
* covered by the approved set. Otherwise it says why on stderr and the apply
|
|
526
|
+
* runs as it would with no record.
|
|
527
|
+
*/
|
|
528
|
+
async function tryResume(p: Prepared, attempt: FanOutAttempt, file: string, op: string): Promise<Resumed | undefined> {
|
|
529
|
+
const skip = (why: string): undefined => {
|
|
530
|
+
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." }));
|
|
531
|
+
return undefined;
|
|
532
|
+
};
|
|
533
|
+
const record = attempt.prApply;
|
|
534
|
+
if (!record) return skip("it was not written by pr-apply");
|
|
535
|
+
if (record.op !== op || record.gate !== p.gate) return skip(`it records ${record.op} ${record.gate}, not ${op} ${p.gate}`);
|
|
536
|
+
if (record.head !== p.head) return skip(`it records an apply of ${record.head.slice(0, 12)}, not ${p.head.slice(0, 12)}`);
|
|
537
|
+
if (!samePlanDigest(attempt.digest, p.plan.digest)) return skip("the change selects other components than it did");
|
|
538
|
+
if (attempt.completed.length === 0) return undefined;
|
|
539
|
+
|
|
540
|
+
let check;
|
|
541
|
+
try {
|
|
542
|
+
check = await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: p.gate, digest: record.digest });
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return skip(`could not read the gate ledger: ${err instanceof Error ? err.message : String(err)}`);
|
|
545
|
+
}
|
|
546
|
+
if (!check.satisfied) return skip(`no approval stands for the plan it applied (${record.digest}) any more`);
|
|
547
|
+
|
|
548
|
+
const outputs = Object.fromEntries(Object.entries(attempt.outputs).filter(([name]) => attempt.completed.includes(name)));
|
|
549
|
+
const resumed = await resumePrSet({
|
|
550
|
+
components: p.components,
|
|
551
|
+
plan: p.plan,
|
|
552
|
+
registry: p.registry,
|
|
553
|
+
env: p.env,
|
|
554
|
+
seededOutputs: p.seededOutputs,
|
|
555
|
+
record,
|
|
556
|
+
completed: attempt.completed,
|
|
557
|
+
});
|
|
558
|
+
if (!resumed.ok) return skip(`what is left is not what was approved. ${resumed.reasons.join("; ")}`);
|
|
559
|
+
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}`));
|
|
560
|
+
return { plan: resumed.plan, set: resumed.set, approval: approvalOf(check), completed: attempt.completed, outputs };
|
|
561
|
+
}
|
|
@@ -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 } : {}),
|