Architecture

Release Handbook

How NextRush versions and ships ~35 packages — the full lifecycle, CLI vs CI, and every real mistake we've already made so you don't repeat them.

Someone on this team ran a beta release from scratch on 2026-07-24 and got three things wrong before getting them right: a version number that lied about its own history, a batch of never-published packages that would've leaked onto the public latest tag, and a dependency injection bug that took thirty-three seconds to fail instead of thirty milliseconds. None of those were exotic. They were the kind of mistake anyone running Changesets for the first time on a real monorepo makes, and this handbook exists so nobody here makes them twice.

Read this end to end before you touch a release. It's long on purpose — every section earns its length from something that actually went wrong, not from padding a "best practices" checklist.

What you're actually managing

NextRush isn't one package. It's nextrush, @nextrush/core, @nextrush/router, and about thirty more, published independently but versioned as a coordinated whole where it matters. Nine of them — the framework's essential runtime — move together, always at the same number, because splitting a core release from a router release would make "which versions actually work together" a research question for every user. Everything else versions on its own clock.

That split is the first thing to internalize. It decides where a package lives in .changeset/config.json, and it decides whether a bug fix in @nextrush/cors needs anyone outside that package to care.

Part 1 — The concept: why Changesets, and how it actually computes a version

The problem it solves

Two people are pushing to the same monorepo. One fixes a typo in @nextrush/cors. The other removes a public export from @nextrush/core. Both changes need a changelog entry and a version bump — but not the same severity, and not the same number. Do this by hand across thirty-five packages for long enough and you'll either forget an entry or ship a patch that was secretly a breaking change.

Changesets turns "remember to bump the version" into a file you commit with your PR. Run pnpm changeset, answer which packages you touched and how badly, and you get a small markdown file in .changeset/ — nothing versioned yet, only a written statement of intent. That file sits there, reviewable in the PR diff, until someone runs the version step and turns every pending statement into an actual number.

The three commands, and what each one really does

