@dbx-tools/projen 0.6.160 → 0.6.163

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
@@ -115,18 +115,28 @@ Cargo/sccache once,
115
115
  builds the Rust workspace once, then packages every discovered output from that
116
116
  shared build. Bun and the workspace install are present only when a Node binding
117
117
  needs TypeScript generation; uv is present only when a Python wheel is needed.
118
+ A new dispatch cancels queued and in-progress release and docs workflows before
119
+ starting, and each workflow's concurrency group also cancels its previous run.
118
120
  A binary-only workspace therefore installs neither. `rustVersion` remains the
119
121
  MSRV recorded in package manifests, while `releaseRustVersion` independently
120
122
  defaults release compilation to `stable`. UBRN uses that same release toolchain
121
123
  unless `ubrnRustVersion` explicitly selects another one.
122
124
 
123
- The Node generator's Rust CLI can be cached under one target directory keyed by
124
- its pinned UBRN version, Rust version, runner OS, and runner architecture. Set
125
- the repository variable `CACHE_UBRN_TARGET=true` to enable that archive while
126
- comparing its transfer cost with the default sccache-only path. Cargo registry
127
- caches and the `SCCACHE_GHA_VERSION` namespace stay stable per target/toolchain
128
- across version tags. Cache keys, restore results, sccache statistics, and phase
129
- timings are written to each build log. Python generation executes the already-built
125
+ Generated build and release workflows share the Bun cache helpers from
126
+ `bun-workflow.ts`. One `BUN_VERSION` value drives setup and cache keys, while a
127
+ generated dependency-only fingerprint keeps release version bumps from
128
+ invalidating Bun's global package cache. `node_modules` remains uncached.
129
+
130
+ The final host UBRN executable is cached by pinned UBRN version, Rust toolchain,
131
+ runner OS, and runner architecture. A hit skips both the workspace `bun install`
132
+ and the UBRN Cargo build. Release packaging passes the cached executable directly
133
+ to the Node binding generator and keeps the package barrel copied from source,
134
+ so it does not need the installed projen dependency graph. A miss saves the
135
+ validated executable immediately, before workspace build and packaging can fail.
136
+ Bun runtime setup still runs because the binding generator is TypeScript. Cargo registry caches
137
+ and the `SCCACHE_GHA_VERSION` namespace stay stable per target/toolchain across
138
+ version tags. Cache keys, restore results, sccache statistics, and phase timings
139
+ are written to each build log. Python generation executes the already-built
130
140
  `target/<triple>/release/uniffi-bindgen` directly. Artifact packaging therefore
131
141
  does no Rust compilation after the main workspace build.
132
142
 
package/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
6
  export * as barrels from "./src/barrels.ts";
7
7
  export * as bunApp from "./src/bun-app.ts";
8
+ export * as bunWorkflow from "./src/bun-workflow.ts";
8
9
  export * as clean from "./src/clean.ts";
9
10
  export * as codegen from "./src/codegen.ts";
10
11
  export * as dbxToolsConfig from "./src/dbx-tools-config.ts";
@@ -31,6 +32,8 @@ export * as vscode from "./src/vscode.ts";
31
32
  export * as watch from "./src/watch.ts";
32
33
  export * as workspaceVersion from "./src/workspace-version.ts";
33
34
  export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
35
+ export { BUN_VERSION } from "./src/bun-workflow.ts";
36
+ export type { BunWorkflowCacheOptions } from "./src/bun-workflow.ts";
34
37
  export { DBXToolsConfig } from "./src/dbx-tools-config.ts";
35
38
  export type { DBXToolsConfigOptions } from "./src/dbx-tools-config.ts";
36
39
  export { resolvePkgRoot } from "./src/engine-root.ts";
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.160",
30
- "@dbx-tools/path": "0.6.160",
31
- "@dbx-tools/shared-core": "0.6.160",
29
+ "@dbx-tools/core": "0.6.163",
30
+ "@dbx-tools/path": "0.6.163",
31
+ "@dbx-tools/shared-core": "0.6.163",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "main": "index.ts",
50
50
  "license": "Apache-2.0",
