@dbx-tools/projen 0.9.12 → 0.9.18

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/openapi.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * OpenAPI generator (tsoa-based).
2
+ * OpenAPI generator for tsoa controllers and code-first Rust producers.
3
3
  *
4
- * Scans `server`/`node` packages for modules that **import tsoa**
5
- * (`from 'tsoa'` / `from '@tsoa/runtime'`) and, for each package that has them,
6
- * generates a read-only `<root>/openapi/<name>` package:
4
+ * TypeScript packages are discovered from tsoa imports. Rust producers are
5
+ * recorded by `DBXToolsRustWorkspace`. Both feed the same optimized generated
6
+ * package:
7
7
  *
8
8
  * - `openapi.json` - the OpenAPI 3 spec (tsoa `generateSpec`, then Speakeasy
9
9
  * optimization to extract duplicate inline schemas into components).
@@ -33,11 +33,11 @@ import {
33
33
  writeFileSync,
34
34
  } from "node:fs";
35
35
  import { createRequire } from "node:module";
36
- import { join } from "node:path";
36
+ import { isAbsolute, join, relative, resolve } from "node:path";
37
37
  import { promisify } from "node:util";
38
38
  import * as bin from "@dbx-tools/core/bin";
39
39
  import { find } from "@dbx-tools/path";
40
- import { log, stringUtils } from "@dbx-tools/shared-core";
40
+ import { log, object, stringUtils } from "@dbx-tools/shared-core";
41
41
  import type * as ts from "typescript";
42
42
  import { lazyRequire } from "./_lazy-require.ts";
43
43
  import { makeReadonly, makeWritable, stampGenerated } from "./generated.ts";
@@ -48,6 +48,7 @@ import {
48
48
  toPosix,
49
49
  recordedPackages,
50
50
  } from "./packages.ts";
51
+ import type { RustOpenApiMapping, RustWorkspaceMapping } from "./project-rs.ts";
51
52
  import type { ReleaseUnitGraph } from "./release-catalog.ts";
52
53
  import { readWorkspaceVersion } from "./workspace-version.ts";
53
54
 
@@ -62,45 +63,95 @@ const SPEAKEASY_OPENAPI_RELEASE_URL = `https://github.com/speakeasy-api/openapi/
62
63
  const execFileAsync = promisify(execFile);
63
64
 
64
65
  // prettier-ignore
65
- const CLIENT_TEMPLATE = (
66
- // ============================================================================
67
- /*ts*/`
68
- import createClient, { type ClientOptions } from "openapi-fetch";
69
- import type { paths } from "./schema";
70
-
71
- /** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
72
- export function createApiClient(options?: ClientOptions) {
73
- return createClient<paths>(options);
74
- }
75
- `
76
- // ============================================================================
77
- );
78
- const CLIENT_SRC = stringUtils.dedent(CLIENT_TEMPLATE, { trimEnd: false });
66
+ const CLIENT_SRC =
67
+ stringUtils.dedent(
68
+ // ============================================================================
69
+ /*ts*/`
70
+ import createClient, { type ClientOptions } from "openapi-fetch";
71
+ import type { paths } from "./schema";
72
+
73
+ /** Create a typed OpenAPI client (openapi-fetch); safe to use in the browser. */
74
+ export function createApiClient(options?: ClientOptions) {
75
+ return createClient<paths>(options);
76
+ }
77
+ `
78
+ // ============================================================================
79
+ ) + "\n";
79
80
 
80
81
  /** True if any module file in `<pkg>/src` matches {@link TSOA_IMPORT}. */
81
82
  function hasTsoaControllers(pkg: Pick<RecordedPackage, "dir">): boolean {
82
- const srcDir = join(pkg.dir, "src");
83
- return [...find.findFiles("**/*", { cwd: srcDir })]
83
+ const srcDir = tsoaSource(pkg);
84
+ return object
85
+ .sequence(find.findFiles("**/*", { cwd: srcDir }))
84
86
  .filter(isModuleFile)
85
- .some((f) => TSOA_IMPORT.test(readFileSync(join(srcDir, f), "utf8")));
87
+ .some((file) => TSOA_IMPORT.test(readFileSync(join(srcDir, file), "utf8")));
88
+ }
89
+
90
+ function tsoaSource(pkg: Pick<RecordedPackage, "dir">): string {
91
+ return join(pkg.dir, "src");
92
+ }
93
+
94
+ function isTsoaCandidate(pkg: RecordedPackage): boolean {
95
+ return pkg.tags.includes("server") || pkg.tags.includes("node");
86
96
  }
