@dbx-tools/projen 0.9.19 → 0.9.21

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 (104) hide show
  1. package/._LICENSE +0 -0
  2. package/._README.md +0 -0
  3. package/._index.ts +0 -0
  4. package/._package.json +0 -0
  5. package/README.md +67 -83
  6. package/index.ts +2 -7
  7. package/package.json +9 -5
  8. package/src/.__lazy-require.ts +0 -0
  9. package/src/.__release-platform.ts +0 -0
  10. package/src/.__rust-project.ts +0 -0
  11. package/src/.__rust-release-workflow.ts +0 -0
  12. package/src/.__task-command.ts +0 -0
  13. package/src/._barrels.ts +0 -0
  14. package/src/._bun-app.ts +0 -0
  15. package/src/._bun-workflow.ts +0 -0
  16. package/src/._clean.ts +0 -0
  17. package/src/._codegen.ts +0 -0
  18. package/src/._dbx-tools-config.ts +0 -0
  19. package/src/._engine-root.ts +0 -0
  20. package/src/._generated.ts +0 -0
  21. package/src/._mixin.ts +0 -0
  22. package/src/._module-exports.ts +0 -0
  23. package/src/._openapi.ts +0 -0
  24. package/src/._packages.ts +0 -0
  25. package/src/._pnpm-workspace.ts +0 -0
  26. package/src/._project-js.ts +0 -0
  27. package/src/._project-predicate.ts +0 -0
  28. package/src/._project-py.ts +0 -0
  29. package/src/._project-rs.ts +0 -0
  30. package/src/._project.ts +0 -0
  31. package/src/._projen-version.ts +0 -0
  32. package/src/._publish.ts +0 -0
  33. package/src/._release-catalog.ts +0 -0
  34. package/src/._release-dispatch.ts +0 -0
  35. package/src/._release-github.ts +0 -0
  36. package/src/._release.ts +0 -0
  37. package/src/._scaffold.ts +0 -0
  38. package/src/._tags.ts +0 -0
  39. package/src/._tsconfig.ts +0 -0
  40. package/src/._uniffi.ts +0 -0
  41. package/src/._vscode.ts +0 -0
  42. package/src/._watch.ts +0 -0
  43. package/src/._workspace-lock.ts +0 -0
  44. package/src/._workspace-version.ts +0 -0
  45. package/src/_rust-release-workflow.ts +4 -391
  46. package/src/_task-command.ts +75 -0
  47. package/src/project-js.ts +2 -42
  48. package/src/project-py.ts +5 -96
  49. package/src/project-rs.ts +3 -76
  50. package/src/release-catalog.ts +37 -203
  51. package/src/release.ts +12 -634
  52. package/src/scaffold.ts +2 -6
  53. package/src/workspace-version.ts +9 -121
  54. package/tasks/._barrels.ts +0 -0
  55. package/tasks/._bump.ts +0 -0
  56. package/tasks/._clean.ts +0 -0
  57. package/tasks/._compile-workspace.ts +0 -0
  58. package/tasks/._local-publish.ts +0 -0
  59. package/tasks/._openapi.ts +0 -0
  60. package/tasks/._projenrc.ts +0 -0
  61. package/tasks/._publish-npm.ts +0 -0
  62. package/tasks/._publish-python.ts +0 -0
  63. package/tasks/._publish-uniffi-local.ts +0 -0
  64. package/tasks/._publish.ts +0 -0
  65. package/tasks/._python-registry.ts +0 -0
  66. package/tasks/._release-assets.ts +0 -0
  67. package/tasks/._release-candidate.ts +0 -0
  68. package/tasks/._release-manifest.ts +0 -0
  69. package/tasks/._release-pr.ts +0 -0
  70. package/tasks/._release-summary.ts +0 -0
  71. package/tasks/._release-version.ts +0 -0
  72. package/tasks/._rust.ts +0 -0
  73. package/tasks/._sync.ts +0 -0
  74. package/tasks/._uniffi-python.d.ts +0 -0
  75. package/tasks/._uniffi-python.js +0 -0
  76. package/tasks/._uniffi-release.mjs +0 -0
  77. package/tasks/._uniffi.ts +0 -0
  78. package/tasks/._version-check.ts +0 -0
  79. package/tasks/bump.ts +17 -34
  80. package/tasks/local-publish.ts +124 -117
  81. package/tasks/publish-npm.ts +62 -30
  82. package/tasks/publish-python.ts +38 -140
  83. package/tasks/publish-uniffi-local.ts +129 -84
  84. package/tasks/publish.ts +25 -139
  85. package/tasks/python-registry.ts +2 -9
  86. package/tasks/release-assets.ts +56 -65
  87. package/tasks/release-candidate.ts +123 -90
  88. package/tasks/release-pr.ts +266 -240
  89. package/tasks/release-summary.ts +8 -10
  90. package/tasks/release-version.ts +23 -2
  91. package/tasks/rust.ts +8 -13
  92. package/tasks/uniffi-python.d.ts +2 -3
  93. package/tasks/uniffi-python.js +7 -20
  94. package/tasks/uniffi-release.mjs +19 -13
  95. package/tasks/uniffi.ts +15 -24
  96. package/tasks/version-check.ts +24 -56
  97. package/src/release-plan.ts +0 -269
  98. package/src/release-please.ts +0 -242
  99. package/tasks/release-bootstrap.ts +0 -59
  100. package/tasks/release-plan.ts +0 -89
  101. package/tasks/release-please.ts +0 -162
  102. package/tasks/release-request.ts +0 -166
  103. package/tasks/release-summary-units.ts +0 -75
  104. package/tasks/stamp-python.ts +0 -13
