@dbx-tools/projen 0.9.95 → 0.9.105

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
@@ -2,23 +2,8 @@
2
2
 
3
3
  Projen engine for Bun-first dbx-tools workspaces.
4
4
 
5
- Import this package from `.projenrc.ts` when a repository should discover
6
- packages from the filesystem and generate manifests, tsconfigs,
7
- barrels, codegen outputs, and release tasks.
8
-
9
- Key features:
10
-
11
- - Filesystem package discovery: every `src`-bearing folder under configured
12
- workspace roots becomes a TypeScript package.
13
- - Tag-driven runtime defaults for shared libraries, Node packages, CLIs,
14
- servers, and React/browser UI packages.
15
- - Generated package manifests, tsconfigs, package-root barrels, Bun app configs,
16
- VS Code settings, and a committed `pnpm-workspace.yaml` retained for
17
- Databricks Apps deployment.
18
- - Extensible mixin system so repositories can add deps, tasks, or generated
19
- files based on package predicates.
20
- - Zod schema generation from `.d.ts` inputs.
21
- - Read-only generated-file ownership, cleanup, and watch-loop helpers.
5
+ Use it from `.projenrc.ts` to discover packages from the filesystem and generate
6
+ manifests, tsconfigs, barrels, code generation, and release tasks.
22
7
 
23
8
  ## Define A Workspace Root
24
9
 
