@intentius/chant 0.124.34 → 0.124.35

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 (169) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +23 -8
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  45. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  46. package/dist/lifecycle/compose-refs.d.ts +52 -0
  47. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  48. package/dist/lifecycle/compose-refs.js +115 -0
  49. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  50. package/dist/lifecycle/deep-observe.js +3 -2
  51. package/dist/lifecycle/green-apply.d.ts +39 -0
  52. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  53. package/dist/lifecycle/green-apply.js +51 -0
  54. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  55. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  56. package/dist/lifecycle/publish-declaration.js +15 -0
  57. package/dist/lifecycle/publish-npm.d.ts +7 -4
  58. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  59. package/dist/lifecycle/publish-npm.js +60 -13
  60. package/dist/lifecycle/release-compose.d.ts +91 -0
  61. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  62. package/dist/lifecycle/release-compose.js +235 -0
  63. package/dist/lifecycle/release-cut.d.ts +8 -0
  64. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  65. package/dist/lifecycle/release-cut.js +2 -0
  66. package/dist/lifecycle/release-publish.d.ts +2 -0
  67. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  68. package/dist/lifecycle/release-publish.js +2 -1
  69. package/dist/lifecycle/release-store.d.ts +15 -0
  70. package/dist/lifecycle/release-store.d.ts.map +1 -1
  71. package/dist/lifecycle/release-store.js +5 -1
  72. package/dist/wave-runtime.mjs +225 -223
  73. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/checks/ci-workflow.js +8 -1
  75. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  76. package/dist/workspace/ci-green-cli.js +5 -3
  77. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  78. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  79. package/dist/workspace/ci-green-workflow.js +27 -5
  80. package/dist/workspace/ci-green.d.ts +31 -0
  81. package/dist/workspace/ci-green.d.ts.map +1 -1
  82. package/dist/workspace/ci-green.js +78 -4
  83. package/dist/workspace/ci-run.d.ts +6 -0
  84. package/dist/workspace/ci-run.d.ts.map +1 -1
  85. package/dist/workspace/ci-run.js +2 -1
  86. package/dist/workspace/ci-serve.d.ts +94 -13
  87. package/dist/workspace/ci-serve.d.ts.map +1 -1
  88. package/dist/workspace/ci-serve.js +248 -30
  89. package/dist/workspace/ci-workflow.d.ts +4 -4
  90. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  91. package/dist/workspace/ci-workflow.js +2 -2
  92. package/dist/workspace/delivery-metrics.d.ts +3 -1
  93. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  94. package/dist/workspace/delivery-metrics.js +12 -3
  95. package/dist/workspace/member-commands.d.ts.map +1 -1
  96. package/dist/workspace/member-commands.js +4 -0
  97. package/dist/workspace/nested-graph.d.ts.map +1 -1
  98. package/dist/workspace/nested-graph.js +1 -0
  99. package/dist/workspace/release-candidates.d.ts +28 -0
  100. package/dist/workspace/release-candidates.d.ts.map +1 -1
  101. package/dist/workspace/release-candidates.js +38 -2
  102. package/dist/workspace/release-candidates.schema.json +51 -0
  103. package/dist/workspace/releases.schema.json +30 -0
  104. package/package.json +1 -1
  105. package/src/cli/handlers/graph.ts +2 -2
  106. package/src/cli/handlers/pr-green.test.ts +230 -0
  107. package/src/cli/handlers/pr.ts +148 -66
  108. package/src/cli/handlers/promote.test.ts +34 -0
  109. package/src/cli/handlers/promote.ts +22 -8
  110. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  111. package/src/cli/handlers/release.ts +112 -10
  112. package/src/cli/handlers/run.ts +12 -1
  113. package/src/cli/main.ts +19 -1
  114. package/src/cli/parse-args.ts +13 -0
  115. package/src/cli/registry.ts +14 -0
  116. package/src/codegen/release-wiring.test.ts +56 -9
  117. package/src/components/auto-release.test.ts +22 -0
  118. package/src/components/auto-release.ts +9 -2
  119. package/src/components/green-cover.test.ts +170 -0
  120. package/src/components/green-cover.ts +186 -0
  121. package/src/components/pr-pipeline.test.ts +14 -4
  122. package/src/components/pr-pipeline.ts +56 -5
  123. package/src/components/promote.test.ts +7 -0
  124. package/src/components/promote.ts +9 -4
  125. package/src/components/release-promote.test.ts +24 -1
  126. package/src/components/release-promote.ts +15 -0
  127. package/src/deep-observation.ts +8 -0
  128. package/src/graph-declared.test.ts +18 -0
  129. package/src/graph-declared.ts +8 -1
  130. package/src/graph-detail.test.ts +68 -1
  131. package/src/graph-detail.ts +29 -1
  132. package/src/graph-ir.ts +12 -4
  133. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  134. package/src/lifecycle/compose-refs.ts +125 -0
  135. package/src/lifecycle/deep-observe.test.ts +20 -0
  136. package/src/lifecycle/deep-observe.ts +4 -2
  137. package/src/lifecycle/green-apply.ts +71 -0
  138. package/src/lifecycle/publish-declaration.ts +15 -0
  139. package/src/lifecycle/publish-npm.ts +55 -13
  140. package/src/lifecycle/publish-targets.test.ts +45 -0
  141. package/src/lifecycle/release-compose.test.ts +125 -0
  142. package/src/lifecycle/release-compose.ts +312 -0
  143. package/src/lifecycle/release-cut.ts +6 -0
  144. package/src/lifecycle/release-publish.ts +4 -1
  145. package/src/lifecycle/release-store.ts +16 -1
  146. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  147. package/src/workspace/checks/ci-workflow.ts +7 -1
  148. package/src/workspace/ci-green-cli.ts +5 -3
  149. package/src/workspace/ci-green-workflow.test.ts +52 -0
  150. package/src/workspace/ci-green-workflow.ts +34 -5
  151. package/src/workspace/ci-green.test.ts +33 -0
  152. package/src/workspace/ci-green.ts +85 -4
  153. package/src/workspace/ci-run.test.ts +18 -13
  154. package/src/workspace/ci-run.ts +8 -1
  155. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  156. package/src/workspace/ci-serve-land.test.ts +176 -0
  157. package/src/workspace/ci-serve.test.ts +77 -43
  158. package/src/workspace/ci-serve.ts +268 -34
  159. package/src/workspace/ci-workflow.ts +4 -4
  160. package/src/workspace/delivery-metrics.ts +12 -3
  161. package/src/workspace/graph-contract.test.ts +3 -2
  162. package/src/workspace/member-commands.test.ts +11 -4
  163. package/src/workspace/member-commands.ts +3 -0
  164. package/src/workspace/nested-graph.test.ts +4 -0
  165. package/src/workspace/nested-graph.ts +1 -0
  166. package/src/workspace/release-candidates.schema.json +51 -0
  167. package/src/workspace/release-candidates.test.ts +30 -3
  168. package/src/workspace/release-candidates.ts +54 -2
  169. package/src/workspace/releases.schema.json +30 -0
