@dbx-tools/projen 0.6.194 → 0.6.196

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/src/project-rs.ts CHANGED
@@ -2,10 +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";
8
- import { GithubWorkflow } from "projen/lib/github";
7
+ import { Component, Project, TextFile, javascript } from "projen";
9
8
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
10
9
  import { BUN_VERSION } from "./bun-workflow.ts";
11
10
  import { DBXToolsTypeScriptProject, projectRepositoryUrl } from "./project-js.ts";
@@ -84,7 +83,7 @@ export interface DBXToolsRustWorkspaceOptions {
84
83
  readonly pythonRoot?: string;
85
84
  readonly pythonModulePrefix?: string;
86
85
  readonly private?: boolean;
87
- /** Generate tag-driven cross-platform UniFFI package releases. */
86
+ /** Generate release-branch-driven cross-platform UniFFI package releases. */
88
87
  readonly release?: boolean;
89
88
  /** Native release targets; defaults to the maintained GitHub-hosted matrix. */
90
89
  readonly releaseTargets?: readonly UniFFIReleaseTarget[];
@@ -174,30 +173,35 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
174
173
  },
175
174
  ] as const;
176
175
 
176
+ const UBRN_VERSION = "0.31.0-5";
177
+ const RELEASE_PLATFORMS_ENV = "DBX_TOOLS_RELEASE_PLATFORMS";
177
178
  const RUST_CACHE_ENV = {
178
179
  CARGO_INCREMENTAL: "0",
179
180
  CARGO_TERM_COLOR: "always",
180
181
  } as const;
181
- const UBRN_VERSION = "0.31.0-5";
182
- const RELEASE_PLATFORMS_ENV = "DBX_TOOLS_RELEASE_PLATFORMS";
183
182
 
