@intentius/chant 0.124.29 → 0.124.30

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 (103) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +4 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -5
  5. package/dist/cli/handlers/pr.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +30 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +49 -0
  10. package/dist/cli/registry.d.ts +17 -1
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/pr-pipeline.d.ts +24 -0
  13. package/dist/components/pr-pipeline.d.ts.map +1 -1
  14. package/dist/components/pr-pipeline.js +16 -0
  15. package/dist/components/pr-run.d.ts +20 -2
  16. package/dist/components/pr-run.d.ts.map +1 -1
  17. package/dist/components/pr-run.js +26 -13
  18. package/dist/components/wave-plan.d.ts +11 -0
  19. package/dist/components/wave-plan.d.ts.map +1 -1
  20. package/dist/components/wave-plan.js +26 -0
  21. package/dist/lifecycle/release-ledger.d.ts +8 -3
  22. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.js +2 -0
  24. package/dist/lifecycle/run-ledger.d.ts +1 -1
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/lifecycle/run-ledger.js +9 -0
  27. package/dist/op/activities/activity-contracts.d.ts +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  29. package/dist/op/activities/activity-contracts.js +1 -0
  30. package/dist/op/activities/shell.d.ts +6 -0
  31. package/dist/op/activities/shell.d.ts.map +1 -1
  32. package/dist/op/activities/shell.js +1 -1
  33. package/dist/op/local-executor.d.ts +23 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +9 -3
  36. package/dist/op/runtime.d.ts +27 -0
  37. package/dist/op/runtime.d.ts.map +1 -1
  38. package/dist/op/runtime.js +20 -0
  39. package/dist/pr-forge.d.ts +23 -0
  40. package/dist/pr-forge.d.ts.map +1 -1
  41. package/dist/pr-forge.js +44 -0
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +7 -0
  45. package/dist/run-event-stream.d.ts +2 -2
  46. package/dist/run-event-stream.d.ts.map +1 -1
  47. package/dist/run-event-stream.js +2 -2
  48. package/dist/run-event.d.ts +15 -0
  49. package/dist/run-event.d.ts.map +1 -1
  50. package/dist/run-event.js +2 -0
  51. package/dist/wave-runtime.mjs +171 -171
  52. package/dist/workspace/ci-green-forge.d.ts +2 -0
  53. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  54. package/dist/workspace/ci-green.d.ts +2 -0
  55. package/dist/workspace/ci-green.d.ts.map +1 -1
  56. package/dist/workspace/ci-green.js +4 -2
  57. package/dist/workspace/ci-run.d.ts +222 -0
  58. package/dist/workspace/ci-run.d.ts.map +1 -0
  59. package/dist/workspace/ci-run.js +464 -0
  60. package/dist/workspace/ci-serve.d.ts +214 -0
  61. package/dist/workspace/ci-serve.d.ts.map +1 -0
  62. package/dist/workspace/ci-serve.js +412 -0
  63. package/dist/workspace/pr-report.schema.json +5 -0
  64. package/dist/workspace/run-event.schema.json +14 -3
  65. package/dist/workspace/status-stewards.d.ts +2 -0
  66. package/dist/workspace/status-stewards.d.ts.map +1 -1
  67. package/dist/workspace/status-stewards.js +1 -0
  68. package/dist/workspace/status.schema.json +12 -1
  69. package/package.json +1 -1
  70. package/src/cli/handlers/build.test.ts +15 -0
  71. package/src/cli/handlers/build.ts +4 -1
  72. package/src/cli/handlers/fan-out.test.ts +82 -0
  73. package/src/cli/handlers/fan-out.ts +34 -5
  74. package/src/cli/handlers/pr.ts +5 -0
  75. package/src/cli/main.ts +30 -0
  76. package/src/cli/parse-args.ts +35 -0
  77. package/src/cli/registry.ts +17 -1
  78. package/src/components/pr-pipeline.ts +32 -0
  79. package/src/components/pr-run.test.ts +127 -0
  80. package/src/components/pr-run.ts +32 -12
  81. package/src/components/wave-plan.ts +30 -0
  82. package/src/lifecycle/release-ledger.ts +9 -3
  83. package/src/lifecycle/run-ledger.ts +7 -1
  84. package/src/op/activities/activity-contracts.ts +1 -0
  85. package/src/op/activities/shell.ts +7 -1
  86. package/src/op/local-executor.ts +27 -4
  87. package/src/op/runtime.ts +42 -0
  88. package/src/pr-forge.test.ts +25 -0
  89. package/src/pr-forge.ts +62 -0
  90. package/src/pr-loop.test.ts +13 -1
  91. package/src/pr-loop.ts +16 -0
  92. package/src/run-event-stream.ts +4 -3
  93. package/src/run-event.ts +17 -0
  94. package/src/workspace/ci-green-forge.ts +2 -0
  95. package/src/workspace/ci-green.ts +6 -2
  96. package/src/workspace/ci-run.test.ts +416 -0
  97. package/src/workspace/ci-run.ts +571 -0
  98. package/src/workspace/ci-serve.test.ts +268 -0
  99. package/src/workspace/ci-serve.ts +521 -0
  100. package/src/workspace/pr-report.schema.json +5 -0
  101. package/src/workspace/run-event.schema.json +14 -3
  102. package/src/workspace/status-stewards.ts +3 -0
  103. package/src/workspace/status.schema.json +12 -1
