@dbx-tools/projen 0.9.5 → 0.9.6

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.
Files changed (47) hide show
  1. package/README.md +64 -16
  2. package/index.ts +7 -5
  3. package/package.json +9 -6
  4. package/src/_rust-project.ts +356 -0
  5. package/src/_rust-release-workflow.ts +901 -0
  6. package/src/_rust-release.ts +605 -0
  7. package/src/_task-command.ts +89 -0
  8. package/src/barrels.ts +9 -7
  9. package/src/engine-root.ts +5 -5
  10. package/src/openapi.ts +1 -1
  11. package/src/packages.ts +9 -9
  12. package/src/pnpm-workspace.ts +1 -1
  13. package/src/project-js.ts +19 -25
  14. package/src/project-py.ts +6 -6
  15. package/src/project-rs.ts +100 -1098
  16. package/src/projen-version.ts +9 -0
  17. package/src/publish.ts +3 -4
  18. package/src/release-catalog.ts +1 -1
  19. package/src/release.ts +11 -12
  20. package/src/scaffold.ts +1 -1
  21. package/src/uniffi.ts +7 -0
  22. package/src/watch.ts +2 -2
  23. package/src/workspace-version.ts +20 -15
  24. package/tasks/barrels.ts +4 -4
  25. package/tasks/build-rust-release.ts +68 -0
  26. package/tasks/bump.ts +3 -2
  27. package/tasks/clean.ts +3 -3
  28. package/tasks/local-publish.ts +21 -3
  29. package/tasks/openapi.ts +2 -2
  30. package/tasks/publish-npm.ts +12 -6
  31. package/tasks/publish-python.ts +2 -2
  32. package/tasks/publish-uniffi-local.ts +11 -15
  33. package/tasks/publish.ts +88 -88
  34. package/tasks/python-registry.ts +1 -1
  35. package/tasks/release-bootstrap.ts +2 -2
  36. package/tasks/release-plan.ts +1 -1
  37. package/tasks/release-please.ts +1 -1
  38. package/tasks/release-pr.ts +60 -115
  39. package/tasks/release-request.ts +8 -29
  40. package/tasks/release-summary-units.ts +2 -9
  41. package/tasks/release-summary.ts +35 -24
  42. package/tasks/release-version.ts +44 -0
  43. package/tasks/rust-release.d.ts +41 -0
  44. package/tasks/rust-release.mjs +1717 -0
  45. package/tasks/uniffi-python.js +1 -1
  46. package/tasks/uniffi.ts +7 -1
  47. package/tasks/version-check.ts +6 -5