Loading diagram...
  • pnpm changeset is the only interactive step. It asks which packages changed, how severely (patch, minor, major), and what the changelog should say. One markdown file comes out.
  • pnpm run version (this repo's alias for changeset version, with a guard bolted on — more on that later) reads every pending changeset, figures out the real next version per package, writes it into package.json and CHANGELOG.md, then deletes the changesets it consumed. This is the step that actually decides numbers.
  • pnpm changeset publish walks the workspace and publishes whatever version is sitting in each package.json, but only if that exact version string doesn't already exist on the registry. That idempotence matters — you can run it twice safely, and it's why a first-ever bootstrap publish works at all.

How the next version gets computed — the part that bites people

Here's the mental model that would've saved us a debugging session: the next version is never whatever's sitting in your local package.json. It's computed from the last version actually published to npm, plus the highest-severity changeset pending against that package.

next version = bump(real npm version, highest pending severity)

On 2026-07-24, our local files said 3.1.0 for the core group. That number meant nothing — nobody had ever published it. npm's actual last release was 3.0.7. Sixteen pending changesets included several major bumps. So the real next version was 4.0.0, and the local 3.1.0 was was going to get overwritten the moment anyone ran the version step. If we'd trusted the working tree instead of checking the registry, we'd have shipped a .1 minor release over a batch of genuinely breaking changes — telling every user "this is safe to upgrade blindly" when it wasn't.

That's the rule, stated plainly: check npm view <package> version before you trust any computed number. Every phase below comes back to this.

Fixed vs. independent — one number for the core, separate clocks for everything else

"fixed": [[
  "@nextrush/types", "@nextrush/errors", "@nextrush/core", "@nextrush/router",
  "@nextrush/runtime", "@nextrush/di", "@nextrush/adapter-node", "nextrush"
]]

Every package inside one fixed array always moves to the same version, together, even if only one of them actually changed. That's deliberate — users install nextrush and expect its dependencies to be one coherent unit, not eight packages each on their own clock.

Anything not listed there versions independently. @nextrush/cors sitting at 3.0.5 while core sits at 3.0.7 isn't a bug. They've never needed to match, and there's no reason they ever will.

One thing worth calling out because we got it wrong once: @nextrush/stream looked, from the README's prose, like it belonged in the core group — "ships with @nextrush/adapter-node" reads like a dependency. It isn't one. We checked the actual import graph: stream depends only on @nextrush/types, and core has zero references to it anywhere. adapter-node consumes it the same way it would consume any middleware — a normal dependency, not a version-lockstep coupling. Always verify the real dependency graph before deciding fixed vs. independent. A README's phrasing is not evidence.

Part 2 — CLI or GitHub Actions? The decision, not only the commands

You asked which one to use. The honest answer is: it depends on where you are in the release, and right now — for a beta — the Action literally can't help you.

Loading diagram...

Here's why the split exists, not only what it is:

The GitHub Action (release.yml) only triggers on push to main. That's in the workflow file, plain as day — on.push.branches: [main]. A beta release is supposed to run from a dedicated branch (release/4.0.0-beta, say), specifically so entering prerelease mode doesn't block every other unrelated PR from shipping until you exit it. Push to that branch, and the Action stays silent. No trigger, no automation, nothing happens. So for the entire beta window, CLI isn't the recommended path — it's the only one that does anything at all.

Once you merge back to main for the stable cut, the Action becomes the right tool. It opens a "Version Packages" pull request automatically, keeps it updated as more changesets land, and publishes to npm the moment that PR merges. That's real automation worth using — no reason to hand-run commands for a release the Action is built to handle.

So: beta from your terminal, stable through the Action once you're back on main. If you ever want the Action to also cover beta branches, that's a config change (on.push.branches: [main, 'release/**']), not a CLI-vs-Action philosophy shift — worth doing eventually, not required to ship this release.

Part 3 — The full release lifecycle, start to finish

This is the actual procedure. Follow it top to bottom. Every checkpoint exists because skipping it cost us real time on 2026-07-24 — they're not defensive ceremony, they're scar tissue.

Phase 1 — Decide the version, then the channel

Two decisions, and they get confused for each other constantly. The version number isn't a choice — semver decides it for you. If anything pending is breaking, it's a major, full stop, regardless of what the working tree currently says. The channel — stable, beta, alpha, rc — is where you actually have freedom.

Read every pending changeset in full. Not the filename — the content. Two changesets can look like duplicates from their names and turn out to be two entirely different, non-overlapping changes (we had exactly this: remove-backcompat-aliases and remove-deprecated-controllers-decorators sounded redundant, weren't). For each one, note every package and bump type it touches. If anything says major, or removes a public export, or moves a dependency into peerDependencies, the release is major-version territory. No exceptions for "but it's mostly small."

Then pick the channel. alpha if the API surface itself might still shift between builds. beta if the surface is believed final and you're hunting bugs — this is the normal default. rc only for a short, final check right before a stable cut you don't expect to need changes on.

Phase 2 — Run the baseline guard before you compute anything

This is the step that didn't exist when we made the mistake, and would've caught it in one command instead of three follow-up debugging turns.

pnpm validate:changeset-baselines
pnpm verify:release-state

The first script reads every pending changeset's frontmatter, checks each named package against the real npm registry, and fails loudly if a patch, minor, or major bump is declared against a package that has never been published. Why that matters: changeset version has no idea whether a package has real history. It applies the bump literally, on top of whatever baseline it finds. Declare a major bump against a package sitting at an un-published 1.0.0, and you get 2.0.0 — a number that claims a 1.x line existed when it never did. We shipped exactly that mistake across nine packages before catching it by hand.

The second script exists because the first one has a blind spot: it only checks packages a pending changeset names. It would never have caught @nextrush/class sitting at a hardcoded 4.0.0-beta.0 with zero changesets pending — which is exactly what happened later the same day. verify-release-state checks the whole working tree independent of what's pending: every fixed-group package carries the identical version right now, no never-published package has a version that looks computed from a real release that never happened, nothing in ignore is depended on by a package that isn't also in ignore, and .changeset/pre.json's snapshot actually matches the current package.json values (see Phase 4's edge case below for why that last check exists).

Both are wired into pnpm run version automatically now, so they run even if you forget. Run them explicitly here anyway — you want to see a failure before you're mid-release, not after. They're also wired into git hooks: pre-commit runs verify:release-state whenever a staged diff touches a package.json or .changeset/, and pre-push runs it unconditionally as the last checkpoint before anything reaches origin. Installed via simple-git-hooks — run npx simple-git-hooks once after cloning if hooks aren't firing (postinstall should do this automatically, but confirm with cat .git/hooks/pre-commit).

If the baseline check fails, the fix is always the same: open the flagged changeset, delete that package's line from the frontmatter. Its package.json version already is its correct first release. A changeset entry adds nothing for a package with no history to be relative to — it only risks producing the exact wrong number this guard exists to catch.

Phase 3 — Hold never-published packages out of the beta

This one won't get caught by the guard above, and it's a separate, real edge case. Changesets has one behavior that's worth watching for: a package's first-ever publish always lands on the latest npm dist-tag, prerelease mode or not. There's no flag that changes this for a brand-new package — it's unconditional, and it's documented plainly in Changesets' own prereleases guide.

Think through what that means for a beta. If a package that's never touched npm rides along in a beta publish, it becomes world-visible on latest the instant you publish — no @beta needed, no opt-in required. The entire point of gating a channel behind an explicit tag evaporates for that one package.

So: for every package with a pending changeset, check npm view <name> version. Empty result means never published. Every one of those goes into .changeset/config.json's ignore array for the duration of the beta window — Changesets' own mechanism for a temporary hold, version-bumped and changelogged, never actually pushed to the registry. Don't use "private": true for this; that's a permanent block, a different tool for a different job. Only pull a package out of ignore once you've exited prerelease mode and are publishing the real stable cut — at that point its first publish is a normal latest release with nothing ambiguous about it.

We held twenty-three packages back this way on 2026-07-24: @nextrush/stream, @nextrush/class, every never-published middleware, both new adapters, @nextrush/dev, create-nextrush, @nextrush/testing. None of them had shipped before. All of them wait for stable.

Phase 4 — Enter prerelease mode and compute

git checkout -b release/4.0.0-beta   # dedicated branch, never main
pnpm changeset pre enter beta
pnpm run version                     # runs the baseline guard first, then changeset version

pre enter beta writes .changeset/pre.json, marking the repo as "in prerelease mode, tag is beta." From here, every version the tool computes gets a -beta.N suffix instead of shipping straight — 4.0.0-beta.0, and N climbs on its own (beta.1, beta.2) each time you run the version step again, so you don't have to hand-manage suffix numbers across a multi-week window.

Loading diagram...

Don't trust the diff blind. Run git diff --stat, look at what actually changed, and check three things every time: every package inside the fixed group landed on the exact same number as its siblings; every independent package's new version is a sane bump from its real npm history, not from whatever the working tree happened to say; and nothing that's supposed to be in ignore shows up as changed at all. If something with zero npm history changed and it's not in ignore, stop — you missed a package in Phase 3.

Never re-run pre enter after correcting versions by hand

changeset pre enter <tag> snapshots the current package.json values into .changeset/pre.json's initialVersions field — but only ONCE. If pre.json already exists, running pre enter again is a silent no-op on that snapshot; it does not refresh it. If you hand-correct a version while already inside a prerelease window (which we did, twice, on 2026-07-24 — once for the whole core group's baseline, once for a single package caught later), the snapshot keeps pointing at the old, wrong numbers, and changeset version computes the next beta number relative to that stale baseline instead of your correction.