@@ -34,6 +19,11 @@ const rootProject = new project.DBXToolsNodeProject({
34
19
  rootProject.synth();
35
20
  ```
36
21
 
22
+ The project discovers every `src`-bearing folder under the configured roots and
23
+ applies defaults for shared libraries, Node packages, CLIs, servers, and React
24
+ packages. It can also generate Zod schemas from declarations and coordinate
25
+ generated-file cleanup and watch tasks.
26
+
37
27
  Every `src`-bearing folder under the configured roots becomes a
38
28
  `DBXToolsTypeScriptProject`. Folder path drives package name and runtime tags.
39
29
 
@@ -338,6 +328,22 @@ wrong fix, since the consumer authors no JSX and cannot know a transitive
338
328
  dependency started to. The option is inert without JSX in the graph: it selects
339
329
  how JSX syntax compiles and adds no lib, global, or type dependency.
340
330
 
331
+ Keep the generated tsconfigs package-local. Each package build config owns its
332
+ `rootDir`, declaration output, package-specific globals, and publish boundary.
333
+ Projen also places a no-emit config in each `test/` directory so TypeScript 6,
334
+ editors, and type-aware ESLint resolve the nearest project for test files. A
335
+ single config per runtime folder would merge package output boundaries and would
336
+ remove that nearest-config behavior. The runtime tag mixins already centralize
337
+ the environment differences without replacing the package configs.
338
+
339
+ Compilation preserves TypeScript's incremental cache. The root compile task
340
+ passes the package configs to batched `tsc --build` processes, and each config
341
+ retains its own `tsconfig.tsbuildinfo`. Synthesis also retains Projen's native
342
+ change detection: unchanged generated files are not rewritten, and dependency
343
+ installation runs only when the generated root manifest changes or
344
+ `node_modules` is absent. Repository discovery is process-cached so every child
345
+ package reuses the root probe instead of invoking `git`, `ssh`, and `gh` again.
346
+
341
347
  ## Work With Package Discovery
342
348
 
343
349
  ```ts
@@ -497,6 +503,8 @@ bun run release --publish local --install never
497
503
  bun run release --local-registry false --local-pypi auto
498
504
  bun run release --demo-deploy
499
505
  bun run release --no-release-notes
506
+ bun run release --release-notes-text "# Release 1.2.3"
507
+ bun run release --release-notes-file ./notes.md
500
508
  bun run release --release-notes-instructions "Focus on operator-visible changes."
501
509
  ```
502
510
 
@@ -512,6 +520,8 @@ After bump and configured validation, release writes
512
520
  --ephemeral -o`. The standard prompt forbids running tests or other validation
513
521
  commands. If Genie fails or writes an empty file, the step keeps going with a
514
522
  short git-log summary. `--no-release-notes` skips the file.
523
+ `--release-notes-text` writes supplied markdown directly, while
524
+ `--release-notes-file` copies a prepared markdown file. Both bypass Genie.
515
525
  `--release-notes-instructions` appends run-specific guidance to the standard
516
526
  Genie prompt. Notes are committed with the version bump and are not recorded in
517
527
  the annotated tag. They are skipped on `--no-bump` because that path requires an
@@ -522,7 +532,8 @@ already committed tree.
522
532
  installed while still regenerating versioned sources. Local release validation
523
533
  runs `eslint:fix` before branch preparation and bump, then runs the selected
524
534
  fail-closed tasks; CI remains check-only.
525
- `--no-validation` skips optional task checks locally and in CI, not version or immutable-source checks.
535
+ CI rebuilds the selected release artifacts instead of checking generated-source fingerprints.
536
+ `--no-validation` skips optional task checks locally and in CI, not workspace-version or immutable-source checks.
526
537
  `--no-docs` skips building and deploying the site, not package README validation.
527
538
  Scopes select artifacts, not the normal commit and tag-push transaction.
528
539
  The annotated tag records CI step selections, so publishing a selected scope does
@@ -588,8 +599,10 @@ release [options]
588
599
  | `--no-pypi` | skip Python build and publication, including local Python publication |
589
600
  | `--docs` | build and deploy docs for a selected scope |
590
601
  | `--no-docs` | skip documentation build and deployment |
591
- | `--no-validation` | skip optional release validation tasks; version/source checks remain mandatory |
602
+ | `--no-validation` | skip optional release validation tasks; workspace version checks remain mandatory |
592
603
  | `--no-release-notes` | skip writing docs/releases notes (Genie and git-log fallback) |
604
+ | `--release-notes-text <markdown>` | write supplied markdown without invoking Genie |
605
+ | `--release-notes-file <path>` | copy supplied markdown without invoking Genie |
593
606
  | `--release-notes-instructions <text>` | append custom instructions to the Genie release-notes prompt |
594
607
  | `--demo-deploy` | after tagging, stage and deploy the AppKit demo app (off by default) |
595
608
  | `--profile <name>` | Databricks CLI profile for --demo-deploy |
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.95";
6
+ export const PACKAGE_VERSION = "0.9.105";
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.95",
34
- "@dbx-tools/path": "^0.9.95",
35
- "@dbx-tools/shared-core": "^0.9.95",
33
+ "@dbx-tools/core": "^0.9.105",
34
+ "@dbx-tools/path": "^0.9.105",
35
+ "@dbx-tools/shared-core": "^0.9.105",
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.95",
57
+ "version": "0.9.105",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
package/src/project-js.ts CHANGED
@@ -12,7 +12,7 @@ import * as projectUtils from "@dbx-tools/core/project-utils";
12
12
  import { ignore, match } from "@dbx-tools/path";
13
13
  import { object, stringUtils, type OneOrMany } from "@dbx-tools/shared-core";
14
14
  import { type IConstruct } from "constructs";
15
- import { Component, LogLevel, Project, javascript, typescript } from "projen";
15
+ import { Component, LogLevel, Project, type Task, javascript, typescript } from "projen";
16
16
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
17
17
  import type { ReleaseProjectOptions } from "projen/lib/release";
18
18
  import { generateBarrels } from "./barrels.ts";
@@ -587,7 +587,6 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
587
587
  /** Release build prerequisites installed before repository validation tasks. */
588
588
  readonly releaseSetupSteps?: readonly JobStep[];
589
589
  /** Repository-specific synthesis commands run before release source verification. */
590
- readonly releaseSynthesisCommands?: readonly string[];
591
590
  /** Set to `false` to omit normal npm workspace publication. */
592
591
  readonly nodeRelease?: boolean;
593
592
  /** Unified dbx-tools release workflow, or no release surface. Defaults to `dbx-tools`. */
@@ -605,8 +604,9 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
605
604
  readonly extraWorkspaceMembers?: readonly string[];
606
605
  /**
607
606
  * Install workspace dependencies once from the custom root instead of once
608
- * per child project during post-synthesis. Defaults to `true`; set `false` to
609
- * preserve projen's native per-project install tasks.
607
+ * per child project during post-synthesis. Defaults to `true`; set `false`
608
+ * on the root to preserve every native child install, or on an individual
609
+ * child to retain that package's native install lifecycle.
610
610
  */
611
611
  readonly rootInstallOnly?: boolean;
612
612
  };
@@ -636,7 +636,7 @@ export class DBXToolsNodeProject
636
636
  rootTsconfig?: DBXToolsRootTsconfig;
637
637
  readonly extraWorkspaceMembers: readonly string[];
638
638
  readonly releaseBranch: string;
639
- private readonly rootInstallOnly: boolean;
639
+ readonly rootInstallOnly: boolean;
640
640
 
641
641
  constructor(options: DBXToolsJavaScriptProjectOptions = {}) {
642
642
  validateReleaseOptions(options);
@@ -684,18 +684,67 @@ export class DBXToolsNodeProject
684
684
  *
685
685
  * Every projen child has its own `NodePackage` post-synth hook, which otherwise
686
686
  * runs the install task against the same root workspace once per package. Clear
687
- * the public child install tasks while leaving dependency resolution and the
688
- * root's real install/install:ci tasks intact. Applied in root `preSynthesize`
689
- * so manually attached late children are included and repeated synths remain
690
- * idempotent.
687
+ * the public child install tasks and suppress Projen's protected install hook,
688
+ * while leaving the rest of `NodePackage.postSynthesize` (including dependency
689
+ * resolution) and the root's real install/install:ci tasks intact. A child can
690
+ * set `rootInstallOnly: false` when it owns a package-local dependency tree.
691
+ * Applied in root `preSynthesize` so manually attached late children are
692
+ * included and repeated synths remain idempotent.
691
693
  */
694
+ class RootInstallOnlyChild extends Component {
695
+ private readonly child: DBXToolsNodeProject | DBXToolsTypeScriptProject;
696
+ private readonly installDependencies: (trigger: unknown) => void;
697
+ private suppressed = false;
698
+
699
+ constructor(child: DBXToolsNodeProject | DBXToolsTypeScriptProject) {
700
+ super(child, "RootInstallOnly");
701
+ this.child = child;
702
+ const nodePackage = child.package as unknown as {
703
+ installDependencies(trigger: unknown): void;
704
+ };
705
+ if (typeof nodePackage.installDependencies !== "function") {
706
+ throw new Error("Projen NodePackage no longer exposes installDependencies");
707
+ }
708
+ this.installDependencies = nodePackage.installDependencies;
709
+ }
710
+
711
+ public override preSynthesize(): void {
712
+ const nodePackage = this.child.package as unknown as {
713
+ installDependencies(trigger: unknown): void;
714
+ };
715
+ if (
716
+ hasCustomInstallSteps(
717
+ this.child.package.installTask,
718
+ this.child.package.installAndUpdateLockfileCommand,
719
+ ) ||
720
+ hasCustomInstallSteps(this.child.package.installCiTask, this.child.package.installCommand)
721
+ ) {
722
+ if (this.suppressed) nodePackage.installDependencies = this.installDependencies;
723
+ this.suppressed = false;
724
+ return;
725
+ }
726
+ this.child.package.installTask.reset();
727
+ this.child.package.installCiTask.reset();
728
+ nodePackage.installDependencies = () => {};
729
+ this.suppressed = true;
730
+ }
731
+ }
732
+
733
+ function hasCustomInstallSteps(task: Task, defaultCommand: string): boolean {
734
+ if (task.steps.length === 0) return false;
735
+ if (task.steps.length !== 1) return true;
736
+ const step = task.steps[0]!;
737
+ return step.exec !== defaultCommand;
738
+ }
739
+
740
+ /** Suppress redundant child install tasks when the workspace root owns dependency installation. */
692
741
  export const ROOT_INSTALL_ONLY_MIXIN = mixin.create(
693
742
  (construct: IConstruct): construct is DBXToolsNodeProject | DBXToolsTypeScriptProject =>
694
743
  (construct instanceof DBXToolsNodeProject || construct instanceof DBXToolsTypeScriptProject) &&
695
- construct.parent !== undefined,
744
+ construct.parent !== undefined &&
745
+ construct.rootInstallOnly,
696
746
  (child) => {
697
- child.package.installTask.reset();
698
- child.package.installCiTask.reset();
747
+ if (!child.node.tryFindChild("RootInstallOnly")) new RootInstallOnlyChild(child);
699
748
  },
700
749
  );
701
750
 
@@ -717,7 +766,7 @@ export class DBXToolsTypeScriptProject
717
766
  rootTsconfig?: DBXToolsRootTsconfig;
718
767
  readonly extraWorkspaceMembers: readonly string[];
719
768
  readonly releaseBranch: string;
720
- private readonly rootInstallOnly: boolean;
769
+ readonly rootInstallOnly: boolean;
721
770
 
722
771
  constructor(options: DBXToolsTypeScriptProjectOptions) {
723
772
  validateReleaseOptions(options);
@@ -1331,7 +1380,6 @@ function initProject(
1331
1380
  pythonRoot: options.releasePythonRoot,
1332
1381
  validationTasks: options.releaseValidationTasks,
1333
1382
  setupSteps: options.releaseSetupSteps,
1334
- synthesisCommands: options.releaseSynthesisCommands,
1335
1383
  });
1336
1384
  }
1337
1385
  }
