@dbx-tools/projen 0.6.146 → 0.6.149

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,9 +44,9 @@ 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";
48
- export { UNIFFI_RELEASE_TARGETS, DBXToolsRustProject, DBXToolsRustWorkspace } from "./src/project-rs.ts";
49
- export type { CargoDependencyOptions, CargoDependency, RustPackageOptions, DBXToolsRustWorkspaceOptions, UniFFIReleaseTarget, RustBindingMapping, RustWorkspaceMapping } from "./src/project-rs.ts";
47
+ export type { PythonRepositoryOptions, PythonPackageOptions, PythonTrustedPublisherOptions, DBXToolsPythonProjectOptions, PythonReleaseOptions, DBXToolsPythonWorkspaceOptions } from "./src/project-py.ts";
48
+ export { RustReleaseOs, RustReleaseCpu, UNIFFI_RELEASE_TARGETS, DBXToolsRustProject, DBXToolsRustWorkspace } from "./src/project-rs.ts";
49
+ export type { CargoDependencyOptions, CargoDependency, RustPackageOptions, DBXToolsRustWorkspaceOptions, RustReleasePlatform, UniFFIReleaseTarget, RustBindingMapping, RustWorkspaceMapping } from "./src/project-rs.ts";
50
50
  export { COMPILED_DIR, COMPILED_COMPILER_OPTIONS } from "./src/publish.ts";
51
51
  export { DBXToolsRelease } from "./src/release.ts";
52
52
  export type { StandaloneRelease, DBXToolsReleaseOptions } from "./src/release.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.146",
30
- "@dbx-tools/path": "0.6.146",
31
- "@dbx-tools/shared-core": "0.6.146",
29
+ "@dbx-tools/core": "0.6.149",
30
+ "@dbx-tools/path": "0.6.149",
31
+ "@dbx-tools/shared-core": "0.6.149",
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.146",
51
+ "version": "0.6.149",
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,10 +26,13 @@ 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[]>>;
30
34
  readonly defaultFeatures?: readonly string[];
35
+ /** Cargo and release executable name. Defaults to the generated package name. */
31
36
  readonly binaryName?: string;
32
37
  readonly bindings?: readonly ("node" | "python")[];
33
38
  readonly nodeDependencies?: readonly string[];
@@ -47,10 +52,31 @@ export interface DBXToolsRustWorkspaceOptions {
47
52
  readonly nodeRoot?: string;
48
53
  readonly pythonRoot?: string;
49
54
  readonly pythonModulePrefix?: string;
55
+ readonly private?: boolean;
50
56
  /** Generate tag-driven cross-platform UniFFI package releases. */
51
57
  readonly release?: boolean;
52
58
  /** Native release targets; defaults to the maintained GitHub-hosted matrix. */
53
59
  readonly releaseTargets?: readonly UniFFIReleaseTarget[];
60
+ /** Maintained OS/CPU combinations to release. Defaults to every supported target. */
61
+ readonly releasePlatforms?: readonly RustReleasePlatform[];
62
+ /** Workflow name used by downstream release stages. Defaults to `rust-release`. */
63
+ readonly releaseWorkflowName?: string;
64
+ }
65
+
66
+ export enum RustReleaseOs {
67
+ DARWIN = "darwin",
68
+ LINUX = "linux",
69
+ WINDOWS = "win32",
70
+ }
71
+
72
+ export enum RustReleaseCpu {
73
+ ARM64 = "arm64",
74
+ X64 = "x64",
75
+ }
76
+
77
+ export interface RustReleasePlatform {
78
+ readonly os: RustReleaseOs;
79
+ readonly cpu: RustReleaseCpu;
54
80
  }
55
81
 
56
82
  export interface UniFFIReleaseTarget {
@@ -58,8 +84,8 @@ export interface UniFFIReleaseTarget {
58
84
  readonly cargo: string;
59
85
  readonly node: string;
60
86
  readonly python: string;
61
- readonly os: "darwin" | "linux" | "win32";
62
- readonly cpu: "arm64" | "x64";
87
+ readonly os: RustReleaseOs;
88
+ readonly cpu: RustReleaseCpu;
63
89
  readonly libc?: "glibc";
64
90
  }
65
91
 
@@ -70,8 +96,8 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
70
96
  cargo: "x86_64-unknown-linux-gnu",
71
97
  node: "linux-x64-gnu",
72
98
  python: "manylinux_2_35_x86_64",
73
- os: "linux",
74
- cpu: "x64",
99
+ os: RustReleaseOs.LINUX,
100
+ cpu: RustReleaseCpu.X64,
75
101
  libc: "glibc",
76
102
  },
