@intentius/chant 0.61.0 → 0.62.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.
Files changed (35) hide show
  1. package/dist/cli/handlers/operator.d.ts +0 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/lexicon.d.ts +51 -0
  4. package/dist/lexicon.d.ts.map +1 -1
  5. package/dist/lifecycle/git.d.ts +117 -0
  6. package/dist/lifecycle/git.d.ts.map +1 -1
  7. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  8. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  9. package/dist/op/activities/reconcile.d.ts +225 -14
  10. package/dist/op/activities/reconcile.d.ts.map +1 -1
  11. package/dist/op/gate.d.ts.map +1 -1
  12. package/dist/op/local-executor.d.ts +17 -2
  13. package/dist/op/local-executor.d.ts.map +1 -1
  14. package/dist/op/operator.d.ts.map +1 -1
  15. package/dist/op/runtimes/local.d.ts.map +1 -1
  16. package/dist/runtime-adapter.d.ts +8 -0
  17. package/dist/runtime-adapter.d.ts.map +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/handlers/operator.test.ts +102 -1
  20. package/src/cli/handlers/operator.ts +75 -5
  21. package/src/lexicon.ts +51 -0
  22. package/src/lifecycle/git.test.ts +49 -5
  23. package/src/lifecycle/git.ts +312 -11
  24. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  25. package/src/op/activities/lexicon-upgrade.ts +55 -9
  26. package/src/op/activities/reconcile.test.ts +527 -1
  27. package/src/op/activities/reconcile.ts +446 -23
  28. package/src/op/gate.test.ts +504 -0
  29. package/src/op/gate.ts +9 -1
  30. package/src/op/local-executor.test.ts +115 -0
  31. package/src/op/local-executor.ts +130 -21
  32. package/src/op/operator.test.ts +20 -0
  33. package/src/op/operator.ts +39 -1
  34. package/src/op/runtimes/local.ts +11 -0
  35. package/src/runtime-adapter.ts +17 -3
