@dbx-tools/projen 0.6.224 → 0.6.226

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,12 @@ 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
+ - `releaseValidationTasks` names repository tasks that must pass in the release
61
+ worktree before Cargo tests, compilation, local publication, or approval.
62
+ Use it for repository-specific guards that also run in release CI.
63
+ - `releaseSummary` controls optional AI release notes. It defaults to enabled
64
+ with Cursor, Codex, Claude fallback order; pass `false` to disable or provide
65
+ a `providers` array to choose the order/subset.
60
66
  - `pullRequestTitlePolicy` configures semantic title types and scope policy.
61
67
  Omit it or pass `false` to disable the title job.
62
68
  - `workflowCacheIgnorePaths` excludes generated output trees that may contain
@@ -441,7 +447,17 @@ it. `--message` sets the source commit message. The task never merges
441
447
  the PR unless `--approve` is passed; that option merges the release branch
442
448
  directly through GitHub's merge API so no PR checks are created. A repository
443
449
  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
450
+ then fast-forwards the still-active source branch to the release commit.
451
+
452
+ After validation, release preparation optionally generates an immutable
453
+ `docs/releases/v<version>.md`. It tries the installed Cursor agent first, then
454
+ Codex, then Claude, all in non-interactive read-only modes against bounded Git
455
+ context. An unavailable, unauthenticated, failed, or empty provider falls
456
+ through to the next; if none works, release preparation continues without a
457
+ summary. The same file is included in the release PR and prepended to GitHub's
458
+ generated release notes, so repository and GitHub views share one source.
459
+
460
+ npm
445
461
  uses `npm config get registry` and publishes to a local Verdaccio automatically.
446
462
  Publishable JavaScript members compile once
447
463
  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.226";
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.226",
29
+ "@dbx-tools/path": "0.6.226",
30
+ "@dbx-tools/shared-core": "0.6.226",
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.226",
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,10 @@ 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
+ /** Repository task names run during local release preparation before publication. */
625
+ readonly releaseValidationTasks?: readonly string[];
626
+ /** Optional AI-generated release summary. Defaults to enabled. */
627
+ readonly releaseSummary?: boolean | ReleaseSummaryOptions;
624
628
  /** Set to `false` to omit normal npm workspace publication. */
625
629
  readonly nodeRelease?: boolean;
626
630
  /** Unified dbx-tools release workflow, or no release surface. Defaults to `dbx-tools`. */
@@ -1540,6 +1544,8 @@ function initProject(
1540
1544
  nodeRelease: options.nodeRelease,
1541
1545
  docs: options.releaseDocs,
1542
1546
  pythonRoot: options.releasePythonRoot,
1547
+ validationTasks: options.releaseValidationTasks,
1548
+ summary: options.releaseSummary,
1543
1549
  });
1544
1550
  }
1545
1551
  }
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,13 @@ export interface DBXToolsReleaseOptions {
38
53
  readonly docs?: ReleaseDocsOptions;
39
54
  /** Python package root passed to local release preparation. */
40
55
  readonly pythonRoot?: string;
56
+ /** Repository task names run in the release worktree before expensive validation and publication. */
57
+ readonly validationTasks?: readonly string[];
58
+ /**
59
+ * Generate a versioned release summary locally. Defaults to enabled with the
60
+ * standard Cursor, Codex, Claude fallback order.
61
+ */
62
+ readonly summary?: boolean | ReleaseSummaryOptions;
41
63
  }
42
64
 
43
65
  /** Locate the unified workflow so attached language workspaces can add jobs. */