51
- "version": "0.6.160",
51
+ "version": "0.6.163",
52
52
  "types": "index.ts",
53
53
  "type": "module",
54
54
  "exports": {
@@ -0,0 +1,145 @@
1
+ /** Shared Bun setup and package-cache steps for generated workflows. */
2
+ import { TextFile, javascript } from "projen";
3
+
4
+ export const BUN_VERSION = "1.3.14";
5
+
6
+ const cacheKeyScript = `#!/usr/bin/env node
7
+ import { createHash } from "node:crypto";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const root = process.cwd();
12
+ const ignored = new Set([
13
+ ".git",
14
+ ".docs-build",
15
+ ".venv",
16
+ ".worktrees",
17
+ "coverage",
18
+ "dist",
19
+ "lib",
20
+ "node_modules",
21
+ "target",
22
+ ]);
23
+ const manifests = [];
24
+ const walk = (directory) => {
25
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
26
+ if (ignored.has(entry.name)) continue;
27
+ const path = join(directory, entry.name);
28
+ if (entry.isDirectory()) walk(path);
29
+ else if (entry.name === "package.json") manifests.push(path);
30
+ }
31
+ };
32
+ walk(root);
33
+
34
+ const dependencyFields = [
35
+ "catalog",
36
+ "dependencies",
37
+ "devDependencies",
38
+ "optionalDependencies",
39
+ "overrides",
40
+ "peerDependencies",
41
+ "peerDependenciesMeta",
42
+ "resolutions",
43
+ "trustedDependencies",
44
+ ];
45
+ const canonical = (value) => {
46
+ if (Array.isArray(value)) return value.map(canonical);
47
+ if (!value || typeof value !== "object") return value;
48
+ return Object.fromEntries(
49
+ Object.entries(value)
50
+ .sort(([left], [right]) => left.localeCompare(right))
51
+ .map(([key, child]) => [key, canonical(child)]),
52
+ );
53
+ };
54
+ const dependencies = manifests
55
+ .sort()
56
+ .map((path) => {
57
+ const manifest = JSON.parse(readFileSync(path, "utf8"));
58
+ return [
59
+ path.slice(root.length + 1),
60
+ Object.fromEntries(
61
+ dependencyFields
62
+ .filter((field) => manifest[field] !== undefined)
63
+ .map((field) => [field, canonical(manifest[field])]),
64
+ ),
65
+ ];
66
+ });
67
+ process.stdout.write(createHash("sha256").update(JSON.stringify(dependencies)).digest("hex"));
68
+ `;
69
+
70
+ const configured = new WeakSet<javascript.NodeProject>();
71
+
72
+ function ensureCacheKeyScript(project: javascript.NodeProject): void {
73
+ if (configured.has(project)) return;
74
+ new TextFile(project.root, ".projen/bun-cache-key.mjs", {
75
+ lines: cacheKeyScript.trimEnd().split("\n"),
76
+ });
77
+ configured.add(project);
78
+ }
79
+
80
+ function stepCondition(condition: string | undefined, cacheMiss = false): string | undefined {
81
+ const expressions = [
82
+ condition,
83
+ ...(cacheMiss ? ["steps.bun_cache.outputs.cache-hit != 'true'"] : []),
84
+ ].filter(Boolean);
85
+ return expressions.length ? `\${{ ${expressions.join(" && ")} }}` : undefined;
86
+ }
87
+
88
+ export interface BunWorkflowCacheOptions {
89
+ readonly setupCondition?: string;
90
+ readonly condition?: string;
91
+ }
92
+
93
+ /** Set up Bun and restore its global package cache. */
94
+ export function bunCacheRestoreSteps(
95
+ project: javascript.NodeProject,
96
+ options: BunWorkflowCacheOptions = {},
97
+ ): readonly Record<string, unknown>[] {
98
+ ensureCacheKeyScript(project);
99
+ const condition = stepCondition(options.condition);
100
+ const setupCondition = stepCondition(options.setupCondition);
101
+ return [
102
+ {
103
+ name: "Setup Bun",
104
+ ...(setupCondition ? { if: setupCondition } : {}),
105
+ uses: "oven-sh/setup-bun@v2",
106
+ with: { "bun-version": "${{ env.BUN_VERSION }}" },
107
+ },
108
+ {
109
+ name: "Resolve Bun cache",
110
+ id: "bun_cache_metadata",
111
+ ...(condition ? { if: condition } : {}),
112
+ shell: "bash",
113
+ run: [
114
+ 'echo "path=$(bun pm cache)" >> "$GITHUB_OUTPUT"',
115
+ 'echo "dependency_hash=$(node .projen/bun-cache-key.mjs)" >> "$GITHUB_OUTPUT"',
116
+ ].join("\n"),
117
+ },
118
+ {
119
+ name: "Restore Bun cache",
120
+ id: "bun_cache",
121
+ ...(condition ? { if: condition } : {}),
122
+ uses: "actions/cache/restore@v5",
123
+ with: {
124
+ path: "${{ steps.bun_cache_metadata.outputs.path }}",
125
+ key: `bun-\${{ runner.os }}-\${{ runner.arch }}-\${{ env.BUN_VERSION }}-\${{ steps.bun_cache_metadata.outputs.dependency_hash }}`,
126
+ "restore-keys": `bun-\${{ runner.os }}-\${{ runner.arch }}-\${{ env.BUN_VERSION }}-`,
127
+ },
128
+ },
129
+ ];
130
+ }
131
+
132
+ /** Save Bun's global package cache immediately after installation. */
133
+ export function bunCacheSaveStep(
134
+ options: BunWorkflowCacheOptions = {},
135
+ ): Readonly<Record<string, unknown>> {
136
+ return {
137
+ name: "Save Bun cache",
138
+ if: stepCondition(options.condition, true),
139
+ uses: "actions/cache/save@v5",
140
+ with: {
141
+ path: "${{ steps.bun_cache_metadata.outputs.path }}",
142
+ key: "${{ steps.bun_cache.outputs.cache-primary-key }}",
143
+ },
144
+ };
145
+ }
package/src/project-js.ts CHANGED
@@ -13,6 +13,7 @@ import { ignore, match } from "@dbx-tools/path";
13
13
  import { object, string, type OneOrMany } from "@dbx-tools/shared-core";
14
14
  import { type IConstruct } from "constructs";
15
15
  import { Component, IgnoreFile, Project, type TaskOptions, javascript, typescript } from "projen";
16
+ import type { JobStep } from "projen/lib/github/workflows-model";
16
17
  import { ReleaseTrigger } from "projen/lib/release";
17
18
  import { mixin } from "..";
18
19
  import { generateBarrels } from "./barrels.ts";
@@ -23,6 +24,7 @@ import {
23
24
  BunfigFile,
24
25
  RootBunfigFile,
25
26
  } from "./bun-app.ts";
27
+ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
26
28
  import { codegenModulePaths, generateCodegen } from "./codegen.ts";
27
29
  import { DBXToolsConfig, type DBXToolsConfigOptions } from "./dbx-tools-config.ts";
28
30
  import { resolvePkgRoot } from "./engine-root.ts";
@@ -642,6 +644,24 @@ export class DBXToolsNodeProject
642
644
  initProject(this, options);
643
645
  }