@@ -0,0 +1,504 @@
1
+ /**
2
+ * The gate against a real `chant/lifecycle` branch, in the two checkout
3
+ * shapes CI actually produces (#2301, #2303).
4
+ *
5
+ * Everything else that exercises `evaluateGate` does it through
6
+ * `memoryGateLedgerPort`, which is the right tool for the decision rules and
7
+ * the wrong one for these two bugs: both are properties of the git port —
8
+ * what a commit needs in order to be written at all, and what a checkout can
9
+ * see of a branch it never fetched. Both were found by the first real
10
+ * execution of a generated Op pipeline (INTENTIUS/choudoufu#1026); neither is
11
+ * reachable without a repository on disk.
12
+ */
13
+ import { describe, test, expect, afterEach } from "vitest";
14
+ import { spawnSync } from "node:child_process";
15
+ import { writeFileSync } from "node:fs";
16
+ import { mkdir, rm } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+ import { evaluateGate, gitGateLedgerPort } from "./gate";
20
+ import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
21
+ import {
22
+ requireLifecycleLedger,
23
+ fetchLifecycleStatus,
24
+ writeBlobToPath,
25
+ LifecycleLedgerUnreadableError,
26
+ } from "../lifecycle/git";
27
+ import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
28
+
29
+ function git(args: string[], cwd: string): { stdout: string; stderr: string; exitCode: number } {
30
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
31
+ return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.status ?? -1 };
32
+ }
33
+
34
+ const dirs: string[] = [];
35
+ function tmp(tag: string): string {
36
+ const p = join(tmpdir(), `chant-gate-${tag}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
37
+ dirs.push(p);
38
+ return p;
39
+ }
40
+
41
+ afterEach(async () => {
42
+ for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
43
+ });
44
+
45
+ /**
46
+ * A bare remote plus one clone that has already pushed `main`. The pair is
47
+ * what makes the #2303 cases expressible at all: the bug is a difference
48
+ * between what a remote holds and what a checkout can see of it.
49
+ */
50
+ async function clonePair(): Promise<{ remote: string; author: string }> {
51
+ const remote = tmp("remote");
52
+ const author = tmp("author");
53
+ await mkdir(remote, { recursive: true });
54
+ git(["init", "-q", "--bare", "-b", "main"], remote);
55
+ git(["clone", "-q", remote, author], tmpdir());
56
+ git(["config", "user.email", "test@chant.dev"], author);
57
+ git(["config", "user.name", "Test"], author);
58
+ writeFileSync(join(author, "README.md"), "fixture\n");
59
+ git(["add", "README.md"], author);
60
+ git(["commit", "-q", "-m", "init"], author);
61
+ git(["push", "-q", "origin", "main"], author);
62
+ return { remote, author };
63
+ }
64
+
65
+ /**
66
+ * A checkout in the state a GitLab CI job is in: `main` and nothing else.
67
+ * `--single-branch` is the closest local equivalent of GitLab's refspec
68
+ * fetch — it leaves the clone with no `chant/lifecycle`, not even a
69
+ * remote-tracking one, which is the whole of finding 1. (`GIT_DEPTH: 0` does
70
+ * not change this on GitLab either: it fetches refspecs, not all branches.)
71
+ */
72
+ function ciCheckout(remote: string): string {
73
+ const dir = tmp("ci");
74
+ git(["clone", "-q", "--single-branch", "--branch", "main", remote, dir], tmpdir());
75
+ git(["config", "user.email", "ci@chant.dev"], dir);
76
+ git(["config", "user.name", "CI"], dir);
77
+ return dir;
78
+ }
79
+
80
+ function lifecycleCommitCount(dir: string, ref = "chant/lifecycle"): number {
81
+ const r = git(["rev-list", "--count", ref], dir);
82
+ return r.exitCode === 0 ? Number(r.stdout.trim()) : 0;
83
+ }
84
+
85
+ describe("op/gate — a CI checkout with no git identity (#2301)", () => {
86
+ /**
87
+ * Deliverable 1. Strips the checkout of any identity the way a CI clone
88
+ * arrives: no `user.name`, no `user.email`, and `user.useConfigOnly` so
89
+ * git refuses to invent one from `$USER@$HOSTNAME` rather than succeeding
90
+ * on some machines and failing on others.
91
+ */
92
+ function stripIdentity(dir: string): void {
93
+ git(["config", "--unset-all", "user.email"], dir);
94
+ git(["config", "--unset-all", "user.name"], dir);
95
+ git(["config", "user.useConfigOnly", "true"], dir);
96
+ }
97
+
98
+ /** Blank out the global/system config so the developer's own identity cannot leak in. */
99
+ async function withoutAmbientIdentity(fn: () => Promise<void>): Promise<void> {
100
+ const saved = { g: process.env.GIT_CONFIG_GLOBAL, s: process.env.GIT_CONFIG_SYSTEM };
101
+ process.env.GIT_CONFIG_GLOBAL = "/dev/null";
102
+ process.env.GIT_CONFIG_SYSTEM = "/dev/null";
103
+ try {
104
+ await fn();
105
+ } finally {
106
+ if (saved.g === undefined) delete process.env.GIT_CONFIG_GLOBAL;
107
+ else process.env.GIT_CONFIG_GLOBAL = saved.g;
108
+ if (saved.s === undefined) delete process.env.GIT_CONFIG_SYSTEM;
109
+ else process.env.GIT_CONFIG_SYSTEM = saved.s;
110
+ }
111
+ }
112
+
113
+ test("records the pending fact anyway, under chant's own committer", async () => {
114
+ const { author } = await clonePair();
115
+ await withoutAmbientIdentity(async () => {
116
+ stripIdentity(author);
117
+
118
+ // The bare `git commit-tree` this goes through exits 128 with "no email
119
+ // was given" in this checkout; before #2301 that is where the gate died.
120
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
121
+ op: "live-apply",
122
+ gate: "approve-live-apply",
123
+ now: "2026-09-09T05:00:00.000Z",
124
+ });
125
+
126
+ expect(check.satisfied).toBe(false);
127
+ if (check.satisfied) return;
128
+ expect(check.recorded).toBe(true);
129
+
130
+ const { pending } = await readGateLedger("live-apply", { cwd: author });
131
+ expect(pending.map((p) => p.gate)).toEqual(["approve-live-apply"]);
132
+
133
+ // The fallback is chant's, applied per-invocation — nothing was written
134
+ // into the checkout's own config.
135
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
136
+ expect(committer.stdout.trim()).toBe("chant <chant@localhost>");
137
+ expect(git(["config", "--get", "user.email"], author).exitCode).not.toBe(0);
138
+ });
139
+ });
140
+
141
+ /**
142
+ * #2309 review, finding 5. `git var GIT_COMMITTER_IDENT` fails if *either*
143
+ * half is missing, so an all-or-nothing fallback would replace a configured
144
+ * `user.name` too — which is not what "a configured identity is left exactly
145
+ * as it is" promises, in this file's own doc comment or in ops.mdx.
146
+ */
147
+ test("a half-configured identity keeps the half it has", async () => {
148
+ const { author } = await clonePair();
149
+ await withoutAmbientIdentity(async () => {
150
+ stripIdentity(author);
151
+ git(["config", "user.name", "Release Bot"], author);
152
+
153
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
154
+ op: "live-apply",
155
+ gate: "approve-live-apply",
156
+ now: "2026-09-09T05:00:00.000Z",
157
+ });
158
+
159
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
160
+ expect(committer.stdout.trim()).toBe("Release Bot <chant@localhost>");
161
+ });
162
+ });
163
+
164
+ test("a configured identity still authors the ledger commit", async () => {
165
+ const { author } = await clonePair();
166
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
167
+ op: "live-apply",
168
+ gate: "approve-live-apply",
169
+ now: "2026-09-09T05:00:00.000Z",
170
+ });
171
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
172
+ expect(committer.stdout.trim()).toBe("Test <test@chant.dev>");
173
+ });
174
+
175
+ test("the committer fallback is overridable per project", async () => {
176
+ const { author } = await clonePair();
177
+ const saved = process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL;
178
+ process.env.CHANT_LIFECYCLE_COMMITTER_NAME = "estate-bot";
179
+ process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL = "estate-bot@example.com";
180
+ try {
181
+ await withoutAmbientIdentity(async () => {
182
+ stripIdentity(author);
183
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
184
+ op: "live-apply",
185
+ gate: "approve-live-apply",
186
+ now: "2026-09-09T05:00:00.000Z",
187
+ });
188
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
189
+ expect(committer.stdout.trim()).toBe("estate-bot <estate-bot@example.com>");
190
+ });
191
+ } finally {
192
+ delete process.env.CHANT_LIFECYCLE_COMMITTER_NAME;
193
+ if (saved === undefined) delete process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL;
194
+ else process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL = saved;
195
+ }
196
+ });
197
+
198
+ /**
199
+ * The identity fallback removes the one cause #2301 was reported for; it
200
+ * does not make the write infallible, and the issue is explicit that the
201
+ * message matters more than the cause. Whatever else goes wrong in there
202
+ * has to say that what failed was chant writing a named file on its own
203
+ * branch — `git hash-object failed: ...` on its own does not.
204
+ */
205
+ test("a ledger write that cannot happen names the branch and the path", async () => {
206
+ const { author } = await clonePair();
207
+ // Establish the branch first, so this exercises the *write* rather than
208
+ // the absent-ledger guard that runs ahead of it.
209
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
210
+ op: "live-apply",
211
+ gate: "approve-live-apply",
212
+ now: "2026-09-09T05:00:00.000Z",
213
+ });
214
+ // A newline in the entry name is `git mktree`'s one deterministic input
215
+ // error, so the failure lands mid-write with the branch already present.
216
+ await expect(
217
+ writeBlobToPath("_gates", "live\napply.jsonl", "{}", "msg", { cwd: author }),
218
+ ).rejects.toThrow(/cannot write _gates\/live\napply\.jsonl on the chant\/lifecycle branch: git mktree/);
219
+ });
220
+ });
221
+
222
+ describe("op/gate — a CI checkout that never fetched the ledger (#2303)", () => {
223
+ test("finding 1: approve then re-run closes the gate, without a second pending fact", async () => {
224
+ const { remote, author } = await clonePair();
225
+
226
+ // Run 1, in a checkout that has the branch: gates and records the fact.
227
+ const first = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
228
+ op: "live-apply",
229
+ gate: "approve-live-apply",
230
+ now: "2026-09-09T05:00:00.000Z",
231
+ });
232
+ expect(first.satisfied).toBe(false);
233
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
234
+
235
+ // The operator approves.
236
+ await appendGateResolution(
237
+ {
238
+ op: "live-apply",
239
+ gate: "approve-live-apply",
240
+ resolvedBy: "e2e-operator",
241
+ timestamp: "2026-09-09T05:15:45.515Z",
242
+ },
243
+ { cwd: author },
244
+ );
245
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
246
+
247
+ // Run 2 is the retried CI job: same commit, a checkout with no
248
+ // `chant/lifecycle` at all. Before #2303 it read an empty ledger, saw no
249
+ // resolution and no standing fact, and recorded a *second* pending one.
250
+ const ci = ciCheckout(remote);
251
+ expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], ci).exitCode).not.toBe(0);
252
+
253
+ const second = await evaluateGate(gitGateLedgerPort({ cwd: ci }), {
254
+ op: "live-apply",
255
+ gate: "approve-live-apply",
256
+ now: "2026-09-09T05:20:00.000Z",
257
+ });
258
+
259
+ expect(second.satisfied).toBe(true);
260
+ if (!second.satisfied) return;
261
+ expect(second.resolution.resolvedBy).toBe("e2e-operator");
262
+
263
+ const { pending } = await readGateLedger("live-apply", { cwd: ci });
264
+ expect(pending).toHaveLength(1);
265
+ });
266
+
267
+ test("finding 1: an unreachable ledger is refused by name, not read as empty", async () => {
268
+ const { remote, author } = await clonePair();
269
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
270
+ op: "live-apply",
271
+ gate: "approve-live-apply",
272
+ now: "2026-09-09T05:00:00.000Z",
273
+ });
274
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
275
+
276
+ const ci = ciCheckout(remote);
277
+ // The remote goes away mid-flight — a lost network, a revoked token, a
278
+ // runner that cannot reach the forge. The ledger's contents are now
279
+ // unknown, which is precisely what must not be reported as "nothing has
280
+ // been approved".
281
+ await rm(remote, { recursive: true, force: true });
282
+
283
+ await expect(
284
+ evaluateGate(gitGateLedgerPort({ cwd: ci }), {
285
+ op: "live-apply",
286
+ gate: "approve-live-apply",
287
+ now: "2026-09-09T05:20:00.000Z",
288
+ }),
289
+ ).rejects.toThrow(/chant\/lifecycle ledger branch is not in this checkout and could not be fetched/);
290
+ });
291
+
292
+ test("finding 1: a project whose ledger was never written still gates normally", async () => {
293
+ // The other half of the refusal: "absent from the remote" is a real
294
+ // empty ledger, and a first gate must not be refused.
295
+ const { author } = await clonePair();
296
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
297
+ op: "live-apply",
298
+ gate: "approve-live-apply",
299
+ now: "2026-09-09T05:00:00.000Z",
300
+ });
301
+ expect(check.satisfied).toBe(false);
302
+ if (check.satisfied) return;
303
+ expect(check.recorded).toBe(true);
304
+ });
305
+
306
+ test("finding 2: approving from an unfetched clone appends, and the pending fact survives", async () => {
307
+ const { remote, author } = await clonePair();
308
+
309
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
310
+ op: "live-apply",
311
+ gate: "approve-live-apply",
312
+ now: "2026-09-09T05:00:00.000Z",
313
+ });
314
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
315
+ const before = lifecycleCommitCount(author);
316
+ expect(before).toBe(1);
317
+
318
+ // The obvious operator move: clone, approve, push. This clone has never
319
+ // seen `chant/lifecycle`, so before #2303 the append built its tree from
320
+ // an empty read and committed with no parent — the branch went from one
321
+ // commit holding the pending record to one commit holding only the
322
+ // resolution.
323
+ const approver = ciCheckout(remote);
324
+ await requireLifecycleLedger({ cwd: approver });
325
+ await appendGateResolution(
326
+ {
327
+ op: "live-apply",
328
+ gate: "approve-live-apply",
329
+ resolvedBy: "e2e-operator",
330
+ timestamp: "2026-09-09T05:15:45.515Z",
331
+ },
332
+ { cwd: approver },
333
+ );
334
+
335
+ const { pending, resolutions } = await readGateLedger("live-apply", { cwd: approver });
336
+ expect(pending).toHaveLength(1);
337
+ expect(resolutions.map((r) => r.resolvedBy)).toEqual(["e2e-operator"]);
338
+ // Appended to the history, not written over it.
339
+ expect(lifecycleCommitCount(approver)).toBe(before + 1);
340
+ });
341
+
342
+ /**
343
+ * Build the state #2309's review found destroys approvals: a remote holding
344
+ * the real ledger, and a *full* clone whose local `chant/lifecycle` is an
345
+ * unrelated root commit. The clone must be full, not `--single-branch` —
346
+ * that is what gives the failed non-fast-forward fetch a default refspec to
347
+ * opportunistically refresh `refs/remotes/origin/chant/lifecycle` through,
348
+ * which is what made `pushLifecycle`'s `--force-with-lease` match.
349
+ */
350
+ async function forkedCheckout(remote: string): Promise<string> {
351
+ const dir = tmp("forked");
352
+ git(["clone", "-q", remote, dir], tmpdir());
353
+ git(["config", "user.email", "fork@chant.dev"], dir);
354
+ git(["config", "user.name", "Fork"], dir);
355
+ git(["branch", "-q", "-D", "chant/lifecycle"], dir);
356
+ git(["update-ref", "-d", "refs/remotes/origin/chant/lifecycle"], dir);
357
+ // An orphan commit on the same branch name, built without ever reading
358
+ // the remote's — the shape a pre-#2303 unfetched append produced, and the
359
+ // shape a fabricated run-ledger branch still would.
360
+ const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { cwd: dir, input: "forked\n", encoding: "utf-8" }).stdout.trim();
361
+ const tree = spawnSync("git", ["mktree"], { cwd: dir, input: `100644 blob ${blob}\tf\n`, encoding: "utf-8" }).stdout.trim();
362
+ const commit = git(["commit-tree", "-m", "forked local ledger", tree], dir).stdout.trim();
363
+ git(["update-ref", "refs/heads/chant/lifecycle", commit], dir);
364
+ return dir;
365
+ }
366
+
367
+ test("finding 1 (review): the gate refuses a forked local ledger instead of reading it as authoritative", async () => {
368
+ const { remote, author } = await clonePair();
369
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
370
+ op: "live-apply",
371
+ gate: "approve-live-apply",
372
+ now: "2026-09-09T05:00:00.000Z",
373
+ });
374
+ await appendGateResolution(
375
+ { op: "live-apply", gate: "approve-live-apply", resolvedBy: "e2e-operator", timestamp: "2026-09-09T05:15:45.515Z" },
376
+ { cwd: author },
377
+ );
378
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
379
+ const remoteTipBefore = git(["rev-parse", "refs/heads/chant/lifecycle"], remote).stdout.trim();
380
+
381
+ const forked = await forkedCheckout(remote);
382
+
383
+ // The read the gate actually performs. Before the review this returned
384
+ // normally — the guard's `if (hadLocal) return` swallowed divergence — so
385
+ // the gate read a ledger with no approval in it, recorded a second
386
+ // pending fact, and pushed it over the top of the real one.
387
+ await expect(
388
+ evaluateGate(gitGateLedgerPort({ cwd: forked }), {
389
+ op: "live-apply",
390
+ gate: "approve-live-apply",
391
+ now: "2026-09-09T05:20:00.000Z",
392
+ }),
393
+ ).rejects.toBeInstanceOf(LifecycleLedgerUnreadableError);
394
+
395
+ // Nothing was written, and the approval on the remote is untouched.
396
+ expect(git(["rev-parse", "refs/heads/chant/lifecycle"], remote).stdout.trim()).toBe(remoteTipBefore);
397
+ const { resolutions } = await readGateLedger("live-apply", { cwd: author });
398
+ expect(resolutions.map((r) => r.resolvedBy)).toEqual(["e2e-operator"]);
399
+ });
400
+
401
+ test("finding 2: a local ledger that diverged from the remote refuses rather than appending across the fork", async () => {
402
+ const { remote, author } = await clonePair();
403
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
404
+ op: "live-apply",
405
+ gate: "approve-live-apply",
406
+ now: "2026-09-09T05:00:00.000Z",
407
+ });
408
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
409
+
410
+ const forked = await forkedCheckout(remote);
411
+ expect(lifecycleCommitCount(forked)).toBe(1);
412
+
413
+ await expect(requireLifecycleLedger({ cwd: forked })).rejects.toBeInstanceOf(
414
+ LifecycleLedgerUnreadableError,
415
+ );
416
+ await expect(requireLifecycleLedger({ cwd: forked })).rejects.toThrow(
417
+ /has diverged from the copy on "origin"/,
418
+ );
419
+ });
420
+
421
+ test("a local ledger merely ahead of the remote is not a fork, and still appends", async () => {
422
+ // The other side of the divergence check: git reports "non-fast-forward"
423
+ // for a local branch that is ahead too — the ordinary state right after an
424
+ // append whose push has not landed. Refusing there would break the retry
425
+ // that is meant to repair it.
426
+ const { author } = await clonePair();
427
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
428
+ op: "live-apply",
429
+ gate: "approve-live-apply",
430
+ now: "2026-09-09T05:00:00.000Z",
431
+ });
432
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
433
+
434
+ // One more local commit that the remote has not seen.
435
+ await appendGateResolution(
436
+ { op: "live-apply", gate: "approve-live-apply", resolvedBy: "e2e-operator", timestamp: "2026-09-09T05:15:45.515Z" },
437
+ { cwd: author },
438
+ );
439
+ expect(lifecycleCommitCount(author)).toBe(2);
440
+
441
+ expect(await fetchLifecycleStatus({ cwd: author })).toMatchObject({ status: "ahead" });
442
+ await expect(requireLifecycleLedger({ cwd: author })).resolves.toBeUndefined();
443
+ });
444
+
445
+ /**
446
+ * #2309 review, finding 2. Every ledger writer reaches `writeBlobToPath`,
447
+ * and on a checkout with no local `chant/lifecycle` that function used to
448
+ * build a root commit unconditionally. Until #2301 the damage was capped by
449
+ * accident: `commit-tree` died for want of a committer, so no branch was
450
+ * created. Supplying an identity would have turned that loud failure into a
451
+ * silent branch fabrication — a run record, on an Op with no gate at all,
452
+ * creating a `chant/lifecycle` that forks the remote's. Since GitLab
453
+ * runners reuse `/builds/<project>` across jobs, the next job would then
454
+ * carry that fork into the gate.
455
+ */
456
+ test("a run-ledger append does not fabricate a ledger branch over the remote's", async () => {
457
+ const { remote, author } = await clonePair();
458
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
459
+ op: "live-apply",
460
+ gate: "approve-live-apply",
461
+ now: "2026-09-09T05:00:00.000Z",
462
+ });
463
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
464
+
465
+ // A CI checkout with no ledger branch, running an Op that never reaches a
466
+ // gate — so nothing on its path has fetched anything.
467
+ const ci = ciCheckout(remote);
468
+ expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], ci).exitCode).not.toBe(0);
469
+
470
+ await appendRunRecord(
471
+ buildRunRecord({ name: "live-check" }, [], {
472
+ started: "2026-09-09T05:30:00.000Z",
473
+ ended: "2026-09-09T05:30:10.000Z",
474
+ status: "ok",
475
+ }),
476
+ { cwd: ci },
477
+ );
478
+
479
+ // The branch it produced descends from the remote's, rather than replacing
480
+ // it: the pending fact written above is still readable here.
481
+ const { pending } = await readGateLedger("live-apply", { cwd: ci });
482
+ expect(pending.map((p) => p.gate)).toEqual(["approve-live-apply"]);
483
+ expect(await fetchLifecycleStatus({ cwd: ci })).toMatchObject({ status: "ahead" });
484
+ });
485
+
486
+ test("a project with no remote is local-only and is never refused", async () => {
487
+ const dir = tmp("solo");
488
+ await mkdir(dir, { recursive: true });
489
+ git(["init", "-q", "-b", "main"], dir);
490
+ git(["config", "user.email", "test@chant.dev"], dir);
491
+ git(["config", "user.name", "Test"], dir);
492
+ writeFileSync(join(dir, "README.md"), "fixture\n");
493
+ git(["add", "README.md"], dir);
494
+ git(["commit", "-q", "-m", "init"], dir);
495
+
496
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: dir }), {
497
+ op: "live-apply",
498
+ gate: "approve-live-apply",
499
+ now: "2026-09-09T05:00:00.000Z",
500
+ });
501
+ expect(check.satisfied).toBe(false);
502
+ await expect(requireLifecycleLedger({ cwd: dir })).resolves.toBeUndefined();
503
+ });
504
+ });
package/src/op/gate.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  type PendingGateInput,
37
37
  type PendingGateRecord,
38
38
  } from "../lifecycle/gate-ledger";
39
- import { pushLifecycle } from "../lifecycle/git";
39
+ import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
40
40
  import { parseDuration } from "./duration";
41
41
 
42
42
  /** The gate ledger, as the two executors need it: read both kinds of line, append a pending fact. */
@@ -55,6 +55,14 @@ export interface GateLedgerPort {
55
55
  export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
56
56
  return {
57
57
  async read(op) {
58
+ // The ledger branch has to be in the checkout before it is read
59
+ // (#2303): a CI clone fetches the pipeline's own ref and nothing else,
60
+ // and an unfetched branch reads as an empty ledger — which is
61
+ // indistinguishable from "nothing has been approved" and makes a
62
+ // retried job record a second pending fact for a gate that was already
63
+ // approved. Refuses rather than guessing when the fetch cannot settle
64
+ // it; see `requireLifecycleLedger`.
65
+ await requireLifecycleLedger(opts);
58
66
  const { resolutions, pending } = await readGateLedger(op, opts);
59
67
  return { resolutions, pending };
60
68
  },
@@ -7,6 +7,7 @@ import {
7
7
  OpRunFailure,
8
8
  } from "./local-executor";
9
9
  import { memoryGateLedgerPort } from "./gate";
10
+ import { renderHuman } from "./local-output";
10
11
  import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
11
12
  import { stepOutput } from "./step-output-ref";
12
13
 
@@ -497,3 +498,117 @@ describe("runOpLocally — step-output references (#1290)", () => {
497
498
  expect(result.records[1].args).toEqual({ stacks: ["stack-a"] });
498
499
  });
499
500
  });
501
+
502
+ /**
503
+ * #2301 deliverable 2. The reported symptom was a whole apply that printed
504
+ * `Op "live-apply" failed after 43.8s` and nothing else — no error line, no
505
+ * failing step — because the gate's ledger write threw a plain `Error` and
506
+ * the executor's catch only ever unpacked `PhaseFailure` and `GateStop`.
507
+ * Everything else was built into an `OpRunFailure` and never read again.
508
+ *
509
+ * These assert the two halves separately: the gate step now produces a
510
+ * failing record like any other step, and the outer catch no longer drops an
511
+ * error it does not recognise. The second is the general one — the gate is
512
+ * where it was found, not the only place it bites.
513
+ */
514
+ describe("runOpLocally — a failure inside the run says what it was (#2301)", () => {
515
+ const NOW = "2026-09-05T12:00:00.000Z";
516
+ const LEDGER_ERROR =
517
+ "cannot write _gates/live-apply.jsonl on the chant/lifecycle branch: " +
518
+ "git commit-tree failed: fatal: unable to auto-detect email address (got 'runner@runner.(none)')";
519
+
520
+ /** A gate ledger whose append fails the way a CI checkout with no git identity does. */
521
+ function unwritablePort(message = LEDGER_ERROR) {
522
+ return {
523
+ async read() {
524
+ return { resolutions: [] as GateResolutionRecord[], pending: [] as PendingGateRecord[] };
525
+ },
526
+ async appendPending(): Promise<PendingGateRecord> {
527
+ throw new Error(message);
528
+ },
529
+ };
530
+ }
531
+
532
+ function gateOnly(): OpConfig {
533
+ return op({
534
+ phases: [
535
+ { name: "Plan", steps: [{ kind: "activity", fn: "plan" }] },
536
+ { name: "Gate", steps: [{ kind: "gate", gate: "approve-live-apply" }] },
537
+ { name: "Apply", steps: [{ kind: "activity", fn: "apply" }] },
538
+ ],
539
+ });
540
+ }
541
+
542
+ const noopActivities = new Map<string, ActivityFn>([
543
+ ["plan", async () => undefined],
544
+ ["apply", async () => undefined],
545
+ ]);
546
+
547
+ test("a gate whose ledger write fails is a failing step carrying the cause", async () => {
548
+ const err = await runOpLocally(gateOnly(), noopActivities, PROFILES, undefined, {
549
+ gates: unwritablePort(),
550
+ now: NOW,
551
+ }).then(() => undefined, (e: unknown) => e);
552
+
553
+ expect(err).toBeInstanceOf(OpRunFailure);
554
+ const { result } = err as OpRunFailure;
555
+ expect(result.status).toBe("fail");
556
+
557
+ const gateRecord = result.records.find((r) => r.fn === "gate:approve-live-apply");
558
+ expect(gateRecord?.status).toBe("fail");
559
+ expect(gateRecord?.error).toBe(LEDGER_ERROR);
560
+
561
+ // The run stops at the gate, exactly as a failing activity stops its
562
+ // phase — the Apply phase never starts.
563
+ expect(result.records.map((r) => [r.fn, r.status])).toEqual([
564
+ ["plan", "ok"],
565
+ ["gate:approve-live-apply", "fail"],
566
+ ]);
567
+ });
568
+
569
+ test("the rendered output names the cause instead of a bare failure line", async () => {
570
+ const err = await runOpLocally(gateOnly(), noopActivities, PROFILES, undefined, {
571
+ gates: unwritablePort(),
572
+ now: NOW,
573
+ }).then(() => undefined, (e: unknown) => e);
574
+
575
+ const lines: string[] = [];
576
+ renderHuman((err as OpRunFailure).result, (line) => lines.push(line));
577
+
578
+ expect(lines).toContain(` ${LEDGER_ERROR}`);
579
+ expect(lines.at(-1)).toMatch(/^Op "test-op" failed after /);
580
+ // The whole point: the failing step is visible, so the last line is no
581
+ // longer the only line about the failure.
582
+ expect(lines.some((l) => l.startsWith(" ✗ gate:approve-live-apply"))).toBe(true);
583
+ });
584
+
585
+ test("an error the executor does not recognise is recorded rather than dropped", async () => {
586
+ // Neither a step's own failure nor a gate: the caller's progress callback
587
+ // throws, which unwinds the phase loop as a plain `Error`. Before #2301
588
+ // that produced an `OpRunFailure` whose `records` held nothing marked
589
+ // failed and nothing carrying a message — a run that failed and said
590
+ // nothing at all about why. The gate is where this was found, not the
591
+ // only thing it swallowed.
592
+ const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "plan" }] }] });
593
+
594
+ const err = await runOpLocally(config, noopActivities, PROFILES, undefined, {
595
+ onRecord: () => {
596
+ throw new Error("progress sink exploded");
597
+ },
598
+ }).then(() => undefined, (e: unknown) => e);
599
+
600
+ expect(err).toBeInstanceOf(OpRunFailure);
601
+ const failure = err as OpRunFailure;
602
+ expect(failure.result.records).toHaveLength(1);
603
+ expect(failure.result.records[0]).toMatchObject({
604
+ status: "fail",
605
+ error: expect.stringContaining("progress sink exploded"),
606
+ });
607
+ // The original error object is still reachable for a programmatic caller.
608
+ expect((failure.cause as Error).message).toMatch(/progress sink exploded/);
609
+
610
+ const lines: string[] = [];
611
+ renderHuman(failure.result, (line) => lines.push(line));
612
+ expect(lines.some((l) => l.includes("progress sink exploded"))).toBe(true);
613
+ });
614
+ });