87
97
 
88
98
  /** `server`/`node` packages (never the generated `openapi` tag) with a tsoa import. */
89
- function controllerPackages(): RecordedPackage[] {
90
- return recordedPackages().filter(
91
- (p) => (p.tags.includes("server") || p.tags.includes("node")) && hasTsoaControllers(p),
92
- );
99
+ function controllerPackages(): object.Sequence<RecordedPackage> {
100
+ return object.sequence(recordedPackages()).filter(isTsoaCandidate).filter(hasTsoaControllers);
101
+ }
102
+
103
+ function rustOpenapiMappings(): RustOpenApiMapping[] {
104
+ const manifest = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as unknown;
105
+ if (!object.isRecord(manifest)) return [];
106
+ const config = manifest.dbxToolsConfig;
107
+ if (!object.isRecord(config) || !object.isRecord(config.rust)) return [];
108
+ const mapping = config.rust as unknown as Partial<RustWorkspaceMapping>;
109
+ return Array.isArray(mapping.openapi) ? [...mapping.openapi] : [];
110
+ }
111
+
112
+ function inside(path: string, directory: string): boolean {
113
+ const child = relative(resolve(directory), resolve(path));
114
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
115
+ }
116
+
117
+ /** Whether one changed source belongs to any producing package directory. */
118
+ export function isOpenapiProducerSource(path: string, producers: readonly string[]): boolean {
119
+ const absolute = resolve(path);
120
+ return object.sequence(producers).some((producer) => inside(absolute, producer));
121
+ }
122
+
123
+ function rustOpenapiSources(mapping: RustOpenApiMapping): string[] {
124
+ return [resolve(repoRoot, mapping.rust, "src"), resolve(repoRoot, mapping.rust, "Cargo.toml")];
93
125
  }
94
126
 
95
- /** True if the changed path is a source file that matches {@link TSOA_IMPORT}. */
96
- export function isTsoaController(path: string): boolean {
97
- const posix = toPosix(path);
98
- return (
99
- !posix.includes(`/${OPENAPI_TAG}/`) &&
100
- isModuleFile(path) &&
101
- existsSync(path) &&
102
- TSOA_IMPORT.test(readFileSync(path, "utf8"))
103
- );
127
+ /** Source roots consumed by TypeScript and Rust OpenAPI producers. */
128
+ export function openapiWatchRoots(): string[] {
129
+ return [
130
+ ...controllerPackages()
131
+ .map((pkg) => resolve(tsoaSource(pkg)))
132
+ .join(object.sequence(rustOpenapiMappings()).flatMap(rustOpenapiSources))
133
+ .distinct(),
134
+ ];
135
+ }
136
+
137
+ /** Whether one changed source belongs to an OpenAPI-producing package. */
138
+ export function isOpenapiSource(path: string): boolean {
139
+ const absolute = resolve(path);
140
+ const segments = toPosix(relative(repoRoot, absolute)).split("/");
141
+ if (segments.includes(OPENAPI_TAG)) return false;
142
+ if (
143
+ object
144
+ .sequence(rustOpenapiMappings())
145
+ .flatMap(rustOpenapiSources)
146
+ .some((source) => inside(absolute, source))
147
+ ) {
148
+ return true;
149
+ }
150
+ return object
151
+ .sequence(recordedPackages())
152
+ .filter(isTsoaCandidate)
153
+ .filter((pkg) => inside(absolute, tsoaSource(pkg)))
154
+ .some(hasTsoaControllers);
104
155
  }
105
156
 
106
157
  /** GitHub release asset name for Speakeasy's OpenAPI binary. */
@@ -145,6 +196,59 @@ export async function optimizeOpenapiSpec(specPath: string, executable?: string)
145
196
  await execFileAsync(openapi, ["spec", "optimize", specPath, "--write", "--non-interactive"]);
146
197
  }
147
198
 
199
+ /** Cargo arguments that export one Rust producer's aide document. */
200
+ export function rustOpenapiArgs(
201
+ mapping: RustOpenApiMapping,
202
+ output: string,
203
+ targetDir = join(repoRoot, "target/openapi"),
204
+ ): string[] {
205
+ return [
206
+ "run",
207
+ "--quiet",
208
+ "--target-dir",
209
+ targetDir,
210
+ "--package",
211
+ mapping.crate,
212
+ ...(mapping.noDefaultFeatures ? ["--no-default-features"] : []),
213
+ ...(mapping.features.length ? ["--features", mapping.features.join(",")] : []),
214
+ ...(mapping.binary ? ["--bin", mapping.binary] : []),
215
+ "--",
216
+ "--generate-spec",
217
+ output,
218
+ ];
219
+ }
220
+
221
+ type OpenApiTypeTools = {
222
+ openapiTS: typeof import("openapi-typescript").default;
223
+ astToString: typeof import("openapi-typescript").astToString;
224
+ };
225
+
226
+ async function writeClientPackage(
227
+ outDir: string,
228
+ source: string,
229
+ tools: OpenApiTypeTools,
230
+ ): Promise<void> {
231
+ const srcDir = join(outDir, "src");
232
+ mkdirSync(srcDir, { recursive: true });
233
+ const specPath = join(outDir, "openapi.json");
234
+ const spec = JSON.parse(readFileSync(specPath, "utf8"));
235
+ const schemaPath = join(srcDir, "schema.ts");
236
+ makeWritable(schemaPath);
237
+ writeFileSync(schemaPath, tools.astToString(await tools.openapiTS(spec)));
238
+ stampGenerated(schemaPath, {
239
+ tool: "projen openapi (Speakeasy + openapi-typescript)",
240
+ source,
241
+ });
242
+
243
+ const clientPath = join(srcDir, "client.ts");
244
+ makeWritable(clientPath);
245
+ writeFileSync(clientPath, CLIENT_SRC);
246
+ stampGenerated(clientPath, {
247
+ tool: "projen openapi (openapi-fetch)",
248
+ source: "./schema",
249
+ });
250
+ }
251
+
148
252
  /**
149
253
  * Regenerate the `openapi` packages from every server/node package with a tsoa
150
254
  * import. Returns the package dirs it wrote so the caller can rebuild their barrels.
@@ -152,35 +256,43 @@ export async function optimizeOpenapiSpec(specPath: string, executable?: string)
152
256
  * members in `pnpm-workspace.yaml`.
153
257
  */
154
258
  export async function generateOpenapi(): Promise<string[]> {
155
- const pkgs = controllerPackages();
259
+ const pkgs = controllerPackages().toArray();
260
+ const rustMappings = rustOpenapiMappings();
156
261
  // Same reasoning as codegen's empty case: a workspace with no tsoa controllers
157
262
  // is not a condition worth a line on every synth.
158
- if (pkgs.length === 0) {
159
- logger.debug("no tsoa controllers found in any server/node package");
263
+ if (pkgs.length === 0 && rustMappings.length === 0) {
264
+ logger.debug("no TypeScript or Rust OpenAPI producers found");
160
265
  return [];
161
266
  }
162
267
 
163
268
  // Lazy, resilient loads: tsoa + typescript are CJS (require), openapi-typescript
164
269
  // is ESM (dynamic import).
165
- const require = createRequire(import.meta.url);
166
- const { generateSpec } = lazyRequire<typeof import("tsoa")>(
167
- require,
168
- "tsoa",
169
- "openapi generation",
170
- );
171
- const tsRuntime = lazyRequire<typeof ts>(require, "typescript", "openapi generation");
172
270
  const { default: openapiTS, astToString } = await import("openapi-typescript");
271
+ const tools = { openapiTS, astToString };
173
272
 
174
- // Read tsoa's controllers with decorator support; skipLibCheck keeps third-party
175
- // `.d.ts` out of the spec-generation compile.
176
- const compilerOptions: ts.CompilerOptions = {
177
- experimentalDecorators: true,
178
- target: tsRuntime.ScriptTarget.ES2022,
179
- module: tsRuntime.ModuleKind.ESNext,
180
- moduleResolution: tsRuntime.ModuleResolutionKind.Bundler,
181
- esModuleInterop: true,
182
- skipLibCheck: true,
183
- };
273
+ const typescript =
274
+ pkgs.length > 0
275
+ ? (() => {
276
+ const require = createRequire(import.meta.url);
277
+ const { generateSpec } = lazyRequire<typeof import("tsoa")>(
278
+ require,
279
+ "tsoa",
280
+ "openapi generation",
281
+ );
282
+ const runtime = lazyRequire<typeof ts>(require, "typescript", "openapi generation");
283
+ return {
284
+ generateSpec,
285
+ compilerOptions: {
286
+ experimentalDecorators: true,
287
+ target: runtime.ScriptTarget.ES2022,
288
+ module: runtime.ModuleKind.ESNext,
289
+ moduleResolution: runtime.ModuleResolutionKind.Bundler,
290
+ esModuleInterop: true,
291
+ skipLibCheck: true,
292
+ } satisfies ts.CompilerOptions,
293
+ };
294
+ })()
295
+ : undefined;
184
296
 
185
297
  const releaseGraphPath = join(repoRoot, ".projen/release-units.json");
186
298
  const releaseGraph = existsSync(releaseGraphPath)
@@ -196,8 +308,7 @@ export async function generateOpenapi(): Promise<string[]> {
196
308
  // its npm name - `p.name` is the (possibly-overridden) manifest name.
197
309
  const leaf = p.relPath.split("/").pop() ?? p.relPath;
198
310
  const outDir = join(repoRoot, p.root, OPENAPI_TAG, leaf);
199
- const srcDir = join(outDir, "src");
200
- mkdirSync(srcDir, { recursive: true });
311
+ mkdirSync(outDir, { recursive: true });
201
312
 
202
313
  // 1) tsoa writes a temporary openapi.json, Speakeasy optimizes it there, then
203
314
  // the complete spec moves into place so readers never observe an intermediate file.
@@ -205,7 +316,7 @@ export async function generateOpenapi(): Promise<string[]> {
205
316
  const tempDir = mkdtempSync(join(outDir, ".openapi-"));
206
317
  const tempSpecPath = join(tempDir, "openapi.json");
207
318
  try {
208
- await generateSpec(
319
+ await typescript!.generateSpec(
209
320
  {
210
321
  entryFile: "",
211
322
  noImplicitAdditionalProperties: "throw-on-extras",
@@ -216,7 +327,7 @@ export async function generateOpenapi(): Promise<string[]> {
216
327
  name: `${p.relPath} API`,
217
328
  version: specVersion,
218
329
  },
219
- compilerOptions,
330
+ typescript!.compilerOptions,
220
331
  );
221
332
  await optimizeOpenapiSpec(tempSpecPath);
222
333
  makeWritable(specPath);
@@ -225,28 +336,29 @@ export async function generateOpenapi(): Promise<string[]> {
225
336
  rmSync(tempDir, { recursive: true, force: true });
226
337
  }
227
338
  makeReadonly(specPath);
228
-
229
- // 2) src/schema.ts: types generated from the spec (openapi-typescript).
230
- const spec = JSON.parse(readFileSync(specPath, "utf8"));
231
- const schemaPath = join(srcDir, "schema.ts");
232
- makeWritable(schemaPath);
233
- writeFileSync(schemaPath, astToString(await openapiTS(spec)));
234
- stampGenerated(schemaPath, {
235
- tool: "projen openapi (tsoa + Speakeasy + openapi-typescript)",
236
- source: `the tsoa controllers in ${p.relPath}`,
237
- });
238
-
239
- // 3) src/client.ts: a typed openapi-fetch client over those types.
240
- const clientPath = join(srcDir, "client.ts");
241
- makeWritable(clientPath);
242
- writeFileSync(clientPath, CLIENT_SRC);
243
- stampGenerated(clientPath, {
244
- tool: "projen openapi (openapi-fetch)",
245
- source: "./schema",
246
- });
339
+ await writeClientPackage(outDir, `the tsoa controllers in ${p.relPath}`, tools);
247
340
 
248
341
  written.push(outDir);
249
342
  logger.success(`openapi/${leaf} (from ${p.relPath})`);
250
343
  }
344
+ for (const mapping of rustMappings) {
345
+ const outDir = resolve(repoRoot, mapping.output);
346
+ mkdirSync(outDir, { recursive: true });
347
+ const specPath = join(outDir, "openapi.json");
348
+ const tempDir = mkdtempSync(join(outDir, ".openapi-"));
349
+ const tempSpecPath = join(tempDir, "openapi.json");
350
+ try {
351
+ await execFileAsync("cargo", rustOpenapiArgs(mapping, tempSpecPath), { cwd: repoRoot });
352
+ await optimizeOpenapiSpec(tempSpecPath);
353
+ makeWritable(specPath);
354
+ renameSync(tempSpecPath, specPath);
355
+ } finally {
356
+ rmSync(tempDir, { recursive: true, force: true });
357
+ }
358
+ makeReadonly(specPath);
359
+ await writeClientPackage(outDir, `the aide routes in ${mapping.rust}`, tools);
360
+ written.push(outDir);
361
+ logger.success(`${mapping.output} (from ${mapping.rust})`);
362
+ }
251
363
  return written;
252
364
  }
package/src/project-js.ts CHANGED
@@ -1064,18 +1064,14 @@ class GeneratedSource extends Component {
1064
1064
  *
1065
1065
  * projen gives a monorepo root empty `compile`/`test` tasks - a child's tasks
1066
1066
  * are the child's business - so `bun run build` at the root type-checked nothing
1067
- * and ran no package tests. The fan-out is delegated to bun's own workspace
1068
- * filter rather than one `exec` per member, which matters three ways: bun runs
1069
- * the members in PARALLEL (measured ~2.5x faster across this repo than the
1070
- * sequential per-`cwd` form), a member that does not define the script is
1071
- * skipped instead of needing a guard, and the filter reads the workspace from
1072
- * `package.json` - so it stays correct when a package is added without a
1073
- * re-synth. A non-zero member exit still fails the run.
1067
+ * and ran no package tests. Compilation groups ordinary `tsc --build` members
1068
+ * into a few TypeScript processes and runs custom compile tasks alongside them. Tests
1069
+ * still use Bun's filtered workspace fan-out. Both read the current workspace
1070
+ * list from `package.json`, so a newly added member needs no re-synth.
1074
1071
  *
1075
- * `*` matches every workspace MEMBER and never the root itself, so the root
1072
+ * `*` matches every workspace MEMBER and never the root itself, so the test
1076
1073
  * task delegating to it cannot recurse. Members declared outside the scanned
1077
- * package roots (`extraWorkspaceMembers`) are workspace members too, so they are
1078
- * covered by the same filter.
1074
+ * package roots (`extraWorkspaceMembers`) are covered by both tasks.
1079
1075
  */
1080
1076
  class WorkspaceValidationTasks extends Component {
1081
1077
  private configured = false;
@@ -1087,9 +1083,8 @@ class WorkspaceValidationTasks extends Component {
1087
1083
  if (project.subprojects.length === 0 && project.extraWorkspaceMembers.length === 0) {
1088
1084
  return;
1089
1085
  }
1090
- for (const task of [project.compileTask, project.testTask]) {
1091
- task.exec(`bun run --filter '*' ${task.name}`);
1092
- }
1086
+ project.compileTask.exec(taskScript(project, "compile-workspace.ts"));
1087
+ project.testTask.exec("bun run --filter '*' test");
1093
1088
  }
1094
1089
  }
1095
1090
 
package/src/project-py.ts CHANGED
@@ -7,7 +7,7 @@ import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workf
7
7
  import { DBX_TOOLS_LICENSE, projectReleaseBranch, projectRepositoryUrl } from "./project-js.ts";
8
8
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
9
9
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
10
- import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
10
+ import { RELEASE_TAG, RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
11
11
  import {
12
12
  independentReleaseSetupSteps,
13
13
  registerIndependentPublicationJob,
@@ -546,9 +546,9 @@ export class DBXToolsPythonWorkspace extends Component {
546
546
  }
547
547
  workflow.addJob("build-python", {
548
548
  if: usesRustArtifacts
549
- ? "${{ 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 == 'python') }}"
549
+ ? "${{ always() && needs.verify-context.result == 'success' && needs.rust-assets.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'python') }}"
550
550
  : releaseStageCondition("python"),
551
- needs: ["verify-context", ...(usesRustArtifacts ? ["rust-build"] : [])],
551
+ needs: ["verify-context", ...(usesRustArtifacts ? ["rust-assets"] : [])],
552
552
  runsOn: ["ubuntu-latest"],
553
553
  permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
554
554
  timeoutMinutes: 20,
@@ -567,8 +567,27 @@ export class DBXToolsPythonWorkspace extends Component {
567
567
  recoveredName: `Download recovered ${publication.distribution} native wheels`,
568
568
  pattern: `${publication.distribution}--*--python-wheel`,
569
569
  path: `dist/${publication.directory}`,
570
+ condition: "needs.verify-context.outputs.build_mode == 'remote'",
570
571
  }),
571
572
  ),
573
+ ...(uniffiPublications.length
574
+ ? [
575
+ {
576
+ name: "Download locally built native wheels",
577
+ if: "${{ needs.verify-context.outputs.build_mode == 'local' }}",
578
+ env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG },
579
+ shell: "bash",
580
+ run: [
581
+ "mkdir -p dist/local-wheels",
582
+ 'gh release download "$RELEASE_TAG" --pattern "*.whl" --dir dist/local-wheels',
583
+ ...uniffiPublications.flatMap((publication) => [
584
+ `mkdir -p dist/${publication.directory}`,
585
+ `cp dist/local-wheels/${publication.distribution.replaceAll("-", "_")}-*.whl dist/${publication.directory}/`,
586
+ ]),
587
+ ].join("\n"),
588
+ },
589
+ ]
590
+ : []),
572
591
  {
573
592
  name: "Stamp workspace versions",
574
593
  env: { VERSION: RELEASE_VERSION },
package/src/project-rs.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /** Filesystem-discovered Rust workspaces and UniFFI binding package wiring. */
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, relative, resolve } from "node:path";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import * as projectUtils from "@dbx-tools/core/project-utils";
5
5
  import { stringUtils } from "@dbx-tools/shared-core";
6
6
  import { Component, Project, TextFile, TomlFile, javascript } from "projen";
7
+ import { releaseBinaryAssetName } from "./_release-platform.ts";
7
8
  import {
8
9
  RustProject,
9
10
  type CargoExampleOptions as RustCargoExampleOptions,
@@ -15,8 +16,10 @@ import {
15
16
  type DBXToolsRustProjectOptions as RustProjectOptions,
16
17
  type RustCrateOptions as RustCrateConfiguration,
17
18
  type RustCliOptions as RustCliConfiguration,
19
+ type RustOpenApiOptions as RustOpenApiConfiguration,
18
20
  } from "./_rust-project.ts";
19
21
  import {
22
+ rustAssetsJob,
20
23
  configureRustReleaseTask,
21
24
  independentRustCargoPublishJob,
22
25
  independentRustGitHubReleaseJob,
@@ -24,7 +27,6 @@ import {
24
27
  independentRustNodeFacadePublishJob,
25
28
  orderRustBindings,
26
29
  planRustRelease,
27
- releaseBinaryAssetName,
28
30
  rustBuildJob,
29
31
  rustCargoPublishJob,
30
32
  rustGitHubReleaseJob,
@@ -57,6 +59,7 @@ export type CargoExampleOptions = RustCargoExampleOptions;
57
59
  export type DBXToolsRustProjectOptions = RustProjectOptions;
58
60
  export type RustCrateOptions = RustCrateConfiguration;
59
61
  export type RustCliOptions = RustCliConfiguration;
62
+ export type RustOpenApiOptions = RustOpenApiConfiguration;
60
63
 
61
64
  /** Public Rust project facade; implementation lives apart from workspace/release coordination. */
62
65
  export class DBXToolsRustProject extends RustProject {
@@ -116,6 +119,7 @@ export interface UniFFIReleaseTarget {
116
119
  readonly os: RustReleaseOs;
117
120
  readonly cpu: RustReleaseCpu;
118
121
  readonly libc?: "glibc";
122
+ readonly glibcVersion?: string;
119
123
  }
120
124
 
121
125
  /** Native targets built on matching GitHub-hosted runners. */
@@ -128,6 +132,7 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
128
132
  os: RustReleaseOs.LINUX,
129
133
  cpu: RustReleaseCpu.X64,
130
134
  libc: "glibc",
135
+ glibcVersion: "2.35",
131
136
  },
132
137
  {
133
138
  runner: "ubuntu-24.04-arm",
@@ -137,6 +142,7 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
137
142
  os: RustReleaseOs.LINUX,
138
143
  cpu: RustReleaseCpu.ARM64,
139
144
  libc: "glibc",
145
+ glibcVersion: "2.39",
140
146
  },
141
147
  {
142
148
  runner: "macos-15-intel",
@@ -269,12 +275,23 @@ export interface RustBindingMapping {
269
275
  readonly dependencies?: readonly string[];
270
276
  }
271
277
 
278
+ /** Persisted Rust OpenAPI producer consumed by the shared OpenAPI task. */
279
+ export interface RustOpenApiMapping {
280
+ readonly crate: string;
281
+ readonly rust: string;
282
+ readonly output: string;
283
+ readonly binary?: string;
284
+ readonly features: readonly string[];
285
+ readonly noDefaultFeatures: boolean;
286
+ }
287
+
272
288
  /** Persisted Rust workspace state consumed by `sync --watch`. */
273
289
  export interface RustWorkspaceMapping {
274
290
  readonly root: string;
275
291
  readonly crates: readonly string[];
276
292
  readonly bindings: readonly RustBindingMapping[];
277
293
  readonly binaries: readonly RustReleaseBinaryMapping[];
294
+ readonly openapi: readonly RustOpenApiMapping[];
278
295
  }
279
296
 
280
297
  /** One platform archive published for a Rust CLI binary. */
@@ -296,6 +313,10 @@ export interface RustReleaseBinaryMapping {
296
313
  readonly tagPrefix: string;
297
314
  readonly tag: string;
298
315
  readonly repository: string;
316
+ /** Cargo crate published or mirrored for `cargo install` when GitHub has no archive. */
317
+ readonly crateName: string;
318
+ /** Feature flags passed to `cargo install --features` for this binary target. */
319
+ readonly cargoFeatures: readonly string[];
299
320
  readonly assets: readonly RustReleaseBinaryAssetMapping[];
300
321
  }
301
322
 
@@ -392,6 +413,7 @@ function planRustReleaseBinaries(
392
413
  release: pkg.packageOptions.release,
393
414
  excludedOs: pkg.packageOptions.releaseExcludeOs,
394
415
  cli: pkg.packageOptions.cli,
416
+ requiredFeatures: undefined,
395
417
  },
396
418
  ]
397
419
  : []),
@@ -404,6 +426,7 @@ function planRustReleaseBinaries(
404
426
  release: binary.release,
405
427
  excludedOs: binary.releaseExcludeOs,
406
428
  cli: binary.cli!,
429
+ requiredFeatures: binary.requiredFeatures,
407
430
  })),
408
431
  ];
409
432
  return configuredBinaries.map((binary) => {
@@ -450,6 +473,8 @@ function planRustReleaseBinaries(
450
473
  tagPrefix,
451
474
  tag: `${tagPrefix}${identity.version}`,
452
475
  repository: resolved.repository,
476
+ crateName: pkg.crateName,
477
+ cargoFeatures: [...(binary.requiredFeatures ?? [])],
453
478
  assets: resolved.nativeTargets
454
479
  .filter((target) => !excludedOs.has(target.os))
455
480
  .map((target) => ({
@@ -770,11 +795,26 @@ function createRustWorkspaceMapping(
770
795
  binaries: readonly RustReleaseBinaryMapping[],
771
796
  resolved: ResolvedRustWorkspaceOptions,
772
797
  ): RustWorkspaceMapping {
798
+ const openapiRoot = join(dirname(resolved.nodeRoot), "openapi");
773
799
  return {
774
800
  root: resolved.root,
775
801
  crates: packages.map((pkg) => `${resolved.root}/${pkg.packageOptions.directory}`),
776
802
  bindings,
777
803
  binaries,
804
+ openapi: packages.flatMap((pkg) => {
805
+ if (!pkg.packageOptions.openapi) return [];
806
+ const options = pkg.packageOptions.openapi === true ? {} : pkg.packageOptions.openapi;
807
+ return [
808
+ {
809
+ crate: pkg.crateName,
810
+ rust: `${resolved.root}/${pkg.packageOptions.directory}`,
811
+ output: toPosix(join(openapiRoot, pkg.packageOptions.directory)),
812
+ ...(options.binary ? { binary: options.binary } : {}),
813
+ features: [...(options.features ?? [])],
814
+ noDefaultFeatures: options.noDefaultFeatures ?? false,
815
+ },
816
+ ];
817
+ }),
778
818
  };
779
819
  }
780
820
 
@@ -963,6 +1003,9 @@ export class DBXToolsRustWorkspace extends Component {
963
1003
  name: binary.binaryName,
964
1004
  generated: true,
965
1005
  data: {
1006
+ binary: binary.binaryName,
1007
+ crate: binary.crateName,
1008
+ features: binary.cargoFeatures,
966
1009
  targets: resolved.nativeTargets.filter((target) =>
967
1010
  binary.assets.some((asset) => asset.os === target.os && asset.cpu === target.cpu),
968
1011
  ),
@@ -1059,7 +1102,7 @@ export class DBXToolsRustWorkspace extends Component {
1059
1102
  }
1060
1103
  configureRustReleaseTask(project, plan);
1061
1104
  if (project.releaseCatalog.mode === "independent") {
1062
- if (plan.hasTargetOutputs && plan.targets.length) {
1105
+ if (plan.targets.length) {
1063
1106
  workflow.addJob("rust-build", rustBuildJob(plan, independentReleaseSetupSteps(project)));
1064
1107
  }
1065
1108
  if (plan.publicCrates.length) {
@@ -1081,15 +1124,16 @@ export class DBXToolsRustWorkspace extends Component {
1081
1124
  }
1082
1125
  return;
1083
1126
  }
1084
- if (plan.hasTargetOutputs && plan.targets.length) {
1127
+ if (plan.targets.length) {
1085
1128
  workflow.addJob("rust-build", rustBuildJob(plan));
1129
+ workflow.addJob("rust-assets", rustAssetsJob());
1086
1130
  }
1087
1131
  if (plan.publicCrates.length) {
1088
1132
  workflow.addJob("publish-cargo", rustCargoPublishJob(plan, false));
1089
1133
  workflow.addJob("publish-local-cargo", rustCargoPublishJob(plan, true));
1090
1134
  }
1091
- if (plan.releaseBinaries.length) {
1092
- workflow.addJob("publish-github-release", rustGitHubReleaseJob());
1135
+ if (plan.hasTargetOutputs) {
1136
+ workflow.addJob("publish-github-release", rustGitHubReleaseJob(plan));
1093
1137
  }
1094
1138
  if (plan.nodeBindings.length && hasNodeRelease(project)) {
1095
1139
  workflow.addJob("publish-native-npm", rustNativeNpmPublishJob(project));
@@ -28,6 +28,29 @@ export interface GithubAuthenticatedAccount {
28
28
  scopes: string[];
29
29
  }
30
30
 
31
+ /** Repository capabilities returned for one authenticated actor. */
32
+ export interface GithubRepositoryPermissions {
33
+ readonly push: boolean;
34
+ readonly maintain: boolean;
35
+ readonly admin: boolean;
36
+ }
37
+
38
+ /** Read normalized repository permissions from a GitHub repository response. */
39
+ export function githubRepositoryPermissions(value: unknown): GithubRepositoryPermissions {
40
+ const repository = object.isRecord(value) ? value : undefined;
41
+ const permissions = object.isRecord(repository?.permissions) ? repository.permissions : undefined;
42
+ return {
43
+ push: permissions?.push === true,
44
+ maintain: permissions?.maintain === true,
45
+ admin: permissions?.admin === true,
46
+ };
47
+ }
48
+
49
+ /** Whether the actor can manage releases rather than only push source. */
50
+ export function githubRepositoryCanManage(permissions: GithubRepositoryPermissions): boolean {
51
+ return permissions.maintain || permissions.admin;
52
+ }
53
+
31
54
  /** Parse successful GitHub CLI accounts with the active account first. */
32
55
  export function githubAuthenticatedAccounts(
33
56
  statusOutput: string,