Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/billing-peer-range.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 8 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions docs/decisions/0015-resolve-workspace-ranges-before-publishing.md
Original file line number Diff line number Diff line change
@@ -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 `^<version>`
- `workspace:~` becomes `~<version>`
- `workspace:*` becomes `<version>`
- `workspace:<range>` becomes `<range>`
- 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"typescript": "^5.9.3"
},
"peerDependencies": {
"@codelitdev/billing": "workspace:*",
"@codelitdev/billing": ">=0.4.0",
"drizzle-orm": "^0.45.2"
},
"peerDependenciesMeta": {
Expand Down
75 changes: 75 additions & 0 deletions scripts/resolve-workspace-ranges.mjs
Original file line number Diff line number Diff line change
@@ -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:^ -> ^<version>, workspace:~ -> ~<version>,
// workspace:* -> <version>, workspace:<range> -> <range>
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`);
Comment thread
rajat1saxena marked this conversation as resolved.
}
Loading