@dbx-tools/projen 0.9.18 → 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 (105) 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 +72 -88
  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 +18 -582
  46. package/src/_task-command.ts +75 -0
  47. package/src/project-js.ts +2 -42
  48. package/src/project-py.ts +60 -162
  49. package/src/project-rs.ts +4 -100
  50. package/src/release-catalog.ts +37 -203
  51. package/src/release.ts +96 -775
  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 +125 -41
  82. package/tasks/publish-python.ts +79 -106
  83. package/tasks/publish-uniffi-local.ts +129 -84
  84. package/tasks/publish.ts +44 -139
  85. package/tasks/python-registry.ts +2 -9
  86. package/tasks/release-assets.ts +56 -65
  87. package/tasks/release-candidate.ts +416 -0
  88. package/tasks/release-manifest.ts +273 -0
  89. package/tasks/release-pr.ts +312 -369
  90. package/tasks/release-summary.ts +8 -10
  91. package/tasks/release-version.ts +23 -2
  92. package/tasks/rust.ts +8 -13
  93. package/tasks/uniffi-python.d.ts +2 -3
  94. package/tasks/uniffi-python.js +7 -20
  95. package/tasks/uniffi-release.mjs +19 -13
  96. package/tasks/uniffi.ts +15 -24
  97. package/tasks/version-check.ts +24 -56
  98. package/src/release-plan.ts +0 -269
  99. package/src/release-please.ts +0 -242
  100. package/tasks/release-bootstrap.ts +0 -59
  101. package/tasks/release-plan.ts +0 -89
  102. package/tasks/release-please.ts +0 -162
  103. package/tasks/release-request.ts +0 -166
  104. package/tasks/release-summary-units.ts +0 -75
  105. 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
  }
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Build and optionally upload the complete locally approved release candidate. */
3
+
4
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import * as projectUtils from "@dbx-tools/core/project-utils";
8
+ import { json, log } from "@dbx-tools/shared-core";
9
+ import { Command } from "commander";
10
+ import { readNpmArchiveIdentity } from "./publish-npm.ts";
11
+ import { buildPythonProjects } from "./publish-python.ts";
12
+ import { buildReleaseAssets } from "./release-assets.ts";
13
+ import {
14
+ type ReleaseArtifactInput,
15
+ type ReleaseArtifactRole,
16
+ verifyReleaseManifest,
17
+ writeReleaseManifest,
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";
21
+
22
+ const logger = log.logger("projen:release-candidate");
23
+
24
+ function files(directory: string): string[] {
25
+ if (!existsSync(directory)) return [];
26
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
27
+ const path = join(directory, entry.name);
28
+ return entry.isDirectory() ? files(path) : [path];
29
+ });
30
+ }
31
+
32
+ function pythonPackageName(path: string, version: string): string {
33
+ const name = basename(path);
34
+ const marker = `-${version}`;
35
+ const index = name.indexOf(marker);
36
+ if (index <= 0) throw new Error(`Cannot resolve Python package name from ${name}`);
37
+ return name.slice(0, index).replace(/[_.]+/g, "-").toLowerCase();
38
+ }
39
+
40
+ function role(path: string): ReleaseArtifactRole {
41
+ const normalized = path.split("\\").join("/");
42
+ if (normalized.includes("/npm-facade/")) return "facade";
43
+ if (normalized.includes("/npm-workspace/") || normalized.includes("/python-workspace/")) {
44
+ return "workspace";
45
+ }
46
+ return "native";
47
+ }
48
+
49
+ export function releaseArtifacts(root: string, version: string): ReleaseArtifactInput[] {
50
+ const releaseRoot = join(root, "dist/release");
51
+ return files(releaseRoot)
52
+ .filter((path) => !path.includes(`${join(releaseRoot, "upload")}/`))
53
+ .flatMap((path): ReleaseArtifactInput[] => {
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
+ }
67
+ if (path.endsWith(".tgz")) {
68
+ const identity = readNpmArchiveIdentity(path);
69
+ if (identity.version !== version) {
70
+ throw new Error(`${identity.name} carries ${identity.version}, expected ${version}`);
71
+ }
72
+ return [
73
+ {
74
+ kind: "npm",
75
+ packageName: identity.name,
76
+ packageVersion: identity.version,
77
+ path,
78
+ role: role(path),
79
+ },
80
+ ];
81
+ }
82
+ if (
83
+ path.endsWith(".whl") ||
84
+ (path.endsWith(".tar.gz") &&
85
+ (normalized.includes("/python/") || normalized.includes("/python-workspace/")))
86
+ ) {
87
+ return [
88
+ {
89
+ kind: "pypi",
90
+ packageName: pythonPackageName(path, version),
91
+ packageVersion: version,
92
+ path,
93
+ role: role(path),
94
+ },
95
+ ];
96
+ }
97
+ if (path.endsWith(".zip") || path.endsWith(".tar.gz")) {
98
+ return [{ kind: "binary", path }];
99
+ }
100
+ return [];
101
+ });
102
+ }
103
+
104
+ function pruneDraftAssets(
105
+ root: string,
106
+ tag: string,
107
+ desired: ReadonlySet<string>,
108
+ env: NodeJS.ProcessEnv,
109
+ ): void {
110
+ const current = captureTaskCommand(
111
+ root,
112
+ "gh",
113
+ ["release", "view", tag, "--json", "assets", "--jq", ".assets[].name"],
114
+ { env, check: true, stderr: "inherit" },
115
+ )
116
+ .split(/\r?\n/)
117
+ .filter(Boolean);
118
+ for (const name of current) {
119
+ if (!desired.has(name)) {
120
+ runTaskCommand(root, "gh", ["release", "delete-asset", tag, name, "--yes"], { env });
121
+ }
122
+ }
123
+ }
124
+
125
+ function rustReleaseConfiguration(root: string): RustReleaseConfiguration | undefined {
126
+ const path = join(root, ".projen/rust-release.json");
127
+ return existsSync(path)
128
+ ? (json.parse(readFileSync(path, "utf8")) as RustReleaseConfiguration)
129
+ : undefined;
130
+ }
131
+
132
+ function buildFacades(root: string, version: string): void {
133
+ const configuration = rustReleaseConfiguration(root);
134
+ if (!configuration) return;
135
+ for (const binding of configuration.bindings) {
136
+ if (!binding.node || !binding.nodePackage) continue;
137
+ runTaskCommand(root, "node", [
138
+ ".projen/uniffi-release.mjs",
139
+ "facade",
140
+ "--node",
141
+ binding.node,
142
+ "--node-package",
143
+ binding.nodePackage,
144
+ "--node-triple",
145
+ "linux-x64-gnu",
146
+ "--version",
147
+ version,
148
+ "--output",
149
+ `dist/release/${binding.crate}/facade`,
150
+ ]);
151
+ }
152
+ }
153
+
154
+ function ensureDraftRelease(options: {
155
+ readonly root: string;
156
+ readonly sha: string;
157
+ readonly tag: string;
158
+ readonly notesFile?: string;
159
+ readonly env: NodeJS.ProcessEnv;
160
+ }): void {
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) {
169
+ throw new Error(`Release tag ${options.tag} does not point to ${options.sha}`);
170
+ }
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
+ ) {
178
+ throw new Error(`Release tag ${options.tag} must be annotated`);
179
+ }
180
+ } else {
181
+ runTaskCommand(
182
+ options.root,
183
+ "git",
184
+ ["tag", "-a", options.tag, options.sha, "-m", options.tag],
185
+ { env: options.env },
186
+ );
187
+ }
188
+ const remoteTag = taskCommandSucceeds(
189
+ options.root,
190
+ "git",
191
+ ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${options.tag}`],
192
+ { env: options.env },
193
+ );
194
+ if (remoteTag) {
195
+ runTaskCommand(
196
+ options.root,
197
+ "git",
198
+ ["fetch", "--force", "origin", `+refs/tags/${options.tag}:refs/tags/${options.tag}`],
199
+ { env: options.env },
200
+ );
201
+ if (
202
+ captureTaskCommand(options.root, "git", ["rev-parse", `${options.tag}^{commit}`], {
203
+ env: options.env,
204
+ check: true,
205
+ stderr: "inherit",
206
+ }) !== options.sha
207
+ ) {
208
+ throw new Error(`Remote release tag ${options.tag} does not point to ${options.sha}`);
209
+ }
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
+ ) {
217
+ throw new Error(`Remote release tag ${options.tag} must be annotated`);
218
+ }
219
+ } else {
220
+ runTaskCommand(options.root, "git", ["push", "origin", `refs/tags/${options.tag}`], {
221
+ env: options.env,
222
+ });
223
+ }
224
+
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) {
234
+ throw new Error(`GitHub Release ${options.tag} is already published`);
235
+ }
236
+ if (options.notesFile) {
237
+ runTaskCommand(
238
+ options.root,
239
+ "gh",
240
+ ["release", "edit", options.tag, "--title", options.tag, "--notes-file", options.notesFile],
241
+ { env: options.env },
242
+ );
243
+ }
244
+ return;
245
+ }
246
+ const notes =
247
+ options.notesFile && existsSync(options.notesFile) ? ["--notes-file", options.notesFile] : [];
248
+ runTaskCommand(
249
+ options.root,
250
+ "gh",
251
+ ["release", "create", options.tag, "--draft", "--verify-tag", "--title", options.tag, ...notes],
252
+ { env: options.env },
253
+ );
254
+ }
255
+
256
+ /** Build the exact release candidate and optionally attach it to a draft release. */
257
+ export function buildReleaseCandidate(options: {
258
+ readonly root: string;
259
+ readonly pythonRoot: string;
260
+ readonly sha: string;
261
+ readonly tag: string;
262
+ readonly version: string;
263
+ readonly notesFile?: string;
264
+ readonly upload?: boolean;
265
+ readonly env?: NodeJS.ProcessEnv;
266
+ }): void {
267
+ const root = resolve(options.root);
268
+ const env = options.env ?? process.env;
269
+ if (
270
+ captureTaskCommand(root, "git", ["rev-parse", "HEAD"], {
271
+ env,
272
+ check: true,
273
+ stderr: "inherit",
274
+ }) !== options.sha
275
+ ) {
276
+ throw new Error(`Release candidate must be built from ${options.sha}`);
277
+ }
278
+ if (readFileSync(join(root, "VERSION"), "utf8").trim() !== options.version) {
279
+ throw new Error(`VERSION does not match ${options.version}`);
280
+ }
281
+ const status = captureTaskCommand(
282
+ root,
283
+ "git",
284
+ ["status", "--porcelain=v1", "--untracked-files=all"],
285
+ { env, check: true, stderr: "inherit" },
286
+ );
287
+ if (status) throw new Error("Release candidate source contains tracked changes");
288
+
289
+ rmSync(join(root, "dist/release"), { recursive: true, force: true });
290
+ if ((rustReleaseConfiguration(root)?.targets.length ?? 0) > 0) {
291
+ buildReleaseAssets({ root, version: options.version });
292
+ buildFacades(root, options.version);
293
+ }
294
+ const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
295
+ runTaskCommand(root, process.execPath, [
296
+ publishScript,
297
+ options.version,
298
+ "--output",
299
+ "dist/release/npm-workspace",
300
+ ]);
301
+ const pythonRoot = resolve(root, options.pythonRoot);
302
+ if (existsSync(pythonRoot)) {
303
+ buildPythonProjects({
304
+ allowEmpty: true,
305
+ excludeUniFFI: true,
306
+ output: join(root, "dist/release/python-workspace"),
307
+ root: pythonRoot,
308
+ version: options.version,
309
+ });
310
+ }
311
+ const uploadDirectory = join(root, "dist/release/upload");
312
+ const manifest = writeReleaseManifest({
313
+ artifacts: releaseArtifacts(root, options.version),
314
+ directory: uploadDirectory,
315
+ gitSha: options.sha,
316
+ tag: options.tag,
317
+ version: options.version,
318
+ });
319
+ logger.success("built release candidate", {
320
+ artifacts: manifest.artifacts.length,
321
+ sha: options.sha,
322
+ tag: options.tag,
323
+ });
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
+ });
346
+ const notesFile = join(root, "dist/release/release-notes.md");
347
+ const summary =
348
+ options.notesFile && existsSync(resolve(root, options.notesFile))
349
+ ? readFileSync(resolve(root, options.notesFile), "utf8").trim()
350
+ : "";
351
+ writeFileSync(
352
+ notesFile,
353
+ [
354
+ `Release commit: \`${options.sha}\``,
355
+ "",
356
+ "Candidate manifest: `release-manifest.json`",
357
+ "",
358
+ "Candidate checksums: `SHA256SUMS`",
359
+ ...(summary ? ["", summary] : []),
360
+ "",
361
+ ].join("\n"),
362
+ );
363
+ ensureDraftRelease({
364
+ root,
365
+ sha: options.sha,
366
+ tag: options.tag,
367
+ notesFile,
368
+ env,
369
+ });
370
+ const uploadFiles = files(uploadDirectory).sort((left, right) => {
371
+ const priority = (path: string): number =>
372
+ basename(path) === "release-manifest.json" ? 2 : basename(path) === "SHA256SUMS" ? 1 : 0;
373
+ return priority(left) - priority(right) || left.localeCompare(right);
374
+ });
375
+ pruneDraftAssets(root, options.tag, new Set(uploadFiles.map((path) => basename(path))), env);
376
+ runTaskCommand(root, "gh", ["release", "upload", options.tag, ...uploadFiles, "--clobber"], {
377
+ env,
378
+ });
379
+ logger.success(`uploaded draft release candidate ${options.tag}`);
380
+ }
381
+
382
+ if (import.meta.main) {
383
+ new Command()
384
+ .requiredOption("--version <version>", "release version")
385
+ .requiredOption("--tag <tag>", "annotated release tag")
386
+ .requiredOption("--sha <sha>", "exact release commit")
387
+ .option("--root <path>", "repository root")
388
+ .option("--python-root <path>", "Python package root", "packages/py")
389
+ .option("--notes-file <path>", "draft release notes file")
390
+ .option("--upload", "create or update the draft GitHub Release after building")
391
+ .option("--upload-existing", "upload the existing verified candidate without rebuilding")
392
+ .action(
393
+ (options: {
394
+ version: string;
395
+ tag: string;
396
+ sha: string;
397
+ root?: string;
398
+ pythonRoot: string;
399
+ notesFile?: string;
400
+ upload?: boolean;
401
+ uploadExisting?: boolean;
402
+ }) => {
403
+ const candidateOptions = {
404
+ root: options.root ?? projectUtils.root() ?? process.cwd(),
405
+ pythonRoot: options.pythonRoot,
406
+ sha: options.sha,
407
+ tag: options.tag,
408
+ version: options.version,
409
+ ...(options.notesFile ? { notesFile: options.notesFile } : {}),
410
+ };
411
+ if (options.uploadExisting) uploadReleaseCandidate(candidateOptions);
412
+ else buildReleaseCandidate({ ...candidateOptions, upload: options.upload });
413
+ },
414
+ )
415
+ .parse();
416
+ }