@dbx-tools/projen 0.6.168 → 0.6.174

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
@@ -93,15 +93,20 @@ VS Code component; dbx-tools reuses it rather than constructing a second
93
93
  configurable `root` (default `packages/rs`), generates its Cargo manifest, and
94
94
  derives the crate name and repository from the parent project. A crate containing
95
95
  `uniffi::setup_scaffolding!()`
96
- automatically wires matching private Node and Python binding packages using the
96
+ automatically wires matching public Node and Python binding packages using the
97
97
  same capability name. Repository-specific dependencies and features remain
98
98
  declarative options in `.projenrc.ts`; generated bindings are built separately
99
99
  from projen synthesis. Target-independent Node binding TypeScript is committed
100
100
  and remains generated/read-only, while native libraries stay ignored. Node
101
101
  facades compile to `lib/` and publish JavaScript entry points that plain Node
102
- can load from `node_modules`. The generated barrel exports the binding API from
103
- the package root. Do not create a `nodeExports` binding subpath or a
104
- handwritten type facade.
102
+ can load from `node_modules`. A complete `bindings.ts` / `_bindings.ts` /
103
+ `_bindings-ffi.ts` triplet is exported directly from the generated package
104
+ barrel. Python keeps `bindings.py` as the generated implementation and exports
105
+ its public names from a fully generated `__init__.py` with no editable marker
106
+ blocks. Node generation fails when direct binding names conflict with another
107
+ package-root export; Python generation refuses to overwrite a non-generated
108
+ package root. Do not create a `nodeExports` binding subpath or a handwritten
109
+ type facade.
105
110
 
106
111
  Rust dependencies between binding-enabled workspace crates become Node
107
112
  `workspace:*` and Python `internalDependencies` automatically. Python generation
@@ -132,31 +137,13 @@ both the tag target and `HEAD` to equal that SHA before building. Its matrix has
132
137
  one row per target. Each row installs native dependencies and restores
133
138
  Cargo/sccache once,
134
139
  builds the Rust workspace once, then packages every discovered output from that
135
- shared build. Bun and the workspace install are present only when a Node binding
136
- needs TypeScript generation; uv is present only when a Python wheel is needed.
140
+ shared build. Rust rows package native npm archives and Python wheels; they do
141
+ not install Bun or UBRN and do not build Node facades.
137
142
  A new dispatch cancels queued and in-progress release and docs workflows before
138
143
  starting, and each workflow's concurrency group also cancels its previous run.
139
- A binary-only workspace therefore installs neither. `rustVersion` remains the
144
+ A binary-only workspace therefore installs no language package tool. `rustVersion` remains the
140
145
  MSRV recorded in package manifests, while `releaseRustVersion` independently
141
- defaults release compilation to `stable`. UBRN uses that same release toolchain
142
- unless `ubrnRustVersion` explicitly selects another one.
143
-
144
- Generated build and release workflows share the Bun cache helpers from
145
- `bun-workflow.ts`. One `BUN_VERSION` value drives setup and cache keys, while a
146
- generated dependency-only fingerprint keeps release version bumps from
147
- invalidating Bun's global package cache. `node_modules` remains uncached.
148
-
149
- The final host UBRN executable is cached by pinned UBRN version, Rust toolchain,
150
- runner OS, and runner architecture. A hit skips both the workspace `bun install`
151
- and the UBRN Cargo build. Release packaging passes the cached executable directly
152
- to the Node binding generator and keeps the package barrel copied from source,
153
- so it does not need the installed projen dependency graph. A miss saves the
154
- validated executable immediately, before workspace build and packaging can fail.
155
- Bun runtime setup still runs in the single facade row because the binding
156
- generator and facade bundle are TypeScript. The bundle points its type surface
157
- at the committed generated source and needs no standalone TypeScript compiler
158
- or workspace install on a UBRN cache hit. Non-facade rows skip the complete
159
- Bun/UBRN path.
146
+ defaults release compilation to `stable`.
160
147
  Stable Windows rows verify and use the hosted runner's installed Rust toolchain
161
148
  and select `rust-lld` for the workspace build. Cargo registry caches
162
149
  and the `SCCACHE_GHA_VERSION` namespace stay stable per target/toolchain across
@@ -165,17 +152,12 @@ are written to each build log. Python generation executes the already-built
165
152
  `target/<triple>/release/<crate>-uniffi-bindgen` directly. Artifact packaging therefore