644
646
 
647
+ public override renderWorkflowSetup(options?: javascript.RenderWorkflowSetupOptions): JobStep[] {
648
+ const steps = super.renderWorkflowSetup(options);
649
+ if (this.parent) return steps;
650
+ return steps.flatMap((step) => {
651
+ if (step.uses === "oven-sh/setup-bun@v2") return [...bunCacheRestoreSteps(this)];
652
+ if (step.run === "bun install") {
653
+ return [
654
+ step,
655
+ bunCacheSaveStep({
656
+ condition:
657
+ "github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository",
658
+ }),
659
+ ];
660
+ }
661
+ return [step];
662
+ }) as JobStep[];
663
+ }
664
+
645
665
  public override preSynthesize(): void {
646
666
  if (this.rootInstallOnly) this.with(ROOT_INSTALL_ONLY_MIXIN);
647
667
  super.preSynthesize();
@@ -1066,6 +1086,7 @@ function initProject(
1066
1086
  // a plain `bun` exec rather than any wrapper: the default task is spawned by
1067
1087
  // nested installs/synths, and a wrapper that exported `npm_config_*` broke them.
1068
1088
  project.defaultTask?.reset("bun .projenrc.ts");
1089
+ project.buildWorkflow?.workflow.file?.addOverride("jobs.build.env.BUN_VERSION", BUN_VERSION);
1069
1090
 
1070
1091
  // Pin bun's hoisted linker workspace-wide (see RootBunfigFile) so a peer dep
1071
1092
  // resolves to one copy and singletons/types stay coherent.
package/src/project-py.ts CHANGED
@@ -429,7 +429,11 @@ export class DBXToolsPythonWorkspace extends Component {
429
429
  if (!project.github) return;
430
430
  const publications = this.publications(options);
431
431
  if (publications.length === 0) return;
432
- const workflow = new GithubWorkflow(project.github, options.workflowName ?? "python-release");
432
+ const workflowName = options.workflowName ?? "python-release";
433
+ const workflow = new GithubWorkflow(project.github, workflowName, {
434
+ limitConcurrency: true,
435
+ concurrencyOptions: { group: workflowName, cancelInProgress: true },
436
+ });
433
437
  workflow.file?.addOverride("permissions", {
434
438
  contents: "read",
435
439
  ...(options.upstreamWorkflow ? { actions: "read" } : {}),
package/src/project-rs.ts CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { project as coreProject } from "@dbx-tools/core";
6
6
  import { string } from "@dbx-tools/shared-core";
7
7
  import { Project, TextFile, YamlFile, javascript } from "projen";
8
+ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
8
9
  import type { DBXToolsProject } from "./project.ts";
9
10
  import {
10
11
  DBXToolsTypeScriptProject,
@@ -641,6 +642,10 @@ export class DBXToolsRustWorkspace {
641
642
  ubrnRustVersion === releaseRustVersion
642
643
  ? ""
643
644
  : `rustup toolchain install ${ubrnRustVersion} --profile minimal\n`;
645
+ const ubrnExecutable =
646
+ "${{ github.workspace }}/.cache/ubrn/uniffi-bindgen-react-native${{ matrix.target.os == 'win32' && '.exe' || '' }}";
647
+ const builtUbrnExecutable =
648
+ "target/ubrn/debug/uniffi-bindgen-react-native${{ matrix.target.os == 'win32' && '.exe' || '' }}";
644
649
  const releaseBranch = projectReleaseBranch(project);
645
650
  const bindings = this.bindingMappings.map((binding) => ({
646
651
  ...binding,
@@ -685,7 +690,7 @@ export class DBXToolsRustWorkspace {
685
690
  `--node-package "${binding.nodePackage}"`,
686
691
  `--python-package "${binding.pythonPackage}"`,
687
692
  ...(binding.node
688
- ? ['--node-generator "node_modules/@dbx-tools/projen/tasks/uniffi.ts"']
693
+ ? ['--node-generator "projen/tasks/uniffi.ts"', '--ubrn "$UBRN_EXECUTABLE"']
689
694
  : []),
690
695
  '--cargo-target "${{ matrix.target.cargo }}"',
691
696
  '--node-triple "${{ matrix.target.node }}"',
@@ -762,6 +767,7 @@ export class DBXToolsRustWorkspace {
762
767
  "runs-on": "${{ matrix.target.runner }}",
763
768
  env: {
764
769
  ...RUST_CACHE_ENV,
770
+ BUN_VERSION,
765
771
  SCCACHE_GHA_VERSION: `release-\${{ matrix.target.cargo }}-rust-${releaseRustVersion}`,
766
772
  },
767
773
  strategy: {
@@ -770,15 +776,6 @@ export class DBXToolsRustWorkspace {
770
776
  },
771
777
  steps: [
772
778
  ...releaseSourceSteps(),
773
- ...(hasNodeBindings
774
- ? [
775
- {
776
- name: "Setup Bun",
777
- uses: "oven-sh/setup-bun@v2",
778
- with: { "bun-version": "1.3.14" },
779
- },
780
- ]
781
- : []),
782
779
  ...(hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
783
780
  {
784
781
  name: "Setup Rust",
@@ -789,17 +786,21 @@ export class DBXToolsRustWorkspace {
789
786
  ...(hasNodeBindings
790
787
  ? [
791
788
  {
792
- name: "Cache UBRN generator",
789
+ name: "Restore UBRN executable",
793
790
  id: "ubrn_cache",
794
- if: "${{ vars.CACHE_UBRN_TARGET == 'true' }}",
795
- uses: "actions/cache@v5",
791
+ uses: "actions/cache/restore@v5",
796
792
  with: {
797
- path: "target/ubrn",
798
- key: `ubrn-\${{ runner.os }}-\${{ runner.arch }}-rust-${ubrnRustVersion}-${UBRN_VERSION}`,
793
+ path: ".cache/ubrn",
794
+ key: `ubrn-executable-\${{ runner.os }}-\${{ runner.arch }}-rust-${ubrnRustVersion}-${UBRN_VERSION}`,
799
795
  },
800
796
  },
801
797
  ]
802
798
  : []),
799
+ ...(hasNodeBindings
800
+ ? bunCacheRestoreSteps(project, {
801
+ condition: "steps.ubrn_cache.outputs.cache-hit != 'true'",
802
+ })
803
+ : []),
803
804
  {
804
805
  name: "Log cache configuration",
805
806
  shell: "bash",
@@ -811,9 +812,8 @@ export class DBXToolsRustWorkspace {
811
812
  `echo "ubrn_rust_toolchain=${ubrnRustVersion}"`,
812
813
  ...(hasNodeBindings
813
814
  ? [
814
- "echo \"ubrn_target_cache_enabled=${{ vars.CACHE_UBRN_TARGET == 'true' }}\"",
815
- `echo "ubrn_target_cache_key=ubrn-\${{ runner.os }}-\${{ runner.arch }}-rust-${ubrnRustVersion}-${UBRN_VERSION}"`,
816
- 'echo "ubrn_target_cache_hit=${{ steps.ubrn_cache.outputs.cache-hit }}"',
815
+ `echo "ubrn_executable_cache_key=ubrn-executable-\${{ runner.os }}-\${{ runner.arch }}-rust-${ubrnRustVersion}-${UBRN_VERSION}"`,
816
+ 'echo "ubrn_executable_cache_hit=${{ steps.ubrn_cache.outputs.cache-hit }}"',
817
817
  ]
818
818
  : []),
819
819
  ].join("\n"),
@@ -827,24 +827,50 @@ export class DBXToolsRustWorkspace {
827
827
  ? [
828
828
  {
829
829
  name: "Install Node tooling",
830
+ if: "${{ steps.ubrn_cache.outputs.cache-hit != 'true' }}",
830
831
  shell: "bash",
831
832
  run: timedBash("node_tooling", "bun install"),
832
833
  },
834
+ bunCacheSaveStep({
835
+ condition: "steps.ubrn_cache.outputs.cache-hit != 'true'",
836
+ }),
833
837
  ]
834
838
  : []),
835
839
  ...(hasNodeBindings
836
840
  ? [
837
841
  {
838
842
  name: "Prepare UBRN generator",
843
+ if: "${{ steps.ubrn_cache.outputs.cache-hit != 'true' }}",
839
844
  env: {
840
845
  CARGO_TARGET_DIR: "${{ github.workspace }}/target/ubrn",
846
+ UBRN_EXECUTABLE: ubrnExecutable,
841
847
  },
842
848
  shell: "bash",
843
849
  run: timedBash(
844
850
  "ubrn_generator",
845
- `${ubrnToolchainInstall}cargo +${ubrnRustVersion} build --manifest-path node_modules/uniffi-bindgen-react-native/crates/ubrn_cli/Cargo.toml`,
851
+ [
852
+ `${ubrnToolchainInstall}cargo +${ubrnRustVersion} build --manifest-path node_modules/uniffi-bindgen-react-native/crates/ubrn_cli/Cargo.toml`,
853
+ 'mkdir -p "$(dirname "$UBRN_EXECUTABLE")"',
854
+ `cp "${builtUbrnExecutable}" "$UBRN_EXECUTABLE"`,
855
+ 'chmod +x "$UBRN_EXECUTABLE"',
856
+ ].join("\n"),
846
857
  ),
847
858
  },
859
+ {
860
+ name: "Verify UBRN executable",
861
+ env: { UBRN_EXECUTABLE: ubrnExecutable },
862
+ shell: "bash",
863
+ run: 'test -f "$UBRN_EXECUTABLE"',
864
+ },
865
+ {
866
+ name: "Save UBRN executable",
867
+ if: "${{ steps.ubrn_cache.outputs.cache-hit != 'true' }}",
868
+ uses: "actions/cache/save@v5",
869
+ with: {
870
+ path: ".cache/ubrn",
871
+ key: "${{ steps.ubrn_cache.outputs.cache-primary-key }}",
872
+ },
873
+ },
848
874
  ]
849
875
  : []),
850
876
  {
@@ -864,7 +890,7 @@ export class DBXToolsRustWorkspace {
864
890
  VERSION: RELEASE_TAG,
865
891
  ...(hasNodeBindings
866
892
  ? {
867
- CARGO_TARGET_DIR: "${{ github.workspace }}/target/ubrn",
893
+ UBRN_EXECUTABLE: ubrnExecutable,
868
894
  }
869
895
  : {}),
870
896
  },
@@ -990,6 +1016,10 @@ export class DBXToolsRustWorkspace {
990
1016
  },
991
1017
  },
992
1018
  },
1019
+ concurrency: {
1020
+ group: workflowName,
1021
+ "cancel-in-progress": true,
1022
+ },
993
1023
  permissions: { contents: "read" },
994
1024
  jobs: {
995
1025
  "verify-context": {
package/src/release.ts CHANGED
@@ -4,12 +4,13 @@
4
4
  * the project has a GitHub component - authors the tag-driven npm publish
5
5
  * workflow that the pushed tag triggers.
6
6
  */
7
- import { rmSync } from "node:fs";
7
+ import { existsSync, rmSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { Component, YamlFile } from "projen";
10
10
  import { GithubWorkflow } from "projen/lib/github";
11
11
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
12
12
  import { object } from "@dbx-tools/shared-core";
13
+ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
13
14
  import { applyTasks, taskScript, type DBXToolsNodeProject } from "./project.ts";
14
15
  import {
15
16
  DOWNSTREAM_RELEASE_EVENT,
@@ -21,7 +22,6 @@ import {
21
22
 
22
23
  const NODE_VERSION = "lts/*";
23
24
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
24
- const BUN_VERSION = "1.3.14";
25
25
 
26
26
  /**
27
27
  * The `release` workflow's version-stamp + publish step, as a shell script.
@@ -73,10 +73,10 @@ interface PublishWorkflow {
73
73
  }
74
74
 
75
75
  /** Shared checkout and toolchain setup for every npm publish workflow. */
76
- function publishSetupSteps(): JobStep[] {
76
+ function publishSetupSteps(project: DBXToolsNodeProject): JobStep[] {
77
77
  return [
78
78
  { name: "Checkout", uses: "actions/checkout@v6", with: { "fetch-depth": 0 } },
79
- { name: "Setup Bun", uses: "oven-sh/setup-bun@v2", with: { "bun-version": BUN_VERSION } },
79
+ ...bunCacheRestoreSteps(project),
80
80
  {
81
81
  name: "Setup Node.js",
82
82
  uses: "actions/setup-node@v6",
@@ -88,6 +88,7 @@ function publishSetupSteps(): JobStep[] {
88
88
  },
89
89
  // Bun's install; the lockfile may be absent or stale in CI so it is not frozen.
90
90
  { name: "Install", run: "bun install" },
91
+ bunCacheSaveStep(),
91
92
  ];
92
93
  }
93
94
 
@@ -196,9 +197,23 @@ export class DBXToolsRelease extends Component {
196
197
  object.isRecord(rust) && typeof rust.releaseWorkflow === "string"
197
198
  ? RUST_RELEASE_EVENT
198
199
  : DOWNSTREAM_RELEASE_EVENT;
199
- project
200
- .tryFindObjectFile(".github/workflows/release-dispatch.yml")
201
- ?.addOverride("jobs.dispatch.steps.1.env.RELEASE_EVENT", releaseEvent);
200
+ const dispatcher = project.tryFindObjectFile(".github/workflows/release-dispatch.yml");
201
+ dispatcher?.addOverride("jobs.dispatch.steps.1.env.RELEASE_EVENT", releaseEvent);
202
+ const releaseWorkflows = [
203
+ ...(object.isRecord(rust) && typeof rust.releaseWorkflow === "string"
204
+ ? [rust.releaseWorkflow]
205
+ : []),
206
+ ...(typeof project.dbxToolsConfig.pythonReleaseWorkflow === "string"
207
+ ? [project.dbxToolsConfig.pythonReleaseWorkflow]
208
+ : []),
209
+ ...(this.workflowName ? [this.workflowName] : []),
210
+ ...this.standaloneReleases.map(({ name }) => name),
211
+ ...(existsSync(resolve(project.outdir, ".github/workflows/docs.yml")) ? ["docs"] : []),
212
+ ];
213
+ dispatcher?.addOverride(
214
+ "jobs.dispatch.steps.1.env.RELEASE_WORKFLOWS",
215
+ [...new Set(releaseWorkflows)].join(","),
216
+ );
202
217
  // Release the standalone projects in the SAME run, at the same version. They
203
218
  // are not workspace members, so nothing else would ever bring them along.
204
219
  const siblingArgs = this.standaloneReleases
@@ -233,7 +248,11 @@ export class DBXToolsRelease extends Component {
233
248
  obj: {
234
249
  name: "release-dispatch",
235
250
  on: { push: { tags: [`${this.tagPrefix}*`] } },
236
- permissions: { contents: "write" },
251
+ concurrency: {
252
+ group: "release-dispatch",
253
+ "cancel-in-progress": true,
254
+ },
255
+ permissions: { actions: "write", contents: "write" },
237
256
  jobs: {
238
257
  dispatch: {
239
258
  "runs-on": "ubuntu-latest",
@@ -250,10 +269,19 @@ export class DBXToolsRelease extends Component {
250
269
  GH_TOKEN: "${{ github.token }}",
251
270
  RELEASE_TAG: "${{ github.ref_name }}",
252
271
  RELEASE_EVENT: DOWNSTREAM_RELEASE_EVENT,
272
+ RELEASE_WORKFLOWS: "",
253
273
  },
254
274
  run: [
255
275
  `case "$RELEASE_TAG" in ${this.tagPrefix}*) ;; *) exit 1 ;; esac`,
256
276
  'EXPECTED_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
277
+ 'IFS="," read -r -a workflows <<< "$RELEASE_WORKFLOWS"',
278
+ 'for workflow in "${workflows[@]}"; do',
279
+ " for status in in_progress queued requested waiting pending action_required; do",
280
+ " while IFS= read -r run_id; do",
281
+ ' if [ -n "$run_id" ]; then gh run cancel "$run_id"; fi',
282
+ ' done < <(gh run list --workflow "$workflow.yml" --status "$status" --limit 100 --json databaseId --jq \'.[].databaseId\')',
283
+ " done",
284
+ "done",
257
285
  [
258
286
  'gh api --method POST "repos/$GITHUB_REPOSITORY/dispatches"',
259
287
  '--raw-field event_type="$RELEASE_EVENT"',
@@ -274,14 +302,13 @@ export class DBXToolsRelease extends Component {
274
302
  project: DBXToolsNodeProject,
275
303
  { name, tagPrefix, steps, workingDirectory, upstreamWorkflow }: PublishWorkflow,
276
304
  ): void {
277
- const setupSteps = publishSetupSteps();
305
+ const setupSteps = publishSetupSteps(project);
278
306
  const branchDispatch = upstreamWorkflow === undefined;
279
307
  const workflow = new GithubWorkflow(project.github!, name, {
280
- // Serialize publishes so two tags landing together cannot race to the
281
- // registry, but never cancel a run already in flight: a half-published
282
- // release is worse than a queued one.
308
+ // A newer release supersedes an older run even when publication has
309
+ // started; the dispatcher also cancels active ecosystem runs immediately.
283
310
  limitConcurrency: true,
284
- concurrencyOptions: { group: name, cancelInProgress: false },
311
+ concurrencyOptions: { group: name, cancelInProgress: true },
285
312
  });
286
313
  // Read-only floor for any job that does not declare its own permissions;
287
314
  // the publish job below overrides it with the `id-token` it needs.
@@ -343,6 +370,7 @@ export class DBXToolsRelease extends Component {
343
370
  // `DRY_RUN_INPUT` is `--dry-run` when the dispatch input is true, else empty;
344
371
  // the publish script also FORCES it on any `workflow_dispatch` run.
345
372
  env: {
373
+ BUN_VERSION,
346
374
  CI: "true",
347
375
  DRY_RUN_INPUT: "${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}",
348
376
  },
@@ -26,6 +26,7 @@ const parsed = parseArgs({
26
26
  python: { type: "string" },
27
27
  "node-package": { type: "string" },
28
28
  "node-generator": { type: "string" },
29
+ ubrn: { type: "string" },
29
30
  "python-package": { type: "string" },
30
31
  "cargo-target": { type: "string" },
31
32
  "node-triple": { type: "string" },
@@ -149,6 +150,7 @@ const packageNode = ({
149
150
  required("cargo-target"),
150
151
  "--node-package-base",
151
152
  `${nodePackage}-`,
153
+ ...(parsed.values.ubrn ? ["--ubrn", parsed.values.ubrn, "--skip-barrels"] : []),
152
154
  "--skip-build",
153
155
  ]);
154
156
  mkdirSync(resolve(output, "npm-facade"), { recursive: true });
package/tasks/uniffi.ts CHANGED
@@ -29,7 +29,9 @@ const { values } = parseArgs({
29
29
  python: { type: "string" },
30
30
  "cargo-target": { type: "string" },
31
31
  "node-package-base": { type: "string" },
32
+ ubrn: { type: "string" },
32
33
  "skip-build": { type: "boolean" },
34
+ "skip-barrels": { type: "boolean" },
33
35
  },
34
36
  });
35
37
  if (!values.crate || (!values.node && !values.python)) {
@@ -109,7 +111,7 @@ if (!existsSync(library)) throw new Error(`Missing compiled UniFFI library: ${li
109
111
  if (values.node) {
110
112
  const nodeSource = resolve(root, values.node, "src");
111
113
  const nodeOutput = mkdtempSync(join(tmpdir(), `${libraryName}-node-`));
112
- run(join(root, "node_modules/.bin/ubrn"), [
114
+ run(values.ubrn ? resolve(values.ubrn) : join(root, "node_modules/.bin/ubrn"), [
113
115
  "generate",
114
116
  "napi",
115
117
  "bindings",
@@ -187,11 +189,13 @@ if (values.node) {
187
189
  }
188
190
  rmSync(resolve(root, values.node, "src/generated"), { recursive: true, force: true });
189
191
  rmSync(nodeOutput, { recursive: true, force: true });
190
- run(process.execPath, [
191
- resolve(dirname(fileURLToPath(import.meta.url)), "barrels.ts"),
192
- "--dir",
193
- resolve(root, values.node),
194
- ]);
192
+ if (!values["skip-barrels"]) {
193
+ run(process.execPath, [
194
+ resolve(dirname(fileURLToPath(import.meta.url)), "barrels.ts"),
195
+ "--dir",
196
+ resolve(root, values.node),
197
+ ]);
198
+ }
195
199
  }
196
200
 
197
201
  if (values.python) {