@dbx-tools/projen 0.6.101 → 0.6.103

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/README.md CHANGED
@@ -251,23 +251,28 @@ Every repo-wide task lives on the root, and the root's `compile` / `test`
251
251
  delegate with `bun run --filter '*'` rather than emitting a step per member - so
252
252
  a new package is covered without a re-synth. Work from the root:
253
253
 
254
- | Task | What it does |
255
- | ----------------- | -------------------------------------------------- |
256
- | `bun run build` | `compile` + `test` + `package` across every member |
257
- | `bun run compile` | `tsc --build` in each member, in parallel |
258
- | `bun run test` | `eslint` once, then each member's tests |
259
- | `bun run sync` | re-synth (`--watch` to keep synthing) |
260
- | `bun run barrels` | regenerate the read-only `index.ts` barrels |
261
- | `bun run bump` | version, tag, and publish |
254
+ | Task | What it does |
255
+ | ----------------- | ----------------------------------------------------- |
256
+ | `bun run build` | workspace `compile` + `test`; no root package fan-out |
257
+ | `bun run compile` | `tsc --build` in each member, in parallel |
258
+ | `bun run test` | `eslint` once, then each member's tests |
259
+ | `bun run sync` | re-synth (`--watch` to keep synthing) |
260
+ | `bun run barrels` | regenerate the read-only `index.ts` barrels |
261
+ | `bun run bump` | version, tag, and publish |
262
262
 
263
263
  `bump` also mirrors a release into local registries when the active clients are
264
264
  pointed at loopback services. npm uses `npm config get registry` and publishes
265
- to a local Verdaccio automatically. Python prefers uv's default index and only
265
+ to a local Verdaccio automatically. Publishable JavaScript members compile once
266
+ from the root in parallel, then upload through a bounded pool without rerunning
267
+ their `prepack` tasks. When synth already made the manifests and Bun workspace
268
+ lock release-current, publishing also skips redundant version stamping and the
269
+ lockfile reinstall. Python prefers uv's default index and only
266
270
  treats a loopback `.../+simple/` URL as writable devpi; a read-only cache such as
267
271
  proxpi (`.../index/`) is deliberately ignored. The task stamps every Python
268
272
  member and its sibling dependencies to the release version, builds the workspace
269
273
  with uv, then runs `devpi upload --from-dir` against the derived writable index.
270
- Devpi client authentication remains in its normal `~/.devpi` state.
274
+ The npm and Python mirrors run concurrently because they touch disjoint package
275
+ trees. Devpi client authentication remains in its normal `~/.devpi` state.
271
276
 
272
277
  Use `--local-registry false` or `--local-pypi false` to disable either local
273
278
  publish. An explicit `--local-pypi http://localhost:3141/user/index/` overrides
@@ -282,14 +287,19 @@ Members intentionally keep only the tasks that something OTHER than a human
282
287
  invokes, so there is no second place to run the same thing:
283
288
 
284
289
  - `compile` / `test` - what the root's `--filter '*'` delegation calls.
285
- - `prepack` - what `bun publish` runs per package while packing a tarball
286
- (27 of 33 members; a package with nothing to pack has none).
290
+ - `prepack` - standalone-publish safety that compiles one package before packing
291
+ (27 of 33 members; the workspace release driver compiles them from the root
292
+ and publishes with lifecycle scripts disabled).
287
293
  - `watch` - a single-package `tsc --build -w`, for narrowing a long
288
294
  edit/compile loop to one package.
289
- - `build` / `install` / `install:ci` / `default` / `pre-compile` /
290
- `post-compile` / `package` - projen's own lifecycle scaffolding, emitted for
291
- every member because projen's task model expects them. Nothing in this repo's
292
- workflow calls them per member.
295
+ - `build` / `package` - a complete compile/test/pack lifecycle when invoked in
296
+ one package. Root bump deliberately bypasses these and uses filtered compile
297
+ plus concurrent `bun publish --ignore-scripts`. The package phase also packs
298
+ with `--ignore-scripts` because its build already compiled; `prepack` remains
299
+ available for a standalone publish that did not run `build` first.
300
+ - `install` / `install:ci` / `default` / `pre-compile` / `post-compile` -
301
+ projen's lifecycle scaffolding, emitted for every member because its task model
302
+ expects them.
293
303
 
