@dbx-tools/projen 0.6.90 → 0.6.91

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
@@ -293,6 +293,22 @@ invokes, so there is no second place to run the same thing:
293
293
 
294
294
  Do NOT run `projen default` (or `bunx projen`) from inside a member. Synth is a
295
295
  whole-tree operation driven by the ROOT `.projenrc.ts`, so a member-level run
296
- re-synths the entire workspace from the member's directory and rewrites the
297
- root `package.json` `version` back to `0.0.0`. Run `bun run sync` from the root
298
- instead.
296
+ re-synths the entire workspace from the member's directory. Run `bun run sync`
297
+ from the root instead.
298
+
299
+ ## Versioning
300
+
301
+ The whole repo shares ONE version, stored in the root `VERSION` file (a plain
302
+ `x.y.z` string; a fresh tree with no file defaults to `0.0.1`). Synth COPIES that
303
+ value into every generated manifest - the root and `projen/` `package.json`, every
304
+ JS member, every Python `pyproject.toml`, the generated openapi packages, and the
305
+ example apps - so the packages, the engine, and the examples always match.
306
+ `src/workspace-version.ts` owns reading and writing it. Synth only ever reads it;
307
+ it never resets, upgrades, or downgrades a version on its own.
308
+
309
+ `bun run bump` is the only command that changes the number: it fetches the remote
310
+ tags once, takes the highest tag across `v*` and every sibling prefix as the base
311
+ (falling back to the local `VERSION` file when the remote is unreachable or has no
312
+ tag), increments by `--level`, writes `VERSION`, then synths so every manifest
313
+ copies it. The remote is consulted only on `bump` and on one-time creation of a
314
+ missing `VERSION` file - never on an ordinary synth.
package/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Regenerated from the exporting modules in ./src.
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
+ export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
5
6
  export * as barrels from "./src/barrels.ts";
6
7
  export * as bunApp from "./src/bun-app.ts";
7
8
  export * as clean from "./src/clean.ts";
@@ -25,6 +26,7 @@ export * as tags from "./src/tags.ts";
25
26
  export * as tsconfig from "./src/tsconfig.ts";
26
27
  export * as vscode from "./src/vscode.ts";
27
28
  export * as watch from "./src/watch.ts";
29
+ export * as workspaceVersion from "./src/workspace-version.ts";
28
30
  export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile, BunfigFile, BunDevServerFile, BunBuildFile } from "./src/bun-app.ts";
29
31
  export { DBXToolsConfig } from "./src/dbx-tools-config.ts";
30
32
  export type { DBXToolsConfigOptions } from "./src/dbx-tools-config.ts";
@@ -49,3 +51,5 @@ export type { PackageTag } from "./src/tags.ts";
49
51
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
50
52
  export { DBXToolsVsCode } from "./src/vscode.ts";
51
53
  export type { IgnoreGroupOptions } from "./src/watch.ts";
54
+ export { VERSION_FILE, DEFAULT_VERSION } from "./src/workspace-version.ts";
55
+ export type { Semver } from "./src/workspace-version.ts";
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.90",
30
- "@dbx-tools/path": "0.6.90",
31
- "@dbx-tools/shared-core": "0.6.90",
29
+ "@dbx-tools/core": "0.6.91",
30
+ "@dbx-tools/path": "0.6.91",
31
+ "@dbx-tools/shared-core": "0.6.91",
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.90",
50
+ "version": "0.6.91",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/barrels.ts CHANGED
@@ -27,6 +27,10 @@
27
27
  * `export { ... }`. The module namespaces stay either way, so a namespaced call
28
28
  * site keeps working.
29
29
  *
30
+ * Every generated barrel also exports `PACKAGE_IDENTIFIER` from the package's own
31
+ * `package.json`. Runtime helpers can retain the package specifier without
32
+ * trying to recover it from an ESM namespace object or loader function.
33
+ *
30
34
  * Uniqueness is tallied over types and values TOGETHER: a name carried by two
31
35
  * modules is ambiguous whichever kind it is, and hoisting one module's value
32
36
  * beside another's same-named type would emit two conflicting re-exports. Such a
@@ -47,7 +51,7 @@
47
51
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
48
52
  import { join, relative } from "node:path";
49
53
  import { find } from "@dbx-tools/path";
50
- import { string } from "@dbx-tools/shared-core";
54
+ import { json, string } from "@dbx-tools/shared-core";
51
55
  import isIdentifier from "is-identifier";
52
56
  import { header, isGenerated, makeReadonly, makeWritable, type HeaderOpts } from "./generated.ts";
53
57
  import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports.ts";
@@ -101,6 +105,11 @@ const BARREL_HEADER: HeaderOpts = {
101
105
  source: "the exporting modules in ./src",
102
106
  };
103
107
 
108
+ /** Generated package identity export, reserved against source-module hoisting. */
109
+ const PACKAGE_IDENTIFIER_EXPORT = "PACKAGE_IDENTIFIER";
110
+ const PACKAGE_IDENTIFIER_LINE = `export const ${PACKAGE_IDENTIFIER_EXPORT} = "";`;
111
+ const PACKAGE_IDENTIFIER_LINE_RE = /^export const PACKAGE_IDENTIFIER = .*;$/m;
112
+
104
113
  /** `pnpm-workspace` -> `pnpmWorkspace`; `local-fs` -> `localFS` (`fs` -> `FS`). */