77
103
  {
@@ -79,8 +105,8 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
79
105
  cargo: "aarch64-unknown-linux-gnu",
80
106
  node: "linux-arm64-gnu",
81
107
  python: "manylinux_2_39_aarch64",
82
- os: "linux",
83
- cpu: "arm64",
108
+ os: RustReleaseOs.LINUX,
109
+ cpu: RustReleaseCpu.ARM64,
84
110
  libc: "glibc",
85
111
  },
86
112
  {
@@ -88,27 +114,59 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
88
114
  cargo: "x86_64-apple-darwin",
89
115
  node: "darwin-x64",
90
116
  python: "macosx_13_0_x86_64",
91
- os: "darwin",
92
- cpu: "x64",
117
+ os: RustReleaseOs.DARWIN,
118
+ cpu: RustReleaseCpu.X64,
93
119
  },
94
120
  {
95
121
  runner: "macos-14",
96
122
  cargo: "aarch64-apple-darwin",
97
123
  node: "darwin-arm64",
98
124
  python: "macosx_11_0_arm64",
99
- os: "darwin",
100
- cpu: "arm64",
125
+ os: RustReleaseOs.DARWIN,
126
+ cpu: RustReleaseCpu.ARM64,
101
127
  },
102
128
  {
103
129
  runner: "windows-latest",
104
130
  cargo: "x86_64-pc-windows-msvc",
105
131
  node: "win32-x64-msvc",
106
132
  python: "win_amd64",
107
- os: "win32",
108
- cpu: "x64",
133
+ os: RustReleaseOs.WINDOWS,
134
+ cpu: RustReleaseCpu.X64,
109
135
  },
110
136
  ] as const;
111
137
 
138
+ const RUST_CACHE_ENV = {
139
+ RUSTC_WRAPPER: "sccache",
140
+ SCCACHE_GHA_ENABLED: "true",
141
+ } as const;
142
+
143
+ function rustCacheSteps(sharedKey: string): readonly Record<string, unknown>[] {
144
+ return [
145
+ { name: "Setup sccache", uses: "mozilla-actions/sccache-action@v0.0.9" },
146
+ {
147
+ name: "Cache Cargo registry",
148
+ uses: "Swatinem/rust-cache@v2",
149
+ with: { "cache-targets": false, "shared-key": sharedKey },
150
+ },
151
+ ];
152
+ }
153
+
154
+ function releaseTargets(options: DBXToolsRustWorkspaceOptions): readonly UniFFIReleaseTarget[] {
155
+ if (options.releaseTargets && options.releasePlatforms) {
156
+ throw new Error("releaseTargets and releasePlatforms are mutually exclusive");
157
+ }
158
+ if (options.releaseTargets) return options.releaseTargets;
159
+ if (!options.releasePlatforms) return UNIFFI_RELEASE_TARGETS;
160
+ return options.releasePlatforms.map((platform) => {
161
+ const target = UNIFFI_RELEASE_TARGETS.find(
162
+ (candidate) => candidate.os === platform.os && candidate.cpu === platform.cpu,
163
+ );
164
+ if (!target)
165
+ throw new Error(`Unsupported Rust release platform: ${platform.os}-${platform.cpu}`);
166
+ return target;
167
+ });
168
+ }
169
+
112
170
  /** Persisted mapping consumed by the focused Rust source watcher. */
