@dbx-tools/projen 0.9.17 → 0.9.19

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,
@@ -77,6 +80,34 @@ async function waitForPullRequestMerge(
77
80
  env: NodeJS.ProcessEnv,
78
81
  timeoutMs: number,
79
82
  ): Promise<string> {
83
+ const checksDeadline = Date.now() + timeoutMs;
84
+ let checksReported = false;
85
+ while (Date.now() < checksDeadline) {
86
+ const count = Number(
87
+ captureTaskCommand(
88
+ root,
89
+ "gh",
90
+ [
91
+ "pr",
92
+ "view",
93
+ releaseBranch,
94
+ "--json",
95
+ "statusCheckRollup",
96
+ "--jq",
97
+ ".statusCheckRollup | length",
98
+ ],
99
+ { env },
100
+ ),
101
+ );
102
+ if (Number.isInteger(count) && count > 0) {
103
+ checksReported = true;
104
+ break;
105
+ }
106
+ await asyncUtils.sleep(2_000);
107
+ }
108
+ if (!checksReported) {
109
+ throw new Error(`Release pull request reported no checks within ${timeoutMs}ms`);
110
+ }
80
111
  await runTaskCommandAsync(
81
112
  root,
82
113
  "gh",
@@ -107,56 +138,13 @@ async function waitForPullRequestMerge(
107
138
  throw new Error(`Release pull request did not merge within ${timeoutMs}ms`);
108
139
  }
109
140
 
110
- /** Find and watch the release workflow for an exact merged commit. */
111
- async function waitForReleaseWorkflow(
112
- root: string,
113
- baseBranch: string,
114
- mergeSha: string,
115
- env: NodeJS.ProcessEnv,
116
- timeoutMs: number,
117
- ): Promise<void> {
118
- const deadline = Date.now() + timeoutMs;
119
- let runId = "";
120
- while (Date.now() < deadline) {
121
- runId = captureTaskCommand(
122
- root,
123
- "gh",
124
- [
125
- "run",
126
- "list",
127
- "--workflow",
128
- "release.yml",
129
- "--branch",
130
- baseBranch,
131
- "--event",
132
- "push",
133
- "--commit",
134
- mergeSha,
135
- "--limit",
136
- "1",
137
- "--json",
138
- "databaseId",
139
- "--jq",
140
- ".[0].databaseId // empty",
141
- ],
142
- { env },
143
- );
144
- if (runId) break;
145
- await asyncUtils.sleep(5_000);
146
- }
147
- if (!runId) throw new Error(`Release workflow did not start within ${timeoutMs}ms`);
148
- await runTaskCommandAsync(root, "gh", ["run", "watch", runId, "--exit-status"], {
149
- env,
150
- signal: AbortSignal.timeout(timeoutMs),
151
- });
152
- }
153
-
154
141
  function githubAccount(root: string): {
155
142
  hostname: string;
156
143
  owner: string;
157
144
  repository: string;
158
145
  login: string;
159
146
  token: string;
147
+ canManage: boolean;
160
148
  } {
161
149
  const repository = projectUtils.repositoryUrl(root);
162
150
  if (!repository) throw new Error("Release preparation requires a GitHub repository");
@@ -199,21 +187,20 @@ function githubAccount(root: string): {
199
187
  });
200
188
  const repositoryData =
201
189
  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
- ) {
190
+ const permissions = githubRepositoryPermissions(repositoryData);
191
+ if (!permissions.push && !permissions.maintain && !permissions.admin) {
210
192
  continue;
211
193
  }
212
194
  logger.info("selected GitHub CLI account", {
213
195
  login: account.login,
214
196
  repository: `${identity.owner}/${identity.repository}`,
215
197
  });
216
- return { ...identity, login: account.login, token };
198
+ return {
199
+ ...identity,
200
+ login: account.login,
201
+ token,
202
+ canManage: githubRepositoryCanManage(permissions),
203
+ };
217
204
  }
218
205
  const detected =
219
206
  accounts
@@ -275,7 +262,7 @@ program
275
262
  .option("--no-wait", "return after enabling automatic merge without watching publication")
276
263
  .option(
277
264
  "--wait-timeout-minutes <minutes>",
278
- "maximum time for pull request checks, merge, and publication",
265
+ "maximum time for pull request checks and merge",
279
266
  "120",
280
267
  )
281
268
  .action(
@@ -313,9 +300,21 @@ program
313
300
  throw new Error("--wait-timeout-minutes must be a positive integer");
314
301
  }
315
302
  const waitTimeoutMs = waitTimeoutMinutes * 60_000;
303
+ if (opts.approve && !opts.wait) {
304
+ throw new Error("automatic merge requires waiting so the draft candidate can be built");
305
+ }
316
306
  const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
317
307
  if (!currentBranch) throw new Error("Release preparation requires a local branch");
318
308
  const account = githubAccount(root);
309
+ if (!account.canManage) {
310
+ throw new Error("Release preparation requires maintain or admin permission");
311
+ }
312
+ if (hasLocalReleaseTargets(root)) {
313
+ const localTools = missingLocalReleaseTools(root);
314
+ if (localTools.length > 0) {
315
+ throw new Error(`Local release candidate build requires: ${localTools.join(", ")}`);
316
+ }
317
+ }
319
318
  git(root, ["fetch", "--tags", "origin", opts.base]);
320
319
  const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
321
320
  fetch: false,
@@ -470,7 +469,7 @@ program
470
469
  "",
471
470
  `Source commit: ${git(releaseRoot, ["rev-parse", `${releaseBranch}^`], { capture: true })}`,
472
471
  "",
473
- "Merging this PR updates VERSION on main and starts the public release workflow.",
472
+ "Merging this PR updates VERSION on main and prepares a draft GitHub Release.",
474
473
  ...(releaseSummary ? ["", releaseSummary] : []),
475
474
  ].join("\n");