@@ -5,10 +5,13 @@
5
5
  * {@link ciRun} (./ci-run.ts): `plan` for each new pull request head, and
6
6
  * `record` for each new commit on the target branch (#4168), measured from
7
7
  * the head the previous poll saw (the push's `before`). The record applies
8
- * nothing; with `applyOnMerge` (`--apply-on-merge`) the commit gets `apply`
9
- * in its place, as before #4168. After each job settles it runs `ci tick`
10
- * from the runs the jobs recorded, when the declaration has `ci.green`.
11
- * What the list below says of the apply holds for it under `applyOnMerge`.
8
+ * nothing. With `applyOnMerge` (`--apply-on-merge`) the newest green commit
9
+ * of the branch, once no apply ran for it, gets `apply`: `pr-apply --green`
10
+ * at that commit, measured from the one last applied (#4168 step 9). After
11
+ * each job settles it runs `ci tick` from the runs the jobs recorded, when
12
+ * the declaration has `ci.green`, so a green commit follows the record that
13
+ * made its runs. What the list below says of the apply holds for it under
14
+ * `applyOnMerge`.
12
15
  *
13
16
  * - Dedupe. A state file in the state directory keeps every job it started,
14
17
  * keyed by job and commit (`plan:<pr>:<sha>`, `record:<sha>`, `apply:<sha>`), with how it
@@ -19,10 +22,11 @@
19
22
  * the one `chant operator` holds) named for `prApplyGroup`, renews it while
20
23
  * it runs and releases it after. An apply that finds it held waits for a
21
24
  * later poll, as a forge's concurrency group queues it.
22
- * - Merged pull request. An apply asks the forge for the pull request whose
23
- * merge commit the new head is (`pullRequestFor`, as pr-apply does) and
25
+ * - Merged pull request. A record asks the forge for the pull request whose
26
+ * merge commit the new head is (`pullRequestFor`, as pr-record does) and
24
27
  * passes it as `--pr`, so its commit status link and run index use
25
- * `<pr>/<commit>`. With none found it runs without `--pr`.
28
+ * `<pr>/<commit>`. With none found it runs without `--pr`. An apply covers
29
+ * every merge since the last applied green commit, so it passes none.
26
30
  * - Resume records. Each apply gets `<state>/resume/<prApplyRecordKey>.json`
27
31
  * as its `--resume` record, so a re-run of a failed apply at the same
28
32
  * commit finishes under the approval it has. This replaces the forge's CI
@@ -56,6 +60,24 @@
56
60
  * forge's keeps its facts, and the job's push sends them. Nothing here
57
61
  * pushes the ledger.
58
62
  *
63
+ * - Land branches (#4168 4.4). With `land`, each `chant/land/<name>` branch
64
+ * pushed to the remote is a change asking to land without a pull request.
65
+ * A head that does not descend from the target's is refused and left for
66
+ * its author to rebase. Otherwise `buildCommand` runs on it with
67
+ * `CHANT_BUILD_PHASES` set to `ci.green.preMerge` and `CHANT_BUILD_REASON`
68
+ * `land`, and the phases are judged from the Op run records on its head
69
+ * ({@link judgePreMerge}). When the command exits 0 and every phase
70
+ * passes, the target is pushed to the head, a fast-forward the remote
71
+ * refuses if the target moved, and the land branch is deleted. The next
72
+ * poll sees the new head and runs the record job, whose merge record
73
+ * names a work item or a direct push. A refused land stays in the state
74
+ * file under `land:<name>:<sha>`, so a new push to the branch is tried
75
+ * again and the same head is not.
76
+ * - The `git` forge kind (#4016 gap 6). With `forge: "git"` the serve reads
77
+ * only refs, through `git ls-remote` against `origin`, so any git server
78
+ * that takes pushes will do. It lists no pull requests and sets no commit
79
+ * status, and the jobs run without `--forge`.
80
+ *
59
81
  * Host mode only: each job runs in a fresh worktree on this host as this
60
82
  * user, with the clean environment `ciRun` gives it. A pull request from a
61
83
  * fork is never planned, and with `authors` only those people's pull requests
@@ -70,7 +92,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
70
92
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
71
93
  import { tmpdir } from "node:os";
72
94
  import { join, resolve } from "node:path";
73
- import { CI_BUILD_TAG_PREFIX, readBuildRequests, type BuildRequest } from "./ci-builds";
95
+ import { CI_BUILD_TAG_PREFIX, readBuildRequests, type BuildRequestReason } from "./ci-builds";
74
96
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
75
97
  import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
76
98
  import type { CommandContext } from "../cli/registry";
@@ -79,12 +101,14 @@ import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type
79
101
  import { parseDuration } from "../op/duration";
80
102
  import { reportUrlOf } from "./ci-report-url";
81
103
  import { CI_JOB_OPS, ciJobPlace, ciLoopOptions, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
82
- import { CiGreenError, ciTick, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
104
+ import { CiGreenError, ciTick, judgePreMerge, lastGreen, RUNNER_KEY_ENV, signedRunsRequired, type CommitVerdict } from "./ci-green";
83
105
  import { gitTop } from "./tree";
106
+ import { readDeclaration } from "./declaration";
107
+ import { locateWorkspace } from "./which-chant";
84
108
  import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
85
109
 
86
- /** The forges `ci serve` polls. GitLab is later. */
87
- export type CiServeForgeKind = "github" | "forgejo";
110
+ /** The forges `ci serve` polls. GitLab is later. `git` is any git server, read through refs alone (#4016 gap 6). */
111
+ export type CiServeForgeKind = "github" | "forgejo" | "git";
88
112
 
89
113
  /** What `ci serve` reads from the forge: the two calls GitHub's and Forgejo's clients have (../pr-forge.ts). */
90
114
  export interface CiServeForge {
@@ -96,13 +120,51 @@ export interface CiServeForge {
96
120
 
97
121
  /**
98
122
  * A job the forge's state calls for: the plan of a pull request's head, or
99
- * for a target-branch commit its record (#4168) or, with `applyOnMerge`,
100
- * its apply.
123
+ * for a target-branch commit its record (#4168), or with `applyOnMerge` the
124
+ * apply of the newest green commit (#4168 step 9).
101
125
  */
102
126
  export type CiServeTrigger =
103
127
  | { job: "plan"; commit: string; base: string; pr: number; rerun?: boolean }
104
128
  | { job: "apply" | "record"; commit: string; base: string; rerun?: boolean };
105
129
 
130
+ /**
131
+ * The `git` forge kind (#4016 gap 6): the branch head from `git ls-remote`
132
+ * against `remote`, and no pull requests. A remote that can't be reached is
133
+ * an error for the pass, as a forge API's would be.
134
+ */
135
+ export function gitCiServeForge(top: string, remote = "origin"): CiServeForge {
136
+ return {
137
+ openPullRequests: async () => [],
138
+ async branchHead(branch) {
139
+ const r = gitStatus(top, ["ls-remote", "--exit-code", remote, `refs/heads/${branch}`]);
140
+ if (r.code === 2) return undefined;
141
+ if (r.code !== 0) throw new CiRunError(`git ls-remote ${remote} could not read refs/heads/${branch}`);
142
+ return r.out.split(/\s+/)[0] || undefined;
143
+ },
144
+ };
145
+ }
146
+
147
+ /** What a build runs: a `ci/build/<sha>` request (#4169), or a land branch's pre-merge phases (#4168 4.4). */
148
+ export interface CiServeBuildInput {
149
+ commit: string;
150
+ phases: string[];
151
+ reason: BuildRequestReason | "land";
152
+ }
153
+
154
+ /** Where pushed land branches live on the remote (#4168 4.4). */
155
+ export const LAND_BRANCH_PREFIX = "chant/land/";
156
+
157
+ /** One land branch head in the state file, by `land:<name>:<sha>`. */
158
+ export interface CiServeLandState {
159
+ branch: string;
160
+ commit: string;
161
+ status: "running" | "landed" | "refused";
162
+ /** Why it was refused, in a line. */
163
+ reason?: string;
164
+ startedAt: string;
165
+ settledAt?: string;
166
+ }
167
+
106
168
  /** One job in the state file. */
107
169
  export interface CiServeJobState {
108
170
  job: CiJobName;
@@ -136,6 +198,8 @@ export interface CiServeState {
136
198
  skipped?: Record<string, "skip-ci">;
137
199
  /** The build requests served (#4169), by `build:<sha>`, with how each ended. */
138
200
  builds?: Record<string, { tag: string; status: "running" | "ok" | "fail"; startedAt: string; settledAt?: string }>;
201
+ /** The land branch heads seen (#4168 4.4), by `land:<name>:<sha>`. */
202
+ lands?: Record<string, CiServeLandState>;
139
203
  }
140
204
 
141
205
  /** What a pass did, in order, for the log. */
@@ -149,6 +213,8 @@ export type CiServeEvent =
149
213
  | { type: "not-run"; job: CiJobName; commit: string; pr?: number; reason: "not-fast-forward" | "skip-ci"; branch?: string; from?: string }
150
214
  | { type: "ledger"; action: "deleted" | "reset"; local: string; forge?: string }
151
215
  | { type: "build"; commit: string; phases: string[]; status: "started" | "ok" | "fail" | "held"; exitCode?: number }
216
+ /** A land branch (#4168 4.4): its pre-merge phases started, the target moved to it, or it was refused and left. */
217
+ | { type: "land"; branch: string; commit: string; status: "started" | "landed" | "refused" | "held"; reason?: string; warning?: string }
152
218
  /** `ci tick` after the pass (#4168): the tags it made from the runs. */
153
219
  | { type: "tick"; made: string[] }
154
220
  | { type: "error"; message: string };
@@ -199,7 +265,14 @@ export interface CiServeOptions {
199
265
  */
200
266
  buildCommand?: string;
201
267
  /** Runs a build. Default: `sh -c <buildCommand>` in a detached worktree of the commit. */
202
- build?: (input: { top: string; request: BuildRequest; command: string }) => Promise<number>;
268
+ build?: (input: { top: string; request: CiServeBuildInput; command: string }) => Promise<number>;
269
+ /**
270
+ * Land `chant/land/<name>` branches (#4168 4.4): the `ci.green.preMerge`
271
+ * phases each must pass first. Needs `buildCommand`, which runs them.
272
+ */
273
+ land?: { phases: string[] };
274
+ /** Judges a land head's phases. Default {@link judgePreMerge} from the Op run records. */
275
+ judgeLand?: (input: { commit: string; base: string }) => Promise<CommitVerdict>;
203
276
  }
204
277
 
205
278
  /** The jobs this process runs now, by state key, shared by every pass of one loop. */
@@ -367,8 +440,8 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
367
440
  const triggers: CiServeTrigger[] = [];
368
441
  const events: CiServeEvent[] = [];
369
442
  const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
370
- // #4168: a new target commit is recorded, and applied only with applyOnMerge.
371
- const onMerge: "apply" | "record" = opts.applyOnMerge ? "apply" : "record";
443
+ // #4168: a new target commit is recorded. With applyOnMerge the newest green commit is applied, below.
444
+ const onMerge = "record" as const;
372
445
 
373
446
  if (head === undefined) {
374
447
  events.push({ type: "no-branch", branch: place.branch });
@@ -398,6 +471,16 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
398
471
  if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
399
472
  }
400
473
 
474
+ if (opts.applyOnMerge && head !== undefined) {
475
+ const green = newestGreen(place.top, opts.cwd, place.branch);
476
+ if (green) {
477
+ const key = ciServeKey({ job: "apply", commit: green });
478
+ const entry = state.jobs[key];
479
+ if (!entry) triggers.push({ job: "apply", commit: green, base: green });
480
+ else if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "apply", commit: green, base: entry.base, rerun: true });
481
+ }
482
+ }
483
+
401
484
  const authors = opts.authors?.length ? new Set(opts.authors.map((a) => a.toLowerCase())) : undefined;
402
485
  for (const pr of prs) {
403
486
  if (pr.fork) {
@@ -498,7 +581,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
498
581
  const entry: CiServeJobState = { job: t.job, commit: t.commit, base: t.base, ...pr, status: "running", runId, attempts: attempt, startedAt: now().toISOString() };
499
582
  update((s) => {
500
583
  s.jobs[key] = entry;
501
- if (t.job !== "plan" && !t.rerun) s.target = t.commit;
584
+ if (t.job === "record" && !t.rerun) s.target = t.commit;
502
585
  });
503
586
  emit({ type: "started", job: t.job, commit: t.commit, ...pr, base: t.base, runId, attempt, ...(resume && existsSync(resume) ? { resume } : {}) });
504
587
 
@@ -541,7 +624,7 @@ async function runJob(
541
624
  tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
542
625
  const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
543
626
  // An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
544
- if (t.job !== "plan" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
627
+ if (t.job === "record" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
545
628
  const run = opts.run ?? ciRun;
546
629
  const r = await run({
547
630
  ...opts.job,
@@ -549,7 +632,8 @@ async function runJob(
549
632
  cwd: opts.cwd,
550
633
  job: t.job,
551
634
  env: opts.env,
552
- forge: opts.forge,
635
+ // #4016 gap 6: a plain git server has no API, so the job runs without --forge, shaped as a GitHub job.
636
+ ...(opts.forge === "git" ? { forge: "github" as const, noForge: true } : { forge: opts.forge }),
553
637
  base: t.base,
554
638
  ...(t.job === "plan" ? { pr: t.pr } : merged !== undefined ? { pr: merged } : {}),
555
639
  commit: t.commit,
@@ -567,15 +651,31 @@ async function runJob(
567
651
  }
568
652
  }
569
653
 
654
+ /**
655
+ * The newest green commit of the target branch (#4168 step 9), after
656
+ * fetching the ci tags, or undefined when none is green or the workspace
657
+ * declares no `ci.green`.
658
+ */
659
+ function newestGreen(top: string, cwd: string, branch: string): string | undefined {
660
+ tryGit(top, ["fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`, "+refs/tags/ci/*:refs/tags/ci/*"]);
661
+ try {
662
+ const doc = lastGreen({ cwd });
663
+ return "commit" in doc && doc.commit ? doc.commit.sha : undefined;
664
+ } catch {
665
+ return undefined;
666
+ }
667
+ }
668
+
570
669
  /** One pass: observe, schedule, serve build requests, and with `wait` wait for the jobs it started. */
571
670
  export async function ciServePass(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime(), wait = true): Promise<{ events: CiServeEvent[]; jobs: CiServeJobState[] }> {
572
671
  const observed = await ciServeObserve(opts, rt);
573
672
  for (const e of observed.events) opts.log?.(e);
574
673
  const scheduled = await ciServeSchedule(opts, observed.triggers, rt);
575
674
  const builds = opts.buildCommand ? ciServeBuilds(opts, rt) : { started: [], events: [] };
675
+ const lands = opts.land && opts.buildCommand ? await ciServeLands(opts, rt) : { started: [], events: [] };
576
676
  const jobs = wait ? await Promise.all(scheduled.started) : [];
577
- if (wait) await Promise.all(builds.started);
578
- return { events: [...observed.events, ...scheduled.events, ...builds.events], jobs };
677
+ if (wait) await Promise.all([...builds.started, ...lands.started]);
678
+ return { events: [...observed.events, ...scheduled.events, ...builds.events, ...lands.events], jobs };
579
679
  }
580
680
 
581
681
  /**
@@ -630,8 +730,107 @@ export function ciServeBuilds(opts: CiServeOptions, rt: CiServeRuntime): { start
630
730
  return { started, events };
631
731
  }
632
732
 
733
+ /** The land branches on `origin`, by name, with their heads (#4168 4.4). */
734
+ function landBranches(top: string): { name: string; head: string }[] {
735
+ const r = gitStatus(top, ["ls-remote", "origin", `refs/heads/${LAND_BRANCH_PREFIX}*`]);
736
+ if (r.code !== 0) return [];
737
+ return r.out
738
+ .split("\n")
739
+ .map((line) => line.split(/\s+/))
740
+ .filter(([sha, ref]) => sha && ref?.startsWith(`refs/heads/${LAND_BRANCH_PREFIX}`))
741
+ .map(([head, ref]) => ({ name: ref!.slice(`refs/heads/${LAND_BRANCH_PREFIX}`.length), head: head! }))
742
+ .sort((a, b) => a.name.localeCompare(b.name));
743
+ }
744
+
745
+ /** Why a land's phases don't let it land, or undefined when every one passed. */
746
+ function landRefusal(verdict: CommitVerdict): string | undefined {
747
+ if (verdict.verdict === "pass") return undefined;
748
+ const failed = verdict.phases.filter((p) => p.verdict === "fail").map((p) => p.phase);
749
+ if (failed.length > 0) return `the pre-merge phase${failed.length > 1 ? "s" : ""} ${failed.join(", ")} failed`;
750
+ const open = verdict.phases.filter((p) => p.verdict !== "pass").map((p) => p.phase);
751
+ return `the build recorded no finished run for ${open.join(", ")}`;
752
+ }
753
+
754
+ /**
755
+ * Serve the land branches (#4168 4.4): for each `chant/land/<name>` head not
756
+ * in the state file, refuse it unless it descends from the target's head,
757
+ * else run `buildCommand` on it with the pre-merge phases, judge them, and
758
+ * when they pass push the target to it and delete the branch. At most
759
+ * `maxJobs` builds and jobs run at once.
760
+ */
761
+ export async function ciServeLands(opts: CiServeOptions, rt: CiServeRuntime): Promise<{ started: Promise<void>[]; events: CiServeEvent[] }> {
762
+ const place = servePlace(opts);
763
+ const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
764
+ const update = (fn: (s: CiServeState) => void): void => {
765
+ const st = readCiServeState(place.stateFile, fresh);
766
+ fn(st);
767
+ writeState(place.stateFile, st);
768
+ };
769
+ const events: CiServeEvent[] = [];
770
+ const started: Promise<void>[] = [];
771
+ const now = opts.now ?? (() => new Date());
772
+ const log = (e: CiServeEvent): void => {
773
+ events.push(e);
774
+ opts.log?.(e);
775
+ };
776
+ const phases = opts.land!.phases;
777
+ const seen = readCiServeState(place.stateFile, fresh).lands ?? {};
778
+ for (const { name, head } of landBranches(place.top)) {
779
+ const key = `land:${name}:${head}`;
780
+ const branch = `${LAND_BRANCH_PREFIX}${name}`;
781
+ if (seen[key] || rt.inflight.has(key)) continue;
782
+ if (rt.inflight.size >= (opts.maxJobs ?? 1)) {
783
+ log({ type: "land", branch, commit: head, status: "held", reason: "--max-jobs jobs run" });
784
+ continue;
785
+ }
786
+ const settle = (status: "landed" | "refused", reason?: string, warning?: string): void => {
787
+ update((st) => {
788
+ st.lands = { ...st.lands, [key]: { branch, commit: head, status, ...(reason ? { reason } : {}), startedAt: st.lands?.[key]?.startedAt ?? now().toISOString(), settledAt: now().toISOString() } };
789
+ });
790
+ log({ type: "land", branch, commit: head, status, ...(reason ? { reason } : {}), ...(warning ? { warning } : {}) });
791
+ };
792
+ const target = await opts.client.branchHead(place.branch);
793
+ if (target === undefined) {
794
+ log({ type: "error", message: `${branch} waits: the remote has no branch ${place.branch} to land it on` });
795
+ continue;
796
+ }
797
+ tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", place.branch, `+refs/heads/${branch}:refs/chant/ci-serve/land/${name}`]);
798
+ // #4168 4.4: a land only ever fast-forwards the target, so its head must descend from the target's.
799
+ if (gitStatus(place.top, ["merge-base", "--is-ancestor", target, head]).code !== 0) {
800
+ settle("refused", `${head.slice(0, 12)} does not descend from ${place.branch} at ${target.slice(0, 12)}; rebase ${branch} onto it and push again`);
801
+ continue;
802
+ }
803
+ update((st) => {
804
+ st.lands = { ...st.lands, [key]: { branch, commit: head, status: "running", startedAt: now().toISOString() } };
805
+ });
806
+ log({ type: "land", branch, commit: head, status: "started" });
807
+ const build = opts.build ?? runBuild;
808
+ const judge = opts.judgeLand ?? ((input: { commit: string; base: string }) => judgePreMerge({ cwd: opts.cwd, ...input }));
809
+ const job = (async () => {
810
+ const code = await build({ top: place.top, request: { commit: head, phases, reason: "land" }, command: opts.buildCommand! }).catch(() => 1);
811
+ if (code !== 0) return settle("refused", `the build of ${phases.join(", ")} exited ${code}`);
812
+ let refusal: string | undefined;
813
+ try {
814
+ refusal = landRefusal(await judge({ commit: head, base: target }));
815
+ } catch (err) {
816
+ refusal = `the pre-merge phases could not be judged: ${message(err)}`;
817
+ }
818
+ if (refusal) return settle("refused", refusal);
819
+ // A plain push: the remote refuses it unless it fast-forwards the target, so a target that moved meanwhile refuses the land.
820
+ const pushed = spawnSync("git", ["push", "--quiet", "origin", `${head}:refs/heads/${place.branch}`], { cwd: place.top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
821
+ if (pushed.status !== 0) return settle("refused", `the push to ${place.branch} was refused: ${(pushed.stderr ?? "").trim().split("\n").pop() ?? ""}`);
822
+ const deleted = gitStatus(place.top, ["push", "--quiet", "origin", "--delete", `refs/heads/${branch}`]);
823
+ tryGit(place.top, ["update-ref", "-d", `refs/chant/ci-serve/land/${name}`]);
824
+ settle("landed", undefined, deleted.code === 0 ? undefined : `${branch} could not be deleted on the remote; delete it by hand`);
825
+ })().finally(() => rt.inflight.delete(key));
826
+ rt.inflight.set(key, job);
827
+ started.push(job);
828
+ }
829
+ return { started, events };
830
+ }
831
+
633
832
  /** `sh -c <command>` in a fresh detached worktree of the request's commit, removed after. Resolves to the exit code. */
634
- async function runBuild(input: { top: string; request: BuildRequest; command: string }): Promise<number> {
833
+ async function runBuild(input: { top: string; request: CiServeBuildInput; command: string }): Promise<number> {
635
834
  const dir = mkdtempSync(join(tmpdir(), "chant-ci-build-"));
636
835
  try {
637
836
  execFileSync("git", ["worktree", "add", "--quiet", "--detach", dir, input.request.commit], { cwd: input.top, stdio: ["ignore", "pipe", "pipe"] });
@@ -717,6 +916,13 @@ export function formatCiServeEvent(e: CiServeEvent): string {
717
916
  if (e.status === "held") return `${what} waits: --max-jobs jobs run`;
718
917
  return e.status === "started" ? `${what} started` : e.status === "ok" ? `${what} passed` : `${what} failed${e.exitCode !== undefined ? ` with exit code ${e.exitCode}` : ""}`;
719
918
  }
919
+ case "land": {
920
+ const what = `${e.branch} at ${e.commit.slice(0, 12)}`;
921
+ if (e.status === "started") return `${what}: the pre-merge phases started`;
922
+ if (e.status === "held") return `${what} waits: ${e.reason ?? "--max-jobs jobs run"}`;
923
+ if (e.status === "refused") return `${what} was refused and left on the remote: ${e.reason ?? "no reason given"}`;
924
+ return `${what} landed${e.warning ? `; ${e.warning}` : ", and the land branch was deleted"}`;
925
+ }
720
926
  case "tick":
721
927
  return e.made.length > 0 ? `ci tick made ${e.made.join(", ")}` : "ci tick: no tag due";
722
928
  case "error":
@@ -754,8 +960,8 @@ export function runnerKeySetupCommand(ctx: CommandContext): number {
754
960
 
755
961
  const USAGE =
756
962
  "chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
757
- "chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
758
- "[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
963
+ "chant ci serve --forge forgejo|github|git --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
964
+ "[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--land] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
759
965
  "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
760
966
 
761
967
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -768,14 +974,29 @@ export function ciServeInterval(value: string | undefined): number {
768
974
  return ms;
769
975
  }
770
976
 
977
+ /** `ci.green.preMerge` where `cwd` is, for `--land` (#4168 4.4). Throws when the declaration names none. */
978
+ function preMergeOf(cwd: string): string[] {
979
+ let pre: string[] | undefined;
980
+ try {
981
+ pre = readDeclaration(locateWorkspace(cwd).tree).ci?.green?.preMerge;
982
+ } catch (err) {
983
+ throw new CiRunError(`--land reads ci.green.preMerge from the workspace declaration, which could not be read: ${message(err)}`);
984
+ }
985
+ if (!pre || pre.length === 0) throw new CiRunError("--land runs the ci.green.preMerge phases on each land branch, and the declaration names none; add them to ci.green");
986
+ return pre;
987
+ }
988
+
771
989
  /** `chant ci serve` (#4095). */
772
990
  export async function runCiServe(ctx: CommandContext): Promise<number> {
773
991
  const { args } = ctx;
774
992
  try {
775
993
  if (args.setupKey) return runnerKeySetupCommand(ctx);
776
994
  const forge = args.forge;
777
- if (forge !== "forgejo" && forge !== "github") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
778
- if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
995
+ if (forge !== "forgejo" && forge !== "github" && forge !== "git") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github|git${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
996
+ // #4016 gap 6: a plain git server has no repository name to ask an API for, so --repo only labels the state file.
997
+ if (!args.ciRepo && forge !== "git") throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
998
+ // #4168 4.4: landing runs the pre-merge phases through the build command.
999
+ if (args.ciLand && !args.ciBuildCommand) throw new CiRunError("--land runs each land branch's pre-merge phases with --build-command, so it needs one: --build-command '<line>'");
779
1000
  if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
780
1001
  for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
781
1002
  // #4168: a gate per wave gates the apply on merge, which a merge runs only when opted in.
@@ -786,6 +1007,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
786
1007
  if (!top) throw new CiRunError("ci serve runs in a clone of the repository it serves, and this directory is not in a git repository");
787
1008
  const stateDir = args.statePath ? resolve(args.statePath) : defaultCiServeStateDir(cwd);
788
1009
  const branch = args.branch ?? "main";
1010
+ const repo = args.ciRepo ?? (tryGit(top, ["remote", "get-url", "origin"]) || "origin");
1011
+ const landPhases = args.ciLand ? preMergeOf(cwd) : undefined;
789
1012
  // #4099: the host's key, checked against .chant/trust.json at the target's head before the first poll.
790
1013
  // #4156: signing is opt-in. Without ci.green.signedSince no key is quiet, and an untrusted one warns once and serves unsigned.
791
1014
  const keyed = ciServeRunnerKey({
@@ -807,25 +1030,36 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
807
1030
  // #4160: without --report-url, the declaration's ci.reportUrl.
808
1031
  const reportUrl = await reportUrlOf(args.reportUrl, cwd);
809
1032
  if (reportUrl && !args.reportUrl) console.error(`ci serve: reports and the run index go to ${reportUrl}, the declaration's ci.reportUrl`);
810
- const hostEnv = { ...process.env, GITHUB_REPOSITORY: args.ciRepo };
811
- const client = forgeFromEnv(forge, hostEnv);
812
- const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
813
- if (warning) console.error(formatWarning({ message: warning }));
814
- if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
1033
+ const hostEnv = { ...process.env, GITHUB_REPOSITORY: repo };
1034
+ let client: CiServeForge;
1035
+ let status: ReturnType<typeof ciStatusFromEnv>["status"];
1036
+ if (forge === "git") {
1037
+ // #4016 gap 6: refs only, and no commit status to set.
1038
+ client = gitCiServeForge(top);
1039
+ if (!args.noStatus) console.error(formatWarning({ message: "the git forge kind has no API, so no commit status is set; each job's result is in the state file and its report" }));
1040
+ } else {
1041
+ const api = forgeFromEnv(forge, hostEnv);
1042
+ const fromEnv = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
1043
+ if (fromEnv.warning) console.error(formatWarning({ message: fromEnv.warning }));
1044
+ status = fromEnv.status;
1045
+ if (!api.openPullRequests || !api.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
1046
+ client = { openPullRequests: api.openPullRequests.bind(api), branchHead: api.branchHead.bind(api), pullRequestFor: api.pullRequestFor.bind(api) };
1047
+ }
815
1048
  // #4168: a host that applied each merge before the upgrade hears that it no longer does.
816
- if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge applies it");
1049
+ if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge also applies each newest green commit");
817
1050
  const opts: CiServeOptions = {
818
1051
  cwd,
819
1052
  forge,
820
- repo: args.ciRepo,
1053
+ repo,
821
1054
  env: args.env,
822
- client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client), pullRequestFor: client.pullRequestFor.bind(client) },
1055
+ client,
823
1056
  stateDir,
824
1057
  ...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
825
1058
  ...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
826
1059
  ...(args.rerunFailed ? { rerunFailed: true } : {}),
827
1060
  ...(args.ciBuildCommand ? { buildCommand: args.ciBuildCommand } : {}),
828
1061
  ...(args.applyOnMerge ? { applyOnMerge: true } : {}),
1062
+ ...(landPhases ? { land: { phases: landPhases } } : {}),
829
1063
  // #4168: the tick from the runs the jobs recorded, signed with the runs' key. No ci.green, no tick.
830
1064
  tick: async () => {
831
1065
  try {
@@ -5,8 +5,8 @@
5
5
  * `trunk-curated`: a merge records the plan and deploys nothing (#4168 4.1),
6
6
  * the quick checks run before the branch moves (4.4), and a release is cut
7
7
  * from a green commit and promoted through the member's environments, gated
8
- * in the last (4.2). `trunk-curated+apply-on-merge` is the same with the apply
9
- * on merge turned back on (section 5).
8
+ * in the last (4.2). `trunk-curated+apply-on-merge` is the same, and each
9
+ * newest green commit is also applied (section 5, step 9).
10
10
  *
11
11
  * A workflow requires no security group: those are opt-in on their own
12
12
  * (./security.ts). `chant workspace check` reports what a member under a
@@ -55,8 +55,8 @@ export const CI_WORKFLOW_REQUIREMENTS = [
55
55
  { code: "ci-workflow-no-components", requirement: "the member declares a component (`*.component.ts`)", fix: "`chant ci init` writes a stub", perMember: true },
56
56
  { code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
57
57
  { code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
58
- { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the merge job (`ci run record`, or `ci run apply` under apply on merge) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
59
- { code: "ci-workflow-pipeline-differs", requirement: "the merge job matches the workflow: it records under `trunk-curated` and applies under `trunk-curated+apply-on-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
58
+ { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the record job (`ci run record`) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
59
+ { code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
60
60
  { code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
61
61
  ] as const satisfies readonly CiWorkflowRequirement[];
62
62
 
@@ -15,7 +15,9 @@
15
15
  * resolutions, for a deploy that names its release only through its
16
16
  * admission (`releaseOfDeploy`, #4043);
17
17
  * - the counted `ci/revoked/<sha>` tags, judged as `release cut` judges them
18
- * (`readRevokedCommits`, #4033), and each commit's committer time from git.
18
+ * (`readRevokedCommits`, #4033), and each commit's committer time from git;
19
+ * a composed commit (#4168 4.3) takes the earliest time of the trunk
20
+ * commits it carries.
19
21
  *
20
22
  * Outside a workspace the project's own ledger is read as one member, `null`.
21
23
  * It reads the local branch and the local tags, and never fetches.
@@ -139,11 +141,18 @@ export async function deliveryMetrics(query: MetricsQuery): Promise<DeliveryMetr
139
141
  const { readRevokedCommits } = await import("../lifecycle/release-revocation");
140
142
  const revoked = await readRevokedCommits(root);
141
143
 
144
+ const { compositionOf } = await import("../lifecycle/compose-refs");
142
145
  const commitTimes = new Map<string, number | null>();
146
+ const ownTime = (sha: string): number | null => {
147
+ const iso = tryGit(top, ["show", "-s", "--format=%cI", `${sha}^{commit}`]);
148
+ return iso ? Date.parse(iso) : null;
149
+ };
143
150
  const commitTime = (sha: string): number | null => {
144
151
  if (!commitTimes.has(sha)) {
145
- const iso = tryGit(top, ["show", "-s", "--format=%cI", `${sha}^{commit}`]);
146
- commitTimes.set(sha, iso ? Date.parse(iso) : null);
152
+ // #4168 4.3: a composed commit's lead time runs from the earliest trunk commit it carries.
153
+ const sources = compositionOf(top, sha)?.sources ?? [];
154
+ const times = sources.map(ownTime).filter((t): t is number => t !== null);
155
+ commitTimes.set(sha, times.length > 0 ? Math.min(...times) : ownTime(sha));
147
156
  }
148
157
  return commitTimes.get(sha)!;
149
158
  };
@@ -221,10 +221,11 @@ describe("chant workspace graph on the chant repo (#2557)", () => {
221
221
  // reference workspace runs its own chant workspace graph (#2551);
222
222
  // read-contract.test.ts covers that. Here both are left out, so nothing runs.
223
223
  const notRun = (JSON.parse(readFileSync(join(REPO, "chant.workspace.json"), "utf-8")) as { members: { name: string; kind: string }[] }).members
224
- .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace")
224
+ // npm (release/, #4040) is the one kind chant member; leaving it out keeps this a read with nothing to build.
225
+ .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace" && m.kind !== "chant")
225
226
  .map((m) => m.name);
226
227
 
227
- test("validates, and lists every member but the terraform one as skipped: none is kind chant", async () => {
228
+ test("validates, and lists every member but the terraform one and npm as skipped: no other is kind chant", async () => {
228
229
  const { doc, failed } = await workspaceGraph({ cwd: join(REPO, "packages", "core"), members: notRun });
229
230
  const g = result(doc);
230
231
  expectValid(g);
@@ -112,22 +112,22 @@ describe("resolveToolchain", () => {
112
112
  });
113
113
 
114
114
  describe("the chant repo's own declaration (#2557)", () => {
115
- test("declares no chant members, so build runs the example groups under one toolchain", () => {
115
+ test("build runs the one chant member, npm (release/, #4040), and the example groups under one toolchain", () => {
116
116
  const plan = planMembers("build", REPO);
117
117
  expect(plan.workspace.name).toBe("chant");
118
118
  expect(plan.unreadable).toEqual([]);
119
119
  expect(plan.groups).toHaveLength(1);
120
120
  const units = plan.groups[0].units;
121
- expect(units.every((u) => u.group)).toBe(true);
121
+ expect(units.filter((u) => !u.group).map((u) => u.id)).toEqual(["npm"]);
122
122
  expect(units.map((u) => u.id)).toContain("fixtures:test/leftness");
123
123
  expect(plan.skipped.every((s) => s.reason.code === "kind-not-run")).toBe(true);
124
124
  expect(plan.skipped.map((s) => s.name)).toContain("core");
125
125
  });
126
126
 
127
- test("graph and audit run nothing there: every member is kind other", () => {
127
+ test("graph and audit run only npm there: every other member is kind other", () => {
128
128
  for (const verb of ["graph", "audit"] as const) {
129
129
  const plan = planMembers(verb, REPO);
130
- expect(plan.groups, verb).toEqual([]);
130
+ expect(plan.groups.flatMap((g) => g.units.map((u) => u.id)), verb).toEqual(["npm"]);
131
131
  }
132
132
  });
133
133
 
@@ -164,6 +164,13 @@ describe("member command lines", () => {
164
164
  expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
165
165
  });
166
166
 
167
+ test("graph hands each member the detail tier (#4228)", () => {
168
+ expect(memberArgv("graph", unit, args({ detail: 1, live: true, overlay: true, env: "local" }))).toEqual([
169
+ "graph", ".", "--format", "ir", "--env", "local", "--live", "--overlay", "--detail", "1",
170
+ ]);
171
+ expect(memberArgv("graph", unit, args({ detail: 0 }))).toEqual(["graph", ".", "--format", "ir", "--detail", "0"]);
172
+ });
173
+
167
174
  test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
168
175
  const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
169
176
  expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
@@ -305,6 +305,9 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
305
305
  if (args.live) argv.push("--live");
306
306
  if (args.overlay) argv.push("--overlay");
307
307
  if (args.traffic) argv.push("--traffic", args.traffic);
308
+ // The detail tier too, so `workspace graph --detail 1` composes collapsed
309
+ // composites with their rolled-up status (#4228).
310
+ if (args.detail !== undefined) argv.push("--detail", String(args.detail));
308
311
  }
309
312
  return argv;
310
313
  }
@@ -69,6 +69,10 @@ describe("readNestedDocument", () => {
69
69
  "workspace", "graph", "/w/inner", "--at", "abc", "--env", "dev", "--live", "--overlay", "--traffic", "10 rps", "--no-cache",
70
70
  ]);
71
71
  });
72
+
73
+ test("passes the detail tier on (#4228)", () => {
74
+ expect(nestedArgv("/w/inner", { detail: 1 }, null)).toEqual(["workspace", "graph", "/w/inner", "--detail", "1"]);
75
+ });
72
76
  });
73
77
 
74
78
  describe("foldNested", () => {
@@ -43,6 +43,7 @@ export function nestedArgv(dir: string, args: Partial<ParsedArgs>, at: string |
43
43
  ...(args.live ? ["--live"] : []),
44
44
  ...(args.overlay ? ["--overlay"] : []),
45
45
  ...(args.traffic !== undefined ? ["--traffic", String(args.traffic)] : []),
46
+ ...(args.detail !== undefined ? ["--detail", String(args.detail)] : []),
46
47
  ...(args.noCache ? ["--no-cache"] : []),
47
48
  ];
48
49
  }