Releasing & Publishing
The full release cycle: bump a version through a PR/MR, tag it, create a release, and publish to npm — as four separate, composable steps, plus sync-deps to fix up any other local package left pointing at a now-stale version afterward. Bumping a batch of packages and then publishing or releasing only some of them are different decisions that don't always happen at the same time, so they're never forced together. Works against GitHub or GitLab, autodetected per repo — see Config & Discovery.
bump
Pick packages, bump their version (patch by default), open a PR/MR, merge it into the default branch, and tag the release.
polyrepo bump [options]- Shows a checkbox of packages with their current version and the version they'd bump to (
1.2.9 → 1.2.10for a patch bump —--minor/--majorbump that part instead, resetting the parts below it to0, same as any semver tool). Packages with a dirty working tree are marked — they'll be skipped. The highlighted package's description shows what's actually changed since the last git tag — computed for every package in parallel. - After confirming, for each selected package, one at a time:
git fetch origin→git checkout <default branch>→git merge --ff-only origin/<default branch>(the bump branch is always created from an up-to-date default branch, not whatever branch the repo happened to be on);- checks the state of a previous attempt — is there already a merged PR/MR, an open one, or just a pushed branch named
<new-version>-version-bump(or<sanitized-name>-<new-version>-version-bumpfor a pnpm workspace member, so two packages bumped to the same version in the same repo never fight over one branch name). Depending on what's found, it resumes from the right place instead of failing on "branch already exists" or opening a duplicate one; - if
package.jsonisn't bumped yet on the branch, its"version"field is updated (a text-level replace — formatting and field order are left alone). If the package already has aCHANGELOG.md, a draft## [x.y.z] - YYYY-MM-DDentry (Keep a Changelog style) is added too, listing commits since the last tag — a starting draft to review, not a finished changelog; gh pr create(GitHub) orglab mr create(GitLab) against the default branch (if there isn't one already);- with
--wait-checks: waits for CI before merging — see the option below, the exact mechanism differs by host; no checks configured isn't an error; gh pr merge/glab mr merge— through a PR/MR, not a direct push;- local default branch is synced to the just-merged PR/MR;
- git tag
v<new-version>is created and pushed if it doesn't already exist —<name>@<new-version>for a pnpm workspace member, for the same collision-avoidance reason as the branch name above.
- Any failure along the way marks that package ✗ with a clear message and moves on to the next one, without aborting the whole run.
- Once every selected package is processed, a separate check across all packages (not just the ones just bumped) reports any local package whose dependency range no longer matches a bumped package's new version — nothing is changed automatically here (see
sync-depsfor the fix). - If at least one package was actually tagged (and it wasn't a
--dry-run), whether to publish those packages to npm right now is asked once for the whole batch — unless--publishwas given, which skips the question and does it anyway (skipped entirely under--yeswithout--publish, same astag's release prompt). Either way this is exactlypublishrun against exactly the packages that were just bumped — see "Publishing" below.
Publishing
Without --publish, the question above is the only place bump touches npm. With it (or answering yes), it's exactly polyrepo publish for exactly the packages that were just bumped: npm login is checked once up front, a pnpm workspace member's dependencies are (re)linked first if needed, and a prerelease version (e.g. 2.0.0-beta.0) publishes under the next dist-tag instead of latest automatically — same rules as publish itself. Only packages that actually got tagged this run are ever offered — one skipped for being dirty, or that failed partway through, is never published.
Example output — a --dry-run, nothing exists yet:
[1/1] os-detect 2.1.5 → 2.1.6
$ git fetch origin
$ git checkout master
Your branch is up to date with 'origin/master'.
Already on 'master'
$ git merge --ff-only origin/master
Already up to date.
✓ master is up to date.
[dry-run] would create branch 2.1.6-version-bump, ensure version 2.1.6 (+ a CHANGELOG.md entry if one exists), commit/push if needed, then open + merge a PR, then tag v2.1.6.Re-running bump on a package where a previous attempt already went all the way through is much shorter — everything already done is just confirmed, not redone:
[1/1] vue-toast-kit 1.0.7 → 1.0.8
✓ master is up to date.
✓ Already merged as PR #12 — master already has it.
✓ Tag v1.0.8 already exists on origin.(On a GitLab repo, this reads "Already merged as PR #12" the same way — the provider layer always calls it "PR" in these messages regardless of host, the underlying object is a merge request.)
--prerelease, or --preid paired with --major/--minor, compute the target version differently but go through the exact same branch → PR → merge → tag steps:
[1/1] os-detect 2.1.5 → 2.1.6-beta.0
$ git fetch origin
$ git checkout master
Your branch is up to date with 'origin/master'.
Already on 'master'
$ git merge --ff-only origin/master
Already up to date.
✓ master is up to date.
[dry-run] would create branch 2.1.6-beta.0-version-bump, ensure version 2.1.6-beta.0 (+ a CHANGELOG.md entry if one exists), commit/push if needed, then open + merge a PR, then tag v2.1.6-beta.0.Options
--dry-run
Prints the plan for each package, changes and pushes nothing — including the CHANGELOG.md entry, CI wait, and git tag.
--minor
Bump the minor version instead of patch (e.g. 1.2.9 → 1.3.0).
--major
Bump the major version instead of patch (e.g. 1.2.9 → 2.0.0).
--prerelease
Bump (or start) a prerelease instead of patch/minor/major (e.g. 1.2.9 → 1.2.10-alpha.0, or 1.2.10-alpha.0 → 1.2.10-alpha.1). Combine with --preid to name it — node-semver decides whether that means adding e.g. -alpha.0 to the current version or advancing an existing prerelease's number.
--preid <name>
Prerelease identifier for --prerelease, or paired with --minor/--major for a preminor/premajor prerelease instead (e.g. --major --preid beta on 1.2.9 → 2.0.0-beta.0). Defaults to alpha.
--custom-version <version>
Set an exact version instead of computing one. Only valid together with --packages naming a single package — applying one literal version to several packages at once is never actually what you want.
--packages <a,b,c>
Comma-separated package dir names instead of the interactive checkbox — for scripts. Unknown names are printed as a warning and skipped.
--yes
Skip the "proceed?" confirmation.
--wait-checks
Wait for CI before merging; don't merge if it fails. On GitHub this is gh pr checks --watch, scoped to the PR. GitLab has no equivalent scoped to an MR — this uses glab ci status --branch --wait on the bump branch's pipeline instead, same idea. No checks configured isn't an error either way — it just doesn't wait.
--publish
After tagging, also publish to npm — without asking (see "Publishing" above).
Example:
# dry run first — nothing is pushed, committed, merged, or tagged
polyrepo bump --dry-run
# bump or advance a beta prerelease
polyrepo bump --prerelease --preid beta
# start a premajor release candidate (e.g. 2.0.0-rc.0)
polyrepo bump --major --preid rc
# set an exact version for one package, e.g. for a hotfix
polyrepo bump --packages os-detect --custom-version 2.1.6-hotfix.1
# bump, tag, and publish, non-interactively
polyrepo bump --publish --yes
# fully non-interactive, for a script/CI
polyrepo bump --packages vue-toast-kit,os-detect --yestag
For a package whose version was already bumped some other way (not through bump, or before it started tagging), release has nothing to work with — there's no tag for the current version yet. tag puts the missing tag on the current version without bumping it again or opening a PR.
polyrepo tag [options]- Fetches each repo's origin default branch (once per physical repo), then checks each package (in parallel) for whether a tag
v<local version>(<name>@<local version>for a pnpm workspace member) already exists. A package whose local version differs from what's actually on origin is flagged right here — worth knowing, since the next step fast-forwards local to origin's default branch before tagging, so the tag about to be created may not end up naming the version that's actually tagged. - Shows a checkbox: package, version, tag — with a
⚠ origin has <version>note on anything that differs from origin. Untagged packages are pre-selected; already-tagged ones can still be picked manually (harmless — it just confirms the tag is there). - After confirming, for each selected package: syncs the default branch (same as
bump), then re-reads the version frompackage.jsonnow that local matches origin exactly — the version on disk can have changed if local wasn't already on the default branch, or was behind. Tagging always names the version that's actually here now, not whatever was there before the sync; a warning is printed if that changed which tag gets created. Creates and pushes the (possibly renamed) tag if it's missing. - If at least one package was actually tagged (and it wasn't a
--dry-run), it asks: "Create a release for the N package(s) just tagged?" — answering yes runs the same process asreleasefor exactly those packages.
Example output — the current version already has a tag, so there's nothing to do but confirm it:
[1/1] os-detect v2.1.5
$ git fetch origin
$ git checkout master
Your branch is up to date with 'origin/master'.
Already on 'master'
$ git merge --ff-only origin/master
Already up to date.
✓ master is up to date.
✓ Tag v2.1.5 already exists on origin.Example output — local was behind origin, so syncing the default branch changed what's actually being tagged:
[1/1] os-detect v2.1.5 (origin has 2.1.6)
$ git fetch origin
$ git checkout master
$ git merge --ff-only origin/master
Fast-forwarded to origin/master.
✓ master is up to date.
! Version changed from 2.1.5 to 2.1.6 after syncing with origin — tagging v2.1.6 instead of v2.1.5.
✓ Tagged and pushed v2.1.6.Options
--dry-run
Prints the plan; tags, pushes, and releases nothing.
--packages <a,b,c>
Package list instead of the interactive checkbox.
--yes
Skip the "proceed?" confirmation (the release question is skipped too — no release is created unless --release is also given).
--release
Create a release right after tagging, without asking — for scripts.
Example:
polyrepo tag --packages vue-toast-kit,os-detect --yes --releaserelease
Pick packages and create a release for their current version's tag.
polyrepo release [options]- Checks each package (in parallel) for a
v<local version>tag (<name>@<local version>for a pnpm workspace member) on origin (the onebumportagcreates) and whether that tag already has a release. - Shows a checkbox: package and its tag. Packages with no tag for their current version show up disabled ("run
bumpfirst"); already-released ones are selectable but unchecked, in case you want to re-run it. - After confirming, for each selected package:
gh release create <tag> --verify-tag(GitHub) orglab release create <tag>(GitLab) — the checkbox already only offers packages with an existing tag, so this never invents one;--verify-tagis GitHub's own second guarantee of the same thing, GitLab has no equivalent flag. Release notes come from the matchingCHANGELOG.mdsection when there is one. Otherwise: GitHub's own--generate-notes(a summary of merged PRs/commits); GitLab has no equivalent, so this falls back to a plain commit-log listing since the last tag instead.
Example output — on a GitHub repo:
[1/1] os-detect v2.1.5
✓ No changelog entry — using --generate-notes.
$ gh release create v2.1.5 --verify-tag --title os-detect@2.1.5 --generate-notes
✓ Created release os-detect@2.1.5.Options
--dry-run
Prints what would be created, without actually creating any release.
--packages <a,b,c>
Package list instead of the interactive checkbox.
--yes
Skip the "proceed?" confirmation.
Example:
polyrepo release --packages vue-toast-kit,os-detect --yespublish
Pick packages and run npm publish — pre-selects ones ahead of the registry.
polyrepo publish [options]- Fetches each repo's origin default branch (once per physical repo), then checks each package's registry version (
npm view <pkg> version) against its local version — in parallel. A package marked"private": true(including a pnpm workspace's own root) is excluded entirely, before the checkbox is even built — it was never meant to reach the registry. - Shows a checkbox: registry version → local version, plus which dist-tag it would publish under (see
--dist-tagbelow) and a⚠ origin has <version>note when origin's default branch has a different version than local. Packages where the registry differs from local are pre-selected; already-published ones stay selectable (e.g. to republish after an unpublish). - After confirming, checks
npm whoamionce for the whole batch (skipped under--dry-run) — not logged in runsnpm login(a real terminal, so its browser-based OTP flow or credential prompt works normally) before publishing anything, instead of only finding out partway through the first package'snpm publish. - For each selected package, one at a time:
npm publish(ornpm publish --dry-runwith the--dry-runflag) — or, for a pnpm workspace member,pnpm publish --no-git-checksinstead, so aworkspace:*dependency on a sibling package resolves to a real version number rather than failing the publish. Runs with a real terminal, not captured — an npm 2FA/OTP prompt works normally.
Example output — the middle section is npm's own build/test/pack output for the package, shown live in the real terminal, not something polyrepo captures or reformats:
[1/1] os-detect@2.1.5
$ npm publish --dry-run
… (the package's own build, tests, and npm's tarball contents listing)
✓ Published os-detect@2.1.5 (dry run).publish never checks git state — same as a plain npm publish never has — it always publishes exactly what's on disk. It does warn (without blocking) right before the confirm prompt when a selected package isn't on its default branch, has uncommitted changes, or its local version differs from origin's:
! os-detect is on branch fix-typo, not master — publishing what's on disk anyway.
! vue-toast-kit has uncommitted changes — publishing what's on disk anyway.Dist-tags
A prerelease version (anything with a -, e.g. 2.0.0-beta.0) publishes under the next dist-tag by default instead of npm's own default latest — otherwise it would become the version anyone installing without pinning a version gets, which is essentially never the intent of a prerelease. --dist-tag overrides this for every selected package (auto-detection included) — pass it explicitly to publish a stable version under something other than latest too (e.g. an LTS channel).
Options
--dry-run
npm publish --dry-run instead of a real publish.
--dist-tag <tag>
npm dist-tag to publish every selected package under (default: next for a prerelease version, otherwise npm's own default latest). See "Dist-tags" above.
--packages <a,b,c>
Package list instead of the interactive checkbox.
--yes
Skip the "proceed?" confirmation.
Example:
polyrepo publish --packages vue-toast-kit,os-detect --yes
polyrepo publish --packages os-detect --dist-tag next # force a specific dist-tagsync-deps
The write side of the "Deps" column in list and the "Cross-package dependencies" section in doctor: a local package whose declared range on another local package no longer matches that package's current version (e.g. it still says ^1.1.0 after the dependency moved to 1.2.0). Every place that reports this stops at reporting — this is the one that fixes it.
polyrepo sync-deps [options]- Reads every discovered package (not just the ones named by
--packages) — finding a stale range requires knowing every local package's current version, not just the ones being fixed. - Shows a before/after checkbox for each stale range found:
"depName" "oldRange" → "newRange", keeping each range's own style (^stays^,~stays~, an exact pin stays exact — anything unusual falls back to a^range, visible in this same preview before anything is written).--packagesnarrows which dependent packages' issues are offered, same meaning as everywhere else in this CLI. - After confirming, updates
package.jsonin place for each selected issue — a text-level replace of the exact"depName": "oldRange"pair, so formatting, key order, and quote style elsewhere in the file are left untouched.
Only ever edits package.json on disk — no commit, no push, no branch/PR. Review the change afterward (git diff), then commit it the way each repo's rules allow with polyrepo commit.
Example output:
Sync local dependency ranges
app-shell @acme/ui-kit "^1.1.0" → "^1.2.0"
app-shell @acme/api-client "~2.0.0" → "~2.1.0"
✓ app-shell: "@acme/ui-kit" "^1.1.0" → "^1.2.0"
✓ app-shell: "@acme/api-client" "~2.0.0" → "~2.1.0"Options
--dry-run
Print what would be updated, without actually writing any package.json.
--packages <a,b,c>
Only offer issues for these dependent packages — same meaning as everywhere else.
--yes
Skip the "proceed?" confirmation.
Example:
polyrepo sync-deps # see what's stale, then update it
polyrepo sync-deps --dry-run # print the plan, change nothing
polyrepo sync-deps --packages a,b --yes # non-interactive, for scripts/CI