@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
@@ -1,7 +1,17 @@
1
1
  #!/usr/bin/env -S bun
2
- import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
- import { arch, platform } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
2
+ import {
3
+ cpSync,
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { arch, platform, tmpdir } from "node:os";
14
+ import { dirname, join, relative, resolve } from "node:path";
5
15
  import { fileURLToPath } from "node:url";
6
16
  import { parseArgs } from "node:util";
7
17
  import { json, log, object } from "@dbx-tools/shared-core";
@@ -12,33 +22,21 @@ import type { RustBindingMapping, RustWorkspaceMapping } from "../src/project-rs
12
22
 
13
23
  const logger = log.logger("projen:publish-uniffi-local");
14
24
 
15
- const parsed = parseArgs({
16
- options: {
17
- version: { type: "string" },
18
- registry: { type: "string" },
19
- "pypi-publish-url": { type: "string" },
20
- "cargo-registry": { type: "string" },
21
- },
22
- });
23
-
24
- function run(command: string, args: string[], capture = false): string {
25
- if (capture) return captureTaskCommand(repoRoot, command, args, { check: true });
26
- runTaskCommand(repoRoot, command, args);
27
- return "";
28
- }
29
-
30
25
  function commandAvailable(command: string): boolean {
31
26
  return taskCommandSucceeds(repoRoot, command, ["--version"]);
32
27
  }
33
28
 
34
29
  function rustHost(): string {
35
- const host = run("rustc", ["-vV"], true).match(/^host:\s+(.+)$/m)?.[1];
30
+ const host = captureTaskCommand(repoRoot, "rustc", ["-vV"], { check: true }).match(
31
+ /^host:\s+(.+)$/m,
32
+ )?.[1];
36
33
  if (!host) throw new Error("Unable to detect the local Rust target from rustc -vV");
37
34
  return host;
38
35
  }
39
36
 
40
37
  function pythonTag(): string {
41
- const value = run(
38
+ const value = captureTaskCommand(
39
+ repoRoot,
42
40
  "uv",
43
41
  [
44
42
  "run",
@@ -47,7 +45,7 @@ function pythonTag(): string {
47
45
  "-c",
48
46
  "import sysconfig; print(sysconfig.get_platform().replace('-', '_').replace('.', '_'))",
49
47
  ],
50
- true,
48
+ { check: true },
51
49
  );
52
50
  if (!value) throw new Error("Unable to detect the local Python wheel platform tag");
53
51
  return value;
@@ -99,12 +97,18 @@ interface CargoMetadataPackage {
99
97
  dependencies: Array<{ name: string; source?: string | null }>;
100
98
  manifest_path: string;
101
99
  name: string;
100
+ version: string;
102
101
  }
103
102
 
104
- function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
105
- const configured = new Set(config.crates.map((crate) => resolve(repoRoot, crate, "Cargo.toml")));
103
+ function orderedCargoManifests(root: string, config: RustWorkspaceMapping): CargoMetadataPackage[] {
104
+ const configured = new Set(config.crates.map((crate) => resolve(root, crate, "Cargo.toml")));
106
105
  const metadata = json.parseRecord(
107
- run("cargo", ["metadata", "--format-version", "1", "--no-deps", "--locked"], true),
106
+ captureTaskCommand(
107
+ root,
108
+ "cargo",
109
+ ["metadata", "--format-version", "1", "--no-deps", "--locked"],
110
+ { check: true },
111
+ ),
108
112
  );
109
113
  if (!metadata || !Array.isArray(metadata.packages)) {
110
114
  throw new Error("Cargo metadata did not return workspace packages");
@@ -112,6 +116,7 @@ function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
112
116
  const packages = metadata.packages.filter(object.isRecord).map((pkg) => {
113
117
  if (
114
118
  typeof pkg.name !== "string" ||
119
+ typeof pkg.version !== "string" ||
115
120
  typeof pkg.manifest_path !== "string" ||
116
121
  !Array.isArray(pkg.dependencies)
117
122
  ) {
@@ -119,6 +124,7 @@ function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
119
124
  }
120
125
  return {
121
126
  name: pkg.name,
127
+ version: pkg.version,
122
128
  manifest_path: resolve(pkg.manifest_path),
123
129
  dependencies: pkg.dependencies.filter(object.isRecord).map((dependency) => {
124
130
  if (typeof dependency.name !== "string") {
@@ -161,79 +167,103 @@ function orderedCargoManifests(config: RustWorkspaceMapping): string[] {
161
167
  ordered.push(pkg);
162
168
  };
163
169
  for (const pkg of publishable.values()) visit(pkg);
164
- return ordered.map((pkg) => pkg.manifest_path);
170
+ return ordered;
171
+ }
172
+
173
+ function copyCargoWorkspace(config: RustWorkspaceMapping): string {
174
+ const root = mkdtempSync(join(tmpdir(), "dbx-tools-local-cargo-"));
175
+ for (const path of ["Cargo.toml", "Cargo.lock", "LICENSE", "README.md", "rust-toolchain.toml"]) {
176
+ const source = join(repoRoot, path);
177
+ if (!existsSync(source)) continue;
178
+ cpSync(source, join(root, path));
179
+ }
180
+ const cargoConfig = join(repoRoot, ".cargo");
181
+ if (existsSync(cargoConfig)) cpSync(cargoConfig, join(root, ".cargo"), { recursive: true });
182
+ for (const crate of config.crates) {
183
+ const source = resolve(repoRoot, crate);
184
+ const destination = resolve(root, relative(repoRoot, source));
185
+ mkdirSync(dirname(destination), { recursive: true });
186
+ cpSync(source, destination, { recursive: true });
187
+ }
188
+ return root;
189
+ }
190
+
191
+ export function projectCargoRegistry(
192
+ document: Record<string, unknown>,
193
+ crateNames: ReadonlySet<string>,
194
+ registry: string,
195
+ ): void {
196
+ const sections: unknown[] = [
197
+ document.dependencies,
198
+ document["dev-dependencies"],
199
+ document["build-dependencies"],
200
+ ];
201
+ const workspace = document.workspace;
202
+ if (workspace && typeof workspace === "object" && !Array.isArray(workspace)) {
203
+ sections.push((workspace as Record<string, unknown>).dependencies);
204
+ }
205
+ for (const value of sections) {
206
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
207
+ const section = value as Record<string, unknown>;
208
+ for (const crateName of crateNames) {
209
+ const dependency = section[crateName];
210
+ if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) continue;
211
+ const record = dependency as Record<string, unknown>;
212
+ if (record.workspace === true) continue;
213
+ record.registry = registry;
214
+ }
215
+ }
165
216
  }
166
217
 
167
218
  function publishCargo(config: RustWorkspaceMapping, registry: string, version: string): void {
168
- const manifests = orderedCargoManifests(config);
169
- const originals = new Map(
170
- manifests.map((manifest) => [manifest, readFileSync(manifest, "utf8")]),
171
- );
219
+ const root = copyCargoWorkspace(config);
172
220
  try {
173
- const crateNames = manifests.map((manifest) => {
174
- const document = parse(readFileSync(manifest, "utf8")) as {
175
- package?: { name?: string };
176
- };
177
- if (!document.package?.name) {
178
- throw new Error(`Cargo manifest has no package name: ${manifest}`);
221
+ const packages = orderedCargoManifests(root, config);
222
+ const crateNames = new Set(packages.map((pkg) => pkg.name));
223
+ for (const pkg of packages) {
224
+ if (pkg.version !== version) {
225
+ throw new Error(`${pkg.name} version ${pkg.version} does not match release ${version}`);
179
226
  }
180
- return document.package.name;
181
- });
227
+ }
228
+ const manifests = [join(root, "Cargo.toml"), ...packages.map((pkg) => pkg.manifest_path)];
182
229
  for (const manifest of manifests) {
183
- const mode = statSync(manifest).mode;
184
230
  const document = parse(readFileSync(manifest, "utf8")) as Record<string, unknown>;
185
- for (const crateName of crateNames) {
186
- if (!crateName) continue;
187
- for (const sectionName of ["dependencies", "dev-dependencies", "build-dependencies"]) {
188
- const section = document[sectionName];
189
- if (!section || typeof section !== "object" || Array.isArray(section)) continue;
190
- const dependency = (section as Record<string, unknown>)[crateName];
191
- if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) {
192
- continue;
193
- }
194
- (dependency as Record<string, unknown>).registry = registry;
195
- }
196
- }
197
- chmodSync(manifest, mode | 0o200);
231
+ projectCargoRegistry(document, crateNames, registry);
232
+ chmodSync(manifest, 0o644);
198
233
  writeFileSync(manifest, `${stringify(document).trimEnd()}\n`);
199
- chmodSync(manifest, mode);
200
234
  }
201
- for (const crateName of crateNames) {
202
- if (!crateName) continue;
203
- if (cargoVersionExists(crateName, version, registry)) {
204
- logger.info(`skip published ${crateName} @ ${version}`);
235
+ for (const pkg of packages) {
236
+ if (cargoVersionExists(pkg.name, version, registry)) {
237
+ logger.info(`skip published ${pkg.name} @ ${version}`);
205
238
  continue;
206
239
  }
207
- run("cargo", [
240
+ runTaskCommand(root, "cargo", [
208
241
  "publish",
209
- "--package",
210
- crateName,
242
+ "--manifest-path",
243
+ pkg.manifest_path,
211
244
  "--registry",
212
245
  registry,
213
- "--allow-dirty",
214
- "--no-verify",
246
+ "--locked",
215
247
  ]);
216
248
  }
217
249
  } finally {
218
- for (const [manifest, source] of originals) {
219
- const mode = statSync(manifest).mode;
220
- chmodSync(manifest, mode | 0o200);
221
- writeFileSync(manifest, source);
222
- chmodSync(manifest, mode);
223
- }
250
+ rmSync(root, { recursive: true, force: true });
224
251
  }
225
252
  }
226
253
 
227
- function buildAndPublish(binding: RustBindingMapping, version: string): void {
228
- const registry = parsed.values.registry;
229
- const pypiPublishUrl = parsed.values["pypi-publish-url"];
254
+ function buildAndPublish(
255
+ binding: RustBindingMapping,
256
+ version: string,
257
+ registry: string | undefined,
258
+ pypiPublishUrl: string | undefined,
259
+ ): void {
230
260
  const includeNode = Boolean(registry && binding.node && binding.nodePackage);
231
261
  const includePython = Boolean(pypiPublishUrl && binding.python && binding.pythonPackage);
232
262
  if (!includeNode && !includePython) return;
233
263
 
234
264
  const target = nativeTarget();
235
265
  const output = resolve(repoRoot, "dist/uniffi");
236
- run("node", [
266
+ runTaskCommand(repoRoot, "node", [
237
267
  resolve(dirname(fileURLToPath(import.meta.url)), "uniffi-release.mjs"),
238
268
  "build",
239
269
  "--crate",
@@ -272,7 +302,7 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
272
302
  const publishNpmScript = resolve(dirname(fileURLToPath(import.meta.url)), "publish-npm.ts");
273
303
  for (const directory of [join(output, "npm"), join(output, "npm-facade")]) {
274
304
  if (artifacts(directory, ".tgz").length === 0) continue;
275
- run(process.execPath, [
305
+ runTaskCommand(repoRoot, process.execPath, [
276
306
  publishNpmScript,
277
307
  "--directory",
278
308
  directory,
@@ -286,7 +316,7 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
286
316
  if (includePython) {
287
317
  const wheels = artifacts(join(output, "python"), ".whl");
288
318
  if (wheels.length === 0) throw new Error(`No wheel produced for ${binding.crate}`);
289
- run("uvx", [
319
+ runTaskCommand(repoRoot, "uvx", [
290
320
  "--from",
291
321
  "devpi-client",
292
322
  "devpi",
@@ -299,16 +329,31 @@ function buildAndPublish(binding: RustBindingMapping, version: string): void {
299
329
  }
300
330
  }
301
331
 
302
- const version = parsed.values.version;
303
- if (!version) throw new Error("Missing --version");
304
- const config = rustConfig();
305
- if (config?.bindings.length) {
306
- if (!commandAvailable("cargo") || !commandAvailable("rustc")) {
307
- throw new Error("Cargo and rustc are required because UniFFI Rust projects were detected");
332
+ function main(args: string[] = process.argv.slice(2)): void {
333
+ const parsed = parseArgs({
334
+ args,
335
+ options: {
336
+ version: { type: "string" },
337
+ registry: { type: "string" },
338
+ "pypi-publish-url": { type: "string" },
339
+ "cargo-registry": { type: "string" },
340
+ },
341
+ });
342
+ const version = parsed.values.version;
343
+ if (!version) throw new Error("Missing --version");
344
+ const config = rustConfig();
345
+ if (config?.bindings.length) {
346
+ if (!commandAvailable("cargo") || !commandAvailable("rustc")) {
347
+ throw new Error("Cargo and rustc are required because UniFFI Rust projects were detected");
348
+ }
349
+ for (const binding of config.bindings) {
350
+ buildAndPublish(binding, version, parsed.values.registry, parsed.values["pypi-publish-url"]);
351
+ }
352
+ }
353
+ const cargoRegistry = parsed.values["cargo-registry"];
354
+ if (cargoRegistry && config?.crates.length) {
355
+ publishCargo(config, cargoRegistry, version);
308
356
  }
309
- for (const binding of config.bindings) buildAndPublish(binding, version);
310
- }
311
- const cargoRegistry = parsed.values["cargo-registry"];
312
- if (cargoRegistry && config?.crates.length) {
313
- publishCargo(config, cargoRegistry, version);
314
357
  }
358
+
359
+ if (import.meta.main) main();
package/tasks/publish.ts CHANGED
@@ -18,14 +18,14 @@
18
18
  * version. When Bun's workspace lock already carries the version, its
19
19
  * refresh is skipped;
20
20
  * - **`publishConfig` substitution** (compiled `lib/` entry points) is done
21
- * HERE, by {@link applyPublishConfig}, NOT by bun: unlike pnpm/npm, `bun
21
+ * inside the temporary archive, NOT in the checkout: unlike pnpm/npm, `bun
22
22
  * publish`/`bun pm pack` do NOT fold `publishConfig`'s `main`/`types`/`bin`/
23
23
  * `exports` into the packed manifest (verified: the packed manifest keeps the
24
24
  * raw `.ts` source paths and an inert `publishConfig`). Left unsubstituted, a
25
25
  * published CLI's `bin` points at `./bin/x.ts`, and because the bin runs via
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
- * manifest before packing so the tarball advertises the compiled `lib/` tree;
28
+ * manifest after packing so the tarball advertises the compiled `lib/` tree;
29
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
31
  * Every package is then packed once with lifecycle scripts disabled. The
@@ -42,26 +42,10 @@
42
42
  * non-default registry (a local verdaccio); `--exclude <dir>` (repeatable,
43
43
  * repo-relative) skips a member owned by another publication flow.
44
44
  *
45
- * `--no-restore` keeps the transient publishConfig edits on disk instead of
46
- * undoing them at exit.
47
- *
48
- * The disk manifests carry the workspace `VERSION` (projen owns them, read-only);
49
- * this unlocks each only long enough to fold in the `publishConfig` entry points
50
- * and publish, then RESTORES every one it touched
51
- * byte-for-byte (and re-locks the mode) on the way out - see
52
- * {@link restoreManifests}. Restore returns each manifest to its committed
53
- * content, which already equals the release version, so the worktree is never
54
- * left regressed.
45
+ * The checkout remains byte-for-byte unchanged. A stale manifest or lockfile is
46
+ * a release error rather than something publication repairs.
55
47
  */
56
- import {
57
- chmodSync,
58
- existsSync,
59
- mkdtempSync,
60
- readFileSync,
61
- rmSync,
62
- statSync,
63
- writeFileSync,
64
- } from "node:fs";
48
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
65
49
  import { tmpdir } from "node:os";
66
50
  import { delimiter, dirname, join, resolve } from "node:path";
67
51
  import { asyncUtils, log } from "@dbx-tools/shared-core";
@@ -75,25 +59,9 @@ import {
75
59
  } from "./publish-npm.ts";
76
60
  import { runTaskCommand, runTaskCommandAsync } from "../src/_task-command.ts";
77
61
  import { toPosix } from "../src/packages.ts";
78
- import type { ReleasePlan } from "../src/release-plan.ts";
79
62
 
80
63
  const logger = log.logger("projen:publish");
81
64
 
82
- /** A manifest's on-disk bytes + mode, captured before this task edits it. */
83
- interface ManifestBackup {
84
- readonly content: string;
85
- readonly mode: number;
86
- }
87
-
88
- /**
89
- * Original state of every manifest this task has unlocked, keyed by path.
90
- *
91
- * Bun does not apply `publishConfig` while packing, so publication temporarily
92
- * projects those entry points onto projen-owned manifests. The projection is
93
- * only meant for the packed tarball and is restored before the process exits.
94
- */
95
- const manifestBackups = new Map<string, ManifestBackup>();
96
-
97
65
  /**
98
66
  * Workspace member dirs (absolute), read from the root `pnpm-workspace.yaml` - the
99
67
  * file the engine keeps for the Databricks Apps pnpm deploy, which also lists every
@@ -107,18 +75,13 @@ function workspaceMembers(root: string): string[] {
107
75
  return (doc?.packages ?? []).map((m) => resolve(root, m));
108
76
  }
109
77
 
110
- /** Whether selected workspace members carry their planned versions. */
111
- function manifestsMatchVersions(
112
- root: string,
113
- members: readonly string[],
114
- expected: ReadonlyMap<string, string>,
115
- ): boolean {
78
+ /** Whether selected workspace members carry the reviewed workspace version. */
79
+ function manifestsMatchVersion(members: readonly string[], version: string): boolean {
116
80
  return members.every((dir) => {
117
81
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
118
82
  version?: string;
119
83
  };
120
- const path = toPosix(dir.slice(resolve(root).length + 1));
121
- return pkg.version === expected.get(path);
84
+ return pkg.version === version;
122
85
  });
123
86
  }
124
87
 
@@ -144,41 +107,6 @@ function lockfileMatchesManifestVersions(root: string, members: readonly string[
144
107
  }
145
108
  }
146
109
 
147
- /**
148
- * Make a projen-readonly manifest writable so the publishConfig projection can
149
- * edit it, recording its original bytes and mode for {@link restoreManifests}
150
- * the first time it is seen.
151
- */
152
- function unlockManifest(pkgPath: string): void {
153
- const mode = statSync(pkgPath).mode;
154
- if (!manifestBackups.has(pkgPath)) {
155
- manifestBackups.set(pkgPath, { content: readFileSync(pkgPath, "utf8"), mode });
156
- }
157
- chmodSync(pkgPath, mode | 0o200);
158
- }
159
-
160
- /**
161
- * Put every manifest this task edited back exactly as it was found, mode included.
162
- *
163
- * Runs from an `exit` handler so a clean finish and a failure partway through the
164
- * publish loop are covered alike - a `bun publish` that dies on package 12 of 34
165
- * must not leave the first 11 projected. Best-effort per file: one unwritable
166
- * manifest is logged and the rest are still restored, since a partial restore
167
- * beats none.
168
- */
169
- function restoreManifests(): void {
170
- for (const [pkgPath, backup] of manifestBackups) {
171
- try {
172
- chmodSync(pkgPath, backup.mode | 0o200);
173
- writeFileSync(pkgPath, backup.content);
174
- chmodSync(pkgPath, backup.mode);
175
- } catch (cause) {
176
- logger.warn(`could not restore ${pkgPath}`, { cause });
177
- }
178
- }
179
- manifestBackups.clear();
180
- }
181
-
182
110
  /** Entry-point fields projen writes as `.ts` source in-repo and rewrites to `lib/` for publish. */
183
111
  const PUBLISH_CONFIG_ENTRY_FIELDS = ["main", "types", "bin", "exports"] as const;
184
112
 
@@ -193,23 +121,18 @@ function compiledPublishTargets(value: unknown): string[] {
193
121
  /**
194
122
  * Fold a package's `publishConfig` entry-point fields onto the top-level manifest,
195
123
  * the way pnpm/npm do at pack time but `bun publish` does NOT (see the module
196
- * doc). Idempotent, writes only when something changes, and leaves `publishConfig`
197
- * in place (npm ignores it once the top-level fields already point at `lib/`). The
198
- * manifest must already be unlocked. {@link restoreManifests} puts the `.ts` entry
199
- * points back at exit, so this only ever affects the packed tarball.
124
+ * doc). The input is the temporary archive manifest, never the checkout.
200
125
  */
201
- function applyPublishConfig(pkgPath: string): void {
202
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
126
+ export function applyPublishConfig(
127
+ source: Readonly<Record<string, unknown>>,
128
+ ): Readonly<Record<string, unknown>> {
129
+ const pkg = { ...source };
203
130
  const publishConfig = pkg.publishConfig as Record<string, unknown> | undefined;
204
- if (!publishConfig) return;
205
- let changed = false;
131
+ if (!publishConfig) return pkg;
206
132
  for (const field of PUBLISH_CONFIG_ENTRY_FIELDS) {
207
- if (field in publishConfig) {
208
- pkg[field] = publishConfig[field];
209
- changed = true;
210
- }
133
+ if (field in publishConfig) pkg[field] = publishConfig[field];
211
134
  }
212
- if (changed) writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
135
+ return pkg;
213
136
  }
214
137
 
215
138
  /**
@@ -227,19 +150,16 @@ const argv = process.argv.slice(2);
227
150
  const version = argv[0] && !argv[0].startsWith("--") ? argv[0] : undefined;
228
151
  const rest = version ? argv.slice(1) : argv;
229
152
  const root = process.cwd();
230
- const planIdx = rest.indexOf("--plan");
231
- const plan =
232
- planIdx >= 0
233
- ? (JSON.parse(readFileSync(resolve(root, rest[planIdx + 1]), "utf8")) as ReleasePlan)
234
- : undefined;
235
- if (!version && !plan) {
153
+ if (!version) {
236
154
  logger.error(
237
- "usage: bun tasks/publish.ts <version> [--plan <path>] [--registry <url>] [--exclude <dir>] [--dry-run] [--skip-compile]",
155
+ "usage: bun tasks/publish.ts <version> [--registry <url>] [--output <dir>] [--exclude <dir>] [--dry-run] [--skip-compile]",
238
156
  );
239
157
  process.exit(1);
240
158
  }
241
159
  const registryIdx = rest.indexOf("--registry");
242
160
  const registry = registryIdx >= 0 ? rest[registryIdx + 1] : undefined;
161
+ const outputIdx = rest.indexOf("--output");
162
+ const output = outputIdx >= 0 ? resolve(root, rest[outputIdx + 1]) : undefined;
243
163
  const dryRun = rest.includes("--dry-run");
244
164
  const skipCompile = rest.includes("--skip-compile");
245
165
  const concurrencyIdx = rest.indexOf("--concurrency");
@@ -248,46 +168,25 @@ if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
248
168
  throw new Error(`--concurrency must be a positive integer, got ${String(parsedConcurrency)}`);
249
169
  }
250
170
  const concurrency = parsedConcurrency;
251
- // Undo transient publishConfig edits at exit.
252
- const restore = !rest.includes("--no-restore");
253
171
  const excluded = new Set(
254
172
  rest.reduce<string[]>((acc, arg, i) => (arg === "--exclude" ? [...acc, rest[i + 1]] : acc), []),
255
173
  );
256
174
 
257
175
  const path = enrichedPath(root);
258
- // Registered BEFORE the first manifest edit so no exit path can skip it: a clean
259
- // finish and a `bun publish` failure mid-loop both land here, so a publish that
260
- // dies partway does not leave half the workspace stamped.
261
- if (restore) process.on("exit", restoreManifests);
262
176
  const allMembers = workspaceMembers(root)
263
177
  .filter((dir) => existsSync(join(dir, "package.json")))
264
178
  .filter((dir) => !excluded.has(resolve(root, dir).replace(`${resolve(root)}/`, "")));
265
- const expectedVersions = new Map(
266
- plan
267
- ? plan.nodePackages.map((pkg) => [pkg.path, pkg.version] as const)
268
- : allMembers.map((dir) => [toPosix(dir.slice(resolve(root).length + 1)), version!]),
269
- );
270
- const members = plan
271
- ? allMembers.filter((dir) => expectedVersions.has(toPosix(dir.slice(resolve(root).length + 1))))
272
- : allMembers;
179
+ const members = allMembers;
273
180
 
274
- if (!manifestsMatchVersions(root, members, expectedVersions)) {
275
- throw new Error("workspace manifests do not match the reviewed release plan; run projen");
181
+ if (!manifestsMatchVersion(members, version)) {
182
+ throw new Error(`workspace manifests do not match release ${version}; run projen`);
276
183
  }
277
184
  logger.info(`validated ${members.length} selected member manifests`);
278
185
 
279
- // Ensure the lockfile resolves each `workspace:*` to the release version.
280
- // `bun publish`/`pm pack` reads workspace versions from the LOCKFILE, not just
281
- // live manifests. A normal bump's synth/install already makes it current; after
282
- // fallback stamping, deleting it before install is what forces re-resolution.
283
- const lockfile = join(root, "bun.lock");
284
- if (lockfileMatchesManifestVersions(root, allMembers)) {
285
- logger.info("workspace lock already resolves live member versions");
286
- } else {
287
- if (existsSync(lockfile)) rmSync(lockfile);
288
- logger.info("refreshing lockfile so workspace deps resolve to the release version");
289
- runTaskCommand(root, "bun", ["install"], { env: { ...process.env, PATH: path } });
186
+ if (!lockfileMatchesManifestVersions(root, allMembers)) {
187
+ throw new Error("bun.lock does not match workspace versions; run bun install and commit it");
290
188
  }
189
+ logger.info("validated workspace lock versions");
291
190
 
292
191
  const publishArgs = [
293
192
  "--ignore-scripts",
@@ -361,24 +260,19 @@ if (compiled.length > 0) {
361
260
  }
362
261
  }
363
262
 
364
- // Compile against workspace source exports first. Switching manifests to their
365
- // publishConfig entries before compilation makes consumers resolve sibling
366
- // `lib/*.d.ts` files that have not been emitted yet in a clean checkout.
367
- for (const { dir } of publishable) {
368
- const manifestPath = join(dir, "package.json");
369
- unlockManifest(manifestPath);
370
- applyPublishConfig(manifestPath);
371
- }
372
-
373
263
  logger.info(
374
- `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
264
+ `${output ? "packing" : dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
375
265
  );
266
+ if (output) {
267
+ rmSync(output, { recursive: true, force: true });
268
+ mkdirSync(output, { recursive: true });
269
+ }
376
270
  await asyncUtils.mapConcurrent(
377
271
  publishable,
378
272
  async ({ dir, name, version: packageVersion, access }) => {
379
273
  const packed = mkdtempSync(join(tmpdir(), "projen-npm-release-"));
380
274
  try {
381
- const archive = packNpmPackage(dir, packed, path);
275
+ const archive = packNpmPackage(dir, packed, path, applyPublishConfig);
382
276
  const local = readNpmArchiveIdentity(archive);
383
277
  if (local.name !== name || local.version !== packageVersion) {
384
278
  throw new Error(
@@ -390,6 +284,15 @@ await asyncUtils.mapConcurrent(
390
284
  `Packed npm access ${String(local.access)} does not match ${String(access)} for ${name}`,
391
285
  );
392
286
  }
287
+ if (output) {
288
+ const destination = join(output, archive.split(/[\\/]/).at(-1)!);
289
+ if (existsSync(destination)) {
290
+ throw new Error(`Duplicate npm archive name: ${destination}`);
291
+ }
292
+ copyFileSync(archive, destination);
293
+ logger.info(`packed ${name} @ ${packageVersion}`);
294
+ return;
295
+ }
393
296
  if (!dryRun) {
394
297
  const published = await publishedNpmRelease(local.name, local.version, registry);
395
298
  if (npmReleaseMatches(local, published)) {
@@ -410,4 +313,6 @@ await asyncUtils.mapConcurrent(
410
313
  },
411
314
  { concurrency, errorMode: "settle" },
412
315
  );
413
- logger.success(`${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages`);
316
+ logger.success(
317
+ `${output || dryRun ? "packed" : "published"} ${publishable.length} packages${output ? ` to ${output}` : ""}`,
318
+ );
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import * as exec from "@dbx-tools/core/exec";
5
4
  import { net } from "@dbx-tools/shared-core";
5
+ import { captureTaskCommand } from "../src/_task-command.ts";
6
6
 
7
7
  export interface LocalPythonRegistry {
8
8
  readonly indexUrl: string;
@@ -126,14 +126,7 @@ function splitIndexList(value: string | undefined): string[] {
126
126
 
127
127
  /** Read a `pip config get <key>`, treating pip's literal "undefined" as unset. */
128
128
  function pipConfig(key: string): string | undefined {
129
- const res = exec.spawnSync("python", ["-m", "pip", "config", "get", key], {
130
- cwd: process.cwd(),
131
- stdout: "capture",
132
- stderr: "ignore",
133
- stdin: "ignore",
134
- check: false,
135
- });
136
- const out = res.stdout?.trim();
129
+ const out = captureTaskCommand(process.cwd(), "python", ["-m", "pip", "config", "get", key]);
137
130
  return out && out !== "undefined" ? out : undefined;
138
131
  }
139
132