@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
|
@@ -75,8 +75,9 @@ vi.mock("./operator", () => ({
|
|
|
75
75
|
// gate handling, and a unit test must never write a fact to the real repo.
|
|
76
76
|
vi.mock("../../op/gate", async () => {
|
|
77
77
|
const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
|
|
78
|
-
return { ...actual, gitGateLedgerPort: () => gateLedger };
|
|
78
|
+
return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
|
|
79
79
|
});
|
|
80
|
+
const sealedGates = new Set<string>();
|
|
80
81
|
// The local runtime appends each run's record to that same branch (#2118).
|
|
81
82
|
// Real behavior in a project, and exactly what this suite must not do in
|
|
82
83
|
// chant's own checkout, since the append is against `process.cwd()`. Only the
|
|
@@ -264,9 +265,28 @@ describe("runOp dispatcher", () => {
|
|
|
264
265
|
stderrWrite.mockRestore();
|
|
265
266
|
expect(out).toContain('is gated on "approve-prod"');
|
|
266
267
|
expect(out).toContain("chant approve gated approve-prod");
|
|
268
|
+
expect(out).not.toContain("--sign");
|
|
267
269
|
expect(gateLedger.appended).toHaveLength(1);
|
|
268
270
|
});
|
|
269
271
|
|
|
272
|
+
test("gate in local mode on a sealed gate: the approve line carries --sign (#3521)", async () => {
|
|
273
|
+
gateLedger = memoryGateLedgerPort();
|
|
274
|
+
sealedGates.add("approve-prod");
|
|
275
|
+
discoverOpsMock.mockResolvedValue({
|
|
276
|
+
ops: new Map([localOp("gated", [{ kind: "gate", gate: "approve-prod" }])]),
|
|
277
|
+
errors: [],
|
|
278
|
+
});
|
|
279
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
280
|
+
try {
|
|
281
|
+
expect(await runOp({ args: makeArgs({ path: "gated" }), plugins: [], serializers: [] })).toBe(3);
|
|
282
|
+
const out = stderrWrite.mock.calls.map((c) => String(c[0])).join("");
|
|
283
|
+
expect(out).toContain("approve : chant approve gated approve-prod --sign");
|
|
284
|
+
} finally {
|
|
285
|
+
stderrWrite.mockRestore();
|
|
286
|
+
sealedGates.clear();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
270
290
|
test("an approved gate lets the run through and exits 0", async () => {
|
|
271
291
|
gateLedger = memoryGateLedgerPort({
|
|
272
292
|
pending: [{
|
|
@@ -616,6 +636,25 @@ describe("runOp: --gated-exit (#2243)", () => {
|
|
|
616
636
|
expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod");
|
|
617
637
|
});
|
|
618
638
|
|
|
639
|
+
test("a sealed gate: the --json approve field and the summary carry --sign (#3521)", async () => {
|
|
640
|
+
const file = summaryFile();
|
|
641
|
+
process.env.GITHUB_STEP_SUMMARY = file;
|
|
642
|
+
sealedGates.add("approve-prod");
|
|
643
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([localOp("gated", [gateStep])]), errors: [] });
|
|
644
|
+
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
645
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
646
|
+
makeStderrSpy();
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
await runOp({ args: makeArgs({ path: "gated", gatedExit: 0, json: true }), plugins: [], serializers: [] });
|
|
650
|
+
const parsed = JSON.parse(stdoutWrite.mock.calls.map((c) => String(c[0])).join("").trim());
|
|
651
|
+
expect(parsed.approve).toBe("chant approve gated approve-prod --sign");
|
|
652
|
+
expect(readFileSync(file, "utf8")).toContain("chant approve gated approve-prod --sign --approver <you>");
|
|
653
|
+
} finally {
|
|
654
|
+
sealedGates.clear();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
619
658
|
test("a run that fails for any other reason is still red under the flag", async () => {
|
|
620
659
|
discoverOpsMock.mockResolvedValue({
|
|
621
660
|
// atMostOnce: one attempt. The default profile retries twice with 5s and
|
|
@@ -776,6 +815,45 @@ describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
|
|
|
776
815
|
expect(seen).not.toHaveProperty("profile");
|
|
777
816
|
});
|
|
778
817
|
|
|
818
|
+
// #3521: a hosted runtime that reports only a gate name gets a hint whose
|
|
819
|
+
// approve command names `--sign` when the workspace seals that gate.
|
|
820
|
+
describe("a runtime that reports a gated state with only the gate name (#3521)", () => {
|
|
821
|
+
async function runGatedOnStub(): Promise<string> {
|
|
822
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("prod-apply")]), errors: [] });
|
|
823
|
+
const start = vi.fn(async () => ({
|
|
824
|
+
op: "prod-apply",
|
|
825
|
+
runId: "stub-1",
|
|
826
|
+
result: async () => ({
|
|
827
|
+
op: "prod-apply", runId: "stub-1", state: "gated", startedAt: "2026-01-01T00:00:00.000Z",
|
|
828
|
+
gate: { name: "approve-prod", since: "2026-01-01T00:00:00.000Z" },
|
|
829
|
+
}),
|
|
830
|
+
}));
|
|
831
|
+
makeStdoutSpy();
|
|
832
|
+
const stderr = makeStderrSpy();
|
|
833
|
+
const exit = await runOp({
|
|
834
|
+
args: makeArgs({ path: "prod-apply", on: "stub" }),
|
|
835
|
+
plugins: [{ name: "stub", opRuntime: { name: "stub", start } } as never], serializers: [],
|
|
836
|
+
});
|
|
837
|
+
expect(exit).toBe(3);
|
|
838
|
+
return stderr.join("\n");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
test("unsealed gate: the hint's approve command has no --sign", async () => {
|
|
842
|
+
const out = await runGatedOnStub();
|
|
843
|
+
expect(out).toContain("Record the resolution with: chant approve prod-apply approve-prod");
|
|
844
|
+
expect(out).not.toContain("--sign");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("sealed gate: the hint's approve command ends in --sign", async () => {
|
|
848
|
+
sealedGates.add("approve-prod");
|
|
849
|
+
try {
|
|
850
|
+
expect(await runGatedOnStub()).toContain("Record the resolution with: chant approve prod-apply approve-prod --sign");
|
|
851
|
+
} finally {
|
|
852
|
+
sealedGates.clear();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
779
857
|
test("--sandbox on an Op with no policyGate step is untouched", async () => {
|
|
780
858
|
discoverOpsMock.mockResolvedValue({
|
|
781
859
|
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
@@ -878,6 +956,29 @@ describe("run subcommands on the resolved runtime", () => {
|
|
|
878
956
|
expect((runtime.start.mock.calls[0][0] as { name: string }).name).toBe("hello");
|
|
879
957
|
});
|
|
880
958
|
|
|
959
|
+
test("--param reaches the runtime's start as params (#3539)", async () => {
|
|
960
|
+
const runtime = makeStubRuntime();
|
|
961
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
962
|
+
makeStdoutSpy();
|
|
963
|
+
const exit = await runOp({
|
|
964
|
+
args: makeArgs({ path: "hello", on: "stub", env: "staging", param: ["agent=ops-bot", "tier=gold plus"] }),
|
|
965
|
+
plugins: [stubPlugin(runtime)], serializers: [],
|
|
966
|
+
});
|
|
967
|
+
expect(exit).toBe(0);
|
|
968
|
+
expect((runtime.start.mock.calls[0] as unknown[])[1]).toMatchObject({
|
|
969
|
+
env: "staging",
|
|
970
|
+
params: { agent: "ops-bot", tier: "gold plus" },
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("no --param hands start no params", async () => {
|
|
975
|
+
const runtime = makeStubRuntime();
|
|
976
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
977
|
+
makeStdoutSpy();
|
|
978
|
+
await runOp({ args: makeArgs({ path: "hello", on: "stub" }), plugins: [stubPlugin(runtime)], serializers: [] });
|
|
979
|
+
expect((runtime.start.mock.calls[0] as unknown[])[1]).not.toHaveProperty("params");
|
|
980
|
+
});
|
|
981
|
+
|
|
881
982
|
test("run status/log/list/cancel --on stub reach their methods", async () => {
|
|
882
983
|
const runtime = makeStubRuntime();
|
|
883
984
|
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
@@ -1060,6 +1161,21 @@ describe("runOpApprove", () => {
|
|
|
1060
1161
|
expect(stderr.join("\n")).toContain('Runtime "stub" was notified');
|
|
1061
1162
|
});
|
|
1062
1163
|
|
|
1164
|
+
test("--env on the approve line reaches resolveGate (#3539)", async () => {
|
|
1165
|
+
const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" };
|
|
1166
|
+
recordGateApprovalMock.mockResolvedValue({ ok: true, record });
|
|
1167
|
+
const resolveGate = vi.fn(async () => undefined);
|
|
1168
|
+
makeStderrSpy();
|
|
1169
|
+
|
|
1170
|
+
const exit = await runOpApprove({
|
|
1171
|
+
args: makeArgs({ on: "stub", extraPositional: "alb-deploy", extraPositional2: "release", approver: "alex", env: "prod" }),
|
|
1172
|
+
plugins: [{ name: "stub", opRuntime: { name: "stub", resolveGate } } as never], serializers: [],
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
expect(exit).toBe(0);
|
|
1176
|
+
expect(resolveGate).toHaveBeenCalledWith("alb-deploy", "release", record, { env: "prod" });
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1063
1179
|
test("a provider with no resolveGate still records the fact and exits 0", async () => {
|
|
1064
1180
|
const record = { version: 1, op: "alb-deploy", gate: "release", resolvedBy: "ci", timestamp: "2026-01-01T00:00:00.000Z" };
|
|
1065
1181
|
recordGateApprovalMock.mockResolvedValue({ ok: true, record });
|
|
@@ -1141,6 +1257,53 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
|
|
|
1141
1257
|
});
|
|
1142
1258
|
});
|
|
1143
1259
|
|
|
1260
|
+
// #3521: a gated component's approve line and mismatch line name `--sign` on
|
|
1261
|
+
// a sealed gate and leave it off otherwise.
|
|
1262
|
+
describe("runOp --components gate hints (#3521)", () => {
|
|
1263
|
+
beforeEach(() => {
|
|
1264
|
+
runComponentsMock.mockReset();
|
|
1265
|
+
loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
|
|
1266
|
+
maybeRecordAutoReleaseMock.mockReset().mockResolvedValue({ recorded: false, reason: "no-digest" });
|
|
1267
|
+
maybePersistBuildManifestMock.mockReset().mockResolvedValue({ persisted: false, reason: "no-manifest" });
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
async function runGatedComponent(): Promise<string> {
|
|
1271
|
+
runComponentsMock.mockResolvedValue({
|
|
1272
|
+
success: false,
|
|
1273
|
+
selected: ["svc"],
|
|
1274
|
+
gated: {
|
|
1275
|
+
component: "svc",
|
|
1276
|
+
gate: {
|
|
1277
|
+
version: 1, kind: "pending", op: "svc", gate: "approve-prod", environment: "prod",
|
|
1278
|
+
timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
|
|
1279
|
+
},
|
|
1280
|
+
mismatch: { approved: "sha256:aaa", planned: "sha256:bbb", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z", environment: "prod" },
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
const stderr = makeStderrSpy();
|
|
1284
|
+
expect(await runOp({ args: makeArgs({ path: "svc", components: true }), plugins: [], serializers: [] })).toBe(3);
|
|
1285
|
+
return stderr.join("\n");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
test("unsealed gate: the approve line and the mismatch line carry no --sign", async () => {
|
|
1289
|
+
const out = await runGatedComponent();
|
|
1290
|
+
expect(out).toContain("approve : chant approve svc approve-prod --env prod");
|
|
1291
|
+
expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod");
|
|
1292
|
+
expect(out).not.toContain("--sign");
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("sealed gate: the approve line and the mismatch line end in --sign", async () => {
|
|
1296
|
+
sealedGates.add("approve-prod");
|
|
1297
|
+
try {
|
|
1298
|
+
const out = await runGatedComponent();
|
|
1299
|
+
expect(out).toContain("approve : chant approve svc approve-prod --env prod --sign");
|
|
1300
|
+
expect(out).toContain("needs a fresh one: chant approve svc approve-prod --env prod --sign");
|
|
1301
|
+
} finally {
|
|
1302
|
+
sealedGates.clear();
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1144
1307
|
describe("runOpComponents", () => {
|
|
1145
1308
|
beforeEach(() => {
|
|
1146
1309
|
runComponentsMock.mockReset();
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { stewardWorkHolder } from "../../op/work-lease-run";
|
|
|
7
7
|
import type { OpConfig } from "../../op/types";
|
|
8
8
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
9
9
|
import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
10
|
-
import { approveCommand, describeGateMismatch } from "../../op/gate";
|
|
10
|
+
import { approveCommand, describeGateMismatch, gateIsSealed } from "../../op/gate";
|
|
11
11
|
import { summaryLedgerPrefix, writeGatedRunSummary, type GatedRunSummary } from "../../op/gate-summary";
|
|
12
12
|
import { createLocalOpRuntime } from "../../op/runtimes/local";
|
|
13
13
|
import type { OpRuntimeProvider, OpRunStatus } from "../../op/runtime";
|
|
@@ -77,8 +77,8 @@ function resolveGatedExitCode(ctx: CommandContext): number | undefined {
|
|
|
77
77
|
* passes `--gated-exit 0` reports success, and the one line that explains why
|
|
78
78
|
* a zero-exit run applied nothing belongs next to the gate itself.
|
|
79
79
|
*/
|
|
80
|
-
function reportGatedRun(summary: GatedRunSummary, exitCode: number): void {
|
|
81
|
-
writeGatedRunSummary(summary);
|
|
80
|
+
async function reportGatedRun(summary: GatedRunSummary, exitCode: number): Promise<void> {
|
|
81
|
+
writeGatedRunSummary({ ...summary, sealed: await gateIsSealed(summary.gate) });
|
|
82
82
|
if (exitCode !== GATED_EXIT_CODE) {
|
|
83
83
|
console.error(formatInfo(
|
|
84
84
|
`gated: exiting ${exitCode} because --gated-exit asked for it. Nothing after the gate ran.`,
|
|
@@ -466,7 +466,10 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
try {
|
|
469
|
-
|
|
469
|
+
// `--env` on the approve line names the environment the woken run runs
|
|
470
|
+
// in (#3539); without it the runtime keeps the gated run's.
|
|
471
|
+
if (ctx.args.env !== undefined) await runtime.resolveGate(opName, gate, outcome.record, { env: ctx.args.env });
|
|
472
|
+
else await runtime.resolveGate(opName, gate, outcome.record);
|
|
470
473
|
} catch (err) {
|
|
471
474
|
console.error(formatWarning({
|
|
472
475
|
message:
|
|
@@ -962,10 +965,11 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
962
965
|
}));
|
|
963
966
|
// #2574: an approval stands, but for another plan or for none. Say so,
|
|
964
967
|
// with the command that replaces it.
|
|
968
|
+
const sealed = await gateIsSealed(gate.gate);
|
|
965
969
|
if (result.gated.mismatch) {
|
|
966
|
-
console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch) }));
|
|
970
|
+
console.error(formatWarning({ message: describeGateMismatch(gate.op, gate.gate, result.gated.mismatch, sealed) }));
|
|
967
971
|
}
|
|
968
|
-
console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment)}`));
|
|
972
|
+
console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate, gate.environment, undefined, sealed)}`));
|
|
969
973
|
if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
|
|
970
974
|
console.error(formatInfo(`expires : ${gate.expiresAt}`));
|
|
971
975
|
// #2310: this run's own append reached only the local chant/lifecycle
|
|
@@ -978,7 +982,7 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
|
|
|
978
982
|
hint: "an operator working from a clone of the remote cannot approve it until it does",
|
|
979
983
|
}));
|
|
980
984
|
}
|
|
981
|
-
reportGatedRun(
|
|
985
|
+
await reportGatedRun(
|
|
982
986
|
{
|
|
983
987
|
op: gate.op,
|
|
984
988
|
gate: gate.gate,
|
|
@@ -1107,8 +1111,13 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1107
1111
|
process.on("SIGTERM", onSigint);
|
|
1108
1112
|
|
|
1109
1113
|
try {
|
|
1114
|
+
// `--param name=value` reaches the runtime (#3539). The local runtime
|
|
1115
|
+
// has no use for it; fountain reads `agent` to pick the steward and posts
|
|
1116
|
+
// the rest on the command line it hands the sandbox.
|
|
1117
|
+
const params = parseParamFlags(ctx.args.param);
|
|
1110
1118
|
const handle = await runtime.start(config, {
|
|
1111
1119
|
env: ctx.args.env,
|
|
1120
|
+
...(params ? { params } : {}),
|
|
1112
1121
|
...(ctx.args.profile !== undefined ? { profile: ctx.args.profile } : {}),
|
|
1113
1122
|
progress,
|
|
1114
1123
|
signal: controller.signal,
|
|
@@ -1119,13 +1128,14 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1119
1128
|
if (status.result) {
|
|
1120
1129
|
// The executor's own render already prints a gate, its approve line
|
|
1121
1130
|
// and its expiry (#2119).
|
|
1122
|
-
|
|
1131
|
+
const sealed = status.result.gate ? await gateIsSealed(status.result.gate.gate) : false;
|
|
1132
|
+
if (ctx.args.json) renderJson(status.result, undefined, sealed); else renderHuman(status.result, undefined, sealed);
|
|
1123
1133
|
} else {
|
|
1124
1134
|
renderRuntimeStatus("Op", opName, runtime.name, status);
|
|
1125
1135
|
if (status.state === "gated" && status.gate) {
|
|
1126
1136
|
console.error(formatWarning({
|
|
1127
1137
|
message: `Op "${opName}" is waiting on gate "${status.gate.name}"`,
|
|
1128
|
-
hint: `Record the resolution with: ${approveCommand(opName, status.gate.name)}`,
|
|
1138
|
+
hint: `Record the resolution with: ${approveCommand(opName, status.gate.name, undefined, undefined, await gateIsSealed(status.gate.name))}`,
|
|
1129
1139
|
}));
|
|
1130
1140
|
}
|
|
1131
1141
|
}
|
|
@@ -1140,7 +1150,7 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1140
1150
|
const pending = status.result?.gate;
|
|
1141
1151
|
const gate = pending?.gate ?? status.gate?.name;
|
|
1142
1152
|
if (gate) {
|
|
1143
|
-
reportGatedRun(
|
|
1153
|
+
await reportGatedRun(
|
|
1144
1154
|
{
|
|
1145
1155
|
op: opName,
|
|
1146
1156
|
gate,
|
|
@@ -1175,7 +1185,8 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
|
|
|
1175
1185
|
return status.state === "completed" ? 0 : 1;
|
|
1176
1186
|
} catch (err) {
|
|
1177
1187
|
if (err instanceof OpRunFailure) {
|
|
1178
|
-
|
|
1188
|
+
const sealed = err.result.gate ? await gateIsSealed(err.result.gate.gate) : false;
|
|
1189
|
+
if (ctx.args.json) renderJson(err.result, undefined, sealed); else renderHuman(err.result, undefined, sealed);
|
|
1179
1190
|
return 1;
|
|
1180
1191
|
}
|
|
1181
1192
|
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
package/src/cli/main.ts
CHANGED
|
@@ -606,6 +606,10 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
606
606
|
// didn't support joined forms at all — now that it does, the joined
|
|
607
607
|
// form is just as valid as the space-separated one).
|
|
608
608
|
(result.param ??= []).push(args[++i]);
|
|
609
|
+
} else if (arg === "--lexicon-output") {
|
|
610
|
+
// `--lexicon-output <lexicon>=<path>` (repeatable); the joined form
|
|
611
|
+
// `--lexicon-output=otel=a.yml` arrives here already split at the first `=`.
|
|
612
|
+
(result.lexiconOutput ??= []).push(args[++i]);
|
|
609
613
|
} else if (arg === "--params-file") {
|
|
610
614
|
result.paramsFile = args[++i];
|
|
611
615
|
} else if (arg === "--projection") {
|
|
@@ -1308,7 +1312,11 @@ Component release ledger + status:
|
|
|
1308
1312
|
(chant approve pr-<n> pr-apply --plan <digest>);
|
|
1309
1313
|
a moved plan refuses with exit 3
|
|
1310
1314
|
(--require-review: the approver must have
|
|
1311
|
-
approved the pull request on the forge
|
|
1315
|
+
approved the pull request on the forge;
|
|
1316
|
+
--member <name>, both commands: a workspace
|
|
1317
|
+
member's loop, gate op pr-<n>-<member>;
|
|
1318
|
+
--resume <file> finishes an apply that failed
|
|
1319
|
+
partway under the same approval)
|
|
1312
1320
|
components release <env> Append one immutable release record
|
|
1313
1321
|
(--component <name> --digest <sha256:...>
|
|
1314
1322
|
[--git-sha <sha>] [--run-id <id>] [--actor <name>]);
|
|
@@ -1376,6 +1384,10 @@ Options:
|
|
|
1376
1384
|
- build: json (default) or yaml
|
|
1377
1385
|
- list: text (default) or json
|
|
1378
1386
|
- lint: stylish (default), json, or sarif
|
|
1387
|
+
--lexicon-output <lexicon>=<path>
|
|
1388
|
+
build: write that lexicon's output to its own file
|
|
1389
|
+
(repeatable). Lexicons not named go to --output when
|
|
1390
|
+
given, and are skipped otherwise
|
|
1379
1391
|
-d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab);
|
|
1380
1392
|
import: use this lexicon instead of detecting one
|
|
1381
1393
|
--env <name> Active environment: sets CHANT_ENV so env-aware source
|
|
@@ -166,4 +166,78 @@ describe("neverOverMcp (chant#3447)", () => {
|
|
|
166
166
|
expect(outcome.ok).toBe(true);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
// chant#3513: workspace-upgrade and the pull-request gate are undiscovered too.
|
|
171
|
+
const gateOnlyOps: Array<{ op: string; gate: string }> = [
|
|
172
|
+
{ op: "workspace-upgrade", gate: "." },
|
|
173
|
+
{ op: "pr-12", gate: "pr-apply" },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const { op, gate } of gateOnlyOps) {
|
|
177
|
+
async function repoWithGate(dir: string): Promise<void> {
|
|
178
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
179
|
+
git(["config", "user.email", "t@chant.dev"], dir);
|
|
180
|
+
git(["config", "user.name", "T"], dir);
|
|
181
|
+
writeFileSync(join(dir, "README.md"), "x\n");
|
|
182
|
+
git(["add", "."], dir);
|
|
183
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
184
|
+
await appendPendingGate(
|
|
185
|
+
{
|
|
186
|
+
op,
|
|
187
|
+
gate,
|
|
188
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
189
|
+
origin: "cli",
|
|
190
|
+
expiresAt: "2999-01-01T00:00:00.000Z",
|
|
191
|
+
planDigest: `sha256:${"c".repeat(64)}`,
|
|
192
|
+
},
|
|
193
|
+
{ cwd: dir },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test(`${op}: the gate record carries neverOverMcp (chant#3513)`, async () => {
|
|
198
|
+
await withTestDir(async (dir) => {
|
|
199
|
+
await repoWithGate(dir);
|
|
200
|
+
const { pending } = await readGateLedger(op, { cwd: dir });
|
|
201
|
+
expect(pending[0].neverOverMcp).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test(`${op}: op-approve over MCP is refused and the ledger is unchanged (chant#3513)`, async () => {
|
|
206
|
+
await withTestDir(async (dir) => {
|
|
207
|
+
await repoWithGate(dir);
|
|
208
|
+
const before = await readGateLedger(op, { cwd: dir });
|
|
209
|
+
process.chdir(dir);
|
|
210
|
+
await expect(createOpApproveTool().handler({ name: op, gate })).rejects.toThrow(
|
|
211
|
+
/neverOverMcp.*whichever channel/s,
|
|
212
|
+
);
|
|
213
|
+
expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
|
|
214
|
+
expect(resolveGate).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test(`${op}: ACP is refused even with the same-origin override (chant#3513)`, async () => {
|
|
219
|
+
await withTestDir(async (dir) => {
|
|
220
|
+
await repoWithGate(dir);
|
|
221
|
+
const before = await readGateLedger(op, { cwd: dir });
|
|
222
|
+
process.chdir(dir);
|
|
223
|
+
const outcome = await recordGateApproval(op, gate, {
|
|
224
|
+
origin: "acp",
|
|
225
|
+
actor: "agent",
|
|
226
|
+
allowSameOrigin: true,
|
|
227
|
+
cwd: dir,
|
|
228
|
+
});
|
|
229
|
+
expect(outcome.ok).toBe(false);
|
|
230
|
+
expect(await readGateLedger(op, { cwd: dir })).toEqual(before);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test(`${op}: a person at a shell can still approve (chant#3513)`, async () => {
|
|
235
|
+
await withTestDir(async (dir) => {
|
|
236
|
+
await repoWithGate(dir);
|
|
237
|
+
process.chdir(dir);
|
|
238
|
+
const outcome = await recordGateApproval(op, gate, { origin: "cli", actor: "alex", cwd: dir });
|
|
239
|
+
expect(outcome.ok).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
169
243
|
});
|
package/src/cli/registry.ts
CHANGED
|
@@ -282,6 +282,9 @@ export interface ParsedArgs {
|
|
|
282
282
|
* the identical command finishes what an interrupted attempt left, because
|
|
283
283
|
* the plan is narrowed by this file before the gate is decided and the
|
|
284
284
|
* digest is carried rather than recomputed, so the approval still stands.
|
|
285
|
+
* `chant components pr-apply --resume <file>` (#3464) keeps the same record
|
|
286
|
+
* with the approved change set in it, and finishes an apply that failed
|
|
287
|
+
* partway under that approval.
|
|
285
288
|
*/
|
|
286
289
|
resume?: string;
|
|
287
290
|
/** `chant audit --tier merge-worthy|all` */
|
|
@@ -446,6 +449,8 @@ export interface ParsedArgs {
|
|
|
446
449
|
/**
|
|
447
450
|
* `chant workspace build|lint|audit|graph --member <name>` (#2537): run only
|
|
448
451
|
* the named members or example groups. Repeatable, and a comma list works too.
|
|
452
|
+
* `chant components pr-plan|pr-apply --member <name>` (#3465) takes exactly
|
|
453
|
+
* one: the workspace member whose pull-request loop runs.
|
|
449
454
|
*/
|
|
450
455
|
members?: string[];
|
|
451
456
|
/**
|
|
@@ -524,6 +529,8 @@ export interface ParsedArgs {
|
|
|
524
529
|
foldRankCollapsedFile?: string;
|
|
525
530
|
/** `chant build --param name=value` (#1064) — repeatable. Bound to `params.<name>` (`@intentius/chant/params`) for source to reference, after validation against `chant.config.ts`'s declared `buildParams`. Highest precedence over `--params-file`/a declared `env` mapping/the declared `default`. */
|
|
526
531
|
param?: string[];
|
|
532
|
+
/** `chant build --lexicon-output <lexicon>=<path>` — repeatable. Writes that lexicon's output to its own file, so one project can declare several lexicons (otel and prometheus) without their outputs sharing a file. Lexicons not named are built into `--output` when given, and skipped otherwise. */
|
|
533
|
+
lexiconOutput?: string[];
|
|
527
534
|
/** `chant build --params-file <path>` (#1064) — a JSON file of `{ "name": value }` build-time parameter values. Second precedence, after `--param`. */
|
|
528
535
|
paramsFile?: string;
|
|
529
536
|
/** `chant graph --components --format ir --projection <lexicon>` (#989) — add
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files a change touched (#3183), as a project in a workspace member
|
|
3
|
+
* sees them (#3465): every changed file in the repository, relative to the
|
|
4
|
+
* member's directory, so one outside it reads `../...`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { changedFilesBetween } from "./changed-files";
|
|
13
|
+
|
|
14
|
+
const repo = mkdtempSync(join(tmpdir(), "chant-changed-files-"));
|
|
15
|
+
afterAll(() => rmSync(repo, { recursive: true, force: true }));
|
|
16
|
+
|
|
17
|
+
const git = (...args: string[]): string =>
|
|
18
|
+
execFileSync("git", args, {
|
|
19
|
+
cwd: repo,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" },
|
|
22
|
+
}).trim();
|
|
23
|
+
|
|
24
|
+
function put(path: string, text: string): void {
|
|
25
|
+
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
|
26
|
+
writeFileSync(join(repo, path), text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
git("init", "-q", "-b", "main");
|
|
30
|
+
put("infra/network/roots/vpc/main.tf", "");
|
|
31
|
+
put("shared/modules/base/main.tf", "");
|
|
32
|
+
put("apps/web/main.tf", "");
|
|
33
|
+
git("add", "-A");
|
|
34
|
+
git("commit", "-q", "-m", "base");
|
|
35
|
+
const base = git("rev-parse", "HEAD");
|
|
36
|
+
put("infra/network/roots/vpc/main.tf", "# changed\n");
|
|
37
|
+
put("shared/modules/base/main.tf", "# changed\n");
|
|
38
|
+
put("apps/web/main.tf", "# changed\n");
|
|
39
|
+
git("add", "-A");
|
|
40
|
+
git("commit", "-q", "-m", "change");
|
|
41
|
+
|
|
42
|
+
describe("changedFilesBetween", () => {
|
|
43
|
+
test("at the repository root, every file by its repository path", async () => {
|
|
44
|
+
expect(await changedFilesBetween(repo, base)).toEqual([
|
|
45
|
+
"apps/web/main.tf",
|
|
46
|
+
"infra/network/roots/vpc/main.tf",
|
|
47
|
+
"shared/modules/base/main.tf",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("in a member's directory, every file relative to the member, outside ones included", async () => {
|
|
52
|
+
expect(await changedFilesBetween(join(repo, "infra/network"), base)).toEqual([
|
|
53
|
+
"../../apps/web/main.tf",
|
|
54
|
+
"../../shared/modules/base/main.tf",
|
|
55
|
+
"roots/vpc/main.tf",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
|
+
import { posix } from "node:path";
|
|
12
13
|
import { promisify } from "node:util";
|
|
13
14
|
import type { LexiconPlugin } from "../lexicon";
|
|
14
15
|
|
|
@@ -43,12 +44,26 @@ export async function resolveCommit(cwd: string, ref: string): Promise<string> {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
* Files changed between `base` and `head`, relative to `cwd
|
|
47
|
-
*
|
|
47
|
+
* Files changed between `base` and `head`, relative to `cwd`, with `/`
|
|
48
|
+
* separators. A rename counts as its old and its new path.
|
|
49
|
+
*
|
|
50
|
+
* Every changed file in the repository is listed, not only those under
|
|
51
|
+
* `cwd`: one outside it reads `../...`. A project in a workspace member
|
|
52
|
+
* (#3465) can call a module that lives in another member or beside the
|
|
53
|
+
* members, and a change there reaches it; `changedUnits` decides which of
|
|
54
|
+
* these paths matter.
|
|
48
55
|
*/
|
|
49
56
|
export async function changedFilesBetween(cwd: string, base: string, head = "HEAD"): Promise<string[]> {
|
|
50
|
-
const
|
|
51
|
-
|
|
57
|
+
const prefix = (await git(cwd, ["rev-parse", "--show-prefix"])).trim().replace(/\/+$/, "");
|
|
58
|
+
const out = await git(cwd, ["diff", "--name-only", "-z", "--no-renames", "--no-relative", base, head]);
|
|
59
|
+
return out
|
|
60
|
+
.split("\0")
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map((file) => {
|
|
63
|
+
const rel = posix.relative(prefix, file);
|
|
64
|
+
return rel === "" ? "." : rel;
|
|
65
|
+
})
|
|
66
|
+
.sort();
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/** The union of every plugin's `changedUnits` answer, sorted. */
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The attempt record keeps what steps carried per member (#3459), so the
|
|
3
|
-
* next attempt, in this process or in the next CI job, hands it back.
|
|
3
|
+
* next attempt, in this process or in the next CI job, hands it back. It
|
|
4
|
+
* also keeps the change set a pull request's apply approved (#3464), so a
|
|
5
|
+
* re-run finishes it under that approval.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { describe, expect, test } from "vitest";
|
|
7
9
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
8
10
|
import { tmpdir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
|
-
import { readFanOutAttempt, writeFanOutAttempt } from "./fan-out-record";
|
|
12
|
+
import { readFanOutAttempt, writeFanOutAttempt, type PrApplyRecord } from "./fan-out-record";
|
|
13
|
+
import { composeChangeSet } from "../change-set";
|
|
11
14
|
|
|
12
15
|
describe("carried", () => {
|
|
13
16
|
test("round-trips through the record", () => {
|
|
@@ -25,3 +28,45 @@ describe("carried", () => {
|
|
|
25
28
|
expect(readFanOutAttempt(join(dir, "b.json"))?.carried).toBeUndefined();
|
|
26
29
|
});
|
|
27
30
|
});
|
|
31
|
+
|
|
32
|
+
describe("prApply (#3464)", () => {
|
|
33
|
+
const changeSet = composeChangeSet([
|
|
34
|
+
{
|
|
35
|
+
member: { member: "net", lexicon: "terraform", planner: "tofu", status: "planned", planDigest: `jcs1-sha256:${"1".repeat(64)}`, holes: [] },
|
|
36
|
+
entries: [],
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
const prApply: PrApplyRecord = {
|
|
40
|
+
op: "pr-12",
|
|
41
|
+
gate: "pr-apply",
|
|
42
|
+
head: "2".repeat(40),
|
|
43
|
+
digest: changeSet.digest,
|
|
44
|
+
approvedBy: ["github:alice"],
|
|
45
|
+
members: [
|
|
46
|
+
{
|
|
47
|
+
member: "net",
|
|
48
|
+
component: "net",
|
|
49
|
+
planDigest: `jcs1-sha256:${"1".repeat(64)}`,
|
|
50
|
+
status: "planned",
|
|
51
|
+
counts: { create: 0, update: 0, replace: 0, delete: 0 },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
changeSet,
|
|
55
|
+
planOutputs: { net: { cidr: "10.0.0.0/16" } },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
test("round-trips through the record", () => {
|
|
59
|
+
const path = join(mkdtempSync(join(tmpdir(), "fan-out-record-")), "pr-apply.json");
|
|
60
|
+
writeFanOutAttempt(path, { digest: "d", completed: ["net"], failed: [], outputs: { net: { cidr: "10.0.0.0/16" } }, prApply });
|
|
61
|
+
expect(readFanOutAttempt(path)?.prApply).toEqual(prApply);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an entry whose change set does not carry its digest, or that misses a field, reads as none", () => {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "fan-out-record-"));
|
|
66
|
+
const base = { digest: "d", completed: [], failed: [], outputs: {} };
|
|
67
|
+
writeFileSync(join(dir, "a.json"), JSON.stringify({ ...base, prApply: { ...prApply, digest: `jcs1-sha256:${"9".repeat(64)}` } }));
|
|
68
|
+
writeFileSync(join(dir, "b.json"), JSON.stringify({ ...base, prApply: { ...prApply, head: undefined } }));
|
|
69
|
+
writeFileSync(join(dir, "c.json"), JSON.stringify({ ...base, prApply: "pr-12" }));
|
|
70
|
+
for (const name of ["a.json", "b.json", "c.json"]) expect(readFanOutAttempt(join(dir, name))?.prApply).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|