@@ -0,0 +1,521 @@
1
+ /**
2
+ * `chant ci serve` (#4095, epic #4101): the chant runner's trigger loop on one
3
+ * host. It polls the forge for the open pull requests into the target branch
4
+ * and for the branch's head, and runs the pull-request jobs with
5
+ * {@link ciRun} (./ci-run.ts): `plan` for each new pull request head, and
6
+ * `apply` for each new commit on the target branch, measured from the head
7
+ * the previous poll saw (the push's `before`).
8
+ *
9
+ * - Dedupe. A state file in the state directory keeps every job it started,
10
+ * keyed by job and commit (`plan:<pr>:<sha>`, `apply:<sha>`), with how it
11
+ * ended, and the target head the last apply was measured to. A job already
12
+ * in it is not started again, unless it failed and `rerunFailed` asks for
13
+ * it: the forge's "re-run failed jobs".
14
+ * - One apply per group. An apply takes the git-ref lease (../lifecycle/lease.ts,
15
+ * the one `chant operator` holds) named for `prApplyGroup`, renews it while
16
+ * it runs and releases it after. An apply that finds it held waits for a
17
+ * later poll, as a forge's concurrency group queues it.
18
+ * - Resume records. Each apply gets `<state>/resume/<prApplyRecordKey>.json`
19
+ * as its `--resume` record, so a re-run of a failed apply at the same
20
+ * commit finishes under the approval it has. This replaces the forge's CI
21
+ * cache.
22
+ * - Capacity. At most `maxJobs` jobs run at once. A job over the limit is not
23
+ * recorded, so a later poll starts it.
24
+ *
25
+ * Host mode only: each job runs in a fresh worktree on this host as this
26
+ * user, with the clean environment `ciRun` gives it. A pull request from a
27
+ * fork is never planned, and with `authors` only those people's pull requests
28
+ * are. Container isolation is #4015.
29
+ *
30
+ * A webhook is not here yet. {@link ciServeSchedule} takes triggers, and
31
+ * {@link ciServeObserve} is the poll that makes them; a webhook handler would
32
+ * turn a checked payload into the same triggers and schedule them.
33
+ */
34
+
35
+ import { execFileSync } from "node:child_process";
36
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
37
+ import { isAbsolute, join, resolve } from "node:path";
38
+ import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
39
+ import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
40
+ import type { CommandContext } from "../cli/registry";
41
+ import { formatError, formatWarning } from "../cli/format";
42
+ import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord } from "../lifecycle/lease";
43
+ import { parseDuration } from "../op/duration";
44
+ import { CI_JOB_OPS, ciJobPlace, ciRun, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
45
+ import { RUNNER_KEY_ENV } from "./ci-green";
46
+ import { gitTop } from "./tree";
47
+
48
+ /** The forges `ci serve` polls. GitLab is later. */
49
+ export type CiServeForgeKind = "github" | "forgejo";
50
+
51
+ /** What `ci serve` reads from the forge: the two calls GitHub's and Forgejo's clients have (../pr-forge.ts). */
52
+ export interface CiServeForge {
53
+ openPullRequests(base: string): Promise<OpenPullRequest[]>;
54
+ branchHead(branch: string): Promise<string | undefined>;
55
+ }
56
+
57
+ /** A job the forge's state calls for: the plan of a pull request's head, or the apply of a target-branch commit. */
58
+ export type CiServeTrigger =
59
+ | { job: "plan"; commit: string; base: string; pr: number; rerun?: boolean }
60
+ | { job: "apply"; commit: string; base: string; rerun?: boolean };
61
+
62
+ /** One job in the state file. */
63
+ export interface CiServeJobState {
64
+ job: CiJobName;
65
+ commit: string;
66
+ base: string;
67
+ pr?: number;
68
+ /** `running` while it runs; a `running` entry no live process holds is taken as failed. */
69
+ status: "running" | "ok" | "fail";
70
+ runId: string;
71
+ attempts: number;
72
+ startedAt: string;
73
+ settledAt?: string;
74
+ /** Why it failed, in a line. */
75
+ failure?: string;
76
+ /** Where its report was copied. */
77
+ report?: string;
78
+ }
79
+
80
+ /** The state file, `<state>/<prApplyGroup>.json`. */
81
+ export interface CiServeState {
82
+ schema: 1;
83
+ forge: CiServeForgeKind;
84
+ repo: string;
85
+ env: string;
86
+ branch: string;
87
+ member?: string;
88
+ /** The target branch's head the last apply was measured to, or the head the first poll found. */
89
+ target?: string;
90
+ jobs: Record<string, CiServeJobState>;
91
+ }
92
+
93
+ /** What a pass did, in order, for the log. */
94
+ export type CiServeEvent =
95
+ | { type: "baseline"; commit: string }
96
+ | { type: "no-branch"; branch: string }
97
+ | { type: "skipped"; pr: number; reason: "fork" | "author"; author?: string }
98
+ | { type: "held"; job: CiJobName; commit: string; pr?: number; reason: "capacity" | "lease"; heldBy?: string }
99
+ | { type: "started"; job: CiJobName; commit: string; pr?: number; base: string; runId: string; attempt: number; resume?: string }
100
+ | { type: "settled"; job: CiJobName; commit: string; pr?: number; status: "ok" | "fail"; failure?: string; report?: string }
101
+ | { type: "error"; message: string };
102
+
103
+ /** The `ciRun` request fields `ci serve` passes through to every job: the generator's options and the runner's. */
104
+ export type CiServeJobOptions = Omit<CiRunRequest, "cwd" | "job" | "env" | "forge" | "base" | "pr" | "commit" | "output" | "resumeRecord" | "runId">;
105
+
106
+ export interface CiServeOptions {
107
+ /** The host's clone, or a workspace member's directory in it. */
108
+ cwd: string;
109
+ forge: CiServeForgeKind;
110
+ /** `owner/name`. */
111
+ repo: string;
112
+ env: string;
113
+ client: CiServeForge;
114
+ /** Default `<git common dir>/chant/ci-serve`. */
115
+ stateDir?: string;
116
+ /** Default 1. */
117
+ maxJobs?: number;
118
+ /** Plan only pull requests these logins opened, without case. Default anyone with a branch in the repository. */
119
+ authors?: readonly string[];
120
+ /** Run again the failed apply at the target's head, and the failed plans of open heads. */
121
+ rerunFailed?: boolean;
122
+ /** Passed to each job. `job.hostEnv` gets `GITHUB_REPOSITORY` set to `repo`. */
123
+ job?: CiServeJobOptions;
124
+ /** This process's lease identity. Default `currentHolderId()`. */
125
+ holder?: string;
126
+ leaseTtlMs?: number;
127
+ log?: (event: CiServeEvent) => void;
128
+ now?: () => Date;
129
+ /** Runs a job. Default {@link ciRun}. */
130
+ run?: (req: CiRunRequest) => Promise<CiRunResult>;
131
+ }
132
+
133
+ /** The jobs this process runs now, by state key, shared by every pass of one loop. */
134
+ export interface CiServeRuntime {
135
+ inflight: Map<string, Promise<CiServeJobState>>;
136
+ }
137
+
138
+ export const newCiServeRuntime = (): CiServeRuntime => ({ inflight: new Map() });
139
+
140
+ /** The state key of a job. */
141
+ export function ciServeKey(t: { job: CiJobName; commit: string; pr?: number }): string {
142
+ return t.job === "plan" ? `plan:${t.pr}:${t.commit}` : `apply:${t.commit}`;
143
+ }
144
+
145
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
146
+
147
+ function tryGit(cwd: string, args: string[]): string | undefined {
148
+ try {
149
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ /** Where a serve keeps its state when `--state` names nowhere: beside the run events, in the repository's common git directory. */
156
+ export function defaultCiServeStateDir(cwd: string): string {
157
+ const common = tryGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
158
+ if (!common) throw new CiRunError("ci serve runs in a clone of the repository it serves, and this directory is not in a git repository");
159
+ return join(common, "chant", "ci-serve");
160
+ }
161
+
162
+ /** What a serve works out once, before its first pass. */
163
+ interface ServePlace {
164
+ top: string;
165
+ member?: string;
166
+ branch: string;
167
+ group: string;
168
+ stateDir: string;
169
+ stateFile: string;
170
+ }
171
+
172
+ function servePlace(opts: CiServeOptions): ServePlace {
173
+ const cwd = resolve(opts.cwd);
174
+ const top = gitTop(cwd);
175
+ 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");
176
+ const { member } = ciJobPlace(cwd, top);
177
+ const group = prApplyGroup(opts.env, member);
178
+ const stateDir = opts.stateDir ? resolve(cwd, opts.stateDir) : defaultCiServeStateDir(cwd);
179
+ return { top, ...(member ? { member } : {}), branch: opts.job?.loop?.branch ?? "main", group, stateDir, stateFile: join(stateDir, `${group}.json`) };
180
+ }
181
+
182
+ /** The state file, or a new one. */
183
+ export function readCiServeState(file: string, fresh: Omit<CiServeState, "schema" | "jobs">): CiServeState {
184
+ if (existsSync(file)) {
185
+ const s = JSON.parse(readFileSync(file, "utf-8")) as CiServeState;
186
+ if (s.schema !== 1 || typeof s.jobs !== "object" || s.jobs === null) throw new CiRunError(`${file} is not a ci serve state file this chant reads`);
187
+ return s;
188
+ }
189
+ return { schema: 1, ...fresh, jobs: {} };
190
+ }
191
+
192
+ function writeState(file: string, state: CiServeState): void {
193
+ mkdirSync(resolve(file, ".."), { recursive: true });
194
+ const tmp = `${file}.${process.pid}.tmp`;
195
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
196
+ renameSync(tmp, file);
197
+ }
198
+
199
+ /** A job entry that can run again with `rerunFailed`: it failed, or it says `running` and no job of this process runs it. */
200
+ const failedEntry = (entry: CiServeJobState | undefined, key: string, rt: CiServeRuntime): entry is CiServeJobState =>
201
+ !!entry && (entry.status === "fail" || (entry.status === "running" && !rt.inflight.has(key)));
202
+
203
+ /**
204
+ * One poll of the forge, as triggers: an apply when the target's head moved
205
+ * since the last apply (none on the first poll, which only notes the head),
206
+ * then a plan for each open pull request head not yet planned, oldest pull
207
+ * request first. A job already in the state file is left out unless it
208
+ * failed and `rerunFailed` is set. The first poll's head is written to the
209
+ * state file.
210
+ */
211
+ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime()): Promise<{ triggers: CiServeTrigger[]; events: CiServeEvent[] }> {
212
+ const place = servePlace(opts);
213
+ const state = readCiServeState(place.stateFile, { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) });
214
+ const triggers: CiServeTrigger[] = [];
215
+ const events: CiServeEvent[] = [];
216
+ const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
217
+
218
+ if (head === undefined) {
219
+ events.push({ type: "no-branch", branch: place.branch });
220
+ } else if (state.target === undefined) {
221
+ // What the branch held before this runner watched it was applied by whatever ran then.
222
+ state.target = head;
223
+ writeState(place.stateFile, state);
224
+ events.push({ type: "baseline", commit: head });
225
+ } else if (head !== state.target) {
226
+ const entry = state.jobs[ciServeKey({ job: "apply", commit: head })];
227
+ if (!entry) triggers.push({ job: "apply", commit: head, base: state.target });
228
+ else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job: "apply", commit: head, base: entry.base, rerun: true });
229
+ } else {
230
+ const key = ciServeKey({ job: "apply", commit: head });
231
+ const entry = state.jobs[key];
232
+ if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "apply", commit: head, base: entry.base, rerun: true });
233
+ }
234
+
235
+ const authors = opts.authors?.length ? new Set(opts.authors.map((a) => a.toLowerCase())) : undefined;
236
+ for (const pr of prs) {
237
+ if (pr.fork) {
238
+ events.push({ type: "skipped", pr: pr.number, reason: "fork" });
239
+ continue;
240
+ }
241
+ if (authors && !authors.has((pr.author ?? "").toLowerCase())) {
242
+ events.push({ type: "skipped", pr: pr.number, reason: "author", ...(pr.author ? { author: pr.author } : {}) });
243
+ continue;
244
+ }
245
+ const key = ciServeKey({ job: "plan", commit: pr.headSha, pr: pr.number });
246
+ const entry = state.jobs[key];
247
+ const base = pr.baseSha ?? head;
248
+ if (!base) continue;
249
+ if (!entry) triggers.push({ job: "plan", commit: pr.headSha, base, pr: pr.number });
250
+ else if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "plan", commit: pr.headSha, base: entry.base, pr: pr.number, rerun: true });
251
+ }
252
+ return { triggers, events };
253
+ }
254
+
255
+ /** What {@link ciServeSchedule} did with the triggers. */
256
+ export interface CiServeScheduled {
257
+ /** Each job started, settling to its state entry. */
258
+ started: Promise<CiServeJobState>[];
259
+ events: CiServeEvent[];
260
+ }
261
+
262
+ /**
263
+ * Start the triggers' jobs, at most `maxJobs` at once with the jobs this
264
+ * process already runs. An apply first takes its group's lease. A trigger
265
+ * that can't start (over capacity, lease held, or already running here) is
266
+ * reported and not recorded, so the next poll finds it again.
267
+ */
268
+ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly CiServeTrigger[], rt: CiServeRuntime = newCiServeRuntime()): Promise<CiServeScheduled> {
269
+ const place = servePlace(opts);
270
+ const max = opts.maxJobs ?? 1;
271
+ const holder = opts.holder ?? currentHolderId();
272
+ const ttlMs = opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
273
+ const now = opts.now ?? (() => new Date());
274
+ const log = opts.log ?? (() => {});
275
+ const events: CiServeEvent[] = [];
276
+ const emit = (e: CiServeEvent): void => {
277
+ events.push(e);
278
+ log(e);
279
+ };
280
+ const started: Promise<CiServeJobState>[] = [];
281
+ const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
282
+ const update = (fn: (s: CiServeState) => void): void => {
283
+ const s = readCiServeState(place.stateFile, fresh);
284
+ fn(s);
285
+ writeState(place.stateFile, s);
286
+ };
287
+
288
+ for (const t of triggers) {
289
+ const key = ciServeKey(t);
290
+ const pr = t.job === "plan" ? { pr: t.pr } : {};
291
+ if (rt.inflight.has(key)) continue;
292
+ if (rt.inflight.size >= max) {
293
+ emit({ type: "held", job: t.job, commit: t.commit, ...pr, reason: "capacity" });
294
+ continue;
295
+ }
296
+ let lease: LeaseRecord | undefined;
297
+ if (t.job === "apply") {
298
+ let got: Awaited<ReturnType<typeof acquireLease>>;
299
+ try {
300
+ got = await acquireLease(place.group, holder, { cwd: opts.cwd, ttlMs, mode: "claim", requirePush: true, now });
301
+ } catch (err) {
302
+ emit({ type: "error", message: `the ${place.group} lease could not be taken: ${message(err)}` });
303
+ continue;
304
+ }
305
+ if (!got.acquired || !got.lease) {
306
+ emit({ type: "held", job: "apply", commit: t.commit, reason: "lease", ...(got.heldBy ? { heldBy: got.heldBy.holder } : {}) });
307
+ continue;
308
+ }
309
+ lease = got.lease;
310
+ }
311
+
312
+ const previous = readCiServeState(place.stateFile, fresh).jobs[key];
313
+ const attempt = (previous?.attempts ?? 0) + 1;
314
+ const runId = `chant-serve-${now().getTime()}-${t.job}-${t.commit.slice(0, 8)}`;
315
+ const resume = t.job === "apply" ? join(place.stateDir, "resume", `${prApplyRecordKey(opts.env, t.commit, place.member)}.json`) : undefined;
316
+ const report = join(place.stateDir, "reports", place.group, `${CI_JOB_OPS[t.job]}${t.job === "plan" ? `-${t.pr}` : ""}-${t.commit.slice(0, 12)}`);
317
+ const entry: CiServeJobState = { job: t.job, commit: t.commit, base: t.base, ...pr, status: "running", runId, attempts: attempt, startedAt: now().toISOString() };
318
+ update((s) => {
319
+ s.jobs[key] = entry;
320
+ if (t.job === "apply" && !t.rerun) s.target = t.commit;
321
+ });
322
+ emit({ type: "started", job: t.job, commit: t.commit, ...pr, base: t.base, runId, attempt, ...(resume && existsSync(resume) ? { resume } : {}) });
323
+
324
+ const job = runJob(opts, place, t, { runId, report, ...(resume ? { resume } : {}) }, lease ? { lease, holder, ttlMs, now } : undefined).then((done) => {
325
+ rt.inflight.delete(key);
326
+ const settled: CiServeJobState = { ...entry, ...done, settledAt: now().toISOString() };
327
+ try {
328
+ update((s) => {
329
+ s.jobs[key] = settled;
330
+ });
331
+ } catch (err) {
332
+ emit({ type: "error", message: `${key} settled, and the state file could not be written: ${message(err)}` });
333
+ }
334
+ emit({ type: "settled", job: t.job, commit: t.commit, ...pr, status: settled.status === "ok" ? "ok" : "fail", ...(settled.failure ? { failure: settled.failure } : {}), ...(settled.report ? { report: settled.report } : {}) });
335
+ return settled;
336
+ });
337
+ rt.inflight.set(key, job);
338
+ started.push(job);
339
+ }
340
+ return { started, events };
341
+ }
342
+
343
+ /** Run one job: fetch its commit, run it with `ciRun`, keep the lease renewed while it runs, and release it after. Never throws. */
344
+ async function runJob(
345
+ opts: CiServeOptions,
346
+ place: ServePlace,
347
+ t: CiServeTrigger,
348
+ paths: { runId: string; report: string; resume?: string },
349
+ held: { lease: LeaseRecord; holder: string; ttlMs: number; now: () => Date } | undefined,
350
+ ): Promise<Pick<CiServeJobState, "status" | "failure" | "report">> {
351
+ const renew = held
352
+ ? setInterval(() => {
353
+ void acquireLease(place.group, held.holder, { cwd: opts.cwd, ttlMs: held.ttlMs, mode: "renew", token: held.lease.token, now: held.now }).catch(() => undefined);
354
+ }, Math.max(1000, Math.floor(held.ttlMs / 3)))
355
+ : undefined;
356
+ renew?.unref?.();
357
+ try {
358
+ // The pull request's head is on the forge's refs/pull/<n>/head, the target's on the branch.
359
+ tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
360
+ const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
361
+ const run = opts.run ?? ciRun;
362
+ const r = await run({
363
+ ...opts.job,
364
+ hostEnv,
365
+ cwd: opts.cwd,
366
+ job: t.job,
367
+ env: opts.env,
368
+ forge: opts.forge,
369
+ base: t.base,
370
+ ...(t.job === "plan" ? { pr: t.pr } : {}),
371
+ commit: t.commit,
372
+ output: paths.report,
373
+ runId: paths.runId,
374
+ ...(paths.resume ? { resumeRecord: paths.resume } : {}),
375
+ });
376
+ const why = r.record.failure ? `${r.record.failure.code}: ${r.record.failure.reason}` : r.failure ? `${r.failure.phase}: ${r.failure.error.split("\n")[0]}` : undefined;
377
+ return { status: r.status === "ok" ? "ok" : "fail", ...(why && r.status !== "ok" ? { failure: why } : {}), ...(r.report ? { report: r.report } : {}) };
378
+ } catch (err) {
379
+ return { status: "fail", failure: message(err) };
380
+ } finally {
381
+ if (renew) clearInterval(renew);
382
+ if (held) await releaseLease(place.group, held.holder, held.lease.token, { cwd: opts.cwd }).catch(() => false);
383
+ }
384
+ }
385
+
386
+ /** One pass: observe, schedule, and with `wait` wait for the jobs it started. */
387
+ export async function ciServePass(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime(), wait = true): Promise<{ events: CiServeEvent[]; jobs: CiServeJobState[] }> {
388
+ const observed = await ciServeObserve(opts, rt);
389
+ for (const e of observed.events) opts.log?.(e);
390
+ const scheduled = await ciServeSchedule(opts, observed.triggers, rt);
391
+ const jobs = wait ? await Promise.all(scheduled.started) : [];
392
+ return { events: [...observed.events, ...scheduled.events], jobs };
393
+ }
394
+
395
+ /**
396
+ * The loop: a pass every `intervalMs` until `signal` aborts, jobs running
397
+ * across passes up to `maxJobs`. A pass the forge fails is logged and the
398
+ * loop goes on. On abort it waits for the jobs that run.
399
+ */
400
+ export async function ciServeLoop(opts: CiServeOptions & { intervalMs: number; signal?: AbortSignal }): Promise<void> {
401
+ const rt = newCiServeRuntime();
402
+ while (!opts.signal?.aborted) {
403
+ try {
404
+ await ciServePass(opts, rt, false);
405
+ } catch (err) {
406
+ opts.log?.({ type: "error", message: message(err) });
407
+ }
408
+ await new Promise<void>((done) => {
409
+ const timer = setTimeout(done, opts.intervalMs);
410
+ opts.signal?.addEventListener("abort", () => {
411
+ clearTimeout(timer);
412
+ done();
413
+ }, { once: true });
414
+ });
415
+ }
416
+ await Promise.allSettled([...rt.inflight.values()]);
417
+ }
418
+
419
+ /** One line for an event, as `ci serve` prints it. */
420
+ export function formatCiServeEvent(e: CiServeEvent): string {
421
+ const at = (job: CiJobName, commit: string, pr?: number): string => `${CI_JOB_OPS[job]} ${commit.slice(0, 12)}${pr !== undefined ? ` (pull request ${pr})` : ""}`;
422
+ switch (e.type) {
423
+ case "baseline":
424
+ return `target at ${e.commit.slice(0, 12)}; the next commit there is applied`;
425
+ case "no-branch":
426
+ return `the forge has no branch ${e.branch}`;
427
+ case "skipped":
428
+ return e.reason === "fork" ? `pull request ${e.pr} is from a fork, and host mode plans none` : `pull request ${e.pr} is by ${e.author ?? "an unknown author"}, who is not an --author`;
429
+ case "held":
430
+ return `${at(e.job, e.commit, e.pr)} waits: ${e.reason === "capacity" ? "--max-jobs jobs run" : `the apply lease is held${e.heldBy ? ` by ${e.heldBy}` : ""}`}`;
431
+ case "started":
432
+ return `${at(e.job, e.commit, e.pr)} started, run ${e.runId}, attempt ${e.attempt}${e.resume ? ", resuming" : ""}`;
433
+ case "settled":
434
+ return `${at(e.job, e.commit, e.pr)} ${e.status === "ok" ? "passed" : `failed${e.failure ? `: ${e.failure}` : ""}`}${e.report ? `; report ${e.report}` : ""}`;
435
+ case "error":
436
+ return `error: ${e.message}`;
437
+ }
438
+ }
439
+
440
+ // ── The command ──────────────────────────────────────────────────────────────
441
+
442
+ const USAGE =
443
+ "chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
444
+ "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--branch <name>] [--events-json] [--report-url <url>] " +
445
+ "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--key <runner key.pem>]";
446
+
447
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
448
+
449
+ /** `--interval`: seconds, or a duration such as `30s` or `2m`. Default 30 seconds. */
450
+ export function ciServeInterval(value: string | undefined): number {
451
+ if (value === undefined) return 30_000;
452
+ const ms = /^\d+$/.test(value) ? Number(value) * 1000 : parseDuration(value);
453
+ if (!(ms >= 1000)) throw new CiRunError(`--interval takes seconds or a duration of a second or more, not ${JSON.stringify(value)}`);
454
+ return ms;
455
+ }
456
+
457
+ /** `chant ci serve` (#4095). */
458
+ export async function runCiServe(ctx: CommandContext): Promise<number> {
459
+ const { args } = ctx;
460
+ try {
461
+ const forge = args.forge;
462
+ if (forge !== "forgejo" && forge !== "github") throw new CiRunError(`--forge names the forge ci serve polls: --forge forgejo|github${forge ? `, not ${JSON.stringify(forge)}` : ""}`);
463
+ if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
464
+ if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
465
+ 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)}`);
466
+ const intervalMs = ciServeInterval(args.interval);
467
+ const fromFlag = args.key !== undefined;
468
+ const runnerKey = fromFlag ? readFileSync(resolve(args.key!), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
469
+ const hostEnv = { ...process.env, GITHUB_REPOSITORY: args.ciRepo };
470
+ const client = forgeFromEnv(forge, hostEnv);
471
+ if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
472
+ const opts: CiServeOptions = {
473
+ cwd: process.cwd(),
474
+ forge,
475
+ repo: args.ciRepo,
476
+ env: args.env,
477
+ client: { openPullRequests: client.openPullRequests.bind(client), branchHead: client.branchHead.bind(client) },
478
+ ...(args.statePath ? { stateDir: isAbsolute(args.statePath) ? args.statePath : resolve(args.statePath) } : {}),
479
+ ...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
480
+ ...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
481
+ ...(args.rerunFailed ? { rerunFailed: true } : {}),
482
+ job: {
483
+ loop: {
484
+ ...(args.gate ? { gate: args.gate } : {}),
485
+ ...(args.branch ? { branch: args.branch } : {}),
486
+ ...(args.eventsJson ? { events: true } : {}),
487
+ ...(args.reportUrl ? { reportUrl: args.reportUrl } : {}),
488
+ ...(args.prBuild ? { build: true } : {}),
489
+ ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
490
+ },
491
+ ...(args.image ? { image: args.image } : {}),
492
+ ...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
493
+ ...(args.ciChant ? { chant: args.ciChant } : {}),
494
+ ...(args.passEnv?.length ? { passEnv: args.passEnv } : {}),
495
+ ...(runnerKey !== undefined ? { runnerKey, runnerKeyFromEnv: !fromFlag } : {}),
496
+ ...(args.eventsJson ? { runEvents: (line: string) => void process.stdout.write(line) } : {}),
497
+ hostEnv,
498
+ stepOutput: (line) => void process.stderr.write(`${line}\n`),
499
+ },
500
+ log: (e) => console.error(e.type === "error" ? formatWarning({ message: formatCiServeEvent(e) }) : `ci serve: ${formatCiServeEvent(e)}`),
501
+ };
502
+ if (args.once) {
503
+ const pass = await ciServePass(opts);
504
+ return pass.jobs.some((j) => j.status !== "ok") ? 1 : 0;
505
+ }
506
+ const stop = new AbortController();
507
+ const onSignal = (): void => stop.abort();
508
+ process.once("SIGINT", onSignal);
509
+ process.once("SIGTERM", onSignal);
510
+ try {
511
+ await ciServeLoop({ ...opts, intervalMs, signal: stop.signal });
512
+ } finally {
513
+ process.off("SIGINT", onSignal);
514
+ process.off("SIGTERM", onSignal);
515
+ }
516
+ return 0;
517
+ } catch (err) {
518
+ console.error(formatError({ message: `chant ci serve: ${message(err)}`, hint: USAGE }));
519
+ return 1;
520
+ }
521
+ }
@@ -49,6 +49,11 @@
49
49
  "applied": { "type": "array", "items": { "type": "string" }, "description": "Components the earlier attempt applied, which this run did not run again." }
50
50
  }
51
51
  },
52
+ "deployedOutputs": {
53
+ "type": "object",
54
+ "description": "Added within contract version 1 by #4104, written by chant 0.124.30 and newer. Per dependency a member was planned against, the names of the outputs read from the deployed estate through its capability's outputs, sorted. A dependency outside the change is listed here too, and one whose stack is not deployed yet lists []. Outputs given with --seed-outputs, or carried from an earlier attempt's record, are not listed. Absent when no dependency was read, and on a resumed apply.",
55
+ "additionalProperties": { "type": "array", "items": { "type": "string" } }
56
+ },
52
57
  "approval": {
53
58
  "type": "object",
54
59
  "required": ["status"],
@@ -50,8 +50,8 @@
50
50
  "required": ["forge"],
51
51
  "properties": {
52
52
  "forge": {
53
- "enum": ["github", "gitlab", "op", "local"],
54
- "description": "github: a GitHub Actions run, also the spelling Forgejo Actions uses. gitlab: a CI pipeline. op: a steward's run, named by its Op run id. local: a run on the machine that started it, outside CI."
53
+ "enum": ["github", "gitlab", "op", "local", "chant"],
54
+ "description": "github: a GitHub Actions run, also the spelling Forgejo Actions uses. gitlab: a CI pipeline. op: a steward's run, named by its Op run id. local: a run on the machine that started it, outside CI. chant: a pull-request job the chant runner ran (chant ci run, #4094), named by its Op run id; added within contract version 1 and written by chant 0.124.30 and newer, so a reader that switches on forge keeps a case for a value it does not know."
55
55
  },
56
56
  "repo": { "type": "string", "description": "The repository or project slug on the forge, when the environment named one." },
57
57
  "url": { "type": "string", "description": "A link to the run, when the environment gave enough to build one." }
@@ -95,7 +95,18 @@
95
95
  "type": { "const": "run-settle" },
96
96
  "status": { "$ref": "#/$defs/settleStatus" },
97
97
  "durationMs": { "type": ["integer", "null"], "minimum": 0, "description": "From run-start to now, or null when the writer did not time it." },
98
- "error": { "type": "string", "description": "With status failed, what failed, when the writer knows." }
98
+ "error": { "type": "string", "description": "With status failed, what failed, when the writer knows." },
99
+ "failure": {
100
+ "type": "object",
101
+ "required": ["code", "reason"],
102
+ "description": "Added within contract version 1 by #4094, written by chant 0.124.30 and newer. With status failed: why, as the run record's failure says. code is a pull-request report's refusal or status (not-approved, plan-changed, not-a-reviewer, no-pull-request, plan-failed, failed, refused) or command-failed, setup-failed or build-failed, and a reader keeps a case for a code it does not know. reason is one line for a person, phase the phase that failed, exitCode the failed command's exit code. chant ci run writes it on every failed job.",
103
+ "properties": {
104
+ "code": { "type": "string", "minLength": 1 },
105
+ "reason": { "type": "string" },
106
+ "phase": { "type": "string" },
107
+ "exitCode": { "type": "integer" }
108
+ }
109
+ }
99
110
  }
100
111
  }
101
112
  ]
@@ -82,6 +82,8 @@ export interface StatusStewardRun {
82
82
  gitSha?: string;
83
83
  /** The CI run the Op ran in (#4020), when it ran in CI: the shape a release's `runOrigin` has. */
84
84
  runOrigin?: OpRunRecord["runOrigin"];
85
+ /** Why the run failed (#4094), for a failed run whose record says. Absent otherwise, and on a record written before chant 0.124.30. */
86
+ failure?: OpRunRecord["failure"];
85
87
  /**
86
88
  * The gate the run stopped at, for a `gated` run: its name, when the run
87
89
  * stopped there, the op it is recorded under (the Op's own, or the
@@ -417,6 +419,7 @@ export async function readMemberStewards(
417
419
  ended: newest.ended,
418
420
  ...(newest.gitSha ? { gitSha: newest.gitSha } : {}),
419
421
  ...(newest.runOrigin ? { runOrigin: newest.runOrigin } : {}),
422
+ ...(newest.failure ? { failure: newest.failure } : {}),
420
423
  gate: newest.gate ? gateOf(newest.gate, op.name) : null,
421
424
  point: point?.wait ?? null,
422
425
  phases: newest.phases.map((p) => ({
@@ -449,11 +449,22 @@
449
449
  "required": ["forge"],
450
450
  "description": "Added in contract 1 by #4020, written by chant 0.124.28 and newer. The CI run the Op ran in, the shape a release's runOrigin has: the forge (github for GitHub and Forgejo, gitlab), the repo or project, and a link to the run. Absent outside CI.",
451
451
  "properties": {
452
- "forge": { "enum": ["github", "gitlab", "op", "local"] },
452
+ "forge": { "enum": ["github", "gitlab", "op", "local", "chant"], "description": "chant: a pull-request job the chant runner ran (chant ci run, #4094), added within contract 1 and written by chant 0.124.30 and newer." },
453
453
  "repo": { "type": "string" },
454
454
  "url": { "type": "string" }
455
455
  }
456
456
  },
457
+ "failure": {
458
+ "type": "object",
459
+ "required": ["code", "reason"],
460
+ "description": "Added in contract 1 by #4094, written by chant 0.124.30 and newer. Why a failed run failed: code is a pull-request report's refusal or status (not-approved, plan-changed, not-a-reviewer, no-pull-request, plan-failed, failed, refused) or command-failed, setup-failed or build-failed, and a reader keeps a case for a code it does not know. reason is one line for a person, phase the phase that failed, exitCode the failed command's exit code. chant ci run writes it on every failed job; absent otherwise.",
461
+ "properties": {
462
+ "code": { "type": "string", "minLength": 1 },
463
+ "reason": { "type": "string" },
464
+ "phase": { "type": "string" },
465
+ "exitCode": { "type": "integer" }
466
+ }
467
+ },
457
468
  "gate": {
458
469
  "oneOf": [
459
470
  { "type": "null" },