@dbx-tools/projen 0.9.60 → 0.9.64

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
@@ -205,9 +205,11 @@ The workspace creates one repository-wide binding lifecycle:
205
205
  - `python-node-bindings` generates every configured bridge.
206
206
  - `python-node-bindings:check` verifies generated files and fails on stale
207
207
  `_generated/node` directories.
208
- - `python-node-bindings:watch` watches all configured bridges and regenerates
209
- only Python projects affected by a Node package, transitive workspace
210
- dependency, binding manifest, or function override change.
208
+ - `python-node-bindings:watch` watches only configured module entrypoints and
209
+ their transitive workspace source imports. Tests, compiled output, generated
210
+ bindings, and unrelated modules do not trigger regeneration or acquire the
211
+ workspace mutation lock. Binding-manifest and generator changes regenerate
212
+ the affected projects and restart the watcher when its input graph changes.
211
213
 
212
214
  When a runtime owner is configured, the workspace also creates one shared
213
215
  runtime lifecycle:
@@ -481,12 +483,30 @@ bun run release --publish pypi --no-docs
481
483
  bun run release --no-npm --no-local-publish
482
484
  bun run release --publish local --install never
483
485
  bun run release --local-registry false --local-pypi auto
486
+ bun run release --demo-deploy
487
+ bun run release --no-release-notes
488
+ bun run release --release-notes-instructions "Focus on operator-visible changes."
484
489
  ```
485
490
 
491
+ `--demo-deploy` is off by default. After tagging and any local registry
492
+ publication it runs `bun run demo:deploy`, which stages the AppKit demo
493
+ and deploys it to the FEVM workspace. That step is local only and is not
494
+ recorded in the annotated tag.
495
+
496
+ After bump, release writes `docs/releases/vX.Y.Z.md` with `dbx genie exec -C
497
+ "$PWD" --sandbox read-only --ephemeral -o`. If Genie fails or writes an empty
498
+ file, the step keeps going with a short git-log summary. `--no-release-notes`
499
+ skips the file. `--release-notes-instructions` appends run-specific guidance to
500
+ the standard Genie prompt. Notes are committed with the version bump and are not
501
+ recorded in the annotated tag. They are skipped on `--no-bump` because that
502
+ path requires an already committed tree.
503
+
486
504
  `--install auto` keeps Projen's normal local dependency-install behavior;
487
505
  `always` performs a manifest-resolved install first, and `never` uses dependencies already
488
- installed while still regenerating versioned sources. `--no-validation` skips
489
- optional task checks locally and in CI, not version or immutable-source checks.
506
+ installed while still regenerating versioned sources. Local release validation
507
+ runs `eslint:fix` before branch preparation and bump, then runs the selected
508
+ fail-closed tasks; CI remains check-only.
509
+ `--no-validation` skips optional task checks locally and in CI, not version or immutable-source checks.
490
510
  `--no-docs` skips building and deploying the site, not package README validation.
491
511
  Scopes select artifacts, not the normal commit and tag-push transaction.
492
512
  The annotated tag records CI step selections, so publishing a selected scope does
@@ -553,6 +573,9 @@ release [options]
553
573
  | `--docs` | build and deploy docs for a selected scope |
554
574
  | `--no-docs` | skip documentation build and deployment |
555
575
  | `--no-validation` | skip optional release validation tasks; version/source checks remain mandatory |
576
+ | `--no-release-notes` | skip writing docs/releases notes (Genie and git-log fallback) |
577
+ | `--release-notes-instructions <text>` | append custom instructions to the Genie release-notes prompt |
578
+ | `--demo-deploy` | after tagging, stage and deploy the AppKit demo app (off by default) |
556
579
  | `--no-local-publish` | skip publishing to configured local registries |
557
580
  | `--local-registry <auto\|false\|url>` | local npm registry selection (default: "auto") |
558
581
  | `--local-pypi <auto\|false\|url>` | local devpi registry selection (default: "auto") |
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.9.60";
6
+ export const PACKAGE_VERSION = "0.9.64";
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";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.60",
34
- "@dbx-tools/path": "^0.9.60",
35
- "@dbx-tools/shared-core": "^0.9.60",
33
+ "@dbx-tools/core": "^0.9.64",
34
+ "@dbx-tools/path": "^0.9.64",
35
+ "@dbx-tools/shared-core": "^0.9.64",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "main": "index.ts",
56
56
  "license": "Apache-2.0",
57
- "version": "0.9.60",
57
+ "version": "0.9.64",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
@@ -69,6 +69,25 @@ export function captureTaskCommand(
69
69
  return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
70
70
  }
71
71
 
72
+ /** Run a command with inherited output and return whether it exited successfully. */
73
+ export function tryTaskCommand(
74
+ cwd: string,
75
+ command: string,
76
+ args: readonly string[],
77
+ options: TaskCommandOptions = {},
78
+ ): boolean {
79
+ return (
80
+ exec.spawnSync(executable(command), [...args], {
81
+ cwd,
82
+ env: options.env,
83
+ stdout: "inherit",
84
+ stderr: options.stderr ?? "inherit",
85
+ stdin: "ignore",
86
+ check: false,
87
+ }).exitCode === 0
88
+ );
89
+ }
90
+
72
91
  /** Whether a quiet, non-interactive command exits successfully. */
73
92
  export function taskCommandSucceeds(
74
93
  cwd: string,
@@ -1,10 +1,11 @@
1
1
  /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
- import { readFileSync, rmSync } from "node:fs";
2
+ import { readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { find } from "@dbx-tools/path";
6
6
  import { object, stringUtils } from "@dbx-tools/shared-core";
7
7
  import { parse } from "smol-toml";
8
+ import ts from "typescript";
8
9
  import { runTaskCommand } from "./_task-command.ts";
9
10
  import { resolveRepoRoot, workspaceDependencyDirectories } from "./packages.ts";
10
11
 
@@ -29,6 +30,7 @@ export interface ResolvedPythonNodeBindings {
29
30
  readonly projectDirectory: string;
30
31
  readonly pyproject: string;
31
32
  readonly runtimeOutput: string;
33
+ readonly watchInputs: readonly string[];
32
34
  readonly workspaceDirectories: readonly string[];
33
35
  }
34
36
 
@@ -152,7 +154,7 @@ export function resolvePythonNodeBindings(
152
154
  }
153
155
  const bindingName = nodePackageName(packageName);
154
156
  const bindingDirectory = join(moduleDirectory, "_generated", "node", bindingName);
155
- return {
157
+ const resolved = {
156
158
  project,
157
159
  projectDirectory,
158
160
  pyproject,
@@ -177,11 +179,85 @@ export function resolvePythonNodeBindings(
177
179
  }),
178
180
  workspaceDirectories: workspaceDependencyDirectories(packageName, root),
179
181
  };
182
+ return {
183
+ ...resolved,
184
+ watchInputs: bindingSourceInputs(root, resolved),
185
+ };
180
186
  });
181
187
  validateBindings(configs);
182
188
  return configs;
183
189
  }
184
190
 
191
+ type BindingSourceConfig = Omit<ResolvedPythonNodeBindings, "watchInputs">;
192
+
193
+ /** Transitive workspace source files reachable from configured binding modules. */
194
+ function bindingSourceInputs(root: string, config: BindingSourceConfig): string[] {
195
+ const allowed = config.workspaceDirectories.map(canonicalPath);
196
+ const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
197
+ const entrypoints =
198
+ config.modules.length > 0
199
+ ? config.modules.map((module) => resolvePythonNodeBindingModule(packageEntrypoint, module))
200
+ : [packageEntrypoint];
201
+ const pending = [
202
+ ...entrypoints,
203
+ ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
204
+ ];
205
+ const visited = new Set<string>();
206
+ while (pending.length > 0) {
207
+ const candidate = canonicalPath(pending.pop()!);
208
+ if (
209
+ visited.has(candidate) ||
210
+ !allowed.some((directory) => containsResolvedPath(directory, candidate))
211
+ ) {
212
+ continue;
213
+ }
214
+ visited.add(candidate);
215
+ let source: string;
216
+ try {
217
+ source = readFileSync(candidate, "utf8");
218
+ } catch {
219
+ continue;
220
+ }
221
+ const dependencies = ts.preProcessFile(source, true, true).importedFiles;
222
+ for (const dependency of dependencies) {
223
+ try {
224
+ pending.push(Bun.resolveSync(dependency.fileName, dirname(candidate)));
225
+ } catch {
226
+ // Built-ins and external packages are outside the workspace source graph.
227
+ }
228
+ }
229
+ }
230
+ return [...visited].sort().map((path) => relative(root, path));
231
+ }
232
+
233
+ /** Resolve one configured namespace from a package entrypoint barrel. */
234
+ export function resolvePythonNodeBindingModule(entrypoint: string, module: string): string {
235
+ if (!/^[$A-Z_a-z][$\w]*$/.test(module)) {
236
+ throw new Error(`Node binding module must be a TypeScript identifier: ${module}`);
237
+ }
238
+ const source = readFileSync(entrypoint, "utf8");
239
+ const escaped = module.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
240
+ const match = new RegExp(`export\\s+\\*\\s+as\\s+${escaped}\\s+from\\s+["']([^"']+)["']`).exec(
241
+ source,
242
+ );
243
+ if (!match?.[1]) {
244
+ throw new Error(`${entrypoint} does not export namespace ${module}`);
245
+ }
246
+ return Bun.resolveSync(match[1], dirname(entrypoint));
247
+ }
248
+
249
+ function canonicalPath(path: string): string {
250
+ try {
251
+ return realpathSync(path);
252
+ } catch {
253
+ return resolve(path);
254
+ }
255
+ }
256
+
257
+ function containsResolvedPath(parent: string, candidate: string): boolean {
258
+ return candidate === parent || candidate.startsWith(`${parent}${sep}`);
259
+ }
260
+
185
261
  /** All paths that can change a workspace-backed generated Node runtime. */
186
262
  export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings): string[] {
187
263
  if (config.workspaceDirectories.length === 0) {
@@ -189,7 +265,7 @@ export function pythonNodeBindingWatchInputs(config: ResolvedPythonNodeBindings)
189
265
  }
190
266
  return [
191
267
  config.pyproject,
192
- ...config.workspaceDirectories,
268
+ ...config.watchInputs,
193
269
  ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
194
270
  ];
195
271
  }
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Keep every configured Python Node bridge current with one repository watcher. */
3
- import { dirname, resolve, sep } from "node:path";
3
+ import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { log, object } from "@dbx-tools/shared-core";
5
+ import { log } from "@dbx-tools/shared-core";
6
6
  import { runTaskCommand } from "../src/_task-command.ts";
7
7
  import { repoRoot } from "../src/packages.ts";
8
8
  import {
@@ -12,36 +12,24 @@ import {
12
12
  resolveAllPythonNodeBindings,
13
13
  type ResolvedPythonNodeBindings,
14
14
  } from "../src/python-node-bindings.ts";
15
- import { watchLoop, watchRoots } from "../src/watch.ts";
15
+ import { watchLoop } from "../src/watch.ts";
16
16
  import { withWorkspaceMutationLock } from "../src/workspace-lock.ts";
17
17
 
18
18
  const logger = log.logger("projen:python-node-bindings-watch");
19
19
  const generator = fileURLToPath(new URL("./python-node-bindings.ts", import.meta.url));
20
+ const watcher = fileURLToPath(import.meta.url);
21
+ const bindingSource = fileURLToPath(new URL("../src/python-node-bindings.ts", import.meta.url));
22
+ const toolingInputs = [generator, watcher, bindingSource];
20
23
 
21
24
  function generate(project: string): void {
22
25
  runTaskCommand(repoRoot, "bun", [generator, "--root", repoRoot, "--project", project]);
23
26
  }
24
27
 
25
- function containsPath(parent: string, candidate: string): boolean {
26
- const resolvedParent = resolve(parent);
27
- const resolvedCandidate = resolve(candidate);
28
- return (
29
- resolvedCandidate === resolvedParent || resolvedCandidate.startsWith(`${resolvedParent}${sep}`)
30
- );
31
- }
32
-
33
28
  /**
34
- * Watch broad package roots, each configured Python workspace root, and only
35
- * exceptional inputs such as function overrides that live outside those roots.
29
+ * Watch only configured manifests and transitive workspace source inputs.
36
30
  */
37
31
  function watchPaths(configs: readonly ResolvedPythonNodeBindings[]): string[] {
38
- const roots = watchRoots();
39
- const pythonRoots = configs.map(({ projectDirectory }) => dirname(projectDirectory));
40
- const covered = [...roots, ...pythonRoots];
41
- const explicitInputs = configs
42
- .flatMap(pythonNodeBindingWatchInputs)
43
- .filter((input) => !covered.some((root) => containsPath(root, input)));
44
- return [...object.sequence(covered, explicitInputs).distinct()].sort();
32
+ return [...new Set([...configs.flatMap(pythonNodeBindingWatchInputs), ...toolingInputs])].sort();
45
33
  }
46
34
 
47
35
  function selection(
@@ -51,14 +39,20 @@ function selection(
51
39
  readonly all: boolean;
52
40
  readonly configs: ResolvedPythonNodeBindings[];
53
41
  readonly projects: string[];
42
+ readonly restart: boolean;
54
43
  } {
55
44
  const configs = resolveAllPythonNodeBindings(repoRoot);
56
45
  const manifests = new Set([...previous, ...configs].map(({ pyproject }) => resolve(pyproject)));
57
- const all = changed.some((path) => manifests.has(resolve(path)));
46
+ const all = changed.some(
47
+ (path) =>
48
+ manifests.has(resolve(path)) ||
49
+ toolingInputs.some((input) => resolve(input) === resolve(path)),
50
+ );
58
51
  return {
59
52
  all,
60
53
  configs,
61
54
  projects: all ? [] : affectedPythonNodeBindingProjects(repoRoot, configs, changed),
55
+ restart: watchPaths(previous).join("\n") !== watchPaths(configs).join("\n"),
62
56
  };
63
57
  }
64
58
 
@@ -77,11 +71,15 @@ export async function main(): Promise<void> {
77
71
  if (next.all) {
78
72
  generatePythonNodeBindings(repoRoot);
79
73
  logger.success("regenerated all Python Node bindings");
80
- return;
74
+ } else {
75
+ for (const project of next.projects) {
76
+ generate(project);
77
+ logger.success(`regenerated ${project} Node bindings`);
78
+ }
81
79
  }
82
- for (const project of next.projects) {
83
- generate(project);
84
- logger.success(`regenerated ${project} Node bindings`);
80
+ if (next.restart) {
81
+ logger.info("binding inputs changed; restarting watcher");
82
+ setTimeout(() => process.exit(0), 0);
85
83
  }
86
84
  },
87
85
  {
@@ -16,6 +16,7 @@ import { publicFunctionExports, publicNamespaceExports } from "../src/module-exp
16
16
  import { resolveRepoRoot } from "../src/packages.ts";
17
17
  import {
18
18
  generatePythonNodeBindings,
19
+ resolvePythonNodeBindingModule,
19
20
  resolvePythonNodeBindings,
20
21
  type ResolvedPythonNodeBindings,
21
22
  type ResolvedPythonNodeFunctionOverride,
@@ -139,7 +140,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
139
140
  );
140
141
  const packageEntrypoint = Bun.resolveSync(config.entrypoint, projectDirectory);
141
142
  const entrypoint = selectedModule
142
- ? resolveGeneratedModule(packageEntrypoint, selectedModule)
143
+ ? resolvePythonNodeBindingModule(packageEntrypoint, selectedModule)
143
144
  : packageEntrypoint;
144
145
  const functions = publicFunctionExports(entrypoint);
145
146
  if (functions.length === 0) {
@@ -217,7 +218,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
217
218
  return binding.modules.length > 0
218
219
  ? binding.modules.map((module) => ({
219
220
  key: runtimeModuleName(binding, module),
220
- entrypoint: resolveGeneratedModule(entrypoint, module),
221
+ entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
221
222
  }))
222
223
  : [{ key: runtimeModuleName(binding), entrypoint }];
223
224
  });
@@ -310,7 +311,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
310
311
  const packageEntrypoint = Bun.resolveSync(candidate.entrypoint, candidate.projectDirectory);
311
312
  const modules = publicNamespaceExports(packageEntrypoint).filter(
312
313
  (module) =>
313
- publicFunctionExports(resolveGeneratedModule(packageEntrypoint, module)).length > 0,
314
+ publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
314
315
  );
315
316
  return { ...candidate, modules };
316
317
  }
@@ -1220,19 +1221,4 @@ function pythonRuntimeLoader(source: string): string {
1220
1221
  ].join("\n");
1221
1222
  }
1222
1223
 
1223
- function resolveGeneratedModule(entrypoint: string, module: string): string {
1224
- if (!/^[$A-Z_a-z][$\w]*$/.test(module)) {
1225
- throw new Error(`Node binding module must be a TypeScript identifier: ${module}`);
1226
- }
1227
- const source = readFileSync(entrypoint, "utf8");
1228
- const escaped = module.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
1229
- const match = new RegExp(`export\\s+\\*\\s+as\\s+${escaped}\\s+from\\s+["']([^"']+)["']`).exec(
1230
- source,
1231
- );
1232
- if (!match?.[1]) {
1233
- throw new Error(`${entrypoint} does not export namespace ${module}`);
1234
- }
1235
- return Bun.resolveSync(match[1], dirname(entrypoint));
1236
- }
1237
-
1238
1224
  if (import.meta.main) await main();
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Write user-facing release notes under `docs/releases/` during `bun run release`.
3
+ *
4
+ * Prefers Genie Code (`dbx genie exec`). When that fails or writes an empty
5
+ * file, falls back to a short git-log summary so the release still commits notes.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { log } from "@dbx-tools/shared-core";
13
+
14
+ import { captureTaskCommand, tryTaskCommand } from "../src/_task-command.ts";
15
+
16
+ const logger = log.logger("projen:release-notes");
17
+
18
+ const GENIE_PROMPT =
19
+ "Compare HEAD and the working tree against the latest v* tag. Produce concise user-facing release notes. Ignore generated-file churn and test-only changes. Include breaking changes, fixes, and validation results. Do not modify files.";
20
+
21
+ /** Inputs for one notes write. */
22
+ export interface WriteReleaseNotesOptions {
23
+ /** Extra agent instructions appended after the standard release-note requirements. */
24
+ readonly instructions?: string;
25
+ readonly prefix: string;
26
+ readonly root: string;
27
+ readonly version: string;
28
+ /** Override Genie invocation; return false to force the git-log fallback. */
29
+ readonly runGenie?: (args: readonly string[]) => boolean;
30
+ }
31
+
32
+ /** Compose the stable release-note prompt with optional caller guidance. */
33
+ export function releaseNotesPrompt(instructions?: string): string {
34
+ const additional = instructions?.trim();
35
+ return additional ? `${GENIE_PROMPT}\n\nAdditional instructions:\n${additional}` : GENIE_PROMPT;
36
+ }
37
+
38
+ /** Path of the notes file for one workspace version. */
39
+ export function releaseNotesPath(root: string, version: string): string {
40
+ return join(root, "docs", "releases", `v${version}.md`);
41
+ }
42
+
43
+ /** Short markdown notes from commit subjects since the previous matching tag. */
44
+ export function fallbackReleaseNotes(
45
+ version: string,
46
+ commits: readonly string[],
47
+ previousTag?: string,
48
+ ): string {
49
+ const range = previousTag ? `since ${previousTag}` : "in this release";
50
+ const items =
51
+ commits.length > 0
52
+ ? commits.map((subject) => `- ${subject}`).join("\n")
53
+ : "- Version bump and synchronized package metadata.";
54
+ return `# Release ${version}\n\ndbx-tools ${version} ${range}.\n\n${items}\n`;
55
+ }
56
+
57
+ /** Run Genie into `docs/releases/vX.Y.Z.md`, or write {@link fallbackReleaseNotes}. */
58
+ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
59
+ const { prefix, root, version } = options;
60
+ const destination = releaseNotesPath(root, version);
61
+ mkdirSync(dirname(destination), { recursive: true });
62
+ const relative = join("docs", "releases", `v${version}.md`);
63
+ const genieArgs = [
64
+ "exec",
65
+ "-C",
66
+ root,
67
+ "--sandbox",
68
+ "read-only",
69
+ "--ephemeral",
70
+ "-o",
71
+ relative,
72
+ releaseNotesPrompt(options.instructions),
73
+ ] as const;
74
+ const ran =
75
+ options.runGenie?.([...genieArgs]) ??
76
+ tryTaskCommand(root, "bun", ["run", "dbx", "genie", ...genieArgs]);
77
+ if (ran && readNotes(destination)) {
78
+ logger.info("wrote Genie release notes", { path: relative });
79
+ return destination;
80
+ }
81
+ const previousTag = captureTaskCommand(root, "git", [
82
+ "describe",
83
+ "--tags",
84
+ "--abbrev=0",
85
+ `--match=${prefix}*`,
86
+ ]);
87
+ const logArgs = previousTag
88
+ ? ["log", "--no-merges", "--pretty=format:%s", `${previousTag}..HEAD`]
89
+ : ["log", "--no-merges", "--pretty=format:%s", "-15"];
90
+ const commits = captureTaskCommand(root, "git", logArgs)
91
+ .split("\n")
92
+ .map((line) => line.trim())
93
+ .filter(Boolean);
94
+ writeFileSync(destination, fallbackReleaseNotes(version, commits, previousTag || undefined));
95
+ logger.warn("Genie release notes unavailable; wrote git-log summary", { path: relative });
96
+ return destination;
97
+ }
98
+
99
+ function readNotes(path: string): string {
100
+ try {
101
+ return readFileSync(path, "utf8").trim();
102
+ } catch {
103
+ return "";
104
+ }
105
+ }
package/tasks/release.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Run the configured release transaction. */
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import * as projectUtils from "@dbx-tools/core/project-utils";
4
6
  import { log } from "@dbx-tools/shared-core";
