Automate releases from your
CHANGELOG.md. Graduate the[Unreleased]block into a real release — bump the version, rewrite the changelog, commit, tag, and push — with one command.
pubv is a tiny, zero-config release automation CLI for projects that keep a Keep a Changelog-style CHANGELOG.md. It picks a sensible semver bump from your unreleased notes, then commits, git-tags, and pushes the release for you — on GitHub, GitLab, or Bitbucket. No config files, one runtime dependency.
pubv v1.0.0
── preflight ─────────────────────────────
✓ on main
✓ working tree clean
✓ origin up-to-date
── plan ──────────────────────────────────
last 1.2.0
major 2.0.0
minor 1.3.0 ← default
patch 1.2.1
tag fmt v1.3.0 matches existing tags
version? [1.3.0] › _
Under the hood: it reads CHANGELOG.md, figures out a sensible default for the next version, rewrites the changelog, commits, tags, and pushes — with a single push using --follow-tags.
# one-shot
bunx @runwisp/pubv # or: npx @runwisp/pubv
# global (recommended — puts `pubv` on your PATH)
bun add -g @runwisp/pubv # or: npm install -g @runwisp/pubvRequires Node 20+ (or Bun).
pubv # interactive
pubv 1.3.0 # explicit version
pubv minor # shorthand: bump minor based on last release
pubv --dry-run # print the plan; don't change anything
pubv --yes 1.3.0 # CI mode: no prompts, exit non-zero on any issue
pubv --release # also create a GitHub/GitLab release (via gh/glab)
pubv init # scaffold a CHANGELOG.md and stopNo CHANGELOG.md yet? Just run pubv — it offers to create one (canonical
Keep a Changelog header) as part of the normal confirmation and cuts your first
release from it. Prefer to write the changelog by hand first? pubv init
scaffolds the file without releasing.
- Preflight — verifies
CHANGELOG.mdexists, you're on the default branch, the tree is clean, andoriginis reachable and not ahead of you. - Plan — parses
CHANGELOG.md, finds your last released version, suggests a default bump based on what's in[Unreleased](see Heuristic), and detects the tag prefix (v1.2.3vs1.2.3) from your existing tags. - Rewrite — moves the
[Unreleased]body into a new## [<version>] - <date>section, and rewrites the trailing[Unreleased]/[<version>]link refs with compare-URLs for your forge (GitHub / GitLab / Bitbucket auto-detected). - Commit + tag + push —
git commit -m v<version>,git tag -a <prefix><version>,git push --follow-tags origin <branch>.
If anything fails or you say no at any prompt, CHANGELOG.md is left untouched.
pubv --release goes one step past the tag: after pushing, it opens a
GitHub/GitLab release with the freshly-graduated [Unreleased] entries as
the notes. It shells out to the forge CLI you already have — gh for GitHub,
glab for GitLab — exactly as it shells out to git. No extra dependency, no
token plumbing in pubv itself (the CLI handles auth).
If the CLI isn't installed, isn't authenticated, or the host has no release CLI
(Bitbucket), pubv warns and moves on — the tag is already pushed, so a missing
release page never fails the run.
pubv --sign signs the release commit and tag (git commit -S / git tag -s),
for repos or orgs that require GPG-signed releases. It uses your existing git
signing configuration.
pubv inspects the [Unreleased] section:
Trigger in [Unreleased] body |
Default bump |
|---|---|
BC, BREAKING CHANGE, or Breaking (capitalized) |
major |
Added or Changed |
minor |
otherwise (incl. only Fixed / Security / Removed) |
patch |
You can always override on the prompt or via pubv major | minor | patch | pre | 1.2.3.
Removedalone doesn't force major — many changelogs use it for deprecations and dead-code removal that aren't breaking. Mark genuinely-breaking entries with**BC**:orBREAKING CHANGE:.
pubv [version] [flags]
pubv init Scaffold a new CHANGELOG.md and exit.
--dry-run Show the plan; don't change anything.
-y, --yes Skip all confirmations (suitable for CI).
--no-push Don't push to the remote.
--no-tag Don't create a tag.
--sign Sign the release commit and tag (git commit -S / tag -s).
--release Create a forge release via gh/glab after pushing the tag.
--allow-empty Allow graduating an empty [Unreleased] section.
--merge-request,--mr Protected branch: commit on a release/<version> branch,
push it, and print a "create merge request" URL (no tag).
--tag-release Post-merge step: tag the latest changelog release on HEAD
and push the tag (pairs with --merge-request).
--tag-prefix=v|none Override tag-prefix auto-detection.
--changelog=PATH Path to the changelog file (default: CHANGELOG.md).
--remote=NAME Remote name (default: origin).
--date=YYYY-MM-DD Override today's date in the new heading.
-h, --help Show this help.
-v, --version Print pubv's version.
pubv respects NO_COLOR and CI (no spinners, no ANSI colors).
If your default branch only accepts changes via merge/pull requests, you can't
push the release commit directly. pubv detects this up front and switches to
the merge-request workflow automatically (or force it with --merge-request).
On GitHub the switch fires only when the branch actually requires a pull
request before merging — other branch-protection rules (required status checks,
signed commits, linear history, deletion protection) don't block a direct push,
so they don't force the MR flow. On GitLab it fires only when you can't
push to the branch directly (can_push: false); a branch merely protected with
"Maintainers can push" still pushes directly when you're a maintainer.
The workflow splits the release into two steps:
pubv --merge-request # off the default branch:
# creates release/<version>, commits the changelog there,
# pushes the branch, and prints a ready-to-open MR URL.
# No tag is created yet — your local default stays clean.
# … review and merge the MR. Then, on the updated default branch:
git switch main && git pull
pubv --tag-release # reads the latest released version from CHANGELOG.md and
# tags the real merge commit, then pushes just the tag.The tag is deferred to --tag-release on purpose: until the MR is merged the
release commit isn't on the protected branch, and a squash/rebase merge would
leave a tag pointing at a commit that never lands there. --tag-release tags
HEAD of the merged default branch so the tag is always correct.
pubv needs to know which forge you're on to write correct compare / merge-request
URLs and to run the protected-branch check. It figures this out in order:
- The git remote is the source of truth.
pubvreadsgit remote get-url <remote>(handleshttps,ssh://, andgit@host:group/sub/proj.gitforms) for the real host and project path — subgroups and custom ports included. - Host name first.
github.com,*gitlab*, and*bitbucket*are recognised instantly, with no network call. - HTTP fingerprint for unknown domains. A self-hosted GitLab on a custom domain
(
code.acme.com,vcs.corp.io, …) can't be recognised by name, sopubvfetches its pre-login PWA manifest (GET /-/manifest.json) and checks for GitLab over HTTPS. - CHANGELOG fallback. With no usable remote,
pubvscrapes the forge URL from yourCHANGELOG.mdlink refs instead — for URL generation only. The protected-branch check is a preflight gate and runs only when the origin remote itself resolves to GitHub/GitLab; with no such remote it's skipped (there's nothing to push to) and the release proceeds in standard mode.
The probe is best-effort and never blocks a release: a host that's unreachable,
login-walled, or simply isn't a known forge falls back to a generic classification
(GitHub-style URLs, protected-branch check skipped). It only runs for genuinely
unknown custom domains — the common GitHub/GitLab cases never touch the network.
Set PUBV_NO_HOST_PROBE=1 to disable the probe entirely (mirrors
PUBV_NO_PROTECTION_CHECK).
Code layout:
src/
core/ pure logic — parse, transform, bump, host, prefix, orchestrator
ports/ narrow TS interfaces for git, fs, prompt, logger
adapters/ the only files that touch node:fs / node:child_process / node:readline
cli/ hand-rolled arg parser + help + composition root
bin.ts entry point
Tests:
- Unit — every core module has its own test file. Changelog transforms are driven by plain
.md/.jsonfixtures intests/fixtures/— input + expected output + metadata. Adding a new scenario is a new folder, no test code change. - E2E —
tests/e2e/publish.test.tsspawns the built CLI inside a temp git repo against a local bare remote, verifying the full path including the push.
pubv deliberately does one thing: turn a Keep a Changelog CHANGELOG.md into a tagged, pushed release. If you were looking at heavier release tools — standard-version, release-it, semantic-release, changesets, np, or commit-and-tag-version — and wanted something smaller that trusts the changelog you already write (no commit-message conventions, no config file, one dependency), that's the gap pubv fills.
bun install
bun run lint
bun run typecheck
bun test # fast: ~50 unit tests + 7 e2e
bun run build