Every package under packages/ that is not private is released, together
and at one version: lerna's fixed mode, where lerna.json's version is
the version of all of them. scripts/publish.ts publishes them, finding
them from the root package.json's workspaces.packages, so a new
package needs no list to be edited (A new package).
The workspace's packages name each other workspace:^: dependencies, peers,
optional peers and devDependencies. External devDependencies used by two
or more packages at one range come from the root's catalog
(workspaces.catalog, named catalog: in a package). Both are spellings
only a Bun workspace resolves. bun pm pack (and bun publish) rewrite
them in the tarball: workspace:^ becomes ^<version>, and catalog:
becomes the catalog's range. npm pack and npm publish run from a
package directory ship them verbatim, and a consumer's install then
fails. So never run npm publish in a package directory: the script packs
with Bun, reads every tarball back (scripts/pack-guard.ts), and uploads
the tarball with npm.
- The version-bump PR (below): the versions, the template, the
CHANGELOG.mdentry, the lock. - Merge it, then update a clean
developcheckout. npm login, once. The script never logs in.bun scripts/publish.ts --dry-run: everything but the uploads, the tag and the release. When it refuses, it gives every reason at once.bun scripts/publish.ts(orbun run release): asks once for the npm one-time code, then publishes, verifies, tags and releases.- Deprecate what the release retired, if anything
(Retiring a package). 3.0.0-rc.1 retired
@kingsleyweb/bun-jobs-uiand@kingsleyweb/bun-api-docs-ui; 3.0.0-rc.2 and 3.0.0 retire nothing. - Move the tags (Prereleases and promotion).
A final version such as 3.0.0 is published to
latestfor every package; then pointnextat it too, so@nextdoes not install an older candidate. For a candidate, movelatestwhere it supersedes one.
- Set
versionin everypackages/*/package.jsonand inlerna.json. Do it by hand, not withlerna version: lerna 10 skips the template andJOBS_VERSION, and rewritesbun.lockwholesale (tried on 3.0.0-rc.0 → 3.0.0). The internal ranges need no edit: they areworkspace:^. - The compute-provider template (
templates/compute-provider/package.json) is not a workspace member, so its ranges are plain. Its peer on bun-jobs is one minor wide while the provider API is0.x(>=<version> <major.minor+1.0>), and its dev range is~<version>. Itsoverrideskeep theirfile:paths. - Set
JOBS_VERSIONinpackages/bun-jobs/lib/shared/constants.ts(a test pins it to the manifest), and any install line or version a README states. Then runbun run gen:clientinpackages/bun-ui: the jobs API's document carries the version, soapp/jobs/api/routes.gen.ts's recorded hash goes stale with it, andbun run check:clientfails until it is rewritten. The 3.0.0-rc.2 bump missed this (#744). - The
CHANGELOG.mdentry.bun scripts/changelog.ts <version>writes one from the commit subjects since the previous tag (--printto see it first;--from <tag>for another start). It never commits; review it and commit it in this PR.- Commits are grouped by type: Breaking (a
!:or aBREAKING CHANGE:footer), Features, Fixes, Performance, Docs, Other. Inside each type they are grouped by scope (feat(bun-client): …goes under bun-client). - Merge commits are left out, but each line links the PR that merged it.
- A long release can have a hand-written entry instead (3.0.0-rc.0 has
one, since 1,786 commits made a list nobody reads). Mark it with
<!-- changelog: hand-written; scripts/changelog.ts never replaces it -->under its heading. - The script refuses a version that already has an entry.
--forcereplaces only an entry it generated. publish.tsrefuses a version with no entry, and uses the entry as the GitHub release's notes.
- Commits are grouped by type: Breaking (a
bun installat the root: it rewrites onlybun.lock's workspace section. Thenbun install --frozen-lockfilemust pass at the root and intemplates/compute-provider. The lock change is its own commit.- Gate as for any change (
CLAUDE.md), plus:bun scripts/consumer-check.ts packages/<pkg>for every package;bun scripts/check-types.tsin the template;bun scripts/publish.ts --dry-run --allow-dirty --skip-whoamion the branch. It packs and guards every tarball (dts/, anddist/for the UI packages, are built byprepack), and prints the order and the release it would make.
From a clean, up-to-date develop after the PR merged, logged in to npm
(npm login) and to GitHub (gh auth status):
bun scripts/publish.ts --dry-run # rehearse: pack, guard, npm publish --dry-run
bun scripts/publish.ts # asks once for the one-time codeIn order, it:
- Finds the packages from
workspaces.packages. Globs are expanded as Bun does, and a!pattern excludes. Every member that is notprivate: trueis published. - Refuses anything that would leave a release half made:
- a member that lacks what publishing needs (below);
- a dirty tree;
--tag latestfor a prerelease. The default tag isnextfor a prerelease andlatestotherwise.- no
npm whoami; - no
CHANGELOG.mdentry for the version; ghnot logged in, or an active account that cannot push to the repository;- a
v<version>tag, locally or on origin, at another commit.
- Orders the packages from the dependency graph and prints the order. Dependencies and required peers come first. Optional peers do not order packages, which is how bun-common and bun-ui can name each other. Ties are broken by name.
- Skips every
name@versionthe registry already has (npm view <name>@<version> version). A re-run after a failure therefore carries on where it stopped. - Packs everything with
bun pm packinto one temp directory (--work <dir>to choose it) and guards each tarball. A tarball fails if it still containsworkspace:orcatalog:, if its version is not the release's, or if an internal range is not^<version>. All of this happens before any code is asked for. Build outputs thatprepackcreated (dts/,dist/) are removed after packing, sincejobsUi()in the repo serves a staledist/. - Asks once for the one-time code, without echoing it. Then it runs
npm publish <tarball> --tag <tag> --access publicfor each package in order, with the code in npm's environment (npm_config_otp), where it is neither printed nor visible inps.--otp <code>gives the code on the command line instead (it then shows in your shell history).--no-otpis for an account without two-factor authentication.- If the code is rejected or has expired (
EOTP), the script asks for a new one and retries that package. - Any other failure stops the run and prints what was published and what was not.
- Verifies each package with
npm view <name>@<tag> version. - Tags and releases, once every package is on the registry:
- It makes a signed, annotated
v<version>tag on HEAD (git tag -s; the repo signs with SSH). - It pushes the tag to origin, retrying, since GitHub's SSH can be flaky. If the push still fails, it prints the commands to finish by hand.
- It runs
gh release create v<version> --verify-tag. The notes are theCHANGELOG.mdentry plus an npm link for each package, and--prereleaseis added for a prerelease. - Each of these steps is skipped if it is already done, so a re-run makes them exactly once.
--no-releaseskips this whole step.
- It makes a signed, annotated
gh uses its active account. If that account cannot push here (the script
says which account it is), choose another for this one command:
GH_TOKEN=$(gh auth token --user kingsloob1) bun scripts/publish.tsA published version can never be published again, and the script never tries to.
lerna 10 rewrites workspace:^ only in the first dependency field that
names the package, checking dependencies, then optionalDependencies, then
devDependencies, then peerDependencies (updateLocalDependency). A
package that names another workspace package as both a peer and a
devDependency would ship its peer as workspace:^. Four packages here do
that: bun-common, bun-client, bun-ui, and bun-nest (twice). lerna
also has no support for catalog: at all. It packs the manifest as it is,
with npm-packlist and tar, so every catalog: devDependency would ship
verbatim.
A release candidate such as 3.0.0-rc.0 goes to the next tag. A
package's first publish also becomes its latest, because npm requires
latest to exist. A later candidate on next does not move it: after
3.0.0-rc.1, the packages first published at 3.0.0-rc.0 (bun-contract,
bun-cache, bun-jobs, bun-client) still have latest at 3.0.0-rc.0, and
bun-ui, first published at 3.0.0-rc.1, has it there.
To promote the candidate to 3.0.0:
- Make a version-bump PR as above:
3.0.0by hand, and aCHANGELOG.mdentry. - After it merges, run
bun scripts/publish.ts(latestis the default tag for a final version). - Run
npm dist-tag add @kingsleyweb/<pkg>@3.0.0 nextfor each package, sonextdoes not lag behind.
You can also point latest at the candidate itself
(npm dist-tag add @kingsleyweb/<pkg>@3.0.0-rc.1 latest), but that leaves
a prerelease as latest.
Until 3.0.0, that was done for each candidate (the maintainer's
decision, 2026-11-08, for 3.0.0-rc.1): the last candidate was superseded, and
with latest left at it a plain bun add of these installed it. For
3.0.0-rc.2 it was, after publish.ts had finished, with --otp=<code> if
npm asks:
npm dist-tag add @kingsleyweb/bun-contract@3.0.0-rc.2 latest
npm dist-tag add @kingsleyweb/bun-cache@3.0.0-rc.2 latest
npm dist-tag add @kingsleyweb/bun-jobs@3.0.0-rc.2 latest
npm dist-tag add @kingsleyweb/bun-client@3.0.0-rc.2 latest
npm dist-tag add @kingsleyweb/bun-ui@3.0.0-rc.2 latestbun-common and bun-nest kept latest at 2.2.0 (their last final release)
until 3.0.0.
For 3.0.0, publish.ts moves latest to 3.0.0 for all seven packages,
bun-common and bun-nest included. Then, with --otp=<code> if npm asks:
npm dist-tag add @kingsleyweb/bun-contract@3.0.0 next
npm dist-tag add @kingsleyweb/bun-common@3.0.0 next
npm dist-tag add @kingsleyweb/bun-client@3.0.0 next
npm dist-tag add @kingsleyweb/bun-cache@3.0.0 next
npm dist-tag add @kingsleyweb/bun-jobs@3.0.0 next
npm dist-tag add @kingsleyweb/bun-nest@3.0.0 next
npm dist-tag add @kingsleyweb/bun-ui@3.0.0 nextCheck with npm view @kingsleyweb/<pkg> dist-tags: latest and next
both print 3.0.0.
A package that leaves the workspace is not unpublished: npm allows that
only in the first 72 hours, and an install of a published version should
keep working. It is deprecated, once the package that replaces it is on
the registry, so every install of it prints where to go instead. The
maintainer runs this, logged in to npm (publish.ts never deprecates).
After 3.0.0-rc.1 is published (npm view @kingsleyweb/bun-ui@next version prints 3.0.0-rc.1), deprecate the two packages @kingsleyweb/bun-ui
replaced:
npm deprecate @kingsleyweb/bun-jobs-ui "moved into @kingsleyweb/bun-ui: import from @kingsleyweb/bun-ui/jobs"
npm deprecate @kingsleyweb/bun-api-docs-ui "moved into @kingsleyweb/bun-ui: import from @kingsleyweb/bun-ui/docs"With two-factor authentication, add --otp=<code> to each, a fresh code
for each command. A name without a version deprecates every version it
has (3.0.0-rc.0 and the 0.0.0-stage placeholder). Check it took:
npm view @kingsleyweb/bun-jobs-ui deprecated
npm view @kingsleyweb/bun-api-docs-ui deprecatedEach prints its message. To undo one, deprecate it again with an empty
message (npm deprecate @kingsleyweb/bun-jobs-ui ""). The packages and
their imports are listed under 3.0.0-rc.1 in CHANGELOG.md.
A new workspace member is published by the script as soon as it meets the script's checks, with no list to edit anywhere. The script refuses a publishable member that lacks any of these, naming each one:
versionequal tolerna.json's.publishConfig.access: "public"if the name is scoped.files: what the tarball ships, usually["dts", "lib"].sideEffects:false, or the list of modules with an effect at load (["**/*.css"]and the like), nevertrueor absent, and proved by the package's__tests__/treeShaking.test.ts(CLAUDE.md, "Packaging types").- A
prepackthat builds its declarations:bun scripts/build-declarations.ts, withscripts/build-declarations.tsandtsconfig.build.jsoncopied unchanged from another package. Those copies are one file (CLAUDE.md, "Packaging types"). - A
LICENSE: copy the root's. - A
consumer-check.jsonwithentries, soscripts/consumer-check.tschecks the package as a consumer sees it. - Internal dependencies on
workspace:^. A package it requires (a dependency or a required peer) must not beprivate.
Mark a member "private": true to keep it out of the release.