@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.
- package/dist/cli/handlers/generate.d.ts +17 -0
- package/dist/cli/handlers/generate.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/json-schema-to-ts.d.ts +61 -0
- package/dist/codegen/json-schema-to-ts.d.ts.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/project-codegen.d.ts +145 -0
- package/dist/project-codegen.d.ts.map +1 -0
- package/dist/workspace/ci-green-cli.d.ts +17 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -0
- package/dist/workspace/ci-green-forge.d.ts +54 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -0
- package/dist/workspace/ci-green-workflow.d.ts +107 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-green.d.ts +191 -0
- package/dist/workspace/ci-green.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +31 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +102 -0
- package/dist/workspace/ls.d.ts +15 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/generate.ts +90 -0
- package/src/cli/main.ts +53 -0
- package/src/cli/plugins.ts +16 -2
- package/src/cli/registry.ts +12 -0
- package/src/codegen/json-schema-to-ts.test.ts +256 -0
- package/src/codegen/json-schema-to-ts.ts +373 -0
- package/src/config.ts +16 -0
- package/src/lexicon.ts +20 -0
- package/src/project-codegen.test.ts +208 -0
- package/src/project-codegen.ts +296 -0
- package/src/workspace/ci-green-cli.ts +118 -0
- package/src/workspace/ci-green-forge.test.ts +66 -0
- package/src/workspace/ci-green-forge.ts +111 -0
- package/src/workspace/ci-green-workflow.test.ts +288 -0
- package/src/workspace/ci-green-workflow.ts +252 -0
- package/src/workspace/ci-green.test.ts +401 -0
- package/src/workspace/ci-green.ts +424 -0
- package/src/workspace/ci-last-green.schema.json +158 -0
- package/src/workspace/declaration.schema.json +102 -0
- package/src/workspace/declaration.ts +64 -0
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +22 -0
- package/src/workspace/read-contract.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local code generation: typed code a lexicon generates into the
|
|
3
|
+
* project from sources the project declares in `chant.config.ts` (a CRD file,
|
|
4
|
+
* a Helm chart), as opposed to the types a lexicon ships in its package.
|
|
5
|
+
*
|
|
6
|
+
* The split of work:
|
|
7
|
+
*
|
|
8
|
+
* - A lexicon implements {@link ProjectCodegen} (the plugin's
|
|
9
|
+
* `projectCodegen()` hook). It says what its declared inputs are
|
|
10
|
+
* ({@link ProjectCodegen.inputs}, offline), renders files from them
|
|
11
|
+
* ({@link ProjectCodegen.generate}, may fetch), and optionally reads its own
|
|
12
|
+
* committed output back when a build starts ({@link ProjectCodegen.load}).
|
|
13
|
+
* - Core owns the output directory, writes the files, and records a digest of
|
|
14
|
+
* the inputs beside them in {@link STAMP_FILE}. `chant generate` is the only
|
|
15
|
+
* writer.
|
|
16
|
+
* - Every build compares the inputs' digest with the recorded one before it
|
|
17
|
+
* serializes ({@link checkProjectCodegen}, wired in through the build-root
|
|
18
|
+
* contributors in ./cli/plugins.ts). A mismatch fails the build. The build
|
|
19
|
+
* never fetches and never regenerates, so it stays offline and the committed
|
|
20
|
+
* output is what a fresh clone typechecks against.
|
|
21
|
+
*
|
|
22
|
+
* Output lives in `<codegen.outDir>/<lexicon>/`, default `src/generated/<lexicon>/`,
|
|
23
|
+
* relative to the directory holding `chant.config.*`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { canonicalJson } from "./effect-receipt";
|
|
30
|
+
import { GENERATED_MARKER } from "./discovery/files";
|
|
31
|
+
|
|
32
|
+
/** Default output directory, relative to the project root. */
|
|
33
|
+
export const DEFAULT_CODEGEN_OUT_DIR = "src/generated";
|
|
34
|
+
|
|
35
|
+
/** The file core writes beside a lexicon's generated files: the inputs digest and the file list. */
|
|
36
|
+
export const STAMP_FILE = "chant-codegen.json";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Header for generated TypeScript. It carries discovery's generated-file
|
|
40
|
+
* marker (`./discovery/files.ts`), so the build and `chant lint` do not treat
|
|
41
|
+
* the generated module as authored source; project files still import it.
|
|
42
|
+
*/
|
|
43
|
+
export const GENERATED_TS_HEADER = `// ${GENERATED_MARKER}. Run \`chant generate\` to update.`;
|
|
44
|
+
|
|
45
|
+
/** What a lexicon's project-codegen hooks receive. */
|
|
46
|
+
export interface ProjectCodegenContext {
|
|
47
|
+
/** The directory holding `chant.config.*`. Relative paths in the config resolve against it. */
|
|
48
|
+
projectRoot: string;
|
|
49
|
+
/** The resolved project configuration; a lexicon reads its own namespace. */
|
|
50
|
+
config: Record<string, unknown>;
|
|
51
|
+
/** Absolute directory this lexicon's files go in (`<codegen.outDir>/<lexicon>`). */
|
|
52
|
+
outDir: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Extra context for {@link ProjectCodegen.generate}. */
|
|
56
|
+
export interface ProjectCodegenGenerateContext extends ProjectCodegenContext {
|
|
57
|
+
/**
|
|
58
|
+
* The fetch remote sources go through. Absent means the global `fetch`.
|
|
59
|
+
* Tests pass a fake so no network is touched.
|
|
60
|
+
*/
|
|
61
|
+
fetch?: typeof fetch;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What {@link ProjectCodegen.generate} returns. */
|
|
65
|
+
export interface ProjectCodegenOutput {
|
|
66
|
+
/** File contents keyed by path relative to `outDir`, `/`-separated. */
|
|
67
|
+
files: Record<string, string>;
|
|
68
|
+
/** One line per generated item, printed by `chant generate`. */
|
|
69
|
+
summary?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A lexicon's project-local code generation (the plugin's `projectCodegen()`
|
|
74
|
+
* hook).
|
|
75
|
+
*/
|
|
76
|
+
export interface ProjectCodegen {
|
|
77
|
+
/**
|
|
78
|
+
* The declared inputs, as a JSON value, or `undefined` when the project
|
|
79
|
+
* declares none. Must not touch the network: the build calls it on every
|
|
80
|
+
* run. A local file is described by a hash of its content, a remote source
|
|
81
|
+
* by its pin (a version, a sha256), so editing a local file or changing a
|
|
82
|
+
* pin changes the value. Core hashes the value; the lexicon never sees the
|
|
83
|
+
* digest.
|
|
84
|
+
*/
|
|
85
|
+
inputs(ctx: ProjectCodegenContext): Promise<unknown | undefined> | unknown | undefined;
|
|
86
|
+
/** Render the files from the declared inputs. May fetch, through `ctx.fetch`. */
|
|
87
|
+
generate(ctx: ProjectCodegenGenerateContext): Promise<ProjectCodegenOutput>;
|
|
88
|
+
/**
|
|
89
|
+
* Read the committed output back at the start of a build, once the inputs
|
|
90
|
+
* are known to match it. The k8s lexicon registers the generated kinds here
|
|
91
|
+
* so its serializer and the CRD spec checks know them even when discovery
|
|
92
|
+
* ran in a sandboxed child process.
|
|
93
|
+
*/
|
|
94
|
+
load?(ctx: ProjectCodegenContext): Promise<void> | void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The recorded stamp. */
|
|
98
|
+
export interface ProjectCodegenStamp {
|
|
99
|
+
/** `sha256:<hex>` of the canonical JSON of {@link ProjectCodegen.inputs}. */
|
|
100
|
+
inputs: string;
|
|
101
|
+
/** The files written, relative to the lexicon's output directory. */
|
|
102
|
+
files: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The minimal plugin shape these functions need. */
|
|
106
|
+
export interface ProjectCodegenPlugin {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
projectCodegen?(): ProjectCodegen;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Resolve the output root (`codegen.outDir`, default {@link DEFAULT_CODEGEN_OUT_DIR}) against the project root. */
|
|
112
|
+
export function resolveCodegenRoot(projectRoot: string, config: Record<string, unknown>): string {
|
|
113
|
+
const codegen = config.codegen as { outDir?: unknown } | undefined;
|
|
114
|
+
const outDir = typeof codegen?.outDir === "string" && codegen.outDir.length > 0 ? codegen.outDir : DEFAULT_CODEGEN_OUT_DIR;
|
|
115
|
+
return resolve(projectRoot, outDir);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The context for one lexicon. */
|
|
119
|
+
export function projectCodegenContext(
|
|
120
|
+
lexicon: string,
|
|
121
|
+
projectRoot: string,
|
|
122
|
+
config: Record<string, unknown>,
|
|
123
|
+
): ProjectCodegenContext {
|
|
124
|
+
return { projectRoot, config, outDir: join(resolveCodegenRoot(projectRoot, config), lexicon) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `sha256:<hex>` of a JSON value's canonical form. */
|
|
128
|
+
export function digestInputs(inputs: unknown): string {
|
|
129
|
+
return `sha256:${createHash("sha256").update(canonicalJson(inputs)).digest("hex")}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read a lexicon's stamp, or `undefined` when it has none (or it does not parse). */
|
|
133
|
+
export function readStamp(outDir: string): ProjectCodegenStamp | undefined {
|
|
134
|
+
const path = join(outDir, STAMP_FILE);
|
|
135
|
+
if (!existsSync(path)) return undefined;
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ProjectCodegenStamp>;
|
|
138
|
+
if (typeof parsed.inputs !== "string" || !Array.isArray(parsed.files)) return undefined;
|
|
139
|
+
return { inputs: parsed.inputs, files: parsed.files.filter((f): f is string => typeof f === "string") };
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One lexicon's result from {@link generateProjectCode}. */
|
|
146
|
+
export interface ProjectCodegenResult {
|
|
147
|
+
lexicon: string;
|
|
148
|
+
/** The lexicon's output directory. */
|
|
149
|
+
outDir: string;
|
|
150
|
+
/** `written`: files (re)generated. `removed`: nothing declared, old output deleted. `none`: nothing declared, nothing there. */
|
|
151
|
+
status: "written" | "removed" | "none";
|
|
152
|
+
files: string[];
|
|
153
|
+
summary: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `chant generate` for one lexicon: render its files, write them, delete
|
|
158
|
+
* files a previous run wrote that this one did not, and record the stamp.
|
|
159
|
+
* With nothing declared, a previous run's output is removed.
|
|
160
|
+
*/
|
|
161
|
+
export async function generateProjectCode(
|
|
162
|
+
plugin: ProjectCodegenPlugin,
|
|
163
|
+
projectRoot: string,
|
|
164
|
+
config: Record<string, unknown>,
|
|
165
|
+
options: { fetch?: typeof fetch } = {},
|
|
166
|
+
): Promise<ProjectCodegenResult | undefined> {
|
|
167
|
+
const codegen = plugin.projectCodegen?.();
|
|
168
|
+
if (!codegen) return undefined;
|
|
169
|
+
const ctx = projectCodegenContext(plugin.name, projectRoot, config);
|
|
170
|
+
const previous = readStamp(ctx.outDir);
|
|
171
|
+
const inputs = await codegen.inputs(ctx);
|
|
172
|
+
|
|
173
|
+
if (inputs === undefined) {
|
|
174
|
+
if (!previous) return { lexicon: plugin.name, outDir: ctx.outDir, status: "none", files: [], summary: [] };
|
|
175
|
+
removeFiles(ctx.outDir, [...previous.files, STAMP_FILE]);
|
|
176
|
+
return { lexicon: plugin.name, outDir: ctx.outDir, status: "removed", files: previous.files, summary: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const output = await codegen.generate({ ...ctx, ...(options.fetch ? { fetch: options.fetch } : {}) });
|
|
180
|
+
const files = Object.keys(output.files).sort();
|
|
181
|
+
for (const file of files) {
|
|
182
|
+
const target = safeJoin(ctx.outDir, file);
|
|
183
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
184
|
+
writeFileSync(target, output.files[file]);
|
|
185
|
+
}
|
|
186
|
+
if (previous) {
|
|
187
|
+
const keep = new Set(files);
|
|
188
|
+
removeFiles(ctx.outDir, previous.files.filter((f) => !keep.has(f)));
|
|
189
|
+
}
|
|
190
|
+
const stamp: ProjectCodegenStamp = { inputs: digestInputs(inputs), files };
|
|
191
|
+
mkdirSync(ctx.outDir, { recursive: true });
|
|
192
|
+
writeFileSync(join(ctx.outDir, STAMP_FILE), JSON.stringify(stamp, null, 2) + "\n");
|
|
193
|
+
return { lexicon: plugin.name, outDir: ctx.outDir, status: "written", files, summary: output.summary ?? [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The outcome of {@link checkProjectCodegen} for one lexicon. */
|
|
197
|
+
export type ProjectCodegenCheck =
|
|
198
|
+
| { status: "current" | "none" }
|
|
199
|
+
| { status: "missing" | "drift" | "orphan"; message: string };
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether a lexicon's committed output matches its declared inputs. Offline.
|
|
203
|
+
*
|
|
204
|
+
* - `none`: nothing declared, nothing generated.
|
|
205
|
+
* - `current`: the recorded digest matches and every recorded file exists.
|
|
206
|
+
* - `missing`: sources are declared and there is no output (or a recorded file is gone).
|
|
207
|
+
* - `drift`: the declared sources changed since the output was generated.
|
|
208
|
+
* - `orphan`: nothing is declared any more, but generated output remains.
|
|
209
|
+
*/
|
|
210
|
+
export async function checkProjectCodegen(
|
|
211
|
+
plugin: ProjectCodegenPlugin,
|
|
212
|
+
projectRoot: string,
|
|
213
|
+
config: Record<string, unknown>,
|
|
214
|
+
): Promise<ProjectCodegenCheck> {
|
|
215
|
+
const codegen = plugin.projectCodegen?.();
|
|
216
|
+
if (!codegen) return { status: "none" };
|
|
217
|
+
const ctx = projectCodegenContext(plugin.name, projectRoot, config);
|
|
218
|
+
const where = displayPath(projectRoot, ctx.outDir);
|
|
219
|
+
const stamp = readStamp(ctx.outDir);
|
|
220
|
+
const inputs = await codegen.inputs(ctx);
|
|
221
|
+
|
|
222
|
+
if (inputs === undefined) {
|
|
223
|
+
if (!stamp) return { status: "none" };
|
|
224
|
+
return {
|
|
225
|
+
status: "orphan",
|
|
226
|
+
message: `${where} holds generated ${plugin.name} code, but chant.config declares no ${plugin.name} sources for it. Run \`chant generate\` to remove it.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (!stamp) {
|
|
230
|
+
return {
|
|
231
|
+
status: "missing",
|
|
232
|
+
message: `chant.config declares ${plugin.name} sources to generate, but ${where} has no generated code. Run \`chant generate\` and commit the result.`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const absent = stamp.files.filter((f) => !existsSync(safeJoin(ctx.outDir, f)));
|
|
236
|
+
if (absent.length > 0) {
|
|
237
|
+
return {
|
|
238
|
+
status: "missing",
|
|
239
|
+
message: `generated ${plugin.name} file(s) missing from ${where}: ${absent.join(", ")}. Run \`chant generate\` and commit the result.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (stamp.inputs !== digestInputs(inputs)) {
|
|
243
|
+
return {
|
|
244
|
+
status: "drift",
|
|
245
|
+
message: `generated ${plugin.name} code in ${where} is out of date: its declared sources changed since it was generated. Run \`chant generate\` and commit the result.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { status: "current" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The build's side of project codegen for one plugin: check the committed
|
|
253
|
+
* output against the declared inputs, throw on any mismatch, and let the
|
|
254
|
+
* lexicon load its output when it is current. Called once per top-level
|
|
255
|
+
* build, before serialization.
|
|
256
|
+
*/
|
|
257
|
+
export async function prepareProjectCodegen(
|
|
258
|
+
plugin: ProjectCodegenPlugin,
|
|
259
|
+
projectRoot: string,
|
|
260
|
+
config: Record<string, unknown>,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
const check = await checkProjectCodegen(plugin, projectRoot, config);
|
|
263
|
+
if ("message" in check) throw new Error(check.message);
|
|
264
|
+
if (check.status === "none") return;
|
|
265
|
+
await plugin.projectCodegen?.().load?.(projectCodegenContext(plugin.name, projectRoot, config));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function removeFiles(outDir: string, files: string[]): void {
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
rmSync(safeJoin(outDir, file), { force: true });
|
|
271
|
+
}
|
|
272
|
+
pruneEmptyDirs(outDir);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Remove empty directories under (and including) `dir`. */
|
|
276
|
+
function pruneEmptyDirs(dir: string): void {
|
|
277
|
+
if (!existsSync(dir)) return;
|
|
278
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
279
|
+
if (entry.isDirectory()) pruneEmptyDirs(join(dir, entry.name));
|
|
280
|
+
}
|
|
281
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Join a recorded relative path onto `outDir`, refusing one that escapes it. */
|
|
285
|
+
function safeJoin(outDir: string, file: string): string {
|
|
286
|
+
const target = resolve(outDir, file);
|
|
287
|
+
if (isAbsolute(file) || (target !== outDir && !target.startsWith(outDir + sep))) {
|
|
288
|
+
throw new Error(`generated file path "${file}" is outside ${outDir}`);
|
|
289
|
+
}
|
|
290
|
+
return target;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function displayPath(projectRoot: string, path: string): string {
|
|
294
|
+
const rel = relative(projectRoot, path);
|
|
295
|
+
return rel === "" ? "." : rel.split(sep).join("/");
|
|
296
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant ci` (#3573, ws-103): which commits passed CI.
|
|
3
|
+
*
|
|
4
|
+
* ci last-green [--json] the newest commit on the branch with a green tag and no revoked tag
|
|
5
|
+
* ci tick [--dry-run] [--forge github] tag the commits that turned green, revoke the ones that turned red
|
|
6
|
+
* ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>]
|
|
7
|
+
* [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
|
|
8
|
+
* write the root CI file that runs the tick
|
|
9
|
+
*
|
|
10
|
+
* `last-green --json` prints `ci-last-green.schema.json`. The tick and the
|
|
11
|
+
* workflow print text.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
15
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { formatError } from "../cli/format";
|
|
17
|
+
import type { CommandContext } from "../cli/registry";
|
|
18
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
19
|
+
import { ciTick, CiGreenError, formatTick, lastGreen } from "./ci-green";
|
|
20
|
+
import { ciForgeFromEnv } from "./ci-green-forge";
|
|
21
|
+
import { ACTIONS_NAME, CI_GREEN_WORKFLOW_FILE, ciGreenHeader, type PushToken, readWorkflowSources, renderCiGreenWorkflow, workflowsHolding, writeWorkflowFile } from "./ci-green-workflow";
|
|
22
|
+
import { readDeclaration, readerVersion, WorkspaceReadError } from "./declaration";
|
|
23
|
+
import { recordGeneratedFiles, resolveMemberContext, shellArg } from "./member-pipeline";
|
|
24
|
+
import { gitTop, workingTree } from "./tree";
|
|
25
|
+
|
|
26
|
+
const USAGE = "chant ci last-green [--json] | ci tick [--dry-run] [--forge github] | ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>] [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]";
|
|
27
|
+
|
|
28
|
+
export async function runCiLastGreen(ctx: CommandContext): Promise<number> {
|
|
29
|
+
const doc = lastGreen({ cwd: process.cwd() });
|
|
30
|
+
if (ctx.args.json) {
|
|
31
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
32
|
+
return "error" in doc ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
if ("error" in doc) {
|
|
35
|
+
console.error(formatError({ message: doc.error.message, hint: USAGE }));
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (!doc.commit) {
|
|
39
|
+
console.error(`No first-parent commit of ${doc.ref} has a ci/green tag without a ci/revoked one. Fetch the tags first: git fetch origin --tags`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
console.log(doc.commit.sha);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const describe = (err: unknown) => (err instanceof WorkspaceReadError ? err.describe() : err instanceof Error ? err.message : String(err));
|
|
47
|
+
|
|
48
|
+
export async function runCiTick(ctx: CommandContext): Promise<number> {
|
|
49
|
+
try {
|
|
50
|
+
const forge = ciForgeFromEnv(ctx.args.forge ?? "github");
|
|
51
|
+
const result = await ciTick({ cwd: process.cwd(), forge, dryRun: ctx.args.dryRun === true });
|
|
52
|
+
console.log(formatTick(result, ctx.args.dryRun === true));
|
|
53
|
+
return 0;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(formatError({ message: `chant ci tick: ${describe(err)}`, hint: USAGE }));
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The push token the flags ask for, or none for the Actions token. Throws on a half-given or clashing pair. */
|
|
61
|
+
function pushToken(args: CommandContext["args"]): PushToken | undefined {
|
|
62
|
+
const { ciTokenSecret: secret, ciAppIdVar: appIdVar, ciAppKeySecret: appKeySecret } = args;
|
|
63
|
+
for (const [flag, name] of [["--token-secret", secret], ["--app-id-var", appIdVar], ["--app-key-secret", appKeySecret]] as const) {
|
|
64
|
+
if (name !== undefined && !ACTIONS_NAME.test(name)) throw new Error(`${flag} takes a secret or variable name, letters, digits and underscores, not ${JSON.stringify(name)}`);
|
|
65
|
+
}
|
|
66
|
+
if (secret !== undefined && (appIdVar !== undefined || appKeySecret !== undefined)) throw new Error("--token-secret and --app-id-var/--app-key-secret each name the push token; pass one or the other");
|
|
67
|
+
if ((appIdVar === undefined) !== (appKeySecret === undefined)) throw new Error("a GitHub App token needs both the App's id and its key: --app-id-var <VAR> --app-key-secret <NAME>");
|
|
68
|
+
if (secret !== undefined) return { secret };
|
|
69
|
+
if (appIdVar !== undefined && appKeySecret !== undefined) return { appIdVar, appKeySecret };
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
74
|
+
const cwd = process.cwd();
|
|
75
|
+
try {
|
|
76
|
+
const push = pushToken(ctx.args);
|
|
77
|
+
const found = findWorkspaceRoot(cwd);
|
|
78
|
+
if (!found) throw new WorkspaceReadError("declaration-missing", "no chant.workspace.json or .jsonc between this directory and the git root");
|
|
79
|
+
const declaration = readDeclaration(workingTree(found.dir));
|
|
80
|
+
const green = declaration.ci?.green;
|
|
81
|
+
if (!green) throw new CiGreenError("ci-green-undeclared", `${declaration.file} declares no ci.green, so there is nothing for the workflow to run`);
|
|
82
|
+
// git reports its top with symlinks resolved (/private/var on macOS), so resolve the root too.
|
|
83
|
+
const root = realpathSync(found.dir);
|
|
84
|
+
const repoRoot = gitTop(root) ?? root;
|
|
85
|
+
const member = resolveMemberContext(cwd, found);
|
|
86
|
+
const target = ctx.args.output ? resolve(realpathSync(cwd), ctx.args.output) : join(repoRoot, CI_GREEN_WORKFLOW_FILE);
|
|
87
|
+
const fromRepo = relative(repoRoot, target).split(sep).join("/");
|
|
88
|
+
|
|
89
|
+
const scan = workflowsHolding(green, readWorkflowSources(repoRoot, fromRepo));
|
|
90
|
+
const extra = ctx.args.ciWorkflows ?? [];
|
|
91
|
+
const workflows = [...new Set([...scan.workflows, ...extra])].sort();
|
|
92
|
+
for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
|
|
93
|
+
for (const u of scan.unmatched) console.error(`warning: no job in .github/workflows/ reports a check run matching ${JSON.stringify(u.pattern)} (phase ${u.phase}); pass --workflow <name> for the workflow that does`);
|
|
94
|
+
if (workflows.length === 0) {
|
|
95
|
+
throw new Error("no workflow in .github/workflows/ holds a check run of a required phase, so nothing would trigger the tick but its schedule; name one with --workflow <name>");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const base = member ? member.memberRoot : root;
|
|
99
|
+
const parts = ["chant", "ci", "workflow", ...extra.flatMap((w) => ["--workflow", w])];
|
|
100
|
+
if (ctx.args.output) parts.push("--output", relative(base, target).split(sep).join("/"));
|
|
101
|
+
if (ctx.args.ciChant) parts.push("--chant", ctx.args.ciChant);
|
|
102
|
+
if (ctx.args.ciInstall) parts.push("--install", ctx.args.ciInstall);
|
|
103
|
+
if (push && "secret" in push) parts.push("--token-secret", push.secret);
|
|
104
|
+
if (push && "appIdVar" in push) parts.push("--app-id-var", push.appIdVar, "--app-key-secret", push.appKeySecret);
|
|
105
|
+
const command = parts.map(shellArg).join(" ");
|
|
106
|
+
const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
|
|
107
|
+
// The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
|
|
108
|
+
const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
|
|
109
|
+
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push });
|
|
110
|
+
const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
|
|
111
|
+
if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
|
|
112
|
+
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
|
|
113
|
+
return 0;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(formatError({ message: `chant ci workflow: ${describe(err)}`, hint: USAGE }));
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3573 — the check-run client `chant ci tick` reads, driven through a
|
|
3
|
+
* recording `fetch` with no network.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { ForgeApiError, ForgeEnvironmentError, type ForgeFetch } from "../pr-forge";
|
|
8
|
+
import { ciForgeFromEnv, CiForgeUnsupportedError, githubCiForge } from "./ci-green-forge";
|
|
9
|
+
|
|
10
|
+
const SHA = "a".repeat(40);
|
|
11
|
+
|
|
12
|
+
function fakeFetch(pages: Record<number, unknown> | number): { fetch: ForgeFetch; urls: string[]; headers: Record<string, string>[] } {
|
|
13
|
+
const urls: string[] = [];
|
|
14
|
+
const headers: Record<string, string>[] = [];
|
|
15
|
+
const fetch: ForgeFetch = async (url, init) => {
|
|
16
|
+
urls.push(url);
|
|
17
|
+
headers.push(init.headers);
|
|
18
|
+
if (typeof pages === "number") return { ok: false, status: pages, text: async () => '{"message":"nope"}' };
|
|
19
|
+
const page = Number(new URL(url).searchParams.get("page"));
|
|
20
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(pages[page] ?? { total_count: 0, check_runs: [] }) };
|
|
21
|
+
};
|
|
22
|
+
return { fetch, urls, headers };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const apiRun = (id: number, extra: Record<string, unknown> = {}) => ({
|
|
26
|
+
id,
|
|
27
|
+
name: `job ${id}`,
|
|
28
|
+
status: "completed",
|
|
29
|
+
conclusion: "success",
|
|
30
|
+
started_at: "2026-10-06T10:00:00Z",
|
|
31
|
+
completed_at: "2026-10-06T10:05:00Z",
|
|
32
|
+
html_url: `https://github.com/acme/app/runs/${id}`,
|
|
33
|
+
...extra,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("the GitHub check-run client (#3573)", () => {
|
|
37
|
+
test("reads every attempt across pages", async () => {
|
|
38
|
+
const first = Array.from({ length: 100 }, (_, i) => apiRun(i + 1));
|
|
39
|
+
const { fetch, urls, headers } = fakeFetch({
|
|
40
|
+
1: { total_count: 101, check_runs: first },
|
|
41
|
+
2: { total_count: 101, check_runs: [apiRun(101, { status: "in_progress", conclusion: null, completed_at: null })] },
|
|
42
|
+
});
|
|
43
|
+
const runs = await githubCiForge({ apiBase: "https://api.github.com/", repo: "acme/app", token: "t0k", fetch }).checkRuns(SHA);
|
|
44
|
+
expect(urls).toEqual([
|
|
45
|
+
`https://api.github.com/repos/acme/app/commits/${SHA}/check-runs?filter=all&per_page=100&page=1`,
|
|
46
|
+
`https://api.github.com/repos/acme/app/commits/${SHA}/check-runs?filter=all&per_page=100&page=2`,
|
|
47
|
+
]);
|
|
48
|
+
expect(headers[0].Authorization).toBe("Bearer t0k");
|
|
49
|
+
expect(runs).toHaveLength(101);
|
|
50
|
+
expect(runs[0]).toEqual({ id: 1, name: "job 1", status: "completed", conclusion: "success", completedAt: "2026-10-06T10:05:00Z", url: "https://github.com/acme/app/runs/1" });
|
|
51
|
+
expect(runs[100]).toMatchObject({ id: 101, status: "in_progress", conclusion: null });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an error status is an error, not an empty list", async () => {
|
|
55
|
+
const { fetch } = fakeFetch(403);
|
|
56
|
+
await expect(githubCiForge({ apiBase: "https://api.github.com", repo: "acme/app", token: "t", fetch }).checkRuns(SHA)).rejects.toBeInstanceOf(ForgeApiError);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("comes from the job's environment, and refuses the forges with no check-run client yet", () => {
|
|
60
|
+
const { fetch } = fakeFetch({});
|
|
61
|
+
expect(ciForgeFromEnv("github", { GITHUB_REPOSITORY: "acme/app", GITHUB_TOKEN: "t" }, fetch).kind).toBe("github");
|
|
62
|
+
expect(() => ciForgeFromEnv("github", { GITHUB_TOKEN: "t" })).toThrow(ForgeEnvironmentError);
|
|
63
|
+
expect(() => ciForgeFromEnv("gitlab", {})).toThrow(CiForgeUnsupportedError);
|
|
64
|
+
expect(() => ciForgeFromEnv("forgejo", {})).toThrow(/GitHub only/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `chant ci tick` asks of a forge (#3573, ws-103): every check run on a
|
|
3
|
+
* commit, every attempt of each, so the tick can judge a phase by each run's
|
|
4
|
+
* latest attempt.
|
|
5
|
+
*
|
|
6
|
+
* The tags themselves are git's, pushed to the remote like any other ref, so
|
|
7
|
+
* the forge is asked for check runs and nothing else. GitHub is the one
|
|
8
|
+
* forge implemented. Forgejo reports commit statuses rather than check runs,
|
|
9
|
+
* and GitLab reports pipeline jobs; each can implement {@link CiForge} by
|
|
10
|
+
* mapping those to {@link CheckRun}s, and until one does `--forge` refuses
|
|
11
|
+
* them by name.
|
|
12
|
+
*
|
|
13
|
+
* The client takes a `fetch` like the pull-request loop's (`../pr-forge.ts`),
|
|
14
|
+
* so the tests drive it with a recording fake and no network.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ForgeApiError, ForgeEnvironmentError, type ForgeFetch, type ForgeKind } from "../pr-forge";
|
|
18
|
+
|
|
19
|
+
/** One attempt of one check run on a commit. */
|
|
20
|
+
export interface CheckRun {
|
|
21
|
+
/** The forge's id. A later attempt of the same check has a larger one. */
|
|
22
|
+
id: number;
|
|
23
|
+
name: string;
|
|
24
|
+
/** `completed` once it has finished; `queued`, `in_progress`, `waiting`, `requested` or `pending` before. */
|
|
25
|
+
status: string;
|
|
26
|
+
/** Set once completed: `success`, `failure`, `skipped`, `cancelled`, `neutral`, `timed_out`, `action_required` or `stale`. */
|
|
27
|
+
conclusion: string | null;
|
|
28
|
+
completedAt: string | null;
|
|
29
|
+
url: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CiForge {
|
|
33
|
+
kind: ForgeKind;
|
|
34
|
+
/** Every check run on `sha`, every attempt. */
|
|
35
|
+
checkRuns(sha: string): Promise<CheckRun[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GithubCiForgeOptions {
|
|
39
|
+
/** `$GITHUB_API_URL`: `https://api.github.com` or `<host>/api/v3`. */
|
|
40
|
+
apiBase: string;
|
|
41
|
+
/** `owner/name`. */
|
|
42
|
+
repo: string;
|
|
43
|
+
token: string;
|
|
44
|
+
fetch?: ForgeFetch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
48
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
49
|
+
|
|
50
|
+
/** A GitHub client (github.com or GitHub Enterprise Server) for check runs. The token needs `checks: read`. */
|
|
51
|
+
export function githubCiForge(opts: GithubCiForgeOptions): CiForge {
|
|
52
|
+
const root = opts.apiBase.replace(/\/+$/, "");
|
|
53
|
+
const fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as ForgeFetch);
|
|
54
|
+
const headers = { Authorization: `Bearer ${opts.token}`, Accept: "application/vnd.github+json" };
|
|
55
|
+
return {
|
|
56
|
+
kind: "github",
|
|
57
|
+
async checkRuns(sha) {
|
|
58
|
+
// filter=all: every attempt, not only the newest per suite, so a re-run's
|
|
59
|
+
// own run is judged against the run it replaced by id.
|
|
60
|
+
const out: CheckRun[] = [];
|
|
61
|
+
for (let page = 1; page <= 50; page++) {
|
|
62
|
+
const url = `${root}/repos/${opts.repo}/commits/${sha}/check-runs?filter=all&per_page=100&page=${page}`;
|
|
63
|
+
const res = await fetchImpl(url, { method: "GET", headers });
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
if (!res.ok) throw new ForgeApiError("GET", url, res.status, text);
|
|
66
|
+
const body: unknown = text ? JSON.parse(text) : null;
|
|
67
|
+
const runs = isObject(body) && Array.isArray(body.check_runs) ? body.check_runs : [];
|
|
68
|
+
for (const r of runs) {
|
|
69
|
+
if (!isObject(r) || typeof r.id !== "number" || typeof r.name !== "string") continue;
|
|
70
|
+
out.push({
|
|
71
|
+
id: r.id,
|
|
72
|
+
name: r.name,
|
|
73
|
+
status: str(r.status) ?? "queued",
|
|
74
|
+
conclusion: str(r.conclusion),
|
|
75
|
+
completedAt: str(r.completed_at),
|
|
76
|
+
url: str(r.html_url),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const total = isObject(body) && typeof body.total_count === "number" ? body.total_count : 0;
|
|
80
|
+
if (runs.length < 100 || out.length >= total) break;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Thrown for a forge `chant ci tick` has no check-run client for yet. */
|
|
88
|
+
export class CiForgeUnsupportedError extends Error {
|
|
89
|
+
constructor(kind: string) {
|
|
90
|
+
super(`check runs are read from GitHub only so far, and --forge asked for ${kind}; see #3573`);
|
|
91
|
+
this.name = "CiForgeUnsupportedError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A check-run client for `kind` from the CI job's environment: the token is
|
|
97
|
+
* `CHANT_FORGE_TOKEN` when set, else `GITHUB_TOKEN` or `GH_TOKEN`, and the
|
|
98
|
+
* repository is `GITHUB_REPOSITORY`.
|
|
99
|
+
*/
|
|
100
|
+
export function ciForgeFromEnv(kind: string = "github", env: Record<string, string | undefined> = process.env, fetchImpl?: ForgeFetch): CiForge {
|
|
101
|
+
if (kind !== "github") throw new CiForgeUnsupportedError(kind);
|
|
102
|
+
const token = env.CHANT_FORGE_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
103
|
+
const missing = [...(env.GITHUB_REPOSITORY ? [] : ["GITHUB_REPOSITORY"]), ...(token ? [] : ["GITHUB_TOKEN or CHANT_FORGE_TOKEN"])];
|
|
104
|
+
if (missing.length > 0) throw new ForgeEnvironmentError("github", missing);
|
|
105
|
+
return githubCiForge({
|
|
106
|
+
apiBase: env.GITHUB_API_URL ?? "https://api.github.com",
|
|
107
|
+
repo: env.GITHUB_REPOSITORY!,
|
|
108
|
+
token: token!,
|
|
109
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|