113
171
  export interface RustBindingMapping {
114
172
  readonly crate: string;
@@ -211,6 +269,8 @@ export class DBXToolsRustProject extends Project implements DBXToolsProject {
211
269
  this.packageOptions = options;
212
270
  this.uniffi = hasUniFFIBindings(this.outdir);
213
271
  const library = existsSync(join(this.outdir, "src/lib.rs"));
272
+ const binary = existsSync(join(this.outdir, "src/main.rs"));
273
+ const binaryName = options.binaryName ?? crateName;
214
274
  const manifest: Record<string, unknown> = {
215
275
  package: {
216
276
  name: crateName,
@@ -231,11 +291,11 @@ export class DBXToolsRustProject extends Project implements DBXToolsProject {
231
291
  },
232
292
  }
233
293
  : {}),
234
- ...(this.uniffi || options.binaryName
294
+ ...(this.uniffi || binary
235
295
  ? {
236
296
  bin: this.uniffi
237
297
  ? { name: "uniffi-bindgen", path: "uniffi-bindgen.rs" }
238
- : { name: options.binaryName, path: "src/main.rs" },
298
+ : { name: binaryName, path: "src/main.rs" },
239
299
  }
240
300
  : {}),
241
301
  ...(options.features || options.defaultFeatures
@@ -332,7 +392,9 @@ export class DBXToolsRustWorkspace {
332
392
  crates: this.packages.map((pkg) => `${root}/${pkg.packageOptions.directory}`),
333
393
  bindings: this.bindingMappings,
334
394
  };
335
- project.dbxToolsConfig.rust = this.workspaceMapping;
395
+ if (isDBXToolsJavaScriptProject()(project)) {
396
+ project.dbxToolsConfig.rust = this.workspaceMapping;
397
+ }
336
398
  this.pythonPackages = bindings
337
399
  .filter((pkg) => (pkg.packageOptions.bindings ?? ["node", "python"]).includes("python"))
338
400
  .map((pkg) => ({
@@ -344,6 +406,13 @@ export class DBXToolsRustWorkspace {
344
406
  module: `${options.pythonModulePrefix ?? options.scope.replaceAll("-", "_")}.${pkg.packageOptions.directory.replaceAll("-", "_")}`,
345
407
  description: `Python bindings for ${pkg.crateName}`,
346
408
  private: true,
409
+ trustedPublisher: {
410
+ workflowName: options.releaseWorkflowName ?? "rust-release",
411
+ environment: `native-${pkg.crateName}`,
412
+ artifacts: `platform-specific wheels for ${releaseTargets(options)
413
+ .map((target) => `${target.os}-${target.cpu}`)
414
+ .join(", ")}; all architectures publish to this one PyPI project`,
415
+ },
347
416
  }));
348
417
 
349
418
  const existing = new Map(
@@ -449,13 +518,17 @@ export class DBXToolsRustWorkspace {
449
518
  ]),
450
519
  ].join(" && "),
451
520
  });
452
- if ((options.release ?? true) && this.bindingMappings.length > 0) {
453
- this.addReleaseWorkflow(project, options.releaseTargets ?? UNIFFI_RELEASE_TARGETS);
521
+ if (
522
+ (options.release ?? true) &&
523
+ (this.bindingMappings.length > 0 || this.packages.some((pkg) => pkg.packageOptions.release))
524
+ ) {
525
+ this.addReleaseWorkflow(project, options, releaseTargets(options));
454
526
  }
455
527
  }
456
528
 
457
529
  private addReleaseWorkflow(
458
530
  project: javascript.NodeProject,
531
+ options: DBXToolsRustWorkspaceOptions,
459
532
  targets: readonly UniFFIReleaseTarget[],
460
533
  ): void {
461
534
  if (!project.github) return;
@@ -472,9 +545,116 @@ export class DBXToolsRustWorkspace {
472
545
  target: { ...target, facade: index === 0 },
473
546
  })),
474
547
  );
