diff --git a/.changeset/billing-peer-range.md b/.changeset/billing-peer-range.md new file mode 100644 index 0000000..cf3eb0e --- /dev/null +++ b/.changeset/billing-peer-range.md @@ -0,0 +1,5 @@ +--- +"@codelitdev/platform": patch +--- + +Declare the optional `@codelitdev/billing` peer as `>=0.4.0`. 0.4.0 published it as `workspace:*`, which npm refuses to install. diff --git a/bun.lock b/bun.lock index 8206b92..c4ea6e0 100644 --- a/bun.lock +++ b/bun.lock @@ -80,7 +80,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.3.0", + "version": "0.4.0", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +106,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,7 +124,7 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76", @@ -141,7 +141,7 @@ }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@better-auth/core": "1.7.2", "@better-auth/oauth-provider": "1.7.2", @@ -165,7 +165,7 @@ }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "pino": "^9.6.0", }, @@ -190,7 +190,7 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -210,7 +210,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.3.0", + "version": "0.4.0", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -222,7 +222,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.3.0", + "version": "0.4.0", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", diff --git a/docs/decisions/0015-resolve-workspace-ranges-before-publishing.md b/docs/decisions/0015-resolve-workspace-ranges-before-publishing.md new file mode 100644 index 0000000..4bfbbff --- /dev/null +++ b/docs/decisions/0015-resolve-workspace-ranges-before-publishing.md @@ -0,0 +1,46 @@ +# ADR 0015: Resolve workspace ranges before publishing + +Status: accepted +Date: 2026-10-09 + +## Context + +Packages in this repository depend on each other with `workspace:` ranges. +`changeset publish` publishes with `npm publish`, which copies a manifest +as committed and does not rewrite `workspace:` ranges, unlike Bun and pnpm. +Until 0.4.0 these ranges appeared only in `devDependencies`, which npm +never installs for consumers. 0.4.0 published `@codelitdev/platform` with +an optional peer of `workspace:*`, and npm refuses to install it +(`EUNSUPPORTEDPROTOCOL`). Bun installs it, and `bun pm pack` rewrites the +range, so testing with packed tarballs did not catch it. + +## Decision + +- Ranges that consumers read (`dependencies`, `peerDependencies`, + `optionalDependencies`) on another `@codelitdev/*` package are written as + real ranges, such as `>=0.4.0`. Changesets keeps those ranges up to date + when versions change. +- The `release` script runs `scripts/resolve-workspace-ranges.mjs` after + `verify` and before `changeset publish`. It rewrites every `workspace:` + range left in a public package's manifest to a real version: + - `workspace:^` becomes `^` + - `workspace:~` becomes `~` + - `workspace:*` becomes `` + - `workspace:` becomes `` +- The rewrite happens only on the release job's checkout, which is thrown + away. It is never committed, and the release PR keeps `workspace:` ranges. +- Workspace patterns are expanded one folder level at a time. A level is a + literal name or `*`; any other pattern stops the release, so a public + package is never skipped. + +## Consequences + +- A published manifest differs from the committed one only in its + `workspace:` ranges. Today those are only in `devDependencies`. +- A `workspace:` range written by mistake in a consumer-facing field still + publishes something npm can install. `workspace:*` becomes an exact + version, which is stricter than intended for a peer. +- Publishing by hand needs the same script first; `npm publish` alone + reintroduces the problem. +- A new workspace pattern that uses other wildcards needs the script + changed before the next release. diff --git a/package.json b/package.json index 1f90ed9..83dd761 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "template:smoke": "bun scripts/template-smoke.mjs", "changeset": "changeset", "version-packages": "changeset version && bun scripts/sync-preset.mjs", - "release": "bun run verify && changeset publish", + "release": "bun run verify && node scripts/resolve-workspace-ranges.mjs && changeset publish", "prepare": "husky" }, "devDependencies": { diff --git a/packages/platform/package.json b/packages/platform/package.json index e102da7..95b3846 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -56,7 +56,7 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@codelitdev/billing": "workspace:*", + "@codelitdev/billing": ">=0.4.0", "drizzle-orm": "^0.45.2" }, "peerDependenciesMeta": { diff --git a/scripts/resolve-workspace-ranges.mjs b/scripts/resolve-workspace-ranges.mjs new file mode 100644 index 0000000..e72a909 --- /dev/null +++ b/scripts/resolve-workspace-ranges.mjs @@ -0,0 +1,75 @@ +// Rewrites `workspace:` dependency ranges in public packages to real version +// ranges before `changeset publish`. Changesets publishes with `npm publish`, +// which copies `workspace:^` into the published package.json as is, and npm +// cannot install that. Runs on CI's throwaway checkout only. +// See docs/decisions/0015-resolve-workspace-ranges-before-publishing.md. +// +// workspace:^ -> ^, workspace:~ -> ~, +// workspace:* -> , workspace: -> +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = + process.argv[2] || path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootManifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + +// Expands a workspace pattern one path segment at a time. A segment is either +// a literal name or `*` (every directory at that level). Anything else throws, +// so a pattern this script cannot read never skips a public package silently. +function expand(pattern) { + let directories = [root]; + for (const segment of pattern.split("/")) { + if (segment === "*") { + directories = directories.flatMap((directory) => + readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "node_modules") + .map((entry) => path.join(directory, entry.name)), + ); + } else if (/[*?[\]{}()!|+]/.test(segment)) { + throw new Error(`Unsupported workspace pattern: ${pattern}`); + } else { + directories = directories + .map((directory) => path.join(directory, segment)) + .filter((directory) => existsSync(directory)); + } + } + return directories + .map((directory) => path.join(directory, "package.json")) + .filter((file) => existsSync(file)); +} + +const manifests = rootManifest.workspaces.flatMap(expand).map((file) => ({ + file, + json: JSON.parse(readFileSync(file, "utf8")), +})); +const versions = new Map(manifests.map(({ json }) => [json.name, json.version])); + +function resolve(name, range) { + const version = versions.get(name); + if (!version) throw new Error(`No workspace package named ${name}`); + const spec = range.slice("workspace:".length); + if (spec === "^" || spec === "~") return `${spec}${version}`; + if (spec === "*") return version; + return spec; +} + +const fields = [ + "dependencies", + "peerDependencies", + "optionalDependencies", + "devDependencies", +]; +for (const { file, json } of manifests) { + if (json.private) continue; + let changed = false; + for (const field of fields) { + for (const [name, range] of Object.entries(json[field] || {})) { + if (typeof range !== "string" || !range.startsWith("workspace:")) continue; + json[field][name] = resolve(name, range); + console.log(`${json.name}: ${name} ${range} -> ${json[field][name]}`); + changed = true; + } + } + if (changed) writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`); +}