476
475
  const githubEnvironment = {
@@ -511,8 +510,6 @@ program
511
510
  env: githubEnvironment,
512
511
  });
513
512
  }
514
- git(root, ["worktree", "remove", "--force", releaseRoot]);
515
- git(root, ["branch", "--delete", "--force", releaseBranch]);
516
513
  if (opts.approve && opts.wait) {
517
514
  const mergeSha = await waitForPullRequestMerge(
518
515
  root,
@@ -521,10 +518,39 @@ program
521
518
  waitTimeoutMs,
522
519
  );
523
520
  logger.info("release pull request merged", { releaseBranch, mergeSha });
524
- await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
525
- logger.success(`published ${releaseTag} from ${mergeSha}`);
521
+ git(root, ["fetch", "origin", opts.base]);
522
+ git(releaseRoot, ["switch", "--detach", mergeSha]);
523
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
524
+ const releaseCandidateScript = join(
525
+ releaseRoot,
526
+ "node_modules/@dbx-tools/projen/tasks/release-candidate.ts",
527
+ );
528
+ runTaskCommand(
529
+ releaseRoot,
530
+ process.execPath,
531
+ [
532
+ releaseCandidateScript,
533
+ "--root",
534
+ releaseRoot,
535
+ "--version",
536
+ next.version,
537
+ "--tag",
538
+ releaseTag,
539
+ "--sha",
540
+ mergeSha,
541
+ "--notes-file",
542
+ `docs/releases/v${next.version}.md`,
543
+ "--upload",
544
+ ],
545
+ { env: githubEnvironment },
546
+ );
547
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
548
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
549
+ logger.success(`prepared draft ${releaseTag} from ${mergeSha}`);
526
550
  return;
527
551
  }
552
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
553
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
528
554
  logger.success(
529
555
  `${
530
556
  opts.approve ? "enabled automatic merge for" : "opened"
@@ -534,4 +560,4 @@ program
534
560
  },
535
561
  );
536
562
 
537
- await program.parseAsync();
563
+ 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")),