@@ -320,6 +342,13 @@ export class DBXToolsRelease extends Component {
320
342
  super(project);
321
343
  const tagPrefix = options.tagPrefix ?? "v";
322
344
  const releaseBranch = projectReleaseBranch(project);
345
+ const summary = options.summary ?? true;
346
+ const summaryArgs =
347
+ summary === false || (typeof summary === "object" && summary.providers?.length === 0)
348
+ ? ["--no-release-summary"]
349
+ : typeof summary === "object" && summary.providers
350
+ ? [`--release-summary-providers ${summary.providers.join(",")}`]
351
+ : [];
323
352
  releaseTagPrefixes.set(project, tagPrefix);
324
353
  if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
325
354
  applyTasks(project, {
@@ -344,6 +373,10 @@ export class DBXToolsRelease extends Component {
344
373
  ...(options.pythonRoot
345
374
  ? [`--python-root ${JSON.stringify(options.pythonRoot)}`]
346
375
  : []),
376
+ ...(options.validationTasks ?? []).map(
377
+ (task) => `--validate-task ${JSON.stringify(task)}`,
378
+ ),
379
+ ...summaryArgs,
347
380
  ].join(" "),
348
381
  ),
349
382
  receiveArgs: true,
@@ -359,8 +392,10 @@ export class DBXToolsRelease extends Component {
359
392
  limitConcurrency: true,
360
393
  concurrencyOptions: { group: "release", cancelInProgress: false },
361
394
  });
395
+ const version = readWorkspaceVersion(project.outdir);
362
396
  workflow.runName =
363
- "release ${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
397
+ `release ${version} ` +
398
+ "${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
364
399
  workflow.on({
365
400
  push: { branches: [releaseBranch], paths: ["VERSION"] },
366
401
  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,17 @@ 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(
167
+ "--validate-task <task>",
168
+ "repository task to run before release validation; repeatable",
169
+ (task: string, tasks: string[]) => [...tasks, task],
170
+ [],
171
+ )
172
+ .option("--no-release-summary", "skip optional AI release summary generation")
173
+ .option(
174
+ "--release-summary-providers <providers>",
175
+ "comma-separated provider order: cursor,codex,claude",
176
+ )
145
177
  .option("--no-local-cargo", "skip local Cargo publication")
146
178
  .option("--approve", "merge the release branch directly into the release base")
147
179
  .action(
@@ -155,6 +187,9 @@ program
155
187
  localRegistry: string;
156
188
  localPypi: string;
157
189
  pythonRoot?: string;
190
+ validateTask: string[];
191
+ releaseSummary: boolean;
192
+ releaseSummaryProviders?: string;
158
193
  localCargo: boolean;
159
194
  approve: boolean;
160
195
  }) => {
@@ -242,6 +277,9 @@ program
242
277
  runIgnoringStdout(releaseRoot, "cargo", ["metadata", "--format-version", "1"]);
243
278
  }
244
279
  run(releaseRoot, process.execPath, [versionCheckScript]);
280
+ for (const task of opts.validateTask) {
281
+ run(releaseRoot, process.execPath, ["run", task]);
282
+ }
245
283
  if (existsSync(join(releaseRoot, "Cargo.toml"))) {
246
284
  run(releaseRoot, "cargo", [
247
285
  "test",
@@ -259,6 +297,14 @@ program
259
297
  localCargo: opts.localCargo,
260
298
  });
261
299
  run(releaseRoot, process.execPath, [versionCheckScript]);
300
+ const releaseSummary = opts.releaseSummary
301
+ ? await generateReleaseSummary({
302
+ root: releaseRoot,
303
+ version: next.version,
304
+ fromRef: `${opts.prefix}${next.base}`,
305
+ providers: releaseSummaryProviders(opts.releaseSummaryProviders),
306
+ })
307
+ : undefined;
262
308
 
263
309
  git(releaseRoot, ["add", "-A"]);
264
310
  const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
@@ -279,6 +325,7 @@ program
279
325
  `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
280
326
  "",
281
327
  "Merging this PR updates VERSION on main and starts the public release workflow.",
328
+ ...(releaseSummary ? ["", releaseSummary] : []),
282
329
  ].join("\n");
283
330
  const githubEnvironment = {
284
331
  ...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
+ }