@dbx-tools/projen 0.9.17 → 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.
@@ -1,11 +1,12 @@
1
1
  /** Rust release planning and generated GitHub workflow jobs. */
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
- import { dirname, join, relative, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  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 { releaseBinaryAssetName } from "./_release-platform.ts";
9
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";
@@ -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,21 +91,21 @@ 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
- interface RustReleaseTargetPlan {
108
+ export interface RustReleaseTargetPlan {
135
109
  readonly runner: string;
136
110
  readonly cargo: string;
137
111
  readonly node: string;
@@ -139,56 +113,42 @@ interface RustReleaseTargetPlan {
139
113
  readonly os: RustReleaseOs;
140
114
  readonly cpu: string;
141
115
  readonly libc: string;
116
+ readonly glibcVersion: string;
142
117
  readonly packages: readonly string[];
143
- readonly sources: readonly string[];
144
118
  readonly binaries: readonly string[];
145
119
  readonly features: readonly string[];
146
- readonly fingerprintConfig: 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[];
147
130
  }
148
131
 
149
132
  export interface RustReleasePlan {
150
133
  readonly releaseRustVersion: string;
151
134
  readonly releaseTask: string;
152
- readonly releaseHelper: string;
153
135
  readonly bindings: readonly RustReleaseBinding[];
154
136
  readonly nodeBindings: readonly RustReleaseBinding[];
155
137
  readonly releaseBinaries: readonly RustReleaseBinaryPlan[];
156
138
  readonly publicCrates: readonly string[];
157
- readonly uniffiTargets: readonly RustReleaseTargetPlan[];
158
- readonly binaryTargets: readonly RustReleaseTargetPlan[];
139
+ readonly targets: readonly RustReleaseTargetPlan[];
159
140
  readonly hasPythonBindings: boolean;
160
141
  readonly usesCargoLock: boolean;
161
142
  readonly usePreinstalledWindowsRust: boolean;
162
143
  readonly hasTargetOutputs: boolean;
163
144
  }
164
145
 
165
- function packageClosure(
166
- roots: readonly RustProject[],
167
- packageDependencies: RustPackageDependencyResolver,
168
- ): RustProject[] {
169
- const found = new Set<RustProject>();
170
- const visit = (pkg: RustProject): void => {
171
- if (found.has(pkg)) return;
172
- found.add(pkg);
173
- for (const dependency of packageDependencies(pkg)) visit(dependency);
174
- };
175
- for (const pkg of roots) visit(pkg);
176
- return [...found].sort((left, right) => left.crateName.localeCompare(right.crateName));
177
- }
178
-
179
- function packageSources(
180
- project: javascript.NodeProject,
181
- packages: readonly RustProject[],
182
- ): string[] {
183
- return packages.map((pkg) => relative(project.outdir, pkg.outdir).replaceAll("\\", "/")).sort();
184
- }
185
-
186
146
  function releaseTargetPlan(
187
147
  target: UniFFIReleaseTarget,
188
148
  packages: readonly RustProject[],
189
- sources: readonly string[],
190
149
  binaries: readonly string[],
191
150
  features: readonly string[],
151
+ localFeatures: readonly string[],
192
152
  ): RustReleaseTargetPlan {
193
153
  const packageNames = packages.map((pkg) => pkg.crateName).sort();
194
154
  const sortedBinaries = [...new Set(binaries)].sort();
@@ -201,15 +161,11 @@ function releaseTargetPlan(
201
161
  os: target.os,
202
162
  cpu: target.cpu,
203
163
  libc: target.libc ?? "",
164
+ glibcVersion: target.glibcVersion ?? "",
204
165
  packages: packageNames,
205
- sources,
206
166
  binaries: sortedBinaries,
207
167
  features: sortedFeatures,
208
- fingerprintConfig: JSON.stringify({
209
- packages: packageNames,
210
- binaries: sortedBinaries,
211
- features: sortedFeatures,
212
- }),
168
+ localFeatures: [...new Set(localFeatures)].sort(),
213
169
  };
214
170
  }
215
171
 
@@ -261,55 +217,48 @@ export function planRustRelease(
261
217
  const bindingPackages = packages.filter((pkg) =>
262
218
  bindings.some((binding) => binding.crate === pkg.crateName),
263
219
  );
264
- const bindingClosure = packageClosure(bindingPackages, packageDependencies);
265
- const bindingSources = packageSources(project, bindingClosure);
266
- const uniffiTargets = bindings.length
267
- ? targets.map((target) =>
268
- releaseTargetPlan(
269
- target,
270
- bindingPackages,
271
- bindingSources,
272
- [],
273
- bindingPackages.map((pkg) => `${pkg.crateName}/${UNIFFI_BINDGEN_FEATURE}`),
274
- ),
275
- )
276
- : [];
277
- const binaryTargets = targets.flatMap((target) => {
220
+ const releaseTargets = targets.flatMap((target) => {
278
221
  const selectedBinaries = releaseBinaries.filter(
279
222
  (binary) => !binary.excludedOs.includes(target.os),
280
223
  );
281
224
  const binaryPackages = packages.filter((pkg) =>
282
225
  selectedBinaries.some((binary) => binary.crate === pkg.crateName),
283
226
  );
284
- if (!binaryPackages.length) return [];
285
- const binaryClosure = packageClosure(binaryPackages, packageDependencies);
286
- const features = selectedBinaries.flatMap((binary) =>
287
- binary.requiredFeatures.map((feature) => `${binary.crate}/${feature}`),
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
+ ),
288
239
  );
289
240
  return [
290
241
  releaseTargetPlan(
291
242
  target,
292
- binaryPackages,
293
- packageSources(project, binaryClosure),
243
+ selectedPackages,
294
244
  selectedBinaries.map((binary) => binary.binary),
295
245
  features,
246
+ localFeatures,
296
247
  ),
297
248
  ];
298
249
  });
299
- const hasTargetOutputs = uniffiTargets.length > 0 || binaryTargets.length > 0;
250
+ const hasTargetOutputs = releaseTargets.length > 0;
300
251
  if (hasTargetOutputs && targets.length === 0) {
301
252
  throw new Error("Rust release requires at least one target");
302
253
  }
303
254
  return {
304
255
  releaseRustVersion,
305
256
  releaseTask: ".projen/uniffi-release.mjs",
306
- releaseHelper: ".projen/rust-release.mjs",
307
257
  bindings,
308
258
  nodeBindings: bindings.filter((binding) => Boolean(binding.node && binding.nodePackage)),
309
259
  releaseBinaries,
310
260
  publicCrates,
311
- uniffiTargets,
312
- binaryTargets,
261
+ targets: releaseTargets,
313
262
  hasPythonBindings: bindings.some((binding) => binding.python),
314
263
  usesCargoLock: existsSync(join(project.outdir, "Cargo.lock")),
315
264
  usePreinstalledWindowsRust: releaseRustVersion === "stable",
@@ -318,10 +267,7 @@ export function planRustRelease(
318
267
  }
319
268
 
320
269
  function rustBuildJobIds(plan: RustReleasePlan): string[] {
321
- return [
322
- ...(plan.uniffiTargets.length ? ["rust-uniffi"] : []),
323
- ...(plan.binaryTargets.length ? ["rust-binaries"] : []),
324
- ];
270
+ return plan.targets.length ? ["rust-build"] : [];
325
271
  }
326
272
 
327
273
  function rustBuildResultCondition(plan: RustReleasePlan): string {
@@ -334,15 +280,25 @@ export function configureRustReleaseTask(
334
280
  project: javascript.NodeProject,
335
281
  plan: RustReleasePlan,
336
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
+ });
337
296
  const bindingSupportFiles = [
338
297
  plan.releaseTask,
339
298
  ".projen/uniffi-python.js",
340
299
  ".projen/smol-toml.cjs",
341
300
  ".projen/smol-toml.LICENSE",
342
301
  ] as const;
343
- new TextFile(project, plan.releaseHelper, {
344
- lines: taskSource("rust-release.mjs").trimEnd().split("\n"),
345
- });
346
302
  if (plan.bindings.length) {
347
303
  new TextFile(project, plan.releaseTask, {
348
304
  lines: taskSource("uniffi-release.mjs").trimEnd().split("\n"),
@@ -359,31 +315,7 @@ export function configureRustReleaseTask(
359
315
  } else {
360
316
  for (const path of bindingSupportFiles) project.tryRemoveFile(path);
361
317
  }
362
- const fingerprintTask = project.addTask("rs:release-fingerprint", {
363
- description: "Write the version-independent Rust release build manifest",
364
- });
365
- for (const [namespace, targets] of [
366
- ["uniffi", plan.uniffiTargets],
367
- ["binaries", plan.binaryTargets],
368
- ] as const) {
369
- if (!targets.length) continue;
370
- const sources = [...new Set(targets.flatMap((target) => target.sources))].sort();
371
- fingerprintTask.exec(
372
- [
373
- `node ${plan.releaseHelper} fingerprint`,
374
- "--root .",
375
- `--output ${JSON.stringify(`.release/rust-${namespace}.json`)}`,
376
- `--namespace ${JSON.stringify(namespace)}`,
377
- ...targets.map(
378
- (target) => `--target ${JSON.stringify(`${target.cargo}|${target.fingerprintConfig}`)}`,
379
- ),
380
- `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
381
- ...sources.map((source) => `--source ${JSON.stringify(source)}`),
382
- "--source-only",
383
- "--portable",
384
- ].join(" "),
385
- );
386
- }
318
+ project.tryRemoveFile(".projen/rust-release.mjs");
387
319
  }
388
320
 
389
321
  function rustBindingCommands(plan: RustReleasePlan, independent: boolean): string[] {
@@ -458,42 +390,38 @@ function rustBinaryCommands(plan: RustReleasePlan, independent: boolean): string
458
390
  });
459
391
  }
460
392
 
461
- type RustBuildKind = "uniffi" | "binaries";
462
-
463
- function rustArtifactSteps(plan: RustReleasePlan, kind: RustBuildKind): JobStep[] {
393
+ function rustArtifactSteps(plan: RustReleasePlan): JobStep[] {
464
394
  const binaryCrates = [...new Set(plan.releaseBinaries.map((binary) => binary.crate))];
465
395
  return [
466
- ...(kind === "uniffi"
467
- ? plan.bindings.flatMap((binding) => [
468
- ...(binding.node
469
- ? [
470
- {
471
- name: `Upload ${binding.crate} native npm package`,
472
- uses: "actions/upload-artifact@v7",
473
- with: {
474
- name: `${binding.crate}-\${{ matrix.node }}-npm`,
475
- path: `dist/release/${binding.crate}/\${{ matrix.node }}/npm/*.tgz`,
476
- "retention-days": 7,
477
- },
478
- },
479
- ]
480
- : []),
481
- ...(binding.python
482
- ? [
483
- {
484
- name: `Upload ${binding.crate} Python wheel`,
485
- uses: "actions/upload-artifact@v7",
486
- with: {
487
- name: `${binding.pythonPackage}--\${{ matrix.python }}--python-wheel`,
488
- path: `dist/release/${binding.crate}/\${{ matrix.node }}/python/*.whl`,
489
- "retention-days": 7,
490
- },
491
- },
492
- ]
493
- : []),
494
- ])
495
- : []),
496
- ...(kind === "binaries" ? binaryCrates : []).map((crate) => {
396
+ ...plan.bindings.flatMap((binding) => [
397
+ ...(binding.node
398
+ ? [
399
+ {
400
+ name: `Upload ${binding.crate} native npm package`,
401
+ uses: "actions/upload-artifact@v7",
402
+ with: {
403
+ name: `${binding.crate}-\${{ matrix.node }}-npm`,
404
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/npm/*.tgz`,
405
+ "retention-days": 7,
406
+ },
407
+ },
408
+ ]
409
+ : []),
410
+ ...(binding.python
411
+ ? [
412
+ {
413
+ name: `Upload ${binding.crate} Python wheel`,
414
+ uses: "actions/upload-artifact@v7",
415
+ with: {
416
+ name: `${binding.pythonPackage}--\${{ matrix.python }}--python-wheel`,
417
+ path: `dist/release/${binding.crate}/\${{ matrix.node }}/python/*.whl`,
418
+ "retention-days": 7,
419
+ },
420
+ },
421
+ ]
422
+ : []),
423
+ ]),
424
+ ...binaryCrates.map((crate) => {
497
425
  const binaries = plan.releaseBinaries.filter((binary) => binary.crate === crate);
498
426
  const excludedOs =
499
427
  binaries[0]?.excludedOs.filter((os) =>
@@ -520,85 +448,50 @@ function matrixArgumentLoop(variable: string, field: string, flag: string): stri
520
448
  return `while IFS= read -r ${variable}; do ${variable}="\${${variable}%$'\\r'}"; ${variable}_ARGS+=(${flag} "$${variable}"); done < <(jq -r '.[]' <<<'\${{ toJSON(matrix.${field}) }}')`;
521
449
  }
522
450
 
523
- function rustCargoBuildCommand(plan: RustReleasePlan, kind: RustBuildKind): string {
451
+ function rustCargoBuildCommand(plan: RustReleasePlan): string {
524
452
  return [
525
453
  "PACKAGE_ARGS=()",
526
454
  "FEATURE_ARGS=()",
527
455
  matrixArgumentLoop("PACKAGE", "packages", "--package"),
528
456
  matrixArgumentLoop("FEATURE", "features", "--features"),
529
- ...(kind === "binaries"
530
- ? ["BINARY_ARGS=()", matrixArgumentLoop("BINARY", "binaries", "--bin")]
531
- : ["BINARY_ARGS=()"]),
532
- `cargo build --release --timings "\${PACKAGE_ARGS[@]}" "\${BINARY_ARGS[@]}" "\${FEATURE_ARGS[@]}"${
457
+ `cargo build --release --timings "\${PACKAGE_ARGS[@]}" "\${FEATURE_ARGS[@]}"${
533
458
  plan.usesCargoLock ? " --locked" : ""
534
459
  } --target "\${{ matrix.cargo }}"`,
535
460
  ].join("\n");
536
461
  }
537
462
 
538
- export function rustBuildJob(
539
- plan: RustReleasePlan,
540
- kind: RustBuildKind,
541
- independentSetup?: readonly JobStep[],
542
- ): Job {
463
+ export function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep[]): Job {
543
464
  const independent = Boolean(independentSetup);
544
- const targets = kind === "uniffi" ? plan.uniffiTargets : plan.binaryTargets;
545
- const outputName = kind === "uniffi" ? "rust_uniffi_targets" : "rust_binary_targets";
546
- const title = kind === "uniffi" ? "UniFFI" : "binaries";
547
- const bindingCommands = kind === "uniffi" ? rustBindingCommands(plan, independent) : [];
548
- const binaryCommands = kind === "binaries" ? rustBinaryCommands(plan, independent) : [];
549
- const fingerprintSources = [...new Set(targets.flatMap((target) => target.sources))].sort();
465
+ const bindingCommands = rustBindingCommands(plan, independent);
466
+ const binaryCommands = rustBinaryCommands(plan, independent);
550
467
  return {
551
468
  if: independentSetup
552
- ? `\${{ needs.release-plan.outputs.${outputName} != '[]' && (github.event_name == 'push' || inputs.stage != 'docs') }}`
553
- : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
554
- name: `${title} / \${{ matrix.node }}`,
469
+ ? "${{ needs.release-plan.outputs.rust_targets != '[]' && (github.event_name == 'push' || inputs.stage != 'docs') }}"
470
+ : "${{ needs.verify-context.outputs.build_mode == 'remote' && (github.event_name == 'push' || inputs.stage == 'all') }}",
471
+ name: "Rust / ${{ matrix.node }}",
555
472
  needs: [independentSetup ? "release-plan" : "verify-context"],
556
473
  runsOn: ["${{ matrix.runner }}"],
557
474
  permissions: { contents: JobPermission.READ },
558
475
  env: {
559
- ...RUST_CACHE_ENV,
476
+ ...RUST_BUILD_ENV,
560
477
  ...(independentSetup ? { BUN_VERSION } : {}),
561
478
  },
562
479
  strategy: {
563
480
  failFast: false,
564
481
  matrix: {
565
482
  include: independentSetup
566
- ? (`\${{ fromJSON(needs.release-plan.outputs.${outputName}) }}` as never)
567
- : ([...targets] as never),
483
+ ? ("${{ fromJSON(needs.release-plan.outputs.rust_targets) }}" as never)
484
+ : ([...plan.targets] as never),
568
485
  },
569
486
  },
570
487
  steps: [
571
488
  ...(independentSetup ?? releaseSourceSteps()),
572
- ...(kind === "uniffi" && plan.hasPythonBindings
573
- ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }]
574
- : []),
489
+ ...(plan.hasPythonBindings ? [{ name: "Setup uv", uses: "astral-sh/setup-uv@v7" }] : []),
575
490
  {
576
491
  name: "Setup Rust",
577
- ...(plan.usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
578
492
  uses: `dtolnay/rust-toolchain@${plan.releaseRustVersion}`,
579
493
  with: { targets: "${{ matrix.cargo }}" },
580
494
  },
581
- ...(plan.usePreinstalledWindowsRust
582
- ? [
583
- {
584
- name: "Verify preinstalled Windows Rust",
585
- if: "${{ matrix.os == 'win32' }}",
586
- shell: "bash",
587
- run: [
588
- "rustc --version --verbose",
589
- "cargo --version",
590
- 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
591
- 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
592
- ].join("\n"),
593
- },
594
- ]
595
- : []),
596
- {
597
- name: "Install LLVM release tools",
598
- shell: "bash",
599
- run: "rustup component add llvm-tools-preview",
600
- },
601
- ...rustCacheSteps(`release-${kind}-\${{ matrix.cargo }}-rust-${plan.releaseRustVersion}`),
602
495
  {
603
496
  name: "Install Linux native dependencies",
604
497
  if: "${{ matrix.os == 'linux' }}",
@@ -614,114 +507,10 @@ export function rustBuildJob(
614
507
  ),
615
508
  },
616
509
  {
617
- name: "Verify Rust build fingerprint",
618
- if: independentSetup
619
- ? undefined
620
- : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
621
- ...(!independentSetup ? { id: "rust-fingerprint" } : {}),
622
- shell: "bash",
623
- run: [
624
- `RUNTIME_MANIFEST="dist/rust-raw/rust-${kind}-build-\${{ matrix.node }}.json"`,
625
- 'mkdir -p "$(dirname "$RUNTIME_MANIFEST")"',
626
- [
627
- `node ${plan.releaseHelper} fingerprint --root .`,
628
- '--output "$RUNTIME_MANIFEST"',
629
- `--namespace ${JSON.stringify(kind)}`,
630
- '--target "${{ matrix.cargo }}|${{ matrix.fingerprintConfig }}"',
631
- `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
632
- ...fingerprintSources.map((source) => `--source ${JSON.stringify(source)}`),
633
- "--source-only",
634
- ...(independentSetup ? ["--portable"] : []),
635
- ].join(" "),
636
- `test "$(jq -r .namespace "$RUNTIME_MANIFEST")" = "$(jq -r .namespace .release/rust-${kind}.json)"`,
637
- `test "$(jq -r .rustSourceHash "$RUNTIME_MANIFEST")" = "$(jq -r .rustSourceHash .release/rust-${kind}.json)"`,
638
- 'KEY="$(jq -r --arg target "${{ matrix.cargo }}" \'.targets[$target] // empty\' "$RUNTIME_MANIFEST")"',
639
- 'test -n "$KEY"',
640
- 'echo "key=$KEY" >> "$GITHUB_OUTPUT"',
641
- ].join("\n"),
642
- },
643
- ...(!independentSetup
644
- ? [
645
- {
646
- name: "Reuse matching raw Rust outputs",
647
- id: "raw-native",
648
- shell: "bash",
649
- env: { GH_TOKEN: "${{ github.token }}" },
650
- // prettier-ignore
651
- run: stringUtils.dedent(
652
- // ============================================================================
653
- /*bash*/`
654
- KEY="\${{ steps.rust-fingerprint.outputs.key }}"
655
- test -n "$KEY"
656
- ASSET="rust-${kind}-raw-\${{ matrix.node }}-$KEY.tar.gz"
657
- mkdir -p dist/rust-raw
658
- 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)"
659
- if [ -n "$MATCH" ]; then
660
- TAG="\${MATCH%%$'\\t'*}"
661
- URL="\${MATCH#*$'\\t'}"
662
- CHECKSUM_URL="$(gh api "repos/\${{ github.repository }}/releases/tags/$TAG" --jq '.assets[] | select(.name == "'"$ASSET.sha256"'") | .url')"
663
- rm -rf "target/\${{ matrix.cargo }}/release"
664
- 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
665
- echo "hit=true" >> "$GITHUB_OUTPUT"
666
- else
667
- rm -rf "target/\${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"
668
- echo "::warning::matching raw Rust bundle failed validation; rebuilding"
669
- echo "hit=false" >> "$GITHUB_OUTPUT"
670
- fi
671
- else
672
- echo "hit=false" >> "$GITHUB_OUTPUT"
673
- fi
674
- echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
675
- `
676
- // ============================================================================
677
- ),
678
- },
679
- ]
680
- : []),
681
- {
682
- name: `Build Rust ${title} outputs`,
683
- ...(independentSetup ? {} : { if: "${{ steps.raw-native.outputs.hit != 'true' }}" }),
510
+ name: "Build Rust release outputs",
684
511
  shell: "bash",
685
- env: {
686
- CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
687
- "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
688
- },
689
- run: timedBash(`rust_${kind}`, rustCargoBuildCommand(plan, kind)),
512
+ run: timedBash("rust", rustCargoBuildCommand(plan)),
690
513
  },
691
- ...(!independentSetup
692
- ? [
693
- {
694
- name: "Upload Cargo timings",
695
- if: "${{ steps.raw-native.outputs.hit != 'true' }}",
696
- uses: "actions/upload-artifact@v7",
697
- with: {
698
- name: `rust-${kind}-\${{ matrix.node }}-cargo-timings`,
699
- path: "target/cargo-timings/*",
700
- "retention-days": 14,
701
- },
702
- },
703
- ]
704
- : []),
705
- ...(!independentSetup
706
- ? [
707
- {
708
- name: "Archive raw Rust outputs",
709
- if: "${{ steps.raw-native.outputs.hit != 'true' }}",
710
- shell: "bash",
711
- run: [
712
- "mkdir -p dist/rust-raw",
713
- 'tar -czf "dist/rust-raw/${{ steps.raw-native.outputs.asset }}" "target/${{ matrix.cargo }}/release"',
714
- '(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")',
715
- ].join("\n"),
716
- },
717
- {
718
- name: "Stamp native release version",
719
- shell: "bash",
720
- env: { VERSION: RELEASE_VERSION },
721
- run: `node ${plan.releaseHelper} stamp-tree --root "target/\${{ matrix.cargo }}/release" --version "$VERSION"`,
722
- },
723
- ]
724
- : []),
725
514
  ...(bindingCommands.length
726
515
  ? [
727
516
  {
@@ -741,20 +530,42 @@ export function rustBuildJob(
741
530
  },
742
531
  ]
743
532
  : []),
744
- ...(!independentSetup
745
- ? [
746
- {
747
- name: "Upload reusable raw Rust outputs",
748
- uses: "actions/upload-artifact@v7",
749
- with: {
750
- name: `rust-${kind}-\${{ matrix.node }}-raw`,
751
- path: "dist/rust-raw/*",
752
- "retention-days": 7,
753
- },
754
- },
755
- ]
756
- : []),
757
- ...rustArtifactSteps(plan, kind),
533
+ ...rustArtifactSteps(plan),
534
+ ],
535
+ };
536
+ }
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
+ },
758
569
  ],
759
570
  };
760
571
  }
@@ -765,7 +576,7 @@ export function rustCargoPublishJob(plan: RustReleasePlan, local: boolean): Job
765
576
  if: local
766
577
  ? "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}"
767
578
  : "${{ github.event_name == 'push' }}",
768
- needs: ["verify-context", ...rustBuildJobIds(plan)],
579
+ needs: ["verify-context", ...(plan.hasTargetOutputs ? ["rust-assets"] : [])],
769
580
  runsOn: [local ? "self-hosted" : "ubuntu-latest"],
770
581
  permissions: { contents: JobPermission.READ },
771
582
  steps: [
@@ -850,15 +661,6 @@ export function independentRustGitHubReleaseJob(
850
661
  "merge-multiple": true,
851
662
  },
852
663
  },
853
- {
854
- name: "Download reusable raw Rust outputs",
855
- uses: "actions/download-artifact@v8",
856
- with: {
857
- pattern: "*-raw",
858
- path: "dist/rust-raw",
859
- "merge-multiple": true,
860
- },
861
- },
862
664
  {
863
665
  name: "Upload affected GitHub release assets",
864
666
  env: { GH_TOKEN: "${{ github.token }}" },
@@ -882,13 +684,14 @@ export function independentRustGitHubReleaseJob(
882
684
  export function rustGitHubReleaseJob(plan: RustReleasePlan): Job {
883
685
  return {
884
686
  if: "${{ github.event_name == 'push' }}",
885
- needs: ["verify-context", ...rustBuildJobIds(plan)],
687
+ needs: ["verify-context", ...(plan.hasTargetOutputs ? ["rust-assets"] : [])],
886
688
  runsOn: ["ubuntu-latest"],
887
689
  permissions: { contents: JobPermission.WRITE },
888
690
  steps: [
889
691
  ...releaseSourceSteps(),
890
692
  {
891
693
  name: "Download release binaries",
694
+ if: "${{ needs.verify-context.outputs.build_mode == 'remote' }}",
892
695
  uses: "actions/download-artifact@v8",
893
696
  with: {
894
697
  pattern: "*-binary",
@@ -896,20 +699,12 @@ export function rustGitHubReleaseJob(plan: RustReleasePlan): Job {
896
699
  "merge-multiple": true,
897
700
  },
898
701
  },
899
- {
900
- name: "Download reusable raw Rust outputs",
901
- uses: "actions/download-artifact@v8",
902
- with: {
903
- pattern: "*-raw",
904
- path: "dist/rust-raw",
905
- "merge-multiple": true,
906
- },
907
- },
908
702
  {
909
703
  name: "Publish GitHub release assets",
704
+ if: "${{ needs.verify-context.outputs.build_mode == 'remote' }}",
910
705
  uses: "softprops/action-gh-release@v2",
911
706
  with: {
912
- files: ["dist/rust-release/*", "dist/rust-raw/*"].join("\n"),
707
+ files: "dist/rust-release/*",
913
708
  body_path: RELEASE_SUMMARY_FILE,
914
709
  generate_release_notes: true,
915
710
  tag_name: RELEASE_TAG,
@@ -917,53 +712,14 @@ export function rustGitHubReleaseJob(plan: RustReleasePlan): Job {
917
712
  },
918
713
  },
919
714
  {
920
- name: "Delete superseded raw Rust release assets",
921
- uses: "actions/github-script@v8",
922
- env: { CURRENT_RELEASE_TAG: RELEASE_TAG },
923
- with: {
924
- // prettier-ignore
925
- script: stringUtils.dedent(
926
- // ============================================================================
927
- /*js*/`
928
- const currentTag = process.env.CURRENT_RELEASE_TAG;
929
- if (!currentTag) throw new Error("CURRENT_RELEASE_TAG is required");
930
- const parseVersion = (tag) => {
931
- const match = /^v(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(tag);
932
- return match ? match.slice(1).map(Number) : undefined;
933
- };
934
- const compareVersions = (left, right) => {
935
- for (let index = 0; index < 3; index += 1) {
936
- if (left[index] !== right[index]) return left[index] - right[index];
937
- }
938
- return 0;
939
- };
940
- const currentVersion = parseVersion(currentTag);
941
- if (!currentVersion) throw new Error(\`Unsupported release tag: \${currentTag}\`);
942
- const releases = await github.paginate(github.rest.repos.listReleases, {
943
- ...context.repo,
944
- per_page: 100,
945
- });
946
- const assets = releases.flatMap((release) => {
947
- const version = parseVersion(release.tag_name);
948
- if (release.draft || !version || compareVersions(version, currentVersion) >= 0) {
949
- return [];
950
- }
951
- return release.assets.filter((asset) =>
952
- /^rust-(?:(?:uniffi|binaries)-(?:raw|build)|(?:raw|build))-/.test(asset.name)
953
- );
954
- });
955
- for (const asset of assets) {
956
- core.info(\`Deleting \${asset.name} (\${asset.id})\`);
957
- await github.rest.repos.deleteReleaseAsset({
958
- ...context.repo,
959
- asset_id: asset.id,
960
- });
961
- }
962
- core.info(\`Deleted \${assets.length} superseded raw Rust release assets\`);
963
- `
964
- // ============================================================================
965
- ),
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,
966
721
  },
722
+ run: 'gh release edit "$RELEASE_TAG" --draft=false --latest --notes-file "$RELEASE_NOTES"',
967
723
  },
968
724
  ],
969
725
  };
@@ -972,7 +728,7 @@ export function rustGitHubReleaseJob(plan: RustReleasePlan): Job {
972
728
  export function independentRustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
973
729
  return {
974
730
  if: "${{ needs.release-plan.outputs.node == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
975
- needs: ["release-plan", "rust-uniffi"],
731
+ needs: ["release-plan", "rust-build"],
976
732
  runsOn: ["ubuntu-latest"],
977
733
  permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
978
734
  timeoutMinutes: 15,
@@ -1035,8 +791,8 @@ export function independentRustNodeFacadePublishJob(
1035
791
 
1036
792
  export function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job {
1037
793
  return {
1038
- if: "${{ always() && needs.verify-context.result == 'success' && needs.rust-uniffi.result != 'failure' && needs.rust-uniffi.result != 'cancelled' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
1039
- needs: ["verify-context", "rust-uniffi"],
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"],
1040
796
  runsOn: ["ubuntu-latest"],
1041
797
  permissions: {
1042
798
  actions: JobPermission.READ,
@@ -1052,7 +808,17 @@ export function rustNativeNpmPublishJob(project: DBXToolsJavaScriptProject): Job
1052
808
  recoveredName: "Download recovered native npm packages",
1053
809
  pattern: "*-npm",
1054
810
  path: "dist/uniffi/native",
811
+ condition: "needs.verify-context.outputs.build_mode == 'remote'",
1055
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
+ },
1056
822
  {
1057
823
  name: "Publish native npm packages",
1058
824
  env: { RELEASE_VERSION, ...npmPublishEnvironment() },