105
114
  function kebabToCamel(segment: string): string {
106
115
  const tokens = [...string.tokenizeWithOptions({ lowerCase: true, capitalize: true }, segment)];
@@ -262,6 +271,31 @@ function mergeCustomExports(content: string, pkgDir: string): string {
262
271
  return `${kept.join("\n").replace(/\n+$/, "")}\nexport * from "./exports.ts";\n`;
263
272
  }
264
273
 
274
+ /** Read the authoritative npm package name emitted by the package project. */
275
+ function packageIdentifier(pkgDir: string): string {
276
+ const manifestPath = join(pkgDir, "package.json");
277
+ const name = existsSync(manifestPath)
278
+ ? json.parseRecord(readFileSync(manifestPath, "utf8"))?.name
279
+ : undefined;
280
+ if (typeof name !== "string" || !name.trim()) {
281
+ throw new Error(`Cannot generate barrel without package.json name: ${manifestPath}`);
282
+ }
283
+ return name;
284
+ }
285
+
286
+ /** Normalize package identity so structural barrel comparisons need no manifest read. */
287
+ function withoutPackageIdentifier(content: string): string {
288
+ return content.replace(PACKAGE_IDENTIFIER_LINE_RE, PACKAGE_IDENTIFIER_LINE);
289
+ }
290
+
291
+ /** Resolve and insert package identity only when a barrel is about to be written. */
292
+ function withPackageIdentifier(content: string, pkgDir: string): string {
293
+ const line = `export const ${PACKAGE_IDENTIFIER_EXPORT} = ${JSON.stringify(
294
+ packageIdentifier(pkgDir),
295
+ )};`;
296
+ return content.replace(PACKAGE_IDENTIFIER_LINE_RE, () => line);
297
+ }
298
+
265
299
  /**
266
300
  * Rebuild one package's root barrel. Returns 1 only if the barrel's contents
267
301
  * actually changed - a module was added, removed, renamed, or toggled its
@@ -317,30 +351,35 @@ function generateForPackage(pkgDir: string): number {
317
351
  // extension is written because `tsc` rewrites it on emit
318
352
  // (`rewriteRelativeImportExtensions`) - an extensionless specifier would be
319
353
  // copied through verbatim and Node's ESM resolver cannot probe for it.
320
- let content = modulePaths
354
+ const namespaceExports = modulePaths
321
355
  .map((stem) => {
322
356
  const modulePath = `./src/${byModulePath.get(stem)!}`;
323
357
  return `export * as ${modulePathToNamespace(modulePath)} from "${modulePath}";`;
324
358
  })
325
359
  .join("\n");
360
+ let content = `${PACKAGE_IDENTIFIER_LINE}\n${namespaceExports}`;
326
361
  // Hoist package-unique named exports to the top level. Names a hand-authored
327
362
  // `exports.ts` declares are suppressed so that file stays authoritative.
328
363
  const customPath = join(pkgDir, CUSTOM_EXPORTS_FILE);
329
364
  const suppress = existsSync(customPath) ? customExportNames(customPath) : new Set<string>();
365
+ suppress.add(PACKAGE_IDENTIFIER_EXPORT);
330
366
  content = hoistUniqueExports(content, pkgDir, suppress);
331
367
  // A sibling `exports.ts` overrides/extends the generated barrel and wins on conflict.
332
368
  content = mergeCustomExports(content, pkgDir);
333
369
 
334
- // The barrel only *changes* when its set of exporting modules does. If the
335
- // stamped result matches what's already on disk, leave the file - and its
370
+ // The barrel only *changes* when its export structure does. Package identity is
371
+ // blanked on both sides of this comparison, so a no-op cycle never reads
372
+ // package.json merely to reconstruct a value the existing barrel already has.
373
+ // If the structural result matches what's on disk, leave the file - and its
336
374
  // read-only bit - completely untouched and report no change (0). This keeps the
337
375
  // watcher quiet on ordinary in-file edits (which leave the export * as … list
338
376
  // identical), and it is also what keeps a no-op cycle off the read-only bit
339
377
  // entirely: see {@link writeBarrel} for why unlocking a barrel we are not about
340
378
  // to rewrite is what produced spurious EACCES failures.
341
379
  content = `${content.replace(/\n+$/, "")}\n`;
342
- const next = `${header(BARREL_HEADER)}\n${content}`;
343
- if (before === next) return 0;
380
+ const template = `${header(BARREL_HEADER)}\n${content}`;
381
+ if (before !== undefined && withoutPackageIdentifier(before) === template) return 0;
382
+ const next = withPackageIdentifier(template, pkgDir);
344
383
 
345
384
  // Written whole (header included) rather than via `stampGenerated`, which would
346
385
  // re-read and rewrite the file to prepend the same header - a second write, and
package/src/openapi.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  toPosix,
49
49
  recordedPackages,
50
50
  } from "./packages.ts";
51
+ import { readWorkspaceVersion } from "./workspace-version.ts";
51
52
 
52
53
  const logger = log.logger("projen:openapi");
53
54
 
@@ -173,6 +174,7 @@ export async function generateOpenapi(): Promise<string[]> {
173
174
  skipLibCheck: true,
174
175
  };
175
176
 
177
+ const specVersion = readWorkspaceVersion(repoRoot);
176
178
  const written: string[] = [];
177
179
  for (const p of pkgs) {
178
180
  // The generated package's folder is the source's leaf folder name (`api`), not
@@ -197,7 +199,7 @@ export async function generateOpenapi(): Promise<string[]> {
197
199
  specFileBaseName: "openapi",
198
200
  specVersion: 3,
199
201
  name: `${p.relPath} API`,
200
- version: "0.0.0",
202
+ version: specVersion,
201
203
  },
202
204
  compilerOptions,
203
205
  );
package/src/packages.ts CHANGED
@@ -177,10 +177,11 @@ function readRecordedMembers(projectRoot: string = repoRoot): string[] {
177
177
  return doc?.packages ?? [];
178
178
  }
179
179
 
180
- /** A member path `<root>/<...rel>` (>= 2 segments) as a {@link DiscoveredPackage}. */
180
+ /** A workspace member path as a {@link DiscoveredPackage}, including root-level members. */
181
181
  function packageOfMember(projectRoot: string, member: string): DiscoveredPackage | undefined {
182
182
  const segs = toPosix(member).split("/").filter(Boolean);
183
- if (segs.length < 2) return undefined;
183
+ if (segs.length === 0) return undefined;
184
+ if (segs.length === 1) return new DiscoveredPackage(projectRoot, segs[0]!, []);
184
185
  return new DiscoveredPackage(projectRoot, segs[0]!, segs.slice(1));
185
186
  }
186
187
 
package/src/project-js.ts CHANGED
@@ -36,12 +36,13 @@ import {
36
36
  toPosix,
37
37
  } from "./packages.ts";
38
38
  import { PnpmWorkspaceState, type DBXToolsPNPMWorkspaceOptions } from "./pnpm-workspace.ts";
39
+ import type { DBXToolsProject, DBXToolsProjectOptions as CommonProjectOptions } from "./project.ts";
39
40
  import { applyCompiledPublish } from "./publish.ts";
40
41
  import { DBXToolsRelease, type StandaloneRelease } from "./release.ts";
41
42
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
42
43
  import { DBXToolsRootTsconfig } from "./tsconfig.ts";
43
44
  import { DBXToolsVsCode } from "./vscode.ts";
44
- import type { DBXToolsProject, DBXToolsProjectOptions as CommonProjectOptions } from "./project.ts";
45
+ import { readWorkspaceVersion } from "./workspace-version.ts";
45
46
 
46
47
  /**
47
48
  * The dbx-tools project surface, backed by projen's Node toolchain. A single
@@ -584,6 +585,9 @@ export class DBXToolsNodeProject
584
585
  // component, but the file is still required by the Databricks Apps platform
585
586
  // (its build phase installs with pnpm and reads catalog + `allowBuilds`).
586
587
  pnpmWorkspace.attachWorkspaceFile(this);
588
+ // Copy the single workspace version onto the root manifest. The `VERSION` file
589
+ // at the workspace root is the source of truth; synth only reads it.
590
+ this.package.addField("version", readWorkspaceVersion(this.outdir));
587
591
  this.scope = scope;
588
592
  this.extraWorkspaceMembers = options.extraWorkspaceMembers ?? [];
589
593
  this.rootInstallOnly = options.rootInstallOnly !== false;
@@ -678,6 +682,10 @@ export class DBXToolsTypeScriptProject
678
682
  ".": "./index.ts",
679
683
  "./package.json": "./package.json",
680
684
  });
685
+ // Every package carries the single workspace version, copied from the root
686
+ // `VERSION` file (the source of truth). `this.root` is the workspace root for a
687
+ // discovered member and this project itself for a standalone compiling root.
688
+ this.package.addField("version", readWorkspaceVersion(this.root.outdir));
681
689
  addPackageFiles(this, "index.ts", "src");
682
690
  // `bun test` intercepts `node:test` (the suites keep using node:test) and
683
691
  // runs it with bun's own fast runner. Args are FILTERS, not globs; a bare
@@ -11,8 +11,8 @@ import { IConstruct } from "constructs";
11
11
  import { Project } from "projen";
12
12
  import { project } from "..";
13
13
  import { toPosix } from "./packages.ts";
14
- import type { DBXToolsProject } from "./project.ts";
15
14
  import type { DBXToolsJavaScriptProject } from "./project-js.ts";
15
+ import type { DBXToolsProject } from "./project.ts";
16
16
 
17
17
  /**
18
18
  * Guard: the construct is a projen {@link Project} - the base every builder here
package/src/project-py.ts CHANGED
@@ -4,6 +4,7 @@ import { Component, type Project, javascript, python, vscode } from "projen";
4
4
  import { GithubWorkflow } from "projen/lib/github";
5
5
  import { JobPermission } from "projen/lib/github/workflows-model";
6
6
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
7
+ import { readWorkspaceVersion } from "./workspace-version.ts";
7
8
 
8
9
  /** Git location used by direct `#subdirectory=` package dependencies. */
9
10
  export interface PythonRepositoryOptions {
@@ -19,6 +20,7 @@ export interface PythonPackageOptions extends DBXToolsProjectOptions {
19
20
  readonly module: string;
20
21
  readonly description: string;
21
22
  readonly dependencies?: readonly string[];
23
+ readonly scripts?: Readonly<Record<string, string>>;
22
24
  }
23
25
 
24
26
  /** Options for one projen-native Python workspace member. */
@@ -27,6 +29,8 @@ export interface DBXToolsPythonProjectOptions extends DBXToolsProjectOptions {
27
29
  readonly package: PythonPackageOptions;
28
30
  readonly repository: Required<PythonRepositoryOptions>;
29
31
  readonly requiresPython: string;
32
+ /** Workspace version copied onto this package's `pyproject.toml`. */
33
+ readonly version: string;
30
34
  }
31
35
 
32
36
  /** Python release workflow configuration. */
@@ -94,7 +98,7 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
94
98
  moduleName: pkg.module,
95
99
  authorName: "",
96
100
  authorEmail: "",
97
- version: "0.0.0",
101
+ version: options.version,
98
102
  description: pkg.description,
99
103
  github: false,
100
104
  sample: false,
@@ -111,7 +115,7 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
111
115
  uvOptions: {
112
116
  project: {
113
117
  name: pkg.name,
114
- version: "0.0.0",
118
+ version: options.version,
115
119
  description: pkg.description,
116
120
  readme: "README.md",
117
121
  requiresPython: options.requiresPython,
@@ -140,6 +144,9 @@ export class DBXToolsPythonProject extends python.PythonProject implements DBXTo
140
144
  this.uv = this.packagingManager;
141
145
  this.uv.file.addDeletionOverride("project.authors");
142
146
  this.uv.file.addDeletionOverride("dependency-groups");
147
+ if (pkg.scripts) {
148
+ this.uv.file.addOverride("project.scripts", pkg.scripts);
149
+ }
143
150
  this.uv.file.readonly = true;
144
151
 
145
152
  for (const path of [".gitattributes", ".gitignore"]) {
@@ -159,6 +166,7 @@ export class DBXToolsPythonWorkspace extends Component {
159
166
  readonly packages: readonly DBXToolsPythonProject[];
160
167
  readonly repository: Required<PythonRepositoryOptions>;
161
168
  readonly requiresPython: string;
169
+ readonly version: string;
162
170
  readonly file: python.PyprojectTomlFile;
163
171
 
164
172
  constructor(project: javascript.NodeProject, options: DBXToolsPythonWorkspaceOptions) {
@@ -169,6 +177,9 @@ export class DBXToolsPythonWorkspace extends Component {
169
177
  root: options.repository.root ?? "packages/py",
170
178
  };
171
179
  this.requiresPython = options.requiresPython ?? ">=3.10";
180
+ // The single workspace version, copied from the root `VERSION` file so Python
181
+ // members carry the same number as their JS siblings.
182
+ this.version = readWorkspaceVersion(project.outdir);
172
183
  this.file = this.emitWorkspace(project, options);
173
184
  this.packages = options.packages.map(
174
185
  (pkg) =>
@@ -177,6 +188,7 @@ export class DBXToolsPythonWorkspace extends Component {
177
188
  package: pkg,
178
189
  repository: this.repository,
179
190
  requiresPython: this.requiresPython,
191
+ version: this.version,
180
192
  }),
181
193
  );
182
194
  for (const pkg of this.packages) {
@@ -216,7 +228,7 @@ export class DBXToolsPythonWorkspace extends Component {
216
228
  const file = new python.PyprojectTomlFile(project, {
217
229
  project: {
218
230
  name: options.workspaceName ?? `${string.toSlug(project.name)}-python-workspace`,
219
- version: "0.0.0",
231
+ version: this.version,
220
232
  requiresPython: this.requiresPython,
221
233
  dependencies: [],
222
234
  },
package/src/project.ts CHANGED
@@ -7,8 +7,8 @@ import { object, type OneOrMany } from "@dbx-tools/shared-core";
7
7
  import { type IConstruct } from "constructs";
8
8
  import { Project, type ProjectOptions } from "projen";
9
9
  import * as mixin from "./mixin.ts";
10
- import * as projectPredicate from "./project-predicate.ts";
11
10
  import type { DBXToolsJavaScriptProject } from "./project-js.ts";
11
+ import * as projectPredicate from "./project-predicate.ts";
12
12
 
13
13
  export * from "./project-js.ts";
14
14
  export * from "./project-py.ts";
package/src/release.ts CHANGED
@@ -269,9 +269,9 @@ export class DBXToolsRelease extends Component {
269
269
  *
270
270
  * `directory` (e.g. `projen/`) is a WORKSPACE MEMBER whose `@dbx-tools/*` deps
271
271
  * are `workspace:*`. `bun publish` resolves those to whatever version its
272
- * SIBLINGS carry (via the lockfile), so before publishing we set the version on
273
- * the package AND its in-scope siblings, then refresh the lockfile - otherwise
274
- * the published engine would depend on the siblings' on-disk `0.0.0`. The
272
+ * SIBLINGS carry (via the lockfile), so before publishing we re-affirm the
273
+ * version on the package AND its in-scope siblings, then refresh the lockfile -
274
+ * otherwise a stale resolved version could reach the published engine. The
275
275
  * `Install` step already ran `bun install` from the repo root (the member
276
276
  * subdir walks up to it), so the workspace is linked. The manifests are
277
277
  * projen-readonly, hence the `chmod`. A manual `workflow_dispatch` run has no
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Single source of truth for the workspace version.
3
+ *
4
+ * The repo-root `VERSION` file holds one plain `x.y.z` string that every
5
+ * generated manifest copies at synth: the root and `projen/` package.json, every
6
+ * JS member, every Python `pyproject.toml`, the generated openapi packages, and
7
+ * the example apps. Synth only READS this file (defaulting to {@link
8
+ * DEFAULT_VERSION} when it is absent on a fresh tree); it never rewrites it, so an
9
+ * ordinary `bunx projen` cannot move a package version up or down.
10
+ *
11
+ * Only two callers change the number: `bump` (which increments it) and the
12
+ * one-time bootstrap of a workspace that has no `VERSION` yet. Both resolve the
13
+ * base from the remote git tags first ({@link resolveRemoteVersion}) so a release
14
+ * cut elsewhere is respected, and fall back to the local file (or {@link
15
+ * DEFAULT_VERSION}) when the remote is unreachable or has no tags. The remote is
16
+ * consulted ONLY on those two paths, never on every synth/compile/commit.
17
+ */
18
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { exec } from "@dbx-tools/core";
21
+
22
+ /** Name of the repo-root file holding the workspace version. */
23
+ export const VERSION_FILE = "VERSION";
24
+
25
+ /** Version a fresh workspace starts at when no `VERSION` file and no remote tag exist. */
26
+ export const DEFAULT_VERSION = "0.0.1";
27
+
28
+ const SEMVER = /^\d+\.\d+\.\d+$/;
29
+
30
+ /** A parsed `[major, minor, patch]` tuple. */
31
+ export type Semver = [number, number, number];
32
+
33
+ /** Parse `x.y.z` (ignoring any leading `v`/prefix), or `undefined` when it does not match. */
34
+ export function parseSemver(raw: string): Semver | undefined {
35
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
36
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
37
+ }
38
+
39
+ /** Ordering comparator: negative when `a < b`, positive when `a > b`, zero when equal. */
40
+ export function compareSemver(a: Semver, b: Semver): number {
41
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
42
+ }
43
+
44
+ /** Absolute path to the `VERSION` file for a workspace root. */
45
+ export function versionPath(root: string): string {
46
+ return join(root, VERSION_FILE);
47
+ }
48
+
49
+ /**
50
+ * Read the workspace version from `<root>/VERSION`. Returns {@link DEFAULT_VERSION}
51
+ * when the file is absent (a fresh consumer tree). A file that EXISTS but does not
52
+ * hold a valid `x.y.z` fails loudly rather than being silently "fixed" to a
53
+ * different number during synth.
54
+ */
55
+ export function readWorkspaceVersion(root: string): string {
56
+ const path = versionPath(root);
57
+ if (!existsSync(path)) return DEFAULT_VERSION;
58
+ const raw = readFileSync(path, "utf8").trim();
59
+ if (!SEMVER.test(raw)) {
60
+ throw new Error(`${VERSION_FILE} must contain an x.y.z version, got ${JSON.stringify(raw)}`);
61
+ }
62
+ return raw;
63
+ }
64
+
65
+ /** Write the workspace version to `<root>/VERSION`. Only `bump` and bootstrap call this. */
66
+ export function writeWorkspaceVersion(root: string, version: string): void {
67
+ if (!SEMVER.test(version)) {
68
+ throw new Error(`workspace version must be x.y.z, got ${JSON.stringify(version)}`);
69
+ }
70
+ writeFileSync(versionPath(root), `${version}\n`);
71
+ }
72
+
73
+ /** Run git in `cwd`, capturing stdout and swallowing failure (offline, no repo). */
74
+ function gitCapture(cwd: string, args: string[]): string {
75
+ try {
76
+ const res = exec.spawnSync("git", args, {
77
+ cwd,
78
+ stdout: "capture",
79
+ stderr: "ignore",
80
+ stdin: "ignore",
81
+ check: false,
82
+ });
83
+ return res.stdout?.trim() ?? "";
84
+ } catch {
85
+ return "";
86
+ }
87
+ }
88
+
89
+ /** Highest tag matching `<prefix><semver>` in the local tag list, or `undefined`. */
90
+ export function latestTagVersion(cwd: string, prefix: string): Semver | undefined {
91
+ const out = gitCapture(cwd, [
92
+ "-c",
93
+ "versionsort.suffix=-",
94
+ "tag",
95
+ "--sort=-version:refname",
96
+ "--list",
97
+ `${prefix}*`,
98
+ ]);
99
+ for (const tag of out.split("\n")) {
100
+ const v = parseSemver(tag.replace(prefix, ""));
101
+ if (v) return v;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /**
107
+ * Highest published version across every tag prefix, or `undefined` when the
108
+ * remote is unreachable or no matching tag exists. Fetches tags first (best
109
+ * effort) so a release made elsewhere is respected; a fetch failure just means
110
+ * the local tag list is used, and callers fall back to the `VERSION` file.
111
+ */
112
+ export function resolveRemoteVersion(
113
+ cwd: string,
114
+ prefixes: readonly string[],
115
+ { fetch = true }: { fetch?: boolean } = {},
116
+ ): string | undefined {
117
+ if (fetch) gitCapture(cwd, ["fetch", "--tags", "--quiet"]);
118
+ let best: Semver | undefined;
119
+ for (const prefix of prefixes) {
120
+ const v = latestTagVersion(cwd, prefix);
121
+ if (v && (!best || compareSemver(v, best) > 0)) best = v;
122
+ }
123
+ return best ? best.join(".") : undefined;
124
+ }
125
+
126
+ /**
127
+ * The base version a `bump` increments from: the highest remote tag if any exists
128
+ * (a local file that is ahead does NOT win), else the local `VERSION` file, else
129
+ * {@link DEFAULT_VERSION}.
130
+ */
131
+ export function resolveBaseVersion(
132
+ root: string,
133
+ prefixes: readonly string[],
134
+ options: { fetch?: boolean } = {},
135
+ ): { version: string; source: "remote" | "local" } {
136
+ const remote = resolveRemoteVersion(root, prefixes, options);
137
+ if (remote) return { version: remote, source: "remote" };
138
+ return { version: readWorkspaceVersion(root), source: "local" };
139
+ }
140
+
141
+ /**
142
+ * Create the `VERSION` file when it does not yet exist, seeding it from the remote
143
+ * tags (or {@link DEFAULT_VERSION} when the remote is unreachable / has no tag). An
144
+ * existing file is left untouched - only `bump` moves an established version, so
145
+ * bootstrap never upgrades or downgrades one. Returns the current version.
146
+ */
147
+ export function ensureWorkspaceVersion(
148
+ root: string,
149
+ { prefixes = ["v"], fetch = true }: { prefixes?: readonly string[]; fetch?: boolean } = {},
150
+ ): string {
151
+ if (!existsSync(versionPath(root))) {
152
+ writeWorkspaceVersion(root, resolveRemoteVersion(root, prefixes, { fetch }) ?? DEFAULT_VERSION);
153
+ }
154
+ return readWorkspaceVersion(root);
155
+ }
package/tasks/bump.ts CHANGED
@@ -1,28 +1,30 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /**
3
- * `projen bump` - synth, compute the next release version, then (by default)
4
- * commit, tag, and push it. Pushing the tag is what triggers the release
5
- * workflow.
3
+ * `projen bump` - compute the next release version, write it to the workspace
4
+ * `VERSION` file, synth so every manifest copies it, then (by default) commit,
5
+ * tag, and push. Pushing the tag is what triggers the release workflow.
6
6
  *
7
- * The next version is derived from the HIGHEST of:
8
- * - the latest published git tag matching `<prefix><semver>` (fetched from
9
- * the remote so a release made elsewhere is respected),
10
- * - the same for every `--sibling` prefix, and
11
- * - the local `package.json` version,
12
- * then incremented by `--level` (patch | minor | major; default patch).
7
+ * The base version is the HIGHEST published git tag across `<prefix>` and every
8
+ * `--sibling` prefix (fetched from the remote so a release cut elsewhere wins),
9
+ * falling back to the local `VERSION` file when the remote is unreachable or has
10
+ * no matching tag. A local file that is ahead does NOT override an existing
11
+ * remote tag. The base is then incremented by `--level` (patch | minor | major;
12
+ * default patch). The remote is consulted only here and on VERSION bootstrap,
13
+ * never on an ordinary synth.
13
14
  *
14
15
  * `--sibling <dir>:<tagPrefix>` (repeatable) releases an in-repo project that
15
16
  * publishes on its OWN tag namespace (e.g. `projen/`, tagged `projen-v*`) at the
16
- * SAME version as the root, in the same run: its manifest version is stamped, its
17
- * `<tagPrefix><version>` tag is cut and pushed (triggering its own workflow), and
18
- * it is included in the local-registry publish. Taking the base version from
19
- * every prefix at once is what keeps the two in lockstep: the engine sat at
20
- * 0.1.24 while the packages reached 0.3.41 precisely because each namespace only
21
- * ever looked at its own tags.
17
+ * SAME version as the root, in the same run: its `<tagPrefix><version>` tag is
18
+ * cut and pushed (triggering its own workflow), and it is included in the
19
+ * local-registry publish. Taking the base version from every prefix at once is
20
+ * what keeps the two in lockstep: the engine sat at 0.1.24 while the packages
21
+ * reached 0.3.41 precisely because each namespace only ever looked at its own
22
+ * tags. Both draw the fallback from the one root `VERSION` file, so the engine
23
+ * and the packages share a single source of truth.
22
24
  *
23
25
  * Flags (all default ON; negate with the `--no-` form, per commander):
24
- * --synth / --no-synth run `projen` (synth) first so the tree is current
25
- * --version / --no-version write the bumped version into package.json
26
+ * --synth / --no-synth synth after writing VERSION so manifests copy it
27
+ * --version / --no-version write the bumped version into `VERSION`
26
28
  * --commit / --no-commit commit the release (staged with `git add -A`)
27
29
  * --tag / --no-tag create the `<prefix><version>` git tag
28
30
  * --push / --no-push push the CURRENT branch + tag to origin
@@ -34,7 +36,14 @@
34
36
  * verdaccio) right after the tag push. `--local-pypi <value>` does the same for
35
37
  * Python packages through a writable devpi index. Values for both:
36
38
  * - `auto` (default): publish only when `npm config get registry` is a
37
- * loopback host, or uv's default index is a loopback devpi `+simple` URL.
39
+ * loopback host, or when ANY active Python index the primary
40
+ * `index-url` OR any `extra-index-url`, across uv and pip — is a loopback
41
+ * devpi URL. Scanning the extras is what lets the corp proxy stay the
42
+ * primary index while a local devpi added as an extra is the detected
43
+ * publish target. The deploy endpoint for that index is taken from the
44
+ * GLOBAL uv config — an explicit `publish-url` on the matching `[[index]]`
45
+ * (or `UV_PUBLISH_URL`) — falling back to deriving it from the `+simple`
46
+ * URL shape when no such setting exists.
38
47
  * - `false`: never publish locally.
39
48
  * - a URL: always publish to that registry.
40
49
  */
@@ -44,7 +53,15 @@ import { fileURLToPath } from "node:url";
44
53
  import { exec, project } from "@dbx-tools/core";
45
54
  import { log, net } from "@dbx-tools/shared-core";
46
55
  import { Command, Option } from "commander";
47
- import { activePythonIndex, resolveLocalPypi } from "./python-registry.ts";
56
+ import { activePythonIndexes, resolveLocalPypi } from "./python-registry.ts";
57
+ import {
58
+ type Semver,
59
+ compareSemver,
60
+ latestTagVersion,
61
+ parseSemver,
62
+ resolveBaseVersion,
63
+ writeWorkspaceVersion,
64
+ } from "../src/workspace-version.ts";
48
65
 
49
66
  const logger = log.logger("projen:bump");
50
67
  const LEVELS = ["patch", "minor", "major"] as const;
@@ -70,17 +87,7 @@ function parseSibling(value: string, previous: Sibling[]): Sibling[] {
70
87
  return [...previous, { dir: value.slice(0, at), prefix: value.slice(at + 1) }];
71
88
  }
72
89
 
73
- /** Parse `x.y.z` (ignoring any leading `v`/prefix), returning a `[maj,min,pat]` tuple. */
74
- function parseSemver(raw: string): [number, number, number] | undefined {
75
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
76
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
77
- }
78
-
79
- function compareSemver(a: [number, number, number], b: [number, number, number]): number {
80
- return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
81
- }
82
-
83
- function increment(v: [number, number, number], level: Level): [number, number, number] {
90
+ function increment(v: Semver, level: Level): Semver {
84
91
  if (level === "major") return [v[0] + 1, 0, 0];
85
92
  if (level === "minor") return [v[0], v[1] + 1, 0];
86
93
  return [v[0], v[1], v[2] + 1];
@@ -97,29 +104,6 @@ function git(args: string[], capture = false): string {
97
104
  return res.stdout?.trim() ?? "";
98
105
  }
99
106
 
100
- /** Highest tag matching `<prefix><semver>`, or undefined. Call {@link fetchTags} first. */
101
- function latestTagVersion(prefix: string): [number, number, number] | undefined {
102
- const out = git(
103
- ["-c", "versionsort.suffix=-", "tag", "--sort=-version:refname", "--list", `${prefix}*`],
104
- true,
105
- );
106
- for (const tag of out.split("\n")) {
107
- const v = parseSemver(tag.replace(prefix, ""));
108
- if (v) return v;
109
- }
110
- return undefined;
111
- }
112
-
113
- /** Pull remote tags once, so a release made elsewhere is respected. */
114
- function fetchTags(): void {
115
- git(["fetch", "--tags", "--quiet"], true);
116
- }
117
-
118
- function readPackageVersion(pkgPath: string): [number, number, number] {
119
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
120
- return parseSemver(pkg.version ?? "") ?? [0, 0, 0];
121
- }
122
-
123
107
  /**
124
108
  * Write ONLY the `version` field into a manifest projen owns (read-only, so
125
109
  * bracketed by a chmod that restores the mode). Used for the ROOT and the
@@ -190,7 +174,7 @@ program
190
174
  )
191
175
  .option(
192
176
  "--local-pypi <value>",
193
- "publish Python packages locally: 'auto' (only a loopback devpi +simple index), 'false', or a devpi URL",
177
+ "publish Python packages locally: 'auto' (any active index-url or extra-index-url that is a loopback devpi +simple), 'false', or a devpi URL",
194
178
  "auto",
195
179
  )
196
180
  .option("--python-root <path>", "Python workspace package root", "packages/py")
@@ -217,50 +201,55 @@ program
217
201
  if (!existsSync(s.pkgPath)) throw new Error(`--sibling ${s.dir}: no package.json there`);
218
202
  }
219
203
 
220
- // Synth first so the release commit captures an up-to-date tree (generated
221
- // manifests, workspace file, tasks, ...) rather than a stale one.
222
- if (opts.synth) {
223
- logger.info("synthesizing (projen)");
224
- exec.spawnSync("bun", [".projenrc.ts"], {
225
- cwd: process.cwd(),
226
- stdout: "inherit",
227
- stderr: "inherit",
228
- stdin: "ignore",
229
- check: true,
230
- });
231
- }
204
+ // The `VERSION` file is the workspace source of truth and lives at the repo
205
+ // root, even when this task runs from a subdirectory (`cd projen && bun run
206
+ // bump`), so the engine and packages always share one number.
207
+ const root = project.root() ?? process.cwd();
232
208
 
233
- // Base = highest of the local package version and the latest tag in EVERY
234
- // namespace being released, so one shared version stays ahead of them all.
235
- fetchTags();
209
+ // Base = the highest published tag across EVERY namespace being released
210
+ // (fetched from the remote so a release cut elsewhere wins), falling back to
211
+ // the local `VERSION` file when the remote is unreachable or has no tag. A
212
+ // local file that happens to be ahead does NOT override an existing remote.
236
213
  const prefixes = [opts.prefix, ...siblings.map((s) => s.prefix)];
237
- const tagged = prefixes
238
- .map((prefix) => ({ prefix, version: latestTagVersion(prefix) }))
239
- .filter((t): t is { prefix: string; version: [number, number, number] } => !!t.version);
240
- const base = tagged.reduce(
241
- (highest, t) => (compareSemver(t.version, highest) > 0 ? t.version : highest),
242
- readPackageVersion(pkgPath),
243
- );
214
+ const baseInfo = resolveBaseVersion(root, prefixes);
215
+ const base = parseSemver(baseInfo.version) ?? [0, 0, 1];
244
216
  const next = increment(base, opts.level);
245
217
  const version = next.join(".");
246
218
  const tags = prefixes.map((prefix) => `${prefix}${version}`);
247
219
  logger.info(
248
220
  `bump ${base.join(".")} -> ${version} (${opts.level}); tags ${tags.join(", ")}` +
249
- `${tagged.length ? "" : " [no remote tag]"}`,
221
+ `${baseInfo.source === "remote" ? "" : " [no remote tag; used local VERSION]"}`,
250
222
  );
251
- for (const t of tagged) {
252
- if (compareSemver(t.version, base) < 0) {
253
- logger.info(`${t.prefix}* was behind at ${t.version.join(".")}, catching it up`);
223
+ // Note any tag namespace that trailed the base so a lockstep catch-up is visible.
224
+ for (const prefix of prefixes) {
225
+ const tagged = latestTagVersion(root, prefix);
226
+ if (tagged && compareSemver(tagged, base) < 0) {
227
+ logger.info(`${prefix}* was behind at ${tagged.join(".")}, catching it up`);
254
228
  }
255
229
  }
256
230
 
257
231
  const push = opts.push && opts.publish;
258
232
 
233
+ // Write the source of truth first, then synth so every generated manifest
234
+ // COPIES it - synth never invents, resets, or drifts a version on its own.
259
235
  if (opts.version) {
236
+ writeWorkspaceVersion(root, version);
237
+ // Keep the writable root/sibling manifests coherent even when synth is
238
+ // skipped (`--no-synth`); synth would otherwise set the same value.
260
239
  writeManifestVersion(pkgPath, version);
261
240
  for (const s of siblings) writeManifestVersion(s.pkgPath, version);
262
- const also = siblings.length ? ` (and ${siblings.map((s) => s.dir).join(", ")})` : "";
263
- logger.info(`wrote version ${version} to package.json${also}`);
241
+ logger.info(`wrote version ${version} to ${root}/VERSION`);
242
+ }
243
+
244
+ if (opts.synth) {
245
+ logger.info("synthesizing (projen)");
246
+ exec.spawnSync("bun", [".projenrc.ts"], {
247
+ cwd: process.cwd(),
248
+ stdout: "inherit",
249
+ stderr: "inherit",
250
+ stdin: "ignore",
251
+ check: true,
252
+ });
264
253
  }
265
254
 
266
255
  if (opts.commit) {
@@ -297,16 +286,15 @@ program
297
286
  }
298
287
  if (publishToLocalRegistry) {
299
288
  logger.info(`publishing ${version} to local registry ${localRegistry}`);
300
- // Mirror the CI `release` workflow via the shared publish task: it sets
301
- // the release version on every workspace member (`bun pm pkg set`; they
302
- // keep `0.0.0` on disk, projen-owned, so it unlocks each briefly), then
303
- // `bun publish`es each non-private one - and bun natively strips the
304
- // `workspace:`/`catalog:` protocols in the packed tarball, resolving each
305
- // to the version just set. `publish.ts` restores every manifest it touched
306
- // at exit, so this leaves the worktree matching the release commit that was
307
- // just pushed - the release version lives in the git tag, not on disk.
308
- // Provenance is off for a local registry (no OIDC), so
309
- // `NPM_CONFIG_PROVENANCE` is unset.
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.
310
298
  // `publish.ts` is this task's SIBLING in the engine's `tasks/` dir; resolve
311
299
  // it off `import.meta.url` (works whether the engine is source-linked in-repo
312
300
  // or installed under node_modules) rather than a repo-relative `tasks/...`
@@ -322,16 +310,21 @@ program
322
310
  logger.success(`published ${version} to ${localRegistry}`);
323
311
  }
324
312
 
325
- const activeIndex = activePythonIndex();
326
- const localPypi = resolveLocalPypi(opts.localPypi, activeIndex);
313
+ // Scan EVERY active index (primary index-url + every extra-index-url,
314
+ // across uv and pip): auto-mode publishes to the first that is a loopback
315
+ // devpi +simple, so the corp proxy stays primary and a local devpi added
316
+ // as an extra index is the detected publish target.
317
+ const activeIndexes = activePythonIndexes();
318
+ const localPypi = resolveLocalPypi(opts.localPypi, activeIndexes);
327
319
  const pythonRoot = resolve(opts.pythonRoot);
328
320
  if (
329
321
  opts.localPypi.toLowerCase() === "auto" &&
330
- activeIndex &&
331
- net.isLoopbackHost(new URL(activeIndex)) &&
322
+ activeIndexes.some((index) => net.isLoopbackHost(index)) &&
332
323
  !localPypi
333
324
  ) {
334
- logger.info(`skipped local Python publish: ${activeIndex} is not a devpi +simple index`);
325
+ logger.info(
326
+ `skipped local Python publish: no active index (${activeIndexes.join(", ")}) is a devpi +simple index`,
327
+ );
335
328
  }
336
329
  if (opts.version === false && localPypi) {
337
330
  logger.info("skipped local Python publish (--no-version left packages unstamped)");
@@ -360,16 +353,6 @@ program
360
353
  );
361
354
  logger.success(`published Python ${version} to ${localPypi.publishUrl}`);
362
355
  }
363
-
364
- // Publishing can run package lifecycle hooks, including a standalone
365
- // project's own projen synth, which rewrites its generated manifest back
366
- // to 0.0.0. Re-assert the release version last so root and every sibling
367
- // manifest finish the bump in lockstep.
368
- if (opts.version) {
369
- writeManifestVersion(pkgPath, version);
370
- for (const s of siblings) writeManifestVersion(s.pkgPath, version);
371
- logger.info(`synchronized release manifests at ${version}`);
372
- }
373
356
  },
374
357
  );
375
358
 
@@ -22,11 +22,24 @@ interface PythonProjectFile {
22
22
  readonly source: string;
23
23
  }
24
24
 
25
+ export interface StampPythonProjectsOptions {
26
+ readonly rewriteDependencies?: boolean;
27
+ }
28
+
29
+ export interface RestorePythonProjects {
30
+ (): void;
31
+ readonly paths: readonly string[];
32
+ }
33
+
25
34
  function escapeRegExp(value: string): string {
26
35
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27
36
  }
28
37
 
29
- export function stampPythonProjects(root: string, version: string): () => void {
38
+ export function stampPythonProjects(
39
+ root: string,
40
+ version: string,
41
+ options: StampPythonProjectsOptions = {},
42
+ ): RestorePythonProjects {
30
43
  const packageFiles = readdirSync(root, { withFileTypes: true })
31
44
  .filter((entry) => entry.isDirectory())
32
45
  .map((entry) => resolve(root, entry.name, "pyproject.toml"))
@@ -52,17 +65,20 @@ export function stampPythonProjects(root: string, version: string): () => void {
52
65
  if (stamped === project.source) {
53
66
  throw new Error(`Expected one project version in ${project.path}`);
54
67
  }
55
- for (const sibling of projects) {
56
- stamped = stamped.replace(
57
- new RegExp(
58
- `${escapeRegExp(sibling.name)} @ git\\+[^" ]+#subdirectory=[^" ]+/${escapeRegExp(sibling.directory)}`,
59
- "g",
60
- ),
61
- `${sibling.name}==${version}`,
62
- );
68
+ if (options.rewriteDependencies ?? true) {
69
+ for (const sibling of projects) {
70
+ stamped = stamped.replace(
71
+ new RegExp(
72
+ `${escapeRegExp(sibling.name)} @ git\\+[^" ]+#subdirectory=[^" ]+/${escapeRegExp(sibling.directory)}`,
73
+ "g",
74
+ ),
75
+ `${sibling.name}==${version}`,
76
+ );
77
+ }
63
78
  }
64
79
  chmodSync(project.path, project.mode | 0o200);
65
80
  writeFileSync(project.path, stamped);
81
+ chmodSync(project.path, project.mode);
66
82
  }
67
83
  } catch (error) {
68
84
  for (const project of projects) {
@@ -73,13 +89,17 @@ export function stampPythonProjects(root: string, version: string): () => void {
73
89
  throw error;
74
90
  }
75
91
 
76
- return () => {
92
+ const restore = () => {
77
93
  for (const project of projects) {
78
94
  chmodSync(project.path, project.mode | 0o200);
79
95
  writeFileSync(project.path, project.source);
80
96
  chmodSync(project.path, project.mode);
81
97
  }
82
98
  };
99
+ Object.defineProperty(restore, "paths", {
100
+ value: projects.map((project) => project.path),
101
+ });
102
+ return restore as RestorePythonProjects;
83
103
  }
84
104
 
85
105
  export function publishPythonProjects(options: {
@@ -91,7 +111,7 @@ export function publishPythonProjects(options: {
91
111
  }): void {
92
112
  const root = resolve(options.root);
93
113
  const output = mkdtempSync(join(tmpdir(), "dbx-tools-python-publish-"));
94
- const restore = stampPythonProjects(root, options.version);
114
+ const stamp = stampPythonProjects(root, options.version);
95
115
  try {
96
116
  exec.spawnSync("uv", ["build", "--all-packages", "--out-dir", output], {
97
117
  cwd: process.cwd(),
@@ -123,7 +143,7 @@ export function publishPythonProjects(options: {
123
143
  },
124
144
  );
125
145
  } finally {
126
- restore();
146
+ stamp();
127
147
  rmSync(output, { recursive: true, force: true });
128
148
  }
129
149
  }
package/tasks/publish.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * packed manifest shows `"@scope/x": "<version>"` and the real catalog range,
18
18
  * while the on-disk manifest keeps the protocols.) Setting each member's
19
19
  * version first is the only prerequisite, so a sibling resolves the release
20
- * version rather than the disk default of `0.0.0`;
20
+ * version; the disk manifest already carries the workspace `VERSION`, and
21
+ * this makes doubly sure it matches the value being published;
21
22
  * - **`publishConfig` substitution** (compiled `lib/` entry points) is done
22
23
  * HERE, by {@link applyPublishConfig}, NOT by bun: unlike pnpm/npm, `bun
23
24
  * publish`/`bun pm pack` do NOT fold `publishConfig`'s `main`/`types`/`bin`/
@@ -42,10 +43,13 @@
42
43
  * since its whole job is to stamp the workspace for a `bun publish` that runs
43
44
  * afterwards from another directory.
44
45
  *
45
- * The disk manifests normally carry `version: 0.0.0` (projen owns them, read-only);
46
- * this unlocks each only long enough to set the version + publish, then RESTORES
47
- * every one it touched byte-for-byte (and re-locks the mode) on the way out - see
48
- * {@link restoreManifests}. The release version lives in the git tag, not on disk.
46
+ * The disk manifests carry the workspace `VERSION` (projen owns them, read-only);
47
+ * this unlocks each only long enough to fold in the `publishConfig` entry points
48
+ * (and re-affirm the version) + publish, then RESTORES every one it touched
49
+ * byte-for-byte (and re-locks the mode) on the way out - see
50
+ * {@link restoreManifests}. Restore returns each manifest to its committed
51
+ * content, which already equals the release version, so the worktree is never
52
+ * left regressed.
49
53
  */
50
54
  import { chmodSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
51
55
  import { dirname, join, resolve } from "node:path";
@@ -215,8 +219,9 @@ const members = workspaceMembers(root)
215
219
  .filter((dir) => !excluded.has(resolve(root, dir).replace(`${resolve(root)}/`, "")));
216
220
 
217
221
  // Set the version on EVERY member first (native `bun pm pkg set`), so a sibling
218
- // published later has its `workspace:*` dep resolved to the release version - not
219
- // the disk default of 0.0.0 - by `bun publish`'s own protocol rewriting.
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.
220
225
  logger.info(`setting ${version} across ${members.length} members`);
221
226
  for (const dir of members) {
222
227
  unlockManifest(join(dir, "package.json"));
@@ -227,7 +232,8 @@ for (const dir of members) {
227
232
  // just set. `bun publish`/`pm pack` reads the workspace version from the LOCKFILE,
228
233
  // not the live manifest, and a plain `bun install` (even `--force`) does NOT
229
234
  // re-resolve it after only a version-field change - deleting the lockfile first
230
- // does. Without this every `workspace:*` dep would publish as the stale `0.0.0`.
235
+ // does. Without this a `workspace:*` dep could publish against a stale resolved
236
+ // version instead of the one just set.
231
237
  const lockfile = join(root, "bun.lock");
232
238
  if (existsSync(lockfile)) rmSync(lockfile);
233
239
  logger.info("refreshing lockfile so workspace deps resolve to the release version");
@@ -22,7 +22,72 @@ export function parseUvDefaultIndex(source: string): string | undefined {
22
22
  return undefined;
23
23
  }
24
24
 
25
- /** Convert a devpi Simple API URL into its writable index URL. */
25
+ /**
26
+ * Read EVERY index URL from uv's TOML config — the default (primary) index
27
+ * first, then any extra `[[index]]` entries in file order. uv's `[[index]]`
28
+ * table with `default = true` is the primary index; every other `[[index]]`
29
+ * is an extra index also consulted at resolve time, so a local devpi added as
30
+ * a non-default block is discoverable here.
31
+ */
32
+ export function parseUvIndexes(source: string): string[] {
33
+ const blocks = source.split(/(?=^\[\[index\]\]\s*$)/m);
34
+ let primary: string | undefined;
35
+ const extras: string[] = [];
36
+ for (const block of blocks) {
37
+ if (!/^\[\[index\]\]\s*$/m.test(block)) continue;
38
+ const url = /^\s*url\s*=\s*["']([^"']+)["']\s*$/m.exec(block)?.[1];
39
+ if (!url) continue;
40
+ if (/^\s*default\s*=\s*true\s*$/m.test(block)) primary = url;
41
+ else extras.push(url);
42
+ }
43
+ return primary ? [primary, ...extras] : extras;
44
+ }
45
+
46
+ /**
47
+ * Read every `[[index]]` block's `url` -> `publish-url` mapping from uv's TOML
48
+ * config. uv lets an index declare an explicit upload endpoint via `publish-url`
49
+ * (the writable index URL, distinct from the `+simple` read URL); this is the
50
+ * global setting that names the local deploy target, so auto-detection prefers
51
+ * it over deriving the publish URL from the `+simple` URL shape.
52
+ */
53
+ export function parseUvPublishUrls(source: string): Map<string, string> {
54
+ const map = new Map<string, string>();
55
+ const blocks = source.split(/(?=^\[\[index\]\]\s*$)/m);
56
+ for (const block of blocks) {
57
+ if (!/^\[\[index\]\]\s*$/m.test(block)) continue;
58
+ const url = /^\s*url\s*=\s*["']([^"']+)["']\s*$/m.exec(block)?.[1];
59
+ const publishUrl = /^\s*publish-url\s*=\s*["']([^"']+)["']\s*$/m.exec(block)?.[1];
60
+ if (url && publishUrl) map.set(url, publishUrl);
61
+ }
62
+ return map;
63
+ }
64
+
65
+ /**
66
+ * The explicit `publish-url` configured (in the global uv config or
67
+ * `UV_PUBLISH_URL`) for a given `+simple` index URL, or `undefined` when none is
68
+ * set. Lets auto-detection use the deploy endpoint the user declared globally
69
+ * rather than inferring it from the index URL.
70
+ */
71
+ export function configuredPublishUrl(
72
+ indexUrl: string,
73
+ uvConfigPath: string = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml"),
74
+ ): string | undefined {
75
+ const fromEnv = process.env.UV_PUBLISH_URL?.trim();
76
+ if (fromEnv) return fromEnv;
77
+ if (!existsSync(uvConfigPath)) return undefined;
78
+ return parseUvPublishUrls(readFileSync(uvConfigPath, "utf8")).get(indexUrl);
79
+ }
80
+
81
+ /**
82
+ * Convert a devpi Simple API URL into a local publish target.
83
+ *
84
+ * The publish URL is taken from the GLOBAL uv config first — an explicit
85
+ * `publish-url` on the matching `[[index]]` (or `UV_PUBLISH_URL`), i.e. the
86
+ * deploy endpoint the user declared — and only DERIVED from the `+simple` URL
87
+ * shape when no such setting exists. Returns `undefined` for a non-loopback
88
+ * host or a URL that is neither a `+simple` index nor has a configured
89
+ * `publish-url`.
90
+ */
26
91
  export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
27
92
  let url: URL;
28
93
  try {
@@ -32,6 +97,16 @@ export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
32
97
  }
33
98
  if (!net.isLoopbackHost(url)) return undefined;
34
99
 
100
+ // A globally-configured publish-url wins: it names the deploy target directly,
101
+ // so we honor it even if the index URL isn't the conventional `+simple` shape.
102
+ const configured = configuredPublishUrl(url.href);
103
+ if (configured) {
104
+ return {
105
+ indexUrl: url.href,
106
+ publishUrl: configured.endsWith("/") ? configured : `${configured}/`,
107
+ };
108
+ }
109
+
35
110
  const path = url.pathname.replace(/\/+$/, "");
36
111
  if (!path.endsWith("/+simple")) return undefined;
37
112
  url.pathname = `${path.slice(0, -"/+simple".length)}/`;
@@ -43,38 +118,94 @@ export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
43
118
  };
44
119
  }
45
120
 
46
- /** The active Python package index, preferring uv because Python builds use uv. */
47
- export function activePythonIndex(): string | undefined {
48
- for (const value of [process.env.UV_DEFAULT_INDEX, process.env.UV_INDEX_URL]) {
49
- if (value?.trim()) return value.trim();
50
- }
51
-
52
- const uvConfig = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml");
53
- if (existsSync(uvConfig)) {
54
- const index = parseUvDefaultIndex(readFileSync(uvConfig, "utf8"));
55
- if (index) return index;
56
- }
121
+ /** Split a whitespace-separated index list (the pip/uv env-var form). */
122
+ function splitIndexList(value: string | undefined): string[] {
123
+ const trimmed = value?.trim();
124
+ return trimmed ? trimmed.split(/\s+/) : [];
125
+ }
57
126
 
58
- if (process.env.PIP_INDEX_URL?.trim()) return process.env.PIP_INDEX_URL.trim();
59
- const pip = exec.spawnSync("python", ["-m", "pip", "config", "get", "global.index-url"], {
127
+ /** Read a `pip config get <key>`, treating pip's literal "undefined" as unset. */
128
+ function pipConfig(key: string): string | undefined {
129
+ const res = exec.spawnSync("python", ["-m", "pip", "config", "get", key], {
60
130
  cwd: process.cwd(),
61
131
  stdout: "capture",
62
132
  stderr: "ignore",
63
133
  stdin: "ignore",
64
134
  check: false,
65
135
  });
66
- return pip.stdout?.trim() || undefined;
136
+ const out = res.stdout?.trim();
137
+ return out && out !== "undefined" ? out : undefined;
67
138
  }
68
139
 
69
- /** Resolve `auto`, `false`, or an explicit devpi index/publish URL. */
140
+ /**
141
+ * Every Python package index in effect — the primary index FIRST, then extra
142
+ * indexes — deduplicated, across uv (preferred, because Python builds use uv)
143
+ * and pip. Both the primary `index-url` and every `extra-index-url` are read so
144
+ * a local devpi configured as an *extra* index (leaving the corp proxy as the
145
+ * primary) is still detected.
146
+ */
147
+ export function activePythonIndexes(): string[] {
148
+ const seen = new Set<string>();
149
+ const out: string[] = [];
150
+ const add = (value: string | undefined): void => {
151
+ const url = value?.trim();
152
+ if (url && !seen.has(url)) {
153
+ seen.add(url);
154
+ out.push(url);
155
+ }
156
+ };
157
+
158
+ // uv: env vars first (primary, then extras), then uv.toml's index blocks.
159
+ add(process.env.UV_DEFAULT_INDEX);
160
+ add(process.env.UV_INDEX_URL);
161
+ for (const url of splitIndexList(process.env.UV_INDEX)) add(url);
162
+ for (const url of splitIndexList(process.env.UV_EXTRA_INDEX_URL)) add(url);
163
+ const uvConfig = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml");
164
+ if (existsSync(uvConfig)) {
165
+ for (const url of parseUvIndexes(readFileSync(uvConfig, "utf8"))) add(url);
166
+ }
167
+
168
+ // pip: env vars (primary + extras), then `pip config` (index-url + extra-index-url).
169
+ add(process.env.PIP_INDEX_URL);
170
+ for (const url of splitIndexList(process.env.PIP_EXTRA_INDEX_URL)) add(url);
171
+ add(pipConfig("global.index-url"));
172
+ for (const url of splitIndexList(pipConfig("global.extra-index-url"))) add(url);
173
+
174
+ return out;
175
+ }
176
+
177
+ /** The primary (first) active Python index, or `undefined` when none is set. */
178
+ export function activePythonIndex(): string | undefined {
179
+ return activePythonIndexes()[0];
180
+ }
181
+
182
+ /**
183
+ * Resolve `auto`, `false`, or an explicit devpi index/publish URL.
184
+ *
185
+ * - `false` (or empty): skip local publishing.
186
+ * - a URL: publish there (derive the writable index from a `+simple` URL, else
187
+ * treat the value itself as the writable index).
188
+ * - `auto`: scan every active index — primary `index-url` AND every
189
+ * `extra-index-url`, across uv and pip — and publish to the FIRST that is a
190
+ * loopback devpi `+simple` index. This is what lets the corp proxy stay the
191
+ * primary index while a local devpi added as an extra is the publish target.
192
+ *
193
+ * `indexes` accepts an array (the normal case) or a single string (kept for the
194
+ * existing single-index callers/tests); it defaults to {@link activePythonIndexes}.
195
+ */
70
196
  export function resolveLocalPypi(
71
197
  value: string,
72
- activeIndex: string | undefined = activePythonIndex(),
198
+ indexes: readonly string[] | string | undefined = activePythonIndexes(),
73
199
  ): LocalPythonRegistry | undefined {
74
200
  const trimmed = value.trim();
75
201
  if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
76
202
  if (trimmed.toLowerCase() === "auto") {
77
- return activeIndex ? devpiRegistry(activeIndex) : undefined;
203
+ const list = typeof indexes === "string" ? [indexes] : (indexes ?? []);
204
+ for (const index of list) {
205
+ const registry = devpiRegistry(index);
206
+ if (registry) return registry;
207
+ }
208
+ return undefined;
78
209
  }
79
210
 
80
211
  const derived = devpiRegistry(trimmed);