DEV Community

Cover image for Why I Built pubm: One CLI to Publish to npm, JSR, and Beyond
Yein Sung
Yein Sung

Posted on

Why I Built pubm: One CLI to Publish to npm, JSR, and Beyond

It was a Friday afternoon. npm publish finished clean. Then I remembered I hadn't updated jsr.json. Ran npx jsr publish. Auth error, my JSR token had expired. So I refreshed it, ran again, and the version numbers between npm and JSR were now out of sync because I'd forgotten to bump jsr.json before the first publish.

That was the moment I started writing pubm. I began the project in 2024, got it to a working prototype, then life happened and it sat untouched for months. I picked it back up with Claude as a coding partner and brought it to completion. The architecture decisions and test coverage were mine to own (I wasn't going to hand that off), but having an AI pair helped me push through the implementation grind that had stalled the project the first time around.


The Problem: Multi-Registry Publishing is a Manual Mess

When JSR entered public beta in March 2024, it brought something genuinely useful: a TypeScript-first registry backed by Deno, with a governance board that includes Evan You, Isaac Schlueter, and Ryan Dahl. Given that lineup, JSR is here to stay, which means dual-publishing to npm and JSR isn't going away either. It's a complement, and a lot of packages now live on both.

That's where the pain starts.

Publishing to npm and JSR isn't twice the work of publishing to one. It's more like five times, because the two registries want completely different things:

npm JSR
Config file package.json jsr.json
Auth npm token (NODE_AUTH_TOKEN) GitHub OIDC or access token
What to publish Compiled JS TypeScript source
Version bump npm version Manual edit
Publish command npm publish npx jsr publish

Manual steps multiply. And more manual steps means more things to forget or get wrong.

The worst part isn't the individual failures. It's the partial failures. npm succeeds, JSR fails for some auth reason. Now you have version 1.2.3 on npm but 1.2.2 on JSR. How do you recover? You can't easily unpublish from npm (there's a 72-hour window, and it's still messy). So you publish 1.2.4 on JSR to "catch up." Your changelog is now a lie.

No existing tool handles this. I checked.


Existing Tools Don't Cover This Gap

changesets, semantic-release, np, release-it. These are solid tools, and none of them claim to solve multi-registry publishing. They assume npm, full stop.

changesets GitHub issue #1717 captures the problem well: the built-in publish command only supports npm, and anything else requires custom CI scripting. pnpm issue #8317 asks for jsr.json version bumping support. The workarounds people use, jsr2npm, mirror-jsr-to-npm, prove the gap is real, but they're also hacks.

The "official" solution today is: publish to npm via your release tool, then add npx jsr publish as an extra CI step at the end. That's it. No version sync, no rollback if JSR fails after npm succeeds, no unified auth management.

For a single package, this is annoying. For a monorepo with multiple packages going to multiple registries, it becomes genuinely difficult to keep straight.


So I Built pubm

The goal was simple enough to say in one sentence: one command, every registry.

pubm
Enter fullscreen mode Exit fullscreen mode

That's the whole publish workflow. pubm detects your registries from your manifest files (package.json → npm, jsr.json → JSR, Cargo.toml → crates.io), runs preflight checks, bumps versions across all config files in sync, and publishes to every registry in the right order.

Here's pubm's own pubm.config.ts, it publishes itself using itself:

import { defineConfig } from '@pubm/core';
import { brewTap } from '@pubm/plugin-brew';
import { externalVersionSync } from '@pubm/plugin-external-version-sync';

export default defineConfig({
  versioning: "independent",
  excludeRelease: ["packages/pubm/platforms/*"],
  packages: [
    { path: "packages/core" },
    { path: "packages/pubm" },
    { path: "packages/pubm/platforms/*" },
    { path: "packages/plugins/plugin-external-version-sync" },
    { path: "packages/plugins/plugin-brew" },
  ],
  releaseAssets: [
    {
      packagePath: "packages/pubm",
      files: ["platforms/{platform}/bin/pubm"],
      name: "pubm-{platform}",
    },
  ],
  plugins: [
    brewTap({
      formula: "Formula/pubm.rb",
      packageName: "pubm",
      repo: "syi0808/homebrew-pubm",
    }),
    externalVersionSync({
      targets: [
        { file: "website/src/i18n/landing.ts", pattern: /v\d+\.\d+\.\d+/g },
        { file: "plugins/pubm-plugin/.claude-plugin/plugin.json", jsonPath: "version" },
        { file: ".claude-plugin/marketplace.json", jsonPath: "metadata.version" },
        { file: ".claude-plugin/marketplace.json", jsonPath: "plugins.0.version" },
      ],
      version: (packages) => packages.get("packages/core") ?? "",
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Five packages, two plugins, binary assets, Homebrew formula, version strings synced across arbitrary files. One command.

Preflight Checks

Before pubm touches anything, it runs a preflight phase:

  • Branch validation (are you on the right branch?)
  • Clean working tree check
  • Auth token validation for every registry you're publishing to
  • Dry-run publish to catch packaging errors before anything goes out

If any preflight check fails, pubm stops. Nothing has been published, nothing has been committed. You fix the issue and try again.

CI and Local: Same Config, Same Command

Most teams end up with divergent local and CI release workflows. Local is ad-hoc; CI is automated but slightly different. pubm uses the same config and the same command in both environments, just with different phase flags:

# CI: split into two phases
pubm --mode ci --phase prepare
pubm --mode ci --phase publish

# Local: all in one
pubm
Enter fullscreen mode Exit fullscreen mode

Getting the config and CLI right was the easier half. The harder half was what happens when things go wrong mid-release.


The Hard Parts

Rollback

The hardest design decision in rollback was figuring out what to do about registry unpublish. Some registries allow it in a time window; others don't at all. Making rollback feel safe without over-promising on things pubm can't undo took most of the iteration time.

That's the problem from the top of this post made concrete: npm succeeds, JSR fails, and your changelog becomes a lie. The rollback system exists specifically to prevent that state.

pubm's RollbackTracker class records every action that succeeds during a release: version bump, git commit, git tag, npm publish, JSR publish, GitHub release, and so on. If any subsequent step fails, it reverses everything in LIFO order.

Rollback on registry unpublish is best-effort with user confirmation, because some registries have restrictions. But the git operations (undo commit, delete tag) happen automatically. You end up back at the state before you ran pubm. No half-released versions, no changelog drift, no 1.2.4 published on JSR just to catch up to npm.

The rollback system is also SIGINT-safe. If you Ctrl+C during a publish, pubm catches the signal and runs the rollback before exiting. Actions that require confirmation are skipped in that case (you can't prompt interactively during a signal handler), but they're listed so you know what to clean up manually.

The Plugin System

npm and JSR are the obvious registries, but releasing software often involves more than that. Homebrew, GitHub Releases, binary tarballs, version strings embedded in documentation sites.

pubm's plugin system hooks into every stage of the release lifecycle: build, version, publish, push, and asset packaging. Plugins can also register custom credentials, add preflight checks, register new registry types, and add CLI subcommands.

Two official plugins ship with pubm:

@pubm/plugin-brew: Publishes a Homebrew formula by opening a PR to your tap repo. If a subsequent step fails and pubm rolls back, the plugin closes the PR it opened. The rollback integrates with the same RollbackTracker.

@pubm/plugin-external-version-sync: Syncs the published version into arbitrary files, landing pages, plugin manifests, JSON configs, anywhere a version string needs to live. Uses regex patterns or JSON path selectors.

Registry Abstraction

Getting the abstraction right for different registries was the most technically interesting design problem. Each registry has different auth protocols, different publish commands, different error shapes, different concepts of what "a package" even means.

The RegistryDescriptor pattern treats each registry as a data structure with a connector factory and a publish factory. Uniform interface, registry-specific implementations behind it. This is what makes it possible to add crates.io support or a private registry without rewriting the core publish loop.

pubm also detects workspaces from pnpm, yarn, npm, bun, deno, and Cargo. For Rust crates, it builds a dependency graph and publishes in topological order, this matters because crates.io rejects a crate if its dependencies haven't been published yet, and the error message won't tell you that's why.


What I Learned

Dogfooding has been the most valuable part of building pubm. Not because it's a good practice in the abstract, but because pubm publishes itself: 5 packages to npm (one of which also goes to JSR), plus Homebrew and GitHub Releases, on every release. That means every edge case in the rollback system, every auth flow, every registry interaction, I hit it myself before anyone else does. The rollback code got its best test cases from actual release failures during development, not from tests I wrote in advance.

Getting the registry abstraction right took three rewrites. The first version had registry-specific error handling leaking directly into the core publish loop, adding JSR support meant touching files that should never have known JSR existed. The second version swung too far the other way: a deeply nested factory hierarchy that made adding crates.io harder than the first version. The third, with RegistryDescriptor as a flat data structure, was the one that held.

Preflight checks beat error messages. Every time. Running pubm and having it stop cleanly before touching anything, because your auth token is expired, or your branch is wrong, is a completely different experience from getting a helpful error message after npm has already published.


Try It

pubm is Apache 2.0, lives on GitHub at github.com/syi0808/pubm, and publishes to npm and JSR.

npm i -g pubm
# or
brew tap syi0808/pubm && brew install pubm
Enter fullscreen mode Exit fullscreen mode

If you publish to more than one registry, give it a try. The quick-start guide walks through the setup in a few minutes: drop a pubm.config.ts in your repo with your package paths, run pubm, and see what the preflight phase catches. Even if you don't adopt the full workflow, the preflight checks alone have saved me several bad releases.

Happy to hear feedback, bug reports, or "this completely broke my release pipeline" stories in the GitHub issues.

Top comments (2)

Collapse
 
botanica_andina profile image
Botánica Andina

Totally get the pain of npm publish vs jsr publish getting out of sync because of separate configs and auth. Been there with other package managers! Your point about package.json vs jsr.json really resonated. Curious, what was the most surprising insight Claude provided during the implementation grind?

Collapse
 
syi0808 profile image
Yein Sung

Honestly, Claude mostly helped with implementation. I led the design and ideas, so I’m not sure I have a specific “insight” from it 🧐

It started as a Node-based CLI, but when I expanded it to support Cargo, GPT suggested moving to a single binary so Rust users could use it more easily.

That felt reasonable, so I switched to a Bun-based single binary.

I did get some interesting technical insights during the process though. I’ll share those in another post!