@@ -0,0 +1,901 @@
1
+ /** Rust release planning and generated GitHub workflow jobs. */
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { TextFile, javascript } from "projen";
7
+ import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
8
+ import type { RustProject } from "./_rust-project.ts";
9
+ import { BUN_VERSION } from "./bun-workflow.ts";
10
+ import type { DBXToolsJavaScriptProject } from "./project-js.ts";
11
+ import type {
12
+ DBXToolsRustWorkspaceOptions,
13
+ RustBindingMapping,
14
+ RustReleaseOs,
15
+ UniFFIReleaseTarget,
16
+ } from "./project-rs.ts";
17
+ import { defaultReleaseUnitId } from "./release-catalog.ts";
18
+ import {
19
+ RELEASE_SHA,
20
+ RELEASE_SUMMARY_FILE,
21
+ RELEASE_TAG,
22
+ RELEASE_VERSION,
23
+ releaseSourceSteps,
24
+ } from "./release-dispatch.ts";
25
+ import {
26
+ independentReleaseSetupSteps,
27
+ npmPublishEnvironment,
28
+ nodeReleaseSetupSteps,
29
+ releaseArtifactSteps,
30
+ releaseStageCondition,
31
+ } from "./release.ts";
32
+
33
+ const require = createRequire(import.meta.url);
34
+
35
+ type RustPackageDependencyResolver = (pkg: RustProject) => RustProject[];
36
+
37
+ const RUST_CACHE_ENV = {
38
+ CARGO_INCREMENTAL: "0",
39
+ CARGO_TERM_COLOR: "always",
40
+ } as const;
41
+
42
+ function rustCacheSteps(sharedKey: string): readonly JobStep[] {
43
+ return [
44
+ {
45
+ name: "Cache Cargo registry and dependencies",
46
+ id: "cargo_cache",
47
+ uses: "Swatinem/rust-cache@v2.9.2",
48
+ with: {
49
+ "cache-targets": true,
50
+ "cache-workspace-crates": true,
51
+ "add-job-id-key": false,
52
+ "add-rust-environment-hash-key": true,
53
+ "shared-key": sharedKey,
54
+ "save-if": "${{ github.event_name == 'push' }}",
55
+ },
56
+ },
57
+ ];
58
+ }
59
+
60
+ function timedBash(phase: string, command: string): string {
61
+ return [
62
+ "SECONDS=0",
63
+ `trap 'status=$?; echo "phase=${phase} duration_seconds=$SECONDS status=$status"; exit "$status"' EXIT`,
64
+ command,
65
+ ].join("\n");
66
+ }
67
+
68
+ export function releaseBinaryAssetName(
69
+ binaryName: string,
70
+ nodeTarget: string,
71
+ os: RustReleaseOs | "darwin" | "linux" | "win32",
72
+ ): string {
73
+ const extension = os === "win32" ? "zip" : "tar.gz";
74
+ return `${binaryName}-${nodeTarget}.${extension}`;
75
+ }
76
+
77
+ function taskSource(name: string): string {
78
+ const sourceDirectory = dirname(fileURLToPath(import.meta.url));
79
+ const candidates = [
80
+ resolve(sourceDirectory, `../tasks/${name}`),
81
+ resolve(sourceDirectory, `../../tasks/${name}`),
82
+ ];
83
+ const source = candidates.find(existsSync);
84
+ if (!source) throw new Error(`Could not locate tasks/${name}`);
85
+ return readFileSync(source, "utf8");
86
+ }
87
+
88
+ function smolTomlSource(name: "dist/index.cjs" | "LICENSE"): string {
89
+ const entry = require.resolve("smol-toml");
90
+ return readFileSync(
91
+ resolve(dirname(entry), name === "LICENSE" ? "../LICENSE" : "index.cjs"),
92
+ "utf8",
93
+ );
94
+ }
95
+
96
+ export function orderRustBindings(bindings: readonly RustBindingMapping[]): RustBindingMapping[] {
97
+ const ordered: RustBindingMapping[] = [];
98
+ const visiting = new Set<string>();
99
+ const completed = new Set<string>();
100
+ const visit = (binding: RustBindingMapping): void => {
101
+ if (completed.has(binding.crate)) return;
102
+ if (visiting.has(binding.crate)) {
103
+ throw new Error(`Cyclic Rust binding dependency: ${binding.crate}`);
104
+ }
105
+ visiting.add(binding.crate);
106
+ for (const name of binding.dependencies ?? []) {
107
+ const dependency = bindings.find((candidate) => candidate.crate === name);
108
+ if (!dependency) throw new Error(`Missing Rust binding dependency: ${name}`);
109
+ visit(dependency);
110
+ }
111
+ visiting.delete(binding.crate);
112
+ completed.add(binding.crate);
113
+ ordered.push(binding);
114
+ };
115
+ for (const binding of bindings) visit(binding);
116
+ return ordered;
117
+ }
118
+
119
+ interface RustReleaseBinding extends RustBindingMapping {
120
+ readonly node: string;
121
+ readonly python: string;
122
+ readonly nodePackage: string;
123
+ readonly pythonPackage: string;
124
+ }
125
+
126
+ interface RustReleaseBinaryPlan {
127
+ readonly crate: string;
128
+ readonly binary: string;
129
+ readonly excludedOs: readonly RustReleaseOs[];
130
+ }
131
+
132
+ type RustReleaseTargetPlan = Readonly<Record<string, string | boolean | number>>;
133
+
134
+ export interface RustReleasePlan {
135
+ readonly releaseRustVersion: string;
136
+ readonly releaseTask: string;
137
+ readonly releaseHelper: string;
138
+ readonly rustRoot: string;
139
+ readonly bindings: readonly RustReleaseBinding[];
140
+ readonly nodeBindings: readonly RustReleaseBinding[];
141
+ readonly releaseBinaries: readonly RustReleaseBinaryPlan[];
142
+ readonly publicCrates: readonly string[];
143
+ readonly targets: readonly RustReleaseTargetPlan[];
144
+ readonly hasReleaseExclusions: boolean;
145
+ readonly hasPythonBindings: boolean;
146
+ readonly usesCargoLock: boolean;
147
+ readonly usePreinstalledWindowsRust: boolean;
148
+ readonly hasTargetOutputs: boolean;
149
+ }
150
+
151
+ export function planRustRelease(
152
+ project: javascript.NodeProject,
153
+ options: DBXToolsRustWorkspaceOptions,
154
+ targets: readonly UniFFIReleaseTarget[],
155
+ packages: readonly RustProject[],
156
+ bindingMappings: readonly RustBindingMapping[],
157
+ packageDependencies: RustPackageDependencyResolver,
158
+ ): RustReleasePlan {
159
+ const releaseRustVersion = options.releaseRustVersion ?? "stable";
160
+ const bindings = bindingMappings.map((binding) => ({
161
+ ...binding,
162
+ node: binding.node ?? "",
163
+ python: binding.python ?? "",
164
+ nodePackage: binding.nodePackage ?? "",
165
+ pythonPackage: binding.pythonPackage ?? "",
166
+ }));
167
+ const releaseBinaries = packages
168
+ .filter((pkg) => pkg.packageOptions.release)
169
+ .map((pkg) => ({
170
+ crate: pkg.crateName,
171
+ binary: pkg.packageOptions.binaryName ?? pkg.crateName,
172
+ excludedOs: pkg.packageOptions.releaseExcludeOs ?? [],
173
+ }));
174
+ const publicCrates = orderRustBindings(
175
+ packages
176
+ .filter((pkg) => !pkg.packageOptions.private)
177
+ .map((pkg) => ({
178
+ crate: pkg.crateName,
179
+ rust: pkg.outdir,
180
+ dependencies: packageDependencies(pkg).map((dependency) => dependency.crateName),
181
+ })),
182
+ ).map((pkg) => pkg.crate);
183
+ const hasReleaseExclusions = packages.some((pkg) => pkg.packageOptions.releaseExcludeOs?.length);
184
+ const plannedTargets: RustReleaseTargetPlan[] = targets.map((target) => {
185
+ const cargoExcludes = packages
186
+ .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
187
+ .map((pkg) => `--exclude ${pkg.crateName}`)
188
+ .join(" ");
189
+ return {
190
+ runner: target.runner,
191
+ cargo: target.cargo,
192
+ node: target.node,
193
+ python: target.python,
194
+ os: target.os,
195
+ cpu: target.cpu,
196
+ ...(target.libc ? { libc: target.libc } : {}),
197
+ ...(hasReleaseExclusions ? { cargoExcludes } : {}),
198
+ };
199
+ });
200
+ const hasTargetOutputs =
201
+ bindings.length > 0 || releaseBinaries.length > 0 || publicCrates.length > 0;
202
+ if (hasTargetOutputs && plannedTargets.length === 0) {
203
+ throw new Error("Rust release requires at least one target");
204
+ }
205
+ return {
206
+ releaseRustVersion,
207
+ releaseTask: ".projen/uniffi-release.mjs",
208
+ releaseHelper: ".projen/rust-release.mjs",
209
+ rustRoot: options.root ?? "packages/rs",
210
+ bindings,
211
+ nodeBindings: bindings.filter((binding) => Boolean(binding.node && binding.nodePackage)),
212
+ releaseBinaries,
213
+ publicCrates,
214
+ targets: plannedTargets,
215
+ hasReleaseExclusions,
216
+ hasPythonBindings: bindings.some((binding) => binding.python),
217
+ usesCargoLock: existsSync(join(project.outdir, "Cargo.lock")),
218
+ usePreinstalledWindowsRust: releaseRustVersion === "stable",
219
+ hasTargetOutputs,
220
+ };
221
+ }
222
+
223
+ export function configureRustReleaseTask(
224
+ project: javascript.NodeProject,
225
+ plan: RustReleasePlan,
226
+ ): void {
227
+ const bindingSupportFiles = [
228
+ plan.releaseTask,
229
+ ".projen/uniffi-python.js",
230
+ ".projen/smol-toml.cjs",
231
+ ".projen/smol-toml.LICENSE",
232
+ ] as const;
233
+ new TextFile(project, plan.releaseHelper, {
234
+ lines: taskSource("rust-release.mjs").trimEnd().split("\n"),
235
+ });
236
+ if (plan.bindings.length) {
237
+ new TextFile(project, plan.releaseTask, {
238
+ lines: taskSource("uniffi-release.mjs").trimEnd().split("\n"),
239
+ });
240
+ new TextFile(project, bindingSupportFiles[1], {
241
+ lines: taskSource("uniffi-python.js").trimEnd().split("\n"),
242
+ });
243
+ new TextFile(project, bindingSupportFiles[2], {
244
+ lines: smolTomlSource("dist/index.cjs").trimEnd().split("\n"),
245
+ });
246
+ new TextFile(project, bindingSupportFiles[3], {
247
+ lines: smolTomlSource("LICENSE").trimEnd().split("\n"),
248
+ });
249
+ } else {
250
+ for (const path of bindingSupportFiles) project.tryRemoveFile(path);
251
+ }
252
+ project.addTask("rs:release-fingerprint", {
253
+ description: "Write the version-independent Rust release build manifest",
254
+ exec: [
255
+ `node ${plan.releaseHelper} fingerprint`,
256
+ "--root .",
257
+ ...plan.targets.map(
258
+ (target) =>
259
+ `--target ${JSON.stringify(`${String(target.cargo)}|${String(target.cargoExcludes ?? "")}`)}`,
260
+ ),
261
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
262
+ `--source ${JSON.stringify(plan.rustRoot)}`,
263
+ "--portable",
264
+ ].join(" "),
265
+ });
266
+ }
267
+
268
+ function rustBindingCommands(plan: RustReleasePlan, independent: boolean): string[] {
269
+ return plan.bindings.map((binding) => {
270
+ const unit = defaultReleaseUnitId("rust", binding.crate);
271
+ const command = [
272
+ `node ${plan.releaseTask} build`,
273
+ `--crate "${binding.crate}"`,
274
+ `--node "${binding.node}"`,
275
+ `--python "${binding.python}"`,
276
+ `--node-package "${binding.nodePackage}"`,
277
+ `--python-package "${binding.pythonPackage}"`,
278
+ `--python-module "${binding.pythonModule}"`,
279
+ '--cargo-target "${{ matrix.cargo }}"',
280
+ '--node-triple "${{ matrix.node }}"',
281
+ '--python-tag "${{ matrix.python }}"',
282
+ '--os "${{ matrix.os }}"',
283
+ '--cpu "${{ matrix.cpu }}"',
284
+ '--libc "${{ matrix.libc }}"',
285
+ independent
286
+ ? `--version "$(jq -r --arg unit "${unit}" '.units[] | select(.id == $unit) | .newVersion' dist/release-plan.json)"`
287
+ : '--version "$VERSION"',
288
+ `--output "dist/release/${binding.crate}/\${{ matrix.node }}"`,
289
+ "--skip-build",
290
+ ].join(" \\\n ");
291
+ return independent
292
+ ? [
293
+ `if jq -e --arg unit "${unit}" '.units[] | select(.id == $unit)' dist/release-plan.json >/dev/null; then`,
294
+ ` ${command.replaceAll("\n", "\n ")}`,
295
+ "fi",
296
+ ].join("\n")
297
+ : command;
298
+ });
299
+ }
300
+
301
+ function rustReleaseBinaryCondition(excludedOs: readonly RustReleaseOs[]): string | undefined {
302
+ return excludedOs.length
303
+ ? `\${{ ${excludedOs.map((os) => `matrix.os != '${os}'`).join(" && ")} }}`
304
+ : undefined;
305
+ }
306
+
307
+ function rustBinaryCommands(plan: RustReleasePlan, independent: boolean): string[] {
308
+ return plan.releaseBinaries.flatMap((pkg) => {
309
+ const unit = defaultReleaseUnitId("rust", pkg.crate);
310
+ const windowsAsset = releaseBinaryAssetName(pkg.binary, "${{ matrix.node }}", "win32");
311
+ const unixAsset = releaseBinaryAssetName(pkg.binary, "${{ matrix.node }}", "linux");
312
+ const commands = [
313
+ `mkdir -p "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
314
+ `SOURCE="target/\${{ matrix.cargo }}/release/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
315
+ `DESTINATION="dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage/${pkg.binary}\${{ matrix.os == 'win32' && '.exe' || '' }}"`,
316
+ 'cp "$SOURCE" "$DESTINATION"',
317
+ 'if [ "${{ matrix.os }}" = "win32" ]; then',
318
+ ` 7z a "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${windowsAsset}" "$DESTINATION"`,
319
+ "else",
320
+ ` tar -C "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage" -czf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/${unixAsset}" "${pkg.binary}"`,
321
+ "fi",
322
+ `rm -rf "dist/release/${pkg.crate}/\${{ matrix.node }}/binary/stage"`,
323
+ ];
324
+ const excludedCondition = pkg.excludedOs
325
+ .map((os) => `[ "\${{ matrix.os }}" != "${os}" ]`)
326
+ .join(" && ");
327
+ const platformCommands = excludedCondition
328
+ ? [`if ${excludedCondition}; then`, ...commands.map((command) => ` ${command}`), "fi"]
329
+ : commands;
330
+ return independent
331
+ ? [
332
+ `if jq -e --arg unit "${unit}" '.units[] | select(.id == $unit)' dist/release-plan.json >/dev/null; then`,
333
+ ...platformCommands.map((command) => ` ${command}`),
334
+ "fi",
335
+ ]
336
+ : platformCommands;
337
+ });
338
+ }
339
+
340
+ function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
341
+ return [
342
+ ...plan.bindings.flatMap((binding) => [
343
+ ...(binding.node
344
+ ? [
345
+ {
346
+ name: `Upload ${binding.crate} native npm package`,
347
+ uses: "actions/upload-artifact@v7",
348
+ with: {
349
+ name: `${binding.crate}-\${{ matrix.node }}-npm`,
350
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/npm/*.tgz`,
351
+ "retention-days": 7,
352
+ },
353
+ },
354
+ ]
355
+ : []),
356
+ ...(binding.python
357
+ ? [
358
+ {
359
+ name: `Upload ${binding.crate} Python wheel`,
360
+ uses: "actions/upload-artifact@v7",
361
+ with: {
362
+ name: `${binding.pythonPackage}--\${{ matrix.python }}--python-wheel`,
363
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/python/*.whl`,
364
+ "retention-days": 7,
365
+ },
366
+ },
367
+ ]
368
+ : []),
369
+ ]),
370
+ ...plan.releaseBinaries.map((pkg) => ({
371
+ name: `Upload ${pkg.crate} release binary`,
372
+ uses: "actions/upload-artifact@v7",
373
+ ...(rustReleaseBinaryCondition(pkg.excludedOs)
374
+ ? { if: rustReleaseBinaryCondition(pkg.excludedOs) }
375
+ : {}),
376
+ with: {
377
+ name: `${pkg.crate}-\${{ matrix.node }}-binary`,
378
+ path: `dist/release/${pkg.crate}/\${{ matrix.node }}/binary/*`,
379
+ "retention-days": 7,
380
+ },
381
+ })),
382
+ ];
383
+ }
384
+
385
+ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep[]): Job {
386
+ const bindingCommands = rustBindingCommands(plan, Boolean(independentSetup));
387
+ const binaryCommands = rustBinaryCommands(plan, Boolean(independentSetup));
388
+ return {
389
+ if: independentSetup
390
+ ? "${{ needs.release-plan.outputs.rust_targets != '[]' && (github.event_name == 'push' || inputs.stage != 'docs') }}"
391
+ : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
392
+ name: "${{ matrix.node }}",
393
+ needs: [independentSetup ? "release-plan" : "verify-context"],
394
+ runsOn: ["${{ matrix.runner }}"],
395
+ permissions: { contents: JobPermission.READ },
396
+ env: {
397
+ ...RUST_CACHE_ENV,
398
+ ...(independentSetup ? { BUN_VERSION } : {}),
399
+ },
400
+ strategy: {
401
+ failFast: false,
402
+ matrix: {
403
+ include: independentSetup
404
+ ? ("${{ fromJSON(needs.release-plan.outputs.rust_targets) }}" as never)
405
+ : [...plan.targets],
406
+ },
407
+ },
408
+ steps: [
409
+ ...(independentSetup ?? releaseSourceSteps()),
410
+ ...(plan.hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
411
+ {
412
+ name: "Setup Rust",
413
+ ...(plan.usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
414
+ uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
415
+ with: { targets: "${{ matrix.cargo }}" },
416
+ },
417
+ ...(plan.usePreinstalledWindowsRust
418
+ ? [
419
+ {
420
+ name: "Verify preinstalled Windows Rust",
421
+ if: "${{ matrix.os == 'win32' }}",
422
+ shell: "bash",
423
+ run: [
424
+ "rustc --version --verbose",
425
+ "cargo --version",
426
+ 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
427
+ 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
428
+ ].join("\n"),
429
+ },
430
+ ]
431
+ : []),
432
+ {
433
+ name: "Install LLVM release tools",
434
+ shell: "bash",
435
+ run: "rustup component add llvm-tools-preview",
436
+ },
437
+ ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${plan.releaseRustVersion}`),
438
+ {
439
+ name: "Install Linux native dependencies",
440
+ if: "${{ matrix.os == 'linux' }}",
441
+ run: [
442
+ "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
443
+ "sudo apt-get update",
444
+ "sudo apt-get install --yes libdbus-1-dev pkg-config",
445
+ ].join("\n"),
446
+ },
447
+ {
448
+ name: "Verify Rust build fingerprint",
449
+ if: independentSetup
450
+ ? undefined
451
+ : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
452
+ ...(!independentSetup ? { id: "rust-fingerprint" } : {}),
453
+ shell: "bash",
454
+ run: independentSetup
455
+ ? [
456
+ `node ${plan.releaseHelper} fingerprint --check --root .`,
457
+ '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
458
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
459
+ `--source ${JSON.stringify(plan.rustRoot)}`,
460
+ "--portable",
461
+ ].join(" ")
462
+ : [
463
+ 'RUNTIME_MANIFEST="dist/rust-raw/rust-build-${{ matrix.node }}.json"',
464
+ 'mkdir -p "$(dirname "$RUNTIME_MANIFEST")"',
465
+ [
466
+ `node ${plan.releaseHelper} fingerprint --root .`,
467
+ '--output "$RUNTIME_MANIFEST"',
468
+ '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
469
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
470
+ `--source ${JSON.stringify(plan.rustRoot)}`,
471
+ ].join(" "),
472
+ 'test "$(jq -r .rustSourceHash "$RUNTIME_MANIFEST")" = "$(jq -r .rustSourceHash .release/rust-build.json)"',
473
+ 'KEY="$(jq -r --arg target "${{ matrix.cargo }}" \'.targets[$target] // empty\' "$RUNTIME_MANIFEST")"',
474
+ 'test -n "$KEY"',
475
+ 'echo "key=$KEY" >> "$GITHUB_OUTPUT"',
476
+ ].join("\n"),
477
+ },
478
+ ...(!independentSetup
479
+ ? [
480
+ {
481
+ name: "Reuse matching raw Rust outputs",
482
+ id: "raw-native",
483
+ shell: "bash",
484
+ env: { GH_TOKEN: "${{ github.token }}" },
485
+ run: [
486
+ 'KEY="${{ steps.rust-fingerprint.outputs.key }}"',
487
+ 'test -n "$KEY"',
488
+ 'ASSET="rust-raw-${{ matrix.node }}-$KEY.tar.gz"',
489
+ "mkdir -p dist/rust-raw",
490
+ 'MATCH="$(gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" --jq \'.[] | select(.draft == false) | . as $release | .assets[] | select(.name == "\'"$ASSET"\'") | [$release.tag_name, .url] | @tsv\' | head -n 1)"',
491
+ 'if [ -n "$MATCH" ]; then',
492
+ " TAG=\"${MATCH%%$'\\t'*}\"",
493
+ " URL=\"${MATCH#*$'\\t'}\"",
494
+ ' CHECKSUM_URL="$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq \'.assets[] | select(.name == "\'"$ASSET.sha256"\'") | .url\')"',
495
+ ' rm -rf "target/${{ matrix.cargo }}/release"',
496
+ ' if test -n "$CHECKSUM_URL" && gh api "$URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET" && gh api "$CHECKSUM_URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET.sha256" && (cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum --check "$ASSET.sha256"; else shasum -a 256 --check "$ASSET.sha256"; fi) && tar -xzf "dist/rust-raw/$ASSET"; then',
497
+ ' echo "hit=true" >> "$GITHUB_OUTPUT"',
498
+ " else",
499
+ ' rm -rf "target/${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"',
500
+ ' echo "::warning::matching raw Rust bundle failed validation; rebuilding"',
501
+ ' echo "hit=false" >> "$GITHUB_OUTPUT"',
502
+ " fi",
503
+ "else",
504
+ ' echo "hit=false" >> "$GITHUB_OUTPUT"',
505
+ "fi",
506
+ 'echo "asset=$ASSET" >> "$GITHUB_OUTPUT"',
507
+ ].join("\n"),
508
+ },
509
+ ]
510
+ : []),
511
+ {
512
+ name: "Build Rust outputs",
513
+ ...(independentSetup ? {} : { if: "${{ steps.raw-native.outputs.hit != 'true' }}" }),
514
+ shell: "bash",
515
+ env: {
516
+ CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
517
+ "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
518
+ },
519
+ run: timedBash(
520
+ "rust_workspace",
521
+ independentSetup
522
+ ? [
523
+ "mapfile -t PACKAGES < <(jq -r '.[]' <<<'${{ toJSON(matrix.packages) }}')",
524
+ "PACKAGE_ARGS=()",
525
+ 'for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_ARGS+=(--package "$PACKAGE"); done',
526
+ `cargo build --release --timings "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
527
+ ].join("\n")
528
+ : `cargo build --release --timings --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
529
+ plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
530
+ }`,
531
+ ),
532
+ },
533
+ ...(!independentSetup
534
+ ? [
535
+ {
536
+ name: "Upload Cargo timings",
537
+ if: "${{ steps.raw-native.outputs.hit != 'true' }}",
538
+ uses: "actions/upload-artifact@v7",
539
+ with: {
540
+ name: "rust-${{ matrix.node }}-cargo-timings",
541
+ path: "target/cargo-timings/*",
542
+ "retention-days": 14,
543
+ },
544
+ },
545
+ ]
546
+ : []),
547
+ ...(!independentSetup
548
+ ? [
549
+ {
550
+ name: "Archive raw Rust outputs",
551
+ if: "${{ steps.raw-native.outputs.hit != 'true' }}",
552
+ shell: "bash",
553
+ run: [
554
+ "mkdir -p dist/rust-raw",
555
+ 'tar -czf "dist/rust-raw/${{ steps.raw-native.outputs.asset }}" "target/${{ matrix.cargo }}/release"',
556
+ '(cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum "${{ steps.raw-native.outputs.asset }}"; else shasum -a 256 "${{ steps.raw-native.outputs.asset }}"; fi > "${{ steps.raw-native.outputs.asset }}.sha256")',
557
+ ].join("\n"),
558
+ },
559
+ {
560
+ name: "Stamp native release version",
561
+ shell: "bash",
562
+ env: { VERSION: RELEASE_VERSION },
563
+ run: `node ${plan.releaseHelper} stamp-tree --root "target/\${{ matrix.cargo }}/release" --version "$VERSION"`,
564
+ },
565
+ ]
566
+ : []),
567
+ ...(bindingCommands.length
568
+ ? [
569
+ {
570
+ name: "Package UniFFI outputs",
571
+ shell: "bash",
572
+ ...(independentSetup ? {} : { env: { VERSION: RELEASE_VERSION } }),
573
+ run: timedBash("uniffi_packaging", bindingCommands.join("\n")),
574
+ },
575
+ ]
576
+ : []),
577
+ ...(binaryCommands.length
578
+ ? [
579
+ {
580
+ name: "Package release binaries",
581
+ shell: "bash",
582
+ run: timedBash("binary_packaging", binaryCommands.join("\n")),
583
+ },
584
+ ]
585
+ : []),
586
+ ...(!independentSetup
587
+ ? [
588
+ {
589
+ name: "Upload reusable raw Rust outputs",
590
+ uses: "actions/upload-artifact@v7",
591
+ with: {
592
+ name: "rust-${{ matrix.node }}-raw",
593
+ path: "dist/rust-raw/*",
594
+ "retention-days": 7,
595
+ },
596
+ },
597
+ ]
598
+ : []),
599
+ ...rustArtifactSteps(plan),
600
+ ],
601
+ };
602
+ }
603
+
604
+ export function rustCargoPublishJob(plan: RustReleasePlan, local: boolean): Job {
605
+ const registry = local ? '"${{ vars.LOCAL_CARGO_REGISTRY }}"' : "crates-io";
606
+ return {
607
+ if: local
608
+ ? "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}"
609
+ : "${{ github.event_name == 'push' }}",
610
+ needs: ["verify-context", "rust-build"],
611
+ runsOn: [local ? "self-hosted" : "ubuntu-latest"],
612
+ permissions: { contents: JobPermission.READ },
613
+ steps: [
614
+ ...releaseSourceSteps(),
615
+ {
616
+ name: "Setup Rust",
617
+ uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
618
+ },
619
+ {
620
+ name: local ? "Publish Cargo crates locally" : "Publish public crates",
621
+ env: {
622
+ CARGO_REGISTRY_TOKEN: local
623
+ ? "${{ secrets.LOCAL_CARGO_TOKEN }}"
624
+ : "${{ secrets.CARGO_REGISTRY_TOKEN }}",
625
+ },
626
+ run: plan.publicCrates
627
+ .map((crate) => `cargo publish --package "${crate}" --registry ${registry} --no-verify`)
628
+ .join("\n"),
629
+ },
630
+ ],
631
+ };
632
+ }
633
+
634
+ export function independentRustCargoPublishJob(
635
+ project: DBXToolsJavaScriptProject,
636
+ plan: RustReleasePlan,
637
+ ): Job {
638
+ const commands = plan.publicCrates.map((crate) =>
639
+ [
640
+ `VERSION="$(jq -r --arg package "${crate}" '.rustPackages[] | select(.identity == $package) | .version' dist/release-plan.json)"`,
641
+ 'if [ -n "$VERSION" ]; then',
642
+ ` if cargo info "${crate}@$VERSION" >/dev/null 2>&1; then`,
643
+ ` echo "skip published ${crate}@$VERSION"`,
644
+ " else",
645
+ ` cargo publish --package "${crate}" --registry crates-io --no-verify`,
646
+ " fi",
647
+ "fi",
648
+ ].join("\n"),
649
+ );
650
+ return {
651
+ if: plan.hasTargetOutputs
652
+ ? "${{ always() && needs.release-plan.outputs.rust == 'true' && needs.rust-build.result != 'failure' && needs.rust-build.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'rust') }}"
653
+ : "${{ needs.release-plan.outputs.rust == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'rust') }}",
654
+ needs: ["release-plan", ...(plan.hasTargetOutputs ? ["rust-build"] : [])],
655
+ runsOn: ["ubuntu-latest"],
656
+ permissions: { contents: JobPermission.READ },
657
+ env: { BUN_VERSION },
658
+ steps: [
659
+ ...independentReleaseSetupSteps(project),
660
+ {
661
+ name: "Setup Rust",
662
+ uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
663
+ },
664
+ {
665
+ name: "Publish affected Cargo crates",
666
+ env: { CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_REGISTRY_TOKEN }}" },
667
+ run: commands.join("\n"),
668
+ },
669
+ ],
670
+ };
671
+ }
672
+
673
+ export function independentRustGitHubReleaseJob(
674
+ project: DBXToolsJavaScriptProject,
675
+ plan: RustReleasePlan,
676
+ ): Job {
677
+ return {
678
+ if: "${{ needs.release-plan.outputs.github == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'github') }}",
679
+ needs: ["release-plan", "rust-build"],
680
+ runsOn: ["ubuntu-latest"],
681
+ permissions: { contents: JobPermission.WRITE },
682
+ env: { BUN_VERSION },
683
+ steps: [
684
+ ...independentReleaseSetupSteps(project),
685
+ {
686
+ name: "Download release binaries",
687
+ uses: "actions/download-artifact@v8",
688
+ with: {
689
+ pattern: "*-binary",
690
+ path: "dist/rust-release",
691
+ "merge-multiple": true,
692
+ },
693
+ },
694
+ {
695
+ name: "Download reusable raw Rust outputs",
696
+ uses: "actions/download-artifact@v8",
697
+ with: {
698
+ pattern: "*-raw",
699
+ path: "dist/rust-raw",
700
+ "merge-multiple": true,
701
+ },
702
+ },
703
+ {
704
+ name: "Upload affected GitHub release assets",
705
+ env: { GH_TOKEN: "${{ github.token }}" },
706
+ shell: "bash",
707
+ run: plan.releaseBinaries
708
+ .map((binary) => {
709
+ const unit = defaultReleaseUnitId("rust", binary.crate);
710
+ return [
711
+ `VERSION="$(jq -r --arg unit "${unit}" '.units[] | select(.id == $unit) | .newVersion' dist/release-plan.json)"`,
712
+ 'if [ -n "$VERSION" ]; then',
713
+ ` gh release upload "${unit}-v$VERSION" dist/rust-release/${binary.binary}-* --clobber`,
714
+ "fi",
715
+ ].join("\n");
716
+ })
717
+ .join("\n"),
718
+ },
719
+ ],
720
+ };
721
+ }
722
+
723
+ export function rustGitHubReleaseJob(): Job {
724
+ return {
725
+ if: "${{ github.event_name == 'push' }}",
726
+ needs: ["verify-context", "rust-build"],
727
+ runsOn: ["ubuntu-latest"],
728
+ permissions: { contents: JobPermission.WRITE },
729
+ steps: [
730
+ ...releaseSourceSteps(),
731
+ {
732
+ name: "Download release binaries",
733
+ uses: "actions/download-artifact@v8",
734
+ with: {
735
+ pattern: "*-binary",
736
+ path: "dist/rust-release",
737
+ "merge-multiple": true,
738
+ },
739
+ },
740
+ {
741
+ name: "Download reusable raw Rust outputs",
742
+ uses: "actions/download-artifact@v8",
743
+ with: {
744
+ pattern: "*-raw",
745
+ path: "dist/rust-raw",
746
+ "merge-multiple": true,
747
+ },
748
+ },
749
+ {
750
+ name: "Publish GitHub release assets",
751
+ uses: "softprops/action-gh-release@v2",
752
+ with: {
753
+ files: ["dist/rust-release/*", "dist/rust-raw/*"].join("\n"),
754
+ body_path: RELEASE_SUMMARY_FILE,
755
+ generate_release_notes: true,
756
+ tag_name: RELEASE_TAG,
757
+ target_commitish: RELEASE_SHA,
758
+ },
759
+ },
760
+ ],
761
+ };
762
+ }
763
+
764
+ export function independentRustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
765
+ return {
766
+ if: "${{ needs.release-plan.outputs.node == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
767
+ needs: ["release-plan", "rust-build"],
768
+ runsOn: ["ubuntu-latest"],
769
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
770
+ timeoutMinutes: 15,
771
+ env: { BUN_VERSION, CI: "true" },
772
+ steps: [
773
+ ...independentReleaseSetupSteps(project),
774
+ {
775
+ name: "Download native npm packages",
776
+ uses: "actions/download-artifact@v8",
777
+ with: {
778
+ pattern: "*-npm",
779
+ path: "dist/uniffi/native",
780
+ "merge-multiple": true,
781
+ },
782
+ },
783
+ {
784
+ name: "Publish affected native npm packages",
785
+ env: npmPublishEnvironment(),
786
+ run: "bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory dist/uniffi/native",
787
+ },
788
+ ],
789
+ };
790
+ }
791
+
792
+ export function independentRustNodeFacadePublishJob(
793
+ project: DBXToolsJavaScriptProject,
794
+ bindings: readonly RustBindingMapping[],
795
+ ): Job {
796
+ return {
797
+ if: "${{ needs.release-plan.outputs.node == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
798
+ needs: ["release-plan", "publish-node", "publish-native-npm"],
799
+ runsOn: ["ubuntu-latest"],
800
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
801
+ timeoutMinutes: 30,
802
+ env: { BUN_VERSION, CI: "true" },
803
+ steps: [
804
+ ...independentReleaseSetupSteps(project),
805
+ {
806
+ name: "Build and publish affected UniFFI npm facades",
807
+ env: npmPublishEnvironment(),
808
+ shell: "bash",
809
+ run: bindings
810
+ .flatMap((binding) => {
811
+ if (!binding.node || !binding.nodePackage) return [];
812
+ const unit = defaultReleaseUnitId("rust", binding.crate);
813
+ const output = `dist/uniffi/facades/${binding.crate}`;
814
+ return [
815
+ `VERSION="$(jq -r --arg unit "${unit}" '.units[] | select(.id == $unit) | .newVersion' dist/release-plan.json)"`,
816
+ 'if [ -n "$VERSION" ]; then',
817
+ ` node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$VERSION" --output "${output}"`,
818
+ ` bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory "${output}/npm-facade" --version "$VERSION"`,
819
+ "fi",
820
+ ];
821
+ })
822
+ .join("\n"),
823
+ },
824
+ ],
825
+ };
826
+ }
827
+
828
+ export function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
829
+ return {
830
+ 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') }}",
831
+ needs: ["verify-context", "rust-build"],
832
+ runsOn: ["ubuntu-latest"],
833
+ permissions: {
834
+ actions: JobPermission.READ,
835
+ contents: JobPermission.READ,
836
+ idToken: JobPermission.WRITE,
837
+ },
838
+ timeoutMinutes: 15,
839
+ env: { BUN_VERSION, CI: "true" },
840
+ steps: [
841
+ ...nodeReleaseSetupSteps(project),
842
+ ...releaseArtifactSteps({
843
+ currentName: "Download native npm packages",
844
+ recoveredName: "Download recovered native npm packages",
845
+ pattern: "*-npm",
846
+ path: "dist/uniffi/native",
847
+ }),
848
+ {
849
+ name: "Publish native npm packages",
850
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
851
+ run: 'bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory dist/uniffi/native --version "$RELEASE_VERSION" $DRY_RUN',
852
+ },
853
+ ],
854
+ };
855
+ }
856
+
857
+ export function rustNodeFacadePublishJob(
858
+ project: DBXToolsJavaScriptProject,
859
+ bindings: readonly RustReleaseBinding[],
860
+ ): Job {
861
+ return {
862
+ if: releaseStageCondition("node"),
863
+ needs: ["verify-context", "publish-node"],
864
+ runsOn: ["ubuntu-latest"],
865
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
866
+ timeoutMinutes: 30,
867
+ env: { BUN_VERSION, CI: "true" },
868
+ steps: [
869
+ ...nodeReleaseSetupSteps(project),
870
+ {
871
+ name: "Build and publish UniFFI npm facades",
872
+ env: { RELEASE_VERSION, ...npmPublishEnvironment() },
873
+ run: bindings
874
+ .flatMap((binding) => {
875
+ const output = `dist/uniffi/facades/${binding.crate}`;
876
+ return [
877
+ `node .projen/uniffi-release.mjs facade --node "${binding.node}" --node-package "${binding.nodePackage}" --node-triple "linux-x64-gnu" --version "$RELEASE_VERSION" --output "${output}"`,
878
+ `bun node_modules/@dbx-tools/projen/tasks/publish-npm.ts --directory "${output}/npm-facade" --version "$RELEASE_VERSION" $DRY_RUN`,
879
+ ];
880
+ })
881
+ .join("\n"),
882
+ },
883
+ {
884
+ name: "Smoke test published UniFFI npm facades",
885
+ if: "${{ github.event_name == 'push' && vars.UNIFFI_FACADE_SMOKE == 'true' }}",
886
+ continueOnError: true,
887
+ env: { RELEASE_VERSION },
888
+ run: [
889
+ 'SMOKE_DIR="$(mktemp -d)"',
890
+ "trap 'rm -rf \"$SMOKE_DIR\"' EXIT",
891
+ 'cd "$SMOKE_DIR"',
892
+ "npm init --yes >/dev/null",
893
+ ...bindings.flatMap((binding) => [
894
+ `npm install --ignore-scripts --no-audit --no-fund --package-lock=false "${binding.nodePackage}@$RELEASE_VERSION"`,
895
+ `node -e 'import("${binding.nodePackage}")'`,
896
+ ]),
897
+ ].join("\n"),
898
+ },
899
+ ],
900
+ };
901
+ }