@dbx-tools/projen 0.6.145 → 0.6.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -44,7 +44,7 @@ export type { DBXToolsProjectLanguage, DBXToolsProjectOptions, DBXToolsProject,
44
44
  export { PackageIdentifier, PROJEN_VERSION, DBXToolsNodeProject, ROOT_INSTALL_ONLY_MIXIN, DBXToolsTypeScriptProject } from "./src/project-js.ts";
45
45
  export type { DBXToolsJavaScriptProject, DBXToolsJavaScriptProjectOptions, DBXToolsTypeScriptProjectOptions } from "./src/project-js.ts";
46
46
  export { DBXToolsPythonProject, DBXToolsPythonWorkspace } from "./src/project-py.ts";
47
- export type { PythonRepositoryOptions, PythonPackageOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
47
+ export type { PythonRepositoryOptions, PythonPackageOptions, PythonTrustedPublisherOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
48
48
  export { UNIFFI_RELEASE_TARGETS, DBXToolsRustProject, DBXToolsRustWorkspace } from "./src/project-rs.ts";
49
49
  export type { CargoDependencyOptions, CargoDependency, RustPackageOptions, DBXToolsRustWorkspaceOptions, UniFFIReleaseTarget, RustBindingMapping, RustWorkspaceMapping } from "./src/project-rs.ts";
50
50
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.145",
30
- "@dbx-tools/path": "0.6.145",
31
- "@dbx-tools/shared-core": "0.6.145",
29
+ "@dbx-tools/core": "0.6.148",
30
+ "@dbx-tools/path": "0.6.148",
31
+ "@dbx-tools/shared-core": "0.6.148",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "main": "index.ts",
50
50
  "license": "Apache-2.0",
51
- "version": "0.6.145",
51
+ "version": "0.6.148",
52
52
  "types": "index.ts",
53
53
  "type": "module",
54
54
  "exports": {
package/src/project-js.ts CHANGED
@@ -515,6 +515,10 @@ export interface DBXToolsJavaScriptProjectOptions
515
515
  * `@dbx-tools/projen` engine in `projen/`, tagged `projen-v*`).
516
516
  */
517
517
  readonly standaloneReleases?: readonly StandaloneRelease[];
518
+ /** Workflow that must finish successfully before the main Node release runs. */
519
+ readonly releaseUpstreamWorkflow?: string;
520
+ /** Main Node release workflow name. Defaults to `node-release`. */
521
+ readonly releaseWorkflowName?: string | false;
518
522
  /**
519
523
  * Extra workspace member paths (repo-relative, POSIX) to list in the workspace
520
524
  * config ALONGSIDE the discovered `packageRoots` members - for a package that
@@ -1183,6 +1187,8 @@ function initProject(
1183
1187
  new DBXToolsRelease(project as DBXToolsNodeProject, {
1184
1188
  tagPrefix: options.releaseTagPrefix,
1185
1189
  standaloneReleases: options.standaloneReleases,
1190
+ upstreamWorkflow: options.releaseUpstreamWorkflow,
1191
+ workflowName: options.releaseWorkflowName,
1186
1192
  });
1187
1193
  }
1188
1194
 
package/src/project-py.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Reusable uv workspace generation for Python packages hosted in a projen tree. */
2
2
  import { string } from "@dbx-tools/shared-core";
3
- import { Component, type Project, javascript, python, vscode } from "projen";
3
+ import { Component, TextFile, type Project, javascript, python, vscode } from "projen";
4
4
  import type { IResolver } from "projen/lib/file";
5
5
  import { GithubWorkflow } from "projen/lib/github";
6
6
  import { JobPermission } from "projen/lib/github/workflows-model";
@@ -25,6 +25,15 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
25
25
  readonly scripts?: Readonly<Record<string, string>>;
26
26
  /** Keep this package unpublished and out of public docs and releases. */
27
27
  readonly private?: boolean;
28
+ /** Trusted publisher used outside the standard Python release workflow. */
29
+ readonly trustedPublisher?: PythonTrustedPublisherOptions;
30
+ }
31
+
32
+ /** GitHub Actions publisher for a Python package released by another workflow. */
33
+ export interface PythonTrustedPublisherOptions {
34
+ readonly workflowName: string;
35
+ readonly environment: string;
36
+ readonly artifacts?: string;
28
37
  }
29
38
 
30
39
  /** Options for one projen-native Python workspace member. */
@@ -40,11 +49,21 @@ export interface DBXToolsPythonProjectOptions extends DBXToolsProjectOptions {
40
49
  /** Python release workflow configuration. */
41
50
  export interface PythonReleaseOptions {
42
51
  readonly workflowName?: string;
52
+ /** Run after this workflow completes successfully instead of directly on tags. */
53
+ readonly upstreamWorkflow?: string;
43
54
  /** GitHub environment by Python distribution name. Defaults to `pypi-<name>`. */
44
55
  readonly environments?: Readonly<Record<string, string>>;
45
56
  readonly environmentUrl?: string;
46
57
  }
47
58
 
59
+ interface PythonPublication {
60
+ readonly directory: string;
61
+ readonly distribution: string;
62
+ readonly environment: string;
63
+ readonly workflowName: string;
64
+ readonly artifacts?: string;
65
+ }
66
+
48
67
  /** Options for {@link DBXToolsPythonWorkspace}. */
49
68
  export interface DBXToolsPythonWorkspaceOptions {
50
69
  readonly packages: readonly PythonPackageOptions[];
@@ -231,13 +250,16 @@ export class DBXToolsPythonWorkspace extends Component {
231
250
  }
232
251
  this.addTasks(project, options);
233
252
 
253
+ const releaseOptions = options.release === true ? {} : options.release || {};
254
+ this.addTrustedPublisherInstructionsTask(project, releaseOptions);
255
+
234
256
  const interpreterPath = options.interpreterPath ?? "${workspaceFolder}/.venv/bin/python";
235
257
  if (interpreterPath !== false) {
236
258
  projectVscode(project)?.settings.addSetting("python.defaultInterpreterPath", interpreterPath);
237
259
  }
238
260
 
239
261
  if (options.release) {
240
- this.addReleaseWorkflow(project, options.release === true ? {} : options.release);
262
+ this.addReleaseWorkflow(project, releaseOptions);
241
263
  }
242
264
  }
243
265
 
@@ -331,11 +353,20 @@ export class DBXToolsPythonWorkspace extends Component {
331
353
 
332
354
  private addReleaseWorkflow(project: javascript.NodeProject, options: PythonReleaseOptions): void {
333
355
  if (!project.github) return;
334
- const publishablePackages = this.packages.filter((pkg) => !pkg.packageOptions.private);
335
- if (publishablePackages.length === 0) return;
356
+ const publications = this.publications(options);
357
+ if (publications.length === 0) return;
336
358
  const workflow = new GithubWorkflow(project.github, options.workflowName ?? "python-release");
337
359
  workflow.file?.addOverride("permissions", { contents: "read" });
338
- workflow.on({ push: { tags: ["v*"] }, workflowDispatch: {} });
360
+ const upstreamWorkflow = options.upstreamWorkflow;
361
+ if (upstreamWorkflow) {
362
+ workflow.file?.addOverride("on.workflow_run", {
363
+ workflows: [upstreamWorkflow],
364
+ types: ["completed"],
365
+ });
366
+ workflow.on({ workflowDispatch: {} });
367
+ } else {
368
+ workflow.on({ push: { tags: ["v*"] }, workflowDispatch: {} });
369
+ }
339
370
  workflow.file?.addOverride("on.workflow_dispatch", {
340
371
  inputs: {
341
372
  version: {
@@ -346,32 +377,56 @@ export class DBXToolsPythonWorkspace extends Component {
346
377
  },
347
378
  });
348
379
  workflow.addJob("build", {
380
+ ...(upstreamWorkflow
381
+ ? {
382
+ if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') }}",
383
+ }
384
+ : {}),
349
385
  runsOn: ["ubuntu-latest"],
350
386
  permissions: { contents: JobPermission.READ },
351
387
  timeoutMinutes: 20,
352
388
  steps: [
353
- { name: "Checkout", uses: "actions/checkout@v6" },
389
+ {
390
+ name: "Checkout",
391
+ uses: "actions/checkout@v6",
392
+ with: {
393
+ "fetch-depth": 0,
394
+ ...(upstreamWorkflow
395
+ ? {
396
+ ref: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
397
+ }
398
+ : {}),
399
+ },
400
+ },
354
401
  { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
355
402
  {
356
403
  name: "Stamp workspace versions",
357
404
  env: {
358
405
  VERSION: "${{ github.event_name == 'push' && github.ref_name || inputs.version }}",
359
406
  },
360
- run: this.renderVersionStampScript(),
407
+ run: upstreamWorkflow
408
+ ? [
409
+ 'if [ "$GITHUB_EVENT_NAME" = "workflow_run" ]; then',
410
+ ' VERSION="$(git tag --points-at HEAD --list "v*" | sort -V | tail -1)"',
411
+ ' test -n "$VERSION"',
412
+ "fi",
413
+ this.renderVersionStampScript(),
414
+ ].join("\n")
415
+ : this.renderVersionStampScript(),
361
416
  },
362
417
  {
363
418
  name: "Build distributions",
364
- run: publishablePackages
419
+ run: publications
365
420
  .map(
366
- (pkg) =>
367
- `uv build --package ${pkg.packageOptions.name} --out-dir dist/${pkg.packageOptions.directory}`,
421
+ (publication) =>
422
+ `uv build --package ${publication.distribution} --out-dir dist/${publication.directory}`,
368
423
  )
369
424
  .join("\n"),
370
425
  },
371
426
  {
372
427
  name: "Validate distributions",
373
428
  run: [
374
- `test "$(find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) | wc -l | tr -d ' ')" -eq ${publishablePackages.length * 2}`,
429
+ `test "$(find dist -type f \\( -name '*.whl' -o -name '*.tar.gz' \\) | wc -l | tr -d ' ')" -eq ${publications.length * 2}`,
375
430
  "uvx twine check dist/*/*.whl dist/*/*.tar.gz",
376
431
  ].join("\n"),
377
432
  },
@@ -382,16 +437,15 @@ export class DBXToolsPythonWorkspace extends Component {
382
437
  },
383
438
  ],
384
439
  });
385
- for (const pkg of publishablePackages) {
386
- workflow.addJob(`publish-${pkg.packageOptions.directory}`, {
387
- if: "${{ github.event_name == 'push' }}",
440
+ for (const publication of publications) {
441
+ workflow.addJob(`publish-${publication.directory}`, {
442
+ if: "${{ github.event_name == 'push' || github.event_name == 'workflow_run' }}",
388
443
  needs: ["build"],
389
444
  environment: {
390
- name:
391
- options.environments?.[pkg.packageOptions.name] ?? `pypi-${pkg.packageOptions.name}`,
445
+ name: publication.environment,
392
446
  url:
393
447
  options.environmentUrl ??
394
- `https://pypi.org/project/${pkg.packageOptions.name.replaceAll("_", "-")}/`,
448
+ `https://pypi.org/project/${publication.distribution.replaceAll("_", "-")}/`,
395
449
  },
396
450
  runsOn: ["ubuntu-latest"],
397
451
  permissions: { idToken: JobPermission.WRITE },
@@ -403,15 +457,154 @@ export class DBXToolsPythonWorkspace extends Component {
403
457
  with: { name: "python-distributions", path: "dist" },
404
458
  },
405
459
  {
406
- name: `Publish ${pkg.packageOptions.name} to PyPI`,
460
+ name: `Publish ${publication.distribution} to PyPI`,
407
461
  uses: "pypa/gh-action-pypi-publish@release/v1",
408
- with: { "packages-dir": `dist/${pkg.packageOptions.directory}` },
462
+ with: { "packages-dir": `dist/${publication.directory}` },
409
463
  },
410
464
  ],
411
465
  });
412
466
  }
413
467
  }
414
468
 
469
+ private publications(options: PythonReleaseOptions): readonly PythonPublication[] {
470
+ return this.packages
471
+ .filter((pkg) => !pkg.packageOptions.private)
472
+ .map((pkg) => ({
473
+ directory: pkg.packageOptions.directory,
474
+ distribution: pkg.packageOptions.name,
475
+ environment:
476
+ options.environments?.[pkg.packageOptions.name] ?? `pypi-${pkg.packageOptions.name}`,
477
+ workflowName: options.workflowName ?? "python-release",
478
+ }));
479
+ }
480
+
481
+ private trustedPublisherPublications(
482
+ options: PythonReleaseOptions,
483
+ ): readonly PythonPublication[] {
484
+ const standard = new Map(
485
+ this.publications(options).map((publication) => [publication.distribution, publication]),
486
+ );
487
+ for (const pkg of this.packages) {
488
+ const publisher = pkg.packageOptions.trustedPublisher;
489
+ if (!publisher || standard.has(pkg.packageOptions.name)) continue;
490
+ standard.set(pkg.packageOptions.name, {
491
+ directory: pkg.packageOptions.directory,
492
+ distribution: pkg.packageOptions.name,
493
+ environment: publisher.environment,
494
+ workflowName: publisher.workflowName,
495
+ artifacts: publisher.artifacts,
496
+ });
497
+ }
498
+ return [...standard.values()];
499
+ }
500
+
501
+ private addTrustedPublisherInstructionsTask(
502
+ project: javascript.NodeProject,
503
+ options: PythonReleaseOptions,
504
+ ): void {
505
+ const repository = this.githubRepository();
506
+ const publications = this.trustedPublisherPublications(options);
507
+ const linesBeforeAuthentication = [
508
+ "# PyPI Trusted Publisher Setup Instructions",
509
+ "",
510
+ "Use a browser to audit and configure the PyPI trusted publishers below.",
511
+ "",
512
+ "## Audit and confirmation",
513
+ "",
514
+ "Before making any changes, complete a read-only audit:",
515
+ "",
516
+ `- Confirm that the active PyPI account is ${repository.owner}.`,
517
+ "- Start at https://pypi.org/manage/projects/ and determine which listed projects exist.",
518
+ "- Inspect every existing GitHub publisher for each project and compare its repository owner, repository name, workflow filename, and environment with the desired values below.",
519
+ "- Identify duplicate and mismatched trusted publishers that must be replaced by removing the publisher entry and adding the correct one.",
520
+ "- For projects that do not exist, identify the pending publisher that must be created.",
521
+ "- Report the active account and a proposed reconciliation plan grouped by publishers that will be left unchanged, updated, replaced, created, or removed.",
522
+ "- Ask the user to confirm the complete proposed plan before submitting any change.",
523
+ "- After confirmation, perform the authorized plan without asking for additional confirmation unless authentication or a CAPTCHA requires user action.",
524
+ "",
525
+ "## Authentication",
526
+ "",
527
+ ];
528
+ const linesAfterAuthentication = [
529
+ `- If the active account is not ${repository.owner}, pause and ask the user to sign in to the correct account.`,
530
+ "- Pause and ask the user to complete every CAPTCHA. Do not attempt to solve or bypass a CAPTCHA.",
531
+ "- After the user completes an authentication or CAPTCHA step, continue from the current browser session.",
532
+ "",
533
+ "## Efficient browser workflow",
534
+ "",
535
+ "- Reuse or claim an existing PyPI browser tab when available.",
536
+ "- For an existing project, open its publisher page directly at https://pypi.org/manage/project/<project-name>/settings/publishing/.",
537
+ "- For a project that does not exist, use the pending-publisher page at https://pypi.org/manage/account/publishing/.",
538
+ "- Treat the publisher table shown after submission as authoritative confirmation of success, even if the navigation header unexpectedly appears signed out.",
539
+ "- Platform-specific wheels for different operating systems and CPU architectures do not require separate PyPI projects or trusted publishers.",
540
+ "",
541
+ "## Reconciliation rules",
542
+ "",
543
+ "- Never delete a PyPI project or package. Every remove or delete action in these instructions applies only to a trusted publisher entry.",
544
+ "- Treat editing or updating a trusted publisher as replacing that publisher: remove the existing publisher entry, then add the correct one.",
545
+ "- If exactly one publisher matches, leave it unchanged.",
546
+ "- If a publisher is mismatched, remove that trusted publisher entry and add the correct one.",
547
+ "- If no publisher exists, create it.",
548
+ "- Remove duplicates so exactly one matching publisher remains for each PyPI project and workflow.",
549
+ "- Use a pending publisher only when the PyPI project does not exist.",
550
+ "- After every change, verify that the resulting table displays the exact desired configuration.",
551
+ "- At completion, report which publishers were unchanged, updated, replaced, created, or removed.",
552
+ "",
553
+ "Publisher type: GitHub Actions",
554
+ "",
555
+ ...publications.flatMap((publication) => {
556
+ const workflowName = `${publication.workflowName}.yml`;
557
+ return [
558
+ `## ${publication.distribution}`,
559
+ `- PyPI project: ${publication.distribution}`,
560
+ `- GitHub repository: ${repository.owner}/${repository.name}`,
561
+ `- Repository owner: ${repository.owner}`,
562
+ `- Repository name: ${repository.name}`,
563
+ `- GitHub environment: ${publication.environment}`,
564
+ `- Workflow filename: ${workflowName}`,
565
+ `- Workflow path: .github/workflows/${workflowName}`,
566
+ ...(publication.artifacts ? [`- Artifacts: ${publication.artifacts}`] : []),
567
+ "",
568
+ ];
569
+ }),
570
+ ];
571
+ const helper = ".projen/pypi-trusted-publisher-instructions.mjs";
572
+ new TextFile(project.root, helper, {
573
+ lines: [
574
+ "#!/usr/bin/env node",
575
+ 'import { parseArgs } from "node:util";',
576
+ "",
577
+ "const { values } = parseArgs({",
578
+ ' options: { secretFile: { type: "string" } },',
579
+ "});",
580
+ `const beforeAuthentication = ${JSON.stringify(linesBeforeAuthentication)};`,
581
+ `const afterAuthentication = ${JSON.stringify(linesAfterAuthentication)};`,
582
+ "const authentication = values.secretFile",
583
+ " ? [",
584
+ " `- If browser authentication is required, read credentials from ${values.secretFile} directly into the browser without printing, logging, or exposing secret values.`,",
585
+ " `- If ${values.secretFile} is absent, invalid, or insufficient for authentication, pause and ask the user to complete authentication.`,",
586
+ " ]",
587
+ ' : ["- If browser authentication is required, pause and ask the user to complete authentication."];',
588
+ 'process.stdout.write(`${[...beforeAuthentication, ...authentication, ...afterAuthentication].join("\\n").trimEnd()}\\n`);',
589
+ ],
590
+ });
591
+ project.root.addTask("pypiTrustedPublisherInstructions", {
592
+ description: "Print browser-agent instructions for PyPI trusted publishers",
593
+ exec: `node ${helper}`,
594
+ receiveArgs: true,
595
+ });
596
+ }
597
+
598
+ private githubRepository(): { readonly owner: string; readonly name: string } {
599
+ const match = this.repository.url
600
+ .replace(/\.git$/, "")
601
+ .match(/github\.com(?:[/:])([^/]+)\/([^/]+)$/);
602
+ if (!match?.[1] || !match[2]) {
603
+ throw new Error(`Python repository must be hosted on GitHub: ${this.repository.url}`);
604
+ }
605
+ return { owner: match[1], name: match[2] };
606
+ }
607
+
415
608
  private renderVersionStampScript(): string {
416
609
  return [
417
610
  `chmod -R u+w ${this.repository.root}`,
package/src/project-rs.ts CHANGED
@@ -7,6 +7,8 @@ import type { DBXToolsProject } from "./project.ts";
7
7
  import { DBXToolsTypeScriptProject } from "./project-js.ts";
8
8
  import type { PythonPackageOptions } from "./project-py.ts";
9
9
  import { readWorkspaceVersion } from "./workspace-version.ts";
10
+ import { mixin } from "../index.ts";
11
+ import { isDBXToolsJavaScriptProject, isDBXToolsProject } from "./project-predicate.ts";
10
12
 
11
13
  export interface CargoDependencyOptions {
12
14
  readonly version?: string;
@@ -24,6 +26,8 @@ export interface RustPackageOptions {
24
26
  readonly description?: string;
25
27
  /** Keep this crate unpublished and out of public docs. */
26
28
  readonly private?: boolean;
29
+ /** Build this crate's binary for each target and attach it to the GitHub release. */
30
+ readonly release?: boolean;
27
31
  readonly dependencies?: Readonly<Record<string, CargoDependency>>;
28
32
  readonly devDependencies?: Readonly<Record<string, CargoDependency>>;
29
33
  readonly features?: Readonly<Record<string, readonly string[]>>;
@@ -47,10 +51,13 @@ export interface DBXToolsRustWorkspaceOptions {
47
51
  readonly nodeRoot?: string;
48
52
  readonly pythonRoot?: string;
49
53
  readonly pythonModulePrefix?: string;
54
+ readonly private?: boolean;
50
55
  /** Generate tag-driven cross-platform UniFFI package releases. */
51
56
  readonly release?: boolean;
52
57
  /** Native release targets; defaults to the maintained GitHub-hosted matrix. */
53
58
  readonly releaseTargets?: readonly UniFFIReleaseTarget[];
59
+ /** Workflow name used by downstream release stages. Defaults to `rust-release`. */
60
+ readonly releaseWorkflowName?: string;
54
61
  }
55
62
 
56
63
  export interface UniFFIReleaseTarget {
@@ -332,7 +339,9 @@ export class DBXToolsRustWorkspace {
332
339
  crates: this.packages.map((pkg) => `${root}/${pkg.packageOptions.directory}`),
333
340
  bindings: this.bindingMappings,
334
341
  };
335
- project.dbxToolsConfig.rust = this.workspaceMapping;
342
+ if (isDBXToolsJavaScriptProject()(project)) {
343
+ project.dbxToolsConfig.rust = this.workspaceMapping;
344
+ }
336
345
  this.pythonPackages = bindings
337
346
  .filter((pkg) => (pkg.packageOptions.bindings ?? ["node", "python"]).includes("python"))
338
347
  .map((pkg) => ({
@@ -344,6 +353,15 @@ export class DBXToolsRustWorkspace {
344
353
  module: `${options.pythonModulePrefix ?? options.scope.replaceAll("-", "_")}.${pkg.packageOptions.directory.replaceAll("-", "_")}`,
345
354
  description: `Python bindings for ${pkg.crateName}`,
346
355
  private: true,
356
+ trustedPublisher: {
357
+ workflowName: options.releaseWorkflowName ?? "rust-release",
358
+ environment: `native-${pkg.crateName}`,
359
+ artifacts: `platform-specific wheels for ${(
360
+ options.releaseTargets ?? UNIFFI_RELEASE_TARGETS
361
+ )
362
+ .map((target) => `${target.os}-${target.cpu}`)
363
+ .join(", ")}; all architectures publish to this one PyPI project`,
364
+ },
347
365
  }));
348
366
 
349
367
  const existing = new Map(
@@ -449,13 +467,17 @@ export class DBXToolsRustWorkspace {
449
467
  ]),
450
468
  ].join(" && "),
451
469
  });
452
- if ((options.release ?? true) && this.bindingMappings.length > 0) {
453
- this.addReleaseWorkflow(project, options.releaseTargets ?? UNIFFI_RELEASE_TARGETS);
470
+ if (
471
+ (options.release ?? true) &&
472
+ (this.bindingMappings.length > 0 || this.packages.some((pkg) => pkg.packageOptions.release))
473
+ ) {
474
+ this.addReleaseWorkflow(project, options, options.releaseTargets ?? UNIFFI_RELEASE_TARGETS);
454
475
  }
455
476
  }
456
477
 
457
478
  private addReleaseWorkflow(
458
479
  project: javascript.NodeProject,
480
+ options: DBXToolsRustWorkspaceOptions,
459
481
  targets: readonly UniFFIReleaseTarget[],
460
482
  ): void {
461
483
  if (!project.github) return;
@@ -472,9 +494,104 @@ export class DBXToolsRustWorkspace {
472
494
  target: { ...target, facade: index === 0 },
473
495
  })),
474
496
  );
475
- new YamlFile(project, ".github/workflows/rust-release.yml", {
497
+ const releaseBinaries = this.packages
498
+ .filter((pkg) => pkg.packageOptions.release)
499
+ .map((pkg) => ({
500
+ crate: pkg.crateName,
501
+ binary: pkg.packageOptions.binaryName ?? pkg.crateName,
502
+ }));
503
+ const binaryMatrix = releaseBinaries.flatMap((pkg) =>
504
+ targets.map((target) => ({ package: pkg, target })),
505
+ );
506
+ const buildJob = {
507
+ name: "${{ matrix.binding.crate }} / ${{ matrix.target.node }}",
508
+ "runs-on": "${{ matrix.target.runner }}",
509
+ strategy: {
510
+ "fail-fast": false,
511
+ matrix: { include: matrix },
512
+ },
513
+ steps: [
514
+ { name: "Checkout", uses: "actions/checkout@v6" },
515
+ {
516
+ name: "Setup Bun",
517
+ uses: "oven-sh/setup-bun@v2",
518
+ with: { "bun-version": "1.3.14" },
519
+ },
520
+ { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
521
+ {
522
+ name: "Setup Rust",
523
+ uses: "dtolnay/rust-toolchain@stable",
524
+ with: { targets: "${{ matrix.target.cargo }}" },
525
+ },
526
+ {
527
+ name: "Install Linux native dependencies",
528
+ if: "${{ matrix.target.os == 'linux' }}",
529
+ run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
530
+ },
531
+ { name: "Install", run: "bun install" },
532
+ {
533
+ name: "Build thin native packages",
534
+ shell: "bash",
535
+ env: {
536
+ VERSION: "${{ github.event_name == 'push' && github.ref_name || inputs.version }}",
537
+ },
538
+ run: 'bun node_modules/@dbx-tools/projen/tasks/uniffi-release.ts build --crate "${{ matrix.binding.crate }}" --rust "${{ matrix.binding.rust }}" --node "${{ matrix.binding.node }}" --python "${{ matrix.binding.python }}" --node-package "${{ matrix.binding.nodePackage }}" --python-package "${{ matrix.binding.pythonPackage }}" --cargo-target "${{ matrix.target.cargo }}" --node-triple "${{ matrix.target.node }}" --python-tag "${{ matrix.target.python }}" --os "${{ matrix.target.os }}" --cpu "${{ matrix.target.cpu }}" --libc "${{ matrix.target.libc }}" --facade "${{ matrix.target.facade }}" --version "${VERSION#v}"',
539
+ },
540
+ {
541
+ name: "Upload native packages",
542
+ uses: "actions/upload-artifact@v7",
543
+ with: {
544
+ name: "${{ matrix.binding.crate }}-${{ matrix.target.node }}",
545
+ path: [
546
+ "dist/uniffi/npm/*.tgz",
547
+ "dist/uniffi/npm-facade/*.tgz",
548
+ "dist/uniffi/python/*.whl",
549
+ ].join("\n"),
550
+ },
551
+ },
552
+ ],
553
+ };
554
+ const binaryBuildJob = {
555
+ name: "${{ matrix.package.crate }} / ${{ matrix.target.node }}",
556
+ "runs-on": "${{ matrix.target.runner }}",
557
+ strategy: { "fail-fast": false, matrix: { include: binaryMatrix } },
558
+ steps: [
559
+ { name: "Checkout", uses: "actions/checkout@v6" },
560
+ {
561
+ name: "Setup Rust",
562
+ uses: "dtolnay/rust-toolchain@stable",
563
+ with: { targets: "${{ matrix.target.cargo }}" },
564
+ },
565
+ {
566
+ name: "Install Linux native dependencies",
567
+ if: "${{ matrix.target.os == 'linux' }}",
568
+ run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
569
+ },
570
+ {
571
+ name: "Build release binary",
572
+ shell: "bash",
573
+ run: [
574
+ 'cargo build --release --package "${{ matrix.package.crate }}" --bin "${{ matrix.package.binary }}" --target "${{ matrix.target.cargo }}"',
575
+ "mkdir -p dist/rust-release",
576
+ "SOURCE=\"target/${{ matrix.target.cargo }}/release/${{ matrix.package.binary }}${{ matrix.target.os == 'win32' && '.exe' || '' }}\"",
577
+ "DESTINATION=\"dist/rust-release/${{ matrix.package.binary }}-${{ matrix.target.node }}${{ matrix.target.os == 'win32' && '.exe' || '' }}\"",
578
+ 'cp "$SOURCE" "$DESTINATION"',
579
+ ].join("\n"),
580
+ },
581
+ {
582
+ name: "Upload release binary",
583
+ uses: "actions/upload-artifact@v7",
584
+ with: {
585
+ name: "release-${{ matrix.package.crate }}-${{ matrix.target.node }}",
586
+ path: "dist/rust-release/*",
587
+ },
588
+ },
589
+ ],
590
+ };
591
+ const workflowName = options.releaseWorkflowName ?? "rust-release";
592
+ new YamlFile(project, `.github/workflows/${workflowName}.yml`, {
476
593
  obj: {
477
- name: "rust-release",
594
+ name: workflowName,
478
595
  on: {
479
596
  push: { tags: ["v*"] },
480
597
  workflow_dispatch: {
@@ -489,118 +606,63 @@ export class DBXToolsRustWorkspace {
489
606
  },
490
607
  permissions: { contents: "read" },
491
608
  jobs: {
492
- build: {
493
- name: "${{ matrix.binding.crate }} / ${{ matrix.target.node }}",
494
- "runs-on": "${{ matrix.target.runner }}",
495
- strategy: {
496
- "fail-fast": false,
497
- matrix: { include: matrix },
498
- },
499
- steps: [
500
- { name: "Checkout", uses: "actions/checkout@v6" },
501
- {
502
- name: "Setup Bun",
503
- uses: "oven-sh/setup-bun@v2",
504
- with: { "bun-version": "1.3.14" },
505
- },
506
- { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
507
- {
508
- name: "Setup Rust",
509
- uses: "dtolnay/rust-toolchain@stable",
510
- with: { targets: "${{ matrix.target.cargo }}" },
511
- },
512
- {
513
- name: "Install Linux native dependencies",
514
- if: "${{ matrix.target.os == 'linux' }}",
515
- run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
516
- },
517
- { name: "Install", run: "bun install" },
518
- {
519
- name: "Build thin native packages",
520
- shell: "bash",
521
- env: {
522
- VERSION:
523
- "${{ github.event_name == 'push' && github.ref_name || inputs.version }}",
609
+ ...(matrix.length ? { build: buildJob } : {}),
610
+ ...(binaryMatrix.length ? { "build-binaries": binaryBuildJob } : {}),
611
+ ...(bindings.length
612
+ ? {
613
+ publish: {
614
+ if: "${{ github.event_name == 'push' }}",
615
+ needs: ["build"],
616
+ "runs-on": "ubuntu-latest",
617
+ strategy: { matrix: { binding: bindings } },
618
+ permissions: { contents: "read", "id-token": "write" },
619
+ environment: { name: "native-${{ matrix.binding.crate }}" },
620
+ steps: [
621
+ { name: "Checkout", uses: "actions/checkout@v6" },
622
+ {
623
+ name: "Setup Node.js",
624
+ uses: "actions/setup-node@v6",
625
+ with: { "registry-url": "https://registry.npmjs.org" },
626
+ },
627
+ {
628
+ name: "Setup Bun",
629
+ uses: "oven-sh/setup-bun@v2",
630
+ with: { "bun-version": "1.3.14" },
631
+ },
632
+ { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
633
+ { name: "Install", run: "bun install" },
634
+ {
635
+ name: "Download packages",
636
+ uses: "actions/download-artifact@v8",
637
+ with: {
638
+ pattern: "${{ matrix.binding.crate }}-*",
639
+ path: "dist/uniffi",
640
+ "merge-multiple": true,
641
+ },
642
+ },
643
+ {
644
+ name: "Publish npm packages",
645
+ env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
646
+ run: 'for package in dist/uniffi/npm/*.tgz; do npm publish "$package" --access public; done',
647
+ },
648
+ {
649
+ name: "Publish npm facades",
650
+ if: "${{ matrix.binding.node != '' }}",
651
+ env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
652
+ run: 'for package in dist/uniffi/npm-facade/*.tgz; do npm publish "$package" --access public; done',
653
+ },
654
+ {
655
+ name: "Publish Python wheels",
656
+ if: "${{ matrix.binding.python != '' }}",
657
+ run: "uv publish --trusted-publishing always dist/uniffi/python/*.whl",
658
+ },
659
+ ],
524
660
  },
525
- run: 'bun node_modules/@dbx-tools/projen/tasks/uniffi-release.ts build --crate "${{ matrix.binding.crate }}" --rust "${{ matrix.binding.rust }}" --node "${{ matrix.binding.node }}" --python "${{ matrix.binding.python }}" --node-package "${{ matrix.binding.nodePackage }}" --python-package "${{ matrix.binding.pythonPackage }}" --cargo-target "${{ matrix.target.cargo }}" --node-triple "${{ matrix.target.node }}" --python-tag "${{ matrix.target.python }}" --os "${{ matrix.target.os }}" --cpu "${{ matrix.target.cpu }}" --libc "${{ matrix.target.libc }}" --facade "${{ matrix.target.facade }}" --version "${VERSION#v}"',
526
- },
527
- {
528
- name: "Upload native packages",
529
- uses: "actions/upload-artifact@v7",
530
- with: {
531
- name: "${{ matrix.binding.crate }}-${{ matrix.target.node }}",
532
- path: [
533
- "dist/uniffi/npm/*.tgz",
534
- "dist/uniffi/npm-facade/*.tgz",
535
- "dist/uniffi/python/*.whl",
536
- ].join("\n"),
537
- },
538
- },
539
- ],
540
- },
541
- publish: {
542
- if: "${{ github.event_name == 'push' }}",
543
- needs: ["build"],
544
- "runs-on": "ubuntu-latest",
545
- strategy: { matrix: { binding: bindings } },
546
- permissions: { contents: "read", "id-token": "write" },
547
- environment: { name: "native-${{ matrix.binding.crate }}" },
548
- steps: [
549
- { name: "Checkout", uses: "actions/checkout@v6" },
550
- {
551
- name: "Setup Node.js",
552
- uses: "actions/setup-node@v6",
553
- with: { "registry-url": "https://registry.npmjs.org" },
554
- },
555
- {
556
- name: "Setup Bun",
557
- uses: "oven-sh/setup-bun@v2",
558
- with: { "bun-version": "1.3.14" },
559
- },
560
- { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
561
- { name: "Install", run: "bun install" },
562
- {
563
- name: "Publish workspace npm dependencies",
564
- if: "${{ matrix.binding.node != '' }}",
565
- env: {
566
- NPM_CONFIG_TOKEN: "${{ secrets.NPM_TOKEN }}",
567
- NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
568
- },
569
- run: [
570
- 'VERSION="${GITHUB_REF_NAME#v}"',
571
- 'bun node_modules/@dbx-tools/projen/tasks/publish.ts "$VERSION" --exclude projen --exclude "${{ matrix.binding.node }}"',
572
- ].join("\n"),
573
- },
574
- {
575
- name: "Download packages",
576
- uses: "actions/download-artifact@v8",
577
- with: {
578
- pattern: "${{ matrix.binding.crate }}-*",
579
- path: "dist/uniffi",
580
- "merge-multiple": true,
581
- },
582
- },
583
- {
584
- name: "Publish npm packages",
585
- env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
586
- run: 'for package in dist/uniffi/npm/*.tgz; do npm publish "$package" --access public; done',
587
- },
588
- {
589
- name: "Publish npm facades",
590
- if: "${{ matrix.binding.node != '' }}",
591
- env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
592
- run: 'for package in dist/uniffi/npm-facade/*.tgz; do npm publish "$package" --access public; done',
593
- },
594
- {
595
- name: "Publish Python wheels",
596
- if: "${{ matrix.binding.python != '' }}",
597
- run: "uv publish --trusted-publishing always dist/uniffi/python/*.whl",
598
- },
599
- ],
600
- },
661
+ }
662
+ : {}),
601
663
  "publish-cargo": {
602
664
  if: "${{ github.event_name == 'push' }}",
603
- needs: ["build"],
665
+ needs: [matrix.length ? "build" : "build-binaries"],
604
666
  "runs-on": "ubuntu-latest",
605
667
  permissions: { contents: "read" },
606
668
  steps: [
@@ -642,6 +704,35 @@ export class DBXToolsRustWorkspace {
642
704
  },
643
705
  ],
644
706
  },
707
+ ...(binaryMatrix.length
708
+ ? {
709
+ "publish-github-release": {
710
+ if: "${{ github.event_name == 'push' }}",
711
+ needs: ["build-binaries"],
712
+ "runs-on": "ubuntu-latest",
713
+ permissions: { contents: "write" },
714
+ steps: [
715
+ {
716
+ name: "Download release binaries",
717
+ uses: "actions/download-artifact@v8",
718
+ with: {
719
+ pattern: "release-*",
720
+ path: "dist/rust-release",
721
+ "merge-multiple": true,
722
+ },
723
+ },
724
+ {
725
+ name: "Publish GitHub release assets",
726
+ uses: "softprops/action-gh-release@v2",
727
+ with: {
728
+ files: "dist/rust-release/*",
729
+ "generate-release-notes": true,
730
+ },
731
+ },
732
+ ],
733
+ },
734
+ }
735
+ : {}),
645
736
  },
646
737
  },
647
738
  });
package/src/release.ts CHANGED
@@ -41,6 +41,9 @@ function BUN_PUBLISH_SCRIPT(tagPrefix: string, excludeDirs: readonly string[]):
41
41
  // A manual run has no tag: use a throwaway version and never really publish.
42
42
  ' VERSION="0.0.0-dry.${GITHUB_RUN_NUMBER}"',
43
43
  " DRY_RUN=--dry-run",
44
+ 'elif [ -n "${RELEASE_VERSION:-}" ]; then',
45
+ ' VERSION="$RELEASE_VERSION"',
46
+ " DRY_RUN=",
44
47
  "else",
45
48
  ` VERSION="\${GITHUB_REF_NAME#${tagPrefix}}"`,
46
49
  // A tag push honors the input too, so a dry-run tag can be tested if wanted.
@@ -56,6 +59,7 @@ interface PublishWorkflow {
56
59
  readonly tagPrefix: string;
57
60
  readonly steps: readonly JobStep[];
58
61
  readonly workingDirectory?: string;
62
+ readonly upstreamWorkflow?: string;
59
63
  }
60
64
 
61
65
  /** Shared checkout and toolchain setup for every npm publish workflow. */
@@ -120,6 +124,10 @@ export interface DBXToolsReleaseOptions {
120
124
  * component. Defaults to none.
121
125
  */
122
126
  readonly standaloneReleases?: readonly StandaloneRelease[];
127
+ /** Run the main Node release after this workflow completes successfully. */
128
+ readonly upstreamWorkflow?: string;
129
+ /** Main Node release workflow name. Defaults to `node-release`. */
130
+ readonly workflowName?: string | false;
123
131
  }
124
132
 
125
133
  /**
@@ -151,11 +159,15 @@ export interface DBXToolsReleaseOptions {
151
159
  export class DBXToolsRelease extends Component {
152
160
  private readonly tagPrefix: string;
153
161
  private readonly standaloneReleases: readonly StandaloneRelease[];
162
+ private readonly upstreamWorkflow?: string;
163
+ private readonly workflowName: string | false;
154
164
 
155
165
  constructor(project: DBXToolsNodeProject, options: DBXToolsReleaseOptions = {}) {
156
166
  super(project);
157
167
  this.tagPrefix = options.tagPrefix ?? "v";
158
168
  this.standaloneReleases = options.standaloneReleases ?? [];
169
+ this.upstreamWorkflow = options.upstreamWorkflow;
170
+ this.workflowName = options.workflowName ?? "node-release";
159
171
  }
160
172
 
161
173
  public override preSynthesize(): void {
@@ -176,7 +188,7 @@ export class DBXToolsRelease extends Component {
176
188
  // Author the tag-driven publish workflows only when GitHub is enabled - they
177
189
  // live in `.github/`, which requires projen's GitHub component.
178
190
  if (project.github) {
179
- this.authorReleaseWorkflow(project);
191
+ if (this.workflowName) this.authorReleaseWorkflow(project);
180
192
  for (const standalone of this.standaloneReleases) {
181
193
  this.authorStandaloneReleaseWorkflow(project, standalone);
182
194
  }
@@ -186,7 +198,7 @@ export class DBXToolsRelease extends Component {
186
198
  /** Author the common tag trigger, concurrency policy, permissions, and publish job. */
187
199
  private authorPublishWorkflow(
188
200
  project: DBXToolsNodeProject,
189
- { name, tagPrefix, steps, workingDirectory }: PublishWorkflow,
201
+ { name, tagPrefix, steps, workingDirectory, upstreamWorkflow }: PublishWorkflow,
190
202
  ): void {
191
203
  const workflow = new GithubWorkflow(project.github!, name, {
192
204
  // Serialize publishes so two tags landing together cannot race to the
@@ -198,7 +210,14 @@ export class DBXToolsRelease extends Component {
198
210
  // Read-only floor for any job that does not declare its own permissions;
199
211
  // the publish job below overrides it with the `id-token` it needs.
200
212
  workflow.file?.addOverride("permissions", { contents: "read" });
201
- workflow.on({ push: { tags: [`${tagPrefix}*`] } });
213
+ if (upstreamWorkflow) {
214
+ workflow.file?.addOverride("on.workflow_run", {
215
+ workflows: [upstreamWorkflow],
216
+ types: ["completed"],
217
+ });
218
+ } else {
219
+ workflow.on({ push: { tags: [`${tagPrefix}*`] } });
220
+ }
202
221
  // Manual trigger for testing the workflow WITHOUT reaching npm: a
203
222
  // `workflow_dispatch` run has no tag, so the publish script forces
204
223
  // `--dry-run` (pack + validate only). The `dry_run` input (default true)
@@ -213,6 +232,11 @@ export class DBXToolsRelease extends Component {
213
232
  },
214
233
  });
215
234
  workflow.addJob("publish", {
235
+ ...(upstreamWorkflow
236
+ ? {
237
+ if: "${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'workflow_run') }}",
238
+ }
239
+ : {}),
216
240
  runsOn: ["ubuntu-latest"],
217
241
  // `id-token: write` lets npm mint the OIDC token for provenance attestation.
218
242
  permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
@@ -223,7 +247,35 @@ export class DBXToolsRelease extends Component {
223
247
  CI: "true",
224
248
  DRY_RUN_INPUT: "${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }}",
225
249
  },
226
- steps: [...publishSetupSteps(), ...steps],
250
+ steps: [
251
+ ...publishSetupSteps().map((step) =>
252
+ step.name === "Checkout" && upstreamWorkflow
253
+ ? {
254
+ ...step,
255
+ with: {
256
+ ...step.with,
257
+ ref: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}",
258
+ },
259
+ }
260
+ : step,
261
+ ),
262
+ ...(upstreamWorkflow
263
+ ? [
264
+ {
265
+ name: "Resolve release tag",
266
+ run: [
267
+ 'RELEASE_TAG="$(git tag --points-at HEAD --list "' +
268
+ tagPrefix +
269
+ '*" | sort -V | tail -1)"',
270
+ 'test -n "$RELEASE_TAG"',
271
+ 'echo "RELEASE_TAG=$RELEASE_TAG" >> "$GITHUB_ENV"',
272
+ `echo "RELEASE_VERSION=\${RELEASE_TAG#${tagPrefix}}" >> "$GITHUB_ENV"`,
273
+ ].join("\n"),
274
+ } satisfies JobStep,
275
+ ]
276
+ : []),
277
+ ...steps,
278
+ ],
227
279
  ...(workingDirectory ? { defaults: { run: { workingDirectory } } } : {}),
228
280
  });
229
281
  }
@@ -235,9 +287,12 @@ export class DBXToolsRelease extends Component {
235
287
  * (no bump math).
236
288
  */
237
289
  private authorReleaseWorkflow(project: DBXToolsNodeProject): void {
290
+ if (!this.workflowName) return;
291
+ if (this.workflowName !== "release") project.tryRemoveFile(".github/workflows/release.yml");
238
292
  this.authorPublishWorkflow(project, {
239
- name: "release",
293
+ name: this.workflowName,
240
294
  tagPrefix: this.tagPrefix,
295
+ upstreamWorkflow: this.upstreamWorkflow,
241
296
  steps: [
242
297
  // The pushed tag is the version: `<prefix>1.2.3` -> `1.2.3`. Stamp it on
243
298
  // every workspace package (manifests are projen-readonly, so unlock
package/tasks/uniffi.ts CHANGED
@@ -65,7 +65,13 @@ const replaceGenerated = (source: string, destination: string): void => {
65
65
  mkdirSync(dirname(destination), { recursive: true });
66
66
  makeWritable(destination);
67
67
  rmSync(destination, { force: true });
68
- renameSync(source, destination);
68
+ try {
69
+ renameSync(source, destination);
70
+ } catch (error) {
71
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "EXDEV") throw error;
72
+ cpSync(source, destination);
73
+ rmSync(source, { force: true });
74
+ }
69
75
  };
70
76
 
71
77
  const stampGeneratedPython = (file: string): void => {