@intentius/chant 0.107.1 → 0.108.1

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 (55) hide show
  1. package/dist/cli/handlers/generate.d.ts +17 -0
  2. package/dist/cli/handlers/generate.d.ts.map +1 -0
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/plugins.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +12 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/codegen/json-schema-to-ts.d.ts +61 -0
  8. package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
  9. package/dist/config.d.ts +15 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +13 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/project-codegen.d.ts +145 -0
  14. package/dist/project-codegen.d.ts.map +1 -0
  15. package/dist/workspace/ci-green-cli.d.ts +17 -0
  16. package/dist/workspace/ci-green-cli.d.ts.map +1 -0
  17. package/dist/workspace/ci-green-forge.d.ts +54 -0
  18. package/dist/workspace/ci-green-forge.d.ts.map +1 -0
  19. package/dist/workspace/ci-green-workflow.d.ts +107 -0
  20. package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
  21. package/dist/workspace/ci-green.d.ts +191 -0
  22. package/dist/workspace/ci-green.d.ts.map +1 -0
  23. package/dist/workspace/declaration.d.ts +31 -0
  24. package/dist/workspace/declaration.d.ts.map +1 -1
  25. package/dist/workspace/declaration.schema.json +102 -0
  26. package/dist/workspace/ls.d.ts +15 -0
  27. package/dist/workspace/ls.d.ts.map +1 -1
  28. package/dist/workspace/reason-codes.d.ts +2 -0
  29. package/dist/workspace/reason-codes.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/handlers/generate.ts +90 -0
  32. package/src/cli/main.ts +53 -0
  33. package/src/cli/plugins.ts +16 -2
  34. package/src/cli/registry.ts +12 -0
  35. package/src/codegen/json-schema-to-ts.test.ts +256 -0
  36. package/src/codegen/json-schema-to-ts.ts +373 -0
  37. package/src/config.ts +16 -0
  38. package/src/lexicon.ts +20 -0
  39. package/src/project-codegen.test.ts +208 -0
  40. package/src/project-codegen.ts +296 -0
  41. package/src/workspace/ci-green-cli.ts +118 -0
  42. package/src/workspace/ci-green-forge.test.ts +66 -0
  43. package/src/workspace/ci-green-forge.ts +111 -0
  44. package/src/workspace/ci-green-workflow.test.ts +288 -0
  45. package/src/workspace/ci-green-workflow.ts +252 -0
  46. package/src/workspace/ci-green.test.ts +401 -0
  47. package/src/workspace/ci-green.ts +424 -0
  48. package/src/workspace/ci-last-green.schema.json +158 -0
  49. package/src/workspace/declaration.schema.json +102 -0
  50. package/src/workspace/declaration.ts +64 -0
  51. package/src/workspace/ls.schema.json +34 -0
  52. package/src/workspace/ls.ts +22 -0
  53. package/src/workspace/read-contract.test.ts +2 -1
  54. package/src/workspace/reason-codes.test.ts +2 -0
  55. package/src/workspace/reason-codes.ts +3 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Which commits passed CI (#3573, ws-103).
3
+ *
4
+ * The declaration's `ci.green` names a branch, a window and phases, each
5
+ * phase a list of check-run name patterns. A phase passes when every check
6
+ * run its patterns match finished with success, judged by each run's latest
7
+ * attempt. A run that finished as skipped passes only in a phase declared
8
+ * `skipped: pass`. A run still going, or a pattern no run matches yet, leaves
9
+ * the phase undecided. A commit passes when every phase in `require` passes,
10
+ * and fails when one of them fails.
11
+ *
12
+ * What chant concluded is kept in git as two kinds of annotated tag, each
13
+ * made once and never moved or deleted by chant:
14
+ *
15
+ * ci/green/<sha> every required phase passed; the annotation gives the
16
+ * time and each phase's runs
17
+ * ci/revoked/<sha> a green commit later failed a required phase (a re-run
18
+ * that went red); the annotation gives the phase, the
19
+ * run and the time
20
+ *
21
+ * {@link ciTick} walks the branch's first-parent commits within the window,
22
+ * tags the ones that turned green and revokes the green ones that turned red.
23
+ * It is idempotent: a second tick over the same check runs changes nothing.
24
+ * A revoked commit is never looked at again, so it goes green again only
25
+ * when someone deletes its revoked tag by hand, which leaves its green tag
26
+ * standing. {@link lastGreen} reads the tags alone, with no forge.
27
+ *
28
+ * The forge is asked only for check runs (`./ci-green-forge.ts`). The tags
29
+ * are fetched from and pushed to the remote with git.
30
+ */
31
+
32
+ import { execFileSync } from "node:child_process";
33
+ import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type CiGreen, type CiPhase } from "./declaration";
34
+ import type { CheckRun, CiForge } from "./ci-green-forge";
35
+ import type { ReasonCode } from "./reason-codes";
36
+ import { gitTop } from "./tree";
37
+ import { locateWorkspace } from "./which-chant";
38
+
39
+ export const CI_GREEN_TAG_PREFIX = "ci/green/";
40
+ export const CI_REVOKED_TAG_PREFIX = "ci/revoked/";
41
+
42
+ export const CI_LAST_GREEN_CONTRACT_VERSION = 1;
43
+ export const CI_LAST_GREEN_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/ci-last-green/v1/ci-last-green.schema.json";
44
+
45
+ /** Why `ci last-green` or `ci tick` could not start. Closed. */
46
+ export const CI_GREEN_ERROR_CODES = [...WORKSPACE_ERROR_CODES, "ci-green-undeclared", "ci-branch-unknown"] as const satisfies readonly ReasonCode[];
47
+ export type CiGreenErrorCode = (typeof CI_GREEN_ERROR_CODES)[number];
48
+
49
+ export class CiGreenError extends Error {
50
+ constructor(
51
+ readonly code: CiGreenErrorCode,
52
+ message: string,
53
+ ) {
54
+ super(message);
55
+ this.name = "CiGreenError";
56
+ }
57
+ }
58
+
59
+ // ── Judging check runs ───────────────────────────────────────────────────────
60
+
61
+ export type Verdict = "pass" | "fail" | "pending";
62
+
63
+ /** Whether a check run's name matches a pattern: the whole name, `*` standing for any run of characters. */
64
+ export function matchesCheckRun(pattern: string, name: string): boolean {
65
+ const re = new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "s");
66
+ return re.test(name);
67
+ }
68
+
69
+ /** Each check run's latest attempt, by name: the run with the largest id. */
70
+ export function latestAttempts(runs: readonly CheckRun[]): CheckRun[] {
71
+ const byName = new Map<string, CheckRun>();
72
+ for (const r of runs) {
73
+ const seen = byName.get(r.name);
74
+ if (!seen || r.id > seen.id) byName.set(r.name, r);
75
+ }
76
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
77
+ }
78
+
79
+ export interface RunVerdict {
80
+ name: string;
81
+ id: number;
82
+ status: string;
83
+ conclusion: string | null;
84
+ url: string | null;
85
+ verdict: Verdict;
86
+ }
87
+
88
+ export interface PhaseVerdict {
89
+ phase: string;
90
+ verdict: Verdict;
91
+ /** The latest attempt of every run the phase's patterns match, by name. */
92
+ runs: RunVerdict[];
93
+ /** Patterns no check run matches yet, which leave the phase undecided. */
94
+ unmatched: string[];
95
+ }
96
+
97
+ function runVerdict(phase: CiPhase, r: CheckRun): Verdict {
98
+ if (r.status !== "completed") return "pending";
99
+ if (r.conclusion === "success") return "pass";
100
+ if (r.conclusion === "skipped") return phase.skipped === "pass" ? "pass" : "fail";
101
+ return "fail";
102
+ }
103
+
104
+ /** Judge one phase against a commit's latest attempts. */
105
+ export function evaluatePhase(phase: CiPhase, latest: readonly CheckRun[]): PhaseVerdict {
106
+ const runs: RunVerdict[] = [];
107
+ const unmatched: string[] = [];
108
+ for (const pattern of phase.runs) {
109
+ const matched = latest.filter((r) => matchesCheckRun(pattern, r.name));
110
+ if (matched.length === 0) unmatched.push(pattern);
111
+ for (const r of matched) {
112
+ if (runs.some((x) => x.name === r.name)) continue;
113
+ runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r) });
114
+ }
115
+ }
116
+ const verdict: Verdict = runs.some((r) => r.verdict === "fail")
117
+ ? "fail"
118
+ : runs.some((r) => r.verdict === "pending") || unmatched.length > 0
119
+ ? "pending"
120
+ : "pass";
121
+ return { phase: phase.name, verdict, runs, unmatched };
122
+ }
123
+
124
+ export interface CommitVerdict {
125
+ verdict: Verdict;
126
+ /** Each required phase, in `require` order. */
127
+ phases: PhaseVerdict[];
128
+ /** The first required phase that failed, and its first failed run, when the commit fails. */
129
+ failure: { phase: string; run: RunVerdict } | null;
130
+ }
131
+
132
+ /** Judge a commit: it fails when a required phase fails, and passes when every one passes. */
133
+ export function evaluateCommit(green: CiGreen, runs: readonly CheckRun[]): CommitVerdict {
134
+ const latest = latestAttempts(runs);
135
+ const phases = green.require.map((name) => evaluatePhase(green.phases.find((p) => p.name === name)!, latest));
136
+ const failed = phases.find((p) => p.verdict === "fail");
137
+ if (failed) return { verdict: "fail", phases, failure: { phase: failed.phase, run: failed.runs.find((r) => r.verdict === "fail")! } };
138
+ return { verdict: phases.some((p) => p.verdict === "pending") ? "pending" : "pass", phases, failure: null };
139
+ }
140
+
141
+ // ── The tick ─────────────────────────────────────────────────────────────────
142
+
143
+ /** A commit's tags before a tick: none, green, or green and revoked. A lone revoked tag counts as revoked. */
144
+ export type TagState = "none" | "green" | "revoked";
145
+
146
+ export interface TickCommit {
147
+ sha: string;
148
+ /** Committer time, ISO 8601. */
149
+ committed: string;
150
+ before: TagState;
151
+ /** Undefined for a revoked commit, which is not judged again. */
152
+ verdict?: CommitVerdict;
153
+ /** The tag the tick made, if any. */
154
+ made: { tag: string; message: string } | null;
155
+ }
156
+
157
+ export interface TickPlanInput {
158
+ green: CiGreen;
159
+ commits: readonly { sha: string; committed: string }[];
160
+ greenTags: ReadonlySet<string>;
161
+ revokedTags: ReadonlySet<string>;
162
+ checkRuns(sha: string): Promise<CheckRun[]>;
163
+ now: Date;
164
+ }
165
+
166
+ /** A time as an annotation and a tick report write it: UTC, to the second. */
167
+ const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
168
+
169
+ /** `window` (`90m`, `24h`, `7d`) in milliseconds. */
170
+ export function windowMs(window: string): number {
171
+ const m = /^([1-9][0-9]*)(m|h|d)$/.exec(window);
172
+ if (!m) throw new Error(`ci.green.window must be a number of minutes, hours or days, such as 24h, not ${JSON.stringify(window)}`);
173
+ return Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "m" | "h" | "d"];
174
+ }
175
+
176
+ const runRecord = (r: RunVerdict) => ({ name: r.name, id: r.id, conclusion: r.conclusion, url: r.url });
177
+
178
+ /** The annotation of a green tag: a subject line, then the record as JSON. */
179
+ export function greenAnnotation(green: CiGreen, sha: string, verdict: CommitVerdict, at: Date): string {
180
+ const record = {
181
+ schema: 1,
182
+ tag: "green",
183
+ commit: sha,
184
+ branch: green.branch,
185
+ at: iso(at),
186
+ phases: Object.fromEntries(verdict.phases.map((p) => [p.phase, p.runs.map(runRecord)])),
187
+ };
188
+ return `ci: every required phase passed (${green.require.join(", ")})\n\n${JSON.stringify(record, null, 2)}\n`;
189
+ }
190
+
191
+ /** The annotation of a revoked tag: the phase and the run that failed, and the time. */
192
+ export function revokedAnnotation(green: CiGreen, sha: string, failure: NonNullable<CommitVerdict["failure"]>, at: Date): string {
193
+ const record = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), phase: failure.phase, run: runRecord(failure.run) };
194
+ return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${JSON.stringify(record, null, 2)}\n`;
195
+ }
196
+
197
+ /**
198
+ * Decide what a tick does, without touching git: a commit with no green tag
199
+ * whose required phases all passed gets one, and a green commit with no
200
+ * revoked tag that now fails a required phase gets revoked. A revoked commit
201
+ * is skipped without asking the forge.
202
+ */
203
+ export async function planTick(input: TickPlanInput): Promise<TickCommit[]> {
204
+ const out: TickCommit[] = [];
205
+ for (const c of input.commits) {
206
+ const before: TagState = input.revokedTags.has(c.sha) ? "revoked" : input.greenTags.has(c.sha) ? "green" : "none";
207
+ if (before === "revoked") {
208
+ out.push({ ...c, before, made: null });
209
+ continue;
210
+ }
211
+ const verdict = evaluateCommit(input.green, await input.checkRuns(c.sha));
212
+ let made: TickCommit["made"] = null;
213
+ if (before === "none" && verdict.verdict === "pass") {
214
+ made = { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, message: greenAnnotation(input.green, c.sha, verdict, input.now) };
215
+ } else if (before === "green" && verdict.verdict === "fail") {
216
+ made = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, message: revokedAnnotation(input.green, c.sha, verdict.failure!, input.now) };
217
+ }
218
+ out.push({ ...c, before, verdict, made });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function git(top: string, args: string[]): string {
224
+ return execFileSync("git", args, {
225
+ cwd: top,
226
+ encoding: "utf-8",
227
+ stdio: ["ignore", "pipe", "pipe"],
228
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
229
+ maxBuffer: 256 * 1024 * 1024,
230
+ }).trimEnd();
231
+ }
232
+
233
+ function tryGit(top: string, args: string[]): string | null {
234
+ try {
235
+ return git(top, args);
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /** The ci tags in the repository: the commits with a green tag, and those with a revoked one. */
242
+ export function readCiTags(top: string): { green: Map<string, string>; revoked: Set<string> } {
243
+ const green = new Map<string, string>();
244
+ const revoked = new Set<string>();
245
+ const lines = git(top, ["for-each-ref", "--format=%(refname:lstrip=2)%09%(taggerdate:iso-strict)", `refs/tags/${CI_GREEN_TAG_PREFIX}`, `refs/tags/${CI_REVOKED_TAG_PREFIX}`]);
246
+ for (const line of lines.split("\n").filter(Boolean)) {
247
+ const [tag, date] = line.split("\t");
248
+ if (tag.startsWith(CI_GREEN_TAG_PREFIX)) green.set(tag.slice(CI_GREEN_TAG_PREFIX.length), date ?? "");
249
+ else revoked.add(tag.slice(CI_REVOKED_TAG_PREFIX.length));
250
+ }
251
+ return { green, revoked };
252
+ }
253
+
254
+ /** The declaration's `ci.green`, read where `cwd` is, and the git top. */
255
+ function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
256
+ const located = locateWorkspace(cwd);
257
+ const declaration = readDeclaration(located.tree);
258
+ const green = declaration.ci?.green ?? null;
259
+ if (!green) throw new CiGreenError("ci-green-undeclared", `${declaration.file} declares no ci.green, so chant does not know which check runs make a commit green`);
260
+ const top = located.top ?? gitTop(cwd);
261
+ if (!top) throw new WorkspaceReadError("not-a-git-repository", "the ci tags are git tags, and this directory is not in a git repository");
262
+ return { green, top, workspace: { name: declaration.name, root: located.root, file: declaration.file } };
263
+ }
264
+
265
+ export interface TickRequest {
266
+ cwd: string;
267
+ forge: CiForge;
268
+ /** The git remote the branch and the tags are fetched from and pushed to. Default `origin`. */
269
+ remote?: string;
270
+ /** Decide and report, and make and push no tag. */
271
+ dryRun?: boolean;
272
+ now?: Date;
273
+ }
274
+
275
+ export interface TickResult {
276
+ branch: string;
277
+ window: string;
278
+ /** The ref the commits were read from. */
279
+ ref: string;
280
+ commits: TickCommit[];
281
+ /** The tags made and pushed, or that would be under `dryRun`. */
282
+ made: string[];
283
+ }
284
+
285
+ /**
286
+ * One tick: fetch the branch and the ci tags from the remote (pruning a ci
287
+ * tag deleted there by hand), judge the first-parent commits within the
288
+ * window, make the tags that are due and push them, never forced.
289
+ */
290
+ export async function ciTick(req: TickRequest): Promise<TickResult> {
291
+ const { green, top } = readGreen(req.cwd);
292
+ const remote = req.remote ?? "origin";
293
+ const now = req.now ?? new Date();
294
+ const tracking = `refs/remotes/${remote}/${green.branch}`;
295
+ try {
296
+ git(top, ["fetch", "--quiet", "--no-tags", "--prune", remote, `+refs/heads/${green.branch}:${tracking}`, "+refs/tags/ci/*:refs/tags/ci/*"]);
297
+ } catch (err) {
298
+ throw new Error(`could not fetch ${green.branch} and the ci tags from ${remote}: ${stderrOf(err)}`);
299
+ }
300
+ if (tryGit(top, ["rev-parse", "--verify", "--quiet", `${tracking}^{commit}`]) === null) {
301
+ throw new CiGreenError("ci-branch-unknown", `${remote} has no branch ${green.branch}`);
302
+ }
303
+
304
+ const cutoff = Math.floor((now.getTime() - windowMs(green.window)) / 1000);
305
+ const commits = git(top, ["log", "--first-parent", `--max-age=${cutoff}`, "--format=%H %ct", tracking])
306
+ .split("\n")
307
+ .filter(Boolean)
308
+ .map((line) => line.split(" "))
309
+ .filter(([, ct]) => Number(ct) >= cutoff)
310
+ .map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
311
+
312
+ const tags = readCiTags(top);
313
+ const planned = await planTick({
314
+ green,
315
+ commits,
316
+ greenTags: new Set(tags.green.keys()),
317
+ revokedTags: tags.revoked,
318
+ checkRuns: (sha) => req.forge.checkRuns(sha),
319
+ now,
320
+ });
321
+ const due = planned.filter((c) => c.made !== null);
322
+ if (!req.dryRun && due.length > 0) {
323
+ const ident = taggerConfig(top);
324
+ for (const c of due) git(top, [...ident, "tag", "-a", "-m", c.made!.message, c.made!.tag, c.sha]);
325
+ try {
326
+ git(top, ["push", "--quiet", remote, ...due.map((c) => `refs/tags/${c.made!.tag}`)]);
327
+ } catch (err) {
328
+ // The local tags stay until the next tick's fetch prunes the ones the remote lacks.
329
+ const stderr = stderrOf(err);
330
+ const cause = pushRefusalCause(stderr);
331
+ throw new Error(`could not push ${due.map((c) => c.made!.tag).join(", ")} to ${remote}: ${cause ? `${cause}\n` : ""}${stderr}`);
332
+ }
333
+ }
334
+ return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made!.tag) };
335
+ }
336
+
337
+ /**
338
+ * Why the remote refused a tag push, when chant can tell. GitHub refuses a
339
+ * new ref to a commit whose workflow files differ from the default branch's
340
+ * unless the token may update workflows, and the Actions token never may.
341
+ */
342
+ export function pushRefusalCause(stderr: string): string | null {
343
+ if (!/without [`']?workflows?[`']? (permission|scope)/i.test(stderr)) return null;
344
+ return (
345
+ "GitHub refused the tag push because the tagged commit changes workflow files, and GITHUB_TOKEN can't update workflows: " +
346
+ "regenerate the workflow with --token-secret <NAME>, naming a secret that holds a fine-grained token with Contents and Workflows read and write, " +
347
+ "or with --app-id-var <VAR> --app-key-secret <NAME> for a GitHub App's token"
348
+ );
349
+ }
350
+
351
+ /** A tagger for an annotated tag when git has none configured, as on a fresh CI runner. The environment's GIT_COMMITTER_* still win. */
352
+ function taggerConfig(top: string): string[] {
353
+ const out: string[] = [];
354
+ if (tryGit(top, ["config", "user.name"]) === null) out.push("-c", "user.name=chant");
355
+ if (tryGit(top, ["config", "user.email"]) === null) out.push("-c", "user.email=chant@localhost");
356
+ return out;
357
+ }
358
+
359
+ function stderrOf(err: unknown): string {
360
+ const e = err as { stderr?: string | Buffer; message?: string };
361
+ return String(e.stderr ?? e.message ?? err).trim();
362
+ }
363
+
364
+ /** The text `chant ci tick` prints. */
365
+ export function formatTick(r: TickResult, dryRun = false): string {
366
+ const lines = [`${r.branch}, commits within ${r.window}: ${r.commits.length}`];
367
+ for (const c of r.commits) {
368
+ const short = c.sha.slice(0, 12);
369
+ if (c.made) {
370
+ lines.push(` ${short} ${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}${c.verdict?.failure ? `: phase ${c.verdict.failure.phase}, ${c.verdict.failure.run.name} ${c.verdict.failure.run.conclusion}` : ""}`);
371
+ } else if (c.before === "revoked") {
372
+ lines.push(` ${short} revoked`);
373
+ } else {
374
+ const v = c.verdict!;
375
+ const waiting = v.phases.filter((p) => p.verdict === "pending").map((p) => p.phase);
376
+ const what = c.before === "green" ? "green" : v.verdict === "fail" ? `failed: phase ${v.failure!.phase}, ${v.failure!.run.name} ${v.failure!.run.conclusion}` : `undecided: ${waiting.join(", ")}`;
377
+ lines.push(` ${short} ${what}`);
378
+ }
379
+ }
380
+ return lines.join("\n");
381
+ }
382
+
383
+ // ── last-green ───────────────────────────────────────────────────────────────
384
+
385
+ export type LastGreenDocument =
386
+ | {
387
+ $schema: string;
388
+ contract: number;
389
+ chant: string;
390
+ workspace: { name: string; root: string; file: string };
391
+ branch: string;
392
+ /** The ref walked: the remote-tracking branch when there is one, else the local branch. */
393
+ ref: string;
394
+ commit: { sha: string; tag: string; tagged: string | null; committed: string } | null;
395
+ }
396
+ | { $schema: string; contract: number; chant: string; error: { code: CiGreenErrorCode; message: string } };
397
+
398
+ /**
399
+ * `chant ci last-green`: the newest first-parent commit of the branch with a
400
+ * green tag and no revoked tag. It reads local refs and tags and never
401
+ * fetches, so a reader fetches first (`git fetch origin --tags`).
402
+ */
403
+ export function lastGreen(req: { cwd: string; remote?: string }): LastGreenDocument {
404
+ const head = { $schema: CI_LAST_GREEN_SCHEMA_ID, contract: CI_LAST_GREEN_CONTRACT_VERSION, chant: readerVersion() };
405
+ try {
406
+ const { green, top, workspace } = readGreen(req.cwd);
407
+ const candidates = [`refs/remotes/${req.remote ?? "origin"}/${green.branch}`, `refs/heads/${green.branch}`];
408
+ const ref = candidates.find((r) => tryGit(top, ["rev-parse", "--verify", "--quiet", `${r}^{commit}`]) !== null);
409
+ if (!ref) throw new CiGreenError("ci-branch-unknown", `this repository has neither ${candidates.join(" nor ")}`);
410
+ const tags = readCiTags(top);
411
+ let commit: Extract<LastGreenDocument, { branch: string }>["commit"] = null;
412
+ if (tags.green.size > 0) {
413
+ for (const sha of git(top, ["rev-list", "--first-parent", ref]).split("\n")) {
414
+ if (!tags.green.has(sha) || tags.revoked.has(sha)) continue;
415
+ commit = { sha, tag: `${CI_GREEN_TAG_PREFIX}${sha}`, tagged: tags.green.get(sha) || null, committed: git(top, ["show", "-s", "--format=%cI", sha]) };
416
+ break;
417
+ }
418
+ }
419
+ return { ...head, workspace, branch: green.branch, ref, commit };
420
+ } catch (err) {
421
+ if (err instanceof CiGreenError || err instanceof WorkspaceReadError) return { ...head, error: { code: err.code, message: err instanceof WorkspaceReadError ? err.describe() : err.message } };
422
+ throw err;
423
+ }
424
+ }
@@ -0,0 +1,158 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/ci-last-green/v1/ci-last-green.schema.json",
4
+ "title": "chant ci last-green output",
5
+ "description": "What `chant ci last-green --json` prints (#3573, ws-103): the newest first-parent commit of the branch ci.green names that has a ci/green/<sha> tag and no ci/revoked/<sha> tag. It reads local refs and tags and never fetches. Version 1 of the read contract. Readers ignore fields they do not know; a field is only ever added within a version. The codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.108.0 and newer; the read contract as a whole by chant 0.81.0 and newer.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "type": "object",
17
+ "description": "Exit code 0, with or without a green commit.",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "chant",
22
+ "workspace",
23
+ "branch",
24
+ "ref",
25
+ "commit"
26
+ ],
27
+ "properties": {
28
+ "$schema": {
29
+ "const": "https://intentius.io/chant/schemas/workspace/ci-last-green/v1/ci-last-green.schema.json"
30
+ },
31
+ "contract": {
32
+ "const": 1
33
+ },
34
+ "chant": {
35
+ "type": "string"
36
+ },
37
+ "error": false,
38
+ "workspace": {
39
+ "type": "object",
40
+ "required": [
41
+ "name",
42
+ "root",
43
+ "file"
44
+ ],
45
+ "properties": {
46
+ "name": {
47
+ "type": "string"
48
+ },
49
+ "root": {
50
+ "type": "string",
51
+ "description": "The workspace root relative to the git root, with / separators, \".\" for the git root itself."
52
+ },
53
+ "file": {
54
+ "enum": [
55
+ "chant.workspace.json",
56
+ "chant.workspace.jsonc"
57
+ ]
58
+ }
59
+ }
60
+ },
61
+ "branch": {
62
+ "type": "string",
63
+ "description": "ci.green.branch."
64
+ },
65
+ "ref": {
66
+ "type": "string",
67
+ "description": "The ref whose first-parent commits were walked: refs/remotes/origin/<branch> when the checkout has it, else refs/heads/<branch>."
68
+ },
69
+ "commit": {
70
+ "description": "The newest green commit that is not revoked, or null when there is none.",
71
+ "oneOf": [
72
+ {
73
+ "type": "null"
74
+ },
75
+ {
76
+ "type": "object",
77
+ "required": [
78
+ "sha",
79
+ "tag",
80
+ "tagged",
81
+ "committed"
82
+ ],
83
+ "properties": {
84
+ "sha": {
85
+ "type": "string",
86
+ "pattern": "^[0-9a-f]{40,64}$"
87
+ },
88
+ "tag": {
89
+ "type": "string",
90
+ "description": "ci/green/<sha>."
91
+ },
92
+ "tagged": {
93
+ "type": [
94
+ "string",
95
+ "null"
96
+ ],
97
+ "description": "When the green tag was made, ISO 8601, or null for a lightweight tag."
98
+ },
99
+ "committed": {
100
+ "type": "string",
101
+ "description": "The commit's committer time, ISO 8601."
102
+ }
103
+ }
104
+ }
105
+ ]
106
+ }
107
+ }
108
+ },
109
+ "failure": {
110
+ "type": "object",
111
+ "description": "Nothing was read. Exit code 1.",
112
+ "required": [
113
+ "$schema",
114
+ "contract",
115
+ "chant",
116
+ "error"
117
+ ],
118
+ "properties": {
119
+ "$schema": {
120
+ "const": "https://intentius.io/chant/schemas/workspace/ci-last-green/v1/ci-last-green.schema.json"
121
+ },
122
+ "contract": {
123
+ "const": 1
124
+ },
125
+ "chant": {
126
+ "type": "string"
127
+ },
128
+ "error": {
129
+ "type": "object",
130
+ "required": [
131
+ "code",
132
+ "message"
133
+ ],
134
+ "properties": {
135
+ "code": {
136
+ "enum": [
137
+ "declaration-missing",
138
+ "declaration-ambiguous",
139
+ "declaration-unparseable",
140
+ "declaration-invalid",
141
+ "placement-invalid",
142
+ "reader-too-old",
143
+ "root-chant-required",
144
+ "not-a-git-repository",
145
+ "revision-unknown",
146
+ "ci-green-undeclared",
147
+ "ci-branch-unknown"
148
+ ]
149
+ },
150
+ "message": {
151
+ "type": "string"
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }