@dbx-tools/projen 0.6.195 → 0.6.197

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
@@ -128,10 +128,15 @@ in dependency order; adding or
128
128
  removing a crate or `setup_scaffolding!()` marker triggers a full synth. Repos
129
129
  without Rust crates start no Rust watcher. When Rust projects are detected,
130
130
  Cargo is required and the focused task fails immediately if it is unavailable.
131
+ `rs:bindings` is explicit rather than part of root `pre-compile`, so ordinary
132
+ JavaScript PR builds type-check committed generated bindings without performing
133
+ a host Rust build. Release preparation runs Cargo workspace tests and
134
+ regenerates bindings before JavaScript validation.
131
135
 
132
136
  The workspace generates one `release.yml` workflow for every ecosystem. A push
133
- to the configured release branch starts it. The context job reads `VERSION`,
134
- creates the corresponding annotated `v*` tag, and exposes the verified tag,
137
+ to the configured release branch starts it only when `VERSION` changed. The
138
+ context job requires the version to differ from its parent and exceed the latest
139
+ tag, creates the corresponding annotated `v*` tag, and exposes the verified tag,
135
140
  commit, and version to every source job. If the tag already exists, it must be
136
141
  annotated and reference the same commit. A manual run accepts that same tag and
137
142
  commit but requires dry-run mode, so it builds and validates without publishing
@@ -189,7 +194,7 @@ Set `LOCAL_CARGO_REGISTRY` to a named Cargo registry such as a loopback
189
194
  Cargo crates also publish directly to crates.io
190
195
  with `CARGO_REGISTRY_TOKEN`. Override `releaseTargets` only when a consumer has
191
196
  additional native runners; ordinary projects inherit the maintained matrix
192
- automatically. `bun run bump` also accepts repeatable `--os` and `--arch`
197
+ automatically. `bun run release` also accepts repeatable `--os` and `--arch`
193
198
  selectors; every selected operating system is crossed with every selected
194
199
  architecture. Omit both filters to regenerate the complete maintained matrix.
195
200
  `DBX_TOOLS_RELEASE_PLATFORMS` can select the generated matrix without repeating
@@ -383,22 +388,27 @@ Every repo-wide task lives on the root, and the root's `compile` / `test`
383
388
  delegate with `bun run --filter '*'` rather than emitting a step per member - so
384
389
  a new package is covered without a re-synth. Work from the root:
385
390
 
386
- | Task | What it does |
387
- | ----------------- | ----------------------------------------------------- |
388
- | `bun run build` | workspace `compile` + `test`; no root package fan-out |
389
- | `bun run compile` | `tsc --build` in each member, in parallel |
390
- | `bun run test` | `eslint` once, then each member's tests |
391
- | `bun run sync` | re-synth (`--watch` to keep synthing) |
392
- | `bun run barrels` | regenerate the read-only `index.ts` barrels |
393
- | `bun run bump` | version, commit, and push the current branch |
394
-
395
- `bump` also mirrors a release into local registries when the active clients are
396
- pointed at loopback services. npm uses `npm config get registry` and publishes
397
- to a local Verdaccio automatically. Publishable JavaScript members compile once
391
+ | Task | What it does |
392
+ | ----------------------- | ------------------------------------------------------- |
393
+ | `bun run build` | synth + workspace compile and tests |
394
+ | `bun run compile` | `tsc --build` in each member, in parallel |
395
+ | `bun run test` | `eslint` once, then each member's tests |
396
+ | `bun run sync` | re-synth (`--watch` to keep synthing) |
397
+ | `bun run barrels` | regenerate the read-only `index.ts` barrels |
398
+ | `bun run bump` | increment `VERSION` and synchronize generated versions |
399
+ | `bun run version:check` | verify every version surface matches `VERSION` |
400
+ | `bun run release` | run full local preflight and open a reviewed release PR |
401
+
402
+ `release` commits pending work on the current branch, pushes it, creates a
403
+ dedicated `release/v<version>` branch, invokes the pure `bump` task, validates
404
+ the complete workspace, runs local publication, and opens a PR into the
405
+ configured release branch. `--message` sets the source commit message. The task
406
+ never merges the PR unless `--approve` is passed; that option requests an
407
+ immediate admin merge so the main release can start without waiting for PR
408
+ checks. npm uses `npm config get registry` and publishes to a local Verdaccio
409
+ automatically. Publishable JavaScript members compile once
398
410
  from the root in parallel, then upload through a bounded pool without rerunning
399
- their `prepack` tasks. When synth already made the manifests and Bun workspace
400
- lock release-current, publishing also skips redundant version stamping and the
401
- lockfile reinstall. Python prefers uv's default index and only
411
+ their `prepack` tasks. Python prefers uv's default index and only
402
412
  treats a loopback `.../+simple/` URL as writable devpi; a read-only cache such as
403
413
  proxpi (`.../index/`) is deliberately ignored. The task stamps every Python
404
414
  member and its sibling dependencies to the release version, builds the workspace
@@ -410,14 +420,18 @@ Use `--local-registry false` or `--local-pypi false` to disable either local
410
420
  publish. An explicit `--local-pypi http://localhost:3141/user/index/` overrides
411
421
  auto-detection; `--python-root` defaults to `packages/py`.
412
422
 
413
- The configured release branch is the publication trigger. Its workflow creates
414
- the annotated `v*` public release boundary from `VERSION`. Available workflow
423
+ The GitHub PR workflow runs synth plus the workspace TypeScript compile rather
424
+ than the complete root build. Release preparation has already run Rust tests,
425
+ binding generation, workspace tests, and local package preflight before opening
426
+ the PR.
427
+
428
+ The configured release branch publishes only when a reviewed PR changes
429
+ `VERSION`. Its workflow creates the annotated `v*` public release boundary.
430
+ Available workflow
415
431
  stages form the generated chain Rust -> Python -> Node -> docs: Rust builds and
416
432
  publishes native artifacts and Cargo crates, Python publishes standard
417
433
  distributions, Node publishes standard workspace packages, and docs deploys
418
- after publication. A stage with no corresponding outputs is omitted. Keep
419
- ongoing development on another branch when every release-branch update should
420
- publish.
434
+ after publication. A stage with no corresponding outputs is omitted.
421
435
 
422
436
  Members intentionally keep only the tasks that something OTHER than a human
423
437
  invokes, so there is no second place to run the same thing:
@@ -429,8 +443,8 @@ invokes, so there is no second place to run the same thing:
429
443
  - `watch` - a single-package `tsc --build -w`, for narrowing a long
430
444
  edit/compile loop to one package.
431
445
  - `build` / `package` - a complete compile/test/pack lifecycle when invoked in
432
- one package. Root bump deliberately bypasses these and uses filtered compile
433
- plus concurrent `bun publish --ignore-scripts`. The package phase also packs
446
+ one package. Release preparation validates through the root's filtered tasks,
447
+ and publication uses concurrent `bun publish --ignore-scripts`. The package phase also packs
434
448
  with `--ignore-scripts` because its build already compiled; `prepack` remains
435
449
  available for a standalone publish that did not run `build` first.
436
450
  - `install` / `install:ci` / `default` / `pre-compile` / `post-compile` -
@@ -452,9 +466,9 @@ example apps - so the packages, the engine, and the examples always match.
452
466
  `src/workspace-version.ts` owns reading and writing it. Synth only ever reads it;
453
467
  it never resets, upgrades, or downgrades a version on its own.
454
468
 
455
- `bun run bump` is the only command that changes the number: it fetches the remote
456
- tags once, takes the highest tag across `v*` and every sibling prefix as the base
457
- (falling back to the local `VERSION` file when the remote is unreachable or has no
458
- tag), increments by `--level`, writes `VERSION`, then synths so every manifest
459
- copies it. The remote is consulted only on `bump` and on one-time creation of a
460
- missing `VERSION` file - never on an ordinary synth.
469
+ `bun run bump` is the only command that changes the number: it fetches remote
470
+ tags once, takes the highest `v*` tag as the base, increments by `--level`, writes
471
+ `VERSION`, then synths so every manifest copies it. It has no git or publication
472
+ side effects. `bun run release` invokes it while preparing the reviewed release
473
+ PR. The remote is consulted only on `bump` and on one-time creation of a missing
474
+ `VERSION` file, never on an ordinary synth.
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.6.194";
6
+ export const PACKAGE_VERSION = "0.6.197";
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";
@@ -64,4 +64,4 @@ export type { TypeScriptBindingModule } from "./src/uniffi.ts";
64
64
  export { DBXToolsVsCode } from "./src/vscode.ts";
