@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.
@@ -6,7 +6,8 @@ import { fileURLToPath } from "node:url";
6
6
  import { stringUtils } from "@dbx-tools/shared-core";
7
7
  import { TextFile, javascript } from "projen";
8
8
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
9
- import type { RustProject } from "./_rust-project.ts";
9
+ import { releaseBinaryAssetName } from "./_release-platform.ts";
10
+ import { UNIFFI_BINDGEN_FEATURE, type RustProject } from "./_rust-project.ts";
10
11
  import { BUN_VERSION } from "./bun-workflow.ts";
11
12
  import type { DBXToolsJavaScriptProject } from "./project-js.ts";
12
13
  import type {
@@ -35,29 +36,11 @@ const require = createRequire(import.meta.url);
35
36
 
36
37
  type RustPackageDependencyResolver = (pkg: RustProject) => RustProject[];
37
38
 
38
- const RUST_CACHE_ENV = {
39
+ const RUST_BUILD_ENV = {
39
40
  CARGO_INCREMENTAL: "0",
40
41
  CARGO_TERM_COLOR: "always",
41
42
  } as const;
42
43
 
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
44
  function timedBash(phase: string, command: string): string {
62
45
  return [
63
46
  "SECONDS=0",
@@ -66,15 +49,6 @@ function timedBash(phase: string, command: string): string {
66
49
  ].join("\n");
67
50
  }
68
51
 
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
52
  function taskSource(name: string): string {
79
53
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
80
54
  const candidates = [
@@ -117,39 +91,84 @@ export function orderRustBindings(bindings: readonly RustBindingMapping[]): Rust
117
91
  return ordered;
118
92
  }
119
93
 
120
- interface RustReleaseBinding extends RustBindingMapping {
94
+ export interface RustReleaseBinding extends RustBindingMapping {
121
95
  readonly node: string;
122
96
  readonly python: string;
123
97
  readonly nodePackage: string;
124
98
  readonly pythonPackage: string;
125
99
  }
126
100
 
127
- interface RustReleaseBinaryPlan {
101
+ export interface RustReleaseBinaryPlan {
128
102
  readonly crate: string;
129
103
  readonly binary: string;
130
104
  readonly excludedOs: readonly RustReleaseOs[];
131
105
  readonly requiredFeatures: readonly string[];
132
106
  }
133
107
 
134
- type RustReleaseTargetPlan = Readonly<Record<string, string | boolean | number>>;
108
+ export interface RustReleaseTargetPlan {
109
+ readonly runner: string;
110
+ readonly cargo: string;
111
+ readonly node: string;
112
+ readonly python: string;
113
+ readonly os: RustReleaseOs;
114
+ readonly cpu: string;
115
+ readonly libc: string;
116
+ readonly glibcVersion: string;
117
+ readonly packages: readonly string[];
118
+ readonly binaries: readonly string[];
119
+ readonly features: readonly string[];
120
+ readonly localFeatures: readonly string[];
121
+ }
122
+
123
+ /** Generated release inputs shared by GitHub-hosted and local target builds. */
124
+ export interface RustReleaseConfiguration {
125
+ readonly releaseRustVersion: string;
126
+ readonly usesCargoLock: boolean;
127
+ readonly bindings: readonly RustReleaseBinding[];
128
+ readonly binaries: readonly RustReleaseBinaryPlan[];
129
+ readonly targets: readonly RustReleaseTargetPlan[];
130
+ }
135
131
 
136
132
  export interface RustReleasePlan {
137
133
  readonly releaseRustVersion: string;
138
134
  readonly releaseTask: string;
139
- readonly releaseHelper: string;
140
- readonly rustRoot: string;
141
135
  readonly bindings: readonly RustReleaseBinding[];
142
136
  readonly nodeBindings: readonly RustReleaseBinding[];
143
137
  readonly releaseBinaries: readonly RustReleaseBinaryPlan[];
144
138
  readonly publicCrates: readonly string[];
145
139
  readonly targets: readonly RustReleaseTargetPlan[];
146
- readonly hasReleaseExclusions: boolean;
147
140
  readonly hasPythonBindings: boolean;
148
141
  readonly usesCargoLock: boolean;
149
142
  readonly usePreinstalledWindowsRust: boolean;
150
143
  readonly hasTargetOutputs: boolean;
151
144
  }
152
145
 
146
+ function releaseTargetPlan(
147
+ target: UniFFIReleaseTarget,
148
+ packages: readonly RustProject[],
149
+ binaries: readonly string[],
150
+ features: readonly string[],
151
+ localFeatures: readonly string[],
152
+ ): RustReleaseTargetPlan {
153
+ const packageNames = packages.map((pkg) => pkg.crateName).sort();
154
+ const sortedBinaries = [...new Set(binaries)].sort();
155
+ const sortedFeatures = [...new Set(features)].sort();
156
+ return {
157
+ runner: target.runner,
158
+ cargo: target.cargo,
159
+ node: target.node,
160
+ python: target.python,
161
+ os: target.os,
162
+ cpu: target.cpu,
163
+ libc: target.libc ?? "",
164
+ glibcVersion: target.glibcVersion ?? "",
165
+ packages: packageNames,
166
+ binaries: sortedBinaries,
167
+ features: sortedFeatures,
168
+ localFeatures: [...new Set(localFeatures)].sort(),
169
+ };
170
+ }
171
+
153
172
  export function planRustRelease(
154
173
  project: javascript.NodeProject,
155
174
  options: DBXToolsRustWorkspaceOptions,
@@ -195,39 +214,51 @@ export function planRustRelease(
195
214
  dependencies: packageDependencies(pkg).map((dependency) => dependency.crateName),
196
215
  })),
197
216
  ).map((pkg) => pkg.crate);
198
- const hasReleaseExclusions = packages.some((pkg) => pkg.packageOptions.releaseExcludeOs?.length);
199
- const plannedTargets: RustReleaseTargetPlan[] = targets.map((target) => {
200
- const cargoExcludes = packages
201
- .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
202
- .map((pkg) => `--exclude ${pkg.crateName}`)
203
- .join(" ");
204
- return {
205
- runner: target.runner,
206
- cargo: target.cargo,
207
- node: target.node,
208
- python: target.python,
209
- os: target.os,
210
- cpu: target.cpu,
211
- ...(target.libc ? { libc: target.libc } : {}),
212
- ...(hasReleaseExclusions ? { cargoExcludes } : {}),
213
- };
217
+ const bindingPackages = packages.filter((pkg) =>
218
+ bindings.some((binding) => binding.crate === pkg.crateName),
219
+ );
220
+ const releaseTargets = targets.flatMap((target) => {
221
+ const selectedBinaries = releaseBinaries.filter(
222
+ (binary) => !binary.excludedOs.includes(target.os),
223
+ );
224
+ const binaryPackages = packages.filter((pkg) =>
225
+ selectedBinaries.some((binary) => binary.crate === pkg.crateName),
226
+ );
227
+ const selectedPackages = [...new Set([...bindingPackages, ...binaryPackages])];
228
+ if (!selectedPackages.length) return [];
229
+ const features = [
230
+ ...bindingPackages.map((pkg) => `${pkg.crateName}/${UNIFFI_BINDGEN_FEATURE}`),
231
+ ...selectedBinaries.flatMap((binary) =>
232
+ binary.requiredFeatures.map((feature) => `${binary.crate}/${feature}`),
233
+ ),
234
+ ];
235
+ const localFeatures = selectedPackages.flatMap((pkg) =>
236
+ (pkg.packageOptions.releaseLocalFeatures ?? []).map(
237
+ (feature) => `${pkg.crateName}/${feature}`,
238
+ ),
239
+ );
240
+ return [
241
+ releaseTargetPlan(
242
+ target,
243
+ selectedPackages,
244
+ selectedBinaries.map((binary) => binary.binary),
245
+ features,
246
+ localFeatures,
247
+ ),
248
+ ];
214
249
  });
215
- const hasTargetOutputs =
216
- bindings.length > 0 || releaseBinaries.length > 0 || publicCrates.length > 0;
217
- if (hasTargetOutputs && plannedTargets.length === 0) {
250
+ const hasTargetOutputs = releaseTargets.length > 0;
251
+ if (hasTargetOutputs && targets.length === 0) {
218
252
  throw new Error("Rust release requires at least one target");
219
253
  }
220
254
  return {
221
255
  releaseRustVersion,
222
256
  releaseTask: ".projen/uniffi-release.mjs",
223
- releaseHelper: ".projen/rust-release.mjs",
224
- rustRoot: options.root ?? "packages/rs",
225
257
  bindings,
226
258
  nodeBindings: bindings.filter((binding) => Boolean(binding.node && binding.nodePackage)),
227
259
  releaseBinaries,
228
260
  publicCrates,
229
- targets: plannedTargets,
230
- hasReleaseExclusions,
261
+ targets: releaseTargets,
231
262
  hasPythonBindings: bindings.some((binding) => binding.python),
232
263
  usesCargoLock: existsSync(join(project.outdir, "Cargo.lock")),
233
264
  usePreinstalledWindowsRust: releaseRustVersion === "stable",
@@ -235,19 +266,39 @@ export function planRustRelease(
235
266
  };
236
267
  }
237
268
 
269
+ function rustBuildJobIds(plan: RustReleasePlan): string[] {
270
+ return plan.targets.length ? ["rust-build"] : [];
271
+ }
272
+
273
+ function rustBuildResultCondition(plan: RustReleasePlan): string {
274
+ return rustBuildJobIds(plan)
275
+ .map((job) => `needs.${job}.result != 'failure' && needs.${job}.result != 'cancelled'`)
276
+ .join(" && ");
277
+ }
278
+
238
279
  export function configureRustReleaseTask(
239
280
  project: javascript.NodeProject,
240
281
  plan: RustReleasePlan,
241
282
  ): void {
283
+ new TextFile(project, ".projen/rust-release.json", {
284
+ lines: JSON.stringify(
285
+ {
286
+ releaseRustVersion: plan.releaseRustVersion,
287
+ usesCargoLock: plan.usesCargoLock,
288
+ bindings: plan.bindings,
289
+ binaries: plan.releaseBinaries,
290
+ targets: plan.targets,
291
+ } satisfies RustReleaseConfiguration,
292
+ null,
293
+ 2,
294
+ ).split("\n"),
295
+ });
242
296
  const bindingSupportFiles = [
243
297
  plan.releaseTask,
244
298
  ".projen/uniffi-python.js",
245
299
  ".projen/smol-toml.cjs",
246
300
  ".projen/smol-toml.LICENSE",
247
301
  ] as const;
248
- new TextFile(project, plan.releaseHelper, {
249
- lines: taskSource("rust-release.mjs").trimEnd().split("\n"),
250
- });
251
302
  if (plan.bindings.length) {
252
303
  new TextFile(project, plan.releaseTask, {
253
304
  lines: taskSource("uniffi-release.mjs").trimEnd().split("\n"),
@@ -264,20 +315,7 @@ export function configureRustReleaseTask(
264
315
  } else {
265
316
  for (const path of bindingSupportFiles) project.tryRemoveFile(path);
266
317
  }
267
- project.addTask("rs:release-fingerprint", {
268
- description: "Write the version-independent Rust release build manifest",
269
- exec: [
270
- `node ${plan.releaseHelper} fingerprint`,
271
- "--root .",
272
- ...plan.targets.map(
273
- (target) =>
274
- `--target ${JSON.stringify(`${String(target.cargo)}|${String(target.cargoExcludes ?? "")}`)}`,
275
- ),
276
- `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
277
- `--source ${JSON.stringify(plan.rustRoot)}`,
278
- "--portable",
279
- ].join(" "),
280
- });
318
+ project.tryRemoveFile(".projen/rust-release.mjs");
281
319
  }
282
320
 
283
321
  function rustBindingCommands(plan: RustReleasePlan, independent: boolean): string[] {
@@ -352,29 +390,6 @@ function rustBinaryCommands(plan: RustReleasePlan, independent: boolean): string
352
390
  });
353
391
  }
354
392
 
355
- function rustAdditionalBinaryBuildCommands(plan: RustReleasePlan, independent: boolean): string[] {
356
- return plan.releaseBinaries
357
- .filter((binary) => binary.requiredFeatures.length)
358
- .flatMap((binary) => {
359
- const command = `cargo build --release --package "${binary.crate}" --bin "${binary.binary}" --features "${binary.requiredFeatures.join(",")}"${
360
- plan.usesCargoLock ? " --locked" : ""
361
- } --target "\${{ matrix.cargo }}"`;
362
- const excludedCondition = binary.excludedOs
363
- .map((os) => `[ "\${{ matrix.os }}" != "${os}" ]`)
364
- .join(" && ");
365
- const platformCommands = excludedCondition
366
- ? [`if ${excludedCondition}; then`, ` ${command}`, "fi"]
367
- : [command];
368
- if (!independent) return platformCommands;
369
- const unit = defaultReleaseUnitId("rust", binary.crate);
370
- return [
371
- `if jq -e --arg unit "${unit}" '.units[] | select(.id == $unit)' dist/release-plan.json >/dev/null; then`,
372
- ...platformCommands.map((line) => ` ${line}`),
373
- "fi",
374
- ];
375
- });
376
- }
377
-
378
393
  function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
379
394
  const binaryCrates = [...new Set(plan.releaseBinaries.map((binary) => binary.crate))];
380
395
  return [
@@ -428,23 +443,37 @@ function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
428
443
  ];
429
444
  }
430
445
 
446
+ /** Build a Bash loop that preserves matrix values across LF and CRLF runners. */
447
+ function matrixArgumentLoop(variable: string, field: string, flag: string): string {
448
+ return `while IFS= read -r ${variable}; do ${variable}="\${${variable}%$'\\r'}"; ${variable}_ARGS+=(${flag} "$${variable}"); done < <(jq -r '.[]' <<<'\${{ toJSON(matrix.${field}) }}')`;
449
+ }
450
+
451
+ function rustCargoBuildCommand(plan: RustReleasePlan): string {
452
+ return [
453
+ "PACKAGE_ARGS=()",
454
+ "FEATURE_ARGS=()",
455
+ matrixArgumentLoop("PACKAGE", "packages", "--package"),
456
+ matrixArgumentLoop("FEATURE", "features", "--features"),
457
+ `cargo build --release --timings "\${PACKAGE_ARGS[@]}" "\${FEATURE_ARGS[@]}"${
458
+ plan.usesCargoLock ? " --locked" : ""
459
+ } --target "\${{ matrix.cargo }}"`,
460
+ ].join("\n");
461
+ }
462
+
431
463
  export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep[]): Job {
432
- const bindingCommands = rustBindingCommands(plan, Boolean(independentSetup));
433
- const binaryCommands = rustBinaryCommands(plan, Boolean(independentSetup));
434
- const additionalBinaryBuildCommands = rustAdditionalBinaryBuildCommands(
435
- plan,
436
- Boolean(independentSetup),
437
- );
464
+ const independent = Boolean(independentSetup);
465
+ const bindingCommands = rustBindingCommands(plan, independent);
466
+ const binaryCommands = rustBinaryCommands(plan, independent);
438
467
  return {
439
468
  if: independentSetup
440
469
  ? "${{ needs.release-plan.outputs.rust_targets != '[]' && (github.event_name == 'push' || inputs.stage != 'docs') }}"
441
- : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
442
- name: "${{ matrix.node }}",
470
+ : "${{ needs.verify-context.outputs.build_mode == 'remote' && (github.event_name == 'push' || inputs.stage == 'all') }}",
471
+ name: "Rust / ${{ matrix.node }}",
443
472
  needs: [independentSetup ? "release-plan" : "verify-context"],
444
473
  runsOn: ["${{ matrix.runner }}"],
445
474
  permissions: { contents: JobPermission.READ },
446
475
  env: {
447
- ...RUST_CACHE_ENV,
476
+ ...RUST_BUILD_ENV,
448
477
  ...(independentSetup ? { BUN_VERSION } : {}),
449
478
  },
450
479
  strategy: {
@@ -452,7 +481,7 @@ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly
452
481
  matrix: {
453
482
  include: independentSetup
454
483
  ? ("${{ fromJSON(needs.release-plan.outputs.rust_targets) }}" as never)
455
- : [...plan.targets],
484
+ : ([...plan.targets] as never),
456
485
  },
457
486
  },
458
487
  steps: [
@@ -460,168 +489,28 @@ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly
460
489
  ...(plan.hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
461
490
  {
462
491
  name: "Setup Rust",
463
- ...(plan.usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
464
492
  uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
465
493
  with: { targets: "${{ matrix.cargo }}" },
466
494
  },
467
- ...(plan.usePreinstalledWindowsRust
468
- ? [
469
- {
470
- name: "Verify preinstalled Windows Rust",
471
- if: "${{ matrix.os == 'win32' }}",
472
- shell: "bash",
473
- run: [
474
- "rustc --version --verbose",
475
- "cargo --version",
476
- 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
477
- 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
478
- ].join("\n"),
479
- },
480
- ]
481
- : []),
482
- {
483
- name: "Install LLVM release tools",
484
- shell: "bash",
485
- run: "rustup component add llvm-tools-preview",
486
- },
487
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${plan.releaseRustVersion}`),
488
495
  {
489
496
  name: "Install Linux native dependencies",
490
497
  if: "${{ matrix.os == 'linux' }}",
491
- run: [
492
- "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
493
- "sudo apt-get update",
494
- "sudo apt-get install --yes libdbus-1-dev pkg-config",
495
- ].join("\n"),
498
+ // prettier-ignore
499
+ run: stringUtils.dedent(
500
+ // ============================================================================
501
+ /*bash*/`
502
+ sudo rm -f /etc/apt/sources.list.d/google-chrome.list
503
+ sudo apt-get update
504
+ sudo apt-get install --yes libdbus-1-dev pkg-config
505
+ `
506
+ // ============================================================================
507
+ ),
496
508
  },
497
509
  {
498
- name: "Verify Rust build fingerprint",
499
- if: independentSetup
500
- ? undefined
501
- : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
502
- ...(!independentSetup ? { id: "rust-fingerprint" } : {}),
510
+ name: "Build Rust release outputs",
503
511
  shell: "bash",
504
- run: independentSetup
505
- ? [
506
- `node ${plan.releaseHelper} fingerprint --check --root .`,
507
- '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
508
- `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
509
- `--source ${JSON.stringify(plan.rustRoot)}`,
510
- "--portable",
511
- ].join(" ")
512
- : [
513
- 'RUNTIME_MANIFEST="dist/rust-raw/rust-build-${{ matrix.node }}.json"',
514
- 'mkdir -p "$(dirname "$RUNTIME_MANIFEST")"',
515
- [
516
- `node ${plan.releaseHelper} fingerprint --root .`,
517
- '--output "$RUNTIME_MANIFEST"',
518
- '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
519
- `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
520
- `--source ${JSON.stringify(plan.rustRoot)}`,
521
- ].join(" "),
522
- 'test "$(jq -r .rustSourceHash "$RUNTIME_MANIFEST")" = "$(jq -r .rustSourceHash .release/rust-build.json)"',
523
- 'KEY="$(jq -r --arg target "${{ matrix.cargo }}" \'.targets[$target] // empty\' "$RUNTIME_MANIFEST")"',
524
- 'test -n "$KEY"',
525
- 'echo "key=$KEY" >> "$GITHUB_OUTPUT"',
526
- ].join("\n"),
512
+ run: timedBash("rust", rustCargoBuildCommand(plan)),
527
513
  },
528
- ...(!independentSetup
529
- ? [
530
- {
531
- name: "Reuse matching raw Rust outputs",
532
- id: "raw-native",
533
- shell: "bash",
534
- env: { GH_TOKEN: "${{ github.token }}" },
535
- // prettier-ignore
536
- run: stringUtils.dedent(
537
- // ============================================================================
538
- /*bash*/`
539
- KEY="\${{ steps.rust-fingerprint.outputs.key }}"
540
- test -n "$KEY"
541
- ASSET="rust-raw-\${{ matrix.node }}-$KEY.tar.gz"
542
- mkdir -p dist/rust-raw
543
- 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)"
544
- if [ -n "$MATCH" ]; then
545
- TAG="\${MATCH%%$'\\t'*}"
546
- URL="\${MATCH#*$'\\t'}"
547
- CHECKSUM_URL="$(gh api "repos/\${{ github.repository }}/releases/tags/$TAG" --jq '.assets[] | select(.name == "'"$ASSET.sha256"'") | .url')"
548
- rm -rf "target/\${{ matrix.cargo }}/release"
549
- 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
550
- echo "hit=true" >> "$GITHUB_OUTPUT"
551
- else
552
- rm -rf "target/\${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"
553
- echo "::warning::matching raw Rust bundle failed validation; rebuilding"
554
- echo "hit=false" >> "$GITHUB_OUTPUT"
555
- fi
556
- else
557
- echo "hit=false" >> "$GITHUB_OUTPUT"
558
- fi
559
- echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
560
- `
561
- // ============================================================================
562
- ),
563
- },
564
- ]
565
- : []),
566
- {
567
- name: "Build Rust outputs",
568
- ...(independentSetup ? {} : { if: "${{ steps.raw-native.outputs.hit != 'true' }}" }),
569
- shell: "bash",
570
- env: {
571
- CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
572
- "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
573
- },
574
- run: timedBash(
575
- "rust_workspace",
576
- [
577
- independentSetup
578
- ? [
579
- "mapfile -t PACKAGES < <(jq -r '.[]' <<<'${{ toJSON(matrix.packages) }}')",
580
- "PACKAGE_ARGS=()",
581
- 'for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_ARGS+=(--package "$PACKAGE"); done',
582
- `cargo build --release --timings "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
583
- ].join("\n")
584
- : `cargo build --release --timings --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
585
- plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
586
- }`,
587
- ...additionalBinaryBuildCommands,
588
- ].join("\n"),
589
- ),
590
- },
591
- ...(!independentSetup
592
- ? [
593
- {
594
- name: "Upload Cargo timings",
595
- if: "${{ steps.raw-native.outputs.hit != 'true' }}",
596
- uses: "actions/upload-artifact@v7",
597
- with: {
598
- name: "rust-${{ matrix.node }}-cargo-timings",
599
- path: "target/cargo-timings/*",
600
- "retention-days": 14,
601
- },
602
- },
603
- ]
604
- : []),
605
- ...(!independentSetup
606
- ? [
607
- {
608
- name: "Archive raw Rust outputs",
609
- if: "${{ steps.raw-native.outputs.hit != 'true' }}",
610
- shell: "bash",
611
- run: [
612
- "mkdir -p dist/rust-raw",
613
- 'tar -czf "dist/rust-raw/${{ steps.raw-native.outputs.asset }}" "target/${{ matrix.cargo }}/release"',
614
- '(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")',
615
- ].join("\n"),
616
- },
617
- {
618
- name: "Stamp native release version",
619
- shell: "bash",
620
- env: { VERSION: RELEASE_VERSION },
621
- run: `node ${plan.releaseHelper} stamp-tree --root "target/\${{ matrix.cargo }}/release" --version "$VERSION"`,
622
- },
623
- ]
624
- : []),
625
514
  ...(bindingCommands.length
626
515
  ? [
627
516
  {
@@ -641,31 +530,53 @@ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly
641
530
  },
642
531
  ]
643
532
  : []),
644
- ...(!independentSetup
645
- ? [
646
- {
647
- name: "Upload reusable raw Rust outputs",
648
- uses: "actions/upload-artifact@v7",
649
- with: {
650
- name: "rust-${{ matrix.node }}-raw",
651
- path: "dist/rust-raw/*",
652
- "retention-days": 7,
653
- },
654
- },
655
- ]
656
- : []),
657
533
  ...rustArtifactSteps(plan),
658
534
  ],
659
535
  };
660
536
  }
661
537
 
538
+ /** Wait for the selected local or GitHub-hosted native build source. */
539
+ export function rustAssetsJob(): Job {
540
+ return {
541
+ if: "${{ always() && needs.verify-context.result == 'success' && (needs.verify-context.outputs.build_mode == 'local' || needs.rust-build.result == 'success') }}",
542
+ needs: ["verify-context", "rust-build"],
543
+ runsOn: ["ubuntu-latest"],
544
+ permissions: { contents: JobPermission.READ },
545
+ steps: [
546
+ {
547
+ name: "Wait for locally uploaded release assets",
548
+ if: "${{ needs.verify-context.outputs.build_mode == 'local' }}",
549
+ env: {
550
+ GH_TOKEN: "${{ github.token }}",
551
+ RELEASE_TAG: RELEASE_TAG,
552
+ },
553
+ shell: "bash",
554
+ run: [
555
+ "mkdir -p dist/local-release",
556
+ "for ATTEMPT in $(seq 1 360); do",
557
+ ' if gh release download "$RELEASE_TAG" --pattern rust-assets.json --dir dist/local-release --clobber; then exit 0; fi',
558
+ ' echo "waiting for local release assets ($ATTEMPT/360)"',
559
+ " sleep 5",
560
+ "done",
561
+ 'echo "::error::local release assets were not uploaded"',
562
+ "exit 1",
563
+ ].join("\n"),
564
+ },
565
+ {
566
+ name: "Confirm native release assets",
567
+ run: "true",
568
+ },
569
+ ],
570
+ };
571
+ }
572
+
662
573
  export function rustCargoPublishJob(plan: RustReleasePlan, local: boolean): Job {
663
574
  const registry = local ? '"${{ vars.LOCAL_CARGO_REGISTRY }}"' : "crates-io";
664
575
  return {
665
576
  if: local
666
577
  ? "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}"
667
578
  : "${{ github.event_name == 'push' }}",
668
- needs: ["verify-context", "rust-build"],
579
+ needs: ["verify-context", ...(plan.hasTargetOutputs ? ["rust-assets"] : [])],
669
580
  runsOn: [local ? "self-hosted" : "ubuntu-latest"],
670
581
  permissions: { contents: JobPermission.READ },
671
582
  steps: [
@@ -705,11 +616,12 @@ export function independentRustCargoPublishJob(
705
616
  "fi",
706
617
  ].join("\n"),
707
618
  );
619
+ const buildCondition = rustBuildResultCondition(plan);
708
620
  return {
709
621
  if: plan.hasTargetOutputs
710
- ? "${{ 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') }}"
622
+ ? `\${{ always() && needs.release-plan.outputs.rust == 'true' && ${buildCondition} && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'rust') }}`
711
623
  : "${{ needs.release-plan.outputs.rust == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'rust') }}",
712
- needs: ["release-plan", ...(plan.hasTargetOutputs ? ["rust-build"] : [])],
624
+ needs: ["release-plan", ...rustBuildJobIds(plan)],
713
625
  runsOn: ["ubuntu-latest"],
714
626
  permissions: { contents: JobPermission.READ },
715
627
  env: { BUN_VERSION },
@@ -734,7 +646,7 @@ export function independentRustGitHubReleaseJob(
734
646
  ): Job {
735
647
  return {
736
648
  if: "${{ needs.release-plan.outputs.github == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'github') }}",
737
- needs: ["release-plan", "rust-build"],
649
+ needs: ["release-plan", ...rustBuildJobIds(plan)],
738
650
  runsOn: ["ubuntu-latest"],
739
651
  permissions: { contents: JobPermission.WRITE },
740
652
  env: { BUN_VERSION },
@@ -749,15 +661,6 @@ export function independentRustGitHubReleaseJob(
749
661
  "merge-multiple": true,
750
662
  },
751
663
  },
752
- {
753
- name: "Download reusable raw Rust outputs",
754
- uses: "actions/download-artifact@v8",
755
- with: {
756
- pattern: "*-raw",
757
- path: "dist/rust-raw",
758
- "merge-multiple": true,
759
- },
760
- },
761
664
  {
762
665
  name: "Upload affected GitHub release assets",
763
666
  env: { GH_TOKEN: "${{ github.token }}" },
@@ -778,16 +681,17 @@ export function independentRustGitHubReleaseJob(
778
681
  };
779
682
  }
780
683
 
781
- export function rustGitHubReleaseJob(): Job {
684
+ export function rustGitHubReleaseJob(plan: RustReleasePlan): Job {
782
685
  return {
783
686
  if: "${{ github.event_name == 'push' }}",
784
- needs: ["verify-context", "rust-build"],
687
+ needs: ["verify-context", ...(plan.hasTargetOutputs ? ["rust-assets"] : [])],
785
688
  runsOn: ["ubuntu-latest"],
786
689
  permissions: { contents: JobPermission.WRITE },
787
690
  steps: [
788
691
  ...releaseSourceSteps(),
789
692
  {
790
693
  name: "Download release binaries",
694
+ if: "${{ needs.verify-context.outputs.build_mode == 'remote' }}",
791
695
  uses: "actions/download-artifact@v8",
792
696
  with: {
793
697
  pattern: "*-binary",
@@ -795,20 +699,12 @@ export function rustGitHubReleaseJob(): Job {
795
699
  "merge-multiple": true,
796
700
  },
797
701
  },
798
- {
799
- name: "Download reusable raw Rust outputs",
800
- uses: "actions/download-artifact@v8",
801
- with: {
802
- pattern: "*-raw",
803
- path: "dist/rust-raw",
804
- "merge-multiple": true,
805
- },
806
- },
807
702
  {
808
703
  name: "Publish GitHub release assets",
704
+ if: "${{ needs.verify-context.outputs.build_mode == 'remote' }}",
809
705
  uses: "softprops/action-gh-release@v2",
810
706
  with: {
811
- files: ["dist/rust-release/*", "dist/rust-raw/*"].join("\n"),
707
+ files: "dist/rust-release/*",
812
708
  body_path: RELEASE_SUMMARY_FILE,
813
709
  generate_release_notes: true,
814
710
  tag_name: RELEASE_TAG,
@@ -816,51 +712,14 @@ export function rustGitHubReleaseJob(): Job {
816
712
  },
817
713
  },
818
714
  {
819
- name: "Delete superseded raw Rust release assets",
820
- uses: "actions/github-script@v8",
821
- env: { CURRENT_RELEASE_TAG: RELEASE_TAG },
822
- with: {
823
- // prettier-ignore
824
- script: stringUtils.dedent(
825
- // ============================================================================
826
- /*js*/`
827
- const currentTag = process.env.CURRENT_RELEASE_TAG;
828
- if (!currentTag) throw new Error("CURRENT_RELEASE_TAG is required");
829
- const parseVersion = (tag) => {
830
- const match = /^v(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(tag);
831
- return match ? match.slice(1).map(Number) : undefined;
832
- };
833
- const compareVersions = (left, right) => {
834
- for (let index = 0; index < 3; index += 1) {
835
- if (left[index] !== right[index]) return left[index] - right[index];
836
- }
837
- return 0;
838
- };
839
- const currentVersion = parseVersion(currentTag);
840
- if (!currentVersion) throw new Error(\`Unsupported release tag: \${currentTag}\`);
841
- const releases = await github.paginate(github.rest.repos.listReleases, {
842
- ...context.repo,
843
- per_page: 100,
844
- });
845
- const assets = releases.flatMap((release) => {
846
- const version = parseVersion(release.tag_name);
847
- if (release.draft || !version || compareVersions(version, currentVersion) >= 0) {
848
- return [];
849
- }
850
- return release.assets.filter((asset) => /^rust-(?:raw|build)-/.test(asset.name));
851
- });
852
- for (const asset of assets) {
853
- core.info(\`Deleting \${asset.name} (\${asset.id})\`);
854
- await github.rest.repos.deleteReleaseAsset({
855
- ...context.repo,
856
- asset_id: asset.id,
857
- });
858
- }
859
- core.info(\`Deleted \${assets.length} superseded raw Rust release assets\`);
860
- `
861
- // ============================================================================
862
- ),
715
+ name: "Publish locally built GitHub release",
716
+ if: "${{ needs.verify-context.outputs.build_mode == 'local' }}",
717
+ env: {
718
+ GH_TOKEN: "${{ github.token }}",
719
+ RELEASE_NOTES: RELEASE_SUMMARY_FILE,
720
+ RELEASE_TAG,
863
721
  },
722
+ run: 'gh release edit "$RELEASE_TAG" --draft=false --latest --notes-file "$RELEASE_NOTES"',
864
723
  },
865
724
  ],
866
725
  };
@@ -932,8 +791,8 @@ export function independentRustNodeFacadePublishJob(
932
791
 
933
792
  export function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
934
793
  return {
935
- 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') }}",
936
- needs: ["verify-context", "rust-build"],
794
+ if: "${{ always() && needs.verify-context.result == 'success' && needs.rust-assets.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
795
+ needs: ["verify-context", "rust-assets"],
937
796
  runsOn: ["ubuntu-latest"],
938
797
  permissions: {
939
798
  actions: JobPermission.READ,
@@ -949,7 +808,17 @@ export function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job
949
808
  recoveredName: "Download recovered native npm packages",
950
809
  pattern: "*-npm",
951
810
  path: "dist/uniffi/native",
811
+ condition: "needs.verify-context.outputs.build_mode == 'remote'",
952
812
  }),
813
+ {
814
+ name: "Download locally built native npm packages",
815
+ if: "${{ needs.verify-context.outputs.build_mode == 'local' }}",
816
+ env: { GH_TOKEN: "${{ github.token }}", RELEASE_TAG },
817
+ run: [
818
+ "mkdir -p dist/uniffi/native",
819
+ 'gh release download "$RELEASE_TAG" --pattern "*.tgz" --dir dist/uniffi/native',
820
+ ].join("\n"),
821
+ },
953
822
  {
954
823
  name: "Publish native npm packages",
955
824
  env: { RELEASE_VERSION, ...npmPublishEnvironment() },