@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
@@ -34,7 +34,7 @@ import {
34
34
  latestResolutionSince, latestPendingGate, isPendingGateExpired,
35
35
  resolveApprovalUrl, isApprovalUrl,
36
36
  } from "../../lifecycle/gate-ledger";
37
- import { pushLifecycle } from "../../lifecycle/git";
37
+ import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
38
38
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
39
39
  import type { CommandContext } from "../registry";
40
40
 
@@ -598,6 +598,39 @@ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
598
598
  * is already expired, which supersedes the standing one on an append-only
599
599
  * ledger, so the next run decides the gate from scratch.
600
600
  */
601
+ /** The ledger branch, named in the warnings below so a reader can go look at it. */
602
+ const LIFECYCLE_BRANCH = "chant/lifecycle";
603
+
604
+ /**
605
+ * Push the ledger and say so when it does not land (#2309 review, refs #2310).
606
+ *
607
+ * Both write paths here used `pushLifecycle().catch(() => undefined)` and then
608
+ * printed unconditional success and exited 0. A rejected push — a stale lease,
609
+ * no credentials, no network — therefore read as a completed approval, which
610
+ * is the one thing an approval must never do: the operator walks away
611
+ * believing the gate is answered for everybody, while the resolution exists
612
+ * only in their own checkout.
613
+ *
614
+ * The append is still a correct *local* fact, so this is a warning and not a
615
+ * failure; the exit code is unchanged.
616
+ */
617
+ async function reportedPush(consequence: string): Promise<boolean> {
618
+ try {
619
+ const pushed = await pushLifecycle();
620
+ if (pushed) return true;
621
+ console.error(formatWarning({
622
+ message: `No remote is configured, so nothing was pushed. ${consequence}`,
623
+ }));
624
+ return false;
625
+ } catch (err) {
626
+ console.error(formatWarning({
627
+ message: `The push to the remote was rejected: ${err instanceof Error ? err.message : String(err)}`,
628
+ hint: consequence,
629
+ }));
630
+ return false;
631
+ }
632
+ }
633
+
601
634
  export async function runApprove(ctx: CommandContext): Promise<number> {
602
635
  const opName = ctx.args.path;
603
636
  const gate = ctx.args.extraPositional;
@@ -607,6 +640,18 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
607
640
  }
608
641
 
609
642
  if (ctx.args.expire) {
643
+ // Same guard as the approve path below (#2303): `--expire` reads the
644
+ // standing fact and appends beside it, so a clone that never fetched the
645
+ // branch would both fail to see the fact it is expiring and replace the
646
+ // branch with the expiry alone.
647
+ try {
648
+ await requireLifecycleLedger();
649
+ } catch (err) {
650
+ console.error(formatError({
651
+ message: `Cannot expire the gate: ${err instanceof Error ? err.message : String(err)}`,
652
+ }));
653
+ return 1;
654
+ }
610
655
  const now = new Date().toISOString();
611
656
  const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
612
657
  if (!standing || isPendingGateExpired(standing, now)) {
@@ -622,8 +667,14 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
622
667
  expiresAt: now,
623
668
  ...(standing.description ? { description: standing.description } : {}),
624
669
  });
625
- await pushLifecycle().catch(() => undefined);
626
- console.error(formatSuccess(`Gate "${gate}" on "${opName}" expired at ${now} — not approved`));
670
+ const pushed = await reportedPush(
671
+ `The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
672
+ `a run in another checkout still sees the old pending fact.`,
673
+ );
674
+ console.error(formatSuccess(
675
+ `Gate "${gate}" on "${opName}" expired at ${now} — not approved` +
676
+ (pushed ? "" : " (local only — the push did not land)"),
677
+ ));
627
678
  console.error(formatInfo(
628
679
  `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
629
680
  ));
@@ -682,6 +733,21 @@ export async function recordGateApproval(
682
733
  }));
683
734
  }
684
735
 
