@dbx-tools/projen 0.9.5 → 0.9.7

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