@dbx-tools/projen 0.9.5 → 0.9.7

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 (50) hide show
  1. package/README.md +69 -17
  2. package/index.ts +7 -5
  3. package/package.json +9 -6
  4. package/src/_rust-project.ts +356 -0
  5. package/src/_rust-release-workflow.ts +954 -0
  6. package/src/_rust-release.ts +605 -0
  7. package/src/_task-command.ts +89 -0
  8. package/src/barrels.ts +12 -11
  9. package/src/bun-app.ts +96 -85
  10. package/src/bun-workflow.ts +60 -52
  11. package/src/engine-root.ts +5 -5
  12. package/src/openapi.ts +16 -9
  13. package/src/packages.ts +9 -9
  14. package/src/pnpm-workspace.ts +1 -1
  15. package/src/project-js.ts +19 -25
  16. package/src/project-py.ts +28 -23
  17. package/src/project-rs.ts +100 -1098
  18. package/src/projen-version.ts +9 -0
  19. package/src/publish.ts +3 -4
  20. package/src/release-catalog.ts +1 -1
  21. package/src/release.ts +212 -177
  22. package/src/scaffold.ts +1 -1
  23. package/src/uniffi.ts +7 -0
  24. package/src/watch.ts +2 -2
  25. package/src/workspace-version.ts +20 -15
  26. package/tasks/barrels.ts +4 -4
  27. package/tasks/build-rust-release.ts +68 -0
  28. package/tasks/bump.ts +3 -2
  29. package/tasks/clean.ts +3 -3
  30. package/tasks/local-publish.ts +21 -3
  31. package/tasks/openapi.ts +2 -2
  32. package/tasks/publish-npm.ts +12 -6
  33. package/tasks/publish-python.ts +2 -2
  34. package/tasks/publish-uniffi-local.ts +11 -15
  35. package/tasks/publish.ts +88 -88
  36. package/tasks/python-registry.ts +1 -1
  37. package/tasks/release-bootstrap.ts +2 -2
  38. package/tasks/release-plan.ts +1 -1
  39. package/tasks/release-please.ts +1 -1
  40. package/tasks/release-pr.ts +60 -115
  41. package/tasks/release-request.ts +8 -29
  42. package/tasks/release-summary-units.ts +2 -9
  43. package/tasks/release-summary.ts +35 -24
  44. package/tasks/release-version.ts +44 -0
  45. package/tasks/rust-release.d.ts +41 -0
  46. package/tasks/rust-release.mjs +1717 -0
  47. package/tasks/sync.ts +3 -3
  48. package/tasks/uniffi-python.js +1 -1
  49. package/tasks/uniffi.ts +7 -1
  50. package/tasks/version-check.ts +6 -5
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import { exec } from "@dbx-tools/core";
4
+ import * as exec from "@dbx-tools/core/exec";
5
5
  import { net } from "@dbx-tools/shared-core";
6
6
 