736
+ // Read the branch before appending to it (#2303 finding 2). Without this,
737
+ // an approve in a clone that never fetched `chant/lifecycle` builds its
738
+ // commit from an empty tree with no parent and replaces the branch with a
739
+ // single commit holding only this resolution — the pending fact it is
740
+ // answering is discarded rather than appended to.
741
+ try {
742
+ await requireLifecycleLedger();
743
+ } catch (err) {
744
+ console.error(formatError({
745
+ message: `Cannot record the approval: ${err instanceof Error ? err.message : String(err)}`,
746
+ hint: "The pending fact this answers lives on that branch; appending without it would drop it.",
747
+ }));
748
+ return { ok: false };
749
+ }
750
+
685
751
  const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
686
752
 
687
753
  // #2028: the resolution's link is typed. `--url` wins; otherwise, running
@@ -704,11 +770,15 @@ export async function recordGateApproval(
704
770
  ...(opts.note ? { note: opts.note } : {}),
705
771
  ...(url ? { url } : {}),
706
772
  });
707
- await pushLifecycle().catch(() => undefined);
773
+ const pushed = await reportedPush(
774
+ `The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
775
+ `a run in another checkout will not see it.`,
776
+ );
708
777
 
709
778
  console.error(formatSuccess(
710
779
  `Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
711
- (record.url ? ` (${record.url})` : ""),
780
+ (record.url ? ` (${record.url})` : "") +
781
+ (pushed ? "" : " (local only — the push did not land)"),
712
782
  ));
713
783
  return { ok: true, record };
714
784
  }
package/src/lexicon.ts CHANGED
@@ -465,6 +465,25 @@ export interface ComponentPipelineOptions {
465
465
  image?: string;
466
466
  /** Top-level CI `variables:` block. */
467
467
  variables?: Record<string, string>;
468
+ /**
469
+ * The stage every generated Op job runs in, and (on GitLab) the default
470
+ * base of the generated file's own name (#2293). GitLab-only: a GitHub or
471
+ * Forgejo Op workflow is one file per Op with no shared stage, so this does
472
+ * nothing there. Default `"ops"` — every trigger kind (cron, merge-request,
473
+ * push) a project mixes into one Op pipeline lands under the same stage,
474
+ * unlike the pre-#2293 constant `"scheduled-ops"`, which named only the
475
+ * cron-only pipeline the generator used to emit. See `opsFileName` for the
476
+ * file name, which follows this unless overridden separately.
477
+ */
478
+ opsStage?: string;
479
+ /**
480
+ * The generated Op pipeline file's name — GitLab only (#2293). Defaults to
481
+ * `` `${opsStage}.gitlab-ci.yml` `` (`ops.gitlab-ci.yml` with no `opsStage`
482
+ * override), so setting `opsStage` alone renames both the stage GitLab's UI
483
+ * groups jobs under and the file a consumer's `include:` line names. Set
484
+ * this too when the file should land under some other name than its stage.
485
+ */
486
+ opsFileName?: string;
468
487
  }
469
488
 
470
489
  /** The synthesized CI pipeline for a component graph (generate mode). */
@@ -638,6 +657,38 @@ export interface ScheduledOpSpec {
638
657
  * own gate.
639
658
  */
640
659
  environment?: OpEnvironment;
660
+ /**
661
+ * Variables and secrets for this Op's generated job alone (#2290). Same
662
+ * shape as {@link ComponentPipelineOptions.variables} — chant draws no
663
+ * type-level line between a plain value and a credential, both are strings
664
+ * a generator drops into the job's environment — but scoped to one Op's job
665
+ * rather than the whole generated file, for the reason `setup` and
666
+ * `permissions` are per-Op already: what a job may hold is a property of
667
+ * that job. A `live-check` plan job that makes no cloud call declares none
668
+ * of this and inherits none of it.
669
+ *
670
+ * **The rule where both are set** (#2290): `ComponentPipelineOptions.variables`
671
+ * (forge-wide) keeps landing on the workflow/file-level `env:`
672
+ * (github/forgejo) or top-level `variables:` (gitlab) exactly as it always
673
+ * has — every caller that declares nothing here sees byte-identical output.
674
+ * This field lands one level down, on the job itself — github/forgejo emit
675
+ * it as the job's own `env:` mapping, gitlab merges it into the job's own
676
+ * `variables:` — and a key present in both wins at the job, the same
677
+ * last-one-wins precedence GitHub Actions and GitLab CI already give
678
+ * step/job env over workflow env. Declaring a credential here rather than
679
+ * in the forge-wide options is therefore how a caller keeps it off every
680
+ * *other* Op's job: nothing about the forge-wide options changes shape,
681
+ * only which of a project's own Ops asks for the credential at all.
682
+ *
683
+ * Supported everywhere a per-Op `env:`/`variables:` block is expressible:
684
+ * github and forgejo both emit job-level `env:`; gitlab merges these into
685
+ * the job's own `variables:` block (already used there for the gated
686
+ * apply's `CHANT_GATE_SUMMARY`, so the merge is native rather than bolted
687
+ * on). No generator refuses this option — unlike `setup`'s `uses:` shape or
688
+ * an additive `permissions` scope, every forge chant targets has some
689
+ * per-job environment mapping to put a value in.
690
+ */
691
+ variables?: Record<string, string>;
641
692
  }
