@dbx-tools/projen 0.6.224 → 0.6.225

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/README.md CHANGED
@@ -57,6 +57,9 @@ Repository policy stays in the consuming `.projenrc.ts`:
57
57
  artifact upload, and deployment without naming a docs script or output tree.
58
58
  - `releasePythonRoot` passes the actual Python package root to local release
59
59
  preparation. Omit it when the workspace has no standard Python packages.
60
+ - `releaseSummary` controls optional AI release notes. It defaults to enabled
61
+ with Cursor, Codex, Claude fallback order; pass `false` to disable or provide
62
+ a `providers` array to choose the order/subset.
60
63
  - `pullRequestTitlePolicy` configures semantic title types and scope policy.
61
64
  Omit it or pass `false` to disable the title job.
62
65
  - `workflowCacheIgnorePaths` excludes generated output trees that may contain
@@ -441,7 +444,17 @@ it. `--message` sets the source commit message. The task never merges
441
444
  the PR unless `--approve` is passed; that option merges the release branch
442
445
  directly through GitHub's merge API so no PR checks are created. A repository
443
446
  that blocks direct merges falls back to an immediate admin-merged PR. The task
444
- then fast-forwards the still-active source branch to the release commit. npm
447
+ then fast-forwards the still-active source branch to the release commit.
448
+
449
+ After validation, release preparation optionally generates an immutable
450
+ `docs/releases/v<version>.md`. It tries the installed Cursor agent first, then
451
+ Codex, then Claude, all in non-interactive read-only modes against bounded Git
452
+ context. An unavailable, unauthenticated, failed, or empty provider falls
453
+ through to the next; if none works, release preparation continues without a
454
+ summary. The same file is included in the release PR and prepended to GitHub's
455
+ generated release notes, so repository and GitHub views share one source.
456
+
457
+ npm
445
458
  uses `npm config get registry` and publishes to a local Verdaccio automatically.
446
459
  Publishable JavaScript members compile once
447
460
  from the root in parallel, then upload through a bounded pool without rerunning
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.6.224";
6
+ export const PACKAGE_VERSION = "0.6.225";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -55,8 +55,9 @@ export { RustReleaseOs, RustReleaseCpu, UNIFFI_RELEASE_TARGETS, DBXToolsRustProj
55
55
  export type { CargoDependencyOptions, CargoDependency, RustCliOptions, RustPackageOptions, DBXToolsRustWorkspaceOptions, RustReleasePlatform, UniFFIReleaseTarget, RustBindingMapping, RustWorkspaceMapping, RustReleaseBinaryAssetMapping, RustReleaseBinaryMapping } from "./src/project-rs.ts";
56
56
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
57
57
  export { DBXToolsRelease } from "./src/release.ts";
58
- export type { ReleaseStage, ReleaseDocsOptions, DBXToolsReleaseOptions } from "./src/release.ts";
59
- export { RELEASE_TAG, RELEASE_SHA, RELEASE_VERSION } from "./src/release-dispatch.ts";
58
+ export type { ReleaseStage, ReleaseDocsOptions, ReleaseSummaryOptions, DBXToolsReleaseOptions } from "./src/release.ts";
59
+ export { RELEASE_TAG, RELEASE_SHA, RELEASE_VERSION, RELEASE_SUMMARY_DIRECTORY, RELEASE_SUMMARY_PROVIDER_NAMES, RELEASE_SUMMARY_FILE } from "./src/release-dispatch.ts";
60
+ export type { ReleaseSummaryProviderName } from "./src/release-dispatch.ts";
60
61
  export type { GithubRepositoryIdentity } from "./src/release-github.ts";
61
62
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
62
63
  export type { PackageTag } from "./src/tags.ts";
package/package.json CHANGED
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.7.0",
28
- "@dbx-tools/core": "0.6.224",
29
- "@dbx-tools/path": "0.6.224",
30
- "@dbx-tools/shared-core": "0.6.224",
28
+ "@dbx-tools/core": "0.6.225",
29
+ "@dbx-tools/path": "0.6.225",
30
+ "@dbx-tools/shared-core": "0.6.225",
31
31
  "commander": "^15.0.0",
32
32
  "concurrently": "^10.0.3",
33
33
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.224",
50
+ "version": "0.6.225",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/project-js.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  import { PnpmWorkspaceState, type DBXToolsPNPMWorkspaceOptions } from "./pnpm-workspace.ts";
42
42
  import type { DBXToolsProject, DBXToolsProjectOptions as CommonProjectOptions } from "./project.ts";
43
43
  import { applyCompiledPublish } from "./publish.ts";
44
- import { DBXToolsRelease, type ReleaseDocsOptions } from "./release.ts";
44
+ import { DBXToolsRelease, type ReleaseDocsOptions, type ReleaseSummaryOptions } from "./release.ts";
45
45
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
46
46
  import { DBXToolsRootTsconfig } from "./tsconfig.ts";
47
47
  import { DBXToolsVsCode } from "./vscode.ts";
@@ -621,6 +621,8 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
621
621
  readonly releaseDocs?: ReleaseDocsOptions;
622
622
  /** Python package root passed to local release preparation when configured. */
623
623
  readonly releasePythonRoot?: string;
624
+ /** Optional AI-generated release summary. Defaults to enabled. */
625
+ readonly releaseSummary?: boolean | ReleaseSummaryOptions;
624
626
  /** Set to `false` to omit normal npm workspace publication. */
625
627
  readonly nodeRelease?: boolean;
626
628
  /** Unified dbx-tools release workflow, or no release surface. Defaults to `dbx-tools`. */
@@ -1540,6 +1542,7 @@ function initProject(
1540
1542
  nodeRelease: options.nodeRelease,
1541
1543
  docs: options.releaseDocs,
1542
1544
  pythonRoot: options.releasePythonRoot,
1545
+ summary: options.releaseSummary,
1543
1546
  });