166
153
  does no Rust compilation after the main workspace build.
167
154
 
168
- Artifacts identify their crate, target, and type. Download-only publication
169
- jobs publish native npm platform archives before the facade and publish
170
- platform-tagged Python wheels without checkout, Bun, or `bun install`.
171
- Non-private Cargo crates publish from a source-only job with
172
- `cargo publish --no-verify`; GitHub Release uploads likewise consume prebuilt
173
- binary artifacts without reinstalling a toolchain.
174
-
175
- Set the repository variable `LOCAL_REPOSITORIES=true` to enable generated
176
- self-hosted publication from the prebuilt artifacts. Configure `LOCAL_NPM_REGISTRY` and
177
- `LOCAL_PYPI_PUBLISH_URL` with their matching `LOCAL_*` credentials. Set
178
- `LOCAL_CARGO_REGISTRY` to a named Cargo registry such as a loopback
155
+ Artifacts identify their crate, target, and type. `rust-release` publishes
156
+ non-private Cargo crates with `cargo publish --no-verify`, uploads requested
157
+ binary assets to the GitHub release, then dispatches the verified Rust run id
158
+ with the tag and SHA. It never publishes npm or PyPI packages.
159
+
160
+ Set `LOCAL_CARGO_REGISTRY` to a named Cargo registry such as a loopback
179
161
  [Kellnr](https://kellnr.io/) instance and provide `LOCAL_CARGO_TOKEN`. Public
180
162
  Cargo crates also publish directly to crates.io
181
163
  with `CARGO_REGISTRY_TOKEN`. Override `releaseTargets` only when a consumer has
@@ -186,12 +168,19 @@ architecture. Omit both filters to regenerate the complete maintained matrix.
186
168
  `DBX_TOOLS_RELEASE_PLATFORMS` can select the generated matrix without repeating
187
169
  environment parsing in a consumer.
188
170
 
189
- Private Python binding projects are marked with `[tool.dbx-tools] private =
190
- true`. They stay out of the standard uv/Python release and docs surfaces;
191
- `rust-release` publishes their prebuilt native wheels directly.
192
- After Rust's public artifacts finish, it dispatches the downstream `release`
193
- event. Python, Node, standalone Node, and docs consume that event independently
194
- and start together with the same verified tag and SHA. Without Rust,
171
+ Public UniFFI facades are marked with `dbxToolsConfig.uniffi = true` in Node
172
+ manifests and `[tool.dbx_tools.config] uniffi = true` in Python manifests. The
173
+ Node release downloads and publishes native archives first, publishes normal
174
+ workspace packages, then builds and publishes facades from the committed
175
+ generated TypeScript in dependency order. The Python release downloads every
176
+ platform wheel and publishes them from one trusted-publisher job per
177
+ distribution. The docs generators include them because they are publicly installable.
178
+ When a conventional Node binding path already belongs to a root subproject,
179
+ Rust mapping reuses that project and adds binding dependencies and metadata to
180
+ its existing manifest.
181
+ After Rust's artifacts finish, it dispatches the downstream `release` event
182
+ with its run id and attempt. Python, Node, and docs consume that event
183
+ independently with the same verified tag and SHA. Without Rust,
195
184
  `release-dispatch` sends that event immediately. Configure release GitHub
196
185
  environments to permit the generated release branch; the trusted-publisher
197
186
  instructions list that branch with the workflow and environment identity PyPI
@@ -274,8 +263,9 @@ never as a blanket `<package>/src/**`. A codegen package may hold hand-written
274
263
  modules beside its generated ones, and those must stay linted.
275
264
 
276
265
  `generateBarrels()` writes package-root `index.ts` barrels with module
277
- namespaces and flat unique type exports, returning the number that actually
278
- changed. A name two modules both declare is ambiguous and stays namespace-only —
266
+ namespaces, flat unique type exports, `PACKAGE_IDENTIFIER`, and
267
+ `PACKAGE_VERSION`, returning the number that actually changed. A name two
268
+ modules both declare is ambiguous and stays namespace-only —
279
269
  except when one of them is generated: the hand-written module is the curated view
280
270
  of the generated shape (`shared-genie`'s `genie-model.ts` extends its own
281
271
  codegen'd `dashboards.ts`), so it owns the name and stays hoisted. A barrel whose export surface is unchanged is left untouched,
package/index.ts CHANGED
@@ -3,6 +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.173";
6
7
  export * as barrels from "./src/barrels.ts";
7
8
  export * as bunApp from "./src/bun-app.ts";
8
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
package/package.json CHANGED
@@ -2,7 +2,6 @@
2
2
  "name": "@dbx-tools/projen",
3
3
  "scripts": {
4
4
  "build": "projen build",
5
- "bump": "projen bump",
6
5
  "clobber": "projen clobber",
7
6
  "compile": "projen compile",
8
7
  "default": "projen default",
@@ -26,9 +25,9 @@
26
25
  },
27
26
  "dependencies": {
28
27
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.168",
30
- "@dbx-tools/path": "0.6.168",
31
- "@dbx-tools/shared-core": "0.6.168",
28
+ "@dbx-tools/core": "0.6.174",
29
+ "@dbx-tools/path": "0.6.174",
30
+ "@dbx-tools/shared-core": "0.6.174",
32
31
  "commander": "^15.0.0",
33
32
  "concurrently": "^10.0.3",
34
33
  "constructs": "^10.6.0",
@@ -48,7 +47,7 @@
48
47
  },
49
48
  "main": "index.ts",
50
49
  "license": "Apache-2.0",
51
- "version": "0.6.168",
50
+ "version": "0.6.174",
52
51
  "types": "index.ts",
53
52
  "type": "module",
54
53
  "exports": {
package/src/barrels.ts CHANGED
@@ -16,6 +16,12 @@
16
16
  * takes priority. This keeps the barrel auto-generated while letting you add or
17
17
  * override individual exports.
18
18
  *
19
+ * A UniFFI source triplet (`bindings.ts`, `_bindings.ts`, and
20
+ * `_bindings-ffi.ts`) is one special case: the internal underscore modules stay
21
+ * private, while `bindings.ts` is re-exported directly instead of under a
22
+ * `bindings` namespace. Generation fails when one of those direct binding names
23
+ * conflicts with another top-level package export.
24
+ *
19
25
  * Each eligible module becomes `export * as <name> from "./src/x.ts"` (camelCase
20
26
  * namespace from its path segments; invalid identifiers suffixed with `Module`),
21
27
  * sorted by module path.
@@ -28,9 +34,10 @@
28
34
  * `export { ... }`. The module namespaces stay either way, so a namespaced call
29
35
  * site keeps working.
30
36
  *
31
- * Every generated barrel also exports `PACKAGE_IDENTIFIER` from the package's own
32
- * `package.json`. Runtime helpers can retain the package specifier without
33
- * trying to recover it from an ESM namespace object or loader function.
37
+ * Every generated barrel also exports `PACKAGE_IDENTIFIER` and
38
+ * `PACKAGE_VERSION` from the package's own `package.json`. Runtime helpers can
39
+ * retain package identity without trying to recover it from an ESM namespace
40
+ * object or loader function.
34
41
  *
35
42
  * Uniqueness is tallied over types and values TOGETHER: a name carried by two
36
43
  * modules is ambiguous whichever kind it is, and hoisting one module's value
@@ -107,10 +114,16 @@ const BARREL_HEADER: HeaderOpts = {
107
114
  source: "the exporting modules in ./src",
108
115
  };
109
116
 
110
- /** Generated package identity export, reserved against source-module hoisting. */
117
+ /** Generated package metadata exports, reserved against source-module hoisting. */
111
118
  const PACKAGE_IDENTIFIER_EXPORT = "PACKAGE_IDENTIFIER";
119
+ const PACKAGE_VERSION_EXPORT = "PACKAGE_VERSION";
112
120
  const PACKAGE_IDENTIFIER_LINE = `export const ${PACKAGE_IDENTIFIER_EXPORT} = "";`;
121
+ const PACKAGE_VERSION_LINE = `export const ${PACKAGE_VERSION_EXPORT} = "";`;
113
122
  const PACKAGE_IDENTIFIER_LINE_RE = /^export const PACKAGE_IDENTIFIER = .*;$/m;
123
+ const PACKAGE_VERSION_LINE_RE = /^export const PACKAGE_VERSION = .*;$/m;
124
+ const UNIFFI_BINDINGS_FILE = "bindings.ts";
125
+ const UNIFFI_GENERATED_FILE = "_bindings.ts";
126
+ const UNIFFI_FFI_FILE = "_bindings-ffi.ts";
114
127
 
115
128
  /** `pnpm-workspace` -> `pnpmWorkspace`; `local-fs` -> `localFS` (`fs` -> `FS`). */
116
129
  function kebabToCamel(segment: string): string {
@@ -266,8 +279,9 @@ function customExportNames(file: string): Set<string> {
266
279
  * `export * as`), then the whole module is re-exported last.
267
280
  */
268
281
  function mergeCustomExports(content: string, pkgDir: string): string {
269
- if (!existsSync(join(pkgDir, CUSTOM_EXPORTS_FILE))) return content;
270
- const overridden = customExportNames(join(pkgDir, CUSTOM_EXPORTS_FILE));
282
+ const customPath = join(pkgDir, CUSTOM_EXPORTS_FILE);
283
+ if (!existsSync(customPath) || isLegacyBindingsExport(customPath)) return content;
284
+ const overridden = customExportNames(customPath);
271
285
  const kept = content.split("\n").filter((line) => {
272
286
  const ns = /^export \* as (\w+) from /.exec(line)?.[1];
273
287
  return !(ns && overridden.has(ns));
@@ -275,29 +289,101 @@ function mergeCustomExports(content: string, pkgDir: string): string {
275
289
  return `${kept.join("\n").replace(/\n+$/, "")}\nexport * from "./exports.ts";\n`;
276
290
  }
277
291
 
278
- /** Read the authoritative npm package name emitted by the package project. */
279
- function packageIdentifier(pkgDir: string): string {
292
+ function isLegacyBindingsExport(file: string): boolean {
293
+ return readFileSync(file, "utf8").trim() === 'export * from "./src/bindings.ts";';
294
+ }
295
+
296
+ function hasUniFFIBindings(srcDir: string): boolean {
297
+ return [UNIFFI_BINDINGS_FILE, UNIFFI_GENERATED_FILE, UNIFFI_FFI_FILE].every((file) =>
298
+ existsSync(join(srcDir, file)),
299
+ );
300
+ }
301
+
302
+ function bindingExportNames(srcDir: string): Set<string> {
303
+ return new Set(
304
+ [UNIFFI_BINDINGS_FILE, UNIFFI_GENERATED_FILE].flatMap((file) =>
305
+ moduleExports(join(srcDir, file)).map((entry) => entry.name),
306
+ ),
307
+ );
308
+ }
309
+
310
+ function barrelExportNames(content: string, customPath: string): Set<string> {
311
+ const names = new Set<string>([PACKAGE_IDENTIFIER_EXPORT, PACKAGE_VERSION_EXPORT]);
312
+ for (const match of content.matchAll(/^export \* as (\w+) from /gm)) {
313
+ names.add(match[1]!);
314
+ }
315
+ for (const match of content.matchAll(/^export(?: type)? \{([^}]+)\} from /gm)) {
316
+ for (const specifier of match[1]!.split(",")) {
317
+ const parts = specifier.trim().split(/\s+as\s+/);
318
+ const name = parts.at(-1);
319
+ if (name) names.add(name);
320
+ }
321
+ }
322
+ if (existsSync(customPath) && !isLegacyBindingsExport(customPath)) {
323
+ const statements = moduleStatements(customPath) as ReadonlyArray<{
324
+ type: string;
325
+ exported?: unknown;
326
+ }>;
327
+ if (
328
+ statements.some(
329
+ (statement) => statement.type === "ExportAllDeclaration" && !statement.exported,
330
+ )
331
+ ) {
332
+ throw new Error(
333
+ `Cannot verify UniFFI export conflicts through a bare export in ${customPath}`,
334
+ );
335
+ }
336
+ for (const name of customExportNames(customPath)) names.add(name);
337
+ }
338
+ return names;
339
+ }
340
+
341
+ function assertNoBindingExportConflicts(srcDir: string, content: string, customPath: string): void {
342
+ const other = barrelExportNames(content, customPath);
343
+ const conflicts = [...bindingExportNames(srcDir)].filter((name) => other.has(name)).sort();
344
+ if (conflicts.length > 0) {
345
+ throw new Error(
346
+ `UniFFI binding exports conflict with package exports in ${srcDir}: ${conflicts.join(", ")}`,
347
+ );
348
+ }
349
+ }
350
+
351
+ /** Read the authoritative npm package name and version emitted by the package project. */
352
+ function packageMetadata(pkgDir: string): { identifier: string; version: string } {
280
353
  const manifestPath = join(pkgDir, "package.json");
281
- const name = existsSync(manifestPath)
282
- ? json.parseRecord(readFileSync(manifestPath, "utf8"))?.name
354
+ const manifest = existsSync(manifestPath)
355
+ ? json.parseRecord(readFileSync(manifestPath, "utf8"))
283
356
  : undefined;
357
+ const name = manifest?.name;
284
358
  if (typeof name !== "string" || !name.trim()) {
285
359
  throw new Error(`Cannot generate barrel without package.json name: ${manifestPath}`);
286
360
  }
287
- return name;
361
+ const version = manifest?.version;
362
+ if (typeof version !== "string" || !version.trim()) {
363
+ throw new Error(`Cannot generate barrel without package.json version: ${manifestPath}`);
364
+ }
365
+ return { identifier: name, version };
288
366
  }
289
367
 
290
- /** Normalize package identity so structural barrel comparisons need no manifest read. */
291
- function withoutPackageIdentifier(content: string): string {
292
- return content.replace(PACKAGE_IDENTIFIER_LINE_RE, PACKAGE_IDENTIFIER_LINE);
368
+ /** Normalize package metadata before comparing the barrel's export structure. */
369
+ function withoutPackageMetadata(content: string): string {
370
+ return content
371
+ .replace(PACKAGE_IDENTIFIER_LINE_RE, PACKAGE_IDENTIFIER_LINE)
372
+ .replace(PACKAGE_VERSION_LINE_RE, PACKAGE_VERSION_LINE);
293
373
  }
294
374
 
295
- /** Resolve and insert package identity only when a barrel is about to be written. */
296
- function withPackageIdentifier(content: string, pkgDir: string): string {
297
- const line = `export const ${PACKAGE_IDENTIFIER_EXPORT} = ${JSON.stringify(
298
- packageIdentifier(pkgDir),
299
- )};`;
300
- return content.replace(PACKAGE_IDENTIFIER_LINE_RE, () => line);
375
+ /** Resolve and insert package metadata when a barrel is about to be written. */
376
+ function withPackageMetadata(content: string, pkgDir: string): string {
377
+ const metadata = packageMetadata(pkgDir);
378
+ return content
379
+ .replace(
380
+ PACKAGE_IDENTIFIER_LINE_RE,
381
+ () => `export const ${PACKAGE_IDENTIFIER_EXPORT} = ${JSON.stringify(metadata.identifier)};`,
382
+ )
383
+ .replace(
384
+ PACKAGE_VERSION_LINE_RE,
385
+ () => `export const ${PACKAGE_VERSION_EXPORT} = ${JSON.stringify(metadata.version)};`,
386
+ );
301
387
  }
302
388
 
303
389
  /**
@@ -353,6 +439,10 @@ function generateForPackage(pkgDir: string): number {
353
439
  if (!existing || (!isSourceExt(existing) && isSourceExt(f))) byModulePath.set(stem, f);
354
440
  }
355
441
  const modulePaths = [...byModulePath.keys()].sort((a, b) => a.localeCompare(b));
442
+ const uniffiBindings = hasUniFFIBindings(srcDir);
443
+ const namespacedModulePaths = uniffiBindings
444
+ ? modulePaths.filter((modulePath) => modulePath !== "bindings")
445
+ : modulePaths;
356
446
 
357
447
  // No eligible modules -> no barrel: drop any stale root barrel and bail.
358
448
  if (modulePaths.length === 0) {
@@ -368,35 +458,45 @@ function generateForPackage(pkgDir: string): number {
368
458
  // extension is written because `tsc` rewrites it on emit
369
459
  // (`rewriteRelativeImportExtensions`) - an extensionless specifier would be
370
460
  // copied through verbatim and Node's ESM resolver cannot probe for it.
371
- const namespaceExports = modulePaths
461
+ const namespaceExports = namespacedModulePaths
372
462
  .map((stem) => {
373
463
  const modulePath = `./src/${byModulePath.get(stem)!}`;
374
464
  return `export * as ${modulePathToNamespace(modulePath)} from "${modulePath}";`;
375
465
  })
376
466
  .join("\n");
377
- let content = `${PACKAGE_IDENTIFIER_LINE}\n${namespaceExports}`;
467
+ let content = `${PACKAGE_IDENTIFIER_LINE}\n${PACKAGE_VERSION_LINE}\n${namespaceExports}`;
378
468
  // Hoist package-unique named exports to the top level. Names a hand-authored
379
469
  // `exports.ts` declares are suppressed so that file stays authoritative.
380
470
  const customPath = join(pkgDir, CUSTOM_EXPORTS_FILE);
381
- const suppress = existsSync(customPath) ? customExportNames(customPath) : new Set<string>();
471
+ const suppress =
472
+ existsSync(customPath) && !isLegacyBindingsExport(customPath)
473
+ ? customExportNames(customPath)
474
+ : new Set<string>();
382
475
  suppress.add(PACKAGE_IDENTIFIER_EXPORT);
476
+ suppress.add(PACKAGE_VERSION_EXPORT);
383
477
  content = hoistUniqueExports(content, pkgDir, suppress);
384
478
  // A sibling `exports.ts` overrides/extends the generated barrel and wins on conflict.
385
479
  content = mergeCustomExports(content, pkgDir);
480
+ if (uniffiBindings) {
481
+ assertNoBindingExportConflicts(srcDir, content, customPath);
482
+ content = content.replace(
483
+ PACKAGE_VERSION_LINE,
484
+ `${PACKAGE_VERSION_LINE}\nexport * from "./src/bindings.ts";`,
485
+ );
486
+ }
386
487
 
387
- // The barrel only *changes* when its export structure does. Package identity is
388
- // blanked on both sides of this comparison, so a no-op cycle never reads
389
- // package.json merely to reconstruct a value the existing barrel already has.
390
- // If the structural result matches what's on disk, leave the file - and its
391
- // read-only bit - completely untouched and report no change (0). This keeps the
392
- // watcher quiet on ordinary in-file edits (which leave the export * as … list
393
- // identical), and it is also what keeps a no-op cycle off the read-only bit
394
- // entirely: see {@link writeBarrel} for why unlocking a barrel we are not about
395
- // to rewrite is what produced spurious EACCES failures.
488
+ // Package metadata is blanked for the structural comparison, then restored
489
+ // from package.json so a version-only change still rewrites the barrel.
490
+ // If the structural result and package metadata match what's on disk, leave
491
+ // the file and its read-only bit untouched and report no change (0). This
492
+ // keeps the watcher quiet on ordinary in-file edits while allowing a version
493
+ // bump to update PACKAGE_VERSION.
396
494
  content = `${content.replace(/\n+$/, "")}\n`;
397
495
  const template = `${header(BARREL_HEADER)}\n${content}`;
398
- if (before !== undefined && withoutPackageIdentifier(before) === template) return 0;
399
- const next = withPackageIdentifier(template, pkgDir);
496
+ const next = withPackageMetadata(template, pkgDir);
497
+ if (before !== undefined && withoutPackageMetadata(before) === template && before === next) {
498
+ return 0;
499
+ }
400
500
 
401
501
  // Written whole (header included) rather than via `stampGenerated`, which would
402
502
  // re-read and rewrite the file to prepend the same header - a second write, and
package/src/project-py.ts CHANGED
@@ -7,8 +7,8 @@ import { GithubWorkflow } from "projen/lib/github";
7
7
  import { JobPermission } from "projen/lib/github/workflows-model";
8
8
  import { parse, stringify } from "smol-toml";
9
9
  import { projectReleaseBranch, projectRepositoryUrl } from "./project-js.ts";
10
- import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
11
10
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
11
+ import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
12
12
  import { DOWNSTREAM_RELEASE_EVENT, RELEASE_TAG, releaseSourceSteps } from "./release-dispatch.ts";
13
13
  import { readWorkspaceVersion } from "./workspace-version.ts";
14
14
 
@@ -29,8 +29,8 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
29
29
  /** Workspace package directories rendered as standalone Git dependencies. */
30
30
  readonly internalDependencies?: readonly string[];
31
31
  readonly scripts?: Readonly<Record<string, string>>;
32
- /** Keep this package unpublished and out of public docs and releases. */
33
- readonly private?: boolean;
32
+ /** Publish this package through the Rust UniFFI release flow instead of the Python workflow. */
33
+ readonly uniffi?: boolean;
34
34
  /** Generated source files excluded from strict static analysis. Package-relative. */
35
35
  readonly generatedSources?: readonly string[];
36
36
  /** Trusted publisher used outside the standard Python release workflow. */
@@ -75,6 +75,7 @@ interface PythonPublication {
75
75
  readonly environment: string;
76
76
  readonly workflowName: string;
77
77
  readonly artifacts?: string;
78
+ readonly dependencies?: readonly string[];
78
79
  }
79
80
 
80
81
  /** Options for {@link DBXToolsPythonWorkspace}. */
@@ -214,8 +215,8 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
214
215
  if (pkg.scripts) {
215
216
  this.uv.file.addOverride("project.scripts", pkg.scripts);
216
217
  }
217
- if (pkg.private) {
218
- this.uv.file.addOverride("tool.dbx-tools.private", true);
218
+ if (pkg.uniffi !== undefined) {
219
+ this.uv.file.addOverride("tool.dbx_tools.config.uniffi", pkg.uniffi);
219
220
  }
220
221
  formatPyproject(this.uv.file);
221
222
  this.uv.file.readonly = true;
@@ -319,7 +320,7 @@ export class DBXToolsPythonWorkspace extends Component {
319
320
  projectVscode(project)?.settings.addSetting("python.defaultInterpreterPath", interpreterPath);
320
321
  }
321
322
 
322
- if (options.release && this.packages.some((pkg) => !pkg.packageOptions.private)) {
323
+ if (options.release && this.packages.length > 0) {
323
324
  if (isDBXToolsJavaScriptProject()(project)) {
324
325
  project.dbxToolsConfig.pythonReleaseWorkflow =
325
326
  releaseOptions.workflowName ?? "python-release";
@@ -428,7 +429,10 @@ export class DBXToolsPythonWorkspace extends Component {
428
429
  private addReleaseWorkflow(project: javascript.NodeProject, options: PythonReleaseOptions): void {
429
430
  if (!project.github) return;
430
431
  const publications = this.publications(options);
431
- if (publications.length === 0) return;
432
+ const uniffiPublications = this.uniffiPublications(options);
433
+ const allPublications = [...publications, ...uniffiPublications];
434
+ if (allPublications.length === 0) return;
435
+ const usesRustArtifacts = uniffiPublications.length > 0;
432
436
  const workflowName = options.workflowName ?? "python-release";
433
437
  const workflow = new GithubWorkflow(project.github, workflowName, {
434
438
  limitConcurrency: true,
@@ -436,7 +440,7 @@ export class DBXToolsPythonWorkspace extends Component {
436
440
  });
437
441
  workflow.file?.addOverride("permissions", {
438
442
  contents: "read",
439
- ...(options.upstreamWorkflow ? { actions: "read" } : {}),
443
+ ...(options.upstreamWorkflow || usesRustArtifacts ? { actions: "read" } : {}),
440
444
  });
441
445
  const upstreamWorkflow = options.upstreamWorkflow;
442
446
  const branchDispatch = upstreamWorkflow === undefined && isDBXToolsJavaScriptProject()(project);
@@ -487,7 +491,7 @@ export class DBXToolsPythonWorkspace extends Component {
487
491
  runsOn: ["ubuntu-latest"],
488
492
  permissions: {
489
493
  contents: JobPermission.READ,
490
- ...(upstreamWorkflow ? { actions: JobPermission.READ } : {}),
494
+ ...(upstreamWorkflow || usesRustArtifacts ? { actions: JobPermission.READ } : {}),
491
495
  },
492
496
  timeoutMinutes: 20,
493
497
  steps: [
@@ -555,6 +559,35 @@ export class DBXToolsPythonWorkspace extends Component {
555
559
  ]
556
560
  : []),
557
561
  { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
562
+ ...(usesRustArtifacts
563
+ ? [
564
+ {
565
+ name: "Require Rust artifact handoff",
566
+ if: "${{ github.event_name == 'repository_dispatch' || github.event_name == 'workflow_run' }}",
567
+ shell: "bash",
568
+ env: {
569
+ RUST_RUN_ID:
570
+ "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.rust_run_id || github.event.workflow_run.id }}",
571
+ RUST_RUN_ATTEMPT:
572
+ "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.rust_run_attempt || github.event.workflow_run.run_attempt }}",
573
+ },
574
+ run: ['test -n "$RUST_RUN_ID"', 'test -n "$RUST_RUN_ATTEMPT"'].join("\n"),
575
+ },
576
+ ]
577
+ : []),
578
+ ...uniffiPublications.map((publication) => ({
579
+ name: `Download ${publication.distribution} native wheels`,
580
+ if: "${{ github.event_name == 'repository_dispatch' || github.event_name == 'workflow_run' }}",
581
+ uses: "actions/download-artifact@v8",
582
+ with: {
583
+ pattern: `${publication.distribution}--*--python-wheel`,
584
+ path: `dist/${publication.directory}`,
585
+ "merge-multiple": true,
586
+ "run-id":
587
+ "${{ github.event_name == 'repository_dispatch' && github.event.client_payload.rust_run_id || github.event.workflow_run.id }}",
588
+ "github-token": "${{ github.token }}",
589
+ },
590
+ })),
558
591
  {
559
592
  name: "Stamp workspace versions",
560
593
  env: {
@@ -594,8 +627,15 @@ export class DBXToolsPythonWorkspace extends Component {
594
627
  {
595
628
  name: "Validate distributions",
596
629
  run: [
597
- `test "$(find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) | wc -l | tr -d ' ')" -eq ${publications.length * 2}`,
598
- "uvx twine check dist/*/*.whl dist/*/*.tar.gz",
630
+ ...publications.map(
631
+ (publication) =>
632
+ `test "$(find dist/${publication.directory} -maxdepth 1 -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) | wc -l | tr -d ' ')" -eq 2`,
633
+ ),
634
+ ...uniffiPublications.map(
635
+ (publication) =>
636
+ `if [ "$GITHUB_EVENT_NAME" = "repository_dispatch" ] || [ "$GITHUB_EVENT_NAME" = "workflow_run" ]; then find dist/${publication.directory} -maxdepth 1 -name '*.whl' -print -quit | grep -q .; fi`,
637
+ ),
638
+ "find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) -print0 | xargs -0 uvx twine check",
599
639
  ].join("\n"),
600
640
  },
601
641
  {
@@ -610,12 +650,15 @@ export class DBXToolsPythonWorkspace extends Component {
610
650
  },
611
651
  ],
612
652
  });
613
- for (const publication of publications) {
653
+ for (const publication of allPublications) {
614
654
  workflow.addJob(`publish-${publication.directory}`, {
615
655
  if: branchDispatch
616
656
  ? "${{ github.event_name == 'repository_dispatch' }}"
617
657
  : "${{ github.event_name == 'push' || github.event_name == 'workflow_run' }}",
618
- needs: ["build"],
658
+ needs: [
659
+ "build",
660
+ ...(publication.dependencies ?? []).map((dependency) => `publish-${dependency}`),
661
+ ],
619
662
  environment: {
620
663
  name: publication.environment,
621
664
  url:
@@ -643,7 +686,7 @@ export class DBXToolsPythonWorkspace extends Component {
643
686
 
644
687
  private publications(options: PythonReleaseOptions): readonly PythonPublication[] {
645
688
  return this.packages
646
- .filter((pkg) => !pkg.packageOptions.private)
689
+ .filter((pkg) => pkg.packageOptions.uniffi !== true)
647
690
  .map((pkg) => ({
648
691
  directory: pkg.packageOptions.directory,
649
692
  distribution: pkg.packageOptions.name,
@@ -653,6 +696,22 @@ export class DBXToolsPythonWorkspace extends Component {
653
696
  }));
654
697
  }
655
698
 
699
+ private uniffiPublications(options: PythonReleaseOptions): readonly PythonPublication[] {
700
+ return this.packages
701
+ .filter((pkg) => pkg.packageOptions.uniffi === true)
702
+ .map((pkg) => ({
703
+ directory: pkg.packageOptions.directory,
704
+ distribution: pkg.packageOptions.name,
705
+ environment:
706
+ pkg.packageOptions.trustedPublisher?.environment ??
707
+ options.environments?.[pkg.packageOptions.name] ??
708
+ `pypi-${pkg.packageOptions.name}`,
709
+ workflowName: options.workflowName ?? "python-release",
710
+ artifacts: pkg.packageOptions.trustedPublisher?.artifacts,
711
+ dependencies: pkg.packageOptions.internalDependencies,
712
+ }));
713
+ }
714
+
656
715
  private trustedPublisherPublications(
657
716
  options: PythonReleaseOptions,
658
717
  ): readonly PythonPublication[] {