642
693
 
643
694
  /**
@@ -420,8 +420,18 @@ describe("lifecycle/git", () => {
420
420
  }
421
421
  });
422
422
 
423
- test("concurrent write rejected: second push throws StaleLifecycleBranchError", async () => {
424
- // Simulate two concurrent operators by setting up two clones of the same remote.
423
+ /**
424
+ * Rewritten for #2309's review. This used to assert that operator B's push
425
+ * was *rejected*: B had no local `chant/lifecycle`, so its write built an
426
+ * unrelated root commit, and the lease was the only thing standing between
427
+ * that fork and A's history on the remote.
428
+ *
429
+ * A write that has to be caught by a lease is the bug, not the contract.
430
+ * `writeBlobToPath` now brings the branch in before it creates one, so B
431
+ * never forks: it appends to A's history and pushes cleanly, and both
432
+ * snapshots survive. The genuine stale-lease case is the test below.
433
+ */
434
+ test("a second writer with no local ledger appends to the first's history rather than forking", async () => {
425
435
  const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
426
436
  const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
427
437
  try {
@@ -433,9 +443,43 @@ describe("lifecycle/git", () => {
433
443
  await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
434
444
  expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
435
445
 
436
- // Operator B writes from the same baseline (chant/lifecycle doesn't exist
437
- // on cloneB's remote-tracking yet) and tries to push — should fail
438
- // with StaleLifecycleBranchError because A's push moved the remote ref.
446
+ // Operator B has never seen `chant/lifecycle`.
447
+ expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], cloneB).exitCode).not.toBe(0);
448
+ await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
449
+ expect(await pushLifecycle({ cwd: cloneB })).toBe(true);
450
+
451
+ // Neither snapshot was lost.
452
+ expect(await readSnapshot("prod", "aws", { cwd: cloneB })).toBe(JSON.stringify({ a: 1 }));
453
+ expect(await readSnapshot("staging", "gcp", { cwd: cloneB })).toBe(JSON.stringify({ b: 2 }));
454
+ } finally {
455
+ await cleanup();
456
+ const { rm } = await import("node:fs/promises");
457
+ await rm(cloneB, { recursive: true, force: true });
458
+ }
459
+ });
460
+
461
+ test("concurrent write rejected: second push throws StaleLifecycleBranchError", async () => {
462
+ const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
463
+ const cloneB = join(tmpdir(), `chant-state-clone-b-${Date.now()}-${Math.random()}`);
464
+ try {
465
+ git(["clone", "-q", remotePath, cloneB], tmpdir());
466
+ git(["config", "user.email", "test@chant.dev"], cloneB);
467
+ git(["config", "user.name", "Test"], cloneB);
468
+
469
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 1 }), { cwd: cloneA });
470
+ expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
471
+
472
+ // B takes a copy of the branch, so it has local history and a
473
+ // remote-tracking ref pinned to what it saw.
474
+ git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], cloneB);
475
+
476
+ // A moves the remote on again, behind B's back.
477
+ git(["fetch", "-q", "origin", "+refs/heads/chant/lifecycle:refs/remotes/origin/chant/lifecycle"], cloneA);
478
+ await writeSnapshot("prod", "aws", JSON.stringify({ a: 2 }), { cwd: cloneA });
479
+ expect(await pushLifecycle({ cwd: cloneA })).toBe(true);
480
+
481
+ // B appends to the tip it knows and pushes against a lease that no
482
+ // longer matches the remote.
439
483
  await writeSnapshot("staging", "gcp", JSON.stringify({ b: 2 }), { cwd: cloneB });
