← Back to Blog

release.mjs: one command, eleven packages

·8 min read

Releasing pyRPC means bumping the version in eleven packages across two ecosystems at once. Doing that by hand is an invitation to drift, one package at 0.12.0 and another at 0.11.1, with a broken publish chain as the reward. The fix is a 85-line Node script that walks the packages directory and rewrites every version marker.

The entry point

node scripts/release.mjs 0.12.0

// the script even tolerates a stray 'v':
const cleanVersion = newVersion.replace(/^v/, '');

One argument, zero flags. The v-stripping is a small ergonomic touch with a real purpose: the release workflow is described as git tag v0.12.0 everywhere, so a user copy-pasting the tag into the script gets the right result either way.

The walk

The script reads the packages/ directory and, for every directory, applies up to three independent edits:

  • package.json, if present, set version.
  • pyproject.toml, if present, regex-replace the version = "..." line.
  • src/<pkg>/__init__.py, if present and contains __version__, rewrite it.

Existence checks make the walk safe for heterogeneous packages: an npm-only package gets one edit, a Python-only package gets two, and nothing breaks because a file is missing.

The dependency sweep

The most subtle part is not the version itself, it is every reference to the version from inside the other packages:

for (const section of ['dependencies', 'peerDependencies']) {
  for (const name of Object.keys(ranges)) {
    if (name.startsWith('@pyrpc/')) {
      ranges[name] = `^${cleanVersion}`;
    }
  }
}

Every internal @pyrpc/* range is swept to ^0.12.0. If this step were skipped, @pyrpc/react could be published at 0.12.0 while still depending on @pyrpc/client@^0.11.0, a broken version pair on the registry. The prefix check means third-party dependencies are never touched.

The root pyproject

The workspace root also carries a pyproject.toml used by uv, and it gets the same regex treatment as the package-level ones. The root version is a workspace-coordination value, not a published artifact, but keeping it in lockstep avoids confusing uv lock diffs.

What the script does not do

The script ends by printing the follow-up commands: commit, tag, push. It deliberately does not create the tag itself. That decision keeps a human in the loop at the one point where a mistake is unrecoverable (a pushed tag triggers the publish pipeline). The script is idempotent machinery; the tag is a conscious act.

The lesson

Version synchronization is a mechanical problem, so it gets a mechanical solution. The script's value is not cleverness, it is completeness: eleven packages, both ecosystems, dependency ranges included, root workspace included. A checklist you can run is a checklist that cannot be half-executed.