475
- new YamlFile(project, ".github/workflows/rust-release.yml", {
548
+ const releaseBinaries = this.packages
549
+ .filter((pkg) => pkg.packageOptions.release)
550
+ .map((pkg) => ({
551
+ crate: pkg.crateName,
552
+ binary: pkg.packageOptions.binaryName ?? pkg.crateName,
553
+ }));
554
+ const binaryMatrix = releaseBinaries.flatMap((pkg) =>
555
+ targets.map((target) => ({ package: pkg, target })),
556
+ );
557
+ const buildJob = {
558
+ name: "${{ matrix.binding.crate }} / ${{ matrix.target.node }}",
559
+ "runs-on": "${{ matrix.target.runner }}",
560
+ env: RUST_CACHE_ENV,
561
+ strategy: {
562
+ "fail-fast": false,
563
+ matrix: { include: matrix },
564
+ },
565
+ steps: [
566
+ { name: "Checkout", uses: "actions/checkout@v6" },
567
+ {
568
+ name: "Setup Bun",
569
+ uses: "oven-sh/setup-bun@v2",
570
+ with: { "bun-version": "1.3.14" },
571
+ },
572
+ { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
573
+ {
574
+ name: "Setup Rust",
575
+ uses: "dtolnay/rust-toolchain@stable",
576
+ with: { targets: "${{ matrix.target.cargo }}" },
577
+ },
578
+ ...rustCacheSteps("uniffi-${{ matrix.target.cargo }}"),
579
+ {
580
+ name: "Install Linux native dependencies",
581
+ if: "${{ matrix.target.os == 'linux' }}",
582
+ run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
583
+ },
584
+ { name: "Install", run: "bun install" },
585
+ {
586
+ name: "Build thin native packages",
587
+ shell: "bash",
588
+ env: {
589
+ VERSION: "${{ github.event_name == 'push' && github.ref_name || inputs.version }}",
590
+ },
591
+ 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}"',
592
+ },
593
+ {
594
+ name: "Upload native packages",
595
+ uses: "actions/upload-artifact@v7",
596
+ with: {
597
+ name: "${{ matrix.binding.crate }}-${{ matrix.target.node }}",
598
+ path: [
599
+ "dist/uniffi/npm/*.tgz",
600
+ "dist/uniffi/npm-facade/*.tgz",
601
+ "dist/uniffi/python/*.whl",
602
+ ].join("\n"),
603
+ },
604
+ },
605
+ ],
606
+ };
607
+ const binaryBuildJob = {
608
+ name: "${{ matrix.package.crate }} / ${{ matrix.target.node }}",
609
+ "runs-on": "${{ matrix.target.runner }}",
610
+ env: RUST_CACHE_ENV,
611
+ strategy: { "fail-fast": false, matrix: { include: binaryMatrix } },
612
+ steps: [
613
+ { name: "Checkout", uses: "actions/checkout@v6" },
614
+ {
615
+ name: "Setup Rust",
616
+ uses: "dtolnay/rust-toolchain@stable",
617
+ with: { targets: "${{ matrix.target.cargo }}" },
618
+ },
619
+ ...rustCacheSteps("binary-${{ matrix.target.cargo }}"),
620
+ {
621
+ name: "Install Linux native dependencies",
622
+ if: "${{ matrix.target.os == 'linux' }}",
623
+ run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
624
+ },
625
+ {
626
+ name: "Build release binary",
627
+ shell: "bash",
628
+ run: [
629
+ 'cargo build --release --package "${{ matrix.package.crate }}" --bin "${{ matrix.package.binary }}" --target "${{ matrix.target.cargo }}"',
630
+ "mkdir -p dist/rust-release/stage",
631
+ "SOURCE=\"target/${{ matrix.target.cargo }}/release/${{ matrix.package.binary }}${{ matrix.target.os == 'win32' && '.exe' || '' }}\"",
632
+ "DESTINATION=\"dist/rust-release/stage/${{ matrix.package.binary }}${{ matrix.target.os == 'win32' && '.exe' || '' }}\"",
633
+ 'cp "$SOURCE" "$DESTINATION"',
634
+ 'if [ "${{ matrix.target.os }}" = "win32" ]; then',
635
+ ' ARCHIVE="dist/rust-release/${{ matrix.package.binary }}-${{ matrix.target.node }}.zip"',
636
+ ' 7z a "$ARCHIVE" "$DESTINATION"',
637
+ "else",
638
+ ' ARCHIVE="dist/rust-release/${{ matrix.package.binary }}-${{ matrix.target.node }}.tar.gz"',
639
+ ' tar -C dist/rust-release/stage -czf "$ARCHIVE" "${{ matrix.package.binary }}"',
640
+ "fi",
641
+ "rm -rf dist/rust-release/stage",
642
+ ].join("\n"),
643
+ },
644
+ {
645
+ name: "Upload release binary",
646
+ uses: "actions/upload-artifact@v7",
647
+ with: {
648
+ name: "release-${{ matrix.package.crate }}-${{ matrix.target.node }}",
649
+ path: "dist/rust-release/*",
650
+ },
651
+ },
652
+ ],
653
+ };
654
+ const workflowName = options.releaseWorkflowName ?? "rust-release";
655
+ new YamlFile(project, `.github/workflows/${workflowName}.yml`, {
476
656
  obj: {
477
- name: "rust-release",
657
+ name: workflowName,
478
658
  on: {
479
659
  push: { tags: ["v*"] },
480
660
  workflow_dispatch: {
@@ -489,123 +669,74 @@ export class DBXToolsRustWorkspace {
489
669
  },
490
670
  permissions: { contents: "read" },
491
671
  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 }}",
524
- },
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,
672
+ ...(matrix.length ? { build: buildJob } : {}),
673
+ ...(binaryMatrix.length ? { "build-binaries": binaryBuildJob } : {}),
674
+ ...(bindings.length
675
+ ? {
676
+ publish: {
677
+ if: "${{ github.event_name == 'push' }}",
678
+ needs: ["build"],
679
+ "runs-on": "ubuntu-latest",
680
+ strategy: { matrix: { binding: bindings } },
681
+ permissions: { contents: "read", "id-token": "write" },
682
+ environment: { name: "native-${{ matrix.binding.crate }}" },
683
+ steps: [
684
+ { name: "Checkout", uses: "actions/checkout@v6" },
685
+ {
686
+ name: "Setup Node.js",
687
+ uses: "actions/setup-node@v6",
688
+ with: { "registry-url": "https://registry.npmjs.org" },
689
+ },
690
+ {
691
+ name: "Setup Bun",
692
+ uses: "oven-sh/setup-bun@v2",
693
+ with: { "bun-version": "1.3.14" },
694
+ },
695
+ { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
696
+ { name: "Install", run: "bun install" },
697
+ {
698
+ name: "Download packages",
699
+ uses: "actions/download-artifact@v8",
700
+ with: {
701
+ pattern: "${{ matrix.binding.crate }}-*",
702
+ path: "dist/uniffi",
703
+ "merge-multiple": true,
704
+ },
705
+ },
706
+ {
707
+ name: "Publish npm packages",
708
+ env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
709
+ run: 'for package in dist/uniffi/npm/*.tgz; do npm publish "$package" --access public; done',
710
+ },
711
+ {
712
+ name: "Publish npm facades",
713
+ if: "${{ matrix.binding.node != '' }}",
714
+ env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" },
715
+ run: 'for package in dist/uniffi/npm-facade/*.tgz; do npm publish "$package" --access public; done',
716
+ },
717
+ {
718
+ name: "Publish Python wheels",
719
+ if: "${{ matrix.binding.python != '' }}",
720
+ run: "uv publish --trusted-publishing always dist/uniffi/python/*.whl",
721
+ },
722
+ ],
581
723
  },
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
- },
724
+ }
725
+ : {}),
601
726
  "publish-cargo": {
602
727
  if: "${{ github.event_name == 'push' }}",
603
- needs: ["build"],
728
+ needs: [matrix.length ? "build" : "build-binaries"],
604
729
  "runs-on": "ubuntu-latest",
605
730
  permissions: { contents: "read" },
731
+ env: RUST_CACHE_ENV,
606
732
  steps: [
607
733
  { name: "Checkout", uses: "actions/checkout@v6" },
608
734
  { name: "Setup Rust", uses: "dtolnay/rust-toolchain@stable" },
735
+ ...rustCacheSteps("cargo-publish"),
736
+ {
737
+ name: "Install Linux native dependencies",
738
+ run: "sudo apt-get update && sudo apt-get install --yes libdbus-1-dev pkg-config",
739
+ },
609
740
  {
610
741
  name: "Publish public crates",
611
742
  env: { CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_REGISTRY_TOKEN }}" },
@@ -617,6 +748,7 @@ export class DBXToolsRustWorkspace {
617
748
  if: "${{ github.event_name == 'push' && vars.LOCAL_REPOSITORIES == 'true' }}",
618
749
  "runs-on": ["self-hosted"],
619
750
  permissions: { contents: "read" },
751
+ env: RUST_CACHE_ENV,
620
752
  steps: [
621
753
  { name: "Checkout", uses: "actions/checkout@v6" },
622
754
  {
@@ -626,6 +758,7 @@ export class DBXToolsRustWorkspace {
626
758
  },
627
759
  { name: "Setup uv", uses: "astral-sh/setup-uv@v7" },
628
760
  { name: "Setup Rust", uses: "dtolnay/rust-toolchain@stable" },
761
+ ...rustCacheSteps("local-${{ runner.os }}-${{ runner.arch }}"),
629
762
  { name: "Install", run: "bun install" },
630
763
  {
631
764
  name: "Build and publish host-native packages",
@@ -642,6 +775,35 @@ export class DBXToolsRustWorkspace {
642
775
  },
643
776
  ],
644
777
  },
778
+ ...(binaryMatrix.length
779
+ ? {
780
+ "publish-github-release": {
781
+ if: "${{ github.event_name == 'push' }}",
782
+ needs: ["build-binaries"],
783
+ "runs-on": "ubuntu-latest",
784
+ permissions: { contents: "write" },
785
+ steps: [
786
+ {
787
+ name: "Download release binaries",
788
+ uses: "actions/download-artifact@v8",
789
+ with: {
790
+ pattern: "release-*",
791
+ path: "dist/rust-release",
792
+ "merge-multiple": true,
793
+ },
794
+ },
795
+ {
796
+ name: "Publish GitHub release assets",
797
+ uses: "softprops/action-gh-release@v2",
798
+ with: {
799
+ files: "dist/rust-release/*",
800
+ "generate-release-notes": true,
801
+ },
802
+ },
803
+ ],
804
+ },
805
+ }
806
+ : {}),
645
807
  },
646
808
  },
647
809
  });
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