@dbx-tools/projen 0.6.163 → 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/src/release.ts CHANGED
@@ -6,11 +6,16 @@
6
6
  */
7
7
  import { existsSync, rmSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
+ import { object } from "@dbx-tools/shared-core";
9
10
  import { Component, YamlFile } from "projen";
10
11
  import { GithubWorkflow } from "projen/lib/github";
11
12
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
12
- import { object } from "@dbx-tools/shared-core";
13
13
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
14
+ import {
15
+ orderRustBindings,
16
+ type RustBindingMapping,
17
+ type RustWorkspaceMapping,
18
+ } from "./project-rs.ts";
14
19
  import { applyTasks, taskScript, type DBXToolsNodeProject } from "./project.ts";
15
20
  import {
16
21
  DOWNSTREAM_RELEASE_EVENT,
@@ -70,6 +75,7 @@ interface PublishWorkflow {
70
75
  readonly steps: readonly JobStep[];
71
76
  readonly workingDirectory?: string;
72
77
  readonly upstreamWorkflow?: string;
78
+ readonly rustArtifacts?: boolean;
73
79
  }
74
80
 
75
81
  /** Shared checkout and toolchain setup for every npm publish workflow. */
@@ -300,7 +306,7 @@ export class DBXToolsRelease extends Component {
300
306
  /** Author the common tag trigger, concurrency policy, permissions, and publish job. */
301
307
  private authorPublishWorkflow(
302
308
  project: DBXToolsNodeProject,
303
- { name, tagPrefix, steps, workingDirectory, upstreamWorkflow }: PublishWorkflow,
309
+ { name, tagPrefix, steps, workingDirectory, upstreamWorkflow, rustArtifacts }: PublishWorkflow,
304
310
  ): void {
305
311
  const setupSteps = publishSetupSteps(project);
306
312
  const branchDispatch = upstreamWorkflow === undefined;
@@ -314,7 +320,7 @@ export class DBXToolsRelease extends Component {
314
320
  // the publish job below overrides it with the `id-token` it needs.
315
321
  workflow.file?.addOverride("permissions", {
316
322
  contents: "read",
317
- ...(upstreamWorkflow ? { actions: "read" } : {}),
323
+ ...(upstreamWorkflow || rustArtifacts ? { actions: "read" } : {}),
318
324
  });
319
325
  if (upstreamWorkflow) {
320
326
  workflow.file?.addOverride("on.workflow_run", {
@@ -362,7 +368,7 @@ export class DBXToolsRelease extends Component {
362
368
  runsOn: ["ubuntu-latest"],
363
369
  // `id-token: write` lets npm mint the OIDC token for provenance attestation.
364
370
  permissions: {
365
- ...(upstreamWorkflow ? { actions: JobPermission.READ } : {}),
371
+ ...(upstreamWorkflow || rustArtifacts ? { actions: JobPermission.READ } : {}),
366
372
  contents: JobPermission.READ,
367
373
  idToken: JobPermission.WRITE,
368
374
  },
@@ -483,14 +489,112 @@ export class DBXToolsRelease extends Component {
483
489
  * version on every package first makes the pushed tag the published version
484
490
  * (no bump math).
485
491
  */
492
+ private nodeBindingReleaseSteps(project: DBXToolsNodeProject): {
493
+ before: JobStep[];
494
+ after: JobStep[];
495
+ } {
496
+ const config = project.dbxToolsConfig.rust;
497
+ if (!object.isRecord(config) || !Array.isArray(config.bindings)) {
498
+ return { before: [], after: [] };
499
+ }
500
+ const bindings = orderRustBindings(config.bindings as RustWorkspaceMapping["bindings"]).filter(
501
+ (binding): binding is RustBindingMapping & { node: string; nodePackage: string } =>
502
+ Boolean(binding.node && binding.nodePackage),
503
+ );
504
+ if (bindings.length === 0) return { before: [], after: [] };
505
+
506
+ const before: JobStep[] = [
507
+ {
508
+ name: "Require Rust artifact handoff",
509
+ if: "${{ github.event_name == 'repository_dispatch' }}",
510
+ shell: "bash",
511
+ env: {
512
+ RUST_RUN_ID: "${{ github.event.client_payload.rust_run_id }}",
513
+ RUST_RUN_ATTEMPT: "${{ github.event.client_payload.rust_run_attempt }}",
514
+ },
515
+ run: ['test -n "$RUST_RUN_ID"', 'test -n "$RUST_RUN_ATTEMPT"'].join("\n"),
516
+ },
517
+ {
518
+ name: "Download native npm packages",
519
+ if: "${{ github.event_name == 'repository_dispatch' }}",
520
+ uses: "actions/download-artifact@v8",
521
+ with: {
522
+ pattern: "*-npm",
523
+ path: "dist/uniffi/native",
524
+ "merge-multiple": true,
525
+ "run-id": "${{ github.event.client_payload.rust_run_id }}",
526
+ "github-token": "${{ github.token }}",
527
+ },
528
+ },
529
+ {
530
+ name: "Publish native npm packages",
531
+ if: "${{ github.event_name == 'repository_dispatch' }}",
532
+ env: {
533
+ NPM_CONFIG_PROVENANCE: "true",
534
+ NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
535
+ NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
536
+ },
537
+ run: [
538
+ "test \"$(find dist/uniffi/native -name '*.tgz' | wc -l | tr -d ' ')\" -gt 0",
539
+ 'for package in dist/uniffi/native/*.tgz; do npm publish "$package" --access public; done',
540
+ ].join("\n"),
541
+ },
542
+ {
543
+ name: "Refresh workspace after native publication",
544
+ if: "${{ github.event_name == 'repository_dispatch' }}",
545
+ run: "bun install --force",
546
+ },
547
+ ];
548
+
549
+ const facadeCommands = [
550
+ 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
551
+ ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
552
+ " DRY_RUN=--dry-run",
553
+ "else",
554
+ ' VERSION="$RELEASE_VERSION"',
555
+ " DRY_RUN=",
556
+ "fi",
557
+ ...bindings.flatMap((binding) => {
558
+ const output = `dist/uniffi/facades/${binding.crate}`;
559
+ return [
560
+ `NATIVE_ARG=""`,
561
+ 'if [ "$GITHUB_EVENT_NAME" = "repository_dispatch" ]; then',
562
+ ` NATIVE_PACKAGE="$(find dist/uniffi/native -name '${binding.crate}-linux-x64-gnu-*.tgz' -print -quit)"`,
563
+ ' test -n "$NATIVE_PACKAGE"',
564
+ ' NATIVE_ARG="--native-package $NATIVE_PACKAGE"',
565
+ "fi",
566
+ `node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$VERSION" --output "${output}" $NATIVE_ARG`,
567
+ `for package in ${output}/npm-facade/*.tgz; do npm publish "$package" --access public $DRY_RUN; done`,
568
+ ];
569
+ }),
570
+ ];
571
+ return {
572
+ before,
573
+ after: [
574
+ {
575
+ name: "Build and publish UniFFI npm facades",
576
+ env: {
577
+ NPM_CONFIG_PROVENANCE: "true",
578
+ NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
579
+ NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
580
+ },
581
+ run: facadeCommands.join("\n"),
582
+ },
583
+ ],
584
+ };
585
+ }
586
+
486
587
  private authorReleaseWorkflow(project: DBXToolsNodeProject): void {
487
588
  if (!this.workflowName) return;
488
589
  if (this.workflowName !== "release") project.tryRemoveFile(".github/workflows/release.yml");
590
+ const bindingSteps = this.nodeBindingReleaseSteps(project);
489
591
  this.authorPublishWorkflow(project, {
490
592
  name: this.workflowName,
491
593
  tagPrefix: this.tagPrefix,
492
594
  upstreamWorkflow: this.upstreamWorkflow,
595
+ rustArtifacts: bindingSteps.before.length > 0,
493
596
  steps: [
597
+ ...bindingSteps.before,
494
598
  // The pushed tag is the version: `<prefix>1.2.3` -> `1.2.3`. Stamp it on
495
599
  // every workspace package (manifests are projen-readonly, so unlock
496
600
  // first), rewriting `workspace:*` sibling deps to `^<version>` so the
@@ -512,6 +616,7 @@ export class DBXToolsRelease extends Component {
512
616
  NPM_CONFIG_PROVENANCE: "true",
513
617
  },
514
618
  },
619
+ ...bindingSteps.after,
515
620
  ],
516
621
  });
517
622
  }
package/src/uniffi.ts CHANGED
@@ -36,6 +36,14 @@ export interface TypeScriptBindingModule {
36
36
  readonly source: string;
37
37
  }
38
38
 
39
+ /** Add source extensions to UBRN's generated relative binding imports. */
40
+ export const addTypeScriptExtensionsToBindingImports = (source: string): string =>
41
+ source.replace(
42
+ /(["'])\.\/_bindings(-ffi)?\1/g,
43
+ (_specifier, quote: string, suffix: string | undefined) =>
44
+ `${quote}./_bindings${suffix ?? ""}.ts${quote}`,
45
+ );
46
+
39
47
  /** Add explicit type exports for interfaces that TypeScript misses through UBRN's star exports. */
40
48
  export const addExplicitInterfaceReexports = (
41
49
  facade: string,
@@ -48,3 +56,40 @@ export const addExplicitInterfaceReexports = (
48
56
  if (exports.length === 0) return facade;
49
57
  return `${facade.trimEnd()}\n${exports.join("\n")}\n`;
50
58
  };
59
+
60
+ const GENERATED_BINDINGS_HEADER = "GENERATED by UniFFI binding generation";
61
+
62
+ function pythonAllNames(source: string, file: string): string[] {
63
+ const body = /^__all__\s*=\s*\[([\s\S]*?)\]/m.exec(source)?.[1];
64
+ if (body === undefined) {
65
+ throw new Error(`Python exports must define a literal __all__ list: ${file}`);
66
+ }
67
+ return [...body.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]!);
68
+ }
69
+
70
+ /** Generate one package root from the binding module's explicit exports. */
71
+ export function mergePythonBindingExports(
72
+ initSource: string,
73
+ bindingsSource: string,
74
+ options: { readonly crate: string; readonly file: string },
75
+ ): string {
76
+ if (initSource.trim() && !initSource.includes(GENERATED_BINDINGS_HEADER)) {
77
+ throw new Error(`Cannot replace non-generated Python package exports: ${options.file}`);
78
+ }
79
+ const bindingNames = pythonAllNames(bindingsSource, options.file);
80
+ const publicNames = [...bindingNames].sort((left, right) =>
81
+ left < right ? -1 : left > right ? 1 : 0,
82
+ );
83
+ return [
84
+ "# GENERATED by UniFFI binding generation - DO NOT EDIT.",
85
+ `# Regenerated from the ${options.crate} Rust exports.`,
86
+ "# Hand edits are overwritten on the next watch; this file is read-only.",
87
+ "",
88
+ "from .bindings import *",
89
+ "",
90
+ "__all__ = [",
91
+ ...publicNames.map((name) => ` ${JSON.stringify(name)},`),
92
+ "]",
93
+ "",
94
+ ].join("\n");
95
+ }
package/tasks/bump.ts CHANGED
@@ -135,8 +135,8 @@ function assertReleaseTagsPointToHead(tags: readonly string[]): void {
135
135
  * (not-yet-published) version into the COMMITTED manifest breaks the release
136
136
  * workflow's initial `bun install`, which checks out that commit before anything
137
137
  * is published. Sibling versions are resolved transiently at publish time instead:
138
- * `projen-release` runs `tasks/publish.ts --stamp-only` (set versions + refresh
139
- * lockfile), then `bun publish` in `projen/` strips `workspace:*` to those versions.
138
+ * The bump stamps every workspace member before committing so independently
139
+ * synthesized members and the Bun lock resolve the same release version.
140
140
  */
141
141
  function writeManifestVersion(pkgPath: string, version: string): void {
142
142
  const { mode } = statSync(pkgPath);
@@ -306,6 +306,16 @@ program
306
306
  },
307
307
  });
308
308
  }
309
+ if (opts.version) {
310
+ const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
311
+ exec.spawnSync("bun", [publishScript, version, "--stamp-only"], {
312
+ cwd: process.cwd(),
313
+ stdout: "inherit",
314
+ stderr: "inherit",
315
+ stdin: "ignore",
316
+ check: true,
317
+ });
318
+ }
309
319
 
310
320
  if (opts.commit) {
311
321
  // Stage the whole tree so the release commit captures the version bump
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env -S bun
2
+ import { spawnSync } from "node:child_process";
2
3
  import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
4
  import { arch, platform } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { parseArgs } from "node:util";
7
- import { spawnSync } from "node:child_process";
8
8
  import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
9
9
  import type { RustBindingMapping, RustWorkspaceMapping } from "../src/project-rs.ts";
10
10
 
@@ -155,8 +155,6 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
155
155
  includePython ? binding.python! : "",
156
156
  "--node-package",
157
157
  includeNode ? binding.nodePackage! : "",
158
- "--node-generator",
159
- "projen/tasks/uniffi.ts",
160
158
  "--python-package",
161
159
  includePython ? binding.pythonPackage! : "",
162
160
  "--cargo-target",
@@ -184,8 +182,9 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
184
182
  ...artifacts(join(output, "npm"), ".tgz"),
185
183
  ...artifacts(join(output, "npm-facade"), ".tgz"),
186
184
  ];
187
- for (const packageFile of packages)
185
+ for (const packageFile of packages) {
188
186
  run("npm", ["publish", packageFile, "--registry", registry!]);
187
+ }
189
188
  }
190
189
  if (includePython) {
191
190
  const wheels = artifacts(join(output, "python"), ".whl");
package/tasks/publish.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]`
4
4
  * - ensure every workspace member and the Bun lock carry the release version,
5
- * then publish each non-private one with `bun publish`.
5
+ * then publish each package owned by the standard Node release with `bun publish`.
6
6
  *
7
7
  * Bun has no `pnpm -r publish`, so this loop is the recursive-publish stand-in.
8
8
  * It leans on native bun for everything bun already does:
@@ -41,8 +41,7 @@
41
41
  * but uploads nothing, so the `release` workflow is testable end-to-end via a
42
42
  * `workflow_dispatch` run without anything reaching npm. `--registry` targets a
43
43
  * non-default registry (a local verdaccio); `--exclude <dir>` (repeatable,
44
- * repo-relative) skips a member that releases on its OWN tag namespace (e.g.
45
- * `projen`, published by `projen-release`, not the main `release`).
44
+ * repo-relative) skips a member owned by another publication flow.
46
45
  *
47
46
  * `--no-restore` keeps the edits on disk instead of undoing them at exit. Only
48
47
  * needed when a LATER process must still see them - `--stamp-only` implies it,
@@ -140,7 +139,8 @@ function lockfileMatchesVersion(
140
139
 
141
140
  /** Spawn `command` in `cwd` with `PATH` overridden, failing the task on non-zero. */
142
141
  function run(cwd: string, command: string, args: string[], path: string): void {
143
- exec.spawnSync(command, args, {
142
+ const executable = command === "bun" && process.versions.bun ? process.execPath : command;
143
+ exec.spawnSync(executable, args, {
144
144
  cwd,
145
145
  stdout: "inherit",
146
146
  stderr: "inherit",
@@ -152,7 +152,8 @@ function run(cwd: string, command: string, args: string[], path: string): void {
152
152
 
153
153
  /** Asynchronous counterpart used for bounded parallel stamping and publishing. */
154
154
  async function runAsync(cwd: string, command: string, args: string[], path: string): Promise<void> {
155
- await exec.spawn(command, args, {
155
+ const executable = command === "bun" && process.versions.bun ? process.execPath : command;
156
+ await exec.spawn(executable, args, {
156
157
  cwd,
157
158
  stdout: "inherit",
158
159
  stderr: "inherit",
@@ -274,9 +275,8 @@ if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
274
275
  }
275
276
  const concurrency = parsedConcurrency;
276
277
  // `--stamp-only`: set versions + refresh the lockfile, then STOP (no publish).
277
- // The standalone `projen-release` uses this to version-stamp the workspace so its
278
- // own `bun publish` (run separately, in `projen/`) resolves `workspace:*` siblings
279
- // to the release version; publishing every member here would double-publish them.
278
+ // A separate package command can use this when its own publish process needs
279
+ // workspace sibling versions resolved before it starts.
280
280
  const stampOnly = rest.includes("--stamp-only");
281
281
  // Undo the manifest edits at exit unless a later process still needs them. Implied
282
282
  // off by `--stamp-only`, whose stamps exist precisely for a subsequent `bun publish`.
@@ -338,17 +338,17 @@ for (const dir of members) {
338
338
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
339
339
  name?: string;
340
340
  private?: boolean;
341
+ dbxToolsConfig?: { uniffi?: boolean };
341
342
  scripts?: Record<string, string>;
342
343
  };
343
344
  if (pkg.private) {
344
345
  logger.info(`skip private ${pkg.name ?? dirname(dir)}`);
345
346
  continue;
346
347
  }
347
- // bun won't fold publishConfig into the packed manifest, so do it ourselves -
348
- // otherwise the tarball's `bin`/`main`/`exports` stay pointed at `.ts` source.
349
- const manifestPath = join(dir, "package.json");
350
- unlockManifest(manifestPath);
351
- applyPublishConfig(manifestPath);
348
+ if (pkg.dbxToolsConfig?.uniffi === true) {
349
+ logger.info(`skip UniFFI ${pkg.name ?? dirname(dir)}`);
350
+ continue;
351
+ }
352
352
  publishable.push({
353
353
  dir,
354
354
  name: pkg.name ?? dirname(dir),
@@ -362,6 +362,15 @@ if (compiled.length > 0) {
362
362
  run(root, "bun", ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"], path);
363
363
  }
364
364
 
365
+ // Compile against workspace source exports first. Switching manifests to their
366
+ // publishConfig entries before compilation makes consumers resolve sibling
367
+ // `lib/*.d.ts` files that have not been emitted yet in a clean checkout.
368
+ for (const { dir } of publishable) {
369
+ const manifestPath = join(dir, "package.json");
370
+ unlockManifest(manifestPath);
371
+ applyPublishConfig(manifestPath);
372
+ }
373
+
365
374
  logger.info(
366
375
  `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
367
376
  );
package/tasks/rust.ts CHANGED
@@ -8,6 +8,7 @@ import { readDbxToolsConfig, repoRoot } from "../src/packages.ts";
8
8
  import {
9
9
  discoverRustCrates,
10
10
  hasUniFFIBindings,
11
+ orderRustBindings,
11
12
  type RustBindingMapping,
12
13
  type RustWorkspaceMapping,
13
14
  } from "../src/project-rs.ts";
@@ -84,6 +85,19 @@ function generate(binding: RustBindingMapping): void {
84
85
  if (result.status !== 0) throw new Error(`binding generation exited with ${result.status}`);
85
86
  }
86
87
 
88
+ export function affectedRustBindings(
89
+ bindings: readonly RustBindingMapping[],
90
+ changed: ReadonlySet<string>,
91
+ ): RustBindingMapping[] {
92
+ const affected = new Set(changed);
93
+ const ordered = orderRustBindings(bindings);
94
+ for (const binding of ordered) {
95
+ if (binding.dependencies?.some((dependency) => affected.has(dependency)))
96
+ affected.add(binding.crate);
97
+ }
98
+ return ordered.filter((binding) => affected.has(binding.crate));
99
+ }
100
+
87
101
  const config = rustConfig();
88
102
 
89
103
  async function main(): Promise<void> {
@@ -92,7 +106,7 @@ async function main(): Promise<void> {
92
106
  throw new Error("Cargo is required because Rust projects were detected");
93
107
  }
94
108
  if (!process.argv.includes("--watch")) {
95
- for (const binding of config.bindings) generate(binding);
109
+ for (const binding of orderRustBindings(config.bindings)) generate(binding);
96
110
  return;
97
111
  }
98
112
 
@@ -109,7 +123,7 @@ async function main(): Promise<void> {
109
123
  const binding = ownerBinding(path, refreshed.bindings);
110
124
  if (binding) targets.set(binding.crate, binding);
111
125
  }
112
- for (const binding of targets.values()) {
126
+ for (const binding of affectedRustBindings(refreshed.bindings, new Set(targets.keys()))) {
113
127
  logger.start(`generating ${binding.crate} bindings`);
114
128
  generate(binding);
115
129
  logger.success(`generated ${binding.crate} bindings`);
@@ -121,7 +135,7 @@ async function main(): Promise<void> {
121
135
  const binding = ownerBinding(path, latest.bindings);
122
136
  if (binding) targets.set(binding.crate, binding);
123
137
  }
124
- for (const binding of targets.values()) {
138
+ for (const binding of affectedRustBindings(latest.bindings, new Set(targets.keys()))) {
125
139
  logger.start(`generating ${binding.crate} bindings`);
126
140
  generate(binding);
127
141
  logger.success(`generated ${binding.crate} bindings`);