@dbx-tools/projen 0.9.4 → 0.9.6

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.
Files changed (47) hide show
  1. package/README.md +64 -16
  2. package/index.ts +7 -5
  3. package/package.json +9 -6
  4. package/src/_rust-project.ts +356 -0
  5. package/src/_rust-release-workflow.ts +901 -0
  6. package/src/_rust-release.ts +605 -0
  7. package/src/_task-command.ts +89 -0
  8. package/src/barrels.ts +9 -7
  9. package/src/engine-root.ts +5 -5
  10. package/src/openapi.ts +1 -1
  11. package/src/packages.ts +9 -9
  12. package/src/pnpm-workspace.ts +1 -1
  13. package/src/project-js.ts +19 -25
  14. package/src/project-py.ts +6 -6
  15. package/src/project-rs.ts +100 -1098
  16. package/src/projen-version.ts +9 -0
  17. package/src/publish.ts +3 -4
  18. package/src/release-catalog.ts +1 -1
  19. package/src/release.ts +11 -12
  20. package/src/scaffold.ts +1 -1
  21. package/src/uniffi.ts +7 -0
  22. package/src/watch.ts +2 -2
  23. package/src/workspace-version.ts +20 -15
  24. package/tasks/barrels.ts +4 -4
  25. package/tasks/build-rust-release.ts +68 -0
  26. package/tasks/bump.ts +3 -2
  27. package/tasks/clean.ts +3 -3
  28. package/tasks/local-publish.ts +21 -3
  29. package/tasks/openapi.ts +2 -2
  30. package/tasks/publish-npm.ts +12 -6
  31. package/tasks/publish-python.ts +2 -2
  32. package/tasks/publish-uniffi-local.ts +11 -15
  33. package/tasks/publish.ts +88 -88
  34. package/tasks/python-registry.ts +1 -1
  35. package/tasks/release-bootstrap.ts +2 -2
  36. package/tasks/release-plan.ts +1 -1
  37. package/tasks/release-please.ts +1 -1
  38. package/tasks/release-pr.ts +60 -115
  39. package/tasks/release-request.ts +8 -29
  40. package/tasks/release-summary-units.ts +2 -9
  41. package/tasks/release-summary.ts +35 -24
  42. package/tasks/release-version.ts +44 -0
  43. package/tasks/rust-release.d.ts +41 -0
  44. package/tasks/rust-release.mjs +1717 -0
  45. package/tasks/uniffi-python.js +1 -1
  46. package/tasks/uniffi.ts +7 -1
  47. package/tasks/version-check.ts +6 -5