1544
1547
  }
1545
1548
  }
package/src/project-rs.ts CHANGED
@@ -19,6 +19,7 @@ import { pythonModuleName, type PythonPackageOptions } from "./project-py.ts";
19
19
  import type { DBXToolsProject } from "./project.ts";
20
20
  import {
21
21
  RELEASE_SHA,
22
+ RELEASE_SUMMARY_FILE,
22
23
  RELEASE_TAG,
23
24
  RELEASE_VERSION,
24
25
  releaseSourceSteps,
@@ -1359,6 +1360,7 @@ function rustGitHubReleaseJob(): Job {
1359
1360
  uses: "softprops/action-gh-release@v2",
1360
1361
  with: {
1361
1362
  files: "dist/rust-release/*",
1363
+ body_path: RELEASE_SUMMARY_FILE,
1362
1364
  "generate-release-notes": true,
1363
1365
  tag_name: RELEASE_TAG,
1364
1366
  target_commitish: RELEASE_SHA,
@@ -4,6 +4,16 @@ import type { JobStep } from "projen/lib/github/workflows-model";
4
4
  export const RELEASE_TAG = "${{ needs.verify-context.outputs.release_tag }}";
5
5
  export const RELEASE_SHA = "${{ needs.verify-context.outputs.expected_sha }}";
6
6
  export const RELEASE_VERSION = "${{ needs.verify-context.outputs.release_version }}";
7
+ export const RELEASE_SUMMARY_DIRECTORY = "docs/releases";
8
+ export const RELEASE_SUMMARY_PROVIDER_NAMES = ["cursor", "codex", "claude"] as const;
9
+ export type ReleaseSummaryProviderName = (typeof RELEASE_SUMMARY_PROVIDER_NAMES)[number];
10
+
11
+ /** Immutable repository path for one version's release summary. */
12
+ export function releaseSummaryFile(version: string): string {
13
+ return `${RELEASE_SUMMARY_DIRECTORY}/v${version.replace(/^v/, "")}.md`;
14
+ }
15
+
16
+ export const RELEASE_SUMMARY_FILE = releaseSummaryFile(RELEASE_VERSION);
7
17
 
8
18
  /** Check out and verify the immutable commit selected by the release tag. */
9
19
  export function releaseSourceSteps(): readonly JobStep[] {
package/src/release.ts CHANGED
@@ -5,7 +5,12 @@ import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflo
5
5
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
6
6
  import { projectReleaseBranch, type DBXToolsJavaScriptProject } from "./project-js.ts";
7
7
  import { applyTasks, taskScript } from "./project.ts";
8
- import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
8
+ import {
9
+ RELEASE_VERSION,
10
+ releaseSourceSteps,
11
+ type ReleaseSummaryProviderName,
12
+ } from "./release-dispatch.ts";
13
+ import { readWorkspaceVersion } from "./workspace-version.ts";
9
14
 
10
15
  const NODE_VERSION = "lts/*";
11
16
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
@@ -28,6 +33,16 @@ export interface ReleaseDocsOptions {
28
33
  readonly artifactPath: string;
29
34
  }
30
35
 
36
+ /** Optional local AI summary generation during reviewed release preparation. */
37
+ export interface ReleaseSummaryOptions {
38
+ /**
39
+ * Provider fallback order.
40
+ *
41
+ * @default ["cursor", "codex", "claude"]
42
+ */
43
+ readonly providers?: readonly ReleaseSummaryProviderName[];
44
+ }
45
+
31
46
  /** Options for {@link DBXToolsRelease}. */
32
47
  export interface DBXToolsReleaseOptions {
33
48
  /** Git tag prefix. Defaults to `v`. */
@@ -38,6 +53,11 @@ export interface DBXToolsReleaseOptions {
38
53
  readonly docs?: ReleaseDocsOptions;
39
54
  /** Python package root passed to local release preparation. */
40
55
  readonly pythonRoot?: string;
56
+ /**
57
+ * Generate a versioned release summary locally. Defaults to enabled with the
58
+ * standard Cursor, Codex, Claude fallback order.
59
+ */
60
+ readonly summary?: boolean | ReleaseSummaryOptions;
41
61
  }
42
62
 
43
63
  /** Locate the unified workflow so attached language workspaces can add jobs. */
@@ -320,6 +340,13 @@ export class DBXToolsRelease extends Component {
320
340
  super(project);
321
341
  const tagPrefix = options.tagPrefix ?? "v";
322
342
  const releaseBranch = projectReleaseBranch(project);
343
+ const summary = options.summary ?? true;
344
+ const summaryArgs =
345
+ summary === false || (typeof summary === "object" && summary.providers?.length === 0)
346
+ ? ["--no-release-summary"]
347
+ : typeof summary === "object" && summary.providers
348
+ ? [`--release-summary-providers ${summary.providers.join(",")}`]
349
+ : [];
323
350
  releaseTagPrefixes.set(project, tagPrefix);
324
351
  if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
325
352
  applyTasks(project, {
@@ -344,6 +371,7 @@ export class DBXToolsRelease extends Component {
344
371
  ...(options.pythonRoot
345
372
  ? [`--python-root ${JSON.stringify(options.pythonRoot)}`]
346
373
  : []),
374
+ ...summaryArgs,
347
375
  ].join(" "),
348
376
  ),
349
377
  receiveArgs: true,
@@ -359,8 +387,10 @@ export class DBXToolsRelease extends Component {
359
387
  limitConcurrency: true,
360
388
  concurrencyOptions: { group: "release", cancelInProgress: false },
361
389
  });
390
+ const version = readWorkspaceVersion(project.outdir);
362
391
  workflow.runName =
363
- "release ${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
392
+ `release ${version} ` +
393
+ "${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
364
394
  workflow.on({
365
395
  push: { branches: [releaseBranch], paths: ["VERSION"] },
366
396
  workflowDispatch: {
@@ -14,6 +14,7 @@ import { exec, project } from "@dbx-tools/core";
14
14
  import { log } from "@dbx-tools/shared-core";
15
15
  import { Command } from "commander";
16
16
  import { publishLocalRelease } from "./local-publish.ts";
17
+ import { generateReleaseSummary } from "./release-summary.ts";
17
18
  import {
18
19
  releaseArchitectureOption,
19
20
  releaseLevelOption,
@@ -28,6 +29,10 @@ import {
28
29
  githubTokenArguments,
29
30
  githubTokenEnvironmentName,
30
31
  } from "../src/release-github.ts";
32
+ import {
33
+ RELEASE_SUMMARY_PROVIDER_NAMES,
34
+ type ReleaseSummaryProviderName,
35
+ } from "../src/release-dispatch.ts";
31
36
  import { readWorkspaceVersion, resolveNextVersion } from "../src/workspace-version.ts";
32
37
 
33
38
  const logger = log.logger("projen:release");
@@ -130,6 +135,22 @@ function githubAccount(root: string): {
130
135
  return { ...identity, token };
131
136
  }
132
137
 
138
+ function releaseSummaryProviders(
139
+ value: string | undefined,
140
+ ): ReleaseSummaryProviderName[] | undefined {
141
+ if (value === undefined) return undefined;
142
+ const providers = value
143
+ .split(",")
144
+ .map((provider) => provider.trim())
145
+ .filter(Boolean);
146
+ for (const provider of providers) {
147
+ if (!RELEASE_SUMMARY_PROVIDER_NAMES.includes(provider as ReleaseSummaryProviderName)) {
148
+ throw new Error(`Unknown release summary provider: ${provider}`);
149
+ }
150
+ }
151
+ return providers as ReleaseSummaryProviderName[];
152
+ }
153
+
133
154
  const program = new Command();
134
155
  program
135
156
  .description("Prepare, validate, locally publish, and open a reviewed release PR")
@@ -142,6 +163,11 @@ program
142
163
  .option("--local-registry <value>", "local npm registry: auto, false, or an explicit URL", "auto")
143
164
  .option("--local-pypi <value>", "local PyPI index: auto, false, or an explicit URL", "auto")
144
165
  .option("--python-root <path>", "Python workspace package root")
166
+ .option("--no-release-summary", "skip optional AI release summary generation")
167
+ .option(
168
+ "--release-summary-providers <providers>",
169
+ "comma-separated provider order: cursor,codex,claude",
170
+ )
145
171
  .option("--no-local-cargo", "skip local Cargo publication")
146
172
  .option("--approve", "merge the release branch directly into the release base")
147
173
  .action(
@@ -155,6 +181,8 @@ program
155
181
  localRegistry: string;
156
182
  localPypi: string;
157
183
  pythonRoot?: string;
184
+ releaseSummary: boolean;
185
+ releaseSummaryProviders?: string;
158
186
  localCargo: boolean;
159
187
  approve: boolean;
160
188
  }) => {
@@ -259,6 +287,14 @@ program
259
287
  localCargo: opts.localCargo,
260
288
  });
261
289
  run(releaseRoot, process.execPath, [versionCheckScript]);
290
+ const releaseSummary = opts.releaseSummary
291
+ ? await generateReleaseSummary({
292
+ root: releaseRoot,
293
+ version: next.version,
294
+ fromRef: `${opts.prefix}${next.base}`,
295
+ providers: releaseSummaryProviders(opts.releaseSummaryProviders),
296
+ })
297
+ : undefined;
262
298
 
263
299
  git(releaseRoot, ["add", "-A"]);
264
300
  const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
@@ -279,6 +315,7 @@ program
279
315
  `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
280
316
  "",
281
317
  "Merging this PR updates VERSION on main and starts the public release workflow.",
318
+ ...(releaseSummary ? ["", releaseSummary] : []),
282
319
  ].join("\n");
283
320
  const githubEnvironment = {
284
321
  ...process.env,
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env -S bun
2
+ /**
3
+ * Optional AI-generated release summary with CLI fallback.
4
+ *
5
+ * @module
6
+ */
7
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { exec } from "@dbx-tools/core";
10
+ import { json, log, object, string } from "@dbx-tools/shared-core";
11
+ import {
12
+ RELEASE_SUMMARY_PROVIDER_NAMES,
13
+ releaseSummaryFile,
14
+ type ReleaseSummaryProviderName,
15
+ } from "../src/release-dispatch.ts";
16
+
17
+ const logger = log.logger("projen:release-summary");
18
+
19
+ export interface ReleaseSummaryProvider {
20
+ readonly name: ReleaseSummaryProviderName;
21
+ readonly command: string;
22
+ readonly probeArgs: readonly string[];
23
+ args(root: string, prompt: string): string[];
24
+ }
25
+
26
+ export const RELEASE_SUMMARY_PROVIDERS: readonly ReleaseSummaryProvider[] = [
27
+ {
28
+ name: "cursor",
29
+ command: "cursor",
30
+ probeArgs: ["agent", "--version"],
31
+ args: (root, prompt) => [
32
+ "agent",
33
+ "--print",
34
+ "--mode",
35
+ "ask",
36
+ "--output-format",
37
+ "stream-json",
38
+ "--trust",
39
+ "--workspace",
40
+ root,
41
+ prompt,
42
+ ],
43
+ },
44
+ {
45
+ name: "codex",
46
+ command: "codex",
47
+ probeArgs: ["--version"],
48
+ args: (root, prompt) => [
49
+ "exec",
50
+ "--sandbox",
51
+ "read-only",
52
+ "--skip-git-repo-check",
53
+ "--json",
54
+ "--color",
55
+ "never",
56
+ "-C",
57
+ root,
58
+ prompt,
59
+ ],
60
+ },
61
+ {
62
+ name: "claude",
63
+ command: "claude",
64
+ probeArgs: ["--version"],
65
+ args: (_root, prompt) => [
66
+ "--print",
67
+ "--no-session-persistence",
68
+ "--output-format",
69
+ "stream-json",
70
+ "--verbose",
71
+ "--tools",
72
+ "",
73
+ prompt,
74
+ ],
75
+ },
76
+ ];
77
+
78
+ export type ReleaseSummaryRunner = (
79
+ provider: ReleaseSummaryProvider,
80
+ root: string,
81
+ prompt: string,
82
+ ) => string | undefined | Promise<string | undefined>;
83
+
84
+ function capture(root: string, command: string, args: string[]): string {
85
+ const result = exec.spawnSync(command, args, {
86
+ cwd: root,
87
+ stdout: "capture",
88
+ stderr: "ignore",
89
+ stdin: "ignore",
90
+ check: false,
91
+ });
92
+ return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
93
+ }
94
+
95
+ function contentText(value: unknown): string | undefined {
96
+ if (typeof value === "string") return string.trimToNull(value) ?? undefined;
97
+ if (!Array.isArray(value)) return undefined;
98
+ const text = value
99
+ .flatMap((item) => (object.isRecord(item) && typeof item.text === "string" ? [item.text] : []))
100
+ .join("");
101
+ return string.trimToNull(text) ?? undefined;
102
+ }
103
+
104
+ function eventText(event: Record<string, unknown>): string | undefined {
105
+ if (typeof event.result === "string") return string.trimToNull(event.result) ?? undefined;
106
+ const item = object.isRecord(event.item) ? event.item : undefined;
107
+ if (item?.type === "agent_message" && typeof item.text === "string") {
108
+ return string.trimToNull(item.text) ?? undefined;
109
+ }
110
+ const message = object.isRecord(event.message) ? event.message : undefined;
111
+ return contentText(message?.content);
112
+ }
113
+
114
+ function eventType(event: Record<string, unknown>): string {
115
+ const item = object.isRecord(event.item) ? event.item : undefined;
116
+ return [event.type, item?.type].filter((value) => typeof value === "string").join(":") || "event";
117
+ }
118
+
119
+ function eventDetail(event: Record<string, unknown>): string | undefined {
120
+ const item = object.isRecord(event.item) ? event.item : undefined;
121
+ for (const value of [item?.message, event.message, event.error]) {
122
+ if (typeof value === "string") return string.trimToNull(value) ?? undefined;
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ async function runProvider(
128
+ provider: ReleaseSummaryProvider,
129
+ root: string,
130
+ prompt: string,
131
+ ): Promise<string | undefined> {
132
+ if (!capture(root, provider.command, [...provider.probeArgs])) return undefined;
133
+ let summary: string | undefined;
134
+ const textOutput: string[] = [];
135
+ const process = exec.spawn(provider.command, provider.args(root, prompt), {
136
+ cwd: root,
137
+ stdout: [
138
+ "capture",
139
+ (line) => {
140
+ const event = json.parseRecord(line);
141
+ if (!event) {
142
+ const text = string.trimToNull(line);
143
+ if (text) {
144
+ textOutput.push(text);
145
+ logger.info("provider-event", { provider: provider.name, type: "text", text });
146
+ }
147
+ return;
148
+ }
149
+ const text = eventText(event);
150
+ const detail = eventDetail(event);
151
+ if (text) summary = text;
152
+ logger.info("provider-event", {
153
+ provider: provider.name,
154
+ type: eventType(event),
155
+ ...(text ? { text } : {}),
156
+ ...(detail ? { detail } : {}),
157
+ });
158
+ },
159
+ ],
160
+ stderr: (line) => {
161
+ const message = string.trimToNull(line);
162
+ if (message) logger.warn("provider-stderr", { provider: provider.name, message });
163
+ },
164
+ stdin: "ignore",
165
+ check: false,
166
+ });
167
+ const result = await process;
168
+ if (result.exitCode !== 0) {
169
+ logger.warn("provider-failed", { provider: provider.name, exitCode: result.exitCode });
170
+ return undefined;
171
+ }
172
+ return summary ?? (textOutput.length ? textOutput.join("\n") : undefined);
173
+ }
174
+
175
+ /** Return the first non-empty provider response in Cursor, Codex, Claude order. */
176
+ export async function selectReleaseSummary(
177
+ root: string,
178
+ prompt: string,
179
+ runner: ReleaseSummaryRunner = runProvider,
180
+ providers: readonly ReleaseSummaryProviderName[] = RELEASE_SUMMARY_PROVIDER_NAMES,
181
+ ): Promise<{ provider: ReleaseSummaryProvider["name"]; summary: string } | undefined> {
182
+ for (const name of providers) {
183
+ const provider = RELEASE_SUMMARY_PROVIDERS.find((candidate) => candidate.name === name);
184
+ if (!provider) continue;
185
+ const summary = (await runner(provider, root, prompt))?.trim();
186
+ if (summary) return { provider: provider.name, summary };
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function summaryPrompt(
192
+ version: string,
193
+ fromRef: string | undefined,
194
+ commits: string,
195
+ changedFiles: string,
196
+ diffStat: string,
197
+ ): string {
198
+ return [
199
+ `Write a concise user-facing Markdown release summary for dbx-tools ${version}.`,
200
+ "Use only the supplied Git context. Do not run commands, edit files, speculate,",
201
+ "mention commit hashes, use emojis, or use em/en dashes.",
202
+ "Return no title and no fenced block. Start with one short paragraph, then",
203
+ "group concrete changes under brief Markdown headings with bullets.",
204
+ "",
205
+ `Comparison base: ${fromRef ?? "repository root"}`,
206
+ "",
207
+ "Commits:",
208
+ commits || "(none)",
209
+ "",
210
+ "Changed files:",
211
+ changedFiles || "(none)",
212
+ "",
213
+ "Diff stat:",
214
+ diffStat || "(none)",
215
+ ].join("\n");
216
+ }
217
+
218
+ /** Generate one immutable versioned summary, or remove a stale retry artifact. */
219
+ export async function generateReleaseSummary(options: {
220
+ readonly root: string;
221
+ readonly version: string;
222
+ readonly fromRef?: string;
223
+ readonly toRef?: string;
224
+ readonly providers?: readonly ReleaseSummaryProviderName[];
225
+ readonly runner?: ReleaseSummaryRunner;
226
+ }): Promise<string | undefined> {
227
+ const relativeOutput = releaseSummaryFile(options.version);
228
+ const output = join(options.root, relativeOutput);
229
+ if (existsSync(output)) rmSync(output);
230
+ const target =
231
+ options.toRef && capture(options.root, "git", ["rev-parse", "--verify", options.toRef])
232
+ ? options.toRef
233
+ : "HEAD";
234
+ const range =
235
+ options.fromRef && capture(options.root, "git", ["rev-parse", "--verify", options.fromRef])
236
+ ? `${options.fromRef}..${target}`
237
+ : target;
238
+ const commits = capture(options.root, "git", ["log", "--no-merges", "--format=%s%n%b", range]);
239
+ const changedFiles = capture(options.root, "git", [
240
+ "diff",
241
+ "--name-status",
242
+ range,
243
+ "--",
244
+ ".",
245
+ `:(exclude)${relativeOutput}`,
246
+ ]);
247
+ const diffStat = capture(options.root, "git", [
248
+ "diff",
249
+ "--stat",
250
+ range,
251
+ "--",
252
+ ".",
253
+ `:(exclude)${relativeOutput}`,
254
+ ]);
255
+ const result = await selectReleaseSummary(
256
+ options.root,
257
+ summaryPrompt(options.version, options.fromRef, commits, changedFiles, diffStat),
258
+ options.runner,
259
+ options.providers,
260
+ );
261
+ if (!result) {
262
+ logger.info("no supported AI CLI available; skipping release summary");
263
+ return undefined;
264
+ }
265
+ const content = `# Release ${options.version}\n\n${result.summary.trim()}\n`;
266
+ mkdirSync(dirname(output), { recursive: true });
267
+ writeFileSync(output, content);
268
+ logger.info("generated", { provider: result.provider, path: output });
269
+ return content;
270
+ }