7
7
  export interface LocalPythonRegistry {
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
- import { project } from "@dbx-tools/core";
6
+ import * as projectUtils from "@dbx-tools/core/project-utils";
7
7
  import { json, log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
@@ -53,7 +53,7 @@ if (import.meta.main) {
53
53
  new Command()
54
54
  .option("--root <path>", "repository root")
55
55
  .action((options: { root?: string }) => {
56
- bootstrapReleaseUnits(resolve(options.root ?? project.root() ?? process.cwd()));
56
+ bootstrapReleaseUnits(resolve(options.root ?? projectUtils.root() ?? process.cwd()));
57
57
  })
58
58
  .parse();
59
59
  }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { exec } from "@dbx-tools/core";
6
+ import * as exec from "@dbx-tools/core/exec";
7
7
  import { json, log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { appendFileSync, readFileSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
- import { exec } from "@dbx-tools/core";
6
+ import * as exec from "@dbx-tools/core/exec";
7
7
  import { log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import { GitHub } from "release-please";
@@ -10,8 +10,9 @@
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { exec, project } from "@dbx-tools/core";
14
- import { async as asyncModule, json, log, object } from "@dbx-tools/shared-core";
13
+ import * as exec from "@dbx-tools/core/exec";
14
+ import * as projectUtils from "@dbx-tools/core/project-utils";
15
+ import { asyncUtils, json, log, object } from "@dbx-tools/shared-core";
15
16
  import { Command } from "commander";
16
17
  import { publishLocalRelease } from "./local-publish.ts";
17
18
  import { generateReleaseSummary } from "./release-summary.ts";
@@ -23,6 +24,16 @@ import {
23
24
  type ReleaseOs,
24
25
  type VersionLevel,
25
26
  } from "../src/_release-platform.ts";
27
+ import {
28
+ captureTaskCommand,
29
+ runTaskCommand,
30
+ runTaskCommandAsync,
31
+ taskCommandSucceeds,
32
+ } from "../src/_task-command.ts";
33
+ import {
34
+ RELEASE_SUMMARY_PROVIDER_NAMES,
35
+ type ReleaseSummaryProviderName,
36
+ } from "../src/release-dispatch.ts";
26
37
  import {
27
38
  githubAccountSupportsWorkflowChanges,
28
39
  githubAuthenticatedAccounts,
@@ -32,10 +43,6 @@ import {
32
43
  githubTokenArguments,
33
44
  githubTokenEnvironmentName,
34
45
  } from "../src/release-github.ts";
35
- import {
36
- RELEASE_SUMMARY_PROVIDER_NAMES,
37
- type ReleaseSummaryProviderName,
38
- } from "../src/release-dispatch.ts";
39
46
  import {
40
47
  readWorkspaceVersion,
41
48
  resolveBaseVersion,
@@ -49,14 +56,9 @@ function git(
49
56
  args: string[],
50
57
  { capture = false, check = true }: { capture?: boolean; check?: boolean } = {},
51
58
  ): string {
52
- const result = exec.spawnSync("git", args, {
53
- cwd: root,
54
- stdout: capture ? "capture" : "inherit",
55
- stderr: capture ? "ignore" : "inherit",
56
- stdin: "ignore",
57
- check,
58
- });
59
- return result.stdout?.trim() ?? "";
59
+ if (capture) return captureTaskCommand(root, "git", args, { check });
60
+ runTaskCommand(root, "git", args);
61
+ return "";
60
62
  }
61
63
 
62
64
  function pushCurrentBranch(root: string, branch: string): void {
@@ -68,84 +70,7 @@ function pushCurrentBranch(root: string, branch: string): void {
68
70
  }
69
71
 
70
72
  function gitSucceeds(root: string, args: string[]): boolean {
71
- return (
72
- exec.spawnSync("git", args, {
73
- cwd: root,
74
- stdout: "ignore",
75
- stderr: "ignore",
76
- stdin: "ignore",
77
- check: false,
78
- }).exitCode === 0
79
- );
80
- }
81
-
82
- function commandSucceeds(
83
- root: string,
84
- command: string,
85
- args: string[],
86
- env?: NodeJS.ProcessEnv,
87
- ): boolean {
88
- return (
89
- exec.spawnSync(command, args, {
90
- cwd: root,
91
- env,
92
- stdout: "ignore",
93
- stderr: "ignore",
94
- stdin: "ignore",
95
- check: false,
96
- }).exitCode === 0
97
- );
98
- }
99
-
100
- function run(root: string, command: string, args: string[], env?: NodeJS.ProcessEnv): void {
101
- exec.spawnSync(command, args, {
102
- cwd: root,
103
- env,
104
- stdout: "inherit",
105
- stderr: "inherit",
106
- stdin: "ignore",
107
- check: true,
108
- });
109
- }
110
-
111
- /** Run one release command with inherited output and a hard timeout. */
112
- async function runWithTimeout(
113
- root: string,
114
- command: string,
115
- args: string[],
116
- env: NodeJS.ProcessEnv,
117
- timeoutMs: number,
118
- ): Promise<void> {
119
- await exec.spawn(command, args, {
120
- cwd: root,
121
- env,
122
- stdout: "inherit",
123
- stderr: "inherit",
124
- stdin: "ignore",
125
- signal: AbortSignal.timeout(timeoutMs),
126
- check: true,
127
- });
128
- }
129
-
130
- /** Capture one non-interactive command without throwing on a nonzero exit. */
131
- function captureCommand(
132
- root: string,
133
- command: string,
134
- args: string[],
135
- env: NodeJS.ProcessEnv,
136
- ): string {
137
- return (
138
- exec
139
- .spawnSync(command, args, {
140
- cwd: root,
141
- env,
142
- stdout: "capture",
143
- stderr: "ignore",
144
- stdin: "ignore",
145
- check: false,
146
- })
147
- .stdout?.trim() ?? ""
148
- );
73
+ return taskCommandSucceeds(root, "git", args);
149
74
  }
150
75
 
151
76
  /** Wait for required checks and return the merged pull request commit. */
@@ -155,16 +80,15 @@ async function waitForPullRequestMerge(
155
80
  env: NodeJS.ProcessEnv,
156
81
  timeoutMs: number,
157
82
  ): Promise<string> {
158
- await runWithTimeout(
83
+ await runTaskCommandAsync(
159
84
  root,
160
85
  "gh",
161
86
  ["pr", "checks", releaseBranch, "--watch", "--fail-fast", "--required", "--interval", "10"],
162
- env,
163
- timeoutMs,
87
+ { env, signal: AbortSignal.timeout(timeoutMs) },
164
88
  );
165
89
  const deadline = Date.now() + timeoutMs;
166
90
  while (Date.now() < deadline) {
167
- const state = captureCommand(
91
+ const state = captureTaskCommand(
168
92
  root,
169
93
  "gh",
170
94
  [
@@ -176,12 +100,12 @@ async function waitForPullRequestMerge(
176
100
  "--jq",
177
101
  '[.state, (.mergeCommit.oid // "")] | @tsv',
178
102
  ],
179
- env,
103
+ { env },
180
104
  );
181
105
  const [status, mergeSha] = state.split("\t");
182
106
  if (status === "MERGED" && mergeSha) return mergeSha;
183
107
  if (status === "CLOSED") throw new Error(`Release pull request closed without merging`);
184
- await asyncModule.sleep(2_000);
108
+ await asyncUtils.sleep(2_000);
185
109
  }
186
110
  throw new Error(`Release pull request did not merge within ${timeoutMs}ms`);
187
111
  }
@@ -197,7 +121,7 @@ async function waitForReleaseWorkflow(
197
121
  const deadline = Date.now() + timeoutMs;
198
122
  let runId = "";
199
123
  while (Date.now() < deadline) {
200
- runId = captureCommand(
124
+ runId = captureTaskCommand(
201
125
  root,
202
126
  "gh",
203
127
  [
@@ -218,13 +142,16 @@ async function waitForReleaseWorkflow(
218
142
  "--jq",
219
143
  ".[0].databaseId // empty",
220
144
  ],
221
- env,
145
+ { env },
222
146
  );
223
147
  if (runId) break;
224
- await asyncModule.sleep(5_000);
148
+ await asyncUtils.sleep(5_000);
225
149
  }
226
150
  if (!runId) throw new Error(`Release workflow did not start within ${timeoutMs}ms`);
227
- await runWithTimeout(root, "gh", ["run", "watch", runId, "--exit-status"], env, timeoutMs);
151
+ await runTaskCommandAsync(root, "gh", ["run", "watch", runId, "--exit-status"], {
152
+ env,
153
+ signal: AbortSignal.timeout(timeoutMs),
154
+ });
228
155
  }
229
156
 
230
157
  function githubAccount(root: string): {
@@ -234,7 +161,7 @@ function githubAccount(root: string): {
234
161
  login: string;
235
162
  token: string;
236
163
  } {
237
- const repository = project.repositoryUrl(root);
164
+ const repository = projectUtils.repositoryUrl(root);
238
165
  if (!repository) throw new Error("Release preparation requires a GitHub repository");
239
166
  const identity = githubRepositoryIdentity(repository);
240
167
  const status = exec
@@ -375,7 +302,7 @@ program
375
302
  wait: boolean;
376
303
  waitTimeoutMinutes: string;
377
304
  }) => {
378
- const root = project.root() ?? process.cwd();
305
+ const root = projectUtils.root() ?? process.cwd();
379
306
  const waitTimeoutMinutes = object.toNumber(opts.waitTimeoutMinutes, {
380
307
  separators: false,
381
308
  percent: false,
@@ -397,6 +324,15 @@ program
397
324
  includeComponentTags: false,
398
325
  }).version;
399
326
  const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
327
+ const releaseVersionScript = fileURLToPath(new URL("./release-version.ts", import.meta.url));
328
+ runTaskCommand(root, process.execPath, [
329
+ releaseVersionScript,
330
+ "--version",
331
+ next.version,
332
+ "--prefix",
333
+ opts.prefix,
334
+ "--assert-next",
335
+ ]);
400
336
  const releaseTag = `${opts.prefix}${next.version}`;
401
337
  const releaseBranch = `release/${releaseTag}`;
402
338
  const releaseRoot = join(root, ".worktrees", releaseTag);
@@ -454,11 +390,11 @@ program
454
390
  }
455
391
  logger.info(`resuming ${releaseBranch} in ${releaseRoot}`);
456
392
  }
457
- run(releaseRoot, process.execPath, ["install"]);
393
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
458
394
 
459
395
  const bumpScript = fileURLToPath(new URL("./bump.ts", import.meta.url));
460
396
  const versionCheckScript = fileURLToPath(new URL("./version-check.ts", import.meta.url));
461
- run(releaseRoot, process.execPath, [
397
+ runTaskCommand(releaseRoot, process.execPath, [
462
398
  bumpScript,
463
399
  "--level",
464
400
  opts.level,
@@ -471,19 +407,19 @@ program
471
407
  throw new Error(`Release preparation did not produce ${next.version}`);
472
408
  }
473
409
 
474
- run(releaseRoot, process.execPath, [versionCheckScript]);
410
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
475
411
  if (opts.validate) {
476
412
  for (const task of opts.validateTask) {
477
- run(releaseRoot, process.execPath, ["run", task]);
413
+ runTaskCommand(releaseRoot, process.execPath, ["run", task]);
478
414
  }
479
415
  if (existsSync(join(releaseRoot, "Cargo.toml"))) {
480
- run(releaseRoot, "cargo", [
416
+ runTaskCommand(releaseRoot, "cargo", [
481
417
  "test",
482
418
  "--workspace",
483
419
  ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
484
420
  ]);
485
421
  }
486
- run(releaseRoot, process.execPath, ["run", "compile"]);
422
+ runTaskCommand(releaseRoot, process.execPath, ["run", "compile"]);
487
423
  } else {
488
424
  logger.warn("release validation skipped by --no-validate");
489
425
  }
@@ -495,11 +431,12 @@ program
495
431
  localPypi: opts.localPypi,
496
432
  pythonRoot: opts.pythonRoot,
497
433
  localCargo: opts.localCargo,
434
+ reuseValidatedNodeCompile: opts.validate,
498
435
  });
499
436
  } else {
500
437
  logger.info("local publication skipped by --no-local-publish");
501
438
  }
502
- run(releaseRoot, process.execPath, [versionCheckScript]);
439
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
503
440
  const releaseSummary = opts.releaseSummary
504
441
  ? await generateReleaseSummary({
505
442
  root: releaseRoot,
@@ -537,8 +474,14 @@ program
537
474
  [githubTokenEnvironmentName(account.hostname)]: account.token,
538
475
  };
539
476
  const ensurePullRequest = (): void => {
540
- if (commandSucceeds(root, "gh", ["pr", "view", releaseBranch], githubEnvironment)) return;
541
- run(
477
+ if (
478
+ taskCommandSucceeds(root, "gh", ["pr", "view", releaseBranch], {
479
+ env: githubEnvironment,
480
+ })
481
+ ) {
482
+ return;
483
+ }
484
+ runTaskCommand(
542
485
  root,
543
486
  "gh",
544
487
  [
@@ -553,12 +496,14 @@ program
553
496
  "--body",
554
497
  body,
555
498
  ],
556
- githubEnvironment,
499
+ { env: githubEnvironment },
557
500
  );
558
501
  };
559
502
  ensurePullRequest();
560
503
  if (opts.approve) {
561
- run(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], githubEnvironment);
504
+ runTaskCommand(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], {
505
+ env: githubEnvironment,
506
+ });
562
507
  }
563
508
  git(root, ["worktree", "remove", "--force", releaseRoot]);
564
509
  git(root, ["branch", "--delete", "--force", releaseBranch]);
@@ -3,10 +3,11 @@
3
3
 
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { exec, project } from "@dbx-tools/core";
7
- import { log, string } from "@dbx-tools/shared-core";
6
+ import * as projectUtils from "@dbx-tools/core/project-utils";
7
+ import { log, stringUtils } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import { selectReleaseSummary } from "./release-summary.ts";
10
+ import { captureTaskCommand, runTaskCommand } from "../src/_task-command.ts";
10
11
 
11
12
  const logger = log.logger("projen:release-request");
12
13
 
@@ -33,13 +34,7 @@ export async function prepareReleaseRequest(
33
34
  }
34
35
  const baseRef = options.push === false ? options.baseBranch : `origin/${options.baseBranch}`;
35
36
  if (options.synthesize !== false) {
36
- exec.spawnSync(process.execPath, ["x", "projen"], {
37
- cwd: root,
38
- stdout: "inherit",
39
- stderr: "inherit",
40
- stdin: "ignore",
41
- check: true,
42
- });
37
+ runTaskCommand(root, process.execPath, ["x", "projen"]);
43
38
  }
44
39
  const dirty = capture(root, ["status", "--porcelain"]);
45
40
  if (dirty) {
@@ -74,7 +69,7 @@ export async function prepareReleaseRequest(
74
69
  throw new Error(`Release requests must run from a branch other than ${options.baseBranch}`);
75
70
  }
76
71
 
77
- const customNotes = string.trimToNull(
72
+ const customNotes = stringUtils.trimToNull(
78
73
  options.notesFile ? readFileSync(resolve(root, options.notesFile), "utf8") : options.notes,
79
74
  );
80
75
  const notes =
@@ -130,27 +125,11 @@ async function generateRequestNotes(root: string, range: string): Promise<string
130
125
  }
131
126
 
132
127
  function capture(root: string, args: string[]): string {
133
- return (
134
- exec
135
- .spawnSync("git", args, {
136
- cwd: root,
137
- stdout: "capture",
138
- stderr: "inherit",
139
- stdin: "ignore",
140
- check: true,
141
- })
142
- .stdout?.trim() ?? ""
143
- );
128
+ return captureTaskCommand(root, "git", args, { check: true, stderr: "inherit" });
144
129
  }
145
130
 
146
131
  function run(root: string, args: string[]): void {
147
- exec.spawnSync("git", args, {
148
- cwd: root,
149
- stdout: "inherit",
150
- stderr: "inherit",
151
- stdin: "ignore",
152
- check: true,
153
- });
132
+ runTaskCommand(root, "git", args);
154
133
  }
155
134
 
156
135
  /** Restore option text after Projen forwards task arguments without shell quoting. */
@@ -175,7 +154,7 @@ if (import.meta.main) {
175
154
  throw new Error("Use --notes or --notes-file, not both");
176
155
  }
177
156
  await prepareReleaseRequest({
178
- root: project.root() ?? process.cwd(),
157
+ root: projectUtils.root() ?? process.cwd(),
179
158
  baseBranch: options.base,
180
159
  message: optionText(options.message),
181
160
  notes: optionText(options.notes),
@@ -3,10 +3,10 @@
3
3
 
4
4
  import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
- import { exec } from "@dbx-tools/core";
7
6
  import { log } from "@dbx-tools/shared-core";
8
7
  import { Command } from "commander";
9
8
  import { generateReleaseSummary } from "./release-summary.ts";
9
+ import { captureTaskCommand } from "../src/_task-command.ts";
10
10
  import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
11
11
  import type { ReleasePlan } from "../src/release-plan.ts";
12
12
 
@@ -71,12 +71,5 @@ function requestNotes(root: string): { summary?: string; paths: string[] } {
71
71
  }
72
72
 
73
73
  function gitCapture(root: string, args: string[]): string {
74
- const result = exec.spawnSync("git", args, {
75
- cwd: root,
76
- stdout: "capture",
77
- stderr: "ignore",
78
- stdin: "ignore",
79
- check: false,
80
- });
81
- return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
74
+ return captureTaskCommand(root, "git", args);
82
75
  }
@@ -6,8 +6,10 @@
6
6
  */
7
7
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
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";
9
+ import * as exec from "@dbx-tools/core/exec";
10
+ import * as projectUtils from "@dbx-tools/core/project-utils";
11
+ import { json, log, object, stringUtils } from "@dbx-tools/shared-core";
12
+ import { captureTaskCommand } from "../src/_task-command.ts";
11
13
  import {
12
14
  RELEASE_SUMMARY_PROVIDER_NAMES,
13
15
  releaseSummaryFile,
@@ -84,30 +86,23 @@ export type ReleaseSummaryRunner = (
84
86
  ) => string | undefined | Promise<string | undefined>;
85
87
 
86
88
  function capture(root: string, command: string, args: string[]): string {
87
- const result = exec.spawnSync(command, args, {
88
- cwd: root,
89
- stdout: "capture",
90
- stderr: "ignore",
91
- stdin: "ignore",
92
- check: false,
93
- });
94
- return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
89
+ return captureTaskCommand(root, command, args);
95
90
  }
96
91
 
97
92
  function contentText(value: unknown): string | undefined {
98
- if (typeof value === "string") return string.trimToNull(value) ?? undefined;
93
+ if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
99
94
  if (!Array.isArray(value)) return undefined;
100
95
  const text = value
101
96
  .flatMap((item) => (object.isRecord(item) && typeof item.text === "string" ? [item.text] : []))
102
97
  .join("");
103
- return string.trimToNull(text) ?? undefined;
98
+ return stringUtils.trimToNull(text) ?? undefined;
104
99
  }
105
100
 
106
101
  function eventText(event: Record<string, unknown>): string | undefined {
107
- if (typeof event.result === "string") return string.trimToNull(event.result) ?? undefined;
102
+ if (typeof event.result === "string") return stringUtils.trimToNull(event.result) ?? undefined;
108
103
  const item = object.isRecord(event.item) ? event.item : undefined;
109
104
  if (item?.type === "agent_message" && typeof item.text === "string") {
110
- return string.trimToNull(item.text) ?? undefined;
105
+ return stringUtils.trimToNull(item.text) ?? undefined;
111
106
  }
112
107
  const message = object.isRecord(event.message) ? event.message : undefined;
113
108
  return contentText(message?.content);
@@ -121,7 +116,7 @@ function eventType(event: Record<string, unknown>): string {
121
116
  function eventDetail(event: Record<string, unknown>): string | undefined {
122
117
  const item = object.isRecord(event.item) ? event.item : undefined;
123
118
  for (const value of [item?.message, event.message, event.error]) {
124
- if (typeof value === "string") return string.trimToNull(value) ?? undefined;
119
+ if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
125
120
  }
126
121
  return undefined;
127
122
  }
@@ -129,7 +124,7 @@ function eventDetail(event: Record<string, unknown>): string | undefined {
129
124
  function eventToolName(event: Record<string, unknown>): string | undefined {
130
125
  const item = object.isRecord(event.item) ? event.item : undefined;
131
126
  for (const value of [event.tool_name, event.toolName, event.name, item?.tool_name, item?.name]) {
132
- if (typeof value === "string") return string.trimToNull(value) ?? undefined;
127
+ if (typeof value === "string") return stringUtils.trimToNull(value) ?? undefined;
133
128
  }
134
129
  const toolCall = object.isRecord(event.tool_call)
135
130
  ? event.tool_call
@@ -185,7 +180,7 @@ async function runProvider(
185
180
  (line) => {
186
181
  const event = json.parseRecord(line);
187
182
  if (!event) {
188
- const text = string.trimToNull(line);
183
+ const text = stringUtils.trimToNull(line);
189
184
  if (text) {
190
185
  textOutput.push(text);
191
186
  logEvent({ type: "text", text });
@@ -198,7 +193,7 @@ async function runProvider(
198
193
  },
199
194
  ],
200
195
  stderr: (line) => {
201
- const message = string.trimToNull(line);
196
+ const message = stringUtils.trimToNull(line);
202
197
  if (message) logger.warn("provider-stderr", { provider: provider.name, message });
203
198
  },
204
199
  stdin: "ignore",
@@ -243,6 +238,7 @@ export async function selectReleaseSummary(
243
238
  }
244
239
 
245
240
  function summaryPrompt(
241
+ projectName: string,
246
242
  version: string,
247
243
  fromRef: string | undefined,
248
244
  commits: string,
@@ -250,7 +246,7 @@ function summaryPrompt(
250
246
  diffStat: string,
251
247
  ): string {
252
248
  return [
253
- `Write a concise user-facing Markdown release summary for dbx-tools ${version}.`,
249
+ `Write a concise user-facing Markdown release summary for ${projectName} ${version}.`,
254
250
  "Use only the supplied Git context. Do not run commands, edit files, speculate,",
255
251
  "mention commit hashes, use emojis, or use em/en dashes.",
256
252
  "Return no title and no fenced block. Start with one short paragraph, then",
@@ -270,7 +266,12 @@ function summaryPrompt(
270
266
  }
271
267
 
272
268
  /** Deterministic fallback when every configured AI provider is unavailable. */
273
- function gitSummary(version: string, commits: string, changedFiles: string): string {
269
+ function gitSummary(
270
+ projectName: string,
271
+ version: string,
272
+ commits: string,
273
+ changedFiles: string,
274
+ ): string {
274
275
  const subjects = commits
275
276
  .split("\n")
276
277
  .map((line) => line.trim())
@@ -290,7 +291,7 @@ function gitSummary(version: string, commits: string, changedFiles: string): str
290
291
  .slice(0, 12);
291
292
  const bullets = subjects.length ? subjects : areas.map((area) => `Updated ${area}`);
292
293
  return [
293
- `dbx-tools ${version} contains the reviewed changes listed below.`,
294
+ `${projectName} ${version} contains the reviewed changes listed below.`,
294
295
  "",
295
296
  "## Changes",
296
297
  ...(bullets.length ? bullets.map((item) => `- ${item}`) : ["- Release metadata updated."]),
@@ -310,6 +311,7 @@ export async function generateReleaseSummary(options: {
310
311
  readonly providers?: readonly ReleaseSummaryProviderName[];
311
312
  readonly runner?: ReleaseSummaryRunner;
312
313
  }): Promise<string | undefined> {
314
+ const projectName = projectUtils.name(options.root);
313
315
  const relativeOutput =
314
316
  options.outputFile ?? releaseSummaryFile(options.version, options.component);
315
317
  const output = join(options.root, relativeOutput);
@@ -347,17 +349,26 @@ export async function generateReleaseSummary(options: {
347
349
  ...scopedPaths,
348
350
  `:(exclude)${relativeOutput}`,
349
351
  ]);
350
- const customSummary = string.trimToNull(options.customSummary);
352
+ const customSummary = stringUtils.trimToNull(options.customSummary);
351
353
  const result = customSummary
352
354
  ? undefined
353
355
  : await selectReleaseSummary(
354
356
  options.root,
355
- summaryPrompt(options.version, options.fromRef, commits, changedFiles, diffStat),
357
+ summaryPrompt(
358
+ projectName,
359
+ options.version,
360
+ options.fromRef,
361
+ commits,
362
+ changedFiles,
363
+ diffStat,
364
+ ),
356
365
  options.runner,
357
366
  options.providers,
358
367
  );
359
368
  const summary =
360
- customSummary ?? result?.summary ?? gitSummary(options.version, commits, changedFiles);
369
+ customSummary ??
370
+ result?.summary ??
371
+ gitSummary(projectName, options.version, commits, changedFiles);
361
372
  const provider = customSummary ? "custom" : (result?.provider ?? "git");
362
373
  if (!customSummary && !result) {
363
374
  logger.info("AI providers unavailable; using Git release summary");
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Validate one stable release version against the canonical workspace policy. */
3
+ import { resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import * as projectUtils from "@dbx-tools/core/project-utils";
6
+ import { compareSemver, parseSemver, resolveRemoteVersion } from "../src/workspace-version.ts";
7
+
8
+ export function assertReleaseVersion(
9
+ version: string,
10
+ options: { root?: string; prefixes?: readonly string[]; assertNext?: boolean } = {},
11
+ ): void {
12
+ const parsed = parseSemver(version);
13
+ if (!parsed) {
14
+ throw new Error(
15
+ `release version must be an exact stable x.y.z, got ${JSON.stringify(version)}`,
16
+ );
17
+ }
18
+ if (!options.assertNext) return;
19
+ const root = resolve(options.root ?? projectUtils.root() ?? process.cwd());
20
+ const prefixes = options.prefixes?.length ? options.prefixes : ["v"];
21
+ const current = resolveRemoteVersion(root, prefixes, { fetch: false });
22
+ const currentParsed = current ? parseSemver(current) : undefined;
23
+ if (currentParsed && compareSemver(parsed, currentParsed) <= 0) {
24
+ throw new Error(`release version ${version} must be greater than published ${current}`);
25
+ }
26
+ }
27
+
28
+ if (import.meta.main) {
29
+ const { values } = parseArgs({
30
+ options: {
31
+ version: { type: "string" },
32
+ root: { type: "string" },
33
+ prefix: { type: "string", multiple: true },
34
+ "assert-next": { type: "boolean", default: false },
35
+ },
36
+ strict: true,
37
+ });
38
+ if (!values.version) throw new Error("--version is required");
39
+ assertReleaseVersion(values.version, {
40
+ ...(values.root ? { root: values.root } : {}),
41
+ ...(values.prefix ? { prefixes: values.prefix } : {}),
42
+ assertNext: values["assert-next"],
43
+ });
44
+ }