@dbx-tools/projen 0.6.210 → 0.6.212

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
@@ -5,9 +5,14 @@ import { fileURLToPath } from "node:url";
5
5
  import { exec, project as coreProject } from "@dbx-tools/core";
6
6
  import { string } from "@dbx-tools/shared-core";
7
7
  import { Component, Project, TextFile, javascript } from "projen";
8
- import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
8
+ import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
9
9
  import { BUN_VERSION } from "./bun-workflow.ts";
10
- import { DBXToolsTypeScriptProject, projectRepositoryUrl } from "./project-js.ts";
10
+ import {
11
+ DBX_TOOLS_LICENSE,
12
+ type DBXToolsJavaScriptProject,
13
+ DBXToolsTypeScriptProject,
14
+ projectRepositoryUrl,
15
+ } from "./project-js.ts";
11
16
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
12
17
  import { pythonModuleName, type PythonPackageOptions } from "./project-py.ts";
13
18
  import type { DBXToolsProject } from "./project.ts";
@@ -180,7 +185,7 @@ const RUST_CACHE_ENV = {
180
185
  CARGO_TERM_COLOR: "always",
181
186
  } as const;
182
187
 
183
- function rustCacheSteps(sharedKey: string): readonly Record<string, unknown>[] {
188
+ function rustCacheSteps(sharedKey: string): readonly JobStep[] {
184
189
  return [
185
190
  {
186
191
  name: "Cache Cargo registry and dependencies",
@@ -531,674 +536,960 @@ function rustPackageDependencies(
531
536
  );
532
537
  }
533
538
 
534
- /** Generated Rust workspace plus convention-derived UniFFI binding packages. */
535
- export class DBXToolsRustWorkspace {
536
- readonly packages: readonly DBXToolsRustProject[];
537
- readonly nodePackages: readonly DBXToolsTypeScriptProject[];
538
- readonly pythonPackages: readonly PythonPackageOptions[];
539
- readonly bindingMappings: readonly RustBindingMapping[];
540
- readonly releaseBinaries: readonly RustReleaseBinaryMapping[];
541
- readonly workspaceMapping: RustWorkspaceMapping;
539
+ interface ResolvedRustWorkspaceOptions {
540
+ readonly root: string;
541
+ readonly nodeRoot: string;
542
+ readonly pythonRoot: string;
543
+ readonly scope: string;
544
+ readonly repository: string;
545
+ readonly nativeTargets: readonly UniFFIReleaseTarget[];
546
+ readonly pythonModulePrefix: string;
547
+ readonly packageOptions: Readonly<Record<string, Omit<RustPackageOptions, "directory">>>;
548
+ readonly release: boolean;
549
+ }
542
550
 
543
- constructor(project: javascript.NodeProject, options: DBXToolsRustWorkspaceOptions) {
544
- const root = options.root ?? "packages/rs";
545
- const nodeRoot = options.nodeRoot ?? "packages/js/node";
546
- const dbxToolsProject = isDBXToolsJavaScriptProject()(project) ? project : undefined;
547
- const scope = string.toSlug(options.scope ?? dbxToolsProject?.scope ?? project.name);
548
- const repository =
551
+ function resolveRustWorkspaceOptions(
552
+ project: javascript.NodeProject,
553
+ options: DBXToolsRustWorkspaceOptions,
554
+ ): ResolvedRustWorkspaceOptions {
555
+ const dbxToolsProject = isDBXToolsJavaScriptProject()(project) ? project : undefined;
556
+ const scope = string.toSlug(options.scope ?? dbxToolsProject?.scope ?? project.name);
557
+ return {
558
+ root: options.root ?? "packages/rs",
559
+ nodeRoot: options.nodeRoot ?? "packages/js/node",
560
+ pythonRoot: options.pythonRoot ?? "packages/py",
561
+ scope,
562
+ repository:
549
563
  options.repository ??
550
564
  projectRepositoryUrl(project) ??
551
565
  coreProject.repositoryUrl(project.outdir) ??
552
- "";
553
- const nativeTargets = releaseTargets(options);
554
- const pythonModulePrefix = options.pythonModulePrefix ?? scope.replaceAll("-", "_");
555
- const packageOptions = options.packages ?? {};
556
- this.packages = discoverRustCrates(resolve(project.outdir, root)).map(
557
- (directory) =>
558
- new DBXToolsRustProject(project, root, scope, {
559
- directory,
560
- ...packageOptions[directory],
561
- }),
562
- );
563
- const commands = new Set<string>();
564
- this.releaseBinaries = this.packages
565
- .filter((pkg) => Boolean(pkg.packageOptions.cli))
566
- .map((pkg) => {
567
- if (!pkg.packageOptions.release) {
568
- throw new Error(`${pkg.crateName} must set release when cli is configured`);
569
- }
570
- const configured = pkg.packageOptions.cli;
571
- const command =
572
- typeof configured === "object" && configured.command
573
- ? configured.command
574
- : pkg.packageOptions.directory;
575
- if (!/^[a-z0-9][a-z0-9-]*$/.test(command)) {
576
- throw new Error(`Invalid Rust CLI command: ${command}`);
577
- }
578
- if (commands.has(command)) {
579
- throw new Error(`Duplicate Rust CLI command: ${command}`);
580
- }
581
- commands.add(command);
582
- const description =
583
- (typeof configured === "object" ? configured.description : undefined) ??
584
- pkg.packageOptions.description ??
585
- pkg.crateName;
586
- const excludedOs = new Set(pkg.packageOptions.releaseExcludeOs ?? []);
587
- const binaryName = pkg.packageOptions.binaryName ?? pkg.crateName;
588
- return {
589
- command,
590
- description,
591
- binaryName,
592
- repository,
593
- assets: nativeTargets
594
- .filter((target) => !excludedOs.has(target.os))
595
- .map((target) => ({
596
- os: target.os,
597
- cpu: target.cpu,
598
- name: releaseBinaryAssetName(binaryName, target.node, target.os),
599
- })),
600
- };
601
- });
602
- if (this.releaseBinaries.length && !repository) {
603
- throw new Error("A repository URL is required when Rust CLI commands are configured");
604
- }
605
- if (this.releaseBinaries.length && !options.cliRegistryPath) {
606
- throw new Error("cliRegistryPath is required when Rust CLI commands are configured");
607
- }
608
- if (options.cliRegistryPath) {
609
- new TextFile(project, options.cliRegistryPath, {
610
- lines: rustCliRegistrySource(this.releaseBinaries).trimEnd().split("\n"),
611
- });
612
- }
566
+ "",
567
+ nativeTargets: releaseTargets(options),
568
+ pythonModulePrefix: options.pythonModulePrefix ?? scope.replaceAll("-", "_"),
569
+ packageOptions: options.packages ?? {},
570
+ release: options.release ?? true,
571
+ };
572
+ }
613
573
 
614
- const bindings = this.packages.filter((pkg) => pkg.uniffi);
615
- const packageDependencies = (pkg: DBXToolsRustProject) =>
616
- rustPackageDependencies(this.packages, pkg, project.outdir, options.workspaceDependencies);
617
- for (const pkg of this.packages) {
618
- const excludedOs = new Set(pkg.packageOptions.releaseExcludeOs ?? []);
619
- if (excludedOs.size && pkg.uniffi) {
620
- throw new Error(
621
- `${pkg.crateName} cannot set releaseExcludeOs because UniFFI requires every configured target`,
622
- );
574
+ type RustPackageDependencyResolver = (pkg: DBXToolsRustProject) => DBXToolsRustProject[];
575
+
576
+ function createRustPackageDependencyResolver(
577
+ packages: readonly DBXToolsRustProject[],
578
+ project: javascript.NodeProject,
579
+ options: DBXToolsRustWorkspaceOptions,
580
+ ): RustPackageDependencyResolver {
581
+ return (pkg) =>
582
+ rustPackageDependencies(packages, pkg, project.outdir, options.workspaceDependencies);
583
+ }
584
+
585
+ function planRustReleaseBinaries(
586
+ packages: readonly DBXToolsRustProject[],
587
+ resolved: ResolvedRustWorkspaceOptions,
588
+ ): RustReleaseBinaryMapping[] {
589
+ const commands = new Set<string>();
590
+ const binaries = packages
591
+ .filter((pkg) => Boolean(pkg.packageOptions.cli))
592
+ .map((pkg) => {
593
+ if (!pkg.packageOptions.release) {
594
+ throw new Error(`${pkg.crateName} must set release when cli is configured`);
623
595
  }
624
- for (const dependency of packageDependencies(pkg)) {
625
- for (const os of dependency.packageOptions.releaseExcludeOs ?? []) {
626
- if (!excludedOs.has(os)) {
627
- throw new Error(
628
- `${pkg.crateName} must exclude ${os} release builds because it depends on ${dependency.crateName}`,
629
- );
630
- }
631
- }
596
+ const configured = pkg.packageOptions.cli;
597
+ const command =
598
+ typeof configured === "object" && configured.command
599
+ ? configured.command
600
+ : pkg.packageOptions.directory;
601
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(command)) {
602
+ throw new Error(`Invalid Rust CLI command: ${command}`);
632
603
  }
633
- }
634
- const bindingDependencies = (pkg: DBXToolsRustProject, language: "node" | "python") =>
635
- bindings.filter(
636
- (dependency) =>
637
- (dependency.packageOptions.bindings ?? ["node", "python"]).includes(language) &&
638
- packageDependencies(pkg).includes(dependency),
604
+ if (commands.has(command)) {
605
+ throw new Error(`Duplicate Rust CLI command: ${command}`);
606
+ }
607
+ commands.add(command);
608
+ const description =
609
+ (typeof configured === "object" ? configured.description : undefined) ??
610
+ pkg.packageOptions.description ??
611
+ pkg.crateName;
612
+ const excludedOs = new Set(pkg.packageOptions.releaseExcludeOs ?? []);
613
+ const binaryName = pkg.packageOptions.binaryName ?? pkg.crateName;
614
+ return {
615
+ command,
616
+ description,
617
+ binaryName,
618
+ repository: resolved.repository,
619
+ assets: resolved.nativeTargets
620
+ .filter((target) => !excludedOs.has(target.os))
621
+ .map((target) => ({
622
+ os: target.os,
623
+ cpu: target.cpu,
624
+ name: releaseBinaryAssetName(binaryName, target.node, target.os),
625
+ })),
626
+ };
627
+ });
628
+ if (binaries.length && !resolved.repository) {
629
+ throw new Error("A repository URL is required when Rust CLI commands are configured");
630
+ }
631
+ return binaries;
632
+ }
633
+
634
+ function validateRustReleaseGraph(
635
+ packages: readonly DBXToolsRustProject[],
636
+ packageDependencies: RustPackageDependencyResolver,
637
+ ): void {
638
+ for (const pkg of packages) {
639
+ const excludedOs = new Set(pkg.packageOptions.releaseExcludeOs ?? []);
640
+ if (excludedOs.size && pkg.uniffi) {
641
+ throw new Error(
642
+ `${pkg.crateName} cannot set releaseExcludeOs because UniFFI requires every configured target`,
639
643
  );
640
- this.bindingMappings = orderRustBindings(
641
- bindings.map((pkg) => {
642
- const targets = pkg.packageOptions.bindings ?? ["node", "python"];
643
- const packageDirectory = `${string.toSlug(pkg.packageOptions.directory)}-rs`;
644
- const dependencies = [
645
- ...new Set([...bindingDependencies(pkg, "node"), ...bindingDependencies(pkg, "python")]),
646
- ].map((dependency) => dependency.crateName);
647
- return {
648
- crate: pkg.crateName,
649
- ...(dependencies.length ? { dependencies } : {}),
650
- rust: `${root}/${pkg.packageOptions.directory}`,
651
- ...(targets.includes("node")
652
- ? {
653
- node: `${nodeRoot}/${packageDirectory}`,
654
- nodePackage: `@${scope}/${packageDirectory}`,
655
- }
656
- : {}),
657
- ...(targets.includes("python")
658
- ? {
659
- python: `${options.pythonRoot ?? "packages/py"}/${packageDirectory}`,
660
- pythonPackage: `${scope}-${packageDirectory}`,
661
- pythonModule: pythonModuleName(pythonModulePrefix, packageDirectory),
662
- }
663
- : {}),
664
- };
665
- }),
666
- );
667
- for (const pkg of bindings) {
668
- const dependencies = bindingDependencies(pkg, "python");
669
- if (dependencies.length === 0) continue;
670
- pkg.tryRemoveFile("uniffi.toml");
671
- new TextFile(pkg, "uniffi.toml", {
672
- lines: renderToml({
673
- "bindings.python": { cdylib_name: pkg.crateName.replaceAll("-", "_") },
674
- "bindings.typescript": { strictTypeChecking: true },
675
- ...pkg.packageOptions.uniffiConfig,
676
- "bindings.python.external_packages": Object.fromEntries(
677
- dependencies.map((dependency) => [
678
- dependency.crateName.replaceAll("-", "_"),
679
- `${pythonModuleName(
680
- pythonModulePrefix,
681
- `${string.toSlug(dependency.packageOptions.directory)}-rs`,
682
- )}.bindings`,
683
- ]),
684
- ),
685
- })
686
- .trimEnd()
687
- .split("\n"),
688
- });
689
644
  }
690
- const releaseEnabled =
691
- (options.release ?? true) &&
692
- (this.bindingMappings.length > 0 ||
693
- this.packages.some((pkg) => pkg.packageOptions.release || !pkg.packageOptions.private));
694
- this.workspaceMapping = {
695
- root,
696
- crates: this.packages.map((pkg) => `${root}/${pkg.packageOptions.directory}`),
697
- bindings: this.bindingMappings,
698
- binaries: this.releaseBinaries,
699
- };
700
- if (dbxToolsProject) {
701
- dbxToolsProject.dbxToolsConfig.rust = this.workspaceMapping;
702
- }
703
- project.gitignore.addPatterns(
704
- "!/Cargo.lock",
705
- "target/",
706
- ...this.bindingMappings.flatMap((binding) => [
707
- ...(binding.node ? [`${binding.node}/src/*${binding.crate.replaceAll("-", "_")}.*`] : []),
708
- ...(binding.python && binding.pythonModule
709
- ? [
710
- `${binding.python}/src/${binding.pythonModule.replaceAll(".", "/")}/bindings.py`,
711
- `${binding.python}/src/${binding.pythonModule.replaceAll(".", "/")}/*${binding.crate.replaceAll("-", "_")}.*`,
712
- ]
713
- : []),
714
- ]),
715
- );
716
- for (const binding of this.bindingMappings) {
717
- if (!binding.node) {
718
- continue;
645
+ for (const dependency of packageDependencies(pkg)) {
646
+ for (const os of dependency.packageOptions.releaseExcludeOs ?? []) {
647
+ if (!excludedOs.has(os)) {
648
+ throw new Error(
649
+ `${pkg.crateName} must exclude ${os} release builds because it depends on ${dependency.crateName}`,
650
+ );
651
+ }
719
652
  }
720
- project.prettier?.addIgnorePattern(`${binding.node}/src/bindings.ts`);
721
- project.prettier?.addIgnorePattern(`${binding.node}/src/_bindings*.ts`);
722
653
  }
723
- this.pythonPackages = bindings
724
- .filter((pkg) => (pkg.packageOptions.bindings ?? ["node", "python"]).includes("python"))
725
- .map((pkg) => {
726
- const directory = `${string.toSlug(pkg.packageOptions.directory)}-rs`;
727
- const module = pythonModuleName(pythonModulePrefix, directory);
728
- const name = `${scope}-${directory}`;
729
- return {
730
- directory,
731
- name,
732
- module,
733
- description: `Python bindings for ${pkg.crateName}`,
734
- uniffi: true,
735
- internalDependencies: bindingDependencies(pkg, "python").map(
736
- (dependency) => `${string.toSlug(dependency.packageOptions.directory)}-rs`,
737
- ),
738
- generatedSources: [
739
- `src/${module.replaceAll(".", "/")}/bindings.py`,
740
- `src/${module.replaceAll(".", "/")}/__init__.py`,
741
- ],
742
- trustedPublisher: {
743
- environment: `pypi-${name}`,
744
- artifacts: `platform-specific wheels for ${nativeTargets
745
- .map((target) => `${target.os}-${target.cpu}`)
746
- .join(", ")}; all architectures publish to this one PyPI project`,
747
- },
748
- };
749
- });
654
+ }
655
+ }
750
656
 
751
- const existing = new Map(
752
- project.subprojects.map((child) => [relative(project.outdir, child.outdir), child]),
753
- );
754
- const nodePackages: DBXToolsTypeScriptProject[] = [];
755
- for (const binding of bindings.filter((pkg) =>
756
- (pkg.packageOptions.bindings ?? ["node", "python"]).includes("node"),
757
- )) {
657
+ interface RustBindingPlan {
658
+ readonly packages: readonly DBXToolsRustProject[];
659
+ readonly mappings: readonly RustBindingMapping[];
660
+ readonly dependencies: ReadonlyMap<
661
+ DBXToolsRustProject,
662
+ Readonly<Record<"node" | "python", readonly DBXToolsRustProject[]>>
663
+ >;
664
+ }
665
+
666
+ function planRustBindings(
667
+ packages: readonly DBXToolsRustProject[],
668
+ packageDependencies: RustPackageDependencyResolver,
669
+ resolved: ResolvedRustWorkspaceOptions,
670
+ ): RustBindingPlan {
671
+ const bindings = packages.filter((pkg) => pkg.uniffi);
672
+ const dependencies = new Map<
673
+ DBXToolsRustProject,
674
+ Readonly<Record<"node" | "python", readonly DBXToolsRustProject[]>>
675
+ >();
676
+ for (const pkg of bindings) {
677
+ const direct = packageDependencies(pkg);
678
+ dependencies.set(pkg, {
679
+ node: bindings.filter(
680
+ (dependency) =>
681
+ (dependency.packageOptions.bindings ?? ["node", "python"]).includes("node") &&
682
+ direct.includes(dependency),
683
+ ),
684
+ python: bindings.filter(
685
+ (dependency) =>
686
+ (dependency.packageOptions.bindings ?? ["node", "python"]).includes("python") &&
687
+ direct.includes(dependency),
688
+ ),
689
+ });
690
+ }
691
+ const mappings = orderRustBindings(
692
+ bindings.map((pkg) => {
693
+ const targets = pkg.packageOptions.bindings ?? ["node", "python"];
694
+ const packageDirectory = `${string.toSlug(pkg.packageOptions.directory)}-rs`;
695
+ const direct = dependencies.get(pkg);
696
+ const dependencyCrates = [
697
+ ...new Set([...(direct?.node ?? []), ...(direct?.python ?? [])]),
698
+ ].map((dependency) => dependency.crateName);
699
+ return {
700
+ crate: pkg.crateName,
701
+ ...(dependencyCrates.length ? { dependencies: dependencyCrates } : {}),
702
+ rust: `${resolved.root}/${pkg.packageOptions.directory}`,
703
+ ...(targets.includes("node")
704
+ ? {
705
+ node: `${resolved.nodeRoot}/${packageDirectory}`,
706
+ nodePackage: `@${resolved.scope}/${packageDirectory}`,
707
+ }
708
+ : {}),
709
+ ...(targets.includes("python")
710
+ ? {
711
+ python: `${resolved.pythonRoot}/${packageDirectory}`,
712
+ pythonPackage: `${resolved.scope}-${packageDirectory}`,
713
+ pythonModule: pythonModuleName(resolved.pythonModulePrefix, packageDirectory),
714
+ }
715
+ : {}),
716
+ };
717
+ }),
718
+ );
719
+ return { packages: bindings, mappings, dependencies };
720
+ }
721
+
722
+ function bindingDependencies(
723
+ plan: RustBindingPlan,
724
+ pkg: DBXToolsRustProject,
725
+ language: "node" | "python",
726
+ ): readonly DBXToolsRustProject[] {
727
+ return plan.dependencies.get(pkg)?.[language] ?? [];
728
+ }
729
+
730
+ function planPythonBindingPackages(
731
+ plan: RustBindingPlan,
732
+ resolved: ResolvedRustWorkspaceOptions,
733
+ ): PythonPackageOptions[] {
734
+ return plan.packages
735
+ .filter((pkg) => (pkg.packageOptions.bindings ?? ["node", "python"]).includes("python"))
736
+ .map((pkg) => {
737
+ const directory = `${string.toSlug(pkg.packageOptions.directory)}-rs`;
738
+ const module = pythonModuleName(resolved.pythonModulePrefix, directory);
739
+ const name = `${resolved.scope}-${directory}`;
740
+ return {
741
+ directory,
742
+ name,
743
+ module,
744
+ description: `Python bindings for ${pkg.crateName}`,
745
+ uniffi: true,
746
+ internalDependencies: bindingDependencies(plan, pkg, "python").map(
747
+ (dependency) => `${string.toSlug(dependency.packageOptions.directory)}-rs`,
748
+ ),
749
+ generatedSources: [
750
+ `src/${module.replaceAll(".", "/")}/bindings.py`,
751
+ `src/${module.replaceAll(".", "/")}/__init__.py`,
752
+ ],
753
+ trustedPublisher: {
754
+ environment: `pypi-${name}`,
755
+ artifacts: `platform-specific wheels for ${resolved.nativeTargets
756
+ .map((target) => `${target.os}-${target.cpu}`)
757
+ .join(", ")}; all architectures publish to this one PyPI project`,
758
+ },
759
+ };
760
+ });
761
+ }
762
+
763
+ interface RustNodeBindingPackagePlan {
764
+ readonly binding: DBXToolsRustProject;
765
+ readonly memberPath: string;
766
+ readonly name: string;
767
+ readonly optionalDependencies: Readonly<Record<string, string>>;
768
+ readonly internalDependencies: readonly string[];
769
+ }
770
+
771
+ function planNodeBindingPackages(
772
+ project: javascript.NodeProject,
773
+ plan: RustBindingPlan,
774
+ resolved: ResolvedRustWorkspaceOptions,
775
+ ): RustNodeBindingPackagePlan[] {
776
+ const version = readWorkspaceVersion(project.outdir);
777
+ return plan.packages
778
+ .filter((pkg) => (pkg.packageOptions.bindings ?? ["node", "python"]).includes("node"))
779
+ .map((binding) => {
758
780
  const directory = `${string.toSlug(binding.packageOptions.directory)}-rs`;
759
- const memberPath = `${nodeRoot}/${directory}`;
760
- const found = existing.get(memberPath);
761
- const existingNode = found instanceof DBXToolsTypeScriptProject ? found : undefined;
762
- const node = existingNode
763
- ? existingNode
764
- : new DBXToolsTypeScriptProject({
765
- parent: project,
766
- outdir: memberPath,
767
- name: `@${scope}/${directory.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`,
768
- tags: ["node"],
769
- });
770
- node.package.addField(
771
- "name",
772
- `@${scope}/${directory.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`,
773
- );
774
- node.dbxToolsConfig.uniffi = true;
775
- node.package.addField("description", `Node bindings for ${binding.crateName}`);
776
- if (options.release ?? true) {
777
- node.package.addField(
778
- "optionalDependencies",
779
- Object.fromEntries(
780
- nativeTargets.map((target) => [
781
- `@${scope}/${directory}-${target.node}`,
782
- readWorkspaceVersion(project.outdir),
783
- ]),
784
- ),
785
- );
786
- }
787
- node.addDeps("@ubjs/core@0.31.0-5", "@ubjs/node@0.31.0-5");
788
- node.addDeps(
789
- ...bindingDependencies(binding, "node").map(
781
+ return {
782
+ binding,
783
+ memberPath: `${resolved.nodeRoot}/${directory}`,
784
+ name: `@${resolved.scope}/${directory.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}`,
785
+ optionalDependencies: resolved.release
786
+ ? Object.fromEntries(
787
+ resolved.nativeTargets.map((target) => [
788
+ `@${resolved.scope}/${directory}-${target.node}`,
789
+ version,
790
+ ]),
791
+ )
792
+ : {},
793
+ internalDependencies: bindingDependencies(plan, binding, "node").map(
790
794
  (dependency) =>
791
- `@${scope}/${string.toSlug(dependency.packageOptions.directory)}-rs@workspace:*`,
795
+ `@${resolved.scope}/${string.toSlug(dependency.packageOptions.directory)}-rs@workspace:*`,
792
796
  ),
793
- );
794
- node.addDevDeps(`uniffi-bindgen-react-native@${UBRN_VERSION}`);
795
- nodePackages.push(node);
796
- }
797
- this.nodePackages = nodePackages;
798
-
799
- const generatedWorkspaceManifest: Record<string, unknown> = {
800
- workspace: {
801
- members: this.packages.map((pkg) => `${root}/${pkg.packageOptions.directory}`),
802
- "default-members": this.packages
803
- .filter((pkg) => !pkg.uniffi)
804
- .map((pkg) => `${root}/${pkg.packageOptions.directory}`),
805
- resolver: "2",
806
- },
807
- "workspace.package": {
808
- version: readWorkspaceVersion(project.outdir),
809
- edition: options.edition ?? "2021",
810
- "rust-version": options.rustVersion ?? "1.82",
811
- license: options.license ?? "Apache-2.0",
812
- repository,
813
- },
814
- ...(options.workspaceDependencies
815
- ? {
816
- "workspace.dependencies": Object.fromEntries(
817
- Object.entries(options.workspaceDependencies).map(([name, value]) => [
818
- name,
819
- cargoDependency(value),
820
- ]),
821
- ),
822
- }
823
- : {}),
824
- };
825
- new TextFile(project, "Cargo.toml", {
826
- lines: renderToml(generatedWorkspaceManifest).trimEnd().split("\n"),
797
+ };
827
798
  });
828
- new TextFile(project, ".cargo/config.toml", {
799
+ }
800
+
801
+ function createRustPackages(
802
+ project: javascript.NodeProject,
803
+ resolved: ResolvedRustWorkspaceOptions,
804
+ ): DBXToolsRustProject[] {
805
+ return discoverRustCrates(resolve(project.outdir, resolved.root)).map(
806
+ (directory) =>
807
+ new DBXToolsRustProject(project, resolved.root, resolved.scope, {
808
+ directory,
809
+ ...resolved.packageOptions[directory],
810
+ }),
811
+ );
812
+ }
813
+
814
+ function configureRustCliRegistry(
815
+ project: javascript.NodeProject,
816
+ path: string | undefined,
817
+ binaries: readonly RustReleaseBinaryMapping[],
818
+ ): void {
819
+ if (binaries.length && !path) {
820
+ throw new Error("cliRegistryPath is required when Rust CLI commands are configured");
821
+ }
822
+ if (!path) return;
823
+ new TextFile(project, path, {
824
+ lines: rustCliRegistrySource(binaries).trimEnd().split("\n"),
825
+ });
826
+ }
827
+
828
+ function configureRustBindingFiles(
829
+ plan: RustBindingPlan,
830
+ resolved: ResolvedRustWorkspaceOptions,
831
+ ): void {
832
+ for (const pkg of plan.packages) {
833
+ const dependencies = bindingDependencies(plan, pkg, "python");
834
+ if (dependencies.length === 0) continue;
835
+ pkg.tryRemoveFile("uniffi.toml");
836
+ new TextFile(pkg, "uniffi.toml", {
829
837
  lines: renderToml({
830
- "target.x86_64-pc-windows-msvc": {
831
- rustflags: ["-C", "target-feature=+crt-static"],
832
- },
833
- "target.aarch64-pc-windows-msvc": {
834
- rustflags: ["-C", "target-feature=+crt-static"],
835
- },
838
+ "bindings.python": { cdylib_name: pkg.crateName.replaceAll("-", "_") },
839
+ "bindings.typescript": { strictTypeChecking: true },
840
+ ...pkg.packageOptions.uniffiConfig,
841
+ "bindings.python.external_packages": Object.fromEntries(
842
+ dependencies.map((dependency) => [
843
+ dependency.crateName.replaceAll("-", "_"),
844
+ `${pythonModuleName(
845
+ resolved.pythonModulePrefix,
846
+ `${string.toSlug(dependency.packageOptions.directory)}-rs`,
847
+ )}.bindings`,
848
+ ]),
849
+ ),
836
850
  })
837
851
  .trimEnd()
838
852
  .split("\n"),
839
853
  });
840
- new RustWorkspaceVersionLock(project);
841
- project.addTask("rs:format", { exec: "cargo fmt --all" });
842
- project.addTask("rs:lint", { exec: "cargo clippy --workspace --all-targets --all-features" });
843
- project.addTask("rs:test", { exec: "cargo test --workspace" });
844
- project.addTask("rs:build", { exec: "cargo build --workspace" });
845
- project.addTask("rs:bindings", {
846
- exec: "bun node_modules/@dbx-tools/projen/tasks/rust.ts",
847
- description: "Generate language bindings for UniFFI-enabled Rust crates",
848
- });
849
- project.removeTask("rs:bindings:demo");
850
- if (releaseEnabled) {
851
- this.addReleaseWorkflow(project, options, nativeTargets);
852
- }
853
854
  }
855
+ }
854
856
 
855
- private addReleaseWorkflow(
856
- project: javascript.NodeProject,
857
- options: DBXToolsRustWorkspaceOptions,
858
- targets: readonly UniFFIReleaseTarget[],
859
- ): void {
860
- if (!project.github || !isDBXToolsJavaScriptProject()(project)) return;
861
- const releaseRustVersion = options.releaseRustVersion ?? "stable";
862
- const bindings = this.bindingMappings.map((binding) => ({
863
- ...binding,
864
- node: binding.node ?? "",
865
- python: binding.python ?? "",
866
- nodePackage: binding.nodePackage ?? "",
867
- pythonPackage: binding.pythonPackage ?? "",
857
+ function configureRustBindingIgnores(
858
+ project: javascript.NodeProject,
859
+ bindings: readonly RustBindingMapping[],
860
+ ): void {
861
+ project.gitignore.addPatterns(
862
+ "!/Cargo.lock",
863
+ "target/",
864
+ ...bindings.flatMap((binding) => [
865
+ ...(binding.node ? [`${binding.node}/src/*${binding.crate.replaceAll("-", "_")}.*`] : []),
866
+ ...(binding.python && binding.pythonModule
867
+ ? [
868
+ `${binding.python}/src/${binding.pythonModule.replaceAll(".", "/")}/bindings.py`,
869
+ `${binding.python}/src/${binding.pythonModule.replaceAll(".", "/")}/*${binding.crate.replaceAll("-", "_")}.*`,
870
+ ]
871
+ : []),
872
+ ]),
873
+ );
874
+ for (const binding of bindings) {
875
+ if (!binding.node) continue;
876
+ project.prettier?.addIgnorePattern(`${binding.node}/src/bindings.ts`);
877
+ project.prettier?.addIgnorePattern(`${binding.node}/src/_bindings*.ts`);
878
+ }
879
+ }
880
+
881
+ function createRustNodeBindingPackages(
882
+ project: javascript.NodeProject,
883
+ plans: readonly RustNodeBindingPackagePlan[],
884
+ ): DBXToolsTypeScriptProject[] {
885
+ const existing = new Map(
886
+ project.subprojects.map((child) => [relative(project.outdir, child.outdir), child]),
887
+ );
888
+ return plans.map((plan) => {
889
+ const found = existing.get(plan.memberPath);
890
+ const existingNode = found instanceof DBXToolsTypeScriptProject ? found : undefined;
891
+ const node =
892
+ existingNode ??
893
+ new DBXToolsTypeScriptProject({
894
+ parent: project,
895
+ outdir: plan.memberPath,
896
+ name: plan.name,
897
+ tags: ["node"],
898
+ });
899
+ node.package.addField("name", plan.name);
900
+ node.dbxToolsConfig.uniffi = true;
901
+ node.package.addField("description", `Node bindings for ${plan.binding.crateName}`);
902
+ if (Object.keys(plan.optionalDependencies).length) {
903
+ node.package.addField("optionalDependencies", plan.optionalDependencies);
904
+ }
905
+ node.addDeps("@ubjs/core@0.31.0-5", "@ubjs/node@0.31.0-5");
906
+ node.addDeps(...plan.internalDependencies);
907
+ node.addDevDeps(`uniffi-bindgen-react-native@${UBRN_VERSION}`);
908
+ return node;
909
+ });
910
+ }
911
+
912
+ function createRustWorkspaceMapping(
913
+ packages: readonly DBXToolsRustProject[],
914
+ bindings: readonly RustBindingMapping[],
915
+ binaries: readonly RustReleaseBinaryMapping[],
916
+ resolved: ResolvedRustWorkspaceOptions,
917
+ ): RustWorkspaceMapping {
918
+ return {
919
+ root: resolved.root,
920
+ crates: packages.map((pkg) => `${resolved.root}/${pkg.packageOptions.directory}`),
921
+ bindings,
922
+ binaries,
923
+ };
924
+ }
925
+
926
+ function configureRustWorkspaceFiles(
927
+ project: javascript.NodeProject,
928
+ packages: readonly DBXToolsRustProject[],
929
+ options: DBXToolsRustWorkspaceOptions,
930
+ resolved: ResolvedRustWorkspaceOptions,
931
+ ): void {
932
+ const manifest: Record<string, unknown> = {
933
+ workspace: {
934
+ members: packages.map((pkg) => `${resolved.root}/${pkg.packageOptions.directory}`),
935
+ "default-members": packages
936
+ .filter((pkg) => !pkg.uniffi)
937
+ .map((pkg) => `${resolved.root}/${pkg.packageOptions.directory}`),
938
+ resolver: "2",
939
+ },
940
+ "workspace.package": {
941
+ version: readWorkspaceVersion(project.outdir),
942
+ edition: options.edition ?? "2021",
943
+ "rust-version": options.rustVersion ?? "1.82",
944
+ license: options.license ?? DBX_TOOLS_LICENSE,
945
+ repository: resolved.repository,
946
+ },
947
+ ...(options.workspaceDependencies
948
+ ? {
949
+ "workspace.dependencies": Object.fromEntries(
950
+ Object.entries(options.workspaceDependencies).map(([name, value]) => [
951
+ name,
952
+ cargoDependency(value),
953
+ ]),
954
+ ),
955
+ }
956
+ : {}),
957
+ };
958
+ new TextFile(project, "Cargo.toml", {
959
+ lines: renderToml(manifest).trimEnd().split("\n"),
960
+ });
961
+ new TextFile(project, ".cargo/config.toml", {
962
+ lines: renderToml({
963
+ "target.x86_64-pc-windows-msvc": {
964
+ rustflags: ["-C", "target-feature=+crt-static"],
965
+ },
966
+ "target.aarch64-pc-windows-msvc": {
967
+ rustflags: ["-C", "target-feature=+crt-static"],
968
+ },
969
+ })
970
+ .trimEnd()
971
+ .split("\n"),
972
+ });
973
+ new RustWorkspaceVersionLock(project);
974
+ }
975
+
976
+ function configureRustWorkspaceTasks(project: javascript.NodeProject): void {
977
+ project.addTask("rs:format", { exec: "cargo fmt --all" });
978
+ project.addTask("rs:lint", { exec: "cargo clippy --workspace --all-targets --all-features" });
979
+ project.addTask("rs:test", { exec: "cargo test --workspace" });
980
+ project.addTask("rs:build", { exec: "cargo build --workspace" });
981
+ project.addTask("rs:bindings", {
982
+ exec: "bun node_modules/@dbx-tools/projen/tasks/rust.ts",
983
+ description: "Generate language bindings for UniFFI-enabled Rust crates",
984
+ });
985
+ project.removeTask("rs:bindings:demo");
986
+ }
987
+
988
+ interface RustReleaseBinding extends RustBindingMapping {
989
+ readonly node: string;
990
+ readonly python: string;
991
+ readonly nodePackage: string;
992
+ readonly pythonPackage: string;
993
+ }
994
+
995
+ interface RustReleaseBinaryPlan {
996
+ readonly crate: string;
997
+ readonly binary: string;
998
+ readonly excludedOs: readonly RustReleaseOs[];
999
+ }
1000
+
1001
+ type RustReleaseTargetPlan = Readonly<Record<string, string | boolean | number>>;
1002
+
1003
+ interface RustReleasePlan {
1004
+ readonly releaseRustVersion: string;
1005
+ readonly releaseTask: string;
1006
+ readonly bindings: readonly RustReleaseBinding[];
1007
+ readonly nodeBindings: readonly RustReleaseBinding[];
1008
+ readonly releaseBinaries: readonly RustReleaseBinaryPlan[];
1009
+ readonly publicCrates: readonly string[];
1010
+ readonly targets: readonly RustReleaseTargetPlan[];
1011
+ readonly hasReleaseExclusions: boolean;
1012
+ readonly hasPythonBindings: boolean;
1013
+ readonly usesCargoLock: boolean;
1014
+ readonly usePreinstalledWindowsRust: boolean;
1015
+ readonly hasTargetOutputs: boolean;
1016
+ }
1017
+
1018
+ function planRustRelease(
1019
+ project: javascript.NodeProject,
1020
+ options: DBXToolsRustWorkspaceOptions,
1021
+ targets: readonly UniFFIReleaseTarget[],
1022
+ packages: readonly DBXToolsRustProject[],
1023
+ bindingMappings: readonly RustBindingMapping[],
1024
+ ): RustReleasePlan {
1025
+ const releaseRustVersion = options.releaseRustVersion ?? "stable";
1026
+ const bindings = bindingMappings.map((binding) => ({
1027
+ ...binding,
1028
+ node: binding.node ?? "",
1029
+ python: binding.python ?? "",
1030
+ nodePackage: binding.nodePackage ?? "",
1031
+ pythonPackage: binding.pythonPackage ?? "",
1032
+ }));
1033
+ const releaseBinaries = packages
1034
+ .filter((pkg) => pkg.packageOptions.release)
1035
+ .map((pkg) => ({
1036
+ crate: pkg.crateName,
1037
+ binary: pkg.packageOptions.binaryName ?? pkg.crateName,
1038
+ excludedOs: pkg.packageOptions.releaseExcludeOs ?? [],
868
1039
  }));
869
- const releaseBinaries = this.packages
870
- .filter((pkg) => pkg.packageOptions.release)
1040
+ const packageDependencies = createRustPackageDependencyResolver(packages, project, options);
1041
+ const publicCrates = orderRustBindings(
1042
+ packages
1043
+ .filter((pkg) => !pkg.packageOptions.private)
871
1044
  .map((pkg) => ({
872
1045
  crate: pkg.crateName,
873
- binary: pkg.packageOptions.binaryName ?? pkg.crateName,
874
- excludedOs: pkg.packageOptions.releaseExcludeOs ?? [],
875
- }));
876
- const publicCrates = orderRustBindings(
877
- this.packages
878
- .filter((pkg) => !pkg.packageOptions.private)
879
- .map((pkg) => ({
880
- crate: pkg.crateName,
881
- rust: pkg.outdir,
882
- dependencies: rustPackageDependencies(
883
- this.packages,
884
- pkg,
885
- project.outdir,
886
- options.workspaceDependencies,
887
- ).map((dependency) => dependency.crateName),
888
- })),
889
- ).map((pkg) => pkg.crate);
890
- const hasReleaseExclusions = this.packages.some(
891
- (pkg) => pkg.packageOptions.releaseExcludeOs?.length,
892
- );
893
- const targetMatrix = targets.map((target) => {
894
- const cargoExcludes = this.packages
895
- .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
896
- .map((pkg) => `--exclude ${pkg.crateName}`)
897
- .join(" ");
898
- return { ...target, ...(hasReleaseExclusions ? { cargoExcludes } : {}) };
1046
+ rust: pkg.outdir,
1047
+ dependencies: packageDependencies(pkg).map((dependency) => dependency.crateName),
1048
+ })),
1049
+ ).map((pkg) => pkg.crate);
1050
+ const hasReleaseExclusions = packages.some((pkg) => pkg.packageOptions.releaseExcludeOs?.length);
1051
+ const plannedTargets: RustReleaseTargetPlan[] = targets.map((target) => {
1052
+ const cargoExcludes = packages
1053
+ .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
1054
+ .map((pkg) => `--exclude ${pkg.crateName}`)
1055
+ .join(" ");
1056
+ return {
1057
+ runner: target.runner,
1058
+ cargo: target.cargo,
1059
+ node: target.node,
1060
+ python: target.python,
1061
+ os: target.os,
1062
+ cpu: target.cpu,
1063
+ ...(target.libc ? { libc: target.libc } : {}),
1064
+ ...(hasReleaseExclusions ? { cargoExcludes } : {}),
1065
+ };
1066
+ });
1067
+ const hasTargetOutputs =
1068
+ bindings.length > 0 || releaseBinaries.length > 0 || publicCrates.length > 0;
1069
+ if (hasTargetOutputs && plannedTargets.length === 0) {
1070
+ throw new Error("Rust release requires at least one target");
1071
+ }
1072
+ return {
1073
+ releaseRustVersion,
1074
+ releaseTask: ".projen/uniffi-release.mjs",
1075
+ bindings,
1076
+ nodeBindings: bindings.filter((binding) => Boolean(binding.node && binding.nodePackage)),
1077
+ releaseBinaries,
1078
+ publicCrates,
1079
+ targets: plannedTargets,
1080
+ hasReleaseExclusions,
1081
+ hasPythonBindings: bindings.some((binding) => binding.python),
1082
+ usesCargoLock: existsSync(join(project.outdir, "Cargo.lock")),
1083
+ usePreinstalledWindowsRust: releaseRustVersion === "stable",
1084
+ hasTargetOutputs,
1085
+ };
1086
+ }
1087
+
1088
+ function configureRustReleaseTask(project: javascript.NodeProject, plan: RustReleasePlan): void {
1089
+ if (plan.bindings.length) {
1090
+ new TextFile(project, plan.releaseTask, {
1091
+ lines: uniffiReleaseTaskSource().trimEnd().split("\n"),
899
1092
  });
900
- const hasPythonBindings = bindings.some((binding) => binding.python);
901
- const usesCargoLock = existsSync(join(project.outdir, "Cargo.lock"));
902
- const usePreinstalledWindowsRust = releaseRustVersion === "stable";
903
- const hasTargetOutputs =
904
- bindings.length > 0 || releaseBinaries.length > 0 || publicCrates.length > 0;
905
- if (hasTargetOutputs && targetMatrix.length === 0) {
906
- throw new Error("Rust release requires at least one target");
907
- }
908
- const releaseTask = ".projen/uniffi-release.mjs";
909
- if (bindings.length) {
910
- new TextFile(project, releaseTask, {
911
- lines: uniffiReleaseTaskSource().trimEnd().split("\n"),
912
- });
913
- } else {
914
- project.tryRemoveFile(releaseTask);
915
- }
916
- const bindingCommands = bindings.map((binding) =>
917
- [
918
- `node ${releaseTask} build`,
919
- `--crate "${binding.crate}"`,
920
- `--node "${binding.node}"`,
921
- `--python "${binding.python}"`,
922
- `--node-package "${binding.nodePackage}"`,
923
- `--python-package "${binding.pythonPackage}"`,
924
- `--python-module "${binding.pythonModule}"`,
925
- '--cargo-target "${{ matrix.cargo }}"',
926
- '--node-triple "${{ matrix.node }}"',
927
- '--python-tag "${{ matrix.python }}"',
928
- '--os "${{ matrix.os }}"',
929
- '--cpu "${{ matrix.cpu }}"',
930
- '--libc "${{ matrix.libc }}"',
931
- '--version "$VERSION"',
932
- `--output "dist/release/${binding.crate}/\${{ matrix.node }}"`,
933
- "--skip-build",
934
- ].join(" \\\n "),
1093
+ } else {
1094
+ project.tryRemoveFile(plan.releaseTask);
1095
+ }
1096
+ }
1097
+
1098
+ function rustBindingCommands(plan: RustReleasePlan): string[] {
1099
+ return plan.bindings.map((binding) =>
1100
+ [
1101
+ `node ${plan.releaseTask} build`,
1102
+ `--crate "${binding.crate}"`,
1103
+ `--node "${binding.node}"`,
1104
+ `--python "${binding.python}"`,
1105
+ `--node-package "${binding.nodePackage}"`,
1106
+ `--python-package "${binding.pythonPackage}"`,
1107
+ `--python-module "${binding.pythonModule}"`,
1108
+ '--cargo-target "${{ matrix.cargo }}"',
1109
+ '--node-triple "${{ matrix.node }}"',
1110
+ '--python-tag "${{ matrix.python }}"',
1111
+ '--os "${{ matrix.os }}"',
1112
+ '--cpu "${{ matrix.cpu }}"',
1113
+ '--libc "${{ matrix.libc }}"',
1114
+ '--version "$VERSION"',
1115
+ `--output "dist/release/${binding.crate}/\${{ matrix.node }}"`,
1116
+ "--skip-build",
1117
+ ].join(" \\\n "),
1118
+ );
1119
+ }
1120
+
1121
+ function rustReleaseBinaryCondition(excludedOs: readonly RustReleaseOs[]): string | undefined {
1122
+ return excludedOs.length
1123
+ ? `\${{ ${excludedOs.map((os) => `matrix.os != '${os}'`).join(" && ")} }}`
1124
+ : undefined;
1125
+ }
1126
+
1127
+ function rustBinaryCommands(plan: RustReleasePlan): string[] {
1128
+ return plan.releaseBinaries.flatMap((pkg) => {
1129
+ const windowsAsset = releaseBinaryAssetName(
1130
+ pkg.binary,
1131
+ "${{ matrix.node }}",
1132
+ RustReleaseOs.WINDOWS,
935
1133
  );
936
- const releaseBinaryCondition = (excludedOs: readonly RustReleaseOs[]) =>
937
- excludedOs.length
938
- ? `\${{ ${excludedOs.map((os) => `matrix.os != '${os}'`).join(" && ")} }}`
939
- : undefined;
940
- const binaryCommands = releaseBinaries.flatMap((pkg) => {
941
- const windowsAsset = releaseBinaryAssetName(
942
- pkg.binary,
943
- "${{ matrix.node }}",
944
- RustReleaseOs.WINDOWS,
945
- );
946
- const unixAsset = releaseBinaryAssetName(
947
- pkg.binary,
948
- "${{ matrix.node }}",
949
- RustReleaseOs.LINUX,
950
- );
951
- const commands = [
952
- `mkdir -p "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
953
- `SOURCE="target/\${{ matrix.cargo }}/release/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
954
- `DESTINATION="dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
955
- 'cp "$SOURCE" "$DESTINATION"',
956
- 'if [ "${{ matrix.os }}" = "win32" ]; then',
957
- ` 7z a "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${windowsAsset}" "$DESTINATION"`,
958
- "else",
959
- ` tar -C "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage" -czf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${unixAsset}" "${pkg.binary}"`,
960
- "fi",
961
- `rm -rf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
962
- ];
963
- const excludedCondition = pkg.excludedOs
964
- .map((os) => `[ "\${{ matrix.os }}" != "${os}" ]`)
965
- .join(" && ");
966
- return excludedCondition
967
- ? [`if ${excludedCondition}; then`, ...commands.map((command) => ` ${command}`), "fi"]
968
- : commands;
969
- });
970
- const artifactSteps: JobStep[] = [
971
- ...bindings.flatMap((binding) => [
972
- ...(binding.node
973
- ? [
974
- {
975
- name: `Upload ${binding.crate} native npm package`,
976
- uses: "actions/upload-artifact@v7",
977
- with: {
978
- name: `${binding.crate}-\${{ matrix.node }}-npm`,
979
- path: `dist/release/${binding.crate}/\${{ matrix.node }}/npm/*.tgz`,
980
- "retention-days": 7,
981
- },
1134
+ const unixAsset = releaseBinaryAssetName(pkg.binary, "${{ matrix.node }}", RustReleaseOs.LINUX);
1135
+ const commands = [
1136
+ `mkdir -p "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
1137
+ `SOURCE="target/\${{ matrix.cargo }}/release/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
1138
+ `DESTINATION="dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
1139
+ 'cp "$SOURCE" "$DESTINATION"',
1140
+ 'if [ "${{ matrix.os }}" = "win32" ]; then',
1141
+ ` 7z a "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${windowsAsset}" "$DESTINATION"`,
1142
+ "else",
1143
+ ` tar -C "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage" -czf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${unixAsset}" "${pkg.binary}"`,
1144
+ "fi",
1145
+ `rm -rf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
1146
+ ];
1147
+ const excludedCondition = pkg.excludedOs
1148
+ .map((os) => `[ "\${{ matrix.os }}" != "${os}" ]`)
1149
+ .join(" && ");
1150
+ return excludedCondition
1151
+ ? [`if ${excludedCondition}; then`, ...commands.map((command) => ` ${command}`), "fi"]
1152
+ : commands;
1153
+ });
1154
+ }
1155
+
1156
+ function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
1157
+ return [
1158
+ ...plan.bindings.flatMap((binding) => [
1159
+ ...(binding.node
1160
+ ? [
1161
+ {
1162
+ name: `Upload ${binding.crate} native npm package`,
1163
+ uses: "actions/upload-artifact@v7",
1164
+ with: {
1165
+ name: `${binding.crate}-\${{ matrix.node }}-npm`,
1166
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/npm/*.tgz`,
1167
+ "retention-days": 7,
982
1168
  },
983
- ]
984
- : []),
985
- ...(binding.python
986
- ? [
987
- {
988
- name: `Upload ${binding.crate} Python wheel`,
989
- uses: "actions/upload-artifact@v7",
990
- with: {
991
- name: `${binding.pythonPackage}--\${{ matrix.python }}--python-wheel`,
992
- path: `dist/release/${binding.crate}/\${{ matrix.node }}/python/*.whl`,
993
- "retention-days": 7,
994
- },
1169
+ },
1170
+ ]
1171
+ : []),
1172
+ ...(binding.python
1173
+ ? [
1174
+ {
1175
+ name: `Upload ${binding.crate} Python wheel`,
1176
+ uses: "actions/upload-artifact@v7",
1177
+ with: {
1178
+ name: `${binding.pythonPackage}--\${{ matrix.python }}--python-wheel`,
1179
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/python/*.whl`,
1180
+ "retention-days": 7,
995
1181
  },
996
- ]
997
- : []),
998
- ]),
999
- ...releaseBinaries.map((pkg) => ({
1000
- name: `Upload ${pkg.crate} release binary`,
1001
- uses: "actions/upload-artifact@v7",
1002
- ...(releaseBinaryCondition(pkg.excludedOs)
1003
- ? { if: releaseBinaryCondition(pkg.excludedOs) }
1004
- : {}),
1005
- with: {
1006
- name: `${pkg.crate}-\${{ matrix.node }}-binary`,
1007
- path: `dist/release/${pkg.crate}/\${{ matrix.node }}/binary/*`,
1008
- "retention-days": 7,
1182
+ },
1183
+ ]
1184
+ : []),
1185
+ ]),
1186
+ ...plan.releaseBinaries.map((pkg) => ({
1187
+ name: `Upload ${pkg.crate} release binary`,
1188
+ uses: "actions/upload-artifact@v7",
1189
+ ...(rustReleaseBinaryCondition(pkg.excludedOs)
1190
+ ? { if: rustReleaseBinaryCondition(pkg.excludedOs) }
1191
+ : {}),
1192
+ with: {
1193
+ name: `${pkg.crate}-\${{ matrix.node }}-binary`,
1194
+ path: `dist/release/${pkg.crate}/\${{ matrix.node }}/binary/*`,
1195
+ "retention-days": 7,
1196
+ },
1197
+ })),
1198
+ ];
1199
+ }
1200
+
1201
+ function rustBuildJob(plan: RustReleasePlan): Job {
1202
+ const bindingCommands = rustBindingCommands(plan);
1203
+ const binaryCommands = rustBinaryCommands(plan);
1204
+ return {
1205
+ if: "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
1206
+ name: "${{ matrix.node }}",
1207
+ needs: ["verify-context"],
1208
+ runsOn: ["${{ matrix.runner }}"],
1209
+ permissions: { contents: JobPermission.READ },
1210
+ env: { ...RUST_CACHE_ENV },
1211
+ strategy: {
1212
+ failFast: false,
1213
+ matrix: { include: [...plan.targets] },
1214
+ },
1215
+ steps: [
1216
+ ...releaseSourceSteps(),
1217
+ ...(plan.hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
1218
+ {
1219
+ name: "Setup Rust",
1220
+ ...(plan.usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
1221
+ uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
1222
+ with: { targets: "${{ matrix.cargo }}" },
1223
+ },
1224
+ ...(plan.usePreinstalledWindowsRust
1225
+ ? [
1226
+ {
1227
+ name: "Verify preinstalled Windows Rust",
1228
+ if: "${{ matrix.os == 'win32' }}",
1229
+ shell: "bash",
1230
+ run: [
1231
+ "rustc --version --verbose",
1232
+ "cargo --version",
1233
+ 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
1234
+ 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
1235
+ ].join("\n"),
1236
+ },
1237
+ ]
1238
+ : []),
1239
+ ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${plan.releaseRustVersion}`),
1240
+ {
1241
+ name: "Install Linux native dependencies",
1242
+ if: "${{ matrix.os == 'linux' }}",
1243
+ run: [
1244
+ "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
1245
+ "sudo apt-get update",
1246
+ "sudo apt-get install --yes libdbus-1-dev pkg-config",
1247
+ ].join("\n"),
1248
+ },
1249
+ {
1250
+ name: "Build Rust outputs",
1251
+ shell: "bash",
1252
+ env: {
1253
+ CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
1254
+ "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
1009
1255
  },
1010
- })),
1011
- ];
1012
- const buildJob = {
1013
- if: "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
1014
- name: "${{ matrix.node }}",
1015
- needs: ["verify-context"],
1016
- runsOn: ["${{ matrix.runner }}"],
1017
- permissions: { contents: JobPermission.READ },
1018
- env: {
1019
- ...RUST_CACHE_ENV,
1256
+ run: timedBash(
1257
+ "rust_workspace",
1258
+ `cargo build --release --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
1259
+ plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
1260
+ }`,
1261
+ ),
1020
1262
  },
1021
- strategy: {
1022
- failFast: false,
1023
- matrix: { include: targetMatrix },
1263
+ ...(bindingCommands.length
1264
+ ? [
1265
+ {
1266
+ name: "Package UniFFI outputs",
1267
+ shell: "bash",
1268
+ env: { VERSION: RELEASE_VERSION },
1269
+ run: timedBash("uniffi_packaging", bindingCommands.join("\n")),
1270
+ },
1271
+ ]
1272
+ : []),
1273
+ ...(binaryCommands.length
1274
+ ? [
1275
+ {
1276
+ name: "Package release binaries",
1277
+ shell: "bash",
1278
+ run: timedBash("binary_packaging", binaryCommands.join("\n")),
1279
+ },
1280
+ ]
1281
+ : []),
1282
+ ...rustArtifactSteps(plan),
1283
+ ],
1284
+ };
1285
+ }
1286
+
1287
+ function rustCargoPublishJob(plan: RustReleasePlan, local: boolean): Job {
1288
+ const registry = local ? '"${{ vars.LOCAL_CARGO_REGISTRY }}"' : "crates-io";
1289
+ return {
1290
+ if: local
1291
+ ? "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}"
1292
+ : "${{ github.event_name == 'push' }}",
1293
+ needs: ["verify-context", "rust-build"],
1294
+ runsOn: [local ? "self-hosted" : "ubuntu-latest"],
1295
+ permissions: { contents: JobPermission.READ },
1296
+ steps: [
1297
+ ...releaseSourceSteps(),
1298
+ {
1299
+ name: "Setup Rust",
1300
+ uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
1024
1301
  },
1025
- steps: [
1026
- ...releaseSourceSteps(),
1027
- ...(hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
1028
- {
1029
- name: "Setup Rust",
1030
- ...(usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
1031
- uses: `dtolnay/rust-toolchain@${releaseRustVersion}`,
1032
- with: { targets: "${{ matrix.cargo }}" },
1302
+ {
1303
+ name: local ? "Publish Cargo crates locally" : "Publish public crates",
1304
+ env: {
1305
+ CARGO_REGISTRY_TOKEN: local
1306
+ ? "${{ secrets.LOCAL_CARGO_TOKEN }}"
1307
+ : "${{ secrets.CARGO_REGISTRY_TOKEN }}",
1033
1308
  },
1034
- ...(usePreinstalledWindowsRust
1035
- ? [
1036
- {
1037
- name: "Verify preinstalled Windows Rust",
1038
- if: "${{ matrix.os == 'win32' }}",
1039
- shell: "bash",
1040
- run: [
1041
- "rustc --version --verbose",
1042
- "cargo --version",
1043
- 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
1044
- 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
1045
- ].join("\n"),
1046
- },
1047
- ]
1048
- : []),
1049
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`),
1050
- {
1051
- name: "Install Linux native dependencies",
1052
- if: "${{ matrix.os == 'linux' }}",
1053
- run: [
1054
- "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
1055
- "sudo apt-get update",
1056
- "sudo apt-get install --yes libdbus-1-dev pkg-config",
1057
- ].join("\n"),
1309
+ run: plan.publicCrates
1310
+ .map((crate) => `cargo publish --package "${crate}" --registry ${registry} --no-verify`)
1311
+ .join("\n"),
1312
+ },
1313
+ ],
1314
+ };
1315
+ }
1316
+
1317
+ function rustGitHubReleaseJob(): Job {
1318
+ return {
1319
+ if: "${{ github.event_name == 'push' }}",
1320
+ needs: ["verify-context", "rust-build"],
1321
+ runsOn: ["ubuntu-latest"],
1322
+ permissions: { contents: JobPermission.WRITE },
1323
+ steps: [
1324
+ {
1325
+ name: "Download release binaries",
1326
+ uses: "actions/download-artifact@v8",
1327
+ with: {
1328
+ pattern: "*-binary",
1329
+ path: "dist/rust-release",
1330
+ "merge-multiple": true,
1058
1331
  },
1059
- {
1060
- name: "Build Rust outputs",
1061
- shell: "bash",
1062
- env: {
1063
- CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
1064
- "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
1065
- },
1066
- run: timedBash(
1067
- "rust_workspace",
1068
- `cargo build --release --workspace${usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
1069
- hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
1070
- }`,
1071
- ),
1332
+ },
1333
+ {
1334
+ name: "Publish GitHub release assets",
1335
+ uses: "softprops/action-gh-release@v2",
1336
+ with: {
1337
+ files: "dist/rust-release/*",
1338
+ "generate-release-notes": true,
1339
+ tag_name: RELEASE_TAG,
1340
+ target_commitish: RELEASE_SHA,
1072
1341
  },
1073
- ...(bindingCommands.length
1074
- ? [
1075
- {
1076
- name: "Package UniFFI outputs",
1077
- shell: "bash",
1078
- env: { VERSION: RELEASE_VERSION },
1079
- run: timedBash("uniffi_packaging", bindingCommands.join("\n")),
1080
- },
1081
- ]
1082
- : []),
1083
- ...(binaryCommands.length
1084
- ? [
1085
- {
1086
- name: "Package release binaries",
1087
- shell: "bash",
1088
- run: timedBash("binary_packaging", binaryCommands.join("\n")),
1089
- },
1090
- ]
1091
- : []),
1092
- ...artifactSteps,
1093
- ],
1094
- };
1342
+ },
1343
+ ],
1344
+ };
1345
+ }
1346
+
1347
+ function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
1348
+ return {
1349
+ if: "${{ always() && needs.verify-context.result == 'success' && needs.rust-build.result != 'failure' && needs.rust-build.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
1350
+ needs: ["verify-context", "rust-build"],
1351
+ runsOn: ["ubuntu-latest"],
1352
+ permissions: {
1353
+ actions: JobPermission.READ,
1354
+ contents: JobPermission.READ,
1355
+ idToken: JobPermission.WRITE,
1356
+ },
1357
+ timeoutMinutes: 15,
1358
+ env: { BUN_VERSION, CI: "true" },
1359
+ steps: [
1360
+ ...nodeReleaseSetupSteps(project),
1361
+ ...releaseArtifactSteps({
1362
+ currentName: "Download native npm packages",
1363
+ recoveredName: "Download recovered native npm packages",
1364
+ pattern: "*-npm",
1365
+ path: "dist/uniffi/native",
1366
+ }),
1367
+ {
1368
+ name: "Publish native npm packages",
1369
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
1370
+ run: 'bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory dist/uniffi/native --version "$RELEASE_VERSION" $DRY_RUN',
1371
+ },
1372
+ ],
1373
+ };
1374
+ }
1375
+
1376
+ function rustNodeFacadePublishJob(
1377
+ project: DBXToolsJavaScriptProject,
1378
+ bindings: readonly RustReleaseBinding[],
1379
+ ): Job {
1380
+ return {
1381
+ if: releaseStageCondition("node"),
1382
+ needs: ["verify-context", "publish-node"],
1383
+ runsOn: ["ubuntu-latest"],
1384
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
1385
+ timeoutMinutes: 30,
1386
+ env: { BUN_VERSION, CI: "true" },
1387
+ steps: [
1388
+ ...nodeReleaseSetupSteps(project),
1389
+ {
1390
+ name: "Build and publish UniFFI npm facades",
1391
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
1392
+ run: bindings
1393
+ .flatMap((binding) => {
1394
+ const output = `dist/uniffi/facades/${binding.crate}`;
1395
+ return [
1396
+ `node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$RELEASE_VERSION" --output "${output}"`,
1397
+ `bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory "${output}/npm-facade" --version "$RELEASE_VERSION" $DRY_RUN`,
1398
+ ];
1399
+ })
1400
+ .join("\n"),
1401
+ },
1402
+ {
1403
+ name: "Smoke test published UniFFI npm facades",
1404
+ if: "${{ github.event_name == 'push' && vars.UNIFFI_FACADE_SMOKE == 'true' }}",
1405
+ continueOnError: true,
1406
+ env: { RELEASE_VERSION },
1407
+ run: [
1408
+ 'SMOKE_DIR="$(mktemp -d)"',
1409
+ "trap 'rm -rf \"$SMOKE_DIR\"' EXIT",
1410
+ 'cd "$SMOKE_DIR"',
1411
+ "npm init --yes >/dev/null",
1412
+ ...bindings.flatMap((binding) => [
1413
+ `npm install --ignore-scripts --no-audit --no-fund --package-lock=false "${binding.nodePackage}@$RELEASE_VERSION"`,
1414
+ `node -e 'import("${binding.nodePackage}")'`,
1415
+ ]),
1416
+ ].join("\n"),
1417
+ },
1418
+ ],
1419
+ };
1420
+ }
1421
+
1422
+ /** Generated Rust workspace plus convention-derived UniFFI binding packages. */
1423
+ export class DBXToolsRustWorkspace {
1424
+ readonly packages: readonly DBXToolsRustProject[];
1425
+ readonly nodePackages: readonly DBXToolsTypeScriptProject[];
1426
+ readonly pythonPackages: readonly PythonPackageOptions[];
1427
+ readonly bindingMappings: readonly RustBindingMapping[];
1428
+ readonly releaseBinaries: readonly RustReleaseBinaryMapping[];
1429
+ readonly workspaceMapping: RustWorkspaceMapping;
1430
+
1431
+ constructor(project: javascript.NodeProject, options: DBXToolsRustWorkspaceOptions) {
1432
+ const resolved = resolveRustWorkspaceOptions(project, options);
1433
+ this.packages = createRustPackages(project, resolved);
1434
+ const packageDependencies = createRustPackageDependencyResolver(
1435
+ this.packages,
1436
+ project,
1437
+ options,
1438
+ );
1439
+ this.releaseBinaries = planRustReleaseBinaries(this.packages, resolved);
1440
+ configureRustCliRegistry(project, options.cliRegistryPath, this.releaseBinaries);
1441
+
1442
+ const bindingPlan = planRustBindings(this.packages, packageDependencies, resolved);
1443
+ validateRustReleaseGraph(this.packages, packageDependencies);
1444
+ this.bindingMappings = bindingPlan.mappings;
1445
+ configureRustBindingFiles(bindingPlan, resolved);
1446
+ const releaseEnabled =
1447
+ resolved.release &&
1448
+ (this.bindingMappings.length > 0 ||
1449
+ this.packages.some((pkg) => pkg.packageOptions.release || !pkg.packageOptions.private));
1450
+ this.workspaceMapping = createRustWorkspaceMapping(
1451
+ this.packages,
1452
+ this.bindingMappings,
1453
+ this.releaseBinaries,
1454
+ resolved,
1455
+ );
1456
+ if (isDBXToolsJavaScriptProject()(project)) {
1457
+ project.dbxToolsConfig.rust = this.workspaceMapping;
1458
+ }
1459
+ configureRustBindingIgnores(project, this.bindingMappings);
1460
+ this.pythonPackages = planPythonBindingPackages(bindingPlan, resolved);
1461
+ this.nodePackages = createRustNodeBindingPackages(
1462
+ project,
1463
+ planNodeBindingPackages(project, bindingPlan, resolved),
1464
+ );
1465
+ configureRustWorkspaceFiles(project, this.packages, options, resolved);
1466
+ configureRustWorkspaceTasks(project);
1467
+ if (releaseEnabled) {
1468
+ this.addReleaseWorkflow(project, options, resolved.nativeTargets);
1469
+ }
1470
+ }
1471
+
1472
+ private addReleaseWorkflow(
1473
+ project: javascript.NodeProject,
1474
+ options: DBXToolsRustWorkspaceOptions,
1475
+ targets: readonly UniFFIReleaseTarget[],
1476
+ ): void {
1477
+ if (!project.github || !isDBXToolsJavaScriptProject()(project)) return;
1478
+ const plan = planRustRelease(project, options, targets, this.packages, this.bindingMappings);
1479
+ configureRustReleaseTask(project, plan);
1095
1480
  const workflow = releaseWorkflow(project);
1096
- if (hasTargetOutputs && targetMatrix.length) {
1097
- workflow.addJob("rust-build", buildJob);
1481
+ if (plan.hasTargetOutputs && plan.targets.length) {
1482
+ workflow.addJob("rust-build", rustBuildJob(plan));
1098
1483
  }
1099
- if (publicCrates.length) {
1100
- workflow.addJob("publish-cargo", {
1101
- if: "${{ github.event_name == 'push' }}",
1102
- needs: ["verify-context", "rust-build"],
1103
- runsOn: ["ubuntu-latest"],
1104
- permissions: { contents: JobPermission.READ },
1105
- steps: [
1106
- ...releaseSourceSteps(),
1107
- {
1108
- name: "Setup Rust",
1109
- uses: `dtolnay/rust-toolchain@${releaseRustVersion}`,
1110
- },
1111
- {
1112
- name: "Publish public crates",
1113
- env: { CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_REGISTRY_TOKEN }}" },
1114
- run: publicCrates
1115
- .map((crate) => `cargo publish --package "${crate}" --registry crates-io --no-verify`)
1116
- .join("\n"),
1117
- },
1118
- ],
1119
- });
1120
- workflow.addJob("publish-local-cargo", {
1121
- if: "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}",
1122
- needs: ["verify-context", "rust-build"],
1123
- runsOn: ["self-hosted"],
1124
- permissions: { contents: JobPermission.READ },
1125
- steps: [
1126
- ...releaseSourceSteps(),
1127
- {
1128
- name: "Setup Rust",
1129
- uses: `dtolnay/rust-toolchain@${releaseRustVersion}`,
1130
- },
1131
- {
1132
- name: "Publish Cargo crates locally",
1133
- env: { CARGO_REGISTRY_TOKEN: "${{ secrets.LOCAL_CARGO_TOKEN }}" },
1134
- run: publicCrates
1135
- .map(
1136
- (crate) =>
1137
- `cargo publish --package "${crate}" --registry "\${{ vars.LOCAL_CARGO_REGISTRY }}" --no-verify`,
1138
- )
1139
- .join("\n"),
1140
- },
1141
- ],
1142
- });
1484
+ if (plan.publicCrates.length) {
1485
+ workflow.addJob("publish-cargo", rustCargoPublishJob(plan, false));
1486
+ workflow.addJob("publish-local-cargo", rustCargoPublishJob(plan, true));
1143
1487
  }
1144
- if (releaseBinaries.length) {
1145
- workflow.addJob("publish-github-release", {
1146
- if: "${{ github.event_name == 'push' }}",
1147
- needs: ["verify-context", "rust-build"],
1148
- runsOn: ["ubuntu-latest"],
1149
- permissions: { contents: JobPermission.WRITE },
1150
- steps: [
1151
- {
1152
- name: "Download release binaries",
1153
- uses: "actions/download-artifact@v8",
1154
- with: {
1155
- pattern: "*-binary",
1156
- path: "dist/rust-release",
1157
- "merge-multiple": true,
1158
- },
1159
- },
1160
- {
1161
- name: "Publish GitHub release assets",
1162
- uses: "softprops/action-gh-release@v2",
1163
- with: {
1164
- files: "dist/rust-release/*",
1165
- "generate-release-notes": true,
1166
- tag_name: RELEASE_TAG,
1167
- target_commitish: RELEASE_SHA,
1168
- },
1169
- },
1170
- ],
1171
- });
1488
+ if (plan.releaseBinaries.length) {
1489
+ workflow.addJob("publish-github-release", rustGitHubReleaseJob());
1172
1490
  }
1173
-
1174
- const nodeBindings = bindings.filter((binding) => Boolean(binding.node && binding.nodePackage));
1175
- if (nodeBindings.length && hasNodeRelease(project)) {
1176
- workflow.addJob("publish-native-npm", {
1177
- if: "${{ always() && needs.verify-context.result == 'success' && needs.rust-build.result != 'failure' && needs.rust-build.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
1178
- needs: ["verify-context", "rust-build"],
1179
- runsOn: ["ubuntu-latest"],
1180
- permissions: {
1181
- actions: JobPermission.READ,
1182
- contents: JobPermission.READ,
1183
- idToken: JobPermission.WRITE,
1184
- },
1185
- timeoutMinutes: 15,
1186
- env: { BUN_VERSION, CI: "true" },
1187
- steps: [
1188
- ...nodeReleaseSetupSteps(project),
1189
- ...releaseArtifactSteps({
1190
- currentName: "Download native npm packages",
1191
- recoveredName: "Download recovered native npm packages",
1192
- pattern: "*-npm",
1193
- path: "dist/uniffi/native",
1194
- }),
1195
- {
1196
- name: "Publish native npm packages",
1197
- env: { RELEASE_VERSION, ...npmPublishEnvironment() },
1198
- run: 'bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory dist/uniffi/native --version "$RELEASE_VERSION" $DRY_RUN',
1199
- },
1200
- ],
1201
- });
1491
+ if (plan.nodeBindings.length && hasNodeRelease(project)) {
1492
+ workflow.addJob("publish-native-npm", rustNativeNpmPublishJob(project));
1202
1493
  const nodeJob = workflow.getJob("publish-node");
1203
1494
  if ("uses" in nodeJob) throw new Error("publish-node must be a workflow job");
1204
1495
  workflow.updateJob("publish-node", {
@@ -1206,46 +1497,7 @@ export class DBXToolsRustWorkspace {
1206
1497
  if: "${{ always() && needs.verify-context.result == 'success' && needs.publish-native-npm.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
1207
1498
  needs: ["verify-context", "publish-native-npm"],
1208
1499
  });
1209
- workflow.addJob("publish-node-facades", {
1210
- if: releaseStageCondition("node"),
1211
- needs: ["verify-context", "publish-node"],
1212
- runsOn: ["ubuntu-latest"],
1213
- permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
1214
- timeoutMinutes: 30,
1215
- env: { BUN_VERSION, CI: "true" },
1216
- steps: [
1217
- ...nodeReleaseSetupSteps(project),
1218
- {
1219
- name: "Build and publish UniFFI npm facades",
1220
- env: { RELEASE_VERSION, ...npmPublishEnvironment() },
1221
- run: nodeBindings
1222
- .flatMap((binding) => {
1223
- const output = `dist/uniffi/facades/${binding.crate}`;
1224
- return [
1225
- `node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$RELEASE_VERSION" --output "${output}"`,
1226
- `bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory "${output}/npm-facade" --version "$RELEASE_VERSION" $DRY_RUN`,
1227
- ];
1228
- })
1229
- .join("\n"),
1230
- },
1231
- {
1232
- name: "Smoke test published UniFFI npm facades",
1233
- if: "${{ github.event_name == 'push' && vars.UNIFFI_FACADE_SMOKE == 'true' }}",
1234
- continueOnError: true,
1235
- env: { RELEASE_VERSION },
1236
- run: [
1237
- 'SMOKE_DIR="$(mktemp -d)"',
1238
- "trap 'rm -rf \"$SMOKE_DIR\"' EXIT",
1239
- 'cd "$SMOKE_DIR"',
1240
- "npm init --yes >/dev/null",
1241
- ...nodeBindings.flatMap((binding) => [
1242
- `npm install --ignore-scripts --no-audit --no-fund --package-lock=false "${binding.nodePackage}@$RELEASE_VERSION"`,
1243
- `node -e 'import("${binding.nodePackage}")'`,
1244
- ]),
1245
- ].join("\n"),
1246
- },
1247
- ],
1248
- });
1500
+ workflow.addJob("publish-node-facades", rustNodeFacadePublishJob(project, plan.nodeBindings));
1249
1501
  }
1250
1502
  }
1251
1503
  }