package/tasks/publish.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /**
3
- * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]`
3
+ * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]`
4
4
  * - verify every workspace member and the Bun lock carry the release version,
5
- * then publish each package owned by the standard Node release with `bun publish`.
5
+ * then pack and publish each package owned by the standard Node release.
6
6
  *
7
7
  * Bun has no `pnpm -r publish`, so this loop is the recursive-publish stand-in.
8
8
  * It leans on native bun for everything bun already does:
@@ -26,14 +26,15 @@
26
26
  * its `#!/usr/bin/env node` shebang, node chokes on the `.ts`
27
27
  * (ERR_UNKNOWN_FILE_EXTENSION). We merge `publishConfig` onto the top-level
28
28
  * manifest before packing so the tarball advertises the compiled `lib/` tree;
29
- * - **compiled output** is emitted once, before publishing, by one root-level
29
+ * - **compiled output** is emitted once, before packing, by one root-level
30
30
  * filtered `bun run` that fans out to every publishable member in parallel.
31
- * The later `bun publish --ignore-scripts` calls therefore pack the already
32
- * compiled `lib/` trees instead of serially repeating each member's
33
- * `prepack`. Packages retain their `prepack` task for standalone publishes.
34
- * - **release recovery** packs each exact version before upload and compares
35
- * its integrity and repository identity with registry metadata. A matching
36
- * immutable version is skipped, while any mismatch fails the retry.
31
+ * Every package is then packed once with lifecycle scripts disabled. The
32
+ * exact validated archive is passed to `bun publish`, so upload never
33
+ * repacks or repeats a member's `prepack`. Packages retain `prepack` for
34
+ * standalone publishes.
35
+ * - **release recovery** compares each packed archive's integrity and
36
+ * repository identity with registry metadata. A matching immutable version
37
+ * is skipped, while any mismatch fails the retry.
37
38
  *
38
39
  * `--dry-run` forwards to `bun publish`: it packs + validates
39
40
  * but uploads nothing, so the `release` workflow is testable end-to-end via a
@@ -63,8 +64,7 @@ import {
63
64
  } from "node:fs";
64
65
  import { tmpdir } from "node:os";
65
66
  import { delimiter, dirname, join, resolve } from "node:path";
66
- import { exec } from "@dbx-tools/core";
67
- import { log } from "@dbx-tools/shared-core";
67
+ import { asyncUtils, log } from "@dbx-tools/shared-core";
68
68
  import ts from "typescript";
69
69
  import { parse } from "yaml";
70
70
  import {
@@ -73,9 +73,11 @@ import {
73
73
  publishedNpmRelease,
74
74
  readNpmArchiveIdentity,
75
75
  } from "./publish-npm.ts";
76
+ import { runTaskCommand, runTaskCommandAsync } from "../src/_task-command.ts";
77
+ import { toPosix } from "../src/packages.ts";
76
78
  import type { ReleasePlan } from "../src/release-plan.ts";
77
79
 
78
- const logger = log.logger("dbx-tools:publish");
80
+ const logger = log.logger("projen:publish");
79
81
 
80
82
  /** A manifest's on-disk bytes + mode, captured before this task edits it. */
81
83
  interface ManifestBackup {
@@ -115,7 +117,7 @@ function manifestsMatchVersions(
115
117
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
116
118
  version?: string;
117
119
  };
118
- const path = dir.slice(resolve(root).length + 1).replaceAll("\\", "/");
120
+ const path = toPosix(dir.slice(resolve(root).length + 1));
119
121
  return pkg.version === expected.get(path);
120
122
  });
121
123
  }
@@ -134,10 +136,7 @@ function lockfileMatchesManifestVersions(root: string, members: readonly string[
134
136
  const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
135
137
  version?: string;
136
138
  };
137
- const relative = dir
138
- .slice(resolve(root).length + 1)
139
- .split("\\")
140
- .join("/");
139
+ const relative = toPosix(dir.slice(resolve(root).length + 1));
141
140
  return lock.workspaces?.[relative]?.version === manifest.version;
142
141
  });