The fix is never to re-run pre enter on top of an existing pre.json. Exit first, then re-enter:

pnpm changeset pre exit      # deletes pre.json outright, no version side effects
pnpm changeset pre enter beta   # re-snapshots from your CURRENT, corrected package.json

verify:release-state (Phase 2) checks for this drift automatically — it compares pre.json's snapshot against the live package.json values and fails loudly if they disagree, so you don't have to remember to check by hand.

Phase 5 — Verify before you publish anything

pnpm build
pnpm test
pnpm typecheck
pnpm lint

All four, zero failures, across every package the release touches. Don't publish on "the changesets read well so it's probably fine" — that's exactly the assumption that let a real bug slip through on this repo.

Here's the concrete story, because it's worth knowing what "slow but passing" actually looked like. A circular-dependency test in @nextrush/di was supposed to fail fast when two services depended on each other. Instead it hung for thirty-three seconds before finally throwing the right error. It passed. Green checkmark, count it as done. Except thirty-three seconds for what should be a millisecond operation is not "eventually correct" — it's a real bug wearing a passing test as camouflage. The actual cause: the fast cycle-detection guard only fired for one dependency injection path, and constructor-injection cycles fell through to a much slower fallback that happened to still work, at a cost nobody would tolerate in production. If a test in your release run finishes unusually slowly, that's a signal to open it up, not a number to wait out.

