@intentius/chant 0.118.1 → 0.121.0
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/build.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts +24 -0
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +15 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +22 -1
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/detectLexicon.d.ts +2 -1
- package/dist/detectLexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +9 -1
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +12 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/local-note.d.ts +14 -0
- package/dist/lifecycle/local-note.d.ts.map +1 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/composites/watch-op.d.ts +8 -0
- package/dist/op/composites/watch-op.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.d.ts +31 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +4 -1
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/types.d.ts +10 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +29 -0
- package/src/cli/commands/build.ts +11 -2
- package/src/cli/handlers/emulator.test.ts +136 -4
- package/src/cli/handlers/emulator.ts +171 -14
- package/src/cli/handlers/lifecycle.ts +13 -2
- package/src/cli/handlers/operator.test.ts +9 -7
- package/src/cli/handlers/operator.ts +18 -23
- package/src/cli/handlers/run.test.ts +52 -0
- package/src/cli/handlers/run.ts +43 -3
- package/src/cli/lexicon-subpath-build.test.ts +129 -0
- package/src/cli/main.ts +24 -6
- package/src/cli/plugins.ts +26 -5
- package/src/cli/registry.ts +6 -0
- package/src/components/driver-output.ts +4 -1
- package/src/deep-observation.ts +29 -1
- package/src/detectLexicon.test.ts +38 -0
- package/src/detectLexicon.ts +7 -2
- package/src/lifecycle/deep-diff.test.ts +24 -1
- package/src/lifecycle/deep-diff.ts +15 -0
- package/src/lifecycle/deep-observe.ts +5 -2
- package/src/lifecycle/git.test.ts +18 -0
- package/src/lifecycle/git.ts +25 -0
- package/src/lifecycle/local-note.ts +17 -0
- package/src/op/activities/converge-push.test.ts +8 -6
- package/src/op/activities/converge.ts +2 -7
- package/src/op/activities/lifecycle.test.ts +5 -0
- package/src/op/activities/lifecycle.ts +3 -0
- package/src/op/composites/composites.test.ts +5 -0
- package/src/op/composites/watch-op.ts +9 -0
- package/src/op/emulator-lifecycle.test.ts +13 -1
- package/src/op/emulator-lifecycle.ts +48 -0
- package/src/op/gate-summary.test.ts +6 -0
- package/src/op/gate-summary.ts +4 -1
- package/src/op/gate.ts +7 -25
- package/src/op/index.ts +3 -3
- package/src/op/local-output.test.ts +12 -0
- package/src/op/local-output.ts +5 -1
- package/src/op/op-ir.test.ts +9 -0
- package/src/op/op-ir.ts +4 -0
- package/src/op/types.ts +11 -0
- package/src/serializer.ts +11 -0
- package/src/workspace/ci-green-workflow.ts +1 -1
- package/src/workspace/lineage-upgrade-cli.ts +3 -1
|
@@ -20,11 +20,13 @@ vi.mock("node:child_process", () => ({
|
|
|
20
20
|
cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// The outcomes `pushLifecycleReport` maps git's push status to (#3677).
|
|
24
|
+
const pushLifecycleReport = vi.fn();
|
|
24
25
|
vi.mock("../../lifecycle/git", () => ({
|
|
25
26
|
fetchLifecycle: vi.fn(async () => undefined),
|
|
26
|
-
|
|
27
|
+
pushLifecycleReport: (...args: unknown[]) => pushLifecycleReport(...args),
|
|
27
28
|
}));
|
|
29
|
+
const { LIFECYCLE_LOCAL_NOTE } = await import("../../lifecycle/local-note");
|
|
28
30
|
|
|
29
31
|
vi.mock("../../lifecycle/converge-ledger", async (orig) => {
|
|
30
32
|
const actual = (await orig()) as Record<string, unknown>;
|
|
@@ -55,7 +57,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
|
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
test("a push that lands reports pushed, with no warning", async () => {
|
|
58
|
-
|
|
60
|
+
pushLifecycleReport.mockResolvedValue({ pushed: true });
|
|
59
61
|
|
|
60
62
|
const result = await tick();
|
|
61
63
|
|
|
@@ -66,7 +68,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
|
|
|
66
68
|
test("a REJECTED push is reported, not swallowed", async () => {
|
|
67
69
|
// Red before chant#2337: `.catch(() => undefined)` meant the tick returned
|
|
68
70
|
// the same shape whether or not the record left the machine.
|
|
69
|
-
|
|
71
|
+
pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: "remote rejected: chant/lifecycle has moved" });
|
|
70
72
|
|
|
71
73
|
const result = await tick();
|
|
72
74
|
|
|
@@ -78,7 +80,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
|
|
|
78
80
|
// `pushLifecycle` returns false rather than throwing when there is no
|
|
79
81
|
// remote. That is not an error, but it is not a push either, and the two
|
|
80
82
|
// reasons are worth telling apart in the warning.
|
|
81
|
-
|
|
83
|
+
pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE });
|
|
82
84
|
|
|
83
85
|
const result = await tick();
|
|
84
86
|
|
|
@@ -90,7 +92,7 @@ describe("a converge tick reports its push outcome (chant#2337)", () => {
|
|
|
90
92
|
// The append is local-first and always lands. A push failure must not make
|
|
91
93
|
// the tick misreport what it observed, or the fix would have traded one
|
|
92
94
|
// wrong answer for another.
|
|
93
|
-
|
|
95
|
+
pushLifecycleReport.mockResolvedValue({ pushed: false, pushWarning: "nope" });
|
|
94
96
|
|
|
95
97
|
const result = await tick();
|
|
96
98
|
|
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
type ConvergeTickRecord,
|
|
59
59
|
} from "../../lifecycle/converge-ledger";
|
|
60
60
|
import { resolveApprovalUrl } from "../../lifecycle/gate-ledger";
|
|
61
|
-
import { fetchLifecycle,
|
|
61
|
+
import { fetchLifecycle, pushLifecycleReport } from "../../lifecycle/git";
|
|
62
62
|
import type { ChangeSet } from "../../lifecycle/change-set";
|
|
63
63
|
import type { ComponentStatusRow } from "../../lifecycle/status";
|
|
64
64
|
|
|
@@ -535,12 +535,7 @@ async function dispatchPlanned(
|
|
|
535
535
|
|
|
536
536
|
/** Push the tick's ledger record, and say whether it reached the remote (chant#2337). */
|
|
537
537
|
async function pushTick(): Promise<{ pushed: boolean; pushWarning?: string }> {
|
|
538
|
-
|
|
539
|
-
const pushed = await pushLifecycle();
|
|
540
|
-
return pushed ? { pushed } : { pushed, pushWarning: "no remote is configured for chant/lifecycle — the tick record was recorded locally only" };
|
|
541
|
-
} catch (err) {
|
|
542
|
-
return { pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
|
|
543
|
-
}
|
|
538
|
+
return pushLifecycleReport();
|
|
544
539
|
}
|
|
545
540
|
|
|
546
541
|
/** A tick of a ConvergeOp with an observer step (#2778): the observation is already in `args.observed`. */
|
|
@@ -28,6 +28,11 @@ describe("detectDrift", () => {
|
|
|
28
28
|
expect(detectDrift(MISSING)).toBe(true);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test("a privilege an apply would revoke is drift (PENDING, #3706)", () => {
|
|
32
|
+
const pending = `${HEAD}0 missing, 0 orphan, 0 disappeared, 0 newly observed, 0 drifted, 3 unchanged\n${"-".repeat(80)}\n\n${BOLD}sql (properties)${RESET}\n0 property drift across 0 resource(s), 0 accepted, 2 unchanged, 1 pending\n${"-".repeat(80)}\n${BOLD}\nPENDING (changes an apply would make that no declared property shows):${RESET}\n ~ relation app.orders TO writer: REVOKE INSERT ON TABLE app.orders FROM writer`;
|
|
33
|
+
expect(detectDrift(pending)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
31
36
|
test("the counts line alone, lower case, is not drift", () => {
|
|
32
37
|
expect(detectDrift("1 property drift across 1 resource(s)")).toBe(false);
|
|
33
38
|
});
|
|
@@ -88,6 +88,9 @@ const DRIFT_HEADERS = [
|
|
|
88
88
|
// An owned resource whose live properties differ from its declaration (the
|
|
89
89
|
// deep read): a sql table's TTL changed out of band, say (#3642).
|
|
90
90
|
"PROPERTY DRIFT",
|
|
91
|
+
// A change an apply would make that no declared property shows: a
|
|
92
|
+
// privilege granted by hand on a Postgres table (#3706).
|
|
93
|
+
"PENDING",
|
|
91
94
|
"ARTIFACTS ADDED",
|
|
92
95
|
"ARTIFACTS REMOVED",
|
|
93
96
|
"ARTIFACTS CHANGED",
|
|
@@ -25,6 +25,11 @@ function getEntityType(entity: unknown): string {
|
|
|
25
25
|
// ── WatchOp ──────────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
describe("WatchOp: shape", () => {
|
|
28
|
+
test("failOn: \"drift\" rides on the op; omitted, the op has none (#3675)", () => {
|
|
29
|
+
expect(getProps(WatchOp({ name: "w", env: "prod", failOn: "drift" }).op).failOn).toBe("drift");
|
|
30
|
+
expect(getProps(WatchOp({ name: "w", env: "prod" }).op)).not.toHaveProperty("failOn");
|
|
31
|
+
});
|
|
32
|
+
|
|
28
33
|
test("returns { op } — the cadence is on the op, not a second resource (#2120)", () => {
|
|
29
34
|
const result = WatchOp({ name: "prod-watch", env: "prod", schedule: "*/15 * * * *" });
|
|
30
35
|
expect(result.op).toBeDefined();
|
|
@@ -55,6 +55,14 @@ export interface WatchOpConfig {
|
|
|
55
55
|
* a receipt — the `effect()` step is the sole writer.
|
|
56
56
|
*/
|
|
57
57
|
receipts?: EffectReceiptDeclaration[];
|
|
58
|
+
/**
|
|
59
|
+
* `"drift"` makes `chant run <name>` exit 2 when the watch finds drift
|
|
60
|
+
* (#3675), so a scheduled drift job fails without a wrapper that reads the
|
|
61
|
+
* run ledger. Omitted, a run that finds drift still exits 0 and records
|
|
62
|
+
* `Drift=true`; `chant run <name> --fail-on-drift` asks for the same exit
|
|
63
|
+
* code on one invocation.
|
|
64
|
+
*/
|
|
65
|
+
failOn?: "drift";
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export interface WatchOpResources {
|
|
@@ -73,6 +81,7 @@ export function WatchOp(config: WatchOpConfig): WatchOpResources {
|
|
|
73
81
|
Env: config.env,
|
|
74
82
|
},
|
|
75
83
|
...(config.schedule ? { schedule: { cron: config.schedule, overlap: "skip" as const } } : {}),
|
|
84
|
+
...(config.failOn ? { failOn: config.failOn } : {}),
|
|
76
85
|
phases: [
|
|
77
86
|
phase("Snapshot", [activity("lifecycleSnapshot", { env: config.env })]),
|
|
78
87
|
phase("Diff", [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { emulatorLifecycle, hostPortInUse } from "./emulator-lifecycle";
|
|
3
4
|
|
|
4
5
|
describe("emulatorLifecycle command builders", () => {
|
|
5
6
|
const emu = emulatorLifecycle({
|
|
@@ -62,3 +63,14 @@ describe("an emulator reached by its own client, not over HTTP", () => {
|
|
|
62
63
|
expect(() => emulatorLifecycle({ name: "x", image: "x:1", containerPort: 1 })).toThrow(/neither a healthPath nor a readyCommand/);
|
|
63
64
|
});
|
|
64
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("hostPortInUse (#3673)", () => {
|
|
68
|
+
test("is true while a process listens on 127.0.0.1:<port>, false once it stops", async () => {
|
|
69
|
+
const server = createServer();
|
|
70
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
71
|
+
const { port } = server.address() as { port: number };
|
|
72
|
+
expect(await hostPortInUse(port)).toBe(true);
|
|
73
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
74
|
+
expect(await hostPortInUse(port)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { sleep } from "./activity-runtime";
|
|
4
5
|
|
|
@@ -53,6 +54,47 @@ export interface EmulatorSpec {
|
|
|
53
54
|
/** `owner/repo` whose latest GitHub release names the current version. */
|
|
54
55
|
repo: string;
|
|
55
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* How to tell this emulator's server from another one answering on the
|
|
59
|
+
* same host port (#3673).
|
|
60
|
+
*
|
|
61
|
+
* On macOS, Docker publishes `0.0.0.0:<port>` even while a native server
|
|
62
|
+
* holds `127.0.0.1:<port>`, and `localhost` then reaches the native one.
|
|
63
|
+
* `chant emulator status` runs {@link EmulatorIdentity.command} in the
|
|
64
|
+
* container and {@link EmulatorIdentity.probe} through the printed endpoint,
|
|
65
|
+
* and reports a mismatch instead of an endpoint that reaches the wrong server.
|
|
66
|
+
*/
|
|
67
|
+
identity?: EmulatorIdentity;
|
|
68
|
+
/** How a client signs in, for `chant emulator --help` (e.g. `user postgres, password chant`). */
|
|
69
|
+
credentials?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A server identity check: one value read inside the container and through the endpoint (#3673). */
|
|
73
|
+
export interface EmulatorIdentity {
|
|
74
|
+
/** What kind of server it is, for messages: `Postgres`, `ClickHouse`. */
|
|
75
|
+
server: string;
|
|
76
|
+
/** Run as `docker exec <name> <command...>`; its trimmed stdout is the server's identity. */
|
|
77
|
+
command: readonly string[];
|
|
78
|
+
/** The identity of the server answering at `endpoint`, and a label for it (e.g. `PostgreSQL 16.14`). */
|
|
79
|
+
probe(endpoint: string): Promise<{ id: string; label: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when something accepts TCP connections on 127.0.0.1:`port` (#3673).
|
|
84
|
+
* `up` asks before `docker run`: a native server there would take every
|
|
85
|
+
* `localhost` connection meant for the emulator.
|
|
86
|
+
*/
|
|
87
|
+
export function hostPortInUse(port: number, timeoutMs = 1_000): Promise<boolean> {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
90
|
+
const done = (inUse: boolean): void => {
|
|
91
|
+
socket.destroy();
|
|
92
|
+
resolve(inUse);
|
|
93
|
+
};
|
|
94
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
95
|
+
socket.once("connect", () => done(true));
|
|
96
|
+
socket.once("error", () => done(false));
|
|
97
|
+
});
|
|
56
98
|
}
|
|
57
99
|
|
|
58
100
|
/**
|
|
@@ -194,6 +236,12 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle {
|
|
|
194
236
|
// clean JSON on stdout. runOp/Op activities capture both streams regardless.
|
|
195
237
|
console.error(`emulator container "${name}" already running — reusing`);
|
|
196
238
|
} else {
|
|
239
|
+
if (await hostPortInUse(port)) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`emulator "${name}": 127.0.0.1:${port} is already held by another process, so localhost:${port} would not reach the emulator. ` +
|
|
242
|
+
`Stop that process, or publish the emulator on another port (chant emulator up --port ${spec.containerPort}=<free port>).`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
197
245
|
await execAsync(runCommand({ ...args, name, port }), { signal });
|
|
198
246
|
}
|
|
199
247
|
|
|
@@ -44,6 +44,12 @@ describe("gatedRunSummaryMarkdown (#2243)", () => {
|
|
|
44
44
|
expect(md).toContain("cannot see it to approve it");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test("says it was recorded locally, without the warning, when chant/lifecycle has no remote (#3677)", () => {
|
|
48
|
+
const md = gatedRunSummaryMarkdown({ ...summary, pushed: false, pushWarning: "recorded locally (no remote for chant/lifecycle)" });
|
|
49
|
+
expect(md).toContain("The pending fact was recorded locally (no remote for chant/lifecycle).");
|
|
50
|
+
expect(md).not.toContain("was not pushed");
|
|
51
|
+
});
|
|
52
|
+
|
|
47
53
|
test("says nothing extra when the push landed or nothing was pushed this run", () => {
|
|
48
54
|
const md = gatedRunSummaryMarkdown(summary);
|
|
49
55
|
expect(md).not.toContain("was not pushed");
|
package/src/op/gate-summary.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { appendFileSync } from "node:fs";
|
|
|
22
22
|
import { gateLedgerPath } from "../lifecycle/gate-ledger";
|
|
23
23
|
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
24
24
|
import { approveCommand } from "./gate";
|
|
25
|
+
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
25
26
|
|
|
26
27
|
/** What a gated run knows about the gate it stopped on. */
|
|
27
28
|
export interface GatedRunSummary {
|
|
@@ -110,7 +111,9 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
|
|
|
110
111
|
);
|
|
111
112
|
if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
|
|
112
113
|
if (summary.url) lines.push(`Approve at: ${summary.url}`);
|
|
113
|
-
if (summary.pushed === false) {
|
|
114
|
+
if (summary.pushed === false && isLifecycleLocalNote(summary.pushWarning)) {
|
|
115
|
+
lines.push("", `The pending fact was ${LIFECYCLE_LOCAL_NOTE}.`);
|
|
116
|
+
} else if (summary.pushed === false) {
|
|
114
117
|
lines.push(
|
|
115
118
|
"",
|
|
116
119
|
`**This pending fact was not pushed to the remote** (${summary.pushWarning ?? "no reason given"}). ` +
|
package/src/op/gate.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
|
|
|
50
50
|
import { isModelAuthored } from "../lifecycle/gate-origin";
|
|
51
51
|
import { sortedJsonReplacer } from "../utils";
|
|
52
52
|
import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
|
|
53
|
-
import {
|
|
53
|
+
import { pushLifecycleReport, requireLifecycleLedger } from "../lifecycle/git";
|
|
54
54
|
import { parseDuration } from "./duration";
|
|
55
55
|
import { resolveGateRunLocator } from "./gate-resume";
|
|
56
56
|
|
|
@@ -69,7 +69,10 @@ export interface PendingGatePush {
|
|
|
69
69
|
* configured, or the push was rejected — `pushWarning` says which.
|
|
70
70
|
*/
|
|
71
71
|
pushed: boolean;
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Set when `pushed` is false. With no remote it is the lifecycle module's
|
|
74
|
+
* `LIFECYCLE_LOCAL_NOTE`, which renderers show as a note, not a warning (#3677).
|
|
75
|
+
*/
|
|
73
76
|
pushWarning?: string;
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -131,32 +134,11 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
|
|
|
131
134
|
},
|
|
132
135
|
async appendResolution(input) {
|
|
133
136
|
const { record } = await appendGateResolution(input, opts);
|
|
134
|
-
|
|
135
|
-
const pushed = await pushLifecycle(opts);
|
|
136
|
-
return pushed ? { record, pushed } : { record, pushed, pushWarning: "no remote is configured for chant/lifecycle — the resolution was recorded locally only" };
|
|
137
|
-
} catch (err) {
|
|
138
|
-
return { record, pushed: false, pushWarning: err instanceof Error ? err.message : String(err) };
|
|
139
|
-
}
|
|
137
|
+
return { record, ...(await pushLifecycleReport(opts)) };
|
|
140
138
|
},
|
|
141
139
|
async appendPending(input) {
|
|
142
140
|
const { record } = await appendPendingGate(input, opts);
|
|
143
|
-
|
|
144
|
-
const pushed = await pushLifecycle(opts);
|
|
145
|
-
return pushed
|
|
146
|
-
? { record, pushed }
|
|
147
|
-
: {
|
|
148
|
-
record,
|
|
149
|
-
pushed,
|
|
150
|
-
pushWarning:
|
|
151
|
-
"no remote is configured for chant/lifecycle — the pending fact was recorded locally only",
|
|
152
|
-
};
|
|
153
|
-
} catch (err) {
|
|
154
|
-
return {
|
|
155
|
-
record,
|
|
156
|
-
pushed: false,
|
|
157
|
-
pushWarning: err instanceof Error ? err.message : String(err),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
141
|
+
return { record, ...(await pushLifecycleReport(opts)) };
|
|
160
142
|
},
|
|
161
143
|
};
|
|
162
144
|
}
|
package/src/op/index.ts
CHANGED
|
@@ -11,12 +11,12 @@ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, he
|
|
|
11
11
|
spritesUp, spritesDown } from "./builders";
|
|
12
12
|
export { OpResource } from "./resource";
|
|
13
13
|
export { sleep } from "./activity-runtime";
|
|
14
|
-
export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-lifecycle";
|
|
15
|
-
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
|
|
14
|
+
export { emulatorLifecycle, emulatorsOf, endpointEnvVars, hostPortInUse } from "./emulator-lifecycle";
|
|
15
|
+
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorIdentity, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
|
|
16
16
|
export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
|
|
17
17
|
export type { FreshnessResult } from "./emulator-freshness";
|
|
18
18
|
export type { OpConfig, OpSchedule, OpWorkLease, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep, OutcomeAttribute } from "./types";
|
|
19
|
-
export { outcomeAttributesOf, WORK_LEASE_STEP_ID } from "./types";
|
|
19
|
+
export { outcomeAttributesOf, WORK_LEASE_STEP_ID, DRIFT_EXIT_CODE } from "./types";
|
|
20
20
|
export {
|
|
21
21
|
RunWorkLease, workLeaseOutput, stewardWorkHolder, workLeaseProblems, workLeaseNeedsRunItem, LEASE_LOST, WORK_BRANCH_PREFIX,
|
|
22
22
|
} from "./work-lease-run";
|
|
@@ -3,6 +3,7 @@ import type { OpRunResult } from "./local-executor";
|
|
|
3
3
|
import { buildRunRecord } from "../lifecycle/run-ledger";
|
|
4
4
|
import type { OpRunRecord } from "./runtime";
|
|
5
5
|
import { renderHuman, renderJson } from "./local-output";
|
|
6
|
+
import { LIFECYCLE_LOCAL_NOTE } from "../lifecycle/local-note";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Attach the ledger record the executor would have built (#2118), so a fixture
|
|
@@ -145,6 +146,17 @@ describe("renderHuman — a gated run whose own push never reached the remote (#
|
|
|
145
146
|
renderHuman({ ...GATED, gatePushed: true }, (l) => lines.push(l));
|
|
146
147
|
expect(lines.join("\n")).not.toContain("was not pushed");
|
|
147
148
|
});
|
|
149
|
+
|
|
150
|
+
// #3677: with no remote at all, the person who approves is the one at this
|
|
151
|
+
// machine; one quiet line, no warning on every local run.
|
|
152
|
+
test("prints one quiet note, not a warning, when chant/lifecycle has no remote", () => {
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
renderHuman({ ...GATED, gatePushed: false, gatePushWarning: LIFECYCLE_LOCAL_NOTE }, (l) => lines.push(l));
|
|
155
|
+
const out = lines.join("\n");
|
|
156
|
+
expect(out).toContain(" note : recorded locally (no remote for chant/lifecycle)");
|
|
157
|
+
expect(out).not.toContain("warning");
|
|
158
|
+
expect(out).not.toContain("was not pushed");
|
|
159
|
+
});
|
|
148
160
|
});
|
|
149
161
|
|
|
150
162
|
describe("renderJson", () => {
|
package/src/op/local-output.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { OpRunResult, StepRecord } from "./local-executor";
|
|
8
8
|
import { approveCommand } from "./gate";
|
|
9
|
+
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
9
10
|
|
|
10
11
|
type Writer = (line: string) => void;
|
|
11
12
|
|
|
@@ -107,7 +108,10 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
107
108
|
// branch, not the remote. The pending fact is still correct — the gate is
|
|
108
109
|
// still right to stand — but an operator working from a clone of the
|
|
109
110
|
// remote cannot see it to approve it, and nothing else here says so.
|
|
110
|
-
|
|
111
|
+
// #3677: with no remote at all, nobody elsewhere approves; one quiet line.
|
|
112
|
+
if (result.gatePushed === false && isLifecycleLocalNote(result.gatePushWarning)) {
|
|
113
|
+
write(` note : ${LIFECYCLE_LOCAL_NOTE}`);
|
|
114
|
+
} else if (result.gatePushed === false) {
|
|
111
115
|
write(
|
|
112
116
|
` warning : the pending fact was not pushed to the remote — ` +
|
|
113
117
|
(result.gatePushWarning ?? "it exists only in this checkout") +
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -334,3 +334,12 @@ describe("neverOverMcp (chant#3447)", () => {
|
|
|
334
334
|
expect(opConfigFromIR(buildOpIR({ ...base, neverOverMcp: true })).neverOverMcp).toBe(true);
|
|
335
335
|
});
|
|
336
336
|
});
|
|
337
|
+
|
|
338
|
+
describe("failOn (chant#3675)", () => {
|
|
339
|
+
it("survives the IR round trip, and is absent when not declared", () => {
|
|
340
|
+
const base = { name: "prod-watch", overview: "watch", phases: [] };
|
|
341
|
+
expect(buildOpIR({ ...base, failOn: "drift" }).failOn).toBe("drift");
|
|
342
|
+
expect(buildOpIR(base)).not.toHaveProperty("failOn");
|
|
343
|
+
expect(opConfigFromIR(buildOpIR({ ...base, failOn: "drift" })).failOn).toBe("drift");
|
|
344
|
+
});
|
|
345
|
+
});
|
package/src/op/op-ir.ts
CHANGED
|
@@ -203,6 +203,8 @@ export interface OpIR {
|
|
|
203
203
|
changesCheckout?: true;
|
|
204
204
|
/** `OpConfig.neverOverMcp`, present only when true. */
|
|
205
205
|
neverOverMcp?: true;
|
|
206
|
+
/** `OpConfig.failOn` (#3675), present only when set. */
|
|
207
|
+
failOn?: "drift";
|
|
206
208
|
phases: OpIRPhase[];
|
|
207
209
|
onFailure: OpIRPhase[];
|
|
208
210
|
/** Every activity profile referenced by a step in this Op, keyed by profile name — from the registry passed to {@link buildOpIR}, empty when none was. */
|
|
@@ -371,6 +373,7 @@ export function buildOpIR(
|
|
|
371
373
|
...(config.workLease ? { workLease: config.workLease } : {}),
|
|
372
374
|
...(config.changesCheckout ? { changesCheckout: true as const } : {}),
|
|
373
375
|
...(config.neverOverMcp ? { neverOverMcp: true as const } : {}),
|
|
376
|
+
...(config.failOn ? { failOn: config.failOn } : {}),
|
|
374
377
|
phases: config.phases.map((p) => irPhase(p, contractRegistry)),
|
|
375
378
|
onFailure: (config.onFailure ?? []).map((p) => irPhase(p, contractRegistry)),
|
|
376
379
|
activityProfiles: sortedEntries(profiles),
|
|
@@ -454,5 +457,6 @@ export function opConfigFromIR(ir: OpIR): OpConfig {
|
|
|
454
457
|
...(ir.workLease ? { workLease: ir.workLease } : {}),
|
|
455
458
|
...(ir.changesCheckout ? { changesCheckout: true } : {}),
|
|
456
459
|
...(ir.neverOverMcp ? { neverOverMcp: true } : {}),
|
|
460
|
+
...(ir.failOn ? { failOn: ir.failOn } : {}),
|
|
457
461
|
};
|
|
458
462
|
}
|
package/src/op/types.ts
CHANGED
|
@@ -68,8 +68,19 @@ export interface OpConfig {
|
|
|
68
68
|
* `chant run` and `chant approve` are unchanged.
|
|
69
69
|
*/
|
|
70
70
|
neverOverMcp?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* An outcome that makes `chant run` exit non-zero although every step
|
|
73
|
+
* succeeded (#3675). `"drift"`: a completed run whose `Drift` outcome is
|
|
74
|
+
* true exits {@link DRIFT_EXIT_CODE}, so a scheduled drift job turns red
|
|
75
|
+
* without a wrapper script. Same as passing `chant run <op> --fail-on-drift`.
|
|
76
|
+
* The run itself, and what it records on the ledger, are unchanged.
|
|
77
|
+
*/
|
|
78
|
+
failOn?: "drift";
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
/** The exit code `chant run` returns for a completed run that found drift, under `failOn: "drift"` or `--fail-on-drift` (#3675). */
|
|
82
|
+
export const DRIFT_EXIT_CODE = 2;
|
|
83
|
+
|
|
73
84
|
/**
|
|
74
85
|
* The id under which a run's work lease is published to its steps (#2748):
|
|
75
86
|
* `stepOutput(WORK_LEASE_STEP_ID, "token")`, or `workLeaseOutput("token")`
|
package/src/serializer.ts
CHANGED
|
@@ -69,6 +69,17 @@ export interface SerializerResult {
|
|
|
69
69
|
* superset).
|
|
70
70
|
*/
|
|
71
71
|
verbatimFiles?: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Basenames from `files` that hold a secret (committed ciphertext) and so
|
|
74
|
+
* must be written to disk, never echoed to a terminal. A build with no
|
|
75
|
+
* `--output` refuses when this is non-empty, naming the flag (chant#3738).
|
|
76
|
+
*
|
|
77
|
+
* Separate from {@link verbatimFiles}: "copy these bytes exactly" says
|
|
78
|
+
* nothing about secrecy. A verbatim file that is not listed here, such as
|
|
79
|
+
* the sql lexicon's DDL, prints like any other additional file when there
|
|
80
|
+
* is no `--output`.
|
|
81
|
+
*/
|
|
82
|
+
secretFiles?: string[];
|
|
72
83
|
/**
|
|
73
84
|
* Non-fatal diagnostics produced during serialization (e.g. a dialect
|
|
74
85
|
* dropping keys the target platform ignores). The build pipeline collects
|
|
@@ -190,7 +190,7 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
|
190
190
|
`name: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
191
191
|
"",
|
|
192
192
|
"# Tags each commit on the branch that passed its required phases ci/green/<sha>,",
|
|
193
|
-
"# and a green commit that later fails one ci/revoked/<sha
|
|
193
|
+
"# and a green commit that later fails one ci/revoked/<sha>.",
|
|
194
194
|
"on:",
|
|
195
195
|
" workflow_run:",
|
|
196
196
|
" workflows:",
|
|
@@ -17,6 +17,7 @@ import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/fo
|
|
|
17
17
|
import type { CommandContext } from "../cli/registry";
|
|
18
18
|
import { approveCommand, describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
19
19
|
import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
|
|
20
|
+
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
20
21
|
import { LockError } from "./lineage-lock";
|
|
21
22
|
import { applyStagedUpgrade, describeStaged, stageUpgrade, type ChantRunner, type StagedUpgrade } from "./lineage-upgrade";
|
|
22
23
|
|
|
@@ -106,7 +107,8 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
|
|
|
106
107
|
if (!opts.json) {
|
|
107
108
|
const sealed = await gateIsSealed(gate);
|
|
108
109
|
if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_UPGRADE_GATE_OP, gate, check.mismatch, sealed) }));
|
|
109
|
-
if (check.pushWarning) console.error(
|
|
110
|
+
if (isLifecycleLocalNote(check.pushWarning)) console.error(formatInfo(LIFECYCLE_LOCAL_NOTE));
|
|
111
|
+
else if (check.pushWarning) console.error(formatWarning({ message: check.pushWarning }));
|
|
110
112
|
if (check.quorum) console.error(formatInfo(`approvals so far: ${check.quorum.approvers.length} of ${check.quorum.need}`));
|
|
111
113
|
console.error(
|
|
112
114
|
formatInfo(
|