143
142
  } catch {
@@ -145,51 +144,6 @@ function lockfileMatchesManifestVersions(root: string, members: readonly string[
145
144
  }
146
145
  }
147
146
 
148
- /** Spawn `command` in `cwd` with `PATH` overridden, failing the task on non-zero. */
149
- function run(cwd: string, command: string, args: string[], path: string): void {
150
- const executable = command === "bun" && process.versions.bun ? process.execPath : command;
151
- exec.spawnSync(executable, args, {
152
- cwd,
153
- stdout: "inherit",
154
- stderr: "inherit",
155
- stdin: "ignore",
156
- check: true,
157
- env: { ...process.env, PATH: path },
158
- });
159
- }
160
-
161
- /** Asynchronous counterpart used for bounded parallel stamping and publishing. */
162
- async function runAsync(cwd: string, command: string, args: string[], path: string): Promise<void> {
163
- const executable = command === "bun" && process.versions.bun ? process.execPath : command;
164
- await exec.spawn(executable, args, {
165
- cwd,
166
- stdout: "inherit",
167
- stderr: "inherit",
168
- stdin: "ignore",
169
- check: true,
170
- env: { ...process.env, PATH: path },
171
- });
172
- }
173
-
174
- /** Run independent jobs with a small fixed worker pool. */
175
- async function runConcurrent<T>(
176
- values: readonly T[],
177
- concurrency: number,
178
- worker: (value: T) => Promise<void>,
179
- ): Promise<void> {
180
- let next = 0;
181
- const results = await Promise.allSettled(
182
- Array.from({ length: Math.min(concurrency, values.length) }, async () => {
183
- while (next < values.length) {
184
- const value = values[next++];
185
- await worker(value);
186
- }
187
- }),
188
- );
189
- const failure = results.find((result) => result.status === "rejected");
190
- if (failure?.status === "rejected") throw failure.reason;
191
- }
192
-
193
147
  /**
194
148
  * Make a projen-readonly manifest writable so the publishConfig projection can
195
149
  * edit it, recording its original bytes and mode for {@link restoreManifests}
@@ -228,6 +182,14 @@ function restoreManifests(): void {
228
182
  /** Entry-point fields projen writes as `.ts` source in-repo and rewrites to `lib/` for publish. */
229
183
  const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const;
230
184
 
185
+ /** Compiled files referenced by a publishConfig entry-point tree. */
186
+ function compiledPublishTargets(value: unknown): string[] {
187
+ if (typeof value === "string") return value.startsWith("./lib/") ? [value] : [];
188
+ if (Array.isArray(value)) return value.flatMap(compiledPublishTargets);
189
+ if (!value || typeof value !== "object") return [];
190
+ return Object.values(value).flatMap(compiledPublishTargets);
191
+ }
192
+
231
193
  /**
232
194
  * Fold a package's `publishConfig` entry-point fields onto the top-level manifest,
233
195
  * the way pnpm/npm do at pack time but `bun publish` does NOT (see the module
@@ -272,13 +234,14 @@ const plan =
272
234
  : undefined;
273
235
  if (!version && !plan) {
274
236
  logger.error(
275
- "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run]",
237
+ "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]",
276
238
  );
277
239
  process.exit(1);
278
240
  }
279
241
  const registryIdx = rest.indexOf("--registry");
280
242
  const registry = registryIdx >= 0 ? rest[registryIdx + 1] : undefined;
281
243
  const dryRun = rest.includes("--dry-run");
244
+ const skipCompile = rest.includes("--skip-compile");
282
245
  const concurrencyIdx = rest.indexOf("--concurrency");
283
246
  const parsedConcurrency = Number(concurrencyIdx >= 0 ? rest[concurrencyIdx + 1] : 4);
284
247
  if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
@@ -302,15 +265,10 @@ const allMembers = workspaceMembers(root)
302
265
  const expectedVersions = new Map(
303
266
  plan
304
267
  ? plan.nodePackages.map((pkg) => [pkg.path, pkg.version] as const)
305
- : allMembers.map((dir) => [
306
- dir.slice(resolve(root).length + 1).replaceAll("\\", "/"),
307
- version!,
308
- ]),
268
+ : allMembers.map((dir) => [toPosix(dir.slice(resolve(root).length + 1)), version!]),
309
269
  );
310
270
  const members = plan
311
- ? allMembers.filter((dir) =>
312
- expectedVersions.has(dir.slice(resolve(root).length + 1).replaceAll("\\", "/")),
313
- )
271
+ ? allMembers.filter((dir) => expectedVersions.has(toPosix(dir.slice(resolve(root).length + 1))))
314
272
  : allMembers;
315
273
 
316
274
  if (!manifestsMatchVersions(root, members, expectedVersions)) {
@@ -328,12 +286,10 @@ if (lockfileMatchesManifestVersions(root, allMembers)) {
328
286
  } else {
329
287
  if (existsSync(lockfile)) rmSync(lockfile);
330
288
  logger.info("refreshing lockfile so workspace deps resolve to the release version");
331
- run(root, "bun", ["install"], path);
289
+ runTaskCommand(root, "bun", ["install"], { env: { ...process.env, PATH: path } });
332
290
  }
333
291
 
334
292
  const publishArgs = [
335
- "--access",
336
- "public",
337
293
  "--ignore-scripts",
338
294
  ...(registry ? ["--registry", registry] : []),
339
295
  ...(dryRun ? ["--dry-run"] : []),
@@ -343,6 +299,8 @@ const publishable: Array<{
343
299
  name: string;
344
300
  version: string;
345
301
  compile: boolean;
302
+ compiledTargets: string[];
303
+ access?: "public" | "restricted";
346
304
  }> = [];
347
305
  for (const dir of members) {
348
306
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
@@ -350,7 +308,7 @@ for (const dir of members) {
350
308
  version?: string;
351
309
  private?: boolean;
352
310
  dbxToolsConfig?: { uniffi?: boolean };
353
- scripts?: Record<string, string>;
311
+ publishConfig?: { access?: unknown };
354
312
  };
355
313
  if (pkg.private) {
356
314
  logger.info(`skip private ${pkg.name ?? dirname(dir)}`);
@@ -361,18 +319,46 @@ for (const dir of members) {
361
319
  continue;
362
320
  }
363
321
  if (!pkg.version) throw new Error(`Missing package version for ${pkg.name ?? dirname(dir)}`);
322
+ const access = pkg.publishConfig?.access;
323
+ if (access !== undefined && access !== "public" && access !== "restricted") {
324
+ throw new Error(
325
+ `${pkg.name ?? dirname(dir)} has invalid publishConfig.access ${String(access)}`,
326
+ );
327
+ }
328
+ const compiledTargets = [...new Set(compiledPublishTargets(pkg.publishConfig))];
364
329
  publishable.push({
365
330
  dir,
366
331
  name: pkg.name ?? dirname(dir),
367
332
  version: pkg.version,
368
- compile: Boolean(pkg.scripts && typeof pkg.scripts === "object" && "prepack" in pkg.scripts),
333
+ compile: compiledTargets.length > 0,
334
+ compiledTargets,
335
+ ...(access ? { access } : {}),
369
336
  });
370
337
  }
371
338
 
372
339
  const compiled = publishable.filter((pkg) => pkg.compile);
373
340
  if (compiled.length > 0) {
374
- logger.info(`compiling ${compiled.length} publishable packages from the workspace root`);
375
- run(root, "bun", ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"], path);
341
+ if (skipCompile) {
342
+ const missing = compiled.flatMap((pkg) =>
343
+ pkg.compiledTargets
344
+ .filter((target) => !existsSync(resolve(pkg.dir, target)))
345
+ .map((target) => `${pkg.name}:${target}`),
346
+ );
347
+ if (missing.length > 0) {
348
+ throw new Error(
349
+ `--skip-compile requires validated compiled output; missing ${missing.join(", ")}`,
350
+ );
351
+ }
352
+ logger.info(`reusing validated compiled output for ${compiled.length} publishable packages`);
353
+ } else {
354
+ logger.info(`compiling ${compiled.length} publishable packages from the workspace root`);
355
+ runTaskCommand(
356
+ root,
357
+ "bun",
358
+ ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"],
359
+ { env: { ...process.env, PATH: path } },
360
+ );
361
+ }
376
362
  }
377
363
 
378
364
  // Compile against workspace source exports first. Switching manifests to their
@@ -387,9 +373,10 @@ for (const { dir } of publishable) {
387
373
  logger.info(
388
374
  `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
389
375
  );
390
- await runConcurrent(publishable, concurrency, async ({ dir, name, version: packageVersion }) => {
391
- if (!dryRun) {
392
- const packed = mkdtempSync(join(tmpdir(), "dbx-tools-npm-release-"));
376
+ await asyncUtils.mapConcurrent(
377
+ publishable,
378
+ async ({ dir, name, version: packageVersion, access }) => {
379
+ const packed = mkdtempSync(join(tmpdir(), "projen-npm-release-"));
393
380
  try {
394
381
  const archive = packNpmPackage(dir, packed, path);
395
382
  const local = readNpmArchiveIdentity(archive);
@@ -398,16 +385,29 @@ await runConcurrent(publishable, concurrency, async ({ dir, name, version: packa
398
385
  `Packed npm identity ${local.name}@${local.version} does not match ${name}@${packageVersion}`,
399
386
  );
400
387
  }
401
- const published = await publishedNpmRelease(local.name, local.version, registry);
402
- if (npmReleaseMatches(local, published)) {
403
- logger.info(`skip published ${name} @ ${packageVersion}`);
404
- return;
388
+ if (local.access !== access) {
389
+ throw new Error(
390
+ `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
391
+ );
392
+ }
393
+ if (!dryRun) {
394
+ const published = await publishedNpmRelease(local.name, local.version, registry);
395
+ if (npmReleaseMatches(local, published)) {
396
+ logger.info(`skip published ${name} @ ${packageVersion}`);
397
+ return;
398
+ }
405
399
  }
400
+ logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
401
+ await runTaskCommandAsync(
402
+ dir,
403
+ "bun",
404
+ ["publish", ...(access ? ["--access", access] : []), ...publishArgs, archive],
405
+ { env: { ...process.env, PATH: path } },
406
+ );
406
407
  } finally {
407
408
  rmSync(packed, { recursive: true, force: true });
408
409
  }
409
- }
410
- logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${packageVersion}`);
411
- await runAsync(dir, "bun", ["publish", ...publishArgs], path);
412
- });
410
+ },
411
+ { concurrency, errorMode: "settle" },
412
+ );
413
413
  logger.success(`${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages`);
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import { exec } from "@dbx-tools/core";
4
+ import * as exec from "@dbx-tools/core/exec";
5
5
  import { net } from "@dbx-tools/shared-core";
6
6
 
7
7
  export interface LocalPythonRegistry {
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
- import { project } from "@dbx-tools/core";
6
+ import * as projectUtils from "@dbx-tools/core/project-utils";
7
7
  import { json, log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
@@ -53,7 +53,7 @@ if (import.meta.main) {
53
53
  new Command()
54
54
  .option("--root <path>", "repository root")
55
55
  .action((options: { root?: string }) => {
56
- bootstrapReleaseUnits(resolve(options.root ?? project.root() ?? process.cwd()));
56
+ bootstrapReleaseUnits(resolve(options.root ?? projectUtils.root() ?? process.cwd()));
57
57
  })
58
58
  .parse();
59
59
  }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
- import { exec } from "@dbx-tools/core";
6
+ import * as exec from "@dbx-tools/core/exec";
7
7
  import { json, log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import type { ReleaseUnitGraph } from "../src/release-catalog.ts";
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { appendFileSync, readFileSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
- import { exec } from "@dbx-tools/core";
6
+ import * as exec from "@dbx-tools/core/exec";
7
7
  import { log } from "@dbx-tools/shared-core";
8
8
  import { Command } from "commander";
9
9
  import { GitHub } from "release-please";
@@ -10,8 +10,9 @@
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { exec, project } from "@dbx-tools/core";
14
- import { async as asyncModule, json, log, object } from "@dbx-tools/shared-core";
13
+ import * as exec from "@dbx-tools/core/exec";
14
+ import * as projectUtils from "@dbx-tools/core/project-utils";
15
+ import { asyncUtils, json, log, object } from "@dbx-tools/shared-core";
15
16
  import { Command } from "commander";
16
17
  import { publishLocalRelease } from "./local-publish.ts";
17
18
  import { generateReleaseSummary } from "./release-summary.ts";
@@ -23,6 +24,16 @@ import {
23
24
  type ReleaseOs,
24
25
  type VersionLevel,
25
26
  } from "../src/_release-platform.ts";
27
+ import {
28
+ captureTaskCommand,
29
+ runTaskCommand,
30
+ runTaskCommandAsync,
31
+ taskCommandSucceeds,
32
+ } from "../src/_task-command.ts";
33
+ import {
34
+ RELEASE_SUMMARY_PROVIDER_NAMES,
35
+ type ReleaseSummaryProviderName,
36
+ } from "../src/release-dispatch.ts";
26
37
  import {
27
38
  githubAccountSupportsWorkflowChanges,
28
39
  githubAuthenticatedAccounts,
@@ -32,10 +43,6 @@ import {
32
43
  githubTokenArguments,
33
44
  githubTokenEnvironmentName,
34
45
  } from "../src/release-github.ts";
35
- import {
36
- RELEASE_SUMMARY_PROVIDER_NAMES,
37
- type ReleaseSummaryProviderName,
38
- } from "../src/release-dispatch.ts";
39
46
  import {
40
47
  readWorkspaceVersion,
41
48
  resolveBaseVersion,
@@ -49,14 +56,9 @@ function git(
49
56
  args: string[],
50
57
  { capture = false, check = true }: { capture?: boolean; check?: boolean } = {},
51
58
  ): string {
52
- const result = exec.spawnSync("git", args, {
53
- cwd: root,
54
- stdout: capture ? "capture" : "inherit",
55
- stderr: capture ? "ignore" : "inherit",
56
- stdin: "ignore",
57
- check,
58
- });
59
- return result.stdout?.trim() ?? "";
59
+ if (capture) return captureTaskCommand(root, "git", args, { check });
60
+ runTaskCommand(root, "git", args);
61
+ return "";
60
62
  }
61
63
 
62
64
  function pushCurrentBranch(root: string, branch: string): void {
@@ -68,84 +70,7 @@ function pushCurrentBranch(root: string, branch: string): void {
68
70
  }
69
71
 
70
72
  function gitSucceeds(root: string, args: string[]): boolean {
71
- return (
72
- exec.spawnSync("git", args, {
73
- cwd: root,
74
- stdout: "ignore",
75
- stderr: "ignore",
76
- stdin: "ignore",
77
- check: false,
78
- }).exitCode === 0
79
- );
80
- }
81
-
82
- function commandSucceeds(
83
- root: string,
84
- command: string,
85
- args: string[],
86
- env?: NodeJS.ProcessEnv,
87
- ): boolean {
88
- return (
89
- exec.spawnSync(command, args, {
90
- cwd: root,
91
- env,
92
- stdout: "ignore",
93
- stderr: "ignore",
94
- stdin: "ignore",
95
- check: false,
96
- }).exitCode === 0
97
- );
98
- }
99
-
100
- function run(root: string, command: string, args: string[], env?: NodeJS.ProcessEnv): void {
101
- exec.spawnSync(command, args, {
102
- cwd: root,
103
- env,
104
- stdout: "inherit",
105
- stderr: "inherit",
106
- stdin: "ignore",
107
- check: true,
108
- });
109
- }
110
-
111
- /** Run one release command with inherited output and a hard timeout. */
112
- async function runWithTimeout(
113
- root: string,
114
- command: string,
115
- args: string[],
116
- env: NodeJS.ProcessEnv,
117
- timeoutMs: number,
118
- ): Promise<void> {
119
- await exec.spawn(command, args, {
120
- cwd: root,
121
- env,
122
- stdout: "inherit",
123
- stderr: "inherit",
124
- stdin: "ignore",
125
- signal: AbortSignal.timeout(timeoutMs),
126
- check: true,
127
- });
128
- }
129
-
130
- /** Capture one non-interactive command without throwing on a nonzero exit. */
131
- function captureCommand(
132
- root: string,
133
- command: string,
134
- args: string[],
135
- env: NodeJS.ProcessEnv,
136
- ): string {
137
- return (
138
- exec
139
- .spawnSync(command, args, {
140
- cwd: root,
141
- env,
142
- stdout: "capture",
143
- stderr: "ignore",
144
- stdin: "ignore",
145
- check: false,
146
- })
147
- .stdout?.trim() ?? ""
148
- );
73
+ return taskCommandSucceeds(root, "git", args);
149
74
  }
150
75
 
151
76
  /** Wait for required checks and return the merged pull request commit. */
@@ -155,16 +80,15 @@ async function waitForPullRequestMerge(
155
80
  env: NodeJS.ProcessEnv,
156
81
  timeoutMs: number,
157
82
  ): Promise<string> {
158
- await runWithTimeout(
83
+ await runTaskCommandAsync(
159
84
  root,
160
85
  "gh",
161
86
  ["pr", "checks", releaseBranch, "--watch", "--fail-fast", "--required", "--interval", "10"],
162
- env,
163
- timeoutMs,
87
+ { env, signal: AbortSignal.timeout(timeoutMs) },
164
88
  );
165
89
  const deadline = Date.now() + timeoutMs;
166
90
  while (Date.now() < deadline) {
167
- const state = captureCommand(
91
+ const state = captureTaskCommand(
168
92
  root,
169
93
  "gh",
170
94
  [
@@ -176,12 +100,12 @@ async function waitForPullRequestMerge(
176
100
  "--jq",
177
101
  '[.state, (.mergeCommit.oid // "")] | @tsv',
178
102
  ],
179
- env,
103
+ { env },
180
104
  );
181
105
  const [status, mergeSha] = state.split("\t");
182
106
  if (status === "MERGED" && mergeSha) return mergeSha;
183
107
  if (status === "CLOSED") throw new Error(`Release pull request closed without merging`);
184
- await asyncModule.sleep(2_000);
108
+ await asyncUtils.sleep(2_000);
185
109
  }
186
110
  throw new Error(`Release pull request did not merge within ${timeoutMs}ms`);
187
111
  }
@@ -197,7 +121,7 @@ async function waitForReleaseWorkflow(
197
121
  const deadline = Date.now() + timeoutMs;
198
122
  let runId = "";
199
123
  while (Date.now() < deadline) {
200
- runId = captureCommand(
124
+ runId = captureTaskCommand(
201
125
  root,
202
126
  "gh",
203
127
  [
@@ -218,13 +142,16 @@ async function waitForReleaseWorkflow(
218
142
  "--jq",
219
143
  ".[0].databaseId // empty",
220
144
  ],
221
- env,
145
+ { env },
222
146
  );
223
147
  if (runId) break;
224
- await asyncModule.sleep(5_000);
148
+ await asyncUtils.sleep(5_000);
225
149
  }
226
150
  if (!runId) throw new Error(`Release workflow did not start within ${timeoutMs}ms`);
227
- await runWithTimeout(root, "gh", ["run", "watch", runId, "--exit-status"], env, timeoutMs);
151
+ await runTaskCommandAsync(root, "gh", ["run", "watch", runId, "--exit-status"], {
152
+ env,
153
+ signal: AbortSignal.timeout(timeoutMs),
154
+ });
228
155
  }
229
156
 
230
157
  function githubAccount(root: string): {
@@ -234,7 +161,7 @@ function githubAccount(root: string): {
234
161
  login: string;
235
162
  token: string;
236
163
  } {
237
- const repository = project.repositoryUrl(root);
164
+ const repository = projectUtils.repositoryUrl(root);
238
165
  if (!repository) throw new Error("Release preparation requires a GitHub repository");
239
166
  const identity = githubRepositoryIdentity(repository);
240
167
  const status = exec
@@ -375,7 +302,7 @@ program
375
302
  wait: boolean;
376
303
  waitTimeoutMinutes: string;
377
304
  }) => {
378
- const root = project.root() ?? process.cwd();
305
+ const root = projectUtils.root() ?? process.cwd();
379
306
  const waitTimeoutMinutes = object.toNumber(opts.waitTimeoutMinutes, {
380
307
  separators: false,
381
308
  percent: false,
@@ -397,6 +324,15 @@ program
397
324
  includeComponentTags: false,
398
325
  }).version;
399
326
  const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
327
+ const releaseVersionScript = fileURLToPath(new URL("./release-version.ts", import.meta.url));
328
+ runTaskCommand(root, process.execPath, [
329
+ releaseVersionScript,
330
+ "--version",
331
+ next.version,
332
+ "--prefix",
333
+ opts.prefix,
334
+ "--assert-next",
335
+ ]);
400
336
  const releaseTag = `${opts.prefix}${next.version}`;
401
337
  const releaseBranch = `release/${releaseTag}`;
402
338
  const releaseRoot = join(root, ".worktrees", releaseTag);
@@ -454,11 +390,11 @@ program
454
390
  }
455
391
  logger.info(`resuming ${releaseBranch} in ${releaseRoot}`);
456
392
  }
457
- run(releaseRoot, process.execPath, ["install"]);
393
+ runTaskCommand(releaseRoot, process.execPath, ["install"]);
458
394
 
459
395
  const bumpScript = fileURLToPath(new URL("./bump.ts", import.meta.url));
460
396
  const versionCheckScript = fileURLToPath(new URL("./version-check.ts", import.meta.url));
461
- run(releaseRoot, process.execPath, [
397
+ runTaskCommand(releaseRoot, process.execPath, [
462
398
  bumpScript,
463
399
  "--level",
464
400
  opts.level,
@@ -471,19 +407,19 @@ program
471
407
  throw new Error(`Release preparation did not produce ${next.version}`);
472
408
  }
473
409
 
474
- run(releaseRoot, process.execPath, [versionCheckScript]);
410
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
475
411
  if (opts.validate) {
476
412
  for (const task of opts.validateTask) {
477
- run(releaseRoot, process.execPath, ["run", task]);
413
+ runTaskCommand(releaseRoot, process.execPath, ["run", task]);
478
414
  }
479
415
  if (existsSync(join(releaseRoot, "Cargo.toml"))) {
480
- run(releaseRoot, "cargo", [
416
+ runTaskCommand(releaseRoot, "cargo", [
481
417
  "test",
482
418
  "--workspace",
483
419
  ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
484
420
  ]);
485
421
  }
486
- run(releaseRoot, process.execPath, ["run", "compile"]);
422
+ runTaskCommand(releaseRoot, process.execPath, ["run", "compile"]);
487
423
  } else {
488
424
  logger.warn("release validation skipped by --no-validate");
489
425
  }
@@ -495,11 +431,12 @@ program
495
431
  localPypi: opts.localPypi,
496
432
  pythonRoot: opts.pythonRoot,
497
433
  localCargo: opts.localCargo,
434
+ reuseValidatedNodeCompile: opts.validate,
498
435
  });
499
436
  } else {
500
437
  logger.info("local publication skipped by --no-local-publish");
501
438
  }
502
- run(releaseRoot, process.execPath, [versionCheckScript]);
439
+ runTaskCommand(releaseRoot, process.execPath, [versionCheckScript]);
503
440
  const releaseSummary = opts.releaseSummary
504
441
  ? await generateReleaseSummary({
505
442
  root: releaseRoot,
@@ -537,8 +474,14 @@ program
537
474
  [githubTokenEnvironmentName(account.hostname)]: account.token,
538
475
  };
539
476
  const ensurePullRequest = (): void => {
540
- if (commandSucceeds(root, "gh", ["pr", "view", releaseBranch], githubEnvironment)) return;
541
- run(
477
+ if (
478
+ taskCommandSucceeds(root, "gh", ["pr", "view", releaseBranch], {
479
+ env: githubEnvironment,
480
+ })
481
+ ) {
482
+ return;
483
+ }
484
+ runTaskCommand(
542
485
  root,
543
486
  "gh",
544
487
  [
@@ -553,12 +496,14 @@ program
553
496
  "--body",
554
497
  body,
555
498
  ],
556
- githubEnvironment,
499
+ { env: githubEnvironment },
557
500
  );
558
501
  };
559
502
  ensurePullRequest();
560
503
  if (opts.approve) {
561
- run(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], githubEnvironment);
504
+ runTaskCommand(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], {
505
+ env: githubEnvironment,
506
+ });
562
507
  }
563
508
  git(root, ["worktree", "remove", "--force", releaseRoot]);
564
509
  git(root, ["branch", "--delete", "--force", releaseBranch]);