@intentius/chant 0.61.0 → 0.63.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/handlers/operator.d.ts +13 -18
- package/dist/cli/handlers/operator.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 +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/driver-output.d.ts.map +1 -1
- package/dist/components/driver.d.ts +12 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon.d.ts +51 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +61 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +117 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +33 -0
- package/dist/lifecycle/plan-digest.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/lifecycle.d.ts +27 -0
- package/dist/op/activities/lifecycle.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +394 -14
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/builders.d.ts +6 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/apply-op.d.ts +6 -0
- package/dist/op/composites/apply-op.d.ts.map +1 -1
- package/dist/op/composites/reconcile-op.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +104 -13
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +3 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +34 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +8 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +19 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +8 -0
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +18 -2
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/operator.test.ts +232 -1
- package/src/cli/handlers/operator.ts +130 -6
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.ts +29 -4
- package/src/components/driver-output.ts +10 -0
- package/src/components/driver.test.ts +31 -0
- package/src/components/driver.ts +54 -8
- package/src/discovery/fold-import.test.ts +55 -0
- package/src/fold/fold.test.ts +152 -0
- package/src/fold/fold.ts +102 -2
- package/src/fold/subset-doc-parity.test.ts +35 -1
- package/src/fold/subset.ts +10 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.test.ts +133 -1
- package/src/lifecycle/gate-ledger.ts +108 -0
- package/src/lifecycle/git.test.ts +49 -5
- package/src/lifecycle/git.ts +312 -11
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/plan-digest.test.ts +49 -0
- package/src/lifecycle/plan-digest.ts +86 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/op/activities/lexicon-upgrade.test.ts +134 -39
- package/src/op/activities/lexicon-upgrade.ts +74 -12
- package/src/op/activities/lifecycle.ts +51 -2
- package/src/op/activities/reconcile.test.ts +1013 -1
- package/src/op/activities/reconcile.ts +721 -25
- package/src/op/builders.ts +7 -1
- package/src/op/composites/apply-op.ts +16 -0
- package/src/op/composites/composites.test.ts +15 -2
- package/src/op/composites/reconcile-op.test.ts +18 -0
- package/src/op/composites/reconcile-op.ts +7 -1
- package/src/op/gate-summary.test.ts +33 -0
- package/src/op/gate-summary.ts +31 -0
- package/src/op/gate.test.ts +614 -0
- package/src/op/gate.ts +190 -24
- package/src/op/index.ts +5 -2
- package/src/op/local-executor.test.ts +340 -2
- package/src/op/local-executor.ts +190 -32
- package/src/op/local-output.test.ts +38 -0
- package/src/op/local-output.ts +24 -1
- package/src/op/op-ir.test.ts +22 -0
- package/src/op/op-ir.ts +9 -0
- package/src/op/operator.test.ts +20 -0
- package/src/op/operator.ts +39 -1
- package/src/op/runtime.ts +2 -0
- package/src/op/runtimes/local.ts +11 -0
- package/src/op/types.ts +19 -0
- package/src/runtime-adapter.ts +17 -3
- package/src/terraform/__fixtures__/build-graph.ts +42 -0
- package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
- package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
- package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
- package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
- package/src/terraform/graph.test.ts +148 -1
- package/src/terraform/graph.ts +144 -6
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/types.ts +7 -0
|
@@ -0,0 +1,614 @@
|
|
|
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, type GateLedgerPort } 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
|
+
// #2310: no remote to have failed against, but still worth saying —
|
|
504
|
+
// nothing this run just recorded left the checkout.
|
|
505
|
+
if (check.satisfied) throw new Error("unreachable");
|
|
506
|
+
expect(check.recorded).toBe(true);
|
|
507
|
+
expect(check.pushed).toBe(false);
|
|
508
|
+
expect(check.pushWarning).toMatch(/no remote/i);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* #2310 — `recordGateApproval`'s push (`../cli/handlers/operator.ts`) was
|
|
514
|
+
* already reported by #2309's review; `evaluateGate`'s own `appendPending`
|
|
515
|
+
* push, right below it in the same file, still swallowed the rejection into
|
|
516
|
+
* `undefined` and let a gated run print as if the pending fact had reached
|
|
517
|
+
* everyone. It had not: an operator working from a clone of the remote
|
|
518
|
+
* cannot see a pending fact that never got there, and the run gave no hint
|
|
519
|
+
* that this was why their `chant approve` found nothing to resolve.
|
|
520
|
+
*
|
|
521
|
+
* Before this fix, `gitGateLedgerPort({ cwd }).appendPending(...)`'s
|
|
522
|
+
* resolved value was the bare `PendingGateRecord` — there was no `pushed`
|
|
523
|
+
* field to assert on at all, so the rejection above was genuinely invisible
|
|
524
|
+
* to any caller. That is the "red" state: this suite fails to typecheck
|
|
525
|
+
* against the old `Promise<PendingGateRecord>` return type, and a rejected
|
|
526
|
+
* push at `gate.ts:71` produced no failure text anywhere.
|
|
527
|
+
*/
|
|
528
|
+
describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)", () => {
|
|
529
|
+
test("gitGateLedgerPort's appendPending reports pushed:false and the rejection reason", async () => {
|
|
530
|
+
const { remote, author } = await clonePair();
|
|
531
|
+
const other = tmp("other");
|
|
532
|
+
git(["clone", "-q", remote, other], tmpdir());
|
|
533
|
+
git(["config", "user.email", "other@chant.dev"], other);
|
|
534
|
+
git(["config", "user.name", "Other"], other);
|
|
535
|
+
|
|
536
|
+
const port = gitGateLedgerPort({ cwd: author });
|
|
537
|
+
|
|
538
|
+
// `author` records and pushes the branch's first pending fact — this
|
|
539
|
+
// both creates `chant/lifecycle` on the remote and pins `author`'s own
|
|
540
|
+
// remote-tracking ref to the commit it just pushed.
|
|
541
|
+
const first = await port.appendPending({
|
|
542
|
+
op: "live-apply",
|
|
543
|
+
gate: "approve-live-apply",
|
|
544
|
+
timestamp: "2026-09-09T05:00:00.000Z",
|
|
545
|
+
expiresAt: "2026-09-10T05:00:00.000Z",
|
|
546
|
+
});
|
|
547
|
+
expect(first.pushed).toBe(true);
|
|
548
|
+
expect(first.pushWarning).toBeUndefined();
|
|
549
|
+
|
|
550
|
+
// `other` fetches that commit, appends a second pending fact on top of
|
|
551
|
+
// it, and pushes — moving the remote on behind `author`'s back. `author`
|
|
552
|
+
// never re-fetches, so its remote-tracking ref still names the first
|
|
553
|
+
// commit alone.
|
|
554
|
+
git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], other);
|
|
555
|
+
const otherPort = gitGateLedgerPort({ cwd: other });
|
|
556
|
+
const second = await otherPort.appendPending({
|
|
557
|
+
op: "live-apply",
|
|
558
|
+
gate: "approve-other-gate",
|
|
559
|
+
timestamp: "2026-09-09T05:05:00.000Z",
|
|
560
|
+
expiresAt: "2026-09-10T05:05:00.000Z",
|
|
561
|
+
});
|
|
562
|
+
expect(second.pushed).toBe(true);
|
|
563
|
+
|
|
564
|
+
// `author` appends its own third fact on the tip it still knows (the
|
|
565
|
+
// first commit) and pushes against a lease the remote has since moved
|
|
566
|
+
// past — the same `StaleLifecycleBranchError` `chant approve` could hit
|
|
567
|
+
// (#2310's issue).
|
|
568
|
+
const third = await port.appendPending({
|
|
569
|
+
op: "live-apply",
|
|
570
|
+
gate: "approve-third-gate",
|
|
571
|
+
timestamp: "2026-09-09T05:10:00.000Z",
|
|
572
|
+
expiresAt: "2026-09-10T05:10:00.000Z",
|
|
573
|
+
});
|
|
574
|
+
expect(third.pushed).toBe(false);
|
|
575
|
+
expect(third.pushWarning).toBeTruthy();
|
|
576
|
+
expect(third.pushWarning).toMatch(/chant\/lifecycle remote branch has moved|stale|rejected|non-fast-forward/i);
|
|
577
|
+
// The append itself still landed locally — a correct local answer, per
|
|
578
|
+
// the #2309 author's reasoning; only the report was missing.
|
|
579
|
+
expect(third.record.gate).toBe("approve-third-gate");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("evaluateGate carries the rejection onto the gate check it returns, recorded but not pushed", async () => {
|
|
583
|
+
// A minimal port whose read side is inert and whose write side reports
|
|
584
|
+
// the exact rejection a stale `chant/lifecycle` lease produces — the
|
|
585
|
+
// shape `evaluateGate` has to propagate regardless of which port
|
|
586
|
+
// implementation is behind it.
|
|
587
|
+
const rejectingPort = {
|
|
588
|
+
async read() {
|
|
589
|
+
return { resolutions: [], pending: [] };
|
|
590
|
+
},
|
|
591
|
+
async appendPending(input: Parameters<GateLedgerPort["appendPending"]>[0]) {
|
|
592
|
+
return {
|
|
593
|
+
record: { version: 1 as const, kind: "pending" as const, ...input },
|
|
594
|
+
pushed: false,
|
|
595
|
+
pushWarning:
|
|
596
|
+
"chant/lifecycle remote branch has moved since this run started — another snapshot was pushed concurrently.",
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const check = await evaluateGate(rejectingPort, {
|
|
602
|
+
op: "live-apply",
|
|
603
|
+
gate: "approve-live-apply",
|
|
604
|
+
now: "2026-09-09T05:00:00.000Z",
|
|
605
|
+
});
|
|
606
|
+
if (check.satisfied) throw new Error("unreachable — nothing resolved this gate");
|
|
607
|
+
expect(check.recorded).toBe(true);
|
|
608
|
+
expect(check.pushed).toBe(false);
|
|
609
|
+
expect(check.pushWarning).toContain("another snapshot was pushed concurrently");
|
|
610
|
+
// The pending fact is still the one that was recorded — gating is still
|
|
611
|
+
// correct, only silent about reaching the remote.
|
|
612
|
+
expect(check.pending.gate).toBe("approve-live-apply");
|
|
613
|
+
});
|
|
614
|
+
});
|