Phase 6 — Publish the beta

pnpm changeset publish

No --tag beta here — that's the mistake to know about before you hit it, not after.

`--tag` is rejected while pre.json exists — this is not optional

Running pnpm changeset publish --tag beta while still in prerelease mode fails outright:

🦋  error Releasing under custom tag is not allowed in pre mode
🦋  To resolve this exit the pre mode by running `changeset pre exit`

The instinct to add --tag beta is reasonable — it's the flag you'd reach for outside prerelease mode — but it's wrong here, and the fix is not the one the error message suggests. Do not run changeset pre exit just to get past this error. That command strips the -beta.N suffix and computes the real, final stable version — exiting the entire beta window early, weeks before you're actually done collecting bug reports. That's Phase 8, not Phase 6.

The actual fix is to drop the flag entirely:

pnpm changeset publish

Why this works without --tag: .changeset/pre.json already records "tag": "beta" from when you ran pre enter beta in Phase 4. Changesets reads that field itself and tags every publish beta automatically — an explicit --tag on the command line is not just redundant, it's a second, conflicting instruction the tool refuses to reconcile with the one already recorded in pre.json. The version string does the rest: npm never routes a version containing a prerelease identifier (-beta.N) onto the latest dist-tag unless you force it, so latest stays exactly where it was — you get the same real-world result as --tag beta intended, through the mechanism the tool actually expects you to use here.

Verify with npm view nextrush dist-tags after publishing; beta should be the only tag that moved.

Confirm the output lists only what you expect — nothing from ignore should appear anywhere. Then check the registry directly:

npm view nextrush dist-tags

beta should point at the new version. latest should be exactly where it was before you started. If latest moved, something published without prerelease mode active — stop and figure out why immediately, don't assume it's fine because the version number still has a -beta suffix in it.

Tell your testers the actual install command: npm install nextrush@beta. Nobody guesses that on their own.

Phase 7 — The beta window

Collect real bug reports for however long you planned — fifteen days, in our case. Every fix during the window gets its own changeset and runs back through Phases 2, 4 (only the version step), 5, and 6. You're already in prerelease mode; there's no need to re-enter it for each iteration.

latest doesn't move during this entire window. If it does, someone ran a plain changeset publish with no tag while prerelease mode was still active — that's the failure mode worth actually watching for, because it's silent until a user complains their stable install suddenly changed.

Phase 8 — Exit and ship stable

pnpm changeset pre exit
pnpm run version

pre exit only records the intent — no version changes yet. The version step right after it is what strips the -beta.N suffix and computes the real final numbers.

Before you touch the ignore list, run the baseline guard again. Packages held back during beta might have picked up new changesets during that window — changesets aimed at their eventual first release — and those need the same never-published check the original batch did. Remove packages from ignore one at a time, and after each one, check its computed version is a sane first release (1.0.0, not something inflated). Then run the full Phase 5 verification gate again — a beta window's worth of fixes deserves the same scrutiny the original release got, not a lighter pass because "it's basically done."

pnpm changeset publish   # no --tag: this is what moves latest
npm view nextrush dist-tags   # confirm latest actually moved

Part 4 — Every real edge case, named