294
304
  Do NOT run `projen default` (or `bunx projen`) from inside a member. Synth is a
295
305
  whole-tree operation driven by the ROOT `.projenrc.ts`, so a member-level run
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "0.6.101",
30
- "@dbx-tools/path": "0.6.101",
31
- "@dbx-tools/shared-core": "0.6.101",
29
+ "@dbx-tools/core": "0.6.103",
30
+ "@dbx-tools/path": "0.6.103",
31
+ "@dbx-tools/shared-core": "0.6.103",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.101",
50
+ "version": "0.6.103",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/project-js.ts CHANGED
@@ -380,13 +380,13 @@ function defaultProjectOptions(
380
380
  ...(isRoot ? {} : { npmAccess: javascript.NpmAccess.PUBLIC }),
381
381
  buildWorkflow: false,
382
382
  release: false,
383
- // No `npm pack` step on any project. projen wires `package` into `build`, so
384
- // `bunx projen build` would tarball all 36 manifests (root included) into
385
- // gitignored `dist/js` on every CI run and never read them: publishing here
386
- // is `bun publish` driving each package's own `prepack` (see
387
- // {@link applyCompiledPublish} and the `publish` task), and `release: false`
388
- // means no projen Publisher exists to consume the artifacts either.
389
- package: false,
383
+ // The root build validates the whole workspace and must not also pack every
384
+ // member into unused `dist/js` tarballs. Child projects keep projen's package
385
+ // task so `bun run build` in ONE package remains a complete compile/test/pack
386
+ // operation. The root bump does not invoke child builds: its publish driver
387
+ // compiles once with filtered root tasks and packs via
388
+ // `bun publish --ignore-scripts` (see {@link applyCompiledPublish}).
389
+ ...(isRoot ? { package: false } : {}),
390
390
  jest: false,
391
391
  github: false,
392
392
  npmignoreEnabled: false,
@@ -969,6 +969,15 @@ function initProject(
969
969
 
970
970
  if (project.parent) {
971
971
  project.package.file.readonly = true;
972
+ // `build` already compiles before its `package` phase. Keep the package's
973
+ // `prepack` for standalone `bun publish`, but suppress lifecycle scripts in
974
+ // this generated pack step so a direct child build does not compile twice.
975
+ const packageStep = project.packageTask.steps.find(
976
+ (step) => step.execArgs?.[0] === "npm" && step.execArgs[1] === "pack",
977
+ );
978
+ if (packageStep?.execArgs && !packageStep.execArgs.includes("--ignore-scripts")) {
979
+ packageStep.execArgs.push("--ignore-scripts");
980
+ }
972
981
  // Stamp `repository` (with this package's `directory` subpath) so a published
973
982
  // package passes npm provenance validation.
974
983
  applyRepository(project, options.repository);
package/src/release.ts CHANGED
@@ -18,12 +18,13 @@ const BUN_VERSION = "1.3.14";
18
18
  *
19
19
  * Bun has no `pnpm -r publish` equivalent, so this drives the engine's
20
20
  * `tasks/publish.ts` (shipped in the engine tarball, run via bun): it reads the
21
- * workspace members from the root `package.json`, stamps the tag version onto
22
- * every manifest (rewriting `@dbx-tools/*` `workspace:*` sibling deps to
23
- * `^<version>` so the tarballs resolve each other), then `bun publish`es each
24
- * non-`private` package. `bun publish` substitutes `publishConfig` (the compiled
25
- * `lib/` entry points) at pack time, runs `prepack` (compile) so `lib/` exists,
26
- * and honors `NPM_CONFIG_PROVENANCE`.
21
+ * workspace members from the root `package.json`, ensures manifests and the Bun
22
+ * lock carry the tag version so `workspace:*` siblings resolve to it, then
23
+ * `bun publish`es each non-`private` package. The driver compiles publishable members once from the
24
+ * root, then runs a bounded pool of `bun publish --ignore-scripts` calls so the
25
+ * already-built packages upload concurrently. It also folds `publishConfig`'s
26
+ * compiled `lib/` entry points into each packed manifest and honors
27
+ * `NPM_CONFIG_PROVENANCE`.
27
28
  *
28
29
  * Two ways in: a pushed `<prefix>*` tag (the real release - `GITHUB_REF_NAME` is
29
30
  * the version) and a manual `workflow_dispatch` (no tag, so a throwaway
package/tasks/bump.ts CHANGED
@@ -179,7 +179,7 @@ program
179
179
  )
180
180
  .option("--python-root <path>", "Python workspace package root", "packages/py")
181
181
  .action(
182
- (opts: {
182
+ async (opts: {
183
183
  level: Level;
184
184
  prefix: string;
185
185
  sibling: Sibling[];
@@ -257,8 +257,17 @@ program
257
257
  // plus anything synth regenerated. Skip the commit when nothing changed.
258
258
  git(["add", "-A"]);
259
259
  const staged = git(["diff", "--cached", "--name-only"], true);
260
- if (staged) git(["commit", "-m", `chore(release): ${version}`]);
261
- else logger.info("nothing to commit");
260
+ if (staged) {
261
+ git([
262
+ "commit",
263
+ "-m",
264
+ `chore(release): ${version}`,
265
+ "-m",
266
+ "🌸 Shipped with Kanna — https://kanna.sh",
267
+ "-m",
268
+ "Co-Authored-By: Kanna <noreply@kanna.sh>\nKanna-Agent: codex/gpt",
269
+ ]);
270
+ } else logger.info("nothing to commit");
262
271
  }
263
272
 
264
273
  if (opts.tag) {
@@ -284,32 +293,6 @@ program
284
293
  if (opts.version === false && localRegistry) {
285
294
  logger.info("skipped local publish (--no-version left package.json unbumped)");
286
295
  }
287
- if (publishToLocalRegistry) {
288
- logger.info(`publishing ${version} to local registry ${localRegistry}`);
289
- // Mirror the CI `release` workflow via the shared publish task: it
290
- // re-affirms the release version on every workspace member (`bun pm pkg
291
- // set`; the manifests already carry the shared `VERSION`, projen-owned, so
292
- // it unlocks each briefly), then `bun publish`es each non-private one - and
293
- // bun natively strips the `workspace:`/`catalog:` protocols in the packed
294
- // tarball, resolving each to that version. `publish.ts` restores every
295
- // manifest it touched at exit, so this leaves the worktree matching the
296
- // release commit that was just pushed. Provenance is off for a local
297
- // registry (no OIDC), so `NPM_CONFIG_PROVENANCE` is unset.
298
- // `publish.ts` is this task's SIBLING in the engine's `tasks/` dir; resolve
299
- // it off `import.meta.url` (works whether the engine is source-linked in-repo
300
- // or installed under node_modules) rather than a repo-relative `tasks/...`
301
- // that only exists inside `projen/`.
302
- const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
303
- exec.spawnSync("bun", [publishScript, version, "--registry", localRegistry], {
304
- cwd: process.cwd(),
305
- stdout: "inherit",
306
- stderr: "inherit",
307
- stdin: "ignore",
308
- check: true,
309
- });
310
- logger.success(`published ${version} to ${localRegistry}`);
311
- }
312
-
313
296
  // Scan EVERY active index (primary index-url + every extra-index-url,
314
297
  // across uv and pip): auto-mode publishes to the first that is a loopback
315
298
  // devpi +simple, so the corp proxy stays primary and a local devpi added
@@ -328,31 +311,61 @@ program
328
311
  }
329
312
  if (opts.version === false && localPypi) {
330
313
  logger.info("skipped local Python publish (--no-version left packages unstamped)");
331
- } else if (opts.version && localPypi && existsSync(pythonRoot)) {
314
+ }
315
+
316
+ // npm and Python touch disjoint package trees and registries. Start both
317
+ // mirrors together so local publishing takes the slower duration rather
318
+ // than the sum of both. Each child still owns its internal build/upload
319
+ // ordering and restores its temporary manifest edits on exit.
320
+ const localPublishes: Promise<unknown>[] = [];
321
+ if (publishToLocalRegistry) {
322
+ logger.info(`publishing ${version} to local registry ${localRegistry}`);
323
+ // The shared driver skips stamping/install when synth already made the
324
+ // workspace release-current, compiles publishable members once from the
325
+ // root, then publishes through a bounded pool. Temporary publishConfig
326
+ // edits are restored before this child exits.
327
+ const publishScript = fileURLToPath(new URL("./publish.ts", import.meta.url));
328
+ localPublishes.push(
329
+ exec
330
+ .spawn("bun", [publishScript, version, "--registry", localRegistry], {
331
+ cwd: process.cwd(),
332
+ stdout: "inherit",
333
+ stderr: "inherit",
334
+ stdin: "ignore",
335
+ check: true,
336
+ })
337
+ .then(() => logger.success(`published ${version} to ${localRegistry}`)),
338
+ );
339
+ }
340
+ if (opts.version && localPypi && existsSync(pythonRoot)) {
332
341
  logger.info(`publishing Python ${version} to local devpi ${localPypi.publishUrl}`);
333
342
  const publishPythonScript = fileURLToPath(new URL("./publish-python.ts", import.meta.url));
334
- exec.spawnSync(
335
- "bun",
336
- [
337
- publishPythonScript,
338
- version,
339
- "--root",
340
- pythonRoot,
341
- "--index-url",
342
- localPypi.indexUrl,
343
- "--publish-url",
344
- localPypi.publishUrl,
345
- ],
346
- {
347
- cwd: process.cwd(),
348
- stdout: "inherit",
349
- stderr: "inherit",
350
- stdin: "ignore",
351
- check: true,
352
- },
343
+ localPublishes.push(
344
+ exec
345
+ .spawn(
346
+ "bun",
347
+ [
348
+ publishPythonScript,
349
+ version,
350
+ "--root",
351
+ pythonRoot,
352
+ "--index-url",
353
+ localPypi.indexUrl,
354
+ "--publish-url",
355
+ localPypi.publishUrl,
356
+ ],
357
+ {
358
+ cwd: process.cwd(),
359
+ stdout: "inherit",
360
+ stderr: "inherit",
361
+ stdin: "ignore",
362
+ check: true,
363
+ },
364
+ )
365
+ .then(() => logger.success(`published Python ${version} to ${localPypi.publishUrl}`)),
353
366
  );
354
- logger.success(`published Python ${version} to ${localPypi.publishUrl}`);
355
367
  }
368
+ await Promise.all(localPublishes);
356
369
  },
357
370
  );
358
371
 
package/tasks/publish.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /**
3
3
  * `bun tasks/publish.ts <version> [--registry <url>] [--exclude <dir>] [--dry-run]`
4
- * - set a release version on every workspace member and publish each non-private
5
- * one with `bun publish`.
4
+ * - ensure every workspace member and the Bun lock carry the release version,
5
+ * then publish each non-private one with `bun publish`.
6
6
  *
7
7
  * Bun has no `pnpm -r publish`, so this loop is the recursive-publish stand-in.
8
8
  * It leans on native bun for everything bun already does:
9
9
  *
10
- * - **version stamping** is `bun pm pkg set version=<version>` per member (bun's
11
- * native package.json editor - idempotent, edits only that dir's manifest, no
12
- * git side effects; `bun pm version` is for bumping and errors on an unchanged
13
- * version, so `pkg set` is the right tool for an exact release value);
10
+ * - **version stamping**, when needed, is `bun pm pkg set version=<version>`
11
+ * per member (bun's native package.json editor - idempotent, edits only that
12
+ * dir's manifest, no git side effects; `bun pm version` is for bumping and
13
+ * errors on an unchanged version, so `pkg set` is the right tool for an exact
14
+ * release value);
14
15
  * - **`workspace:` / `catalog:` rewriting** is NOT done here - `bun publish`
15
16
  * strips both protocols in the PACKED tarball, resolving `workspace:*` to the
16
17
  * sibling's version and `catalog:` to the root catalog entry. (Verified: a
@@ -18,7 +19,9 @@
18
19
  * while the on-disk manifest keeps the protocols.) Setting each member's
19
20
  * version first is the only prerequisite, so a sibling resolves the release
20
21
  * version; the disk manifest already carries the workspace `VERSION`, and
21
- * this makes doubly sure it matches the value being published;
22
+ * this makes doubly sure it matches the value being published. When every
23
+ * manifest and Bun's workspace lock already carry the version, both this
24
+ * stamp and the lockfile refresh are skipped;
22
25
  * - **`publishConfig` substitution** (compiled `lib/` entry points) is done
23
26
  * HERE, by {@link applyPublishConfig}, NOT by bun: unlike pnpm/npm, `bun
24
27
  * publish`/`bun pm pack` do NOT fold `publishConfig`'s `main`/`types`/`bin`/
@@ -28,10 +31,13 @@
28
31
  * its `#!/usr/bin/env node` shebang, node chokes on the `.ts`
29
32
  * (ERR_UNKNOWN_FILE_EXTENSION). We merge `publishConfig` onto the top-level
30
33
  * manifest before packing so the tarball advertises the compiled `lib/` tree;
31
- * - the **`prepack` (compile) run** that emits that `lib/` tree is `bun
32
- * publish`'s own pack behavior.
34
+ * - **compiled output** is emitted once, before publishing, by one root-level
35
+ * filtered `bun run` that fans out to every publishable member in parallel.
36
+ * The later `bun publish --ignore-scripts` calls therefore pack the already
37
+ * compiled `lib/` trees instead of serially repeating each member's
38
+ * `prepack`. Packages retain their `prepack` task for standalone publishes.
33
39
  *
34
- * `--dry-run` forwards to `bun publish`: it packs + validates (running prepack)
40
+ * `--dry-run` forwards to `bun publish`: it packs + validates
35
41
  * but uploads nothing, so the `release` workflow is testable end-to-end via a
36
42
  * `workflow_dispatch` run without anything reaching npm. `--registry` targets a
37
43
  * non-default registry (a local verdaccio); `--exclude <dir>` (repeatable,
@@ -55,6 +61,7 @@ import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync }
55
61
  import { dirname, join, resolve } from "node:path";
56
62
  import { exec } from "@dbx-tools/core";
57
63
  import { log } from "@dbx-tools/shared-core";
64
+ import ts from "typescript";
58
65
  import { parse } from "yaml";
59
66
 
60
67
  const logger = log.logger("dbx-tools:publish");
@@ -95,6 +102,42 @@ function workspaceMembers(root: string): string[] {
95
102
  return (doc?.packages ?? []).map((m) => resolve(root, m));
96
103
  }
97
104
 
105
+ /** Whether every workspace member already carries the requested release version. */
106
+ function manifestsMatchVersion(members: readonly string[], version: string): boolean {
107
+ return members.every((dir) => {
108
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
109
+ version?: string;
110
+ };
111
+ return pkg.version === version;
112
+ });
113
+ }
114
+
115
+ /** Whether Bun's workspace lock records the requested version for every member. */
116
+ function lockfileMatchesVersion(
117
+ root: string,
118
+ members: readonly string[],
119
+ version: string,
120
+ ): boolean {
121
+ const lockfile = join(root, "bun.lock");
122
+ if (!existsSync(lockfile)) return false;
123
+ try {
124
+ const parsed = ts.parseConfigFileTextToJson(lockfile, readFileSync(lockfile, "utf8"));
125
+ if (parsed.error) return false;
126
+ const lock = parsed.config as {
127
+ workspaces?: Record<string, { version?: string }>;
128
+ };
129
+ return members.every((dir) => {
130
+ const relative = dir
131
+ .slice(resolve(root).length + 1)
132
+ .split("\\")
133
+ .join("/");
134
+ return lock.workspaces?.[relative]?.version === version;
135
+ });
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
98
141
  /** Spawn `command` in `cwd` with `PATH` overridden, failing the task on non-zero. */
99
142
  function run(cwd: string, command: string, args: string[], path: string): void {
100
143
  exec.spawnSync(command, args, {
@@ -107,6 +150,37 @@ function run(cwd: string, command: string, args: string[], path: string): void {
107
150
  });
108
151
  }
109
152
 
153
+ /** Asynchronous counterpart used for bounded parallel stamping and publishing. */
154
+ async function runAsync(cwd: string, command: string, args: string[], path: string): Promise<void> {
155
+ await exec.spawn(command, args, {
156
+ cwd,
157
+ stdout: "inherit",
158
+ stderr: "inherit",
159
+ stdin: "ignore",
160
+ check: true,
161
+ env: { ...process.env, PATH: path },
162
+ });
163
+ }
164
+
165
+ /** Run independent jobs with a small fixed worker pool. */
166
+ async function runConcurrent<T>(
167
+ values: readonly T[],
168
+ concurrency: number,
169
+ worker: (value: T) => Promise<void>,
170
+ ): Promise<void> {
171
+ let next = 0;
172
+ const results = await Promise.allSettled(
173
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => {
174
+ while (next < values.length) {
175
+ const value = values[next++];
176
+ await worker(value);
177
+ }
178
+ }),
179
+ );
180
+ const failure = results.find((result) => result.status === "rejected");
181
+ if (failure?.status === "rejected") throw failure.reason;
182
+ }
183
+
110
184
  /**
111
185
  * Make a projen-readonly manifest writable so `bun pm pkg set` / `bun publish` can
112
186
  * edit it, recording its original bytes + mode for {@link restoreManifests} the
@@ -173,12 +247,9 @@ function applyPublishConfig(pkgPath: string): void {
173
247
  }
174
248
 
175
249
  /**
176
- * PATH with the workspace-root `node_modules/.bin` prepended. `bun publish` runs
177
- * each package's `prepack` (compile) through projen's dax shell, which resolves
178
- * `tsc` off PATH - but under the hoisted linker `tsc` lives ONLY in the root
179
- * `.bin`, not a per-package one, so without this the compile fails with
180
- * `dax: tsc: command not found`. (In CI `bun install` already puts it there; this
181
- * makes the task self-sufficient when invoked directly too.)
250
+ * PATH with the workspace-root `node_modules/.bin` prepended. The root-level
251
+ * compile reaches each package's projen/dax task, which resolves `tsc` off PATH;
252
+ * under the hoisted linker `tsc` lives only in the root `.bin`.
182
253
  */
183
254
  function enrichedPath(root: string): string {
184
255
  const binDir = join(root, "node_modules", ".bin");
@@ -196,6 +267,12 @@ if (!version) {
196
267
  const registryIdx = rest.indexOf("--registry");
197
268
  const registry = registryIdx >= 0 ? rest[registryIdx + 1] : undefined;
198
269
  const dryRun = rest.includes("--dry-run");
270
+ const concurrencyIdx = rest.indexOf("--concurrency");
271
+ const parsedConcurrency = Number(concurrencyIdx >= 0 ? rest[concurrencyIdx + 1] : 4);
272
+ if (!Number.isInteger(parsedConcurrency) || parsedConcurrency < 1) {
273
+ throw new Error(`--concurrency must be a positive integer, got ${String(parsedConcurrency)}`);
274
+ }
275
+ const concurrency = parsedConcurrency;
199
276
  // `--stamp-only`: set versions + refresh the lockfile, then STOP (no publish).
200
277
  // The standalone `projen-release` uses this to version-stamp the workspace so its
201
278
  // own `bun publish` (run separately, in `projen/`) resolves `workspace:*` siblings
@@ -218,26 +295,31 @@ const members = workspaceMembers(root)
218
295
  .filter((dir) => existsSync(join(dir, "package.json")))
219
296
  .filter((dir) => !excluded.has(resolve(root, dir).replace(`${resolve(root)}/`, "")));
220
297
 
221
- // Set the version on EVERY member first (native `bun pm pkg set`), so a sibling
222
- // published later has its `workspace:*` dep resolved to the release version by
223
- // `bun publish`'s own protocol rewriting. The disk manifests already carry the
224
- // workspace `VERSION`; this re-affirms it against the value being published.
225
- logger.info(`setting ${version} across ${members.length} members`);
226
- for (const dir of members) {
227
- unlockManifest(join(dir, "package.json"));
228
- run(dir, "bun", ["pm", "pkg", "set", `version=${version}`], path);
298
+ // Ensure every member carries the release version before packing. A normal bump
299
+ // already synthesized it everywhere; dry runs and standalone invocations may not
300
+ // have, so retain the native `bun pm pkg set` fallback for those paths.
301
+ if (manifestsMatchVersion(members, version)) {
302
+ logger.info(`all ${members.length} member manifests already carry ${version}`);
303
+ } else {
304
+ logger.info(`setting ${version} across ${members.length} members`);
305
+ await runConcurrent(members, concurrency, async (dir) => {
306
+ unlockManifest(join(dir, "package.json"));
307
+ await runAsync(dir, "bun", ["pm", "pkg", "set", `version=${version}`], path);
308
+ });
229
309
  }
230
310
 
231
- // Refresh the lockfile so `bun publish` resolves each `workspace:*` to the version
232
- // just set. `bun publish`/`pm pack` reads the workspace version from the LOCKFILE,
233
- // not the live manifest, and a plain `bun install` (even `--force`) does NOT
234
- // re-resolve it after only a version-field change - deleting the lockfile first
235
- // does. Without this a `workspace:*` dep could publish against a stale resolved
236
- // version instead of the one just set.
311
+ // Ensure the lockfile resolves each `workspace:*` to the release version.
312
+ // `bun publish`/`pm pack` reads workspace versions from the LOCKFILE, not just
313
+ // live manifests. A normal bump's synth/install already makes it current; after
314
+ // fallback stamping, deleting it before install is what forces re-resolution.
237
315
  const lockfile = join(root, "bun.lock");
238
- if (existsSync(lockfile)) rmSync(lockfile);
239
- logger.info("refreshing lockfile so workspace deps resolve to the release version");
240
- run(root, "bun", ["install"], path);
316
+ if (lockfileMatchesVersion(root, members, version)) {
317
+ logger.info(`workspace lock already resolves members at ${version}`);
318
+ } else {
319
+ if (existsSync(lockfile)) rmSync(lockfile);
320
+ logger.info("refreshing lockfile so workspace deps resolve to the release version");
321
+ run(root, "bun", ["install"], path);
322
+ }
241
323
 
242
324
  if (stampOnly) {
243
325
  logger.success(`stamped ${members.length} members @ ${version} (no publish)`);
@@ -247,14 +329,16 @@ if (stampOnly) {
247
329
  const publishArgs = [
248
330
  "--access",
249
331
  "public",
332
+ "--ignore-scripts",
250
333
  ...(registry ? ["--registry", registry] : []),
251
334
  ...(dryRun ? ["--dry-run"] : []),
252
335
  ];
253
- let published = 0;
336
+ const publishable: Array<{ dir: string; name: string; compile: boolean }> = [];
254
337
  for (const dir of members) {
255
338
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
256
339
  name?: string;
257
340
  private?: boolean;
341
+ scripts?: Record<string, string>;
258
342
  };
259
343
  if (pkg.private) {
260
344
  logger.info(`skip private ${pkg.name ?? dirname(dir)}`);
@@ -265,8 +349,26 @@ for (const dir of members) {
265
349
  const manifestPath = join(dir, "package.json");
266
350
  unlockManifest(manifestPath);
267
351
  applyPublishConfig(manifestPath);
268
- logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${pkg.name} @ ${version}`);
269
- run(dir, "bun", ["publish", ...publishArgs], path);
270
- published += 1;
352
+ publishable.push({
353
+ dir,
354
+ name: pkg.name ?? dirname(dir),
355
+ compile: Boolean(pkg.scripts && typeof pkg.scripts === "object" && "prepack" in pkg.scripts),
356
+ });
357
+ }
358
+
359
+ const compiled = publishable.filter((pkg) => pkg.compile);
360
+ if (compiled.length > 0) {
361
+ logger.info(`compiling ${compiled.length} publishable packages from the workspace root`);
362
+ run(root, "bun", ["run", ...compiled.flatMap((pkg) => ["--filter", pkg.name]), "compile"], path);
271
363
  }
272
- logger.success(`${dryRun ? "dry-run: packed" : "published"} ${published} packages @ ${version}`);
364
+
365
+ logger.info(
366
+ `${dryRun ? "dry-run packing" : "publishing"} ${publishable.length} packages with concurrency ${concurrency}`,
367
+ );
368
+ await runConcurrent(publishable, concurrency, async ({ dir, name }) => {
369
+ logger.info(`${dryRun ? "dry-run publishing" : "publishing"} ${name} @ ${version}`);
370
+ await runAsync(dir, "bun", ["publish", ...publishArgs], path);
371
+ });
372
+ logger.success(
373
+ `${dryRun ? "dry-run: packed" : "published"} ${publishable.length} packages @ ${version}`,
374
+ );