@@ -1,14 +1,58 @@
1
1
  /** Shared pyproject-backed configuration for PythonMonkey Node bindings. */
2
- import { readFileSync, realpathSync, rmSync } from "node:fs";
3
- import { dirname, join, relative, resolve, sep } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { find } from "@dbx-tools/path";
6
7
  import { object, stringUtils } from "@dbx-tools/shared-core";
7
8
  import { parse } from "smol-toml";
8
9
  import ts from "typescript";
9
10
  import { runTaskCommand } from "./_task-command.ts";
11
+ import { makeReadonly } from "./generated.ts";
12
+ import { publicFunctionExports, publicNamespaceExports } from "./module-exports.ts";
10
13
  import { resolveRepoRoot, workspaceDependencyDirectories } from "./packages.ts";
11
14
 
15
+ const FINGERPRINT_VERSION = 5;
16
+ const INPUT_FINGERPRINT_LABEL = "dbx-tools binding inputs sha256";
17
+ const CONTENT_FINGERPRINT_LABEL = "dbx-tools binding content sha256";
18
+ const PYTHON_KEYWORDS = new Set([
19
+ "and",
20
+ "as",
21
+ "assert",
22
+ "async",
23
+ "await",
24
+ "break",
25
+ "class",
26
+ "continue",
27
+ "def",
28
+ "del",
29
+ "elif",
30
+ "else",
31
+ "except",
32
+ "False",
33
+ "finally",
34
+ "for",
35
+ "from",
36
+ "global",
37
+ "if",
38
+ "import",
39
+ "in",
40
+ "is",
41
+ "lambda",
42
+ "None",
43
+ "nonlocal",
44
+ "not",
45
+ "or",
46
+ "pass",
47
+ "raise",
48
+ "return",
49
+ "True",
50
+ "try",
51
+ "while",
52
+ "with",
53
+ "yield",
54
+ ]);
55
+
12
56
  /** Resolved function replacement consumed while generating one PythonMonkey bundle. */