What happenedWhyWhat catches it now
Local package.json said 3.1.0; npm's real last release was 3.0.7The working tree isn't the source of truth — the registry isPhase 1: always compute from real npm history, never from what's already in the file
A major/minor changeset targeted a package with zero publish historychangeset version has no concept of "this has no history to bump from" — it applies the severity literallyPhase 2: validate:changeset-baselines, wired into pnpm run version
A never-published package almost rode along in a beta publishFirst publish always lands on latest, prerelease mode or not — unconditional, undocumented-until-you-hit-it behaviorPhase 3: the ignore list, held until stable
@nextrush/stream sat in the core fixed group with zero real dependency on coreREADME prose implied a coupling that the actual import graph didn't haveAlways check the real dependency graph, not the description
A circular-dependency test passed after 33 seconds instead of millisecondsA fast cycle guard only covered one DI resolution path; the other path fell through to a much slower, technically-correct fallbackPhase 5: an unusually slow test is a bug to investigate, not a green check to accept
A hardcoded version on a never-published package (@nextrush/class at 4.0.0-beta.0) sat wrong with zero changesets pending to catch itvalidate:changeset-baselines only checks packages a pending changeset names — an already-wrong version with nothing pending against it is invisible to itverify:release-state's never-published version-sanity check, independent of pending changesets
pnpm changeset publish --tag beta failed with "Releasing under custom tag is not allowed in pre mode".changeset/pre.json already records the tag ("tag": "beta") from pre enter beta in Phase 4; an explicit --tag is a second, conflicting instruction the tool won't reconcile with what's already recorded — it is never needed while pre.json existsPhase 6: drop --tag entirely and run plain pnpm changeset publish; the version string's own -beta.N suffix keeps it off latest
@nextrush/adapter-bun@1.0.0-beta.0 and @nextrush/dev@1.0.0-beta.0 both failed with E409 Conflict on publishBoth package names had been fully unpublished on npm weeks earlier (2026-07-08/09). npm's unpublish policy is permanent for that exact name+version combination — never just a 24/72-hour cooldown once the whole package was unpublished — so republishing the same version string is blocked forever, no matter how much time has passedBump to a new version string never used before (1.0.0-beta.1) — confirm with npm view <pkg> first; a 404 ... Unpublished on <date> response (not a plain 404) is the tell that this is the permanent-reuse block, not a transient registry error
@nextrush/form-data and @nextrush/static both failed with E403 Forbidden on their first-ever publish, while a dozen other brand-new package names in the same run succeededNeither name appeared in npm access list packages @nextrush at all beforehand (unlike the unpublish cases above, which return a dated 404) — a genuinely first-time package creation can hit npm's 2FA/granular-access-token publish policy even when the CLI token has write access to every existing package in the scope, because creating a brand-new package name is a stricter security check than publishing a new version of one already claimedNot automated — needs an interactive npm publish (answers the 2FA/OTP prompt directly) or a token with "bypass 2FA" explicitly enabled, done by the account owner before an automated changeset publish retry can succeed for that specific package
Two fixed-group packages (nextrush, @nextrush/adapter-node) depended on packages sitting in ignore (@nextrush/class, @nextrush/stream) — changeset pre exit refused to runChangesets won't let a publishing package depend on one it can't resolve; the ignore list was built by "never published → hold back" without checking who actually consumes each packageverify:release-state's ignore-vs-dependency-graph check, run proactively before pre exit/version instead of discovered as a runtime error
Re-running changeset pre enter beta after hand-correcting versions left pre.json's snapshot pointing at the old, wrong baselinepre enter only snapshots initialVersions once; re-running it while pre.json already exists is a silent no-op on that snapshotverify:release-state's stale-pre.json check, plus the documented recovery (pre exit then pre enter again) in Phase 4
Two packages (@nextrush/controllers, @nextrush/decorators) are live on npm but deleted from the repoChangesets only versions packages that exist in the current workspace — a removed package is invisible to the whole flowNot automated. Deprecate manually with npm deprecate, tracked as its own task, never folded into a version bump

That last one is worth a sentence on its own, because it's the one thing in this whole handbook that genuinely has no automated fix. If you delete a package from the monorepo, npm doesn't know that happened. Anyone can still npm install it. The only way to signal "this is abandoned" is npm deprecate <package>@"*" "moved to X" — a manual, one-time command, run once outside the whole Changesets flow, and it's the one step nothing in the pipeline reminds you to run.

Quick reference — every command, in order

# Prep
pnpm validate:changeset-baselines

# Enter beta, compute
git checkout -b release/x.y.z-beta
pnpm changeset pre enter beta
pnpm run version
git diff --stat   # look before you trust it

# Verify
pnpm build && pnpm test && pnpm typecheck && pnpm lint

# Publish beta (no --tag: pre.json already records "beta" — see Phase 6's callout)
pnpm changeset publish
npm view nextrush dist-tags

# Repeat version/verify/publish per fix during the window

# Exit and ship stable
pnpm validate:changeset-baselines
# edit .changeset/config.json — remove ready packages from "ignore"
pnpm changeset pre exit
pnpm run version
pnpm build && pnpm test && pnpm typecheck && pnpm lint
pnpm changeset publish
npm view nextrush dist-tags

See PUBLISHING.md at the repo root for the package tiers, CI trigger details, and the GitHub setup this handbook assumes (secrets, workflow permissions, that kind of thing). This page is the lifecycle and the reasoning; that one is the reference table you keep open in a second tab.

Was this helpful?

On this page