@@ -14,7 +14,6 @@ import {
14
14
  } from "node:fs";
15
15
  import { platform } from "node:os";
16
16
  import { basename, delimiter, dirname, join, relative, resolve } from "node:path";
17
- import * as exec from "@dbx-tools/core/exec";
18
17
  import * as projectUtils from "@dbx-tools/core/project-utils";
19
18
  import { json, log } from "@dbx-tools/shared-core";
20
19
  import { Command } from "commander";
@@ -23,6 +22,11 @@ import type {
23
22
  RustReleaseConfiguration,
24
23
  RustReleaseTargetPlan,
25
24
  } from "../src/_rust-release-workflow.ts";
25
+ import {
26
+ captureTaskCommand,
27
+ runLoggedTaskCommand,
28
+ taskCommandSucceeds,
29
+ } from "../src/_task-command.ts";
26
30
 
27
31
  const logger = log.logger("projen:release-assets");
28
32
  const emoji = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
@@ -39,55 +43,8 @@ interface ReleaseAsset {
39
43
  readonly size: number;
40
44
  }
41
45
 
42
- function run(
43
- root: string,
44
- command: string,
45
- args: readonly string[],
46
- env: NodeJS.ProcessEnv = process.env,
47
- ): void {
48
- const result = exec.spawnSync(command, [...args], {
49
- cwd: root,
50
- env,
51
- stdin: "ignore",
52
- stdout: "capture",
53
- stderr: "capture",
54
- check: false,
55
- });
56
- for (const line of `${result.stdout}\n${result.stderr}`.split(/\r?\n/)) {
57
- if (line) logger.info(line.replace(emoji, ""));
58
- }
59
- if (result.exitCode !== 0) {
60
- throw new Error(`${command} exited with ${result.exitCode}`);
61
- }
62
- }
63
-
64
- function capture(
65
- root: string,
66
- command: string,
67
- args: readonly string[],
68
- env: NodeJS.ProcessEnv,
69
- ): string {
70
- return exec.spawnSync(command, [...args], {
71
- cwd: root,
72
- env,
73
- stdin: "ignore",
74
- stdout: "capture",
75
- stderr: "inherit",
76
- check: true,
77
- }).stdout;
78
- }
79
-
80
- function commandSucceeds(root: string, command: string, args: readonly string[]): boolean {
81
- return (
82
- exec.spawnSync(command, [...args], {
83
- cwd: root,
84
- env: { ...process.env, PATH: releasePath() },
85
- stdin: "ignore",
86
- stdout: "ignore",
87
- stderr: "ignore",
88
- check: false,
89
- }).exitCode === 0
90
- );
46
+ function logReleaseOutput(line: string): void {
47
+ logger.info(line.replace(emoji, ""));
91
48
  }
92
49
 
93
50
  function releasePath(): string {
@@ -110,7 +67,12 @@ export function missingLocalReleaseTools(root: string): string[] {
110
67
  ["zip", "zip", ["-v"]],
111
68
  ];
112
69
  return checks
113
- .filter(([, command, args]) => !commandSucceeds(root, command, args))
70
+ .filter(
71
+ ([, command, args]) =>
72
+ !taskCommandSucceeds(root, command, args, {
73
+ env: { ...process.env, PATH: releasePath() },
74
+ }),
75
+ )
114
76
  .map(([name]) => name);
115
77
  }
116
78
 
@@ -205,7 +167,11 @@ function targetBuildEnvironment(
205
167
 
206
168
  function cargoTargetRoot(root: string, env: NodeJS.ProcessEnv): string {
207
169
  const metadata = json.parseRecord(
208
- capture(root, "cargo", ["metadata", "--format-version", "1", "--no-deps"], env),
170
+ captureTaskCommand(root, "cargo", ["metadata", "--format-version", "1", "--no-deps"], {
171
+ env,
172
+ check: true,
173
+ stderr: "inherit",
174
+ }),
209
175
  );
210
176
  if (typeof metadata?.target_directory !== "string") {
211
177
  throw new Error("Cargo metadata returned no target directory");
@@ -214,7 +180,13 @@ function cargoTargetRoot(root: string, env: NodeJS.ProcessEnv): string {
214
180
  }
215
181
 
216
182
  function rustHostTarget(root: string, env: NodeJS.ProcessEnv): string {
217
- const host = /^host:\s+(.+)$/m.exec(capture(root, "rustc", ["-vV"], env))?.[1];
183
+ const host = /^host:\s+(.+)$/m.exec(
184
+ captureTaskCommand(root, "rustc", ["-vV"], {
185
+ env,
186
+ check: true,
187
+ stderr: "inherit",
188
+ }),
189
+ )?.[1];
218
190
  if (!host) throw new Error("rustc did not report a host target");
219
191
  return host;
220
192
  }
@@ -226,7 +198,7 @@ function buildBindingGenerators(
226
198
  env: NodeJS.ProcessEnv,
227
199
  ): void {
228
200
  if (configuration.bindings.length === 0) return;
229
- run(
201
+ runLoggedTaskCommand(
230
202
  root,
231
203
  "cargo",
232
204
  [
@@ -241,7 +213,7 @@ function buildBindingGenerators(
241
213
  "--target",
242
214
  hostTarget,
243
215
  ],
244
- env,
216
+ { env, onLine: logReleaseOutput },
245
217
  );
246
218
  }
247
219
 
@@ -255,7 +227,7 @@ function generatePythonBindings(
255
227
  for (const binding of configuration.bindings) {
256
228
  if (!binding.python) continue;
257
229
  const output = join(root, "dist/local-bindings", binding.crate);
258
- run(
230
+ runLoggedTaskCommand(
259
231
  root,
260
232
  "node",
261
233
  [
@@ -270,7 +242,7 @@ function generatePythonBindings(
270
242
  "--output",
271
243
  output,
272
244
  ],
273
- env,
245
+ { env, onLine: logReleaseOutput },
274
246
  );
275
247
  generated.set(binding.crate, join(output, `${binding.crate.replaceAll("-", "_")}.py`));
276
248
  }
@@ -286,7 +258,7 @@ function packageBindings(
286
258
  env: NodeJS.ProcessEnv,
287
259
  ): void {
288
260
  for (const binding of configuration.bindings) {
289
- run(
261
+ runLoggedTaskCommand(
290
262
  root,
291
263
  "node",
292
264
  [
@@ -325,7 +297,7 @@ function packageBindings(
325
297
  ? ["--python-bindings", pythonBindings.get(binding.crate)!]
326
298
  : []),
327
299
  ],
328
- env,
300
+ { env, onLine: logReleaseOutput },
329
301
  );
330
302
  }
331
303
  }
@@ -352,9 +324,15 @@ function packageBinaries(
352
324
  const output = join(root, "dist/release", binary.crate, target.node, "binary");
353
325
  const asset = join(output, releaseBinaryAssetName(binary.binary, target.node, target.os));
354
326
  if (target.os === "win32") {
355
- run(root, "zip", ["-j", asset, source], env);
327
+ runLoggedTaskCommand(root, "zip", ["-j", asset, source], {
328
+ env,
329
+ onLine: logReleaseOutput,
330
+ });
356
331
  } else {
357
- run(root, "tar", ["-C", dirname(source), "-czf", asset, basename(source)], env);
332
+ runLoggedTaskCommand(root, "tar", ["-C", dirname(source), "-czf", asset, basename(source)], {
333
+ env,
334
+ onLine: logReleaseOutput,
335
+ });
358
336
  }
359
337
  }
360
338
  }
@@ -418,8 +396,15 @@ function uploadAssets(
418
396
  assets: readonly ReleaseAsset[],
419
397
  manifest: string,
420
398
  ): void {
421
- run(root, "gh", ["release", "upload", tag, ...assets.map((asset) => asset.path), "--clobber"]);
422
- run(root, "gh", ["release", "upload", tag, manifest, "--clobber"]);
399
+ runLoggedTaskCommand(
400
+ root,
401
+ "gh",
402
+ ["release", "upload", tag, ...assets.map((asset) => asset.path), "--clobber"],
403
+ { onLine: logReleaseOutput },
404
+ );
405
+ runLoggedTaskCommand(root, "gh", ["release", "upload", tag, manifest, "--clobber"], {
406
+ onLine: logReleaseOutput,
407
+ });
423
408
  }
424
409
 
425
410
  /** Build all configured targets sequentially and optionally upload them. */
@@ -443,11 +428,17 @@ export function buildReleaseAssets(options: {
443
428
  buildBindingGenerators(root, configuration, hostTarget, env);
444
429
  const pythonBindings = generatePythonBindings(root, configuration, hostTarget, env);
445
430
  for (const target of configuration.targets) {
446
- run(root, "rustup", ["target", "add", target.cargo], env);
431
+ runLoggedTaskCommand(root, "rustup", ["target", "add", target.cargo], {
432
+ env,
433
+ onLine: logReleaseOutput,
434
+ });
447
435
  const invocation = localTargetCommand(configuration, target);
448
436
  const buildEnv = targetBuildEnvironment(root, target, env);
449
437
  logger.info("building Rust release target", { target: target.cargo });
450
- run(root, invocation.command, invocation.args, buildEnv);
438
+ runLoggedTaskCommand(root, invocation.command, invocation.args, {
439
+ env: buildEnv,
440
+ onLine: logReleaseOutput,
441
+ });
451
442
  packageBindings(root, configuration, target, options.version, pythonBindings, env);
452
443
  packageBinaries(root, configuration, target, targetRoot, env);
453
444
  }
@@ -4,51 +4,23 @@
4
4
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import * as exec from "@dbx-tools/core/exec";
8
7
  import * as projectUtils from "@dbx-tools/core/project-utils";
9
8
  import { json, log } from "@dbx-tools/shared-core";
10
9
  import { Command } from "commander";
11
- import type { RustReleaseConfiguration } from "../src/_rust-release-workflow.ts";
12
- import { buildPythonProjects } from "./publish-python.ts";
13
10
  import { readNpmArchiveIdentity } from "./publish-npm.ts";
11
+ import { buildPythonProjects } from "./publish-python.ts";
14
12
  import { buildReleaseAssets } from "./release-assets.ts";
15
13
  import {
16
14
  type ReleaseArtifactInput,
17
15
  type ReleaseArtifactRole,
16
+ verifyReleaseManifest,
18
17
  writeReleaseManifest,
19
18
  } from "./release-manifest.ts";
19
+ import type { RustReleaseConfiguration } from "../src/_rust-release-workflow.ts";
20
+ import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
20
21
 
21
22
  const logger = log.logger("projen:release-candidate");
22
23
 
23
- function run(root: string, command: string, args: readonly string[], env = process.env): void {
24
- exec.spawnSync(command, [...args], {
25
- cwd: root,
26
- env,
27
- stdin: "ignore",
28
- stdout: "inherit",
29
- stderr: "inherit",
30
- check: true,
31
- });
32
- }
33
-
34
- function capture(
35
- root: string,
36
- command: string,
37
- args: readonly string[],
38
- env = process.env,
39
- ): string {
40
- return exec
41
- .spawnSync(command, [...args], {
42
- cwd: root,
43
- env,
44
- stdin: "ignore",
45
- stdout: "capture",
46
- stderr: "inherit",
47
- check: true,
48
- })
49
- .stdout.trim();
50
- }
51
-
52
24
  function files(directory: string): string[] {
53
25
  if (!existsSync(directory)) return [];
54
26
  return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
@@ -74,12 +46,24 @@ function role(path: string): ReleaseArtifactRole {
74
46
  return "native";
75
47
  }
76
48
 
77
- function releaseArtifacts(root: string, version: string): ReleaseArtifactInput[] {
49
+ export function releaseArtifacts(root: string, version: string): ReleaseArtifactInput[] {
78
50
  const releaseRoot = join(root, "dist/release");
79
51
  return files(releaseRoot)
80
52
  .filter((path) => !path.includes(`${join(releaseRoot, "upload")}/`))
81
53
  .flatMap((path): ReleaseArtifactInput[] => {
82
54
  const normalized = path.split("\\").join("/");
55
+ if (
56
+ ![
57
+ "/binary/",
58
+ "/npm/",
59
+ "/npm-facade/",
60
+ "/npm-workspace/",
61
+ "/python/",
62
+ "/python-workspace/",
63
+ ].some((directory) => normalized.includes(directory))
64
+ ) {
65
+ return [];
66
+ }
83
67
  if (path.endsWith(".tgz")) {
84
68
  const identity = readNpmArchiveIdentity(path);
85
69
  if (identity.version !== version) {
@@ -123,17 +107,17 @@ function pruneDraftAssets(
123
107
  desired: ReadonlySet<string>,
124
108
  env: NodeJS.ProcessEnv,
125
109
  ): void {
126
- const current = capture(
110
+ const current = captureTaskCommand(
127
111
  root,
128
112
  "gh",
129
113
  ["release", "view", tag, "--json", "assets", "--jq", ".assets[].name"],
130
- env,
114
+ { env, check: true, stderr: "inherit" },
131
115
  )
132
116
  .split(/\r?\n/)
133
117
  .filter(Boolean);
134
118
  for (const name of current) {
135
119
  if (!desired.has(name)) {
136
- run(root, "gh", ["release", "delete-asset", tag, name, "--yes"], env);
120
+ runTaskCommand(root, "gh", ["release", "delete-asset", tag, name, "--yes"], { env });
137
121
  }
138
122
  }
139
123
  }
@@ -150,7 +134,7 @@ function buildFacades(root: string, version: string): void {
150
134
  if (!configuration) return;
151
135
  for (const binding of configuration.bindings) {
152
136
  if (!binding.node || !binding.nodePackage) continue;
153
- run(root, "node", [
137
+ runTaskCommand(root, "node", [
154
138
  ".projen/uniffi-release.mjs",
155
139
  "facade",
156
140
  "--node",
@@ -174,96 +158,105 @@ function ensureDraftRelease(options: {
174
158
  readonly notesFile?: string;
175
159
  readonly env: NodeJS.ProcessEnv;
176
160
  }): void {
177
- const existingTag = exec.spawnSync("git", ["rev-parse", "--verify", `${options.tag}^{commit}`], {
178
- cwd: options.root,
179
- env: options.env,
180
- stdin: "ignore",
181
- stdout: "capture",
182
- stderr: "ignore",
183
- check: false,
184
- });
185
- if (existingTag.exitCode === 0) {
186
- if (existingTag.stdout.trim() !== options.sha) {
161
+ const existingTag = captureTaskCommand(
162
+ options.root,
163
+ "git",
164
+ ["rev-parse", "--verify", `${options.tag}^{commit}`],
165
+ { env: options.env },
166
+ );
167
+ if (existingTag) {
168
+ if (existingTag !== options.sha) {
187
169
  throw new Error(`Release tag ${options.tag} does not point to ${options.sha}`);
188
170
  }
189
- if (capture(options.root, "git", ["cat-file", "-t", options.tag], options.env) !== "tag") {
171
+ if (
172
+ captureTaskCommand(options.root, "git", ["cat-file", "-t", options.tag], {
173
+ env: options.env,
174
+ check: true,
175
+ stderr: "inherit",
176
+ }) !== "tag"
177
+ ) {
190
178
  throw new Error(`Release tag ${options.tag} must be annotated`);
191
179
  }
192
180
  } else {
193
- run(
181
+ runTaskCommand(
194
182
  options.root,
195
183
  "git",
196
184
  ["tag", "-a", options.tag, options.sha, "-m", options.tag],
197
- options.env,
185
+ { env: options.env },
198
186
  );
199
187
  }
200
- const remoteTag = exec.spawnSync(
188
+ const remoteTag = taskCommandSucceeds(
189
+ options.root,
201
190
  "git",
202
191
  ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${options.tag}`],
203
- {
204
- cwd: options.root,
205
- env: options.env,
206
- stdin: "ignore",
207
- stdout: "ignore",
208
- stderr: "ignore",
209
- check: false,
210
- },
192
+ { env: options.env },
211
193
  );
212
- if (remoteTag.exitCode === 0) {
213
- run(
194
+ if (remoteTag) {
195
+ runTaskCommand(
214
196
  options.root,
215
197
  "git",
216
198
  ["fetch", "--force", "origin", `+refs/tags/${options.tag}:refs/tags/${options.tag}`],
217
- options.env,
199
+ { env: options.env },
218
200
  );
219
201
  if (
220
- capture(options.root, "git", ["rev-parse", `${options.tag}^{commit}`], options.env) !==
221
- options.sha
202
+ captureTaskCommand(options.root, "git", ["rev-parse", `${options.tag}^{commit}`], {
203
+ env: options.env,
204
+ check: true,
205
+ stderr: "inherit",
206
+ }) !== options.sha
222
207
  ) {
223
208
  throw new Error(`Remote release tag ${options.tag} does not point to ${options.sha}`);
224
209
  }
225
- if (capture(options.root, "git", ["cat-file", "-t", options.tag], options.env) !== "tag") {
210
+ if (
211
+ captureTaskCommand(options.root, "git", ["cat-file", "-t", options.tag], {
212
+ env: options.env,
213
+ check: true,
214
+ stderr: "inherit",
215
+ }) !== "tag"
216
+ ) {
226
217
  throw new Error(`Remote release tag ${options.tag} must be annotated`);
227
218
  }
228
219
  } else {
229
- run(options.root, "git", ["push", "origin", `refs/tags/${options.tag}`], options.env);
220
+ runTaskCommand(options.root, "git", ["push", "origin", `refs/tags/${options.tag}`], {
221
+ env: options.env,
222
+ });
230
223
  }
231
224
 
232
- const release = exec.spawnSync("gh", ["release", "view", options.tag, "--json", "isDraft"], {
233
- cwd: options.root,
234
- env: options.env,
235
- stdin: "ignore",
236
- stdout: "capture",
237
- stderr: "ignore",
238
- check: false,
239
- });
240
- if (release.exitCode === 0) {
241
- const state = JSON.parse(release.stdout) as { isDraft?: boolean };
242
- if (state.isDraft !== true)
225
+ const release = captureTaskCommand(
226
+ options.root,
227
+ "gh",
228
+ ["release", "view", options.tag, "--json", "isDraft"],
229
+ { env: options.env },
230
+ );
231
+ if (release) {
232
+ const state = JSON.parse(release) as { isDraft?: boolean };
233
+ if (state.isDraft !== true) {
243
234
  throw new Error(`GitHub Release ${options.tag} is already published`);
235
+ }
244
236
  if (options.notesFile) {
245
- run(
237
+ runTaskCommand(
246
238
  options.root,
247
239
  "gh",
248
240
  ["release", "edit", options.tag, "--title", options.tag, "--notes-file", options.notesFile],
249
- options.env,
241
+ { env: options.env },
250
242
  );
251
243
  }
252
244
  return;
253
245
  }
254
246
  const notes =
255
247
  options.notesFile && existsSync(options.notesFile) ? ["--notes-file", options.notesFile] : [];
256
- run(
248
+ runTaskCommand(
257
249
  options.root,
258
250
  "gh",
259
251
  ["release", "create", options.tag, "--draft", "--verify-tag", "--title", options.tag, ...notes],
260
- options.env,
252
+ { env: options.env },
261
253
  );
262
254
  }
263
255
 
264
256
  /** Build the exact release candidate and optionally attach it to a draft release. */
265
257
  export function buildReleaseCandidate(options: {
266
258
  readonly root: string;
259
+ readonly pythonRoot: string;
267
260
  readonly sha: string;
268
261
  readonly tag: string;
269
262
  readonly version: string;
@@ -273,13 +266,24 @@ export function buildReleaseCandidate(options: {
273
266
  }): void {
274
267
  const root = resolve(options.root);
275
268
  const env = options.env ?? process.env;
276
- if (capture(root, "git", ["rev-parse", "HEAD"], env) !== options.sha) {
269
+ if (
270
+ captureTaskCommand(root, "git", ["rev-parse", "HEAD"], {
271
+ env,
272
+ check: true,
273
+ stderr: "inherit",
274
+ }) !== options.sha
275
+ ) {
277
276
  throw new Error(`Release candidate must be built from ${options.sha}`);
278
277
  }
279
278
  if (readFileSync(join(root, "VERSION"), "utf8").trim() !== options.version) {
280
279
  throw new Error(`VERSION does not match ${options.version}`);
281
280
  }
282
- const status = capture(root, "git", ["status", "--porcelain=v1", "--untracked-files=all"], env);
281
+ const status = captureTaskCommand(
282
+ root,
283
+ "git",
284
+ ["status", "--porcelain=v1", "--untracked-files=all"],
285
+ { env, check: true, stderr: "inherit" },
286
+ );
283
287
  if (status) throw new Error("Release candidate source contains tracked changes");
284
288
 
285
289
  rmSync(join(root, "dist/release"), { recursive: true, force: true });
@@ -288,13 +292,13 @@ export function buildReleaseCandidate(options: {
288
292
  buildFacades(root, options.version);
289
293
  }
290
294
  const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
291
- run(root, process.execPath, [
295
+ runTaskCommand(root, process.execPath, [
292
296
  publishScript,
293
297
  options.version,
294
298
  "--output",
295
299
  "dist/release/npm-workspace",
296
300
  ]);
297
- const pythonRoot = join(root, "packages/py");
301
+ const pythonRoot = resolve(root, options.pythonRoot);
298
302
  if (existsSync(pythonRoot)) {
299
303
  buildPythonProjects({
300
304
  allowEmpty: true,
@@ -318,6 +322,27 @@ export function buildReleaseCandidate(options: {
318
322
  tag: options.tag,
319
323
  });
320
324
  if (!options.upload) return;
325
+ uploadReleaseCandidate(options);
326
+ }
327
+
328
+ /** Attach an already-built and verified candidate to its draft GitHub Release. */
329
+ export function uploadReleaseCandidate(options: {
330
+ readonly root: string;
331
+ readonly sha: string;
332
+ readonly tag: string;
333
+ readonly version: string;
334
+ readonly notesFile?: string;
335
+ readonly env?: NodeJS.ProcessEnv;
336
+ }): void {
337
+ const root = resolve(options.root);
338
+ const env = options.env ?? process.env;
339
+ const uploadDirectory = join(root, "dist/release/upload");
340
+ verifyReleaseManifest({
341
+ directory: uploadDirectory,
342
+ gitSha: options.sha,
343
+ tag: options.tag,
344
+ version: options.version,
345
+ });
321
346
  const notesFile = join(root, "dist/release/release-notes.md");
322
347
  const summary =
323
348
  options.notesFile && existsSync(resolve(root, options.notesFile))
@@ -348,7 +373,9 @@ export function buildReleaseCandidate(options: {
348
373
  return priority(left) - priority(right) || left.localeCompare(right);
349
374
  });
350
375
  pruneDraftAssets(root, options.tag, new Set(uploadFiles.map((path) => basename(path))), env);
351
- run(root, "gh", ["release", "upload", options.tag, ...uploadFiles, "--clobber"], env);
376
+ runTaskCommand(root, "gh", ["release", "upload", options.tag, ...uploadFiles, "--clobber"], {
377
+ env,
378
+ });
352
379
  logger.success(`uploaded draft release candidate ${options.tag}`);
353
380
  }
354
381
 
@@ -358,25 +385,31 @@ if (import.meta.main) {
358
385
  .requiredOption("--tag <tag>", "annotated release tag")
359
386
  .requiredOption("--sha <sha>", "exact release commit")
360
387
  .option("--root <path>", "repository root")
388
+ .option("--python-root <path>", "Python package root", "packages/py")
361
389
  .option("--notes-file <path>", "draft release notes file")
362
- .option("--upload", "create or update the draft GitHub Release")
390
+ .option("--upload", "create or update the draft GitHub Release after building")
391
+ .option("--upload-existing", "upload the existing verified candidate without rebuilding")
363
392
  .action(
364
393
  (options: {
365
394
  version: string;
366
395
  tag: string;
367
396
  sha: string;
368
397
  root?: string;
398
+ pythonRoot: string;
369
399
  notesFile?: string;
370
400
  upload?: boolean;
401
+ uploadExisting?: boolean;
371
402
  }) => {
372
- buildReleaseCandidate({
403
+ const candidateOptions = {
373
404
  root: options.root ?? projectUtils.root() ?? process.cwd(),
405
+ pythonRoot: options.pythonRoot,
374
406
  sha: options.sha,
375
407
  tag: options.tag,
376
408
  version: options.version,
377
409
  ...(options.notesFile ? { notesFile: options.notesFile } : {}),
378
- upload: options.upload,
379
- });
410
+ };
411
+ if (options.uploadExisting) uploadReleaseCandidate(candidateOptions);
412
+ else buildReleaseCandidate({ ...candidateOptions, upload: options.upload });
380
413
  },
381
414
  )
382
415
  .parse();