5
7
  import { Command, Option } from "commander";
6
8
  import { publishLocalRelease } from "./local-publish.ts";
9
+ import { writeReleaseNotes } from "./release-notes.ts";
7
10
  import { assertReleaseVersion } from "./release-version.ts";
8
11
  import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
9
12
  import {
@@ -19,6 +22,14 @@ import { readWorkspaceVersion } from "../src/workspace-version.ts";
19
22
 
20
23
  const logger = log.logger("projen:release");
21
24
 
25
+ /** Whether the root manifest exposes one repository-owned script. */
26
+ function hasPackageScript(root: string, name: string): boolean {
27
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
28
+ scripts?: Record<string, string>;
29
+ };
30
+ return Boolean(manifest.scripts?.[name]);
31
+ }
32
+
22
33
  function gitHead(root: string, ref: string): string | undefined {
23
34
  if (!taskCommandSucceeds(root, "git", ["rev-parse", "--verify", ref])) return undefined;
24
35
  return captureTaskCommand(root, "git", ["rev-parse", ref], { check: true });
@@ -105,6 +116,13 @@ export async function runRelease(
105
116
  readonly install?: ReleaseInstallMode;
106
117
  readonly pythonRoot?: string;
107
118
  readonly validationTasks?: readonly string[];
119
+ /** After tagging, stage and deploy the AppKit demo app. Off by default. */
120
+ readonly demoDeploy?: boolean;
121
+ /** Write `docs/releases/vX.Y.Z.md` via Genie, with a git-log fallback. On by default. */
122
+ readonly releaseNotes?: boolean;
123
+ /** Additional instructions appended to the standard Genie release-notes prompt. */
124
+ readonly releaseNotesInstructions?: string;
125
+ readonly writeReleaseNotes?: typeof writeReleaseNotes;
108
126
  },
109
127
  ): Promise<string> {
110
128
  const { branch, prefix, remote, root } = options;
@@ -112,6 +130,9 @@ export async function runRelease(
112
130
  const install = options.install ?? "auto";
113
131
 
114
132
  if (install === "always") runTaskCommand(root, "bun", ["install"]);
133
+ if (selection.validation && hasPackageScript(root, "eslint:fix")) {
134
+ runTaskCommand(root, "bun", ["run", "eslint:fix"]);
135
+ }
115
136
 
116
137
  prepareReleaseBranch({ branch, remote, root });
117
138
 
@@ -125,6 +146,14 @@ export async function runRelease(
125
146
  const version = readWorkspaceVersion(root);
126
147
  const tag = `${prefix}${version}`;
127
148
  assertReleaseVersion(version, { root, prefixes: [prefix], assertNext: true });
149
+ if ((options.releaseNotes ?? true) && (options.bump ?? true)) {
150
+ (options.writeReleaseNotes ?? writeReleaseNotes)({
151
+ prefix,
152
+ root,
153
+ version,
154
+ instructions: options.releaseNotesInstructions,
155
+ });
156
+ }
128
157
 
129
158
  if (taskCommandSucceeds(root, "git", ["rev-parse", "--verify", `refs/tags/${tag}`])) {
130
159
  throw new Error(`release tag ${tag} already exists`);
@@ -190,6 +219,10 @@ export async function runRelease(
190
219
  });
191
220
  }
192
221
 
222
+ if (options.demoDeploy) {
223
+ runTaskCommand(root, "bun", ["run", "demo:deploy"]);
224
+ }
225
+
193
226
  return tag;
194
227
  }
195
228
 
@@ -228,6 +261,12 @@ export function createReleaseCommand(): Command {
228
261
  "--no-validation",
229
262
  "skip optional release validation tasks; version/source checks remain mandatory",
230
263
  )
264
+ .option("--no-release-notes", "skip writing docs/releases notes (Genie and git-log fallback)")
265
+ .option(
266
+ "--release-notes-instructions <text>",
267
+ "append custom instructions to the Genie release-notes prompt",
268
+ )
269
+ .option("--demo-deploy", "after tagging, stage and deploy the AppKit demo app (off by default)")
231
270
  .option("--no-local-publish", "skip publishing to configured local registries")
232
271
  .option("--local-registry <auto|false|url>", "local npm registry selection", "auto")
233
272
  .option("--local-pypi <auto|false|url>", "local devpi registry selection", "auto")
@@ -245,6 +284,9 @@ export function createReleaseCommand(): Command {
245
284
  localPypi: string;
246
285
  install: ReleaseInstallMode;
247
286
  validate: string[];
287
+ demoDeploy?: boolean;
288
+ releaseNotes?: boolean;
289
+ releaseNotesInstructions?: string;
248
290
  },
249
291
  ) => {
250
292
  await runRelease({
@@ -264,6 +306,9 @@ export function createReleaseCommand(): Command {
264
306
  docs: options.docs,
265
307
  validation: options.validation,
266
308
  validationTasks: options.validate,
309
+ demoDeploy: options.demoDeploy,
310
+ releaseNotes: options.releaseNotes,
311
+ releaseNotesInstructions: options.releaseNotesInstructions,
267
312
  });
268
313
  },
269
314
  );