440
484
  await expect(pushLifecycle({ cwd: cloneB })).rejects.toBeInstanceOf(StaleLifecycleBranchError);
441
485
  } finally {
@@ -8,6 +8,69 @@ import { getRuntime } from "../runtime-adapter";
8
8
 
9
9
  const STATE_BRANCH = "chant/lifecycle";
10
10
 
11
+ /**
12
+ * The identity chant's ledger commits fall back to when the checkout has none
13
+ * of its own (#2301).
14
+ *
15
+ * A CI checkout is the ordinary case here, not an exotic one: GitLab CI,
16
+ * GitHub Actions and Forgejo Actions all clone without `user.email` or
17
+ * `user.name`, and `git commit-tree` refuses outright rather than inventing
18
+ * one — `fatal: unable to auto-detect email address`. Every write in this
19
+ * module goes through `commit-tree`, so before #2301 a gate's pending fact,
20
+ * a run record, a snapshot and a release record all died the same way the
21
+ * moment they ran anywhere but a developer's laptop.
22
+ *
23
+ * This is a *fallback*, applied per-invocation via `git -c` and field by
24
+ * field: only the half the checkout cannot supply is filled in. A configured
25
+ * `user.name` or `user.email` — a developer's, or a CI job that sets one
26
+ * deliberately — is left exactly as it is and still authors these commits, and
27
+ * nothing is written to the repository's config either way. Override the
28
+ * fallback with `CHANT_LIFECYCLE_COMMITTER_NAME` / `_EMAIL` when a project
29
+ * wants its ledger commits attributed to a bot account it controls.
30
+ */
31
+ const LEDGER_COMMITTER_NAME = "chant";
32
+ const LEDGER_COMMITTER_EMAIL = "chant@localhost";
33
+
34
+ /**
35
+ * `git -c` arguments that give {@link writeBlobToPath}'s `commit-tree` a
36
+ * committer when — and only when — the checkout cannot supply one itself.
37
+ * Empty for any checkout that has an identity, which keeps every existing
38
+ * caller's commits byte-identical to what they were before #2301.
39
+ */
40
+ async function ledgerCommitIdentityArgs(cwd?: string): Promise<string[]> {
41
+ const rt = getRuntime();
42
+ const probe = await rt.spawn(["git", "var", "GIT_COMMITTER_IDENT"], { cwd, env: C_LOCALE_ENV });
43
+ if (probe.exitCode === 0) return [];
44
+
45
+ // Per-field, not all-or-nothing (#2309 review). `git var` fails if *either*
46
+ // half is missing, so filling in both would overwrite a configured
47
+ // `user.name` in the checkout that has a name but no email — which is not
48
+ // what "a configured identity is left exactly as it is" promises.
49
+ const args: string[] = [];
50
+ for (const [key, envVar, fallback] of [
51
+ ["user.name", "CHANT_LIFECYCLE_COMMITTER_NAME", LEDGER_COMMITTER_NAME],
52
+ ["user.email", "CHANT_LIFECYCLE_COMMITTER_EMAIL", LEDGER_COMMITTER_EMAIL],
53
+ ] as const) {
54
+ const configured = await rt.spawn(["git", "config", "--get", key], { cwd, env: C_LOCALE_ENV });
55
+ if (configured.exitCode === 0 && configured.stdout.trim() !== "") continue;
56
+ args.push("-c", `${key}=${process.env[envVar]?.trim() || fallback}`);
57
+ }
58
+ return args;
59
+ }
60
+
61
+ /**
62
+ * Prefix every failure out of the ledger write path with what was being
63
+ * written and where (#2301 deliverable 2). `git commit-tree failed: <stderr>`
64
+ * on its own does not say that the thing that failed was chant recording a
65
+ * fact on its own branch, and the caller that swallows it is one stack frame
66
+ * away from printing nothing at all.
67
+ */
68
+ function ledgerWriteError(stage: string, path: string, stderr: string): Error {
69
+ return new Error(
70
+ `cannot write ${path} on the ${STATE_BRANCH} branch: ${stage} failed: ${(stderr ?? "").trim()}`,
71
+ );
72
+ }
73
+
11
74
  /**
12
75
  * Write a blob to an arbitrary `<environment>/<filename>` path on the orphan
13
76
  * branch, preserving every other env/file entry already on the branch.
@@ -56,13 +119,42 @@ export async function writeBlobToPath(
56
119
  // annotation) and no shell-quoting dance around embedded single quotes.
57
120
  // Content-addressed and idempotent, so this stays outside the retry loop
58
121
  // below — nothing about a CAS conflict on the ref ever invalidates it.
122
+ //
123
+ // `path` is resolved first only so every failure below can name it (#2301).
124
+ const path = `${environment}/${filename}`;
125
+
59
126
  const blobResult = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd, stdin: content });
60
127
  if (blobResult.exitCode !== 0) {
61
- throw new Error(`git hash-object failed: ${blobResult.stderr}`);
128
+ throw ledgerWriteError("git hash-object", path, blobResult.stderr);
62
129
  }
63
130
  const blobSha = blobResult.stdout.trim();
64
131
 
65
- const path = `${environment}/${filename}`;
132
+ // Resolved once, outside the retry loop: whether the checkout can name a
133
+ // committer does not change between attempts (#2301).
134
+ const identityArgs = await ledgerCommitIdentityArgs(cwd);
135
+
136
+ // Creating the ledger branch is a different act from extending it, and only
137
+ // one of them is safe to do blind (#2309 review). With no local
138
+ // `chant/lifecycle`, everything below builds a tree from an empty read and
139
+ // commits it with no parent — a brand-new root commit that *replaces* the
140
+ // branch rather than appending to it, which `updateRefCAS` cannot catch
141
+ // because "the ref does not exist" is perfectly true locally.
142
+ //
143
+ // Until #2301 this was mostly hidden: `commit-tree` died for want of a
144
+ // committer before it could happen. Supplying an identity turned that loud
145
+ // failure into a silent branch fabrication on every writer that reaches
146
+ // here without a ledger — a run record, a snapshot, a release, a build
147
+ // manifest — and a fabricated branch is precisely the fork the guard above
148
+ // exists to refuse. So the guard runs here, at the one seam they all pass
149
+ // through, rather than at each of their call sites.
150
+ //
151
+ // Only when the branch is absent: an extend costs no fetch, and the check
152
+ // that matters is whether the history this is about to discard exists
153
+ // somewhere. `requireLifecycleLedger` allows `no-remote` and `absent`, so a
154
+ // genuinely first write still creates the branch.
155
+ if ((await getStateBranchTip(cwd)) === null) {
156
+ await requireLifecycleLedger({ ...(cwd ? { cwd } : {}) });
157
+ }
66
158
 
67
159
  // 2-5. Read tree, build the commit, and CAS-update the branch ref — retried
68
160
  // on a conflict (#1959 finding 1). `writeBlobToPath` had no retry of its
@@ -127,7 +219,7 @@ export async function writeBlobToPath(
127
219
 
128
220
  const envTreeResult = await rt.spawn(["git", "mktree"], { cwd, stdin: `${envEntries}\n` });
129
221
  if (envTreeResult.exitCode !== 0) {
130
- throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
222
+ throw ledgerWriteError("git mktree (env)", path, envTreeResult.stderr);
131
223
  }
132
224
  const envTreeSha = envTreeResult.stdout.trim();
133
225
 
@@ -150,7 +242,7 @@ export async function writeBlobToPath(
150
242
  stdin: `${rootEntries.join("\n")}\n`,
151
243
  });
152
244
  if (rootTreeResult.exitCode !== 0) {
153
- throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
245
+ throw ledgerWriteError("git mktree (root)", path, rootTreeResult.stderr);
154
246
  }
155
247
  const rootTreeSha = rootTreeResult.stdout.trim();
156
248
 
@@ -160,11 +252,11 @@ export async function writeBlobToPath(
160
252
  const parentRef = tip;
161
253
  const parentArgs = parentRef ? ["-p", parentRef] : [];
162
254
  const commitResult = await rt.spawn(
163
- ["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
255
+ ["git", ...identityArgs, "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
164
256
  { cwd },
165
257
  );
166
258
  if (commitResult.exitCode !== 0) {
167
- throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
259
+ throw ledgerWriteError("git commit-tree", path, commitResult.stderr);
168
260
  }
169
261
  const commitSha = commitResult.stdout.trim();
170
262
 
@@ -534,16 +626,225 @@ export async function pushLifecycle(opts?: { cwd?: string }): Promise<boolean> {
534
626
  * Fetch the state branch from remote.
535
627
  */
536
628
  export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean> {
629
+ return (await fetchLifecycleStatus(opts)).status === "fetched";
630
+ }
631
+
632
+ /**
633
+ * Why a fetch of the ledger branch did or did not produce local history
634
+ * (#2303).
635
+ *
636
+ * `fetchLifecycle`'s boolean cannot answer the question the gate actually
637
+ * needs answered. "No local `chant/lifecycle`" has two causes that a reader
638
+ * of an empty ledger cannot tell apart, and they call for opposite
639
+ * behaviour: on a project whose ledger has never been written, an empty
640
+ * ledger is the truth; in a CI checkout that fetched only the pipeline's own
641
+ * ref, an empty ledger is history the checkout simply cannot see. Reading the
642
+ * second as the first is what makes a second pending fact look correct, so
643
+ * these are separate outcomes:
644
+ *
645
+ * - `fetched` — the branch is now in this checkout (or already was, and is
646
+ * up to date). Whatever the ledger says is the whole truth.
647
+ * - `no-remote` — nothing to fetch from. A single-machine project; local is
648
+ * authoritative by construction, the same assumption `chant operator`'s
649
+ * lease already documents.
650
+ * - `absent` — the remote answered and has no `chant/lifecycle`. The ledger
651
+ * is genuinely empty; this is a project's first gate.
652
+ * - `diverged` — both sides have the branch and neither contains the other.
653
+ * Appending here would drop one side's facts.
654
+ * - `unreachable` — a remote is configured and did not answer (no network,
655
+ * no credentials, a shallow-clone refspec that cannot resolve). Whether
656
+ * anything is on the branch is unknown, which is exactly the state that
657
+ * must not be read as "nothing has been approved".
658
+ */
659
+ export type LifecycleFetchStatus =
660
+ | { status: "fetched" }
661
+ | { status: "no-remote" }
662
+ | { status: "absent"; remote: string }
663
+ | { status: "ahead"; remote: string }
664
+ | { status: "diverged"; remote: string; stderr: string }
665
+ | { status: "unreachable"; remote: string; stderr: string };
666
+
667
+ /**
668
+ * git's wording when the remote answered and simply does not carry the ref.
669
+ *
670
+ * Matched against output forced to `LC_ALL=C` ({@link C_LOCALE_ENV}) — both of
671
+ * these strings are in git's translation catalogs, so on a distro git built
672
+ * with NLS and a non-C `LANG` an unpinned locale would fail to match and send
673
+ * a project's genuine first gate down the `unreachable` path, refusing it.
674
+ */
675
+ const NO_SUCH_REMOTE_REF_RE = /couldn't find remote ref|no such ref was fetched/i;
676
+ /** git's wording when the refspec would not fast-forward the local branch. Same locale caveat. */
677
+ const NON_FAST_FORWARD_RE = /non-fast-forward|rejected/i;
678
+
679
+ /**
680
+ * `git` invocations here are parsed by their output, so they are pinned to the
681
+ * C locale. `LC_ALL` alone is enough — it outranks `LC_MESSAGES` and `LANG` —
682
+ * but `LANGUAGE` is GNU gettext's own override and outranks all of them, so it
683
+ * is cleared too.
684
+ */
685
+ const C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
686
+
687
+ /**
688
+ * The remote the ledger lives on.
689
+ *
690
+ * `origin` when it exists, rather than whichever name sorts first — a repo
691
+ * with a `backup` remote alongside `origin` would otherwise fetch the ledger
692
+ * from `backup`. A non-zero exit is *not* read as "no remote": a git that
693
+ * failed to answer is not evidence that a project is single-machine, and
694
+ * treating it as one is what lets an unreadable ledger look empty.
695
+ */
696
+ async function ledgerRemote(cwd?: string): Promise<{ remote: string } | { failed: true; stderr: string } | null> {
537
697
  const rt = getRuntime();
538
- const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
539
- if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
698
+ const result = await rt.spawn(["git", "remote"], { cwd, env: C_LOCALE_ENV });
699
+ if (result.exitCode !== 0) return { failed: true, stderr: (result.stderr ?? "").trim() };
700
+ const names = result.stdout.trim().split("\n").map((n) => n.trim()).filter(Boolean);
701
+ if (names.length === 0) return null;
702
+ return { remote: names.includes("origin") ? "origin" : names[0] };
703
+ }
704
+
705
+ /** A scratch ref this module owns, used to resolve the remote tip without touching the ledger branch. */
706
+ const PROBE_REF = "refs/chant/lifecycle-probe";
707
+
708
+ /**
709
+ * Fetch `chant/lifecycle` and say what happened, in the terms
710
+ * {@link LifecycleFetchStatus} defines.
711
+ *
712
+ * The refspec is deliberately un-forced (`chant/lifecycle:chant/lifecycle`,
713
+ * no leading `+`), so a local branch holding facts the remote does not have is
714
+ * never rewound by a read.
715
+ *
716
+ * git reports "non-fast-forward" for two states that could not be more
717
+ * different, which is why the rejection is not taken at face value (#2309
718
+ * review): a local branch *ahead* of the remote — the ordinary state right
719
+ * after an append whose push has not landed yet — and a local branch that has
720
+ * genuinely *forked*. Resolving the remote tip into a scratch ref and asking
721
+ * whether it is an ancestor of the local tip separates them. Reading a fork as
722
+ * the ledger is what destroys approvals; refusing an append that is merely
723
+ * ahead would break the retry that is supposed to repair a failed push.
724
+ */
725
+ export async function fetchLifecycleStatus(opts?: { cwd?: string }): Promise<LifecycleFetchStatus> {
726
+ const rt = getRuntime();
727
+ const cwd = opts?.cwd;
728
+ const found = await ledgerRemote(cwd);
729
+ if (found === null) return { status: "no-remote" };
730
+ if ("failed" in found) {
731
+ return { status: "unreachable", remote: "(unknown)", stderr: found.stderr };
732
+ }
733
+ const { remote } = found;
540
734
 
541
- const remote = remoteResult.stdout.trim().split("\n")[0];
542
735
  const fetchResult = await rt.spawn(
543
736
  ["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
544
- { cwd: opts?.cwd },
737
+ { cwd, env: C_LOCALE_ENV },
545
738
  );
546
- return fetchResult.exitCode === 0;
739
+ if (fetchResult.exitCode === 0) return { status: "fetched" };
740
+
741
+ const stderr = (fetchResult.stderr ?? "").trim();
742
+ if (NO_SUCH_REMOTE_REF_RE.test(stderr)) return { status: "absent", remote };
743
+ if (!NON_FAST_FORWARD_RE.test(stderr)) return { status: "unreachable", remote, stderr };
744
+
745
+ // Rejected. Resolve the remote tip into our own scratch ref (forced: the ref
746
+ // is this module's, and nothing reads it but the next two lines) and ask
747
+ // whether the local branch already contains it.
748
+ const probe = await rt.spawn(
749
+ ["git", "fetch", "--force", remote, `${STATE_BRANCH}:${PROBE_REF}`],
750
+ { cwd, env: C_LOCALE_ENV },
751
+ );
752
+ if (probe.exitCode !== 0) {
753
+ return { status: "unreachable", remote, stderr: (probe.stderr ?? "").trim() || stderr };
754
+ }
755
+ const contains = await rt.spawn(
756
+ ["git", "merge-base", "--is-ancestor", PROBE_REF, `refs/heads/${STATE_BRANCH}`],
757
+ { cwd, env: C_LOCALE_ENV },
758
+ );
759
+ await rt.spawn(["git", "update-ref", "-d", PROBE_REF], { cwd, env: C_LOCALE_ENV });
760
+ if (contains.exitCode === 0) return { status: "ahead", remote };
761
+ return { status: "diverged", remote, stderr };
762
+ }
763
+
764
+ /**
765
+ * Thrown when the ledger branch cannot be read, rather than being reported as
766
+ * empty (#2303).
767
+ */
768
+ export class LifecycleLedgerUnreadableError extends Error {
769
+ constructor(
770
+ public readonly reason: "unreachable" | "diverged",
771
+ public readonly remote: string,
772
+ public readonly stderr: string,
773
+ detail: string,
774
+ ) {
775
+ super(
776
+ `the ${STATE_BRANCH} ledger branch ${detail}, so what is on it cannot be read. ` +
777
+ `Refusing rather than treating an unreadable ledger as an empty one.` +
778
+ (reason === "diverged"
779
+ ? `\n Two histories claim to be the ledger; appending to either one drops the other's facts. ` +
780
+ `Inspect both before doing anything else:\n` +
781
+ ` git fetch ${remote} ${STATE_BRANCH}:refs/chant/remote-ledger\n` +
782
+ ` git log --oneline ${STATE_BRANCH} refs/chant/remote-ledger`
783
+ : `\n Fetch it and retry: git fetch ${remote} ${STATE_BRANCH}:${STATE_BRANCH}`) +
784
+ (stderr ? `\n git said: ${stderr.split("\n")[0]}` : ""),
785
+ );
786
+ this.name = "LifecycleLedgerUnreadableError";
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Bring the ledger branch into this checkout before reading or appending to
792
+ * it, and refuse when it cannot be (#2303).
793
+ *
794
+ * The gate's read is the one that has to be right: `evaluateGate` decides
795
+ * whether to walk through or to record a *new* pending fact purely from what
796
+ * the ledger says, so a read that reports history it cannot see as an empty
797
+ * ledger makes the second run of an already-approved commit record a second
798
+ * pending fact and gate again. That is what a GitLab CI retry did on
799
+ * INTENTIUS/choudoufu#1026 — two pending records for one commit, with
800
+ * different expiries — because a GitLab checkout fetches the pipeline's own
801
+ * ref at depth 20 and nothing else. `GIT_DEPTH: 0` does not help: GitLab
802
+ * fetches refspecs, not all branches.
803
+ *
804
+ * There was a separate, laxer guard for the read until #2309's review, on the
805
+ * reasoning that a checkout which already has the branch "has real history to
806
+ * read". That reasoning is only sound when the local branch *is* the remote's
807
+ * history, and it let the worst case through: on a fork, the gate read a
808
+ * ledger missing every remote approval, recorded a fresh pending fact, and
809
+ * pushed. The push was not protected either — a rejected non-fast-forward
810
+ * fetch still updates `refs/remotes/<remote>/chant/lifecycle` opportunistically
811
+ * in a full clone, so `pushLifecycle`'s `--force-with-lease` matched the tip
812
+ * it had just learned and forced. Verified on git 2.50.1: a remote holding a
813
+ * pending fact and an approval ended up holding neither.
814
+ *
815
+ * So there is one rule now, for readers and writers alike:
816
+ *
817
+ * - `diverged` always refuses. A fork is not the ledger, and nothing good
818
+ * comes of reading one as if it were.
819
+ * - `unreachable` refuses only when the checkout has no local history at all,
820
+ * which is the case that would otherwise read as an empty ledger. With
821
+ * local history and no answer from the remote, the read proceeds and the
822
+ * push's lease fails safely on its own — refusing there would strand every
823
+ * offline run.
824
+ * - `fetched`, `no-remote`, `absent` and `ahead` all proceed. `ahead` is the
825
+ * ordinary state right after an append whose push has not landed, and is
826
+ * exactly what a retry needs to be able to repair.
827
+ */
828
+ export async function requireLifecycleLedger(opts?: { cwd?: string }): Promise<void> {
829
+ const hadLocal = (await getStateBranchTip(opts?.cwd)) !== null;
830
+ const result = await fetchLifecycleStatus(opts);
831
+
832
+ if (result.status === "diverged") {
833
+ throw new LifecycleLedgerUnreadableError(
834
+ "diverged",
835
+ result.remote,
836
+ result.stderr,
837
+ `has diverged from the copy on "${result.remote}" — neither contains the other`,
838
+ );
839
+ }
840
+ if (result.status === "unreachable" && !hadLocal) {
841
+ throw new LifecycleLedgerUnreadableError(
842
+ "unreachable",
843
+ result.remote,
844
+ result.stderr,
845
+ `is not in this checkout and could not be fetched from "${result.remote}"`,
846
+ );
847
+ }
547
848
  }
548
849
 
549
850
  // ── Generic ref CAS (#1485) ──────────────────────────────────────────────────