@intentius/chant 0.124.33 → 0.124.34

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 (75) hide show
  1. package/dist/cli/handlers/promote.d.ts.map +1 -1
  2. package/dist/cli/handlers/promote.js +261 -73
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/main.js +18 -1
  5. package/dist/cli/parse-args.d.ts.map +1 -1
  6. package/dist/cli/parse-args.js +7 -0
  7. package/dist/cli/registry.d.ts +3 -1
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/components/promote-rounds.d.ts +102 -0
  10. package/dist/components/promote-rounds.d.ts.map +1 -0
  11. package/dist/components/promote-rounds.js +174 -0
  12. package/dist/components/release-gate.d.ts +30 -0
  13. package/dist/components/release-gate.d.ts.map +1 -1
  14. package/dist/components/release-gate.js +92 -42
  15. package/dist/graph-declared.d.ts +4 -0
  16. package/dist/graph-declared.d.ts.map +1 -1
  17. package/dist/graph-declared.js +11 -1
  18. package/dist/wave-runtime.mjs +39 -39
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  20. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  21. package/dist/workspace/check.schema.json +20 -2
  22. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  23. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  24. package/dist/workspace/checks/ci-workflow.js +123 -0
  25. package/dist/workspace/checks/security.d.ts +16 -1
  26. package/dist/workspace/checks/security.d.ts.map +1 -1
  27. package/dist/workspace/checks/security.js +4 -4
  28. package/dist/workspace/checks.d.ts +1 -1
  29. package/dist/workspace/checks.d.ts.map +1 -1
  30. package/dist/workspace/checks.js +4 -1
  31. package/dist/workspace/ci-init.d.ts +100 -0
  32. package/dist/workspace/ci-init.d.ts.map +1 -0
  33. package/dist/workspace/ci-init.js +434 -0
  34. package/dist/workspace/ci-workflow.d.ts +91 -0
  35. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  36. package/dist/workspace/ci-workflow.js +33 -0
  37. package/dist/workspace/declaration.d.ts +18 -0
  38. package/dist/workspace/declaration.d.ts.map +1 -1
  39. package/dist/workspace/declaration.js +9 -2
  40. package/dist/workspace/declaration.schema.json +28 -0
  41. package/dist/workspace/init.js +2 -2
  42. package/dist/workspace/reason-codes.d.ts +9 -0
  43. package/dist/workspace/reason-codes.d.ts.map +1 -1
  44. package/dist/workspace/reason-codes.js +10 -0
  45. package/dist/workspace/release-workflow.d.ts +31 -0
  46. package/dist/workspace/release-workflow.d.ts.map +1 -0
  47. package/dist/workspace/release-workflow.js +150 -0
  48. package/package.json +1 -1
  49. package/src/cli/handlers/promote.ts +280 -83
  50. package/src/cli/main.ts +18 -1
  51. package/src/cli/parse-args.ts +5 -0
  52. package/src/cli/registry.ts +3 -1
  53. package/src/components/promote-rounds.test.ts +220 -0
  54. package/src/components/promote-rounds.ts +232 -0
  55. package/src/components/release-gate.test.ts +47 -0
  56. package/src/components/release-gate.ts +111 -46
  57. package/src/graph-declared-exports.test.ts +40 -0
  58. package/src/graph-declared.ts +9 -1
  59. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  60. package/src/workspace/check-contract.test.ts +3 -2
  61. package/src/workspace/check.schema.json +20 -2
  62. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  63. package/src/workspace/checks/ci-workflow.ts +129 -0
  64. package/src/workspace/checks/security.ts +4 -4
  65. package/src/workspace/checks.ts +4 -1
  66. package/src/workspace/ci-init.test.ts +159 -0
  67. package/src/workspace/ci-init.ts +445 -0
  68. package/src/workspace/ci-report-url.test.ts +6 -1
  69. package/src/workspace/ci-workflow.ts +71 -0
  70. package/src/workspace/declaration.schema.json +28 -0
  71. package/src/workspace/declaration.ts +32 -3
  72. package/src/workspace/init.ts +2 -2
  73. package/src/workspace/reason-codes.test.ts +2 -0
  74. package/src/workspace/reason-codes.ts +10 -0
  75. package/src/workspace/release-workflow.ts +167 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * `chant ci init --workflow <name> [--forge <forge>] [--security <group>]...
3
+ * [--yes]` (#4168 step 6, section 9): set a workspace up for a prescribed
4
+ * delivery workflow (../ci-workflow.ts).
5
+ *
6
+ * It proposes, prints the proposal as a diff, and writes only on `--yes` or a
7
+ * yes at the prompt. What it proposes comes from what `workspace check`'s
8
+ * WSP151 reports for the workspace as it would be with `ci.workflow` set, so
9
+ * a second run proposes only what is still missing, and nothing once the
10
+ * workspace has it all.
11
+ *
12
+ * - The declaration gains `ci.workflow`, a `ci.green` block when it has none
13
+ * (one phase per GitHub workflow, its jobs as the check runs, and the
14
+ * workflows a pull request runs as `preMerge`), and `preMerge` when the
15
+ * block has none. `--security` adds groups to the top-level `security`;
16
+ * without it no group is turned on, and the groups are listed.
17
+ * - A member that builds with chant and declares no component gets a
18
+ * `<member>.component.ts` stub with a gate and a TODO line, which WSP151
19
+ * keeps reporting until someone fills the stub in. A `chant.config.ts`
20
+ * that declares no environments gains `environments: ["staging", "prod"]`
21
+ * when its default export is a plain object literal.
22
+ * - The generated files are written by the generators that own them, run
23
+ * after the edits: `chant build --components --generate <forge> --pr-loop`
24
+ * in each member, `chant ci workflow` for the tick, and `chant release
25
+ * workflow` for the publish job. Each is named in the proposal before it
26
+ * runs.
27
+ *
28
+ * It never edits a file the diff did not show, and never overwrites a file
29
+ * it proposed to create that appeared in the meantime.
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
33
+ import { basename, join } from "node:path";
34
+ import { formatError, formatSuccess } from "../cli/format.js";
35
+ import { parseYAMLDocument } from "../yaml.js";
36
+ import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow.js";
37
+ import { CI_WORKFLOWS, STUB_MARKER, appliesOnMerge } from "./ci-workflow.js";
38
+ export { STUB_MARKER };
39
+ import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, readWorkflowSources, workflowsHolding } from "./ci-green-workflow.js";
40
+ import { findWorkspaceRoot } from "../project-root.js";
41
+ import { parseDeclaration, readDeclaration, WorkspaceReadError } from "./declaration.js";
42
+ import { staticPromotionOrder } from "./promotion-order.js";
43
+ import { SECURITY_GROUPS } from "./security.js";
44
+ import { gitTop, workingTree } from "./tree.js";
45
+ import { RELEASE_WORKFLOW_FORGES } from "./release-workflow.js";
46
+ const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
47
+ /** The environments a config with none is given. */
48
+ export const DEFAULT_ENVIRONMENTS = ["staging", "prod"];
49
+ const memberDir = (dir) => (dir === "." ? "" : dir);
50
+ const join2 = (dir, file) => (dir === "" ? file : `${dir}/${file}`);
51
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
52
+ /** The forge whose pipeline files the repository has, or null. */
53
+ export function inferForge(repoRoot) {
54
+ if (existsSync(join(repoRoot, ".github", "workflows")))
55
+ return "github";
56
+ if (existsSync(join(repoRoot, ".forgejo", "workflows")))
57
+ return "forgejo";
58
+ if (existsSync(join(repoRoot, ".gitlab-ci.yml")))
59
+ return "gitlab";
60
+ return null;
61
+ }
62
+ /** The branch `origin/HEAD` names, else `main`. */
63
+ export function inferTrunk(repoRoot) {
64
+ const r = spawnSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoRoot, encoding: "utf-8" });
65
+ const ref = r.status === 0 ? r.stdout.trim() : "";
66
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : "main";
67
+ }
68
+ /** Whether a workflow document runs on a pull request. */
69
+ function onPullRequest(doc) {
70
+ if (!isObject(doc))
71
+ return false;
72
+ const on = doc.on ?? doc["true"];
73
+ if (typeof on === "string")
74
+ return on === "pull_request" || on === "pull_request_target";
75
+ if (Array.isArray(on))
76
+ return on.includes("pull_request") || on.includes("pull_request_target");
77
+ return isObject(on) && ("pull_request" in on || "pull_request_target" in on);
78
+ }
79
+ /**
80
+ * A `ci.green` block from the repository's GitHub workflows: a phase per
81
+ * workflow holding its jobs' check runs, all required, and those a pull
82
+ * request runs as `preMerge`. chant's own generated workflows are left out.
83
+ */
84
+ export function inferGreen(repoRoot) {
85
+ const phases = {};
86
+ const preMerge = [];
87
+ for (const s of readWorkflowSources(repoRoot, "")) {
88
+ if (/\/chant-[^/]*\.ya?ml$/.test(s.path))
89
+ continue;
90
+ let doc;
91
+ try {
92
+ doc = parseYAMLDocument(s.text);
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ const runs = jobCheckRuns(doc);
98
+ if (runs.length === 0)
99
+ continue;
100
+ const name = basename(s.path).replace(/\.ya?ml$/, "").replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64) || "check";
101
+ phases[name] = runs;
102
+ if (onPullRequest(doc))
103
+ preMerge.push(name);
104
+ }
105
+ if (Object.keys(phases).length === 0) {
106
+ phases.check = ["check"];
107
+ preMerge.push("check");
108
+ }
109
+ return { branch: inferTrunk(repoRoot), phases, require: Object.keys(phases), preMerge: preMerge.length > 0 ? preMerge : [Object.keys(phases)[0]] };
110
+ }
111
+ /**
112
+ * The declaration's text with `next`'s top-level keys. A key the file lacks
113
+ * is added as text before the closing brace, so the rest keeps its layout; a
114
+ * key it has whose value changes rewrites the file, two-space indented.
115
+ */
116
+ export function editDeclarationText(text, raw, next) {
117
+ const whole = `${JSON.stringify(next, null, 2)}\n`;
118
+ const changed = Object.keys(next).filter((k) => JSON.stringify(raw[k]) !== JSON.stringify(next[k]));
119
+ if (changed.length === 0)
120
+ return text;
121
+ if (changed.some((k) => k in raw))
122
+ return whole;
123
+ const close = text.lastIndexOf("}");
124
+ if (close < 0)
125
+ return whole;
126
+ const head = text.slice(0, close).replace(/\s*$/, "");
127
+ const added = changed.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(next[k], null, 2).replace(/\n/g, "\n ")}`).join(",\n");
128
+ const out = `${head}${head.endsWith("{") ? "" : ","}\n${added}\n${text.slice(close)}`;
129
+ try {
130
+ return JSON.stringify(JSON.parse(out)) === JSON.stringify(next) ? out : whole;
131
+ }
132
+ catch {
133
+ return whole;
134
+ }
135
+ }
136
+ /** An identifier for a member's stub export. */
137
+ function exportName(member) {
138
+ const parts = member.split(/[^A-Za-z0-9]+/).filter(Boolean);
139
+ const id = parts.map((p, i) => (i === 0 ? p.toLowerCase() : p[0].toUpperCase() + p.slice(1))).join("");
140
+ return /^[A-Za-z_]/.test(id) ? id : `component${id}`;
141
+ }
142
+ /** The component stub for a member that declares none: a gate and a TODO, so WSP151 keeps asking until it is filled in. */
143
+ export function componentStub(member, lastEnvironment) {
144
+ return [
145
+ `// ${STUB_MARKER}: say how ${member} is built and deployed, then delete this line.`,
146
+ "// The gate makes a promote wait on an approval; a release gate decides it",
147
+ `// for each environment, ${lastEnvironment} among them.`,
148
+ "// chant-disable COMP004 -- the release gate: the person promoting approves each environment with chant approve",
149
+ 'import { gate, phase, type Component } from "@intentius/chant/components/component";',
150
+ "",
151
+ `export const ${exportName(member)}: Component = {`,
152
+ ` name: ${JSON.stringify(member)},`,
153
+ " deploy: [",
154
+ ` phase("Release", [gate("release", { description: "Approve the release for ${lastEnvironment}." })]),`,
155
+ " ],",
156
+ "};",
157
+ "",
158
+ ].join("\n");
159
+ }
160
+ /** The config with `environments` added after `export default {`, or null when its default export is not a plain object literal. */
161
+ export function withEnvironments(config, environments = DEFAULT_ENVIRONMENTS) {
162
+ const m = /export\s+default\s*\{/.exec(config);
163
+ if (!m)
164
+ return null;
165
+ const at = m.index + m[0].length;
166
+ return `${config.slice(0, at)}\n environments: ${JSON.stringify(environments).replace(/,/g, ", ")},${config.slice(at)}`;
167
+ }
168
+ function read(tree, path) {
169
+ if (tree.stat(path) !== "file")
170
+ return null;
171
+ try {
172
+ return tree.read(path);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ /** Run WSP151 over `declaration` and the tree as it is. */
179
+ function check(declaration, tree) {
180
+ return CI_WORKFLOW_CHECKS.flatMap((c) => c.check({ declaration, tree, groups: [], kinds: new Map(), kindProblems: [] }));
181
+ }
182
+ /**
183
+ * What `chant ci init` would write for the workspace at `root`, and the
184
+ * generators it would run. Reads only; {@link applyCiInit} writes.
185
+ */
186
+ export function proposeCiInit(root, options) {
187
+ const tree = workingTree(root);
188
+ const current = readDeclaration(tree);
189
+ if (current.file.endsWith(".jsonc")) {
190
+ throw new WorkspaceReadError("declaration-invalid", `${current.file} is JSONC, which chant ci init does not rewrite, since its comments would be lost; add ci.workflow by hand, then rerun chant ci init for the rest`);
191
+ }
192
+ const repoRoot = gitTop(root) ?? root;
193
+ const text = readFileSync(join(root, current.file), "utf-8");
194
+ const raw = JSON.parse(text);
195
+ const ci = isObject(raw.ci) ? { ...raw.ci } : {};
196
+ ci.workflow = options.workflow;
197
+ if (!isObject(ci.green))
198
+ ci.green = inferGreen(repoRoot);
199
+ else if (!Array.isArray(ci.green.preMerge) || ci.green.preMerge.length === 0) {
200
+ const inferred = inferGreen(repoRoot).preMerge;
201
+ const declared = Object.keys(ci.green.phases ?? {});
202
+ const pick = inferred.filter((p) => declared.includes(p));
203
+ ci.green = { ...ci.green, preMerge: pick.length > 0 ? pick : declared.slice(0, 1) };
204
+ }
205
+ const next = { ...raw, ci };
206
+ const security = [...new Set([...(raw.security ?? []), ...(options.security ?? [])])];
207
+ if (security.length > 0)
208
+ next.security = security;
209
+ const after = editDeclarationText(text, raw, next);
210
+ const proposed = parseDeclaration(after, current.file);
211
+ const files = [];
212
+ if (after !== text)
213
+ files.push({ path: current.file, before: text, after });
214
+ const notes = [];
215
+ const findings = check(proposed, tree);
216
+ const reported = (code, entity) => findings.some((f) => f.code === code && (entity === undefined || f.entity === entity));
217
+ const forge = options.forge ?? inferForge(repoRoot);
218
+ const releasing = proposed.members.filter((m) => (m.ciWorkflow ?? proposed.ci?.workflow) && (m.ciWorkflow !== null || tree.stat(join2(memberDir(m.dir), "chant.config.ts")) === "file"));
219
+ const firstEnv = new Map();
220
+ for (const m of releasing) {
221
+ const dir = memberDir(m.dir);
222
+ const configPath = join2(dir, "chant.config.ts");
223
+ const config = read(tree, configPath);
224
+ let order = config === null ? [] : (staticPromotionOrder(config) ?? null);
225
+ if (reported("ci-workflow-no-environments", m.name) && config !== null) {
226
+ const edited = withEnvironments(config);
227
+ if (edited) {
228
+ files.push({ path: configPath, before: config, after: edited });
229
+ order = DEFAULT_ENVIRONMENTS.map((name) => ({ name, canary: false }));
230
+ notes.push(`${configPath}: ${DEFAULT_ENVIRONMENTS.join(", ")} is a guess at the promotion order; edit it if yours differs.`);
231
+ }
232
+ else {
233
+ notes.push(`${configPath}: add environments, the promotion order; its default export is not a plain object, so chant ci init leaves it alone.`);
234
+ }
235
+ }
236
+ const names = (order ?? []).map((s) => s.name);
237
+ if (names.length > 0)
238
+ firstEnv.set(m.name, names[0]);
239
+ if (reported("ci-workflow-no-components", m.name)) {
240
+ const stubPath = join2(dir, `${m.name}.component.ts`);
241
+ if (!tree.stat(stubPath))
242
+ files.push({ path: stubPath, before: null, after: componentStub(m.name, names[names.length - 1] ?? "the last environment") });
243
+ }
244
+ for (const f of findings.filter((f) => f.code === "ci-workflow-ungated-env" && f.entity === m.name)) {
245
+ notes.push(`${f.treeFile}: add a gate() step, so a promote to the last environment waits on an approval; chant ci init does not edit a component you wrote.`);
246
+ }
247
+ }
248
+ const commands = [];
249
+ if (forge === null && (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs") || reported("ci-workflow-publish-missing"))) {
250
+ notes.push("No .github/workflows, .forgejo/workflows or .gitlab-ci.yml was found, so no pipeline is generated; rerun with --forge github|gitlab|forgejo.");
251
+ }
252
+ else if (forge !== null) {
253
+ if (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs")) {
254
+ for (const m of releasing) {
255
+ const env = firstEnv.get(m.name);
256
+ commands.push({
257
+ cwd: memberDir(m.dir) || ".",
258
+ // The root member builds alone: at a workspace root, chant build refuses without --root-only (WSP000).
259
+ args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(m.ciWorkflow ?? proposed.ci.workflow) ? ["--apply-on-merge"] : [])],
260
+ why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesOnMerge(m.ciWorkflow ?? proposed.ci.workflow) ? "apply" : "record"} on merge`,
261
+ });
262
+ }
263
+ if (forge === "github") {
264
+ // ci workflow refuses when no workflow holds a required phase's check run, so say so instead of running it.
265
+ const green = proposed.ci?.green;
266
+ const holding = green ? workflowsHolding(green, readWorkflowSources(repoRoot, CI_GREEN_WORKFLOW_FILE)).workflows : [];
267
+ if (holding.length > 0)
268
+ commands.push({ cwd: ".", args: ["ci", "workflow"], why: "the tick that tags green commits" });
269
+ else
270
+ notes.push("No workflow in .github/workflows runs a job of a required ci.green phase on the branch yet, so the tick is not generated; add your CI, name its jobs in ci.green.phases, then rerun chant ci init.");
271
+ }
272
+ else
273
+ notes.push(`${forge} has no check runs for ci tick to read; set ci.green.source to runs and run chant ci tick from a scheduled job (see the green-commits guide).`);
274
+ }
275
+ if (reported("ci-workflow-publish-missing"))
276
+ commands.push({ cwd: ".", args: ["release", "workflow", "--forge", forge], why: "the publish job for release/** tags" });
277
+ }
278
+ return { files, commands, notes, findings };
279
+ }
280
+ /** A unified-style diff of two texts, for the proposal. Line-based, from their longest common subsequence. */
281
+ export function lineDiff(path, before, after) {
282
+ const a = before === null ? [] : before.replace(/\n$/, "").split("\n");
283
+ const b = after.replace(/\n$/, "").split("\n");
284
+ const n = a.length;
285
+ const m = b.length;
286
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
287
+ for (let i = n - 1; i >= 0; i--)
288
+ for (let j = m - 1; j >= 0; j--)
289
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
290
+ const out = [`--- ${before === null ? "/dev/null" : `a/${path}`}`, `+++ b/${path}`];
291
+ let i = 0;
292
+ let j = 0;
293
+ while (i < n || j < m) {
294
+ if (i < n && j < m && a[i] === b[j]) {
295
+ out.push(` ${a[i]}`);
296
+ i++;
297
+ j++;
298
+ }
299
+ else if (i < n && (j >= m || lcs[i + 1][j] >= lcs[i][j + 1]))
300
+ out.push(`- ${a[i++]}`);
301
+ else
302
+ out.push(`+ ${b[j++]}`);
303
+ }
304
+ // Keep three lines of context around each change.
305
+ const keep = new Set();
306
+ out.forEach((l, k) => {
307
+ if (k < 2 || l.startsWith("+ ") || l.startsWith("- "))
308
+ for (let d = -3; d <= 3; d++)
309
+ keep.add(k + d);
310
+ });
311
+ const shown = [];
312
+ let skipped = false;
313
+ out.forEach((l, k) => {
314
+ if (keep.has(k)) {
315
+ shown.push(l);
316
+ skipped = false;
317
+ }
318
+ else if (!skipped) {
319
+ shown.push(" ...");
320
+ skipped = true;
321
+ }
322
+ });
323
+ return shown.join("\n");
324
+ }
325
+ /** Write the proposal's files. A file it would create that now exists is refused, and nothing is written. */
326
+ export function applyCiInit(root, proposal) {
327
+ for (const f of proposal.files) {
328
+ if (f.before === null && existsSync(join(root, f.path)))
329
+ throw new Error(`${f.path} appeared after the proposal was made; nothing written`);
330
+ if (f.before !== null && readFileSync(join(root, f.path), "utf-8") !== f.before)
331
+ throw new Error(`${f.path} changed after the proposal was made; nothing written`);
332
+ }
333
+ for (const f of proposal.files)
334
+ writeFileSync(join(root, f.path), f.after, f.before === null ? { flag: "wx" } : {});
335
+ return proposal.files.map((f) => f.path);
336
+ }
337
+ async function confirm(question) {
338
+ const { createInterface } = await import("node:readline");
339
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
340
+ return new Promise((resolveAnswer) => {
341
+ rl.question(question, (answer) => {
342
+ resolveAnswer(/^y(es)?$/i.test(answer.trim()));
343
+ rl.close();
344
+ });
345
+ rl.on("close", () => resolveAnswer(false));
346
+ });
347
+ }
348
+ /** Run a chant command the way this process was run, so `npx chant` and a source checkout both work. */
349
+ function runChant(cwd, args) {
350
+ const r = spawnSync(process.execPath, [...process.execArgv, process.argv[1], ...args], { cwd, stdio: "inherit" });
351
+ return r.status ?? 1;
352
+ }
353
+ export async function runCiInit(ctx) {
354
+ const { args } = ctx;
355
+ const workflow = (args.ciWorkflows ?? [])[0];
356
+ if (!workflow || !CI_WORKFLOWS.includes(workflow) || (args.ciWorkflows ?? []).length > 1) {
357
+ console.error(formatError({ message: `--workflow names one of ${CI_WORKFLOWS.join(", ")}`, hint: USAGE }));
358
+ return 1;
359
+ }
360
+ if (args.forge !== undefined && !RELEASE_WORKFLOW_FORGES.includes(args.forge)) {
361
+ console.error(formatError({ message: `--forge ${JSON.stringify(args.forge)} is not one of ${RELEASE_WORKFLOW_FORGES.join(", ")}`, hint: USAGE }));
362
+ return 1;
363
+ }
364
+ const security = args.securityGroups ?? [];
365
+ const unknown = security.filter((g) => !SECURITY_GROUPS.includes(g));
366
+ if (unknown.length > 0) {
367
+ console.error(formatError({ message: `--security ${unknown.join(", ")} is not a group; the groups are ${SECURITY_GROUPS.join(", ")}`, hint: USAGE }));
368
+ return 1;
369
+ }
370
+ const found = findWorkspaceRoot(process.cwd());
371
+ if (!found) {
372
+ console.error(formatError({ message: "no chant.workspace.json between this directory and the git root", hint: "Run chant workspace init first, then chant ci init." }));
373
+ return 1;
374
+ }
375
+ const root = realpathSync(found.dir);
376
+ let proposal;
377
+ try {
378
+ proposal = proposeCiInit(root, { workflow: workflow, ...(args.forge ? { forge: args.forge } : {}), security: security });
379
+ }
380
+ catch (err) {
381
+ console.error(formatError({ message: `chant ci init: ${err instanceof WorkspaceReadError ? err.describe() : err.message}`, hint: USAGE }));
382
+ return 1;
383
+ }
384
+ if (proposal.files.length === 0 && proposal.commands.length === 0) {
385
+ for (const n of proposal.notes)
386
+ console.log(n);
387
+ console.log(formatSuccess(`The workspace has what ${workflow} needs; chant workspace check reports anything left.`));
388
+ return 0;
389
+ }
390
+ for (const f of proposal.files) {
391
+ console.log(lineDiff(f.path, f.before, f.after));
392
+ console.log("");
393
+ }
394
+ if (proposal.commands.length > 0) {
395
+ console.log("Then runs:");
396
+ for (const c of proposal.commands)
397
+ console.log(` (in ${c.cwd}) chant ${c.args.join(" ")} # ${c.why}`);
398
+ console.log("");
399
+ }
400
+ for (const n of proposal.notes)
401
+ console.log(`Note: ${n}`);
402
+ if (security.length === 0)
403
+ console.log(`No security group is turned on. Add one later with chant ci init --workflow ${workflow} --security <group>: ${SECURITY_GROUPS.join(", ")}.`);
404
+ console.log("");
405
+ let write = !!args.yes;
406
+ if (!write) {
407
+ if (!process.stdin.isTTY) {
408
+ console.log("Nothing written. Re-run with --yes to write it.");
409
+ return 0;
410
+ }
411
+ write = await confirm("Write these files and run the generators? [y/N] ");
412
+ }
413
+ if (!write) {
414
+ console.log("Nothing written.");
415
+ return 0;
416
+ }
417
+ try {
418
+ for (const path of applyCiInit(root, proposal))
419
+ console.log(`Wrote ${path}`);
420
+ }
421
+ catch (err) {
422
+ console.error(formatError({ message: `chant ci init: ${err.message}`, hint: "Rerun chant ci init to see a fresh proposal." }));
423
+ return 1;
424
+ }
425
+ for (const c of proposal.commands) {
426
+ const status = runChant(join(root, c.cwd), c.args);
427
+ if (status !== 0) {
428
+ console.error(formatError({ message: `chant ${c.args.join(" ")} failed in ${c.cwd}`, hint: "Fix what it reports and rerun chant ci init; it proposes only what is still missing." }));
429
+ return 1;
430
+ }
431
+ }
432
+ console.log(formatSuccess("Done. chant workspace check reports what the workflow still lacks, such as the component stubs to fill in."));
433
+ return 0;
434
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The prescribed delivery workflows (#4168 section 7): `ci.workflow` in the
3
+ * declaration, at the top level or on a member, which wins.
4
+ *
5
+ * `trunk-curated`: a merge records the plan and deploys nothing (#4168 4.1),
6
+ * the quick checks run before the branch moves (4.4), and a release is cut
7
+ * from a green commit and promoted through the member's environments, gated
8
+ * in the last (4.2). `trunk-curated+apply-on-merge` is the same with the apply
9
+ * on merge turned back on (section 5).
10
+ *
11
+ * A workflow requires no security group: those are opt-in on their own
12
+ * (./security.ts). `chant workspace check` reports what a member under a
13
+ * workflow still lacks (./checks/ci-workflow.ts, WSP151), and `chant ci
14
+ * init` writes what it can. {@link CI_WORKFLOW_REQUIREMENTS} is the table the
15
+ * reference page prints.
16
+ */
17
+ import type { CiWorkflow, Declaration, Member } from "./declaration.js";
18
+ /** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
19
+ export declare const STUB_MARKER = "TODO(chant ci init)";
20
+ export declare const CI_WORKFLOWS: readonly ["trunk-curated", "trunk-curated+apply-on-merge"];
21
+ /** Whether `workflow` turns the apply on merge back on. */
22
+ export declare function appliesOnMerge(workflow: CiWorkflow): boolean;
23
+ /** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
24
+ export declare function workflowOf(declaration: Pick<Declaration, "ci">, member: Pick<Member, "ciWorkflow"> | null): CiWorkflow | null;
25
+ /** One requirement of the workflows, with the `workspace check` code that reports it missing. */
26
+ export interface CiWorkflowRequirement {
27
+ /** The reason code WSP151 reports. */
28
+ code: string;
29
+ /** What the workflow needs. */
30
+ requirement: string;
31
+ /** What adds it. */
32
+ fix: string;
33
+ /** Whether it applies per member (true) or once for the workspace. */
34
+ perMember: boolean;
35
+ }
36
+ /**
37
+ * Every requirement, in the order `workspace check` reports them. The
38
+ * reference page's table is printed from this list (`ciWorkflowTable`), and a
39
+ * test compares the two.
40
+ */
41
+ export declare const CI_WORKFLOW_REQUIREMENTS: readonly [{
42
+ readonly code: "ci-workflow-green-undeclared";
43
+ readonly requirement: "`ci.green` is declared, so commits get a verdict and a release is cut only from a green one";
44
+ readonly fix: "`chant ci init`, or a hand-written `ci.green` block";
45
+ readonly perMember: false;
46
+ }, {
47
+ readonly code: "ci-workflow-no-pre-merge";
48
+ readonly requirement: "`ci.green.preMerge` names at least one phase, the quick checks that run before the branch moves";
49
+ readonly fix: "`preMerge` in `ci.green`";
50
+ readonly perMember: false;
51
+ }, {
52
+ readonly code: "ci-workflow-apply-in-green";
53
+ readonly requirement: "no phase in `ci.green.require` matches an apply job, which would wait on its own verdict";
54
+ readonly fix: "drop the apply job's pattern from the required phases";
55
+ readonly perMember: false;
56
+ }, {
57
+ readonly code: "ci-workflow-no-components";
58
+ readonly requirement: "the member declares a component (`*.component.ts`)";
59
+ readonly fix: "`chant ci init` writes a stub";
60
+ readonly perMember: true;
61
+ }, {
62
+ readonly code: "ci-workflow-no-environments";
63
+ readonly requirement: "the member's `chant.config.ts` declares `environments`, the promotion order";
64
+ readonly fix: "`environments` in `chant.config.ts`";
65
+ readonly perMember: true;
66
+ }, {
67
+ readonly code: "ci-workflow-ungated-env";
68
+ readonly requirement: "each component has a gate, so a promote to the last environment waits on an approval";
69
+ readonly fix: "a `gate()` step in the component";
70
+ readonly perMember: true;
71
+ }, {
72
+ readonly code: "ci-workflow-pipeline-missing";
73
+ readonly requirement: "a pipeline file runs the plan job, the merge job (`ci run record`, or `ci run apply` under apply on merge) and `chant ci tick`";
74
+ readonly fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`";
75
+ readonly perMember: false;
76
+ }, {
77
+ readonly code: "ci-workflow-pipeline-differs";
78
+ readonly requirement: "the merge job matches the workflow: it records under `trunk-curated` and applies under `trunk-curated+apply-on-merge`";
79
+ readonly fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge";
80
+ readonly perMember: false;
81
+ }, {
82
+ readonly code: "ci-workflow-publish-missing";
83
+ readonly requirement: "a pipeline file runs `chant release publish`";
84
+ readonly fix: "`chant release workflow`";
85
+ readonly perMember: false;
86
+ }];
87
+ export type CiWorkflowCode = (typeof CI_WORKFLOW_REQUIREMENTS)[number]["code"];
88
+ export declare const CI_WORKFLOW_FINDING_CODES: readonly CiWorkflowCode[];
89
+ /** The requirements table the reference page carries, row for row. */
90
+ export declare function ciWorkflowTable(): string;
91
+ //# sourceMappingURL=ci-workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-workflow.d.ts","sourceRoot":"","sources":["../../src/workspace/ci-workflow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAErE,oJAAoJ;AACpJ,eAAO,MAAM,WAAW,wBAAwB,CAAC;AAEjD,eAAO,MAAM,YAAY,4DAA6F,CAAC;AAEvH,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,UAAU,GAAG,OAAO,CAE5D;AAED,6FAA6F;AAC7F,wBAAgB,UAAU,CAAC,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC,GAAG,IAAI,GAAG,UAAU,GAAG,IAAI,CAE7H;AAED,iGAAiG;AACjG,MAAM,WAAW,qBAAqB;IACpC,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,oBAAoB;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;mBAC3B,8BAA8B;0BAAe,6FAA6F;kBAAO,qDAAqD;;;mBACtM,0BAA0B;0BAAe,iGAAiG;kBAAO,0BAA0B;;;mBAC3K,4BAA4B;0BAAe,0FAA0F;kBAAO,uDAAuD;;;mBACnM,2BAA2B;0BAAe,oDAAoD;kBAAO,+BAA+B;;;mBACpI,6BAA6B;0BAAe,6EAA6E;kBAAO,qCAAqC;;;mBACrK,yBAAyB;0BAAe,sFAAsF;kBAAO,kCAAkC;;;mBACvK,8BAA8B;0BAAe,gIAAgI;kBAAO,iFAAiF;;;mBACrQ,8BAA8B;0BAAe,uHAAuH;kBAAO,yFAAyF;;;mBACpQ,6BAA6B;0BAAe,8CAA8C;kBAAO,0BAA0B;;EAChF,CAAC;AAEtD,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,wBAAwB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAE/E,eAAO,MAAM,yBAAyB,EAAkD,SAAS,cAAc,EAAE,CAAC;AAElH,sEAAsE;AACtE,wBAAgB,eAAe,IAAI,MAAM,CAGxC"}
@@ -0,0 +1,33 @@
1
+ /** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
2
+ export const STUB_MARKER = "TODO(chant ci init)";
3
+ export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge"];
4
+ /** Whether `workflow` turns the apply on merge back on. */
5
+ export function appliesOnMerge(workflow) {
6
+ return workflow === "trunk-curated+apply-on-merge";
7
+ }
8
+ /** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
9
+ export function workflowOf(declaration, member) {
10
+ return member?.ciWorkflow ?? declaration.ci?.workflow ?? null;
11
+ }
12
+ /**
13
+ * Every requirement, in the order `workspace check` reports them. The
14
+ * reference page's table is printed from this list (`ciWorkflowTable`), and a
15
+ * test compares the two.
16
+ */
17
+ export const CI_WORKFLOW_REQUIREMENTS = [
18
+ { code: "ci-workflow-green-undeclared", requirement: "`ci.green` is declared, so commits get a verdict and a release is cut only from a green one", fix: "`chant ci init`, or a hand-written `ci.green` block", perMember: false },
19
+ { code: "ci-workflow-no-pre-merge", requirement: "`ci.green.preMerge` names at least one phase, the quick checks that run before the branch moves", fix: "`preMerge` in `ci.green`", perMember: false },
20
+ { code: "ci-workflow-apply-in-green", requirement: "no phase in `ci.green.require` matches an apply job, which would wait on its own verdict", fix: "drop the apply job's pattern from the required phases", perMember: false },
21
+ { code: "ci-workflow-no-components", requirement: "the member declares a component (`*.component.ts`)", fix: "`chant ci init` writes a stub", perMember: true },
22
+ { code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
23
+ { code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
24
+ { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the merge job (`ci run record`, or `ci run apply` under apply on merge) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
25
+ { code: "ci-workflow-pipeline-differs", requirement: "the merge job matches the workflow: it records under `trunk-curated` and applies under `trunk-curated+apply-on-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
26
+ { code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
27
+ ];
28
+ export const CI_WORKFLOW_FINDING_CODES = CI_WORKFLOW_REQUIREMENTS.map((r) => r.code);
29
+ /** The requirements table the reference page carries, row for row. */
30
+ export function ciWorkflowTable() {
31
+ const rows = CI_WORKFLOW_REQUIREMENTS.map((r) => `| \`${r.code}\` | ${r.requirement} | ${r.perMember ? "each member" : "the workspace"} | ${r.fix} |`);
32
+ return ["| Code | Requirement | Checked for | Added by |", "|---|---|---|---|", ...rows].join("\n");
33
+ }
@@ -160,6 +160,12 @@ export interface CiGreen {
160
160
  phases: CiPhase[];
161
161
  /** The phases a commit must pass, by name, in file order. */
162
162
  require: string[];
163
+ /**
164
+ * The phases that must pass before the branch moves (#4168 4.4), by name,
165
+ * in file order: the quick checks a pull request, a merge queue or a local
166
+ * run gives before a change lands. Absent or empty when the declaration names none.
167
+ */
168
+ preMerge?: string[];
163
169
  /**
164
170
  * The full sha of the first commit on the branch whose ci tags need a
165
171
  * statement signed by a runner key (#4031). A tag on a strict
@@ -181,9 +187,19 @@ export interface CiGreen {
181
187
  }
182
188
  /** Where `ci tick` reads phase results (#4044): forge check runs, or chant Op runs. */
183
189
  export type CiSource = "checks" | "runs";
190
+ /**
191
+ * A prescribed delivery workflow (#4168 section 7). `trunk-curated`: a merge
192
+ * records the plan and deploys nothing, and releases are cut from green
193
+ * commits and promoted through the environments. `trunk-curated+apply-on-merge`
194
+ * adds the apply on merge as an opt-in. `chant workspace check` reports what a
195
+ * member under one still lacks (./checks/ci-workflow.ts, WSP151).
196
+ */
197
+ export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
184
198
  /** The declaration's `ci` block (#3573). */
185
199
  export interface CiDeclaration {
186
200
  green: CiGreen | null;
201
+ /** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
202
+ workflow?: CiWorkflow;
187
203
  /**
188
204
  * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
189
205
  * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
@@ -493,6 +509,8 @@ export interface Member {
493
509
  travel: boolean;
494
510
  /** The security groups this member adds to the declaration's top-level `security` (#4168 step 7), in file order; empty when it names none. */
495
511
  security: SecurityGroup[];
512
+ /** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
513
+ ciWorkflow: CiWorkflow | null;
496
514
  suppress: Suppression[];
497
515
  /** The entry's JSON Pointer in the file, for messages. */
498
516
  pointer: string;