65
65
  export type { IgnoreGroupOptions } from "./src/watch.ts";
66
66
  export { VERSION_FILE, DEFAULT_VERSION } from "./src/workspace-version.ts";
67
- export type { Semver } from "./src/workspace-version.ts";
67
+ export type { Semver, VersionLevel } from "./src/workspace-version.ts";
package/package.json CHANGED
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.7.0",
28
- "@dbx-tools/core": "0.6.195",
29
- "@dbx-tools/path": "0.6.195",
30
- "@dbx-tools/shared-core": "0.6.195",
28
+ "@dbx-tools/core": "0.6.197",
29
+ "@dbx-tools/path": "0.6.197",
30
+ "@dbx-tools/shared-core": "0.6.197",
31
31
  "commander": "^15.0.0",
32
32
  "concurrently": "^10.0.3",
33
33
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.195",
50
+ "version": "0.6.197",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import { relative } from "node:path";
42
42
  import { javascript, type Project } from "projen";
43
+ import { BUN_VERSION } from "./bun-workflow.ts";
43
44
  import { toPosix } from "./packages.ts";
44
45
 
45
46
  /**
@@ -63,8 +64,8 @@ export type AllowBuilds = Record<string, boolean>;
63
64
  * always available at `catalog:` without a per-repo override.
64
65
  */