184
- function rustCacheSteps(
185
- sharedKey: string,
186
- idPrefix = "",
187
- saveIf = true,
188
- ): readonly Record<string, unknown>[] {
183
+ function cargoCacheKeyStep(): Record<string, unknown> {
184
+ return {
185
+ name: "Resolve Cargo dependency cache key",
186
+ id: "cargo_cache_key",
187
+ run: "node .projen/cargo-cache-key.mjs",
188
+ };
189
+ }
190
+
191
+ function rustCacheSteps(sharedKey: string): readonly Record<string, unknown>[] {
189
192
  return [
190
193
  {
191
- name: "Cache Cargo registry and targets",
192
- id: `${idPrefix}cargo_cache`,
194
+ name: "Cache Cargo registry and dependencies",
195
+ id: "cargo_cache",
193
196
  uses: "Swatinem/rust-cache@v2.9.2",
194
197
  with: {
195
198
  "cache-targets": true,
196
199
  "cache-workspace-crates": false,
197
200
  "add-job-id-key": false,
198
- "add-rust-environment-hash-key": true,
201
+ "add-rust-environment-hash-key": false,
202
+ key: "${{ steps.cargo_cache_key.outputs.key }}",
199
203
  "shared-key": sharedKey,
200
- "save-if": saveIf,
204
+ "save-if": "${{ github.event_name == 'push' }}",
201
205
  },
202
206
  },
203
207
  ];
@@ -273,6 +277,79 @@ function uniffiReleaseTaskSource(): string {
273
277
  return readFileSync(source, "utf8");
274
278
  }
275
279
 
280
+ function cargoCacheKeySource(): string {
281
+ return `#!/usr/bin/env node
282
+ import { createHash } from "node:crypto";
283
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
284
+ import { spawnSync } from "node:child_process";
285
+
286
+ function command(command, args) {
287
+ const result = spawnSync(command, args, { encoding: "utf8" });
288
+ if (result.error) throw result.error;
289
+ if (result.status !== 0) throw new Error(\`\${command} exited with \${result.status}\`);
290
+ return result.stdout;
291
+ }
292
+
293
+ const hasLock = existsSync("Cargo.lock");
294
+ const metadata = JSON.parse(
295
+ command("cargo", [
296
+ "metadata",
297
+ ...(hasLock ? ["--locked"] : []),
298
+ "--format-version",
299
+ "1",
300
+ "--no-deps",
301
+ ]),
302
+ );
303
+ const workspaceNames = new Set(metadata.packages.map((pkg) => pkg.name));
304
+ const dependencyLock = hasLock
305
+ ? readFileSync("Cargo.lock", "utf8")
306
+ .split("[[package]]")
307
+ .slice(1)
308
+ .filter((block) => {
309
+ const name = /^\\s*name = "([^"]+)"/m.exec(block)?.[1];
310
+ return name && !workspaceNames.has(name);
311
+ })
312
+ .map((block) => \`[[package]]\${block}\`)
313
+ .join("")
314
+ : "";
315
+ const manifests = [
316
+ metadata.workspace_root + "/Cargo.toml",
317
+ ...metadata.packages.map((pkg) => pkg.manifest_path),
318
+ ]
319
+ .map((path) =>
320
+ readFileSync(path, "utf8").replace(/^version = "[0-9]+\\.[0-9]+\\.[0-9]+"\\s*$/gm, ""),
321
+ )
322
+ .join("\\n");
323
+ const config = existsSync(".cargo/config.toml")
324
+ ? readFileSync(".cargo/config.toml", "utf8")
325
+ : "";
326
+ const key = createHash("sha256")
327
+ .update(dependencyLock)
328
+ .update(manifests)
329
+ .update(config)
330
+ .update(command("rustc", ["-vV"]))
331
+ .digest("hex");
332
+ if (process.env.GITHUB_OUTPUT) {
333
+ appendFileSync(process.env.GITHUB_OUTPUT, \`key=\${key}\\n\`);
334
+ } else {
335
+ process.stdout.write(\`\${key}\\n\`);
336
+ }
337
+ `;
338
+ }
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
+
276
353
  /** Persisted mapping consumed by the focused Rust source watcher. */
277
354
  export interface RustBindingMapping {
278
355
  readonly crate: string;
@@ -829,125 +906,24 @@ export class DBXToolsRustWorkspace {
829
906
  .trimEnd()
830
907
  .split("\n"),
831
908
  });
909
+ new RustWorkspaceVersionLock(project);
832
910
  project.addTask("rs:format", { exec: "cargo fmt --all" });
833
911
  project.addTask("rs:lint", { exec: "cargo clippy --workspace --all-targets --all-features" });
834
912
  project.addTask("rs:test", { exec: "cargo test --workspace" });
835
913
  project.addTask("rs:build", { exec: "cargo build --workspace" });
836
- const bindingsTask = project.addTask("rs:bindings", {
914
+ project.addTask("rs:bindings", {
837
915
  exec: "bun node_modules/@dbx-tools/projen/tasks/rust.ts",
838
916
  description: "Generate language bindings for UniFFI-enabled Rust crates",
839
917
  });
840
- if (this.bindingMappings.some((binding) => binding.node)) {
841
- project.tasks.tryFind("pre-compile")?.spawn(bindingsTask);
842
- }
843
918
  project.removeTask("rs:bindings:demo");
844
919
  if (releaseEnabled) {
845
- this.addCacheWorkflow(project, options, nativeTargets);
920
+ new TextFile(project, ".projen/cargo-cache-key.mjs", {
921
+ lines: cargoCacheKeySource().trimEnd().split("\n"),
922
+ });
846
923
  this.addReleaseWorkflow(project, options, nativeTargets);
847
924
  }
848
925
  }
849
926
 
850
- private addCacheWorkflow(
851
- project: javascript.NodeProject,
852
- options: DBXToolsRustWorkspaceOptions,
853
- targets: readonly UniFFIReleaseTarget[],
854
- ): void {
855
- if (!project.github || !isDBXToolsJavaScriptProject()(project) || targets.length === 0) return;
856
- const releaseRustVersion = options.releaseRustVersion ?? "stable";
857
- const usePreinstalledWindowsRust = releaseRustVersion === "stable";
858
- const usesCargoLock = existsSync(join(project.outdir, "Cargo.lock"));
859
- const hasReleaseExclusions = this.packages.some(
860
- (pkg) => pkg.packageOptions.releaseExcludeOs?.length,
861
- );
862
- const matrix = targets.map((target) => ({
863
- ...target,
864
- ...(hasReleaseExclusions
865
- ? {
866
- cargoExcludes: this.packages
867
- .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
868
- .map((pkg) => `--exclude ${pkg.crateName}`)
869
- .join(" "),
870
- }
871
- : {}),
872
- }));
873
- const workflow = new GithubWorkflow(project.github, "rust-cache", {
874
- fileName: "rust-cache.yml",
875
- limitConcurrency: true,
876
- concurrencyOptions: { group: "rust-cache", cancelInProgress: true },
877
- });
878
- workflow.on({
879
- push: {
880
- branches: ["main"],
881
- paths: [
882
- ".cargo/**",
883
- ".github/workflows/rust-cache.yml",
884
- "Cargo.lock",
885
- "Cargo.toml",
886
- "packages/rs/**",
887
- ],
888
- },
889
- workflowDispatch: {},
890
- });
891
- workflow.addJob("prime", {
892
- name: "${{ matrix.node }}",
893
- runsOn: ["${{ matrix.runner }}"],
894
- permissions: { contents: JobPermission.READ },
895
- env: RUST_CACHE_ENV,
896
- strategy: {
897
- failFast: false,
898
- matrix: { include: matrix },
899
- },
900
- steps: [
901
- { name: "Checkout", uses: "actions/checkout@v6" },
902
- {
903
- name: "Setup Rust",
904
- ...(usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
905
- uses: `dtolnay/rust-toolchain@${releaseRustVersion}`,
906
- with: { targets: "${{ matrix.cargo }}" },
907
- },
908
- ...(usePreinstalledWindowsRust
909
- ? [
910
- {
911
- name: "Verify preinstalled Windows Rust",
912
- if: "${{ matrix.os == 'win32' }}",
913
- shell: "bash",
914
- run: [
915
- "rustc --version --verbose",
916
- "cargo --version",
917
- 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
918
- 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
919
- ].join("\n"),
920
- },
921
- ]
922
- : []),
923
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`),
924
- {
925
- name: "Install Linux native dependencies",
926
- if: "${{ matrix.os == 'linux' }}",
927
- run: [
928
- "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
929
- "sudo apt-get update",
930
- "sudo apt-get install --yes libdbus-1-dev pkg-config",
931
- ].join("\n"),
932
- },
933
- {
934
- name: "Prime Cargo target cache",
935
- shell: "bash",
936
- env: {
937
- CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
938
- "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
939
- },
940
- run: timedBash(
941
- "rust_cache",
942
- `cargo build --release --workspace${usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
943
- hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
944
- }`,
945
- ),
946
- },
947
- ],
948
- });
949
- }
950
-
951
927
  private addReleaseWorkflow(
952
928
  project: javascript.NodeProject,
953
929
  options: DBXToolsRustWorkspaceOptions,
@@ -1142,15 +1118,8 @@ export class DBXToolsRustWorkspace {
1142
1118
  },
1143
1119
  ]
1144
1120
  : []),
1145
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`, "", false),
1146
- {
1147
- name: "Log cache configuration",
1148
- shell: "bash",
1149
- run: [
1150
- `echo "cargo_cache_namespace=release-\${{ matrix.cargo }}-rust-${releaseRustVersion}"`,
1151
- 'echo "cargo_cache_hit=${{ steps.cargo_cache.outputs.cache-hit }}"',
1152
- ].join("\n"),
1153
- },
1121
+ cargoCacheKeyStep(),
1122
+ ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`),
1154
1123
  {
1155
1124
  name: "Install Linux native dependencies",
1156
1125
  if: "${{ matrix.os == 'linux' }}",
package/src/release.ts CHANGED
@@ -1,9 +1,9 @@
1
- /** Unified tag-driven release workflow generation. */
1
+ /** Unified default-branch release workflow generation. */
2
2
  import { Component } from "projen";
3
3
  import { GithubWorkflow } from "projen/lib/github";
4
4
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
5
5
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
6
- import type { DBXToolsJavaScriptProject } from "./project-js.ts";
6
+ import { projectReleaseBranch, type DBXToolsJavaScriptProject } from "./project-js.ts";
7
7
  import { applyTasks, taskScript } from "./project.ts";
8
8
  import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
9
9
 
@@ -44,14 +44,14 @@ export function hasNodeRelease(project: DBXToolsJavaScriptProject): boolean {
44
44
  return nodeReleaseProjects.has(project);
45
45
  }
46
46
 
47
- /** Tag pattern accepted by release jobs and GitHub environments. */
47
+ /** Tag pattern created by release jobs and accepted by manual recovery. */
48
48
  export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
49
49
  const prefix = releaseTagPrefixes.get(project);
50
50
  if (!prefix) throw new Error("Release workflow is not configured");
51
51
  return `${prefix}*`;
52
52
  }
53
53
 
54
- /** Run a release stage on tag pushes or when selected for manual recovery. */
54
+ /** Run a release stage on default-branch pushes or when selected for manual recovery. */
55
55
  export function releaseStageCondition(stage: Exclude<ReleaseStage, "all">): string {
56
56
  return `\${{ github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == '${stage}' }}`;
57
57
  }
@@ -121,10 +121,10 @@ export function npmPublishEnvironment(): Record<string, string> {
121
121
  };
122
122
  }
123
123
 
124
- function verifyContextJob(tagPrefix: string): Job {
124
+ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
125
125
  return {
126
126
  runsOn: ["ubuntu-latest"],
127
- permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
127
+ permissions: { actions: JobPermission.READ, contents: JobPermission.WRITE },
128
128
  outputs: {
129
129
  release_tag: { stepId: "release", outputName: "release_tag" },
130
130
  expected_sha: { stepId: "release", outputName: "expected_sha" },
@@ -132,11 +132,11 @@ function verifyContextJob(tagPrefix: string): Job {
132
132
  },
133
133
  steps: [
134
134
  {
135
- name: "Checkout release tag",
135
+ name: "Checkout release source",
136
136
  uses: "actions/checkout@v6",
137
137
  with: {
138
- ref: "${{ github.event_name == 'push' && github.ref || inputs.expected_sha }}",
139
- "fetch-depth": 1,
138
+ ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
139
+ "fetch-depth": 2,
140
140
  },
141
141
  },
142
142
  {
@@ -145,18 +145,50 @@ function verifyContextJob(tagPrefix: string): Job {
145
145
  shell: "bash",
146
146
  env: {
147
147
  RELEASE_TAG:
148
- "${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}",
148
+ "${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || '' }}",
149
149
  EXPECTED_SHA:
150
150
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
151
151
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
152
152
  },
153
153
  run: [
154
- `case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
155
- 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
156
- 'test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
157
- 'RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
158
- 'test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
- 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
154
+ 'if [ "$GITHUB_EVENT_NAME" = "push" ]; then',
155
+ ' test "$GITHUB_REF_TYPE" = "branch"',
156
+ ` test "$GITHUB_REF_NAME" = "${releaseBranch}"`,
157
+ ' RELEASE_SHA="$GITHUB_SHA"',
158
+ ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
+ " RELEASE_VERSION=\"$(tr -d '\\r\\n' < VERSION)\"",
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"',
163
+ ` RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"`,
164
+ ' if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then',
165
+ ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
166
+ ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
167
+ ' test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"',
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",
181
+ ' git config user.name "github-actions[bot]"',
182
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
183
+ ' git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"',
184
+ ' git push origin "refs/tags/$RELEASE_TAG"',
185
+ " fi",
186
+ "else",
187
+ ` case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
188
+ ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
189
+ ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
190
+ ' RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
191
+ ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
160
192
  ' test "$GITHUB_REF_TYPE" = "tag"',
161
193
  ' test "$GITHUB_REF_NAME" = "$RELEASE_TAG"',
162
194
  ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
@@ -289,31 +321,49 @@ function addDocsJobs(
289
321
  });
290
322
  }
291
323
 
292
- /** Owns the single release workflow and the local bump task. */
324
+ /** Owns the single release workflow and local release preparation tasks. */
293
325
  export class DBXToolsRelease extends Component {
294
326
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
295
327
  super(project);
296
328
  const tagPrefix = options.tagPrefix ?? "v";
329
+ const releaseBranch = projectReleaseBranch(project);
297
330
  releaseTagPrefixes.set(project, tagPrefix);
298
331
  if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
299
332
  applyTasks(project, {
300
333
  bump: {
301
334
  exec: taskScript(project, "bump.ts", `--prefix ${tagPrefix}`),
302
335
  receiveArgs: true,
303
- description: "Bump the release version (default patch), then commit, tag, and push it",
336
+ description: "Increment VERSION and synchronize generated workspace versions",
337
+ },
338
+ "version:check": {
339
+ exec: taskScript(project, "version-check.ts"),
340
+ description: "Verify every package and generated barrel matches VERSION",
304
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
+ : {}),
305
355
  });
306
356
  if (!project.github) return;
307
357
 
308
358
  const workflow = new GithubWorkflow(project.github, "release", {
309
359
  fileName: "release.yml",
310
360
  limitConcurrency: true,
311
- concurrencyOptions: { group: "release", cancelInProgress: true },
361
+ concurrencyOptions: { group: "release", cancelInProgress: false },
312
362
  });
313
363
  workflow.runName =
314
- "release ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}";
364
+ "release ${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
315
365
  workflow.on({
316
- push: { tags: [`${tagPrefix}*`] },
366
+ push: { branches: [releaseBranch], paths: ["VERSION"] },
317
367
  workflowDispatch: {
318
368
  inputs: {
319
369
  release_tag: {
@@ -350,7 +400,7 @@ export class DBXToolsRelease extends Component {
350
400
  });
351
401
  workflow.file?.addOverride("permissions.contents", "read");
352
402
  workflow.file?.addOverride("on.workflow_dispatch.inputs.dry_run.default", true);
353
- workflow.addJob("verify-context", verifyContextJob(tagPrefix));
403
+ workflow.addJob("verify-context", verifyContextJob(tagPrefix, releaseBranch));
354
404
  if (options.nodeRelease !== false) {
355
405
  workflow.addJob("publish-node", nodePublishJob(project));
356
406
  }
@@ -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