← Back to Blog

The version contract between npm workspaces and uv

·7 min read

The npm and Python sides of the repo never import each other's code. But they share a version number, and that number is an unenforced contract. Nothing in either package manager knows about the other, the agreement lives entirely in the release script and in human discipline. This post is about how that contract is structured and where it can quietly break.

The two dependency languages

Python and npm express the same "depends on another package" idea differently:

# pyproject.toml (pyrpc-fastapi)
dependencies = ["pyrpc-core>=0.12.0"]

// package.json (@pyrpc/react)
"dependencies": { "@pyrpc/client": "^0.12.0" }

One uses PEP 508 requirement strings, the other uses semver ranges with a caret. Neither syntax is shared, neither resolver coordinates with the other, and both package managers would happily resolve against a registry where the two ecosystems have drifted apart.

The contract is a convention

pyRPC commits to a strong invariant: all eleven packages ship the same version, and cross-package dependencies always reference that version. The invariant is enforced by convention and tooling:

  • One tag (v0.12.0) names one release containing both ecosystems.
  • release.mjs sweeps both @pyrpc/* ranges (npm) and, implicitly, the Python dependencies via the shared version.
  • Lockfiles are re-synced after every bump so both resolvers agree with the manifests.

The word "implicitly" is doing heavy lifting on the Python side: the script bumps versions but does not sweep PEP 508 requirement strings, because those use >=0.12.0 bounds rather than exact versions, a range that remains satisfied as the version rises. The Python contract is looser by design.

Where drift could creep in

The failure modes are exactly the ones the release flow guards against:

  • Bumping npm packages but forgetting the Python side → the tag ships half a version.
  • Bumping manifests but not the lockfiles → CI's locked resolution fails.
  • Hand-editing one package.json range → a published pair that cannot co-install.

None of these fail at bump time. They fail later (at publish, at CI, or at a user's install) which is why the process is mechanical and reviewed.

Why coordinate at all?

A monorepo could skip the contract entirely and let each package version independently. pyRPC chooses lockstep because the product is consumed as a system: you install a Python adapter and a TypeScript adapter that were designed together and are documented together. A shared version is the simplest possible compatibility statement, everything from tag X works together. It costs some flexibility and buys a guarantee users can verify by reading one number.

The lesson

When two package managers share a monorepo, the version is a contract you must enforce because neither tool will. The enforcement points are a shared tag, a sweeping script, and lockfile syncing, mechanical steps that turn a fragile convention into a repeatable process.