@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 +28 -5
- package/index.ts +1 -1
- package/package.json +4 -4
- package/src/_task-command.ts +19 -0
- package/src/python-node-bindings.ts +79 -3
- package/tasks/python-node-bindings-watch.ts +23 -25
- package/tasks/python-node-bindings.ts +4 -18
- package/tasks/release-notes.ts +105 -0
- package/tasks/release.ts +45 -0
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
|
|
209
|
-
|
|
210
|
-
|
|
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.
|
|
489
|
-
|
|
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.
|
|
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.
|
|
34
|
-
"@dbx-tools/path": "^0.9.
|
|
35
|
-
"@dbx-tools/shared-core": "^0.9.
|
|
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.
|
|
57
|
+
"version": "0.9.64",
|
|
58
58
|
"types": "index.ts",
|
|
59
59
|
"type": "module",
|
|
60
60
|
"exports": {
|
package/src/_task-command.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 {
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { log
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
74
|
+
} else {
|
|
75
|
+
for (const project of next.projects) {
|
|
76
|
+
generate(project);
|
|
77
|
+
logger.success(`regenerated ${project} Node bindings`);
|
|
78
|
+
}
|
|
81
79
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
?
|
|
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:
|
|
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(
|
|
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
|
);
|