65
66
  const DEFAULT_CATALOG: Catalog = {
66
- react: "^19.2.4",
67
- "react-dom": "^19.2.4",
67
+ react: "~19.2.4",
68
+ "react-dom": "~19.2.4",
68
69
  "@types/react": "^19.2.2",
69
70
  "@types/react-dom": "^19.2.2",
70
71
  // Tailwind v4 compiler for bun's dev server + `Bun.build` (the `app` tag).
@@ -117,6 +118,7 @@ const DEFAULT_ALLOW_BUILDS: AllowBuilds = {
117
118
  };
118
119
 
119
120
  const DEFAULT_OVERRIDES: Readonly<Record<string, string>> = {
121
+ bun: BUN_VERSION,
120
122
  glob: "^13.0.0",
121
123
  };
122
124
 
package/src/project-js.ts CHANGED
@@ -44,7 +44,7 @@ import { DBXToolsRelease, type ReleaseDocsOptions } from "./release.ts";
44
44
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
45
45
  import { DBXToolsRootTsconfig } from "./tsconfig.ts";
46
46
  import { DBXToolsVsCode } from "./vscode.ts";
47
- import { readWorkspaceVersion } from "./workspace-version.ts";
47
+ import { readWorkspaceVersion, syncWorkspaceManifestVersion } from "./workspace-version.ts";
48
48
 
49
49
  /**
50
50
  * The dbx-tools project surface, backed by projen's Node toolchain. A single
@@ -415,10 +415,10 @@ function defaultProjectOptions(
415
415
  // would omit the key - except that `npmProvenance` then defaults on and forces
416
416
  // the block to render, giving the root a `publishConfig` it does not have
417
417
  // today. Provenance is never written to a manifest here (projen only reads it
418
- // in its own `Publisher`, and `release: false` means none exists); the
419
- // The release workflow opts in per run through `npm_config_provenance`.
420
- // instead, so LOCAL publishes to a verdaccio still work with no CI OIDC
421
- // provider. See {@link DBXToolsRelease}.
418
+ // in its own `Publisher`, and `release: false` means none exists). The release
419
+ // workflow opts in per run through `npm_config_provenance`, so local publishes
420
+ // to Verdaccio still work without a CI OIDC provider. See
421
+ // {@link DBXToolsRelease}.
422
422
  ...(isRoot ? {} : { npmAccess: javascript.NpmAccess.PUBLIC }),
423
423
  buildWorkflow: false,
424
424
  workflowPackageCache: false,
@@ -426,8 +426,7 @@ function defaultProjectOptions(
426
426
  // The root build validates the whole workspace and must not also pack every
427
427
  // member into unused `dist/js` tarballs. Child projects keep projen's package
428
428
  // task so `bun run build` in ONE package remains a complete compile/test/pack
429
- // operation. The root bump does not invoke child builds: its publish driver
430
- // compiles once with filtered root tasks and packs via
429
+ // operation. Release publication compiles once with filtered root tasks and packs via
431
430
  // `bun publish --ignore-scripts` (see {@link applyCompiledPublish}).
432
431
  ...(isRoot ? { package: false } : {}),
433
432
  jest: false,
@@ -450,7 +449,9 @@ function defaultProjectOptions(
450
449
  prettierOptions: {
451
450
  settings: PRETTIER_SETTINGS,
452
451
  ignoreFile: true,
453
- ignoreFileOptions: { ignorePatterns: [...ignore.ignorePatterns({ test: false })] },
452
+ ignoreFileOptions: {
453
+ ignorePatterns: [...ignore.ignorePatterns({ test: false, lock: true })],
454
+ },
454
455
  },
455
456
  }
456
457
  : {}),
@@ -656,6 +657,10 @@ export class DBXToolsNodeProject
656
657
  public override preSynthesize(): void {
657
658
  if (this.rootInstallOnly) this.with(ROOT_INSTALL_ONLY_MIXIN);
658
659
  super.preSynthesize();
660
+ const version = readWorkspaceVersion(this.outdir);
661
+ for (const member of this.extraWorkspaceMembers) {
662
+ syncWorkspaceManifestVersion(join(this.outdir, member, "package.json"), version);
663
+ }
659
664
  // Members come from the attached subprojects, which the root's scan appends
660
665
  // after construction - so the list is filled here, not in the constructor.
661
666
  // `extraWorkspaceMembers` adds self-synthesizing siblings (e.g. `projen/`).
@@ -834,6 +839,29 @@ class WorkspaceValidationTasks extends Component {
834
839
  */
835
840
  class WorkflowDefaults extends Component {
836
841
  public override preSynthesize(): void {
842
+ const project = this.project as javascript.NodeProject;
843
+ const workflow = project.buildWorkflow?.workflow;
844
+ if (workflow) {
845
+ const job = workflow.getJob("build");
846
+ if ("steps" in job) {
847
+ const mutableJob = job as unknown as { steps: JobStep[] | (() => JobStep[]) };
848
+ const configuredSteps = mutableJob.steps;
849
+ const rewrite = (steps: JobStep[]): JobStep[] =>
850
+ steps.map((step) =>
851
+ step.name === "build"
852
+ ? {
853
+ ...step,
854
+ name: "Validate generated files and types",
855
+ run: "bunx projen default\nbun run compile",
856
+ }
857
+ : step,
858
+ );
859
+ mutableJob.steps =
860
+ typeof configuredSteps === "function"
861
+ ? () => rewrite(configuredSteps())
862
+ : rewrite(configuredSteps);
863
+ }
864
+ }
837
865
  const build = this.project.tryFindObjectFile(".github/workflows/build.yml");
838
866
  for (const job of ["build", "self-mutation"]) {
839
867
  build?.addOverride(`jobs.${job}.timeout-minutes`, 30);
@@ -890,6 +918,7 @@ class PrettierIgnoreGenerated extends Component {
890
918
  public override preSynthesize(): void {
891
919
  const prettier = javascript.Prettier.of(this.project);
892
920
  if (!prettier) return;
921
+ prettier.addIgnorePattern("**/src/generated/**");
893
922
  const rootAbs = resolve(this.project.outdir);
894
923
  for (const sub of this.project.subprojects) {
895
924
  if (!(sub instanceof javascript.NodeProject)) continue;
@@ -1115,7 +1144,9 @@ function initProject(
1115
1144
  // to re-include a file whose parent directory is excluded - so every per-file
1116
1145
  // `!/.github/...` negation projen emits for its own generated files silently
1117
1146
  // does nothing, and the file cannot be added at all.
1118
- project.gitignore.addPatterns(...[...ignore.ignorePatterns({ test: false, dot: false })]);
1147
+ project.gitignore.addPatterns(
1148
+ ...[...ignore.ignorePatterns({ test: false, dot: false, lock: true })],
1149
+ );
1119
1150
  // What the dot group was actually earning here, named explicitly: secrets and
1120
1151
  // local editor state. Both ignore CONTENTS (`.idea/*`) rather than the
1121
1152
  // directory, so a later `!` negation can still reach a file inside.
@@ -1156,6 +1187,7 @@ function initProject(
1156
1187
  eslint.addIgnorePattern(`${root}/openapi/**`);
1157
1188
  eslint.addIgnorePattern(`${root}/**/index.ts`);
1158
1189
  }
1190
+ eslint.addIgnorePattern("**/src/generated/**");
1159
1191
  eslint.addIgnorePattern("projen/index.ts");
1160
1192
  // The generated bun app scripts + unmanaged overrides live at the package root,
1161
1193
  // outside any `src/**` tsconfig include, so the type-aware parser cannot resolve
@@ -1253,9 +1285,8 @@ function initProject(
1253
1285
  new PrettierIgnoreGenerated(project);
1254
1286
 
1255
1287
  new GeneratedSource(project);
1256
- // The `bump` task (compute next version + commit + tag + push) is useful on
1257
- // any root; the actual publish is a tag-triggered GitHub workflow the caller
1258
- // authors. Independent of projen's own `release` component.
1288
+ // Version mutation, reviewed release preparation, and public publication are
1289
+ // separate surfaces owned by one release component.
1259
1290
  new DBXToolsRelease(project as DBXToolsNodeProject, {
1260
1291
  tagPrefix: options.releaseTagPrefix,
1261
1292
  nodeRelease: options.nodeRelease,
package/src/project-rs.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { project as coreProject } from "@dbx-tools/core";
5
+ import { exec, project as coreProject } from "@dbx-tools/core";
6
6
  import { string } from "@dbx-tools/shared-core";
7
- import { Project, TextFile, javascript } from "projen";
7
+ import { Component, Project, TextFile, javascript } from "projen";
8
8
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
9
9
  import { BUN_VERSION } from "./bun-workflow.ts";
10
10
  import { DBXToolsTypeScriptProject, projectRepositoryUrl } from "./project-js.ts";
@@ -337,6 +337,19 @@ if (process.env.GITHUB_OUTPUT) {
337
337
  `;
338
338
  }
339
339
 
340
+ /** Keep tracked workspace package versions in Cargo.lock aligned with VERSION. */
341
+ class RustWorkspaceVersionLock extends Component {
342
+ public override postSynthesize(): void {
343
+ exec.spawnSync("cargo", ["metadata", "--format-version", "1", "--no-deps"], {
344
+ cwd: this.project.outdir,
345
+ stdout: "ignore",
346
+ stderr: "inherit",
347
+ stdin: "ignore",
348
+ check: true,
349
+ });
350
+ }
351
+ }
352
+
340
353
  /** Persisted mapping consumed by the focused Rust source watcher. */
341
354
  export interface RustBindingMapping {
342
355
  readonly crate: string;
@@ -893,17 +906,15 @@ export class DBXToolsRustWorkspace {
893
906
  .trimEnd()
894
907
  .split("\n"),
895
908
  });
909
+ new RustWorkspaceVersionLock(project);
896
910
  project.addTask("rs:format", { exec: "cargo fmt --all" });
897
911
  project.addTask("rs:lint", { exec: "cargo clippy --workspace --all-targets --all-features" });
898
912
  project.addTask("rs:test", { exec: "cargo test --workspace" });
899
913
  project.addTask("rs:build", { exec: "cargo build --workspace" });
900
- const bindingsTask = project.addTask("rs:bindings", {
914
+ project.addTask("rs:bindings", {
901
915
  exec: "bun node_modules/@dbx-tools/projen/tasks/rust.ts",
902
916
  description: "Generate language bindings for UniFFI-enabled Rust crates",
903
917
  });
904
- if (this.bindingMappings.some((binding) => binding.node)) {
905
- project.tasks.tryFind("pre-compile")?.spawn(bindingsTask);
906
- }
907
918
  project.removeTask("rs:bindings:demo");
908
919
  if (releaseEnabled) {
909
920
  new TextFile(project, ".projen/cargo-cache-key.mjs", {
package/src/release.ts CHANGED
@@ -136,7 +136,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
136
136
  uses: "actions/checkout@v6",
137
137
  with: {
138
138
  ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
139
- "fetch-depth": 1,
139
+ "fetch-depth": 2,
140
140
  },
141
141
  },
142
142
  {
@@ -158,12 +158,26 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
158
158
  ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
159
  " RELEASE_VERSION=\"$(tr -d '\\r\\n' < VERSION)\"",
160
160
  ' [[ "$RELEASE_VERSION" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]',
161
+ ' PREVIOUS_VERSION="$(git show "$RELEASE_SHA^:VERSION" | tr -d \'\\r\\n\')"',
162
+ ' test "$PREVIOUS_VERSION" != "$RELEASE_VERSION"',
161
163
  ` RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"`,
162
164
  ' if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then',
163
165
  ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
164
166
  ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
165
167
  ' test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"',
166
168
  " else",
169
+ " git fetch --force --tags origin",
170
+ ' LATEST_VERSION=""',
171
+ ` for TAG in $(git tag --sort=-version:refname --list '${tagPrefix}*'); do`,
172
+ ` CANDIDATE="\${TAG#${tagPrefix}}"`,
173
+ ' if [[ "$CANDIDATE" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then',
174
+ ' LATEST_VERSION="$CANDIDATE"',
175
+ " break",
176
+ " fi",
177
+ " done",
178
+ ' if [ -n "$LATEST_VERSION" ]; then',
179
+ ' node -e \'const a=process.argv[1].split(".").map(Number); const b=process.argv[2].split(".").map(Number); if (!(b[0]>a[0] || b[0]===a[0] && (b[1]>a[1] || b[1]===a[1] && b[2]>a[2]))) process.exit(1)\' "$LATEST_VERSION" "$RELEASE_VERSION"',
180
+ " fi",
167
181
  ' git config user.name "github-actions[bot]"',
168
182
  ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
169
183
  ' git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"',
@@ -307,7 +321,7 @@ function addDocsJobs(
307
321
  });
308
322
  }
309
323
 
310
- /** Owns the single release workflow and the local bump task. */
324
+ /** Owns the single release workflow and local release preparation tasks. */
311
325
  export class DBXToolsRelease extends Component {
312
326
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
313
327
  super(project);
@@ -319,8 +333,25 @@ export class DBXToolsRelease extends Component {
319
333
  bump: {
320
334
  exec: taskScript(project, "bump.ts", `--prefix ${tagPrefix}`),
321
335
  receiveArgs: true,
322
- description: "Bump the release version (default patch), then commit and push it",
336
+ description: "Increment VERSION and synchronize generated workspace versions",
323
337
  },
338
+ "version:check": {
339
+ exec: taskScript(project, "version-check.ts"),
340
+ description: "Verify every package and generated barrel matches VERSION",
341
+ },
342
+ ...(project.github
343
+ ? {
344
+ release: {
345
+ exec: taskScript(
346
+ project,
347
+ "release-pr.ts",
348
+ `--prefix ${tagPrefix} --base ${releaseBranch}`,
349
+ ),
350
+ receiveArgs: true,
351
+ description: "Prepare, validate, locally publish, and open a reviewed release PR",
352
+ },
353
+ }
354
+ : {}),
324
355
  });
325
356
  if (!project.github) return;
326
357
 
@@ -332,7 +363,7 @@ export class DBXToolsRelease extends Component {
332
363
  workflow.runName =
333
364
  "release ${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
334
365
  workflow.on({
335
- push: { branches: [releaseBranch] },
366
+ push: { branches: [releaseBranch], paths: ["VERSION"] },
336
367
  workflowDispatch: {
337
368
  inputs: {
338
369
  release_tag: {
@@ -8,14 +8,14 @@
8
8
  * DEFAULT_VERSION} when it is absent on a fresh tree); it never rewrites it, so an
9
9
  * ordinary `bunx projen` cannot move a package version up or down.
10
10
  *
11
- * Only two callers change the number: `bump` (which increments it) and the
12
- * one-time bootstrap of a workspace that has no `VERSION` yet. Both resolve the
13
- * base from the remote git tags first ({@link resolveRemoteVersion}) so a release
14
- * cut elsewhere is respected, and fall back to the local file (or {@link
15
- * DEFAULT_VERSION}) when the remote is unreachable or has no tags. The remote is
16
- * consulted ONLY on those two paths, never on every synth/compile/commit.
11
+ * Only two callers change the number: the pure `bump` task used while preparing
12
+ * a reviewed release PR, and the one-time bootstrap of a workspace that has no
13
+ * `VERSION` yet. Both resolve the base from remote git tags first ({@link
14
+ * resolveRemoteVersion}) so a release cut elsewhere is respected, and fall back
15
+ * to the local file (or {@link DEFAULT_VERSION}) when the remote is unreachable
16
+ * or has no tags. The remote is consulted only on those paths.
17
17
  */
18
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { exec } from "@dbx-tools/core";
21
21
 
@@ -30,6 +30,9 @@ const SEMVER = /^\d+\.\d+\.\d+$/;
30
30
  /** A parsed `[major, minor, patch]` tuple. */
31
31
  export type Semver = [number, number, number];
32
32
 
33
+ /** Supported semantic release increments. */
34
+ export type VersionLevel = "patch" | "minor" | "major";
35
+
33
36
  /** Parse `x.y.z` (ignoring any leading `v`/prefix), or `undefined` when it does not match. */
34
37
  export function parseSemver(raw: string): Semver | undefined {
35
38
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
@@ -41,6 +44,13 @@ export function compareSemver(a: Semver, b: Semver): number {
41
44
  return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
42
45
  }
43
46
 
47
+ /** Increment a semantic version tuple by the requested release level. */
48
+ export function incrementSemver(version: Semver, level: VersionLevel): Semver {
49
+ if (level === "major") return [version[0] + 1, 0, 0];
50
+ if (level === "minor") return [version[0], version[1] + 1, 0];
51
+ return [version[0], version[1], version[2] + 1];
52
+ }
53
+
44
54
  /** Absolute path to the `VERSION` file for a workspace root. */
45
55
  export function versionPath(root: string): string {
46
56
  return join(root, VERSION_FILE);
@@ -70,6 +80,30 @@ export function writeWorkspaceVersion(root: string, version: string): void {
70
80
  writeFileSync(versionPath(root), `${version}\n`);
71
81
  }
72
82
 
83
+ /**
84
+ * Synchronize one existing workspace manifest with the authoritative version.
85
+ *
86
+ * This covers workspace members that synthesize themselves and therefore are
87
+ * not child projects of the root. Missing manifests are ignored so a freshly
88
+ * bootstrapped extra member can create its package metadata independently.
89
+ */
90
+ export function syncWorkspaceManifestVersion(manifestPath: string, version: string): boolean {
91
+ if (!existsSync(manifestPath)) return false;
92
+ const content = readFileSync(manifestPath, "utf8");
93
+ const manifest = JSON.parse(content) as Record<string, unknown>;
94
+ if (manifest.version === version) return false;
95
+
96
+ manifest.version = version;
97
+ const { mode } = statSync(manifestPath);
98
+ chmodSync(manifestPath, mode | 0o200);
99
+ try {
100
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
101
+ } finally {
102
+ chmodSync(manifestPath, mode);
103
+ }
104
+ return true;
105
+ }
106
+
73
107
  /** Run git in `cwd`, capturing stdout and swallowing failure (offline, no repo). */
74
108
  function gitCapture(cwd: string, args: string[]): string {
75
109
  try {
@@ -138,6 +172,22 @@ export function resolveBaseVersion(
138
172
  return { version: readWorkspaceVersion(root), source: "local" };
139
173
  }
140
174
 
175
+ /** Resolve the next release version without mutating the workspace. */
176
+ export function resolveNextVersion(
177
+ root: string,
178
+ prefixes: readonly string[],
179
+ level: VersionLevel,
180
+ options: { fetch?: boolean } = {},
181
+ ): { base: string; version: string; source: "remote" | "local" } {
182
+ const base = resolveBaseVersion(root, prefixes, options);
183
+ const parsed = parseSemver(base.version) ?? [0, 0, 1];
184
+ return {
185
+ base: parsed.join("."),
186
+ version: incrementSemver(parsed, level).join("."),
187
+ source: base.source,
188
+ };
189
+ }
190
+
141
191
  /**
142
192
  * Create the `VERSION` file when it does not yet exist, seeding it from the remote
143
193
  * tags (or {@link DEFAULT_VERSION} when the remote is unreachable / has no tag). An