@intentius/chant 0.105.0 → 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/activities/factory.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/activities/factory.ts +9 -1
- 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
package/src/components/pr-run.ts
CHANGED
|
@@ -18,9 +18,22 @@
|
|
|
18
18
|
* it against the new value. A change that must carry a moved output through
|
|
19
19
|
* its dependents in one rollout is what gated waves are for (#3049): each
|
|
20
20
|
* wave is planned after the one before it applied.
|
|
21
|
+
*
|
|
22
|
+
* ## Finishing an apply that failed partway
|
|
23
|
+
*
|
|
24
|
+
* {@link resumePrSet} (#3464) is fan-out's resume (#2417, #3049) for a pull
|
|
25
|
+
* request. The attempt record (`./fan-out-record.ts`) keeps the change set
|
|
26
|
+
* the gate approved and the components that applied. A re-run on the same
|
|
27
|
+
* commit narrows the derivation to what is left (`remainingFanOut`), plans
|
|
28
|
+
* only that against the outputs the approved plans read, and checks each
|
|
29
|
+
* member it plans against the approved set. When every one is covered, the
|
|
30
|
+
* gate is decided against the approved digest, which still stands, and only
|
|
31
|
+
* the rest applies. Planning everything again would give the applied members
|
|
32
|
+
* empty plans and the set a new digest, and so ask for a second approval of
|
|
33
|
+
* work already approved.
|
|
21
34
|
*/
|
|
22
35
|
|
|
23
|
-
import { composeChangeSet, type ChangeSetDocument, type ChangeSetPart } from "../change-set";
|
|
36
|
+
import { composeChangeSet, verifyChangeSetDigest, type ChangeSetDocument, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
|
|
24
37
|
import { evaluateGate, type GateCheck, type GateLedgerPort } from "../op/gate";
|
|
25
38
|
import {
|
|
26
39
|
memberCounts,
|
|
@@ -32,10 +45,11 @@ import {
|
|
|
32
45
|
type PrReport,
|
|
33
46
|
type PrStatus,
|
|
34
47
|
} from "../pr-loop";
|
|
35
|
-
import { deployContext, type DriverComponent } from "./driver";
|
|
48
|
+
import { deployContext, type DriverComponent, type DriverComponentResult } from "./driver";
|
|
36
49
|
import { deployUnits } from "./deploy-units";
|
|
37
50
|
import { runFanOut, type FanOutRunResult } from "./fan-out-run";
|
|
38
|
-
import type
|
|
51
|
+
import { remainingFanOut, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
|
|
52
|
+
import type { PrApplyRecord } from "./fan-out-record";
|
|
39
53
|
import type { CapabilityRegistry } from "./capability";
|
|
40
54
|
import type { RunProgressEvent } from "./run-progress";
|
|
41
55
|
import { planWaveComponent, readComponentOutputs } from "./wave-plan";
|
|
@@ -189,9 +203,17 @@ export interface PrApplyOptions {
|
|
|
189
203
|
registry: CapabilityRegistry;
|
|
190
204
|
env: string;
|
|
191
205
|
vars?: Record<string, unknown>;
|
|
192
|
-
/** The set {@link planPrSet} planned and the gate approved. */
|
|
206
|
+
/** The set {@link planPrSet} planned and the gate approved, or {@link resumePrSet}'s set. */
|
|
193
207
|
set: PrPlanSet;
|
|
194
208
|
onProgress?: (event: RunProgressEvent) => void;
|
|
209
|
+
/**
|
|
210
|
+
* What an earlier attempt at this approved set applied (#3464): its
|
|
211
|
+
* components, which are reported applied and not run again, and the
|
|
212
|
+
* outputs they left.
|
|
213
|
+
*/
|
|
214
|
+
resumed?: { completed: string[]; outputs: Record<string, Record<string, unknown>> };
|
|
215
|
+
/** Called as each component settles, so an attempt record survives a kill (`runFanOut`). */
|
|
216
|
+
onComponentSettled?: (result: DriverComponentResult, outputs: Record<string, unknown> | undefined) => void;
|
|
195
217
|
}
|
|
196
218
|
|
|
197
219
|
const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
@@ -208,8 +230,19 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
|
|
|
208
230
|
plans: options.set.plans,
|
|
209
231
|
componentOutputs: { ...before },
|
|
210
232
|
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
233
|
+
...(options.onComponentSettled ? { onComponentSettled: options.onComponentSettled } : {}),
|
|
211
234
|
});
|
|
212
235
|
const completed = new Set(run.completed);
|
|
236
|
+
const priorCompleted = new Set(options.resumed?.completed ?? []);
|
|
237
|
+
/**
|
|
238
|
+
* Whether a dependency's outputs moved from what the plans read: by this
|
|
239
|
+
* run's apply, or by the earlier attempt's when the record kept them.
|
|
240
|
+
*/
|
|
241
|
+
const movedBy = (dep: string): boolean => {
|
|
242
|
+
if (completed.has(dep)) return !sameJson(before[dep], run.componentOutputs[dep]);
|
|
243
|
+
const earlier = options.resumed?.outputs;
|
|
244
|
+
return priorCompleted.has(dep) && earlier !== undefined && dep in earlier && !sameJson(before[dep], earlier[dep]);
|
|
245
|
+
};
|
|
213
246
|
const blocked = new Set(run.blocked.map((b) => b.component));
|
|
214
247
|
const errorOf = (component: string): string | undefined => {
|
|
215
248
|
const result = run.results.find((r) => r.component === component);
|
|
@@ -218,9 +251,10 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
|
|
|
218
251
|
};
|
|
219
252
|
const byName = new Map(options.components.map((c) => [c.name, c]));
|
|
220
253
|
const members = options.set.members.map((m): PrMember => {
|
|
254
|
+
if (priorCompleted.has(m.component)) return { ...m, status: "applied" };
|
|
221
255
|
if (completed.has(m.component)) {
|
|
222
256
|
const moved = (byName.get(m.component)?.dependsOn ?? [])
|
|
223
|
-
.filter(
|
|
257
|
+
.filter(movedBy)
|
|
224
258
|
.sort();
|
|
225
259
|
return { ...m, status: "applied", ...(moved.length > 0 ? { inputsMoved: moved } : {}) };
|
|
226
260
|
}
|
|
@@ -231,10 +265,163 @@ export async function applyPrSet(options: PrApplyOptions): Promise<{ members: Pr
|
|
|
231
265
|
return { members, status: run.status === "ok" ? "applied" : "failed", run };
|
|
232
266
|
}
|
|
233
267
|
|
|
268
|
+
const entryKey = (e: Pick<ChangeSetEntry, "member" | "address" | "deposed">): string => `${e.member}\u0000${e.address}\u0000${e.deposed ?? ""}`;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Why `member`'s fresh plan is not covered by the approved change set, or
|
|
272
|
+
* `undefined` when it is. A member left over from a partial apply is covered
|
|
273
|
+
* when it plans to the digest that was approved, or when every change it
|
|
274
|
+
* plans now is one the approved plan made:
|
|
275
|
+
*
|
|
276
|
+
* - an entry at the same address with the same action, or a `create` or a
|
|
277
|
+
* `delete` where a `replace` was approved (the other half applied);
|
|
278
|
+
* - each attribute it writes was written by the approved entry, to the same
|
|
279
|
+
* value or to one the approved plan knew only after apply. A sensitive
|
|
280
|
+
* attribute carries no value to compare, so it is covered only where the
|
|
281
|
+
* approved entry wrote that attribute as sensitive too. On a `create` left
|
|
282
|
+
* from a `replace`, an attribute the replace did not change is not compared;
|
|
283
|
+
* - each side effect is one the approved plan ran.
|
|
284
|
+
*
|
|
285
|
+
* A member with holes the approved plan did not have is never covered by its
|
|
286
|
+
* entries, since its entries may not say everything it will do.
|
|
287
|
+
*/
|
|
288
|
+
export function planCoveredBy(approved: ChangeSetDocument, fresh: ChangeSetDocument, member: string): string | undefined {
|
|
289
|
+
const was = approved.members.find((m) => m.member === member);
|
|
290
|
+
const now = fresh.members.find((m) => m.member === member);
|
|
291
|
+
if (!now) return undefined;
|
|
292
|
+
if (!was) return `${member} was not in the approved plan`;
|
|
293
|
+
if (now.status === "failed" || now.planDigest === null) return `${member} failed to plan${now.error ? `: ${now.error}` : ""}`;
|
|
294
|
+
if (was.planDigest !== null && now.planDigest === was.planDigest) return undefined;
|
|
295
|
+
if (was.status === "failed") return `${member} had no approved plan`;
|
|
296
|
+
const knownHoles = new Set(was.holes.map((h) => h.address));
|
|
297
|
+
const newHole = now.holes.find((h) => !knownHoles.has(h.address));
|
|
298
|
+
if (newHole) return `${member} cannot read ${newHole.address} (${newHole.reason}), so its plan cannot be checked against the approved one`;
|
|
299
|
+
|
|
300
|
+
const approvedEntries = new Map(approved.entries.filter((e) => e.member === member).map((e) => [entryKey(e), e]));
|
|
301
|
+
for (const e of fresh.entries) {
|
|
302
|
+
if (e.member !== member || e.action === "no-op" || e.action === "read") continue;
|
|
303
|
+
// A create-before-destroy replace whose create applied leaves the old
|
|
304
|
+
// object deposed, and the plan now deletes it under a deposed key.
|
|
305
|
+
const a =
|
|
306
|
+
approvedEntries.get(entryKey(e)) ??
|
|
307
|
+
(e.action === "delete" && e.deposed ? approvedEntries.get(entryKey({ ...e, deposed: undefined })) : undefined);
|
|
308
|
+
if (!a) return `${member} now plans to ${e.action} ${e.address}, which the approved plan did not`;
|
|
309
|
+
const halfReplace = a.action === "replace" && (e.action === "create" || e.action === "delete");
|
|
310
|
+
if (a.action !== e.action && !halfReplace) {
|
|
311
|
+
return `${member} now plans to ${e.action} ${e.address}, where the approved plan would ${a.action} it`;
|
|
312
|
+
}
|
|
313
|
+
const approvedAttrs = new Map(a.attributes.map((attr) => [attr.path, attr]));
|
|
314
|
+
for (const attr of e.attributes) {
|
|
315
|
+
const at = `${e.address}.${attr.path}`;
|
|
316
|
+
const prior = approvedAttrs.get(attr.path);
|
|
317
|
+
if (!prior) {
|
|
318
|
+
if (halfReplace && e.action === "create") continue;
|
|
319
|
+
return `${member} now writes ${at}, which the approved plan did not`;
|
|
320
|
+
}
|
|
321
|
+
if (attr.sensitive) {
|
|
322
|
+
if (!prior.sensitive) return `${member} now writes ${at} as sensitive, so it cannot be compared with the approved value`;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (prior.sensitive) return `${member} writes ${at}, which the approved plan wrote as sensitive, so it cannot be compared`;
|
|
326
|
+
if (prior.unknown) continue;
|
|
327
|
+
if (attr.unknown) return `${member} now writes ${at} with a value known only after apply, where the approved plan knew it`;
|
|
328
|
+
if (!sameJson(attr.after, prior.after)) return `${member} now writes ${at} to a different value than the approved plan`;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const approvedEffects = new Set(
|
|
332
|
+
(approved.sideEffects ?? []).filter((s) => s.member === member).map((s) => JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null])),
|
|
333
|
+
);
|
|
334
|
+
for (const s of fresh.sideEffects ?? []) {
|
|
335
|
+
if (s.member !== member) continue;
|
|
336
|
+
if (!approvedEffects.has(JSON.stringify([s.address, s.type, s.trigger ?? null, s.event ?? null]))) {
|
|
337
|
+
return `${member} now runs ${s.address}, which the approved plan did not`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface PrResumeOptions {
|
|
344
|
+
components: DriverComponent[];
|
|
345
|
+
/** The fan-out the change derives now. The caller has checked it is the one the record was made from. */
|
|
346
|
+
plan: FanOutPlan;
|
|
347
|
+
registry: CapabilityRegistry;
|
|
348
|
+
env: string;
|
|
349
|
+
vars?: Record<string, unknown>;
|
|
350
|
+
seededOutputs?: Record<string, Record<string, unknown>>;
|
|
351
|
+
/** The approved set, from the attempt record. */
|
|
352
|
+
record: PrApplyRecord;
|
|
353
|
+
/** Components an earlier attempt applied. */
|
|
354
|
+
completed: string[];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type PrResume =
|
|
358
|
+
| {
|
|
359
|
+
ok: true;
|
|
360
|
+
/** The derivation narrowed to what is left. */
|
|
361
|
+
plan: FanOutPlan;
|
|
362
|
+
/** The approved set, carrying fresh plans for the members left. Its digest is the approved one. */
|
|
363
|
+
set: PrPlanSet;
|
|
364
|
+
/** What the fresh plans of the members left came to. */
|
|
365
|
+
fresh: PrPlanSet;
|
|
366
|
+
}
|
|
367
|
+
| {
|
|
368
|
+
ok: false;
|
|
369
|
+
/** Why the approved set does not cover what is left, one line per member. */
|
|
370
|
+
reasons: string[];
|
|
371
|
+
fresh?: PrPlanSet;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Plan what an apply that failed partway left (#3464): narrow the plan by
|
|
376
|
+
* the components that applied, plan the rest against the outputs the
|
|
377
|
+
* approved plans read, and check each member against the approved change
|
|
378
|
+
* set ({@link planCoveredBy}). Covered, the result is the approved set with
|
|
379
|
+
* the new plans in it, to decide the gate against `record.digest` and hand
|
|
380
|
+
* to {@link applyPrSet}. Not covered, it says why, and the caller plans
|
|
381
|
+
* everything and asks for a fresh approval.
|
|
382
|
+
*
|
|
383
|
+
* The record now travels between CI jobs through a cache (#3543), so its
|
|
384
|
+
* change set has to be the one its digest names: the document's own digest
|
|
385
|
+
* is `record.digest`, and its members' plan digests give that digest.
|
|
386
|
+
*/
|
|
387
|
+
export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
|
|
388
|
+
const { record } = options;
|
|
389
|
+
if (record.changeSet.digest !== record.digest || !verifyChangeSetDigest(record.changeSet)) {
|
|
390
|
+
return { ok: false, reasons: [`the record's change set is not the one its digest ${record.digest} names`] };
|
|
391
|
+
}
|
|
392
|
+
const plan = remainingFanOut(options.plan, options.components, { completed: options.completed });
|
|
393
|
+
const fresh = await planPrSet({
|
|
394
|
+
components: options.components,
|
|
395
|
+
plan,
|
|
396
|
+
registry: options.registry,
|
|
397
|
+
env: options.env,
|
|
398
|
+
...(options.vars ? { vars: options.vars } : {}),
|
|
399
|
+
seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
|
|
400
|
+
});
|
|
401
|
+
const reasons = fresh.doc.members
|
|
402
|
+
.map((m) => planCoveredBy(options.record.changeSet, fresh.doc, m.member))
|
|
403
|
+
.filter((r): r is string => r !== undefined);
|
|
404
|
+
if (reasons.length > 0) return { ok: false, reasons, fresh };
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
plan,
|
|
408
|
+
fresh,
|
|
409
|
+
set: {
|
|
410
|
+
doc: options.record.changeSet,
|
|
411
|
+
members: options.record.members.map((m) => ({ ...m, status: "planned" as const })),
|
|
412
|
+
plans: fresh.plans,
|
|
413
|
+
outputs: options.record.planOutputs,
|
|
414
|
+
failed: false,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
234
419
|
export interface PrReportInput {
|
|
235
420
|
stage: "plan" | "apply";
|
|
236
421
|
pr: number | null;
|
|
237
422
|
env: string;
|
|
423
|
+
/** The workspace member whose pipeline runs the stage (#3465). */
|
|
424
|
+
member?: string;
|
|
238
425
|
base: string;
|
|
239
426
|
head: string;
|
|
240
427
|
op: string;
|
|
@@ -247,6 +434,8 @@ export interface PrReportInput {
|
|
|
247
434
|
members?: PrMember[];
|
|
248
435
|
refusal?: PrRefusal;
|
|
249
436
|
message?: string;
|
|
437
|
+
/** Components an earlier attempt at the approved set applied (#3464). */
|
|
438
|
+
resumed?: string[];
|
|
250
439
|
}
|
|
251
440
|
|
|
252
441
|
/** The report a stage writes. */
|
|
@@ -258,6 +447,7 @@ export function prReport(input: PrReportInput): PrReport {
|
|
|
258
447
|
stage: input.stage,
|
|
259
448
|
pr: input.pr,
|
|
260
449
|
env: input.env,
|
|
450
|
+
...(input.member ? { member: input.member } : {}),
|
|
261
451
|
base: input.base,
|
|
262
452
|
head: input.head,
|
|
263
453
|
op: input.op,
|
|
@@ -266,6 +456,7 @@ export function prReport(input: PrReportInput): PrReport {
|
|
|
266
456
|
status: input.status,
|
|
267
457
|
...(input.refusal ? { refusal: input.refusal } : {}),
|
|
268
458
|
...(input.message ? { message: input.message } : {}),
|
|
459
|
+
...(input.resumed ? { resumed: { applied: [...input.resumed].sort() } } : {}),
|
|
269
460
|
approval: input.approval,
|
|
270
461
|
selection: {
|
|
271
462
|
changed: input.plan.order.filter((c) => changed.has(c)),
|
package/src/gated-waves.test.ts
CHANGED
|
@@ -115,4 +115,15 @@ describe("wave records", () => {
|
|
|
115
115
|
expect(line).toContain("nothing in it was applied");
|
|
116
116
|
expect(line).toContain("chant approve fan-out release-wave-2 --plan");
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
test("the approve hint on an unsealed gate has no --sign", () => {
|
|
120
|
+
const line = describeChangedWave(record);
|
|
121
|
+
expect(line).not.toContain("--sign");
|
|
122
|
+
expect(line.endsWith(`--plan ${record.digest}`)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("the approve hint on a sealed gate ends in --sign", () => {
|
|
126
|
+
const line = describeChangedWave(record, true);
|
|
127
|
+
expect(line.endsWith(`--plan ${record.digest} --sign`)).toBe(true);
|
|
128
|
+
});
|
|
118
129
|
});
|
package/src/gated-waves.ts
CHANGED
|
@@ -230,11 +230,16 @@ export function withWaveRecord(records: readonly WaveRecord[], record: WaveRecor
|
|
|
230
230
|
/**
|
|
231
231
|
* The refusal for a wave whose set changed after it was approved: the digest
|
|
232
232
|
* that was approved, the one planned now, and that nothing in the wave ran.
|
|
233
|
+
* On a sealed gate the approve hint ends in `--sign`.
|
|
233
234
|
*/
|
|
234
|
-
export function describeChangedWave(
|
|
235
|
+
export function describeChangedWave(
|
|
236
|
+
record: Pick<WaveRecord, "wave" | "op" | "gate" | "digest" | "approved">,
|
|
237
|
+
/** True when the gate is sealed (`identity.gates`): an approval without `--sign` doesn't count, so the hint says to sign. */
|
|
238
|
+
sealed = false,
|
|
239
|
+
): string {
|
|
235
240
|
return (
|
|
236
241
|
`wave ${record.wave} changed after it was approved, so nothing in it was applied. ` +
|
|
237
242
|
`approved: ${record.approved ?? "(none)"}; planned now: ${record.digest}. ` +
|
|
238
|
-
`Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}`
|
|
243
|
+
`Read the new plan, then approve it: chant approve ${record.op} ${record.gate} --plan ${record.digest}${sealed ? " --sign" : ""}`
|
|
239
244
|
);
|
|
240
245
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type {
|
|
|
41
41
|
OpRunHandle,
|
|
42
42
|
OpRunRecord,
|
|
43
43
|
OpRunStartOptions,
|
|
44
|
+
OpGateResolveOptions,
|
|
44
45
|
OpRunState,
|
|
45
46
|
OpRunStatus,
|
|
46
47
|
} from "./op/runtime";
|
|
@@ -562,6 +563,12 @@ export interface ComponentPipelineOptions {
|
|
|
562
563
|
* GitLab's job rules) are limited to the member's files and the pipeline
|
|
563
564
|
* file. Its jobs run in the member's directory, and its names carry the
|
|
564
565
|
* member's name so two members' pipelines never collide.
|
|
566
|
+
*
|
|
567
|
+
* A pull-request pipeline (`prLoop`, #3465) takes no path filter: a change
|
|
568
|
+
* outside the member can reach it (a module it calls in another member),
|
|
569
|
+
* so its plan runs on every pull request and selects from the whole
|
|
570
|
+
* change. Its commands pass `--member`, which gives the member its own
|
|
571
|
+
* gate op, note and statuses on the pull request.
|
|
565
572
|
*/
|
|
566
573
|
member?: PipelineMember;
|
|
567
574
|
}
|
|
@@ -913,7 +920,12 @@ export interface ChangedUnitsContext {
|
|
|
913
920
|
projectRoot: string;
|
|
914
921
|
/** The resolved project configuration, for the lexicon's own namespace. */
|
|
915
922
|
config: Record<string, unknown>;
|
|
916
|
-
/**
|
|
923
|
+
/**
|
|
924
|
+
* Files the change added, modified or deleted, relative to `projectRoot`,
|
|
925
|
+
* with `/` separators. Files outside `projectRoot` are listed too, as
|
|
926
|
+
* `../...`, so a unit that calls code outside the project (another
|
|
927
|
+
* workspace member's module, #3465) can claim them.
|
|
928
|
+
*/
|
|
917
929
|
changedFiles: string[];
|
|
918
930
|
}
|
|
919
931
|
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* (absent on the resolution lines written before #2119, which is why
|
|
42
42
|
* `"resolution"` is the default reading).
|
|
43
43
|
*/
|
|
44
|
-
import {
|
|
44
|
+
import { isNeverOverMcpGateOp } from "../op/gate-name";
|
|
45
45
|
import { samePlanDigest } from "./plan-digest";
|
|
46
46
|
import { sortedJsonReplacer } from "../utils";
|
|
47
47
|
import { currentGateOrigin, type GateOrigin } from "./gate-origin";
|
|
@@ -342,8 +342,9 @@ export async function appendPendingGate(
|
|
|
342
342
|
kind: "pending",
|
|
343
343
|
origin: currentGateOrigin(),
|
|
344
344
|
// chant#3485: an Op MCP cannot discover declares nothing, so the gate
|
|
345
|
-
// records the rule for it
|
|
346
|
-
|
|
345
|
+
// records the rule for it: fan-out, workspace-upgrade (chant#3513) and
|
|
346
|
+
// the pull-request gate's `pr-<number>` op.
|
|
347
|
+
...(isNeverOverMcpGateOp(input.op) ? { neverOverMcp: true as const } : {}),
|
|
347
348
|
...input,
|
|
348
349
|
};
|
|
349
350
|
const commit = await appendGateLine(record, "Pending gate record", opts);
|
|
@@ -111,6 +111,12 @@ function sameKind(file: string, kind: string, cwd: string): boolean {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/** The work kind's items and the box's intent, read through the read contract. */
|
|
114
|
+
/** The contract an item's own field names, when the kind links no contract kind: its state is unknown to chant. */
|
|
115
|
+
function contractField(data: Record<string, unknown> | null, field: string | undefined): { id: string; state: string | null } | null {
|
|
116
|
+
const value = data?.[field ?? "contract"];
|
|
117
|
+
return typeof value === "string" && value.trim() !== "" ? { id: value.trim(), state: null } : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
async function readWork(cwd: string, kind?: string): Promise<WorkRead> {
|
|
115
121
|
const { declaredKindFiles, queryDeclaredRecords } = await import("../../workspace/records-cli");
|
|
116
122
|
const { loadRecordKind } = await import("../../workspace/records");
|
|
@@ -142,7 +148,9 @@ async function readWork(cwd: string, kind?: string): Promise<WorkRead> {
|
|
|
142
148
|
ready: r.ready ?? false,
|
|
143
149
|
data,
|
|
144
150
|
tier: typeof tier === "string" ? tier : null,
|
|
145
|
-
contract
|
|
151
|
+
// The kind's contract link, else the item's own contract field (#3503): a kind whose contracts aren't
|
|
152
|
+
// a chant record kind yet, such as studio's markdown contracts, still names the contract an item builds.
|
|
153
|
+
contract: r.contract ?? contractField(data, work.contract?.field),
|
|
146
154
|
warnings: r.warnings.map((w) => w.code),
|
|
147
155
|
answers: (r.answers ?? []).map((a) => ({ id: a.id, point: a.point, state: a.state, answer: a.answer })),
|
|
148
156
|
leased: !!r.lease,
|
package/src/op/gate-name.ts
CHANGED
|
@@ -37,6 +37,23 @@ export const FAN_OUT_GATE_OP = "fan-out";
|
|
|
37
37
|
*/
|
|
38
38
|
export const WORKSPACE_UPGRADE_GATE_OP = "workspace-upgrade";
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The pull-request loop records its gate under `pr-<number>`, or
|
|
42
|
+
* `pr-<number>-<member>` in a workspace member (`prOp` in `pr-loop.ts`), and
|
|
43
|
+
* no `*.op.ts` stands behind that name either.
|
|
44
|
+
*/
|
|
45
|
+
const PR_GATE_OP = /^pr-\d+(?:-[a-z0-9][a-z0-9-]*)?$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether gates recorded under this op name are never resolved over MCP or ACP
|
|
49
|
+
* (chant#3485, chant#3513). These are the gate-only names MCP cannot discover:
|
|
50
|
+
* `fan-out`, `workspace-upgrade` and the pull-request gate `pr-<number>`. With
|
|
51
|
+
* no Op config to read `neverOverMcp` from, the pending record carries it.
|
|
52
|
+
*/
|
|
53
|
+
export function isNeverOverMcpGateOp(op: string): boolean {
|
|
54
|
+
return op === FAN_OUT_GATE_OP || op === WORKSPACE_UPGRADE_GATE_OP || PR_GATE_OP.test(op);
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
/** Either spelling of a gate step's name. `gate` since #2202; `signalName` through 0.59.0. */
|
|
41
58
|
export interface GateNamed {
|
|
42
59
|
gate?: string;
|
|
@@ -107,3 +107,15 @@ describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
|
|
|
107
107
|
expect(writeGatedRunSummary(summary, { CHANT_GATE_SUMMARY: "/no/such/dir/gate.md" })).toBeUndefined();
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("gatedRunSummaryMarkdown on a sealed gate (#3521)", () => {
|
|
112
|
+
test("the approve line carries --sign when sealed", () => {
|
|
113
|
+
const md = gatedRunSummaryMarkdown({ ...summary, sealed: true });
|
|
114
|
+
expect(md).toContain("chant approve app-apply approve-app-apply --sign --approver <you>");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the approve line has no --sign when unsealed", () => {
|
|
118
|
+
expect(gatedRunSummaryMarkdown({ ...summary, sealed: false })).not.toContain("--sign");
|
|
119
|
+
expect(gatedRunSummaryMarkdown(summary)).not.toContain("--sign");
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/op/gate-summary.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface GatedRunSummary {
|
|
|
57
57
|
* Absent at level 0 and for the root member.
|
|
58
58
|
*/
|
|
59
59
|
ledgerPrefix?: string;
|
|
60
|
+
/** Whether the workspace seals this gate, so the `chant approve` line carries `--sign`. */
|
|
61
|
+
sealed?: boolean;
|
|
60
62
|
/** The environment a component gate's pending fact was recorded in (#2574). The `chant approve` line names it. */
|
|
61
63
|
environment?: string;
|
|
62
64
|
}
|
|
@@ -101,7 +103,7 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
|
|
|
101
103
|
"Approve it, then re-run this workflow:",
|
|
102
104
|
"",
|
|
103
105
|
"```",
|
|
104
|
-
`${approveCommand(summary.op, summary.gate, summary.environment)} --approver <you>`,
|
|
106
|
+
`${approveCommand(summary.op, summary.gate, summary.environment, undefined, summary.sealed === true)} --approver <you>`,
|
|
105
107
|
"```",
|
|
106
108
|
"",
|
|
107
109
|
`Ledger: \`${gateLedgerPath(summary.op, summary.ledgerPrefix)}\` on the \`chant/lifecycle\` branch.`,
|
package/src/op/gate.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { writeFileSync } from "node:fs";
|
|
|
16
16
|
import { mkdir, rm } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
|
-
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
19
|
+
import { approveCommand, describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
|
|
20
20
|
import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
|
|
21
21
|
import {
|
|
22
22
|
requireLifecycleLedger,
|
|
@@ -612,3 +612,17 @@ describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)"
|
|
|
612
612
|
expect(check.pending.gate).toBe("approve-live-apply");
|
|
613
613
|
});
|
|
614
614
|
});
|
|
615
|
+
|
|
616
|
+
describe("op/gate — the approve hint on a sealed gate (#3521)", () => {
|
|
617
|
+
test("approveCommand adds --sign last, only when sealed", () => {
|
|
618
|
+
expect(approveCommand("app", "ship")).toBe("chant approve app ship");
|
|
619
|
+
expect(approveCommand("app", "ship", "prod", "sha256:abc", true)).toBe("chant approve app ship --env prod --plan sha256:abc --sign");
|
|
620
|
+
expect(approveCommand("app", "ship", undefined, undefined, false)).toBe("chant approve app ship");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("describeGateMismatch names --sign when sealed, and not when unsealed", () => {
|
|
624
|
+
const mismatch = { approved: "sha256:old", planned: "sha256:new", resolvedBy: "alex", timestamp: "2026-09-08T00:00:00Z" } as GateDigestMismatch;
|
|
625
|
+
expect(describeGateMismatch("app", "ship", mismatch, true)).toMatch(/chant approve app ship --sign$/);
|
|
626
|
+
expect(describeGateMismatch("app", "ship", mismatch)).not.toContain("--sign");
|
|
627
|
+
});
|
|
628
|
+
});
|
package/src/op/gate.ts
CHANGED
|
@@ -235,7 +235,7 @@ export interface GateDigestMismatch {
|
|
|
235
235
|
* have changed — the record simply never said what it approved, which is
|
|
236
236
|
* every record written before #2300.
|
|
237
237
|
*/
|
|
238
|
-
export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch): string {
|
|
238
|
+
export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch, sealed = false): string {
|
|
239
239
|
const why =
|
|
240
240
|
mismatch.approved === undefined
|
|
241
241
|
? mismatch.environment !== undefined
|
|
@@ -249,7 +249,7 @@ export function describeGateMismatch(op: string, gate: string, mismatch: GateDig
|
|
|
249
249
|
`Gate "${gate}" is approved, but not for this plan. ` +
|
|
250
250
|
`approved: ${describePlanDigest(mismatch.approved)} (by ${mismatch.resolvedBy} at ${mismatch.timestamp}); ` +
|
|
251
251
|
`planned: ${mismatch.planned}. ` +
|
|
252
|
-
`${why} ${approveCommand(op, gate, mismatch.environment)}`
|
|
252
|
+
`${why} ${approveCommand(op, gate, mismatch.environment, undefined, sealed)}`
|
|
253
253
|
);
|
|
254
254
|
}
|
|
255
255
|
|
|
@@ -512,8 +512,25 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
|
|
|
512
512
|
* plan the reader was shown and not whatever is pending by the time they run
|
|
513
513
|
* it.
|
|
514
514
|
*/
|
|
515
|
-
export function approveCommand(op: string, gate: string, environment?: string, planDigest?: string): string {
|
|
515
|
+
export function approveCommand(op: string, gate: string, environment?: string, planDigest?: string, sealed = false): string {
|
|
516
516
|
return `chant approve ${op} ${gate}` +
|
|
517
517
|
(environment === undefined ? "" : ` --env ${environment}`) +
|
|
518
|
-
(planDigest === undefined ? "" : ` --plan ${planDigest}`)
|
|
518
|
+
(planDigest === undefined ? "" : ` --plan ${planDigest}`) +
|
|
519
|
+
(sealed ? " --sign" : "");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Whether the workspace seals `gate` (`identity.gates`), so an approval
|
|
524
|
+
* without `--sign` doesn't count. Callers pass the answer to
|
|
525
|
+
* {@link approveCommand} so the hint names the flag. Loaded on demand, as the
|
|
526
|
+
* git ledger port does: the workspace modules import the Op modules. A read
|
|
527
|
+
* that throws counts as unsealed, since the hint is advice and not the check.
|
|
528
|
+
*/
|
|
529
|
+
export async function gateIsSealed(gate: string, cwd: string = process.cwd()): Promise<boolean> {
|
|
530
|
+
try {
|
|
531
|
+
const { gateAdmission } = await import("../workspace/identity");
|
|
532
|
+
return gateAdmission(cwd, gate) !== null;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
519
536
|
}
|
package/src/op/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
|
|
|
62
62
|
export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
|
|
63
63
|
export { currentOpRun, withOpRunContext } from "./run-context";
|
|
64
64
|
export type { OpRunContext, PassedGate } from "./run-context";
|
|
65
|
-
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
|
|
65
|
+
export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch, gateIsSealed } from "./gate";
|
|
66
66
|
export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch, GateQuorumProgress, GateTally } from "./gate";
|
|
67
67
|
export { tallyGateApprovals, approverOf } from "./gate";
|
|
68
68
|
export {
|
|
@@ -80,7 +80,7 @@ export type { GateNamed } from "./gate-name";
|
|
|
80
80
|
export { createLocalOpRuntime } from "./runtimes/local";
|
|
81
81
|
export { runStateOf } from "./runtime";
|
|
82
82
|
export type {
|
|
83
|
-
OpRuntimeProvider, OpRunHandle, OpRunStartOptions, OpRunState, OpRunStatus,
|
|
83
|
+
OpRuntimeProvider, OpRunHandle, OpRunStartOptions, OpGateResolveOptions, OpRunState, OpRunStatus,
|
|
84
84
|
OpRunRecord, OpRunRecordInput, OpRunPhaseRecord, OpRunStepRecord,
|
|
85
85
|
} from "./runtime";
|
|
86
86
|
export { buildOpIR, serializeOpIR, opConfigFromIR, OP_IR_FORMAT_VERSION } from "./op-ir";
|
|
@@ -9,6 +9,14 @@ import {
|
|
|
9
9
|
import { memoryGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
10
10
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
11
11
|
import { renderHuman } from "./local-output";
|
|
12
|
+
|
|
13
|
+
// #3521: whether the workspace seals a gate is read from identity.gates in the
|
|
14
|
+
// working directory; a test says which gates are sealed instead.
|
|
15
|
+
const sealedGates = new Set<string>();
|
|
16
|
+
vi.mock("./gate", async () => {
|
|
17
|
+
const actual = await vi.importActual<typeof import("./gate")>("./gate");
|
|
18
|
+
return { ...actual, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
|
|
19
|
+
});
|
|
12
20
|
import type { GateResolutionRecord, PendingGateRecord, PendingGateInput } from "../lifecycle/gate-ledger";
|
|
13
21
|
import { stepOutput } from "./step-output-ref";
|
|
14
22
|
|
|
@@ -778,6 +786,33 @@ describe("runOpLocally — a gate approves a plan, not the next run (#2300)", ()
|
|
|
778
786
|
expect(lines.some((l) => l.includes("[refused]") && l.includes("not for this plan"))).toBe(true);
|
|
779
787
|
});
|
|
780
788
|
|
|
789
|
+
describe("the refusal's approve command (#3521)", () => {
|
|
790
|
+
async function refusalOnRename(): Promise<string | undefined> {
|
|
791
|
+
const { root, activities, config, digestOf } = harness();
|
|
792
|
+
const gates = approvableLedger();
|
|
793
|
+
await runOpLocally(config, activities, PROFILES, undefined, { gates, now: NOW });
|
|
794
|
+
gates.approve("approve-live-apply", digestOf("aws_s3_bucket.original"), "2026-09-09T12:05:00.000Z");
|
|
795
|
+
root.address = "aws_s3_bucket.renamed";
|
|
796
|
+
const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-09-09T12:10:00.000Z" });
|
|
797
|
+
return second.records.find((r) => r.fn === "gate:approve-live-apply")?.refusal;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
test("unsealed gate: the refusal's approve command has no --sign", async () => {
|
|
801
|
+
const refusal = await refusalOnRename();
|
|
802
|
+
expect(refusal).toContain("chant approve live-apply approve-live-apply");
|
|
803
|
+
expect(refusal).not.toContain("--sign");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("sealed gate: the refusal's approve command ends in --sign", async () => {
|
|
807
|
+
sealedGates.add("approve-live-apply");
|
|
808
|
+
try {
|
|
809
|
+
expect(await refusalOnRename()).toContain("chant approve live-apply approve-live-apply --sign");
|
|
810
|
+
} finally {
|
|
811
|
+
sealedGates.clear();
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
781
816
|
test("approve, re-run with the root unchanged — it applies", async () => {
|
|
782
817
|
const { applied, activities, config, digestOf } = harness();
|
|
783
818
|
const gates = approvableLedger();
|
package/src/op/local-executor.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { ReceiptReadResult } from "./receipt-store";
|
|
|
26
26
|
import { isStepOutputRef } from "./step-output-ref";
|
|
27
27
|
import { parseDuration } from "./duration";
|
|
28
28
|
import { stepTimeoutProblem } from "./activity-profiles";
|
|
29
|
-
import { describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
29
|
+
import { describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateCheck, type GateLedgerPort } from "./gate";
|
|
30
30
|
import { gateName } from "./gate-name";
|
|
31
31
|
import type { ResolvedGateApproval } from "./gate-approval";
|
|
32
32
|
import { withOpRunContext, type OpRunContext, type PassedGate } from "./run-context";
|
|
@@ -672,7 +672,7 @@ async function runGateStep(
|
|
|
672
672
|
}
|
|
673
673
|
|
|
674
674
|
const refusal = check.mismatch
|
|
675
|
-
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch) }
|
|
675
|
+
? { refusal: describeGateMismatch(gates.op, gateName(step), check.mismatch, await gateIsSealed(gateName(step))) }
|
|
676
676
|
: {};
|
|
677
677
|
return {
|
|
678
678
|
record: {
|
|
@@ -207,3 +207,23 @@ describe("renderJson", () => {
|
|
|
207
207
|
expect(parsed.pushed).toBeUndefined();
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("a sealed gate's approve line carries --sign (#3521)", () => {
|
|
212
|
+
test("renderHuman prints --sign when sealed, and not when unsealed", () => {
|
|
213
|
+
const sealed: string[] = [];
|
|
214
|
+
renderHuman(GATED_WITH_PLAN, (l) => sealed.push(l), true);
|
|
215
|
+
expect(sealed.join("\n")).toContain(`approve : chant approve prod-apply rollout-gate --plan ${GATED_WITH_PLAN.gate!.planDigest} --sign`);
|
|
216
|
+
const unsealed: string[] = [];
|
|
217
|
+
renderHuman(GATED_WITH_PLAN, (l) => unsealed.push(l), false);
|
|
218
|
+
expect(unsealed.join("\n")).not.toContain("--sign");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("renderJson's approve field carries --sign when sealed, and not when unsealed", () => {
|
|
222
|
+
const sealed: string[] = [];
|
|
223
|
+
renderJson(GATED, (l) => sealed.push(l), true);
|
|
224
|
+
expect(JSON.parse(sealed[0]).approve).toBe("chant approve prod-apply rollout-gate --sign");
|
|
225
|
+
const unsealed: string[] = [];
|
|
226
|
+
renderJson(GATED, (l) => unsealed.push(l), false);
|
|
227
|
+
expect(JSON.parse(unsealed[0]).approve).toBe("chant approve prod-apply rollout-gate");
|
|
228
|
+
});
|
|
229
|
+
});
|