13
57
  export interface ResolvedPythonNodeFunctionOverride {
14
58
  readonly handlerExport: string;
@@ -38,6 +82,8 @@ export interface ResolvedPythonNodeBindings {
38
82
  export interface GeneratePythonNodeBindingsOptions {
39
83
  /** Verify generated output without changing it. */
40
84
  readonly check?: boolean;
85
+ /** Regenerate outputs without consulting fingerprints. */
86
+ readonly force?: boolean;
41
87
  }
42
88
 
43
89
  /** Generate or verify every pyproject-configured Node binding in a repository. */
@@ -46,10 +92,14 @@ export function generatePythonNodeBindings(
46
92
  options: GeneratePythonNodeBindingsOptions = {},
47
93
  ): void {
48
94
  const task = resolve(dirname(fileURLToPath(import.meta.url)), "../tasks/python-node-bindings.ts");
49
- const configuredProjects = pythonNodeBindingProjects(projectRoot);
95
+ const force = options.force ?? process.env.CI === "true";
96
+ const configuredProjects = pythonNodeBindingProjects(projectRoot).map((project) => ({
97
+ project,
98
+ configs: resolvePythonNodeBindings(projectRoot, project).map(resolvePythonNodeBindingModules),
99
+ }));
50
100
  const directories = new Set<string>();
51
- for (const project of configuredProjects) {
52
- for (const config of resolvePythonNodeBindings(projectRoot, project)) {
101
+ for (const { configs } of configuredProjects) {
102
+ for (const config of configs) {
53
103
  directories.add(relative(projectRoot, join(config.moduleDirectory, "_generated", "node")));
54
104
  }
55
105
  }
@@ -64,7 +114,12 @@ export function generatePythonNodeBindings(
64
114
  for (const directory of stale) {
65
115
  rmSync(resolve(projectRoot, directory), { recursive: true, force: true });
66
116
  }
67
- for (const project of configuredProjects) {
117
+ for (const { configs, project } of configuredProjects) {
118
+ const inputFingerprint = pythonNodeBindingInputFingerprint(projectRoot, configs);
119
+ if (!force && pythonNodeBindingOutputsCurrent(configs, inputFingerprint)) {
120
+ for (const output of pythonNodeBindingOutputs(configs)) makeReadonly(output);
121
+ continue;
122
+ }
68
123
  runTaskCommand(projectRoot, "bun", [
69
124
  task,
70
125
  "--root",
@@ -72,10 +127,194 @@ export function generatePythonNodeBindings(
72
127
  "--project",
73
128
  project,
74
129
  ...(options.check ? ["--check"] : []),
130
+ ...(force ? ["--force"] : []),
75
131
  ]);
76
132
  }
77
133
  }
78
134
 
135
+ /** Resolve automatically discovered bindable namespaces for one configured package. */
136
+ export function resolvePythonNodeBindingModules(
137
+ config: ResolvedPythonNodeBindings,
138
+ ): ResolvedPythonNodeBindings {
139
+ if (config.modules.length > 0) return config;
140
+ const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
141
+ const modules = publicNamespaceExports(packageEntrypoint).filter(
142
+ (module) =>
143
+ publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
144
+ );
145
+ return { ...config, modules };
146
+ }
147
+
148
+ /** Map a JavaScript export or namespace to its generated Python identifier. */
149
+ export function pythonNodeBindingFunctionName(javascriptName: string): string {
150
+ const name = stringUtils.toIdentifierWithOptions({ delimiter: "_" }, javascriptName);
151
+ if (!isPythonNodeBindingIdentifier(name)) {
152
+ throw new Error(
153
+ `JavaScript export ${javascriptName} does not map to a valid Python function name`,
154
+ );
155
+ }
156
+ return name;
157
+ }
158
+
159
+ /** Whether a value is a legal non-keyword Python identifier. */
160
+ export function isPythonNodeBindingIdentifier(value: string): boolean {
161
+ return /^[_A-Za-z]\w*$/.test(value) && !PYTHON_KEYWORDS.has(value);
162
+ }
163
+
164
+ /** Content fingerprint for every source and toolchain input affecting generated bindings. */
165
+ export function pythonNodeBindingInputFingerprint(
166
+ root: string,
167
+ configs: readonly ResolvedPythonNodeBindings[],
168
+ ): string {
169
+ const canonicalRoot = canonicalPath(root);
170
+ const fingerprintPath = (path: string): string => relative(canonicalRoot, canonicalPath(path));
171
+ const sourceFile = canonicalPath(fileURLToPath(import.meta.url));
172
+ const sourceDirectory = dirname(sourceFile);
173
+ const files = new Set<string>([
174
+ sourceFile,
175
+ canonicalPath(resolve(sourceDirectory, "../tasks/python-node-bindings.ts")),
176
+ canonicalPath(join(sourceDirectory, "generated.ts")),
177
+ canonicalPath(join(sourceDirectory, "module-exports.ts")),
178
+ canonicalPath(join(sourceDirectory, "packages.ts")),
179
+ canonicalPath(join(sourceDirectory, "python-node-runtime.ts")),
180
+ ]);
181
+ const versionFile = join(root, "VERSION");
182
+ if (existsSync(versionFile)) files.add(canonicalPath(versionFile));
183
+ for (const config of configs) {
184
+ for (const input of config.watchInputs) files.add(canonicalPath(resolve(root, input)));
185
+ for (const { handlerFile } of config.functionOverrides) files.add(canonicalPath(handlerFile));
186
+ const packageEntrypoint = canonicalPath(
187
+ Bun.resolveSync(config.entrypoint, config.projectDirectory),
188
+ );
189
+ files.add(packageEntrypoint);
190
+ const packageManifest = nearestPackageManifest(packageEntrypoint);
191
+ if (packageManifest) files.add(canonicalPath(packageManifest));
192
+ for (const module of config.modules) {
193
+ files.add(canonicalPath(resolvePythonNodeBindingModule(packageEntrypoint, module)));
194
+ }
195
+ }
196
+
197
+ const hash = createHash("sha256");
198
+ hash.update(
199
+ JSON.stringify({
200
+ configs: configs.map((config) => ({
201
+ bindingDirectory: fingerprintPath(config.bindingDirectory),
202
+ bindingName: config.bindingName,
203
+ entrypoint: config.entrypoint,
204
+ functionOverrides: config.functionOverrides.map((override) => ({
205
+ handlerExport: override.handlerExport,
206
+ handlerFile: fingerprintPath(override.handlerFile),
207
+ targetExport: override.targetExport,
208
+ targetModule: override.targetModule,
209
+ })),
210
+ moduleDirectory: fingerprintPath(config.moduleDirectory),
211
+ modules: config.modules,
212
+ package: config.package,
213
+ project: config.project,
214
+ runtimeOutput: fingerprintPath(config.runtimeOutput),
215
+ })),
216
+ format: FINGERPRINT_VERSION,
217
+ }),
218
+ );
219
+ for (const { file, path } of [...files]
220
+ .map((file) => ({ file, path: fingerprintPath(file) }))
221
+ .sort((left, right) => left.path.localeCompare(right.path))) {
222
+ hash.update("\0file\0");
223
+ hash.update(path);
224
+ hash.update("\0");
225
+ hash.update(existsSync(file) ? readFileSync(file) : "<missing>");
226
+ }
227
+ return hash.digest("hex");
228
+ }
229
+
230
+ /** Complete generated output set for one Python project's Node bindings. */
231
+ export function pythonNodeBindingOutputs(configs: readonly ResolvedPythonNodeBindings[]): string[] {
232
+ const nodeDirectory = pythonNodeBindingDirectory(configs);
233
+ return [
234
+ join(nodeDirectory, "_runtime.js"),
235
+ join(nodeDirectory, "_runtime.py"),
236
+ ...configs.flatMap((config) =>
237
+ config.modules.length > 0
238
+ ? config.modules.map((module) =>
239
+ join(config.bindingDirectory, `${pythonNodeBindingFunctionName(module)}.py`),
240
+ )
241
+ : [join(config.bindingDirectory, "index.py")],
242
+ ),
243
+ ].sort();
244
+ }
245
+
246
+ /** Whether all expected outputs exactly match their input and body fingerprints. */
247
+ export function pythonNodeBindingOutputsCurrent(
248
+ configs: readonly ResolvedPythonNodeBindings[],
249
+ inputFingerprint: string,
250
+ ): boolean {
251
+ const generatedPackage = join(configs[0]!.moduleDirectory, "_generated", "__init__.py");
252
+ if (existsSync(generatedPackage)) return false;
253
+ const expected = pythonNodeBindingOutputs(configs);
254
+ const actual = filesBelow(pythonNodeBindingDirectory(configs)).sort();
255
+ if (actual.length !== expected.length || actual.some((file, index) => file !== expected[index])) {
256
+ return false;
257
+ }
258
+ return expected.every((output) => pythonNodeBindingFingerprintCurrent(output, inputFingerprint));
259
+ }
260
+
261
+ /** Prefix generated output with its input fingerprint and a self-validating body digest. */
262
+ export function fingerprintPythonNodeBindingOutput(
263
+ contents: string,
264
+ inputFingerprint: string,
265
+ output: string,
266
+ ): string {
267
+ const prefix = output.endsWith(".py") ? "#" : "//";
268
+ const contentFingerprint = createHash("sha256").update(contents).digest("hex");
269
+ return [
270
+ `${prefix} ${INPUT_FINGERPRINT_LABEL}: ${inputFingerprint}`,
271
+ `${prefix} ${CONTENT_FINGERPRINT_LABEL}: ${contentFingerprint}`,
272
+ contents,
273
+ ].join("\n");
274
+ }
275
+
276
+ function pythonNodeBindingDirectory(configs: readonly ResolvedPythonNodeBindings[]): string {
277
+ const moduleDirectory = configs[0]?.moduleDirectory;
278
+ if (!moduleDirectory) throw new Error("Expected at least one Node binding configuration");
279
+ return join(moduleDirectory, "_generated", "node");
280
+ }
281
+
282
+ function pythonNodeBindingFingerprintCurrent(output: string, inputFingerprint: string): boolean {
283
+ try {
284
+ const [inputLine, contentLine, ...bodyLines] = readFileSync(output, "utf8").split("\n");
285
+ const prefix = output.endsWith(".py") ? "#" : "//";
286
+ if (inputLine !== `${prefix} ${INPUT_FINGERPRINT_LABEL}: ${inputFingerprint}`) return false;
287
+ const escapedPrefix = prefix === "//" ? "\\/\\/" : "#";
288
+ const expectedContent = contentLine?.match(
289
+ new RegExp(`^${escapedPrefix} ${CONTENT_FINGERPRINT_LABEL}: ([a-f0-9]{64})$`),
290
+ )?.[1];
291
+ if (!expectedContent) return false;
292
+ return createHash("sha256").update(bodyLines.join("\n")).digest("hex") === expectedContent;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ function filesBelow(directory: string): string[] {
299
+ if (!existsSync(directory)) return [];
300
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
301
+ if (entry.name === "__pycache__" || entry.name.endsWith(".pyc")) return [];
302
+ const path = join(directory, entry.name);
303
+ return entry.isDirectory() ? filesBelow(path) : [path];
304
+ });
305
+ }
306
+
307
+ function nearestPackageManifest(path: string): string | undefined {
308
+ let directory = dirname(path);
309
+ while (true) {
310
+ const manifest = join(directory, "package.json");
311
+ if (existsSync(manifest)) return manifest;
312
+ const parent = dirname(directory);
313
+ if (parent === directory) return undefined;
314
+ directory = parent;
315
+ }
316
+ }
317
+
79
318
  /** Resolve every configured binding in a repository. */
80
319
  export function resolveAllPythonNodeBindings(
81
320
  projectRoot: string = resolveRepoRoot(),
@@ -102,16 +341,28 @@ export function pythonNodeBindingProjects(projectRoot: string): string[] {
102
341
 
103
342
  function existingBindingDirectories(projectRoot: string): string[] {
104
343
  const directories = new Set<string>();
105
- for (const file of find.findFiles("**/_generated/node/**/*", {
106
- cwd: projectRoot,
107
- ignore: () => false,
108
- })) {
109
- const segments = file.split("/");
110
- const generated = segments.lastIndexOf("_generated");
111
- if (generated >= 0 && segments[generated + 1] === "node") {
112
- directories.add(segments.slice(0, generated + 2).join("/"));
344
+ const ignored = new Set([
345
+ ".git",
346
+ ".mypy_cache",
347
+ ".pytest_cache",
348
+ ".ruff_cache",
349
+ ".venv",
350
+ "__pycache__",
351
+ "dist",
352
+ "node_modules",
353
+ ]);
354
+ const visit = (directory: string): void => {
355
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
356
+ if (!entry.isDirectory() || ignored.has(entry.name)) continue;
357
+ const path = join(directory, entry.name);
358
+ if (entry.name === "node" && basename(directory) === "_generated") {
359
+ directories.add(relative(projectRoot, path));
360
+ continue;
361
+ }
362
+ visit(path);
113
363
  }
114
- }
364
+ };
365
+ visit(projectRoot);
115
366
  return [...directories].sort();
116
367
  }
117
368
 
@@ -192,16 +443,23 @@ type BindingSourceConfig = Omit<ResolvedPythonNodeBindings, "watchInputs">;
192
443
 
193
444
  /** Transitive workspace source files reachable from configured binding modules. */
194
445
  function bindingSourceInputs(root: string, config: BindingSourceConfig): string[] {
195
- const allowed = config.workspaceDirectories.map(canonicalPath);
196
446
  const packageEntrypoint = Bun.resolveSync(config.entrypoint, config.projectDirectory);
197
447
  const entrypoints =
198
448
  config.modules.length > 0
199
449
  ? config.modules.map((module) => resolvePythonNodeBindingModule(packageEntrypoint, module))
200
450
  : [packageEntrypoint];
201
- const pending = [
202
- ...entrypoints,
203
- ...config.functionOverrides.map(({ handlerFile }) => handlerFile),
204
- ];
451
+ return transitiveSourceFiles(
452
+ [...entrypoints, ...config.functionOverrides.map(({ handlerFile }) => handlerFile)],
453
+ config.workspaceDirectories,
454
+ ).map((path) => relative(root, path));
455
+ }
456
+
457
+ function transitiveSourceFiles(
458
+ entrypoints: readonly string[],
459
+ allowedDirectories: readonly string[],
460
+ ): string[] {
461
+ const allowed = allowedDirectories.map(canonicalPath);
462
+ const pending = [...entrypoints];
205
463
  const visited = new Set<string>();
206
464
  while (pending.length > 0) {
207
465
  const candidate = canonicalPath(pending.pop()!);
@@ -227,7 +485,7 @@ function bindingSourceInputs(root: string, config: BindingSourceConfig): string[
227
485
  }
228
486
  }
229
487
  }
230
- return [...visited].sort().map((path) => relative(root, path));
488
+ return [...visited].sort();
231
489
  }
232
490
 
233
491
  /** Resolve one configured namespace from a package entrypoint barrel. */
@@ -247,10 +505,17 @@ export function resolvePythonNodeBindingModule(entrypoint: string, module: strin
247
505
  }
248
506
 
249
507
  function canonicalPath(path: string): string {
250
- try {
251
- return realpathSync(path);
252
- } catch {
253
- return resolve(path);
508
+ const missing: string[] = [];
509
+ let candidate = resolve(path);
510
+ while (true) {
511
+ try {
512
+ return resolve(realpathSync(candidate), ...missing);
513
+ } catch {
514
+ const parent = dirname(candidate);
515
+ if (parent === candidate) return resolve(path);
516
+ missing.unshift(basename(candidate));
517
+ candidate = parent;
518
+ }
254
519
  }
255
520
  }
256
521
 
package/src/release.ts CHANGED
@@ -51,8 +51,6 @@ export interface DBXToolsReleaseOptions {
51
51
  readonly validationTasks?: readonly string[];
52
52
  /** Repository prerequisites installed before release validation and artifact builds. */
53
53
  readonly setupSteps?: readonly JobStep[];
54
- /** Repository-specific synthesis commands run before the root Projen synthesis check. */
55
- readonly synthesisCommands?: readonly string[];
56
54
  }
57
55
 
58
56
  /** Locate the unified workflow when release generation is enabled. */
@@ -204,14 +202,6 @@ function releaseBuildJob(
204
202
  // ============================================================================
205
203
  ),
206
204
  },
207
- {
208
- name: "Verify generated sources",
209
- run: [
210
- ...(options.synthesisCommands ?? []),
211
- "bunx projen",
212
- "git diff --ignore-space-at-eol --exit-code",
213
- ].join("\n"),
214
- },
215
205
  { name: "Verify workspace versions", run: "bun run version:check" },
216
206
  ...(options.setupSteps ?? []),
217
207
  ...releaseBuildSteps(
package/src/tsconfig.ts CHANGED
@@ -55,7 +55,7 @@ export class DBXToolsRootTsconfig extends Component {
55
55
  extends: javascript.TypescriptConfigExtends.fromTypescriptConfigs([this.base]),
56
56
  compilerOptions: {
57
57
  lib: ["ESNext"],
58
- types: ["node"],
58
+ types: ["node", "bun"],
59
59
  },
60
60
  include: [".projenrc.ts"],
61
61
  exclude: ["node_modules", "**/dist", "**/node_modules"],
package/tasks/bump.ts CHANGED
@@ -35,6 +35,11 @@ export async function main(): Promise<void> {
35
35
 
36
36
  if (opts.synth) {
37
37
  runTaskCommand(root, process.execPath, [".projenrc.ts"]);
38
+ // Package manifests are generated during the first synthesis, while
39
+ // cross-package derived artifacts can read those manifests earlier in
40
+ // the same pass. A second pass makes the new version visible to every
41
+ // generator and leaves release validation on a converged tree.
42
+ runTaskCommand(root, process.execPath, [".projenrc.ts"]);
38
43
  }
39
44
  logger.success(`workspace version synchronized at ${next.version}`);
40
45
  });
@@ -12,11 +12,18 @@ import type { BunPlugin } from "bun";
12
12
  import stdLibBrowser from "node-stdlib-browser";
13
13
  import ts from "typescript";
14
14
  import { header, makeReadonly, makeWritable } from "../src/generated.ts";
15
- import { publicFunctionExports, publicNamespaceExports } from "../src/module-exports.ts";
15
+ import { publicFunctionExports } from "../src/module-exports.ts";
16
16
  import { resolveRepoRoot } from "../src/packages.ts";
17
17
  import {
18
18
  generatePythonNodeBindings,
19
+ fingerprintPythonNodeBindingOutput,
20
+ isPythonNodeBindingIdentifier,
21
+ pythonNodeBindingFunctionName as pythonFunctionName,
22
+ pythonNodeBindingInputFingerprint,
23
+ pythonNodeBindingOutputs,
24
+ pythonNodeBindingOutputsCurrent,
19
25
  resolvePythonNodeBindingModule,
26
+ resolvePythonNodeBindingModules,
20
27
  resolvePythonNodeBindings,
21
28
  type ResolvedPythonNodeBindings,
22
29
  type ResolvedPythonNodeFunctionOverride,
@@ -35,50 +42,13 @@ function bindingIndex(value: string, count: number, project: string): number {
35
42
  return index;
36
43
  }
37
44
 
38
- const PYTHON_KEYWORDS = new Set([
39
- "and",
40
- "as",
41
- "assert",
42
- "async",
43
- "await",
44
- "break",
45
- "class",
46
- "continue",
47
- "def",
48
- "del",
49
- "elif",
50
- "else",
51
- "except",
52
- "False",
53
- "finally",
54
- "for",
55
- "from",
56
- "global",
57
- "if",
58
- "import",
59
- "in",
60
- "is",
61
- "lambda",
62
- "None",
63
- "nonlocal",
64
- "not",
65
- "or",
66
- "pass",
67
- "raise",
68
- "return",
69
- "True",
70
- "try",
71
- "while",
72
- "with",
73
- "yield",
74
- ]);
75
-
76
45
  export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
77
46
  const { values } = parseArgs({
78
47
  args,
79
48
  options: {
80
49
  binding: { type: "string" },
81
50
  check: { type: "boolean" },
51
+ force: { type: "boolean" },
82
52
  module: { type: "string" },
83
53
  project: { type: "string" },
84
54
  root: { type: "string" },
@@ -89,11 +59,13 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
89
59
  if (values.binding !== undefined || values.module !== undefined) {
90
60
  throw new Error("--binding and --module require --project");
91
61
  }
92
- generatePythonNodeBindings(root, { check: values.check });
62
+ generatePythonNodeBindings(root, { check: values.check, force: values.force });
93
63
  return;
94
64
  }
95
65
 
96
- const configs = resolvePythonNodeBindings(root, values.project).map(discoverBindingModules);
66
+ const configs = resolvePythonNodeBindings(root, values.project).map(
67
+ resolvePythonNodeBindingModules,
68
+ );
97
69
  if (configs.length > 1 && configs.some(({ modules }) => modules.length === 0)) {
98
70
  throw new Error(
99
71
  "Every Node binding must export a namespace with bindable functions or configure modules",
@@ -105,6 +77,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
105
77
  : bindingIndex(values.binding, configs.length, values.project);
106
78
  const config = selectedBinding === undefined ? configs[0] : configs[selectedBinding];
107
79
  if (!config) throw new Error("Expected at least one Node binding configuration");
80
+ const inputFingerprint = pythonNodeBindingInputFingerprint(root, configs);
81
+ if (
82
+ selectedBinding === undefined &&
83
+ !values.force &&
84
+ pythonNodeBindingOutputsCurrent(configs, inputFingerprint)
85
+ ) {
86
+ for (const output of pythonNodeBindingOutputs(configs)) makeReadonly(output);
87
+ if (values.check) {
88
+ logger.info(`verified ${relative(root, dirname(config.runtimeOutput))} from fingerprints`);
89
+ }
90
+ return;
91
+ }
108
92
  if (selectedBinding === undefined && (configs.length > 1 || config.modules.length > 0)) {
109
93
  prepareBindingDirectory(configs);
110
94
  writeRuntimeLoader(configs);
@@ -115,6 +99,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
115
99
  values.project,
116
100
  ...(values.root ? ["--root", values.root] : []),
117
101
  ...(values.check ? ["--check"] : []),
102
+ ...(values.force ? ["--force"] : []),
118
103
  "--binding",
119
104
  String(binding),
120
105
  "--module",
@@ -217,9 +202,9 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
217
202
  const entrypoint = Bun.resolveSync(binding.entrypoint, binding.projectDirectory);
218
203
  return binding.modules.length > 0
219
204
  ? binding.modules.map((module) => ({
220
- key: runtimeModuleName(binding, module),
221
- entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
222
- }))
205
+ key: runtimeModuleName(binding, module),
206
+ entrypoint: resolvePythonNodeBindingModule(entrypoint, module),
207
+ }))
223
208
  : [{ key: runtimeModuleName(binding), entrypoint }];
224
209
  });
225
210
  const runtimeExports = [
@@ -304,18 +289,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
304
289
  }
305
290
  await generate();
306
291
 
307
- function discoverBindingModules(
308
- candidate: ResolvedPythonNodeBindings,
309
- ): ResolvedPythonNodeBindings {
310
- if (candidate.modules.length > 0) return candidate;
311
- const packageEntrypoint = Bun.resolveSync(candidate.entrypoint, candidate.projectDirectory);
312
- const modules = publicNamespaceExports(packageEntrypoint).filter(
313
- (module) =>
314
- publicFunctionExports(resolvePythonNodeBindingModule(packageEntrypoint, module)).length > 0,
315
- );
316
- return { ...candidate, modules };
317
- }
318
-
319
292
  async function generate(): Promise<void> {
320
293
  const result = await Bun.build({
321
294
  entrypoints: [shimEntry],
@@ -367,8 +340,8 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
367
340
  ...bindings.flatMap((binding) => [
368
341
  ...(binding.modules.length > 0
369
342
  ? binding.modules.map((module) =>
370
- join(binding.bindingDirectory, `${pythonFunctionName(module)}.py`),
371
- )
343
+ join(binding.bindingDirectory, `${pythonFunctionName(module)}.py`),
344
+ )
372
345
  : [join(binding.bindingDirectory, "index.py")]),
373
346
  ]),
374
347
  ].filter((file): file is string => Boolean(file)),
@@ -414,16 +387,6 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
414
387
  return grouped;
415
388
  }
416
389
 
417
- function pythonFunctionName(javascriptName: string): string {
418
- const name = stringUtils.toIdentifierWithOptions({ delimiter: "_" }, javascriptName);
419
- if (!/^[_A-Za-z]\w*$/.test(name) || PYTHON_KEYWORDS.has(name)) {
420
- throw new Error(
421
- `JavaScript export ${javascriptName} does not map to a valid Python function name`,
422
- );
423
- }
424
- return name;
425
- }
426
-
427
390
  function runtimeModuleName(binding: ResolvedPythonNodeBindings, module?: string): string {
428
391
  return [binding.bindingName, ...(module ? [pythonFunctionName(module)] : [])].join("__");
429
392
  }
@@ -987,18 +950,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
987
950
  function pythonDataclass(record: PythonRecord): string {
988
951
  const body = record.fields.length
989
952
  ? record.fields
990
- .map((field) => {
991
- const defaultArgument = Object.hasOwn(field, "defaultValue")
992
- ? pythonDefault(field.defaultValue)
993
- : "default=None";
994
- return [
995
- ` ${field.pythonName}: ${pythonOptionalType(field.type)} = field(`,
996
- ` ${defaultArgument},`,
997
- ` metadata={"javascript_name": ${JSON.stringify(field.javascriptName)}},`,
998
- " )",
999
- ].join("\n");
1000
- })
1001
- .join("\n")
953
+ .map((field) => {
954
+ const defaultArgument = Object.hasOwn(field, "defaultValue")
955
+ ? pythonDefault(field.defaultValue)
956
+ : "default=None";
957
+ return [
958
+ ` ${field.pythonName}: ${pythonOptionalType(field.type)} = field(`,
959
+ ` ${defaultArgument},`,
960
+ ` metadata={"javascript_name": ${JSON.stringify(field.javascriptName)}},`,
961
+ " )",
962
+ ].join("\n");
963
+ })
964
+ .join("\n")
1002
965
  : " pass";
1003
966
  return `@dataclass(kw_only=True)\nclass ${record.name}:\n${body}`;
1004
967
  }
@@ -1026,17 +989,17 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1026
989
  }
1027
990
  const body = response.fields.length
1028
991
  ? response.fields
1029
- .map(
1030
- ({ name, required, type }) =>
1031
- ` ${name}: ${required ? type : `NotRequired[${type}]`}`,
1032
- )
1033
- .join("\n")
992
+ .map(
993
+ ({ name, required, type }) =>
994
+ ` ${name}: ${required ? type : `NotRequired[${type}]`}`,
995
+ )
996
+ .join("\n")
1034
997
  : " pass";
1035
998
  return `class ${response.name}(TypedDict):\n${body}`;
1036
999
  }
1037
1000
 
1038
1001
  function isPythonIdentifier(value: string): boolean {
1039
- return /^[_A-Za-z]\w*$/.test(value) && !PYTHON_KEYWORDS.has(value);
1002
+ return isPythonNodeBindingIdentifier(value);
1040
1003
  }
1041
1004
 
1042
1005
  function pythonProtocol(protocol: PythonProtocol): string {
@@ -1045,18 +1008,18 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1045
1008
  );
1046
1009
  const methods = protocol.methods.length
1047
1010
  ? protocol.methods
1048
- .map((method) => {
1049
- const parameters = method.parameters.map(
1050
- ({ name, required, type }) => ` ${name}: ${type}${required ? "" : " = ..."},`,
1051
- );
1052
- return [
1053
- ` async def ${method.name}(`,
1054
- " self,",
1055
- ...parameters,
1056
- ` ) -> ${method.returnType}: ...`,
1057
- ].join("\n");
1058
- })
1059
- .join("\n\n")
1011
+ .map((method) => {
1012
+ const parameters = method.parameters.map(
1013
+ ({ name, required, type }) => ` ${name}: ${type}${required ? "" : " = ..."},`,
1014
+ );
1015
+ return [
1016
+ ` async def ${method.name}(`,
1017
+ " self,",
1018
+ ...parameters,
1019
+ ` ) -> ${method.returnType}: ...`,
1020
+ ].join("\n");
1021
+ })
1022
+ .join("\n\n")
1060
1023
  : "";
1061
1024
  const body = [...properties, methods].filter(Boolean).join("\n\n") || " pass";
1062
1025
  return `class ${protocol.name}(Protocol):\n${body}`;
@@ -1149,7 +1112,8 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1149
1112
 
1150
1113
  function writeGenerated(output: string, contents: string, kind: string): void {
1151
1114
  const destination = relative(root, output);
1152
- if (existsSync(output) && readFileSync(output, "utf8") === contents) {
1115
+ const generated = fingerprintPythonNodeBindingOutput(contents, inputFingerprint, output);
1116
+ if (existsSync(output) && readFileSync(output, "utf8") === generated) {
1153
1117
  if (values.check) logger.info(`verified ${destination}`);
1154
1118
  return;
1155
1119
  }
@@ -1158,7 +1122,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1158
1122
  }
1159
1123
  mkdirSync(dirname(output), { recursive: true });
1160
1124
  makeWritable(output);
1161
- writeFileSync(output, contents);
1125
+ writeFileSync(output, generated);
1162
1126
  makeReadonly(output);
1163
1127
  logger.info(`generated ${destination}`);
1164
1128
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { log } from "@dbx-tools/shared-core";
13
13
 
14
14
  import { captureTaskCommand, tryTaskCommand } from "../src/_task-command.ts";
@@ -20,6 +20,10 @@ const GENIE_PROMPT =
20
20
 
21
21
  /** Inputs for one notes write. */
22
22
  export interface WriteReleaseNotesOptions {
23
+ /** Complete release-note content supplied by the caller instead of Genie. */
24
+ readonly content?: string;
25
+ /** File containing complete release notes supplied by the caller instead of Genie. */
26
+ readonly file?: string;
23
27
  /** Extra agent instructions appended after the standard release-note requirements. */
24
28
  readonly instructions?: string;
25
29
  readonly prefix: string;
@@ -60,6 +64,25 @@ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
60
64
  const destination = releaseNotesPath(root, version);
61
65
  mkdirSync(dirname(destination), { recursive: true });
62
66
  const relative = join("docs", "releases", `v${version}.md`);
67
+ const suppliedContent = options.content?.trim();
68
+ const suppliedFile = options.file?.trim();
69
+ if (suppliedContent && suppliedFile) {
70
+ throw new Error("release notes content and file are mutually exclusive");
71
+ }
72
+ if (options.content !== undefined) {
73
+ if (!suppliedContent) throw new Error("release notes content must not be empty");
74
+ writeFileSync(destination, `${suppliedContent}\n`);
75
+ logger.info("wrote supplied release notes", { path: relative });
76
+ return destination;
77
+ }
78
+ if (options.file !== undefined) {
79
+ if (!suppliedFile) throw new Error("release notes file must not be empty");
80
+ const content = readNotes(resolve(root, suppliedFile));
81
+ if (!content) throw new Error(`release notes file is empty or unreadable: ${suppliedFile}`);
82
+ writeFileSync(destination, `${content}\n`);
83
+ logger.info("copied supplied release notes", { path: relative, source: suppliedFile });
84
+ return destination;
85
+ }
63
86
  const genieArgs = [
64
87
  "exec",
65
88
  "-C",
package/tasks/release.ts CHANGED
@@ -236,6 +236,10 @@ export async function runRelease(
236
236
  readonly releaseNotes?: boolean;
237
237
  /** Additional instructions appended to the standard Genie release-notes prompt. */
238
238
  readonly releaseNotesInstructions?: string;
239
+ /** Complete release notes supplied directly instead of invoking Genie. */
240
+ readonly releaseNotesText?: string;
241
+ /** Path to complete release notes supplied instead of invoking Genie. */
242
+ readonly releaseNotesFile?: string;
239
243
  readonly writeReleaseNotes?: typeof writeReleaseNotes;
240
244
  },
241
245
  ): Promise<string> {
@@ -247,6 +251,21 @@ export async function runRelease(
247
251
  if (options.demoDeploy && !demoProfile) {
248
252
  throw new Error("--demo-deploy requires --profile <name>");
249
253
  }
254
+ if (options.releaseNotesText !== undefined && options.releaseNotesFile !== undefined) {
255
+ throw new Error("--release-notes-text and --release-notes-file are mutually exclusive");
256
+ }
257
+ if (
258
+ options.releaseNotesInstructions !== undefined &&
259
+ (options.releaseNotesText !== undefined || options.releaseNotesFile !== undefined)
260
+ ) {
261
+ throw new Error("--release-notes-instructions cannot be combined with supplied release notes");
262
+ }
263
+ if (
264
+ options.releaseNotes === false &&
265
+ (options.releaseNotesText !== undefined || options.releaseNotesFile !== undefined)
266
+ ) {
267
+ throw new Error("--no-release-notes cannot be combined with supplied release notes");
268
+ }
250
269
 
251
270
  if (install === "always") runTaskCommand(root, "bun", ["install"]);
252
271
  if (selection.validation && hasPackageScript(root, "eslint:fix")) {
@@ -279,6 +298,8 @@ export async function runRelease(
279
298
  prefix,
280
299
  root,
281
300
  version,
301
+ content: options.releaseNotesText,
302
+ file: options.releaseNotesFile,
282
303
  instructions: options.releaseNotesInstructions,
283
304
  });
284
305
  }
@@ -380,9 +401,11 @@ export function createReleaseCommand(): Command {
380
401
  .option("--no-docs", "skip documentation build and deployment")
381
402
  .option(
382
403
  "--no-validation",
383
- "skip optional release validation tasks; version/source checks remain mandatory",
404
+ "skip optional release validation tasks; workspace version checks remain mandatory",
384
405
  )
385
406
  .option("--no-release-notes", "skip writing docs/releases notes (Genie and git-log fallback)")
407
+ .option("--release-notes-text <markdown>", "write supplied markdown without invoking Genie")
408
+ .option("--release-notes-file <path>", "copy supplied markdown without invoking Genie")
386
409
  .option(
387
410
  "--release-notes-instructions <text>",
388
411
  "append custom instructions to the Genie release-notes prompt",
@@ -410,6 +433,8 @@ export function createReleaseCommand(): Command {
410
433
  profile?: string;
411
434
  releaseNotes?: boolean;
412
435
  releaseNotesInstructions?: string;
436
+ releaseNotesText?: string;
437
+ releaseNotesFile?: string;
413
438
  },
414
439
  ) => {
415
440
  await runRelease({
@@ -433,6 +458,8 @@ export function createReleaseCommand(): Command {
433
458
  demoProfile: options.profile,
434
459
  releaseNotes: options.releaseNotes,
435
460
  releaseNotesInstructions: options.releaseNotesInstructions,
461
+ releaseNotesText: options.releaseNotesText,
462
+ releaseNotesFile: options.releaseNotesFile,
436
463
  });
437
464
  },
438
465
  );