@dbx-tools/projen 0.9.18 → 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.
package/tasks/publish.ts CHANGED
@@ -55,7 +55,9 @@
55
55
  */
56
56
  import {
57
57
  chmodSync,
58
+ copyFileSync,
58
59
  existsSync,
60
+ mkdirSync,
59
61
  mkdtempSync,
60
62
  readFileSync,
61
63
  rmSync,
@@ -234,12 +236,14 @@ const plan =
234
236
  : undefined;
235
237
  if (!version && !plan) {
236
238
  logger.error(
237
- "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]",
239
+ "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--output <dir>] [--exclude <dir>] [--dry-run] [--skip-compile]",
238
240
  );
239
241
  process.exit(1);
240
242
  }
241
243
  const registryIdx = rest.indexOf("--registry");
242
244
  const registry = registryIdx >= 0 ? rest[registryIdx + 1] : undefined;
245
+ const outputIdx = rest.indexOf("--output");
246
+ const output = outputIdx >= 0 ? resolve(root, rest[outputIdx + 1]) : undefined;
243
247
  const dryRun = rest.includes("--dry-run");
244
248
  const skipCompile = rest.includes("--skip-compile");
245
249
  const concurrencyIdx = rest.indexOf("--concurrency");
@@ -371,8 +375,12 @@ for (const { dir } of publishable) {
371
375
  }
372
376
 
373
377
  logger.info(
374
- `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
378
+ `${output ? "packing" : dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
375
379
  );
380
+ if (output) {
381
+ rmSync(output, { recursive: true, force: true });
382
+ mkdirSync(output, { recursive: true });
383
+ }
376
384
  await asyncUtils.mapConcurrent(
377
385
  publishable,
378
386
  async ({ dir, name, version: packageVersion, access }) => {
@@ -390,6 +398,15 @@ await asyncUtils.mapConcurrent(
390
398
  `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
391
399
  );
392
400
  }
401
+ if (output) {
402
+ const destination = join(output, archive.split(/[\\/]/).at(-1)!);
403
+ if (existsSync(destination)) {
404
+ throw new Error(`Duplicate npm archive name: ${destination}`);
405
+ }
406
+ copyFileSync(archive, destination);
407
+ logger.info(`packed ${name} @ ${packageVersion}`);
408
+ return;
409
+ }
393
410
  if (!dryRun) {
394
411
  const published = await publishedNpmRelease(local.name, local.version, registry);
395
412
  if (npmReleaseMatches(local, published)) {
@@ -410,4 +427,6 @@ await asyncUtils.mapConcurrent(
410
427
  },
411
428
  { concurrency, errorMode: "settle" },
412
429
  );
413
- logger.success(`${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages`);
430
+ logger.success(
431
+ `${output || dryRun ? "packed" : "published"} ${publishable.length} packages${output ? ` to ${output}` : ""}`,
432
+ );
@@ -0,0 +1,383 @@
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 exec from "@dbx-tools/core/exec";
8
+ import * as projectUtils from "@dbx-tools/core/project-utils";
9
+ import { json, log } from "@dbx-tools/shared-core";
10
+ import { Command } from "commander";
11
+ import type { RustReleaseConfiguration } from "../src/_rust-release-workflow.ts";
12
+ import { buildPythonProjects } from "./publish-python.ts";
13
+ import { readNpmArchiveIdentity } from "./publish-npm.ts";
14
+ import { buildReleaseAssets } from "./release-assets.ts";
15
+ import {
16
+ type ReleaseArtifactInput,
17
+ type ReleaseArtifactRole,
18
+ writeReleaseManifest,
19
+ } from "./release-manifest.ts";
20
+
21
+ const logger = log.logger("projen:release-candidate");
22
+
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
+ function files(directory: string): string[] {
53
+ if (!existsSync(directory)) return [];
54
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
55
+ const path = join(directory, entry.name);
56
+ return entry.isDirectory() ? files(path) : [path];
57
+ });
58
+ }
59
+
60
+ function pythonPackageName(path: string, version: string): string {
61
+ const name = basename(path);
62
+ const marker = `-${version}`;
63
+ const index = name.indexOf(marker);
64
+ if (index <= 0) throw new Error(`Cannot resolve Python package name from ${name}`);
65
+ return name.slice(0, index).replace(/[_.]+/g, "-").toLowerCase();
66
+ }
67
+
68
+ function role(path: string): ReleaseArtifactRole {
69
+ const normalized = path.split("\\").join("/");
70
+ if (normalized.includes("/npm-facade/")) return "facade";
71
+ if (normalized.includes("/npm-workspace/") || normalized.includes("/python-workspace/")) {
72
+ return "workspace";
73
+ }
74
+ return "native";
75
+ }
76
+
77
+ function releaseArtifacts(root: string, version: string): ReleaseArtifactInput[] {
78
+ const releaseRoot = join(root, "dist/release");
79
+ return files(releaseRoot)
80
+ .filter((path) => !path.includes(`${join(releaseRoot, "upload")}/`))
81
+ .flatMap((path): ReleaseArtifactInput[] => {
82
+ const normalized = path.split("\\").join("/");
83
+ if (path.endsWith(".tgz")) {
84
+ const identity = readNpmArchiveIdentity(path);
85
+ if (identity.version !== version) {
86
+ throw new Error(`${identity.name} carries ${identity.version}, expected ${version}`);
87
+ }
88
+ return [
89
+ {
90
+ kind: "npm",
91
+ packageName: identity.name,
92
+ packageVersion: identity.version,
93
+ path,
94
+ role: role(path),
95
+ },
96
+ ];
97
+ }
98
+ if (
99
+ path.endsWith(".whl") ||
100
+ (path.endsWith(".tar.gz") &&
101
+ (normalized.includes("/python/") || normalized.includes("/python-workspace/")))
102
+ ) {
103
+ return [
104
+ {
105
+ kind: "pypi",
106
+ packageName: pythonPackageName(path, version),
107
+ packageVersion: version,
108
+ path,
109
+ role: role(path),
110
+ },
111
+ ];
112
+ }
113
+ if (path.endsWith(".zip") || path.endsWith(".tar.gz")) {
114
+ return [{ kind: "binary", path }];
115
+ }
116
+ return [];
117
+ });
118
+ }
119
+
120
+ function pruneDraftAssets(
121
+ root: string,
122
+ tag: string,
123
+ desired: ReadonlySet<string>,
124
+ env: NodeJS.ProcessEnv,
125
+ ): void {
126
+ const current = capture(
127
+ root,
128
+ "gh",
129
+ ["release", "view", tag, "--json", "assets", "--jq", ".assets[].name"],
130
+ env,
131
+ )
132
+ .split(/\r?\n/)
133
+ .filter(Boolean);
134
+ for (const name of current) {
135
+ if (!desired.has(name)) {
136
+ run(root, "gh", ["release", "delete-asset", tag, name, "--yes"], env);
137
+ }
138
+ }
139
+ }
140
+
141
+ function rustReleaseConfiguration(root: string): RustReleaseConfiguration | undefined {
142
+ const path = join(root, ".projen/rust-release.json");
143
+ return existsSync(path)
144
+ ? (json.parse(readFileSync(path, "utf8")) as RustReleaseConfiguration)
145
+ : undefined;
146
+ }
147
+
148
+ function buildFacades(root: string, version: string): void {
149
+ const configuration = rustReleaseConfiguration(root);
150
+ if (!configuration) return;
151
+ for (const binding of configuration.bindings) {
152
+ if (!binding.node || !binding.nodePackage) continue;
153
+ run(root, "node", [
154
+ ".projen/uniffi-release.mjs",
155
+ "facade",
156
+ "--node",
157
+ binding.node,
158
+ "--node-package",
159
+ binding.nodePackage,
160
+ "--node-triple",
161
+ "linux-x64-gnu",
162
+ "--version",
163
+ version,
164
+ "--output",
165
+ `dist/release/${binding.crate}/facade`,
166
+ ]);
167
+ }
168
+ }
169
+
170
+ function ensureDraftRelease(options: {
171
+ readonly root: string;
172
+ readonly sha: string;
173
+ readonly tag: string;
174
+ readonly notesFile?: string;
175
+ readonly env: NodeJS.ProcessEnv;
176
+ }): 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) {
187
+ throw new Error(`Release tag ${options.tag} does not point to ${options.sha}`);
188
+ }
189
+ if (capture(options.root, "git", ["cat-file", "-t", options.tag], options.env) !== "tag") {
190
+ throw new Error(`Release tag ${options.tag} must be annotated`);
191
+ }
192
+ } else {
193
+ run(
194
+ options.root,
195
+ "git",
196
+ ["tag", "-a", options.tag, options.sha, "-m", options.tag],
197
+ options.env,
198
+ );
199
+ }
200
+ const remoteTag = exec.spawnSync(
201
+ "git",
202
+ ["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
+ },
211
+ );
212
+ if (remoteTag.exitCode === 0) {
213
+ run(
214
+ options.root,
215
+ "git",
216
+ ["fetch", "--force", "origin", `+refs/tags/${options.tag}:refs/tags/${options.tag}`],
217
+ options.env,
218
+ );
219
+ if (
220
+ capture(options.root, "git", ["rev-parse", `${options.tag}^{commit}`], options.env) !==
221
+ options.sha
222
+ ) {
223
+ throw new Error(`Remote release tag ${options.tag} does not point to ${options.sha}`);
224
+ }
225
+ if (capture(options.root, "git", ["cat-file", "-t", options.tag], options.env) !== "tag") {
226
+ throw new Error(`Remote release tag ${options.tag} must be annotated`);
227
+ }
228
+ } else {
229
+ run(options.root, "git", ["push", "origin", `refs/tags/${options.tag}`], options.env);
230
+ }
231
+
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)
243
+ throw new Error(`GitHub Release ${options.tag} is already published`);
244
+ if (options.notesFile) {
245
+ run(
246
+ options.root,
247
+ "gh",
248
+ ["release", "edit", options.tag, "--title", options.tag, "--notes-file", options.notesFile],
249
+ options.env,
250
+ );
251
+ }
252
+ return;
253
+ }
254
+ const notes =
255
+ options.notesFile && existsSync(options.notesFile) ? ["--notes-file", options.notesFile] : [];
256
+ run(
257
+ options.root,
258
+ "gh",
259
+ ["release", "create", options.tag, "--draft", "--verify-tag", "--title", options.tag, ...notes],
260
+ options.env,
261
+ );
262
+ }
263
+
264
+ /** Build the exact release candidate and optionally attach it to a draft release. */
265
+ export function buildReleaseCandidate(options: {
266
+ readonly root: string;
267
+ readonly sha: string;
268
+ readonly tag: string;
269
+ readonly version: string;
270
+ readonly notesFile?: string;
271
+ readonly upload?: boolean;
272
+ readonly env?: NodeJS.ProcessEnv;
273
+ }): void {
274
+ const root = resolve(options.root);
275
+ const env = options.env ?? process.env;
276
+ if (capture(root, "git", ["rev-parse", "HEAD"], env) !== options.sha) {
277
+ throw new Error(`Release candidate must be built from ${options.sha}`);
278
+ }
279
+ if (readFileSync(join(root, "VERSION"), "utf8").trim() !== options.version) {
280
+ throw new Error(`VERSION does not match ${options.version}`);
281
+ }
282
+ const status = capture(root, "git", ["status", "--porcelain=v1", "--untracked-files=all"], env);
283
+ if (status) throw new Error("Release candidate source contains tracked changes");
284
+
285
+ rmSync(join(root, "dist/release"), { recursive: true, force: true });
286
+ if ((rustReleaseConfiguration(root)?.targets.length ?? 0) > 0) {
287
+ buildReleaseAssets({ root, version: options.version });
288
+ buildFacades(root, options.version);
289
+ }
290
+ const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
291
+ run(root, process.execPath, [
292
+ publishScript,
293
+ options.version,
294
+ "--output",
295
+ "dist/release/npm-workspace",
296
+ ]);
297
+ const pythonRoot = join(root, "packages/py");
298
+ if (existsSync(pythonRoot)) {
299
+ buildPythonProjects({
300
+ allowEmpty: true,
301
+ excludeUniFFI: true,
302
+ output: join(root, "dist/release/python-workspace"),
303
+ root: pythonRoot,
304
+ version: options.version,
305
+ });
306
+ }
307
+ const uploadDirectory = join(root, "dist/release/upload");
308
+ const manifest = writeReleaseManifest({
309
+ artifacts: releaseArtifacts(root, options.version),
310
+ directory: uploadDirectory,
311
+ gitSha: options.sha,
312
+ tag: options.tag,
313
+ version: options.version,
314
+ });
315
+ logger.success("built release candidate", {
316
+ artifacts: manifest.artifacts.length,
317
+ sha: options.sha,
318
+ tag: options.tag,
319
+ });
320
+ if (!options.upload) return;
321
+ const notesFile = join(root, "dist/release/release-notes.md");
322
+ const summary =
323
+ options.notesFile && existsSync(resolve(root, options.notesFile))
324
+ ? readFileSync(resolve(root, options.notesFile), "utf8").trim()
325
+ : "";
326
+ writeFileSync(
327
+ notesFile,
328
+ [
329
+ `Release commit: \`${options.sha}\``,
330
+ "",
331
+ "Candidate manifest: `release-manifest.json`",
332
+ "",
333
+ "Candidate checksums: `SHA256SUMS`",
334
+ ...(summary ? ["", summary] : []),
335
+ "",
336
+ ].join("\n"),
337
+ );
338
+ ensureDraftRelease({
339
+ root,
340
+ sha: options.sha,
341
+ tag: options.tag,
342
+ notesFile,
343
+ env,
344
+ });
345
+ const uploadFiles = files(uploadDirectory).sort((left, right) => {
346
+ const priority = (path: string): number =>
347
+ basename(path) === "release-manifest.json" ? 2 : basename(path) === "SHA256SUMS" ? 1 : 0;
348
+ return priority(left) - priority(right) || left.localeCompare(right);
349
+ });
350
+ pruneDraftAssets(root, options.tag, new Set(uploadFiles.map((path) => basename(path))), env);
351
+ run(root, "gh", ["release", "upload", options.tag, ...uploadFiles, "--clobber"], env);
352
+ logger.success(`uploaded draft release candidate ${options.tag}`);
353
+ }
354
+
355
+ if (import.meta.main) {
356
+ new Command()
357
+ .requiredOption("--version <version>", "release version")
358
+ .requiredOption("--tag <tag>", "annotated release tag")
359
+ .requiredOption("--sha <sha>", "exact release commit")
360
+ .option("--root <path>", "repository root")
361
+ .option("--notes-file <path>", "draft release notes file")
362
+ .option("--upload", "create or update the draft GitHub Release")
363
+ .action(
364
+ (options: {
365
+ version: string;
366
+ tag: string;
367
+ sha: string;
368
+ root?: string;
369
+ notesFile?: string;
370
+ upload?: boolean;
371
+ }) => {
372
+ buildReleaseCandidate({
373
+ root: options.root ?? projectUtils.root() ?? process.cwd(),
374
+ sha: options.sha,
375
+ tag: options.tag,
376
+ version: options.version,
377
+ ...(options.notesFile ? { notesFile: options.notesFile } : {}),
378
+ upload: options.upload,
379
+ });
380
+ },
381
+ )
382
+ .parse();
383
+ }