@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.
@@ -15,6 +15,7 @@ import * as projectUtils from "@dbx-tools/core/project-utils";
15
15
  import { asyncUtils, json, log, object } from "@dbx-tools/shared-core";
16
16
  import { Command } from "commander";
17
17
  import { publishLocalRelease } from "./local-publish.ts";
18
+ import { hasLocalReleaseTargets, missingLocalReleaseTools } from "./release-assets.ts";
18
19
  import { generateReleaseSummary } from "./release-summary.ts";
19
20
  import {
20
21
  releaseArchitectureOption,
@@ -38,7 +39,9 @@ import {
38
39
  githubAccountSupportsWorkflowChanges,
39
40
  githubAuthenticatedAccounts,
40
41
  githubRepositoryApiPath,
42
+ githubRepositoryCanManage,
41
43
  githubRepositoryIdentity,
44
+ githubRepositoryPermissions,
42
45
  githubRepositorySpecifier,
43
46
  githubTokenArguments,
44
47
  githubTokenEnvironmentName,
@@ -52,6 +55,41 @@ import {
52
55
 
53
56
  const logger = log.logger("projen:release");
54
57
 
58
+ export type ReleaseBuildMode = "auto" | "local" | "remote";
59
+
60
+ /** Resolve an explicit or environment-sensitive native release build mode. */
61
+ export function resolveReleaseBuildMode(options: {
62
+ readonly requested: ReleaseBuildMode;
63
+ readonly canManage: boolean;
64
+ readonly localTools: readonly string[];
65
+ readonly hasLocalTargets: boolean;
66
+ readonly approve: boolean;
67
+ readonly wait: boolean;
68
+ readonly hostPlatform?: NodeJS.Platform;
69
+ }): Exclude<ReleaseBuildMode, "auto"> {
70
+ const local =
71
+ options.canManage &&
72
+ options.hasLocalTargets &&
73
+ (options.hostPlatform ?? process.platform) === "darwin" &&
74
+ options.localTools.length === 0 &&
75
+ options.approve &&
76
+ options.wait;
77
+ if (options.requested === "auto") return local ? "local" : "remote";
78
+ if (options.requested === "remote") return "remote";
79
+ if (!options.canManage) throw new Error("--build local requires maintain or admin permission");
80
+ if (!options.hasLocalTargets) throw new Error("--build local requires Rust release targets");
81
+ if ((options.hostPlatform ?? process.platform) !== "darwin") {
82
+ throw new Error("--build local requires macOS");
83
+ }
84
+ if (options.localTools.length > 0) {
85
+ throw new Error(`--build local requires: ${options.localTools.join(", ")}`);
86
+ }
87
+ if (!options.approve || !options.wait) {
88
+ throw new Error("--build local requires automatic merge and waiting");
89
+ }
90
+ return "local";
91
+ }
92
+
55
93
  function git(
56
94
  root: string,
57
95
  args: string[],
@@ -151,12 +189,27 @@ async function waitForReleaseWorkflow(
151
189
  });
152
190
  }
153
191
 
192
+ async function waitForGitHubRelease(
193
+ root: string,
194
+ tag: string,
195
+ env: NodeJS.ProcessEnv,
196
+ timeoutMs: number,
197
+ ): Promise<void> {
198
+ const deadline = Date.now() + timeoutMs;
199
+ while (Date.now() < deadline) {
200
+ if (taskCommandSucceeds(root, "gh", ["release", "view", tag], { env })) return;
201
+ await asyncUtils.sleep(2_000);
202
+ }
203
+ throw new Error(`GitHub Release ${tag} did not appear within ${timeoutMs}ms`);
204
+ }
205
+
154
206
  function githubAccount(root: string): {
155
207
  hostname: string;
156
208
  owner: string;
157
209
  repository: string;
158
210
  login: string;
159
211
  token: string;
212
+ canManage: boolean;
160
213
  } {
161
214
  const repository = projectUtils.repositoryUrl(root);
162
215
  if (!repository) throw new Error("Release preparation requires a GitHub repository");
@@ -199,21 +252,20 @@ function githubAccount(root: string): {
199
252
  });
200
253
  const repositoryData =
201
254
  repositoryResult.exitCode === 0 ? json.parseRecord(repositoryResult.stdout ?? "") : undefined;
202
- const permissions = object.isRecord(repositoryData?.permissions)
203
- ? repositoryData.permissions
204
- : undefined;
205
- if (
206
- permissions?.push !== true &&
207
- permissions?.maintain !== true &&
208
- permissions?.admin !== true
209
- ) {
255
+ const permissions = githubRepositoryPermissions(repositoryData);
256
+ if (!permissions.push && !permissions.maintain && !permissions.admin) {
210
257
  continue;
211
258
  }
212
259
  logger.info("selected GitHub CLI account", {
213
260
  login: account.login,
214
261
  repository: `${identity.owner}/${identity.repository}`,
215
262
  });
216
- return { ...identity, login: account.login, token };
263
+ return {
264
+ ...identity,
265
+ login: account.login,
266
+ token,
267
+ canManage: githubRepositoryCanManage(permissions),
268
+ };
217
269
  }
218
270
  const detected =
219
271
  accounts
@@ -271,6 +323,7 @@ program
271
323
  .option("--no-validate", "skip repository validation tasks, Rust tests, and TypeScript compile")
272
324
  .option("--no-local-publish", "skip local npm, PyPI, and Cargo publication")
273
325
  .option("--no-local-cargo", "skip local Cargo publication")
326
+ .option("--build <mode>", "native release build: auto, local, or remote", "auto")
274
327
  .option("--no-approve", "open the release pull request without enabling automatic merge")
275
328
  .option("--no-wait", "return after enabling automatic merge without watching publication")
276
329
  .option(
@@ -295,6 +348,7 @@ program
295
348
  validate: boolean;
296
349
  localPublish: boolean;
297
350
  localCargo: boolean;
351
+ build: ReleaseBuildMode;
298
352
  approve: boolean;
299
353
  wait: boolean;
300
354
  waitTimeoutMinutes: string;
@@ -316,6 +370,26 @@ program
316
370
  const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
317
371
  if (!currentBranch) throw new Error("Release preparation requires a local branch");
318
372
  const account = githubAccount(root);
373
+ if (!["auto", "local", "remote"].includes(opts.build)) {
374
+ throw new Error("--build must be auto, local, or remote");
375
+ }
376
+ const inspectLocalTools =
377
+ opts.build === "local" || (opts.build === "auto" && account.canManage);
378
+ const localTools = inspectLocalTools ? missingLocalReleaseTools(root) : [];
379
+ const buildMode = resolveReleaseBuildMode({
380
+ requested: opts.build,
381
+ canManage: account.canManage,
382
+ localTools,
383
+ hasLocalTargets: hasLocalReleaseTargets(root),
384
+ approve: opts.approve,
385
+ wait: opts.wait,
386
+ });
387
+ logger.info("selected native release build", {
388
+ requested: opts.build,
389
+ resolved: buildMode,
390
+ manager: account.canManage,
391
+ ...(localTools.length > 0 ? { missing: localTools } : {}),
392
+ });
319
393
  git(root, ["fetch", "--tags", "origin", opts.base]);
320
394
  const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
321
395
  fetch: false,
@@ -455,7 +529,13 @@ program
455
529
  git(releaseRoot, ["add", "-A"]);
456
530
  const staged = git(releaseRoot, ["diff", "--cached", "--name-only"], { capture: true });
457
531
  if (staged) {
458
- git(releaseRoot, ["commit", "-m", `chore(release): ${next.version}`]);
532
+ git(releaseRoot, [
533
+ "commit",
534
+ "-m",
535
+ `chore(release): ${next.version}`,
536
+ "-m",
537
+ `Release-Build: ${buildMode}`,
538
+ ]);
459
539
  } else if (!worktreeExists) {
460
540
  throw new Error("Release preparation produced no changes");
461
541
  }
@@ -471,6 +551,8 @@ program
471
551
  `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
472
552
  "",
473
553
  "Merging this PR updates VERSION on main and starts the public release workflow.",
554
+ "",
555
+ `Native build: ${buildMode}.`,
474
556
  ...(releaseSummary ? ["", releaseSummary] : []),
475
557
  ].join("\n");
476
558
  const githubEnvironment = {
@@ -511,8 +593,6 @@ program
511
593
  env: githubEnvironment,
512
594
  });
513
595
  }
514
- git(root, ["worktree", "remove", "--force", releaseRoot]);
515
- git(root, ["branch", "--delete", "--force", releaseBranch]);
516
596
  if (opts.approve && opts.wait) {
517
597
  const mergeSha = await waitForPullRequestMerge(
518
598
  root,
@@ -521,10 +601,39 @@ program
521
601
  waitTimeoutMs,
522
602
  );
523
603
  logger.info("release pull request merged", { releaseBranch, mergeSha });
604
+ if (buildMode === "local") {
605
+ git(root, ["fetch", "origin", opts.base]);
606
+ git(releaseRoot, ["switch", "--detach", mergeSha]);
607
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
608
+ await waitForGitHubRelease(root, releaseTag, githubEnvironment, waitTimeoutMs);
609
+ const releaseAssetsScript = join(
610
+ releaseRoot,
611
+ "node_modules/@dbx-tools/projen/tasks/release-assets.ts",
612
+ );
613
+ runTaskCommand(
614
+ releaseRoot,
615
+ process.execPath,
616
+ [
617
+ releaseAssetsScript,
618
+ "--root",
619
+ releaseRoot,
620
+ "--version",
621
+ next.version,
622
+ "--tag",
623
+ releaseTag,
624
+ "--upload",
625
+ ],
626
+ { env: githubEnvironment },
627
+ );
628
+ }
629
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
630
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
524
631
  await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
525
632
  logger.success(`published ${releaseTag} from ${mergeSha}`);
526
633
  return;
527
634
  }
635
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
636
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
528
637
  logger.success(
529
638
  `${
530
639
  opts.approve ? "enabled automatic merge for" : "opened"
@@ -534,4 +643,4 @@ program
534
643
  },
535
644
  );
536
645
 
537
- await program.parseAsync();
646
+ if (import.meta.main) await program.parseAsync();
@@ -5,6 +5,7 @@ import {
5
5
  mkdirSync,
6
6
  mkdtempSync,
7
7
  readFileSync,
8
+ readdirSync,
8
9
  rmSync,
9
10
  writeFileSync,
10
11
  } from "node:fs";
@@ -130,6 +131,7 @@ export const installPythonBindings = ({
130
131
  module,
131
132
  packageRoot,
132
133
  platform,
134
+ generatedSource,
133
135
  readonly = false,
134
136
  run,
135
137
  targetDirectory,
@@ -137,20 +139,25 @@ export const installPythonBindings = ({
137
139
  const packageName = module.split(".").at(-1);
138
140
  if (!packageName) throw new Error(`Invalid Python module ${module}`);
139
141
  const packageDirectory = resolve(packageRoot, "src", ...module.split("."));
140
- const generatedDirectory = mkdtempSync(join(tmpdir(), `${packageName}-python-`));
142
+ const generatedDirectory = generatedSource
143
+ ? undefined
144
+ : mkdtempSync(join(tmpdir(), `${packageName}-python-`));
141
145
  const generator = resolve(targetDirectory, pythonBindingGeneratorName(crate, platform));
142
- if (!existsSync(generator)) throw new Error(`Missing UniFFI generator ${generator}`);
143
146
 
144
147
  try {
145
- run(
146
- generator,
147
- pythonBindingGeneratorArgs({
148
- crate,
149
- library,
150
- output: generatedDirectory,
151
- }),
152
- );
153
- const generated = join(generatedDirectory, `${crate.replaceAll("-", "_")}.py`);
148
+ if (!generatedSource) {
149
+ if (!existsSync(generator)) throw new Error(`Missing UniFFI generator ${generator}`);
150
+ run(
151
+ generator,
152
+ pythonBindingGeneratorArgs({
153
+ crate,
154
+ library,
155
+ output: generatedDirectory,
156
+ }),
157
+ );
158
+ }
159
+ const generated =
160
+ generatedSource ?? join(generatedDirectory, `${crate.replaceAll("-", "_")}.py`);
154
161
  if (!existsSync(generated)) throw new Error(`Missing generated binding ${generated}`);
155
162
  mkdirSync(packageDirectory, { recursive: true });
156
163
 
@@ -162,6 +169,11 @@ export const installPythonBindings = ({
162
169
  setWritable(init);
163
170
  writeFileSync(init, `${generatedHeader(crate)}from .bindings import *\n`);
164
171
 
172
+ for (const candidate of readdirSync(packageDirectory)) {
173
+ if (/\.(?:dll|dylib|so)$/.test(candidate)) {
174
+ rmSync(join(packageDirectory, candidate), { force: true });
175
+ }
176
+ }
165
177
  const nativeLibrary = join(packageDirectory, basename(library));
166
178
  setWritable(nativeLibrary);
167
179
  cpSync(library, nativeLibrary);
@@ -174,6 +186,6 @@ export const installPythonBindings = ({
174
186
  rmSync(join(packageDirectory, "_generated"), { recursive: true, force: true });
175
187
  return { bindings, init, library: nativeLibrary };
176
188
  } finally {
177
- rmSync(generatedDirectory, { recursive: true, force: true });
189
+ if (generatedDirectory) rmSync(generatedDirectory, { recursive: true, force: true });
178
190
  }
179
191
  };
@@ -17,7 +17,12 @@ import { fileURLToPath } from "node:url";
17
17
  import { createRequire } from "node:module";
18
18
  import { parseArgs } from "node:util";
19
19
  import { spawnSync } from "node:child_process";
20
- import { installPythonBindings, stampPythonProject } from "./uniffi-python.js";
20
+ import {
21
+ installPythonBindings,
22
+ pythonBindingGeneratorArgs,
23
+ pythonBindingGeneratorName,
24
+ stampPythonProject,
25
+ } from "./uniffi-python.js";
21
26
 
22
27
  const require = createRequire(import.meta.url);
23
28
  const bundledToml = fileURLToPath(new URL("./smol-toml.cjs", import.meta.url));
@@ -34,7 +39,9 @@ const parsed = parseArgs({
34
39
  "native-package": { type: "string" },
35
40
  "python-package": { type: "string" },
36
41
  "python-module": { type: "string" },
42
+ "python-bindings": { type: "string" },
37
43
  "cargo-target": { type: "string" },
44
+ "generator-target": { type: "string" },
38
45
  "node-triple": { type: "string" },
39
46
  "python-tag": { type: "string" },
40
47
  os: { type: "string" },
@@ -414,8 +421,10 @@ const packagePython = ({
414
421
  pythonTag,
415
422
  version,
416
423
  cargoTarget,
424
+ generatorTarget,
417
425
  os,
418
426
  pythonModule,
427
+ pythonBindings,
419
428
  }) => {
420
429
  const pythonRoot = resolve(output, "python-root");
421
430
  cpSync(resolve(root, pythonDirectory), pythonRoot, { recursive: true });
@@ -442,8 +451,9 @@ const packagePython = ({
442
451
  module: pythonModule,
443
452
  packageRoot: pythonRoot,
444
453
  platform: os,
454
+ generatedSource: pythonBindings,
445
455
  run,
446
- targetDirectory: resolve(cargoTargetRoot, cargoTarget, "release"),
456
+ targetDirectory: resolve(cargoTargetRoot, generatorTarget ?? cargoTarget, "release"),
447
457
  });
448
458
 
449
459
  const wheelDirectory = resolve(output, "python");
@@ -463,6 +473,25 @@ const packagePython = ({
463
473
  ]);
464
474
  };
465
475
 
476
+ const generatePython = () => {
477
+ const crate = required("crate");
478
+ const cargoTarget = required("cargo-target");
479
+ const os = required("os");
480
+ const output = resolve(root, required("output"));
481
+ const generator = resolve(
482
+ cargoTargetRoot,
483
+ cargoTarget,
484
+ "release",
485
+ pythonBindingGeneratorName(crate, os),
486
+ );
487
+ const library = libraryPath(crate, cargoTarget, os);
488
+ if (!existsSync(generator)) throw new Error(`Missing UniFFI generator ${generator}`);
489
+ if (!existsSync(library)) throw new Error(`Missing native library ${library}`);
490
+ rmSync(output, { recursive: true, force: true });
491
+ mkdirSync(output, { recursive: true });
492
+ run(generator, pythonBindingGeneratorArgs({ crate, library, output }));
493
+ };
494
+
466
495
  const build = () => {
467
496
  const crate = required("crate");
468
497
  const cargoTarget = required("cargo-target");
@@ -517,14 +546,18 @@ const build = () => {
517
546
  pythonTag,
518
547
  version,
519
548
  cargoTarget,
549
+ generatorTarget: parsed.values["generator-target"],
520
550
  os,
521
551
  pythonModule: required("python-module"),
552
+ pythonBindings: parsed.values["python-bindings"],
522
553
  });
523
554
  }
524
555
  };
525
556
 
526
557
  if (parsed.positionals[0] === "build") {
527
558
  build();
559
+ } else if (parsed.positionals[0] === "generate-python") {
560
+ generatePython();
528
561
  } else if (parsed.positionals[0] === "facade") {
529
562
  packageNodeFacade({
530
563
  output: resolve(root, required("output")),