verbump-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
verbump/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """verbump: version and changelog bumps for repos."""
2
+
3
+ __version__ = "1.0.0"
verbump/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from verbump.cli import main
2
+
3
+ main()
verbump/changelog.py ADDED
@@ -0,0 +1,53 @@
1
+ """Keep a Changelog files: turn "Unreleased" into a version section, and read a section back.
2
+
3
+ The notes written under "## [Unreleased]" become the release. When nobody wrote any, the
4
+ section lists the commit subjects that touched the package since its last release.
5
+ """
6
+
7
+ import re
8
+
9
+ HEADER = (
10
+ "# Changelog\n\n"
11
+ "All notable changes are listed here. The format follows "
12
+ "[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow "
13
+ "[Semantic Versioning](https://semver.org/).\n\n"
14
+ "## [Unreleased]\n"
15
+ )
16
+ UNRELEASED = re.compile(r"^## \[Unreleased\][^\n]*\n", re.MULTILINE)
17
+ ANY_SECTION = re.compile(r"^## \[", re.MULTILINE)
18
+ SKIP_SUBJECT = re.compile(r"^(chore\(release\)|Merge )")
19
+
20
+
21
+ def section_re(version: str) -> re.Pattern:
22
+ return re.compile(rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\Z)", re.DOTALL | re.MULTILINE)
23
+
24
+
25
+ def commit_entries(commits: list[tuple[str, str]]) -> str:
26
+ lines = [f"- {subject} ({sha})" for sha, subject in commits if not SKIP_SUBJECT.match(subject)]
27
+ return "### Changes\n" + "\n".join(lines) + "\n" if lines else ""
28
+
29
+
30
+ def release(text: str | None, version: str, date: str, commits: list[tuple[str, str]]) -> str:
31
+ """The changelog with a `## [version] - date` section; unchanged if that section exists."""
32
+ text = text if text is not None else HEADER
33
+ if section_re(version).search(text):
34
+ return text
35
+ m = UNRELEASED.search(text)
36
+ if m:
37
+ nxt = ANY_SECTION.search(text, m.end())
38
+ body_end = nxt.start() if nxt else len(text)
39
+ body = text[m.end():body_end].strip()
40
+ head, rest = text[:m.end()], text[body_end:]
41
+ else:
42
+ nxt = ANY_SECTION.search(text)
43
+ cut = nxt.start() if nxt else len(text)
44
+ body = ""
45
+ head, rest = text[:cut].rstrip("\n") + "\n\n## [Unreleased]\n", text[cut:]
46
+ body = body or commit_entries(commits).strip() or "No notable changes."
47
+ section = f"\n## [{version}] - {date}\n\n{body}\n"
48
+ return head + section + ("\n" + rest if rest else "")
49
+
50
+
51
+ def notes(text: str, version: str) -> str:
52
+ m = section_re(version).search(text or "")
53
+ return (m.group(1).strip() if m else "") or f"Release {version}."
verbump/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ """verbump command line.
2
+
3
+ verbump status packages, versions, last tags, pending changes
4
+ verbump plan --previous v1.4.0 --version-from v1.5.0 what would release (trunk-semver outputs)
5
+ verbump apply --bump minor --plan-out plan.json write versions and changelogs
6
+ verbump tag --plan plan.json create the planned git tags
7
+ verbump notes <package> [<version>] a version's changelog section, for a release body
8
+ verbump set <package> <version> write an exact version into one package
9
+ verbump check exit 1 if a package's version files disagree
10
+ verbump init write a starter verbump.toml
11
+ verbump demo a narrated release in a throwaway repo
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from verbump import __version__, changelog, gitutil, release, semver
20
+ from verbump.config import Config, ConfigError, load
21
+
22
+ STARTER = '''\
23
+ # verbump: one table per package that has its own version.
24
+ # Tags are "<name>/v<version>" unless tag_format says otherwise.
25
+ tag_format = "{name}/v{version}"
26
+ changelog = "CHANGELOG.md"
27
+
28
+ [packages.app]
29
+ path = "."
30
+ files = ["pyproject.toml"]
31
+ # watch = ["libs/shared"] # changes here also release this package
32
+ # exclude = ["*.md", "docs/*"] # changes only here don't
33
+ '''
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> None:
37
+ args = parser().parse_args(argv)
38
+ try:
39
+ code = args.func(args)
40
+ except (ConfigError, release.ReleaseError, semver.VersionError, gitutil.GitError, ValueError) as e:
41
+ sys.exit(f"verbump: {e}")
42
+ sys.exit(code or 0)
43
+
44
+
45
+ def parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(prog="verbump", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
47
+ p.add_argument("--version", action="version", version=f"verbump {__version__}")
48
+ p.add_argument("-C", "--root", type=Path, default=Path("."), help="repo root (default: .)")
49
+ p.add_argument("-c", "--config", type=Path, help="config file (default: <root>/verbump.toml)")
50
+ sub = p.add_subparsers(dest="command", required=True)
51
+
52
+ s = sub.add_parser("status", help="show every package")
53
+ s.add_argument("--json", action="store_true")
54
+ s.set_defaults(func=cmd_status)
55
+
56
+ for name, func, help_ in (("plan", cmd_plan, "show what would release"),
57
+ ("apply", cmd_apply, "write the versions and changelogs")):
58
+ s = sub.add_parser(name, help=help_)
59
+ src = s.add_mutually_exclusive_group()
60
+ src.add_argument("--bump", choices=semver.BUMPS, type=str.lower, help="bump every changed package by this")
61
+ src.add_argument("--branch", help="pick the bump from a branch name, like trunk-semver")
62
+ s.add_argument("--previous", help="trunk-semver previous-version; with --version-from, gives the bump")
63
+ s.add_argument("--version-from", dest="new_version", metavar="VERSION",
64
+ help="trunk-semver version output (the new version)")
65
+ s.add_argument("--exact", action="append", default=[], metavar="NAME=X.Y.Z",
66
+ help="release a package at exactly this version, changed or not")
67
+ s.add_argument("--release-branch", metavar="BRANCH",
68
+ help="release/<package>/X.Y.Z (or release/X.Y.Z with one package) sets --exact")
69
+ s.add_argument("--only", action="append", default=[], metavar="NAME", help="limit to these packages")
70
+ s.add_argument("--json", action="store_true", help="print the plan as JSON")
71
+ s.add_argument("--plan-out", type=Path, help="also write the plan as JSON to this file")
72
+ if name == "apply":
73
+ s.add_argument("--date", help="release date (default: today, UTC)")
74
+ s.set_defaults(func=func)
75
+
76
+ s = sub.add_parser("tag", help="create the git tags of a plan")
77
+ s.add_argument("--plan", type=Path, required=True)
78
+ s.set_defaults(func=cmd_tag)
79
+
80
+ s = sub.add_parser("notes", help="print a version's changelog section")
81
+ s.add_argument("package")
82
+ s.add_argument("version", nargs="?")
83
+ s.set_defaults(func=cmd_notes)
84
+
85
+ s = sub.add_parser("set", help="write an exact version into one package's files and changelog")
86
+ s.add_argument("package")
87
+ s.add_argument("version")
88
+ s.add_argument("--date")
89
+ s.set_defaults(func=cmd_set)
90
+
91
+ s = sub.add_parser("check", help="exit 1 if a package's version files disagree")
92
+ s.set_defaults(func=cmd_check)
93
+
94
+ s = sub.add_parser("init", help="write a starter verbump.toml")
95
+ s.set_defaults(func=cmd_init)
96
+
97
+ s = sub.add_parser("demo", help="a narrated release in a throwaway repo")
98
+ s.add_argument("--dir", type=Path, help="build the demo repo here and keep it (an empty or new folder)")
99
+ s.add_argument("--setup-only", action="store_true", help="only build the demo repo and print its path")
100
+ s.set_defaults(func=cmd_demo)
101
+ return p
102
+
103
+
104
+ def config(args) -> Config:
105
+ return load(args.config or args.root / "verbump.toml")
106
+
107
+
108
+ def resolve_bump(args) -> str:
109
+ if args.bump:
110
+ return args.bump
111
+ if args.previous or args.new_version:
112
+ if not (args.previous and args.new_version):
113
+ raise ValueError("--previous and --version-from go together")
114
+ return semver.bump_between(args.previous, args.new_version)
115
+ if args.branch:
116
+ return semver.bump_for_branch(args.branch)
117
+ return "patch"
118
+
119
+
120
+ def build_plan(args):
121
+ cfg = config(args)
122
+ bump = resolve_bump(args)
123
+ exact = release.exact_from_branch(cfg, args.release_branch)
124
+ for item in args.exact:
125
+ name, sep, version = item.partition("=")
126
+ if not sep:
127
+ raise ValueError(f"--exact {item!r}: use NAME=X.Y.Z")
128
+ exact[name] = version
129
+ releases = release.plan(args.root, cfg, bump, exact, args.only)
130
+ return cfg, bump, releases
131
+
132
+
133
+ def report(args, cfg, bump, releases) -> None:
134
+ data = release.to_json(cfg, bump, releases)
135
+ if args.plan_out:
136
+ args.plan_out.write_text(json.dumps(data, indent=2) + "\n")
137
+ if args.json:
138
+ print(json.dumps(data, indent=2))
139
+ elif not releases:
140
+ print("nothing to release")
141
+ else:
142
+ for r in releases:
143
+ print(f"{r.name}: {r.previous or '-'} -> {r.version} ({r.reason}, tag {r.tag})")
144
+
145
+
146
+ def cmd_plan(args) -> int:
147
+ report(args, *build_plan(args))
148
+ return 0
149
+
150
+
151
+ def cmd_apply(args) -> int:
152
+ cfg, bump, releases = build_plan(args)
153
+ touched = release.apply(args.root, cfg, releases, args.date)
154
+ report(args, cfg, bump, releases)
155
+ for path in touched:
156
+ print(f" wrote {path}", file=sys.stderr)
157
+ return 0
158
+
159
+
160
+ def cmd_tag(args) -> int:
161
+ data = json.loads(args.plan.read_text())
162
+ for r in data["releases"]:
163
+ gitutil.git(args.root, "tag", "-a", r["tag"], "-m", f"{r['name']} {r['version']}")
164
+ print(r["tag"])
165
+ if r.get("major_tag"):
166
+ gitutil.git(args.root, "tag", "-f", r["major_tag"])
167
+ print(r["major_tag"])
168
+ return 0
169
+
170
+
171
+ def cmd_notes(args) -> int:
172
+ cfg = config(args)
173
+ pkg = cfg.get(args.package)
174
+ version = semver.normalize(args.version) if args.version else release.file_version(args.root, pkg)
175
+ if not version:
176
+ raise ValueError(f"{pkg.name} has no version files; pass the version")
177
+ path = args.root / pkg.changelog_path if pkg.changelog_path else None
178
+ print(changelog.notes(path.read_text() if path and path.is_file() else "", version))
179
+ return 0
180
+
181
+
182
+ def cmd_set(args) -> int:
183
+ cfg = config(args)
184
+ pkg = cfg.get(args.package)
185
+ version = semver.normalize(args.version)
186
+ r = release.Release(name=pkg.name, path=pkg.path, previous=None, version=version, tag=pkg.tag(version),
187
+ major_tag=None, changelog=pkg.changelog_path, reason="set")
188
+ release.apply(args.root, cfg, [r], args.date)
189
+ print(f"set {pkg.name} {version}")
190
+ return 0
191
+
192
+
193
+ def cmd_check(args) -> int:
194
+ cfg = config(args)
195
+ bad = 0
196
+ for pkg in cfg.packages:
197
+ try:
198
+ v = release.file_version(args.root, pkg)
199
+ print(f"{pkg.name}: {v or 'no version files'}")
200
+ except (release.ReleaseError, ValueError) as e:
201
+ print(f"{pkg.name}: {e}", file=sys.stderr)
202
+ bad += 1
203
+ return 1 if bad else 0
204
+
205
+
206
+ def cmd_status(args) -> int:
207
+ cfg = config(args)
208
+ tags = gitutil.tags_merged(args.root)
209
+ rows = []
210
+ for pkg in cfg.packages:
211
+ st = release.state(args.root, pkg, tags)
212
+ rows.append({"name": pkg.name, "path": pkg.path, "version": st.file_version, "last_tag": st.last_tag,
213
+ "changed": len(st.changed)})
214
+ if args.json:
215
+ print(json.dumps(rows, indent=2))
216
+ else:
217
+ table = [(r["name"], r["path"], r["version"] or "-", r["last_tag"] or "(no tag)", f"{r['changed']} changed")
218
+ for r in rows]
219
+ widths = [max(len(row[i]) for row in table) for i in range(4)]
220
+ for row in table:
221
+ print(" ".join(cell.ljust(w) for cell, w in zip(row[:4], widths, strict=True)) + " " + row[4])
222
+ return 0
223
+
224
+
225
+ def cmd_init(args) -> int:
226
+ path = args.config or args.root / "verbump.toml"
227
+ if path.exists():
228
+ raise ValueError(f"{path} already exists")
229
+ path.write_text(STARTER)
230
+ print(f"wrote {path}")
231
+ return 0
232
+
233
+
234
+ def cmd_demo(args) -> int:
235
+ from verbump import demo
236
+
237
+ return demo.main(keep=args.dir, setup_only=args.setup_only)
verbump/config.py ADDED
@@ -0,0 +1,158 @@
1
+ """Load verbump.toml.
2
+
3
+ tag_format = "{name}/v{version}" # defaults for every package
4
+ changelog = "CHANGELOG.md" # relative to the package path
5
+
6
+ [packages.api]
7
+ path = "services/api"
8
+ files = ["pyproject.toml", { path = "deploy/values.yaml", pattern = 'tag: "(?P<version>[0-9.]+)"' }]
9
+ watch = ["libs/shared"] # more repo paths whose changes release this package
10
+ exclude = ["*.md", "docs/**"] # changes here alone don't release it
11
+ """
12
+
13
+ import posixpath
14
+ import re
15
+ import tomllib
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from verbump.files import VersionFile, version_file
20
+
21
+ DEFAULT_TAG_FORMAT = "{name}/v{version}"
22
+ DEFAULT_COMMIT = "chore(release): {releases}"
23
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
24
+ PACKAGE_KEYS = {"path", "files", "watch", "exclude", "tag_format", "changelog", "major_tag", "initial_version"}
25
+ FILE_KEYS = {"path", "kind", "pattern", "after", "replace_all"}
26
+ TOP_KEYS = {"tag_format", "changelog", "commit_message", "major_tag", "packages"}
27
+
28
+
29
+ class ConfigError(ValueError):
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class Package:
35
+ name: str
36
+ path: str
37
+ files: list[VersionFile]
38
+ watch: list[str] = field(default_factory=list)
39
+ exclude: list[str] = field(default_factory=list)
40
+ tag_format: str = DEFAULT_TAG_FORMAT
41
+ changelog: str | None = "CHANGELOG.md"
42
+ major_tag: bool = False
43
+ initial_version: str = "0.0.0"
44
+
45
+ def repo_path(self, rel: str) -> str:
46
+ return posixpath.normpath(posixpath.join(self.path, rel))
47
+
48
+ @property
49
+ def changelog_path(self) -> str | None:
50
+ return self.repo_path(self.changelog) if self.changelog else None
51
+
52
+ @property
53
+ def paths(self) -> list[str]:
54
+ return [self.path, *self.watch]
55
+
56
+ def tag(self, version: str) -> str:
57
+ return self.tag_format.format(name=self.name, path=self.path, version=version)
58
+
59
+ def major_tag_name(self, version: str) -> str:
60
+ return self.tag(version).rsplit(version, 1)[0] + version.split(".")[0]
61
+
62
+ def tag_regex(self) -> re.Pattern:
63
+ head, _, tail = self.tag_format.partition("{version}")
64
+ return re.compile(
65
+ "^" + re.escape(head.format(name=self.name, path=self.path)) + r"(?P<version>\d+\.\d+\.\d+)"
66
+ + re.escape(tail.format(name=self.name, path=self.path)) + "$"
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class Config:
72
+ packages: list[Package]
73
+ commit_message: str = DEFAULT_COMMIT
74
+
75
+ def get(self, name: str) -> Package:
76
+ for p in self.packages:
77
+ if p.name == name:
78
+ return p
79
+ raise ConfigError(f"no package named {name!r} (known: {', '.join(p.name for p in self.packages)})")
80
+
81
+
82
+ def load(path: Path) -> Config:
83
+ if not path.is_file():
84
+ raise ConfigError(f"{path}: not found (create a verbump.toml, see `verbump init`)")
85
+ try:
86
+ data = tomllib.loads(path.read_text())
87
+ except tomllib.TOMLDecodeError as e:
88
+ raise ConfigError(f"{path}: {e}") from e
89
+ return parse(data)
90
+
91
+
92
+ def parse(data: dict) -> Config:
93
+ unknown = set(data) - TOP_KEYS
94
+ if unknown:
95
+ raise ConfigError(f"unknown top-level keys: {', '.join(sorted(unknown))}")
96
+ raw = data.get("packages")
97
+ if not isinstance(raw, dict) or not raw:
98
+ raise ConfigError("no [packages.<name>] tables")
99
+ defaults = {k: data[k] for k in ("tag_format", "changelog", "major_tag") if k in data}
100
+ packages = [_package(name, {**defaults, **spec}) for name, spec in raw.items()]
101
+ _check_unique_tags(packages)
102
+ return Config(packages=packages, commit_message=data.get("commit_message", DEFAULT_COMMIT))
103
+
104
+
105
+ def _package(name: str, spec: dict) -> Package:
106
+ if not NAME_RE.match(name):
107
+ raise ConfigError(f"package name {name!r}: use letters, digits, '.', '_' or '-'")
108
+ unknown = set(spec) - PACKAGE_KEYS
109
+ if unknown:
110
+ raise ConfigError(f"[packages.{name}]: unknown keys {', '.join(sorted(unknown))}")
111
+ path = posixpath.normpath(spec.get("path", "."))
112
+ if path.startswith(("..", "/")):
113
+ raise ConfigError(f"[packages.{name}]: path must stay inside the repo")
114
+ tag_format = spec.get("tag_format", DEFAULT_TAG_FORMAT)
115
+ if tag_format.count("{version}") != 1:
116
+ raise ConfigError(f"[packages.{name}]: tag_format needs exactly one {{version}}")
117
+ try:
118
+ tag_format.format(name=name, path=path, version="0.0.0")
119
+ except (KeyError, IndexError, ValueError) as e:
120
+ raise ConfigError(f"[packages.{name}]: tag_format may use only {{name}}, {{path}} and {{version}}") from e
121
+ if "{path}" in tag_format and path == ".":
122
+ raise ConfigError(f"[packages.{name}]: a package at the repo root can't use {{path}} in tag_format")
123
+ files = []
124
+ for f in spec.get("files", []):
125
+ if isinstance(f, str):
126
+ f = {"path": f}
127
+ if not isinstance(f, dict) or "path" not in f:
128
+ raise ConfigError(f"[packages.{name}]: each file is a path or {{ path, kind | pattern, ... }}")
129
+ if unknown := set(f) - FILE_KEYS:
130
+ raise ConfigError(f"[packages.{name}] file {f['path']}: unknown keys {', '.join(sorted(unknown))}")
131
+ rel = posixpath.normpath(posixpath.join(path, f["path"]))
132
+ try:
133
+ vf = version_file(rel, f.get("kind"), f.get("pattern"), f.get("after"), f.get("replace_all"))
134
+ vf.regex()
135
+ files.append(vf)
136
+ except ValueError as e:
137
+ raise ConfigError(f"[packages.{name}]: {e}") from e
138
+ changelog = spec.get("changelog", "CHANGELOG.md")
139
+ return Package(
140
+ name=name,
141
+ path=path,
142
+ files=files,
143
+ watch=[posixpath.normpath(w) for w in spec.get("watch", [])],
144
+ exclude=list(spec.get("exclude", [])),
145
+ tag_format=tag_format,
146
+ changelog=changelog or None,
147
+ major_tag=bool(spec.get("major_tag", False)),
148
+ initial_version=spec.get("initial_version", "0.0.0"),
149
+ )
150
+
151
+
152
+ def _check_unique_tags(packages: list[Package]) -> None:
153
+ seen = {}
154
+ for p in packages:
155
+ probe = p.tag("1.2.3")
156
+ if probe in seen:
157
+ raise ConfigError(f"packages {seen[probe]!r} and {p.name!r} would share tags; give one its own tag_format")
158
+ seen[probe] = p.name
verbump/demo.py ADDED
@@ -0,0 +1,101 @@
1
+ """`verbump demo`: a narrated release in a throwaway repo with a Python and a Node package."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import textwrap
10
+ from pathlib import Path
11
+
12
+ CONFIG = """\
13
+ [packages.api]
14
+ path = "services/api"
15
+ files = ["pyproject.toml"]
16
+
17
+ [packages.web]
18
+ path = "apps/web"
19
+ files = ["package.json"]
20
+ """
21
+
22
+ GIT = ["git", "-c", "user.name=demo", "-c", "user.email=demo@example.com", "-c", "commit.gpgsign=false",
23
+ "-c", "tag.gpgsign=false", "-c", "init.defaultBranch=main"]
24
+
25
+
26
+ def git(root: Path, *args: str) -> str:
27
+ return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout
28
+
29
+
30
+ def write(root: Path, path: str, text: str) -> None:
31
+ p = root / path
32
+ p.parent.mkdir(parents=True, exist_ok=True)
33
+ p.write_text(text)
34
+
35
+
36
+ def changelog(*sections: str) -> str:
37
+ return "# Changelog\n\n## [Unreleased]\n" + "".join(sections)
38
+
39
+
40
+ def setup(root: Path) -> None:
41
+ """Build the demo repo: api 1.4.0 and web 0.3.0 released, then a PR that changed web."""
42
+ root.mkdir(parents=True, exist_ok=True)
43
+ git(root, "init", "-q")
44
+ write(root, "verbump.toml", CONFIG)
45
+ write(root, "services/api/pyproject.toml", '[project]\nname = "api"\nversion = "1.4.0"\n')
46
+ write(root, "services/api/app.py", "def health():\n return 'ok'\n")
47
+ write(root, "services/api/CHANGELOG.md", changelog("\n## [1.4.0] - 2026-11-02\n\n- Health check.\n"))
48
+ write(root, "apps/web/package.json", json.dumps({"name": "web", "version": "0.3.0"}, indent=2) + "\n")
49
+ write(root, "apps/web/index.js", "export const home = () => 'home'\n")
50
+ write(root, "apps/web/CHANGELOG.md", changelog("\n## [0.3.0] - 2026-11-02\n\n- Home page.\n"))
51
+ git(root, "add", "-A")
52
+ git(root, "commit", "-qm", "chore(release): api 1.4.0, web 0.3.0")
53
+ git(root, "tag", "api/v1.4.0")
54
+ git(root, "tag", "web/v0.3.0")
55
+ write(root, "apps/web/checkout.js", "export const checkout = () => 'pay'\n")
56
+ write(root, "apps/web/CHANGELOG.md", changelog("\n### Added\n- Checkout page.\n",
57
+ "\n## [0.3.0] - 2026-11-02\n\n- Home page.\n"))
58
+ git(root, "add", "-A")
59
+ git(root, "commit", "-qm", "feat(web): checkout page")
60
+
61
+
62
+ def run(root: Path, *args: str) -> None:
63
+ print(f"$ {' '.join(args)}", flush=True)
64
+ cmd = [sys.executable, "-m", "verbump", *args[1:]] if args[0] == "verbump" else [*GIT, *args[1:]]
65
+ pkg_root = str(Path(__file__).resolve().parent.parent)
66
+ env = {**os.environ, "PYTHONPATH": os.pathsep.join(filter(None, [pkg_root, os.environ.get("PYTHONPATH")]))}
67
+ proc = subprocess.run(cmd, cwd=root, capture_output=True, text=True, check=False, env=env)
68
+ out = (proc.stdout + proc.stderr).rstrip()
69
+ if out:
70
+ print(out)
71
+ print()
72
+
73
+
74
+ def say(text: str) -> None:
75
+ print(textwrap.fill(f"# {text}", width=88, subsequent_indent="# "))
76
+
77
+
78
+ def main(keep: Path | None = None, setup_only: bool = False) -> int:
79
+ root = keep or Path(tempfile.mkdtemp(prefix="verbump-demo-"))
80
+ if keep and keep.exists() and any(keep.iterdir()):
81
+ raise ValueError(f"{keep} is not empty")
82
+ setup(root)
83
+ if setup_only:
84
+ print(root)
85
+ return 0
86
+ try:
87
+ say("A repo with two packages: api (Python) at 1.4.0 and web (Node) at 0.3.0, both tagged.")
88
+ say("A PR from feat/checkout just changed apps/web and wrote a note under Unreleased.")
89
+ print()
90
+ run(root, "verbump", "status")
91
+ say("feat/* is a minor bump. Only web changed since its tag, so only web moves.")
92
+ run(root, "verbump", "plan", "--branch", "feat/checkout")
93
+ run(root, "verbump", "apply", "--branch", "feat/checkout", "--date", "2026-11-09")
94
+ say("The version and the changelog, written:")
95
+ run(root, "git", "diff", "--stat")
96
+ run(root, "verbump", "notes", "web")
97
+ say("CI then commits, runs `verbump tag`, pushes, and uses those notes as the GitHub release.")
98
+ finally:
99
+ if keep is None:
100
+ shutil.rmtree(root, ignore_errors=True)
101
+ return 0
verbump/files.py ADDED
@@ -0,0 +1,149 @@
1
+ """Read and write the version inside the files that carry it.
2
+
3
+ A file is matched by a regex with a named group `version`. Files of the common stacks get a
4
+ kind from their name, so most configs only list paths. Any file can name a `kind`, or set
5
+ its own `pattern`, optionally searched only `after` a marker and rewritten at every match.
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ V = r"\d+\.\d+\.\d+"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Kind:
17
+ pattern: str
18
+ after: str | None = None
19
+ replace_all: bool = False
20
+
21
+
22
+ KINDS = {
23
+ "toml": Kind(rf'(?m)^version\s*=\s*"(?P<version>{V})"'),
24
+ "json": Kind(rf'"version"\s*:\s*"(?P<version>{V})"'),
25
+ "yaml": Kind(rf"(?m)^version:\s*['\"]?(?P<version>{V})"),
26
+ "helm": Kind(rf"(?m)^(?:version|appVersion):\s*['\"]?v?(?P<version>{V})", replace_all=True),
27
+ "plain": Kind(rf"\A\s*v?(?P<version>{V})"),
28
+ "python": Kind(rf"__version__\s*=\s*['\"](?P<version>{V})['\"]"),
29
+ "msbuild": Kind(rf"<(?:Version|VersionPrefix)>(?P<version>{V})</"),
30
+ "assemblyinfo": Kind(rf'Assembly(?:File|Informational)?Version\(\s*"(?P<version>{V})', replace_all=True),
31
+ "maven": Kind(rf"<version>(?P<version>{V})</version>", after="</parent>"),
32
+ "properties": Kind(rf"(?m)^version\s*[=:]\s*(?P<version>{V})"),
33
+ "gradle": Kind(rf"""(?m)^\s*version\s*=?\s*["'](?P<version>{V})["']"""),
34
+ "ruby": Kind(rf"""(?:VERSION|\.version)\s*=\s*["'](?P<version>{V})["']"""),
35
+ "elixir": Kind(rf'version:\s*"(?P<version>{V})"'),
36
+ "dockerfile": Kind(rf"""(?im)^LABEL\s.*?version\s*=\s*["']?(?P<version>{V})"""),
37
+ }
38
+
39
+ BY_NAME = {
40
+ "pyproject.toml": "toml",
41
+ "Cargo.toml": "toml",
42
+ "package.json": "json",
43
+ "composer.json": "json",
44
+ "deno.json": "json",
45
+ "jsr.json": "json",
46
+ "manifest.json": "json",
47
+ "Chart.yaml": "yaml",
48
+ "pubspec.yaml": "yaml",
49
+ "VERSION": "plain",
50
+ "VERSION.txt": "plain",
51
+ "version.txt": "plain",
52
+ ".version": "plain",
53
+ "AssemblyInfo.cs": "assemblyinfo",
54
+ "pom.xml": "maven",
55
+ "gradle.properties": "properties",
56
+ "setup.cfg": "properties",
57
+ "build.gradle": "gradle",
58
+ "build.gradle.kts": "gradle",
59
+ "version.rb": "ruby",
60
+ "mix.exs": "elixir",
61
+ "Dockerfile": "dockerfile",
62
+ }
63
+ BY_SUFFIX = {
64
+ ".py": "python",
65
+ ".csproj": "msbuild",
66
+ ".fsproj": "msbuild",
67
+ ".vbproj": "msbuild",
68
+ ".props": "msbuild",
69
+ ".gemspec": "ruby",
70
+ }
71
+
72
+
73
+ class VersionFileError(ValueError):
74
+ pass
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class VersionFile:
79
+ path: str
80
+ pattern: str
81
+ after: str | None = None
82
+ replace_all: bool = False
83
+
84
+ def regex(self) -> re.Pattern:
85
+ rx = re.compile(self.pattern)
86
+ if "version" not in rx.groupindex:
87
+ raise VersionFileError(f"{self.path}: pattern has no (?P<version>...) group")
88
+ return rx
89
+
90
+ def read(self, root: Path) -> str:
91
+ text = self._text(root)
92
+ return self._matches(text)[0].group("version")
93
+
94
+ def write(self, root: Path, version: str) -> bool:
95
+ text = self._text(root)
96
+ matches = self._matches(text)
97
+ if not self.replace_all:
98
+ matches = matches[:1]
99
+ new = text
100
+ for m in reversed(matches):
101
+ start, end = m.span("version")
102
+ new = new[:start] + version + new[end:]
103
+ if new == text:
104
+ return False
105
+ (root / self.path).write_text(new)
106
+ return True
107
+
108
+ def _matches(self, text: str) -> list[re.Match]:
109
+ start = 0
110
+ if self.after and (i := text.find(self.after)) >= 0:
111
+ start = i + len(self.after)
112
+ matches = list(self.regex().finditer(text, start))
113
+ if not matches:
114
+ raise VersionFileError(f"{self.path}: no version matches {self.pattern!r}")
115
+ return matches
116
+
117
+ def _text(self, root: Path) -> str:
118
+ p = root / self.path
119
+ if not p.is_file():
120
+ raise VersionFileError(f"{self.path}: file not found")
121
+ return p.read_text()
122
+
123
+
124
+ def kind_for(path: str) -> str:
125
+ name, suffix = Path(path).name, Path(path).suffix
126
+ if name in BY_NAME:
127
+ return BY_NAME[name]
128
+ if suffix in BY_SUFFIX:
129
+ return BY_SUFFIX[suffix]
130
+ raise VersionFileError(
131
+ f"{path}: unknown file type; set `kind` (one of {', '.join(KINDS)}) or a `pattern` with a (?P<version>...) group"
132
+ )
133
+
134
+
135
+ def version_file(path: str, kind: str | None = None, pattern: str | None = None, after: str | None = None,
136
+ replace_all: bool | None = None) -> VersionFile:
137
+ """A version file from its config: an explicit pattern wins, then the kind, then the file name."""
138
+ base = None
139
+ if not pattern:
140
+ name = kind or kind_for(path)
141
+ if name not in KINDS:
142
+ raise VersionFileError(f"{path}: unknown kind {name!r}; use one of {', '.join(KINDS)}")
143
+ base = KINDS[name]
144
+ return VersionFile(
145
+ path=path,
146
+ pattern=pattern or base.pattern,
147
+ after=after if after is not None else (base.after if base else None),
148
+ replace_all=replace_all if replace_all is not None else (base.replace_all if base else False),
149
+ )
verbump/gitutil.py ADDED
@@ -0,0 +1,39 @@
1
+ """Thin wrappers around the git commands verbump needs."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ class GitError(RuntimeError):
8
+ pass
9
+
10
+
11
+ def git(root: Path, *args: str) -> str:
12
+ proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False)
13
+ if proc.returncode != 0:
14
+ raise GitError(f"git {' '.join(args)}: {proc.stderr.strip()}")
15
+ return proc.stdout
16
+
17
+
18
+ def tags_merged(root: Path) -> list[str]:
19
+ return git(root, "tag", "--merged", "HEAD").split()
20
+
21
+
22
+ def tag_exists(root: Path, tag: str) -> bool:
23
+ return bool(git(root, "tag", "--list", tag).strip())
24
+
25
+
26
+ def changed_files(root: Path, base: str | None, paths: list[str]) -> list[str]:
27
+ """Files changed under `paths` since `base`; every tracked file under them when there is no base."""
28
+ if base is None:
29
+ out = git(root, "ls-files", "--", *paths)
30
+ else:
31
+ out = git(root, "diff", "--name-only", f"{base}..HEAD", "--", *paths)
32
+ return [line for line in out.splitlines() if line]
33
+
34
+
35
+ def log_subjects(root: Path, base: str | None, paths: list[str]) -> list[tuple[str, str]]:
36
+ """(short sha, subject) of the commits touching `paths` since `base`, oldest first."""
37
+ rng = [f"{base}..HEAD"] if base else ["HEAD"]
38
+ out = git(root, "log", "--reverse", "--no-merges", "--format=%h%x09%s", *rng, "--", *paths)
39
+ return [tuple(line.split("\t", 1)) for line in out.splitlines() if "\t" in line]
verbump/release.py ADDED
@@ -0,0 +1,152 @@
1
+ """Work out which packages release and at what version, then write it."""
2
+
3
+ import datetime
4
+ import fnmatch
5
+ import posixpath
6
+ import re
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+
10
+ from verbump import changelog, gitutil, semver
11
+ from verbump.config import Config, Package
12
+
13
+ RELEASE_BRANCH = re.compile(r"^release/(?:(?P<name>[^/]+)/)?v?(?P<version>\d+\.\d+\.\d+)$")
14
+
15
+
16
+ class ReleaseError(RuntimeError):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class State:
22
+ package: Package
23
+ file_version: str | None
24
+ last_tag: str | None
25
+ last_version: str | None
26
+ changed: list[str]
27
+
28
+ @property
29
+ def current(self) -> str | None:
30
+ known = [v for v in (self.file_version, self.last_version) if v]
31
+ return max(known, key=semver.parse) if known else None
32
+
33
+
34
+ @dataclass
35
+ class Release:
36
+ name: str
37
+ path: str
38
+ previous: str | None
39
+ version: str
40
+ tag: str
41
+ major_tag: str | None
42
+ changelog: str | None
43
+ reason: str
44
+ changed: list[str] = field(default_factory=list)
45
+
46
+
47
+ def last_release(root: Path, pkg: Package, tags: list[str]) -> tuple[str | None, str | None]:
48
+ rx = pkg.tag_regex()
49
+ found = [(semver.parse(m.group("version")), t, m.group("version")) for t in tags if (m := rx.match(t))]
50
+ if not found:
51
+ return None, None
52
+ _, tag, version = max(found)
53
+ return tag, version
54
+
55
+
56
+ def file_version(root: Path, pkg: Package) -> str | None:
57
+ versions = {f.path: f.read(root) for f in pkg.files}
58
+ if len(set(versions.values())) > 1:
59
+ raise ReleaseError(f"{pkg.name}: version files disagree: {versions}")
60
+ return next(iter(versions.values()), None)
61
+
62
+
63
+ def excluded(pkg: Package, path: str) -> bool:
64
+ rel = posixpath.relpath(path, pkg.path) if pkg.path != "." else path
65
+ return any(fnmatch.fnmatch(path, pat) or fnmatch.fnmatch(rel, pat) for pat in pkg.exclude)
66
+
67
+
68
+ def state(root: Path, pkg: Package, tags: list[str]) -> State:
69
+ tag, version = last_release(root, pkg, tags)
70
+ files = gitutil.changed_files(root, tag, pkg.paths)
71
+ return State(pkg, file_version(root, pkg), tag, version, [f for f in files if not excluded(pkg, f)])
72
+
73
+
74
+ def exact_from_branch(cfg: Config, branch: str | None) -> dict[str, str]:
75
+ m = RELEASE_BRANCH.match(branch or "")
76
+ if not m:
77
+ return {}
78
+ name = m.group("name")
79
+ if name is None:
80
+ if len(cfg.packages) != 1:
81
+ raise ReleaseError(f"{branch}: name the package, e.g. release/<package>/{m.group('version')}")
82
+ name = cfg.packages[0].name
83
+ cfg.get(name)
84
+ return {name: m.group("version")}
85
+
86
+
87
+ def plan(root: Path, cfg: Config, bump: str, exact: dict[str, str] | None = None,
88
+ only: list[str] | None = None) -> list[Release]:
89
+ exact = {k: semver.normalize(v) for k, v in (exact or {}).items()}
90
+ for name in [*exact, *(only or [])]:
91
+ cfg.get(name)
92
+ tags = gitutil.tags_merged(root)
93
+ releases = []
94
+ for pkg in cfg.packages:
95
+ if only and pkg.name not in only:
96
+ continue
97
+ st = state(root, pkg, tags)
98
+ if pkg.name in exact:
99
+ version, reason = exact[pkg.name], "exact"
100
+ if st.last_version and semver.parse(version) <= semver.parse(st.last_version):
101
+ raise ReleaseError(f"{pkg.name}: {version} is not newer than the last release {st.last_version}")
102
+ elif not st.changed:
103
+ continue
104
+ elif st.last_tag is None and st.file_version:
105
+ version, reason = st.file_version, "first release"
106
+ elif st.last_tag is None:
107
+ version, reason = semver.bump(pkg.initial_version, bump), "first release"
108
+ else:
109
+ version, reason = semver.bump(st.current, bump), bump
110
+ tag = pkg.tag(version)
111
+ if gitutil.tag_exists(root, tag):
112
+ raise ReleaseError(f"{pkg.name}: tag {tag} already exists")
113
+ releases.append(Release(
114
+ name=pkg.name, path=pkg.path, previous=st.last_version, version=version, tag=tag,
115
+ major_tag=pkg.major_tag_name(version) if pkg.major_tag else None,
116
+ changelog=pkg.changelog_path, reason=reason, changed=st.changed,
117
+ ))
118
+ return releases
119
+
120
+
121
+ def apply(root: Path, cfg: Config, releases: list[Release], date: str | None = None) -> list[str]:
122
+ """Write every release into its files and changelog; returns the paths it changed."""
123
+ date = date or datetime.datetime.now(datetime.UTC).date().isoformat()
124
+ tags = gitutil.tags_merged(root)
125
+ touched = []
126
+ for r in releases:
127
+ pkg = cfg.get(r.name)
128
+ for f in pkg.files:
129
+ if f.write(root, r.version):
130
+ touched.append(f.path)
131
+ if pkg.changelog_path:
132
+ last_tag, _ = last_release(root, pkg, tags)
133
+ path = root / pkg.changelog_path
134
+ old = path.read_text() if path.is_file() else None
135
+ new = changelog.release(old, r.version, date, gitutil.log_subjects(root, last_tag, pkg.paths))
136
+ if new != old:
137
+ path.parent.mkdir(parents=True, exist_ok=True)
138
+ path.write_text(new)
139
+ touched.append(pkg.changelog_path)
140
+ return touched
141
+
142
+
143
+ def commit_message(cfg: Config, releases: list[Release]) -> str:
144
+ return cfg.commit_message.format(releases=", ".join(f"{r.name} {r.version}" for r in releases))
145
+
146
+
147
+ def to_json(cfg: Config, bump: str, releases: list[Release]) -> dict:
148
+ return {
149
+ "bump": bump,
150
+ "commit_message": commit_message(cfg, releases) if releases else "",
151
+ "releases": [asdict(r) for r in releases],
152
+ }
verbump/semver.py ADDED
@@ -0,0 +1,50 @@
1
+ """Plain X.Y.Z versions: parse, compare and bump."""
2
+
3
+ import re
4
+
5
+ SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
6
+ BUMPS = ("major", "minor", "patch")
7
+
8
+
9
+ class VersionError(ValueError):
10
+ pass
11
+
12
+
13
+ def parse(text: str) -> tuple[int, int, int]:
14
+ m = SEMVER.match(text.strip())
15
+ if not m:
16
+ raise VersionError(f"not an X.Y.Z version: {text!r}")
17
+ return int(m.group(1)), int(m.group(2)), int(m.group(3))
18
+
19
+
20
+ def normalize(text: str) -> str:
21
+ return ".".join(map(str, parse(text)))
22
+
23
+
24
+ def bump(version: str, kind: str) -> str:
25
+ major, minor, patch = parse(version)
26
+ kind = kind.lower()
27
+ if kind == "major":
28
+ return f"{major + 1}.0.0"
29
+ if kind == "minor":
30
+ return f"{major}.{minor + 1}.0"
31
+ if kind == "patch":
32
+ return f"{major}.{minor}.{patch + 1}"
33
+ raise VersionError(f"unknown bump {kind!r}; use one of {', '.join(BUMPS)}")
34
+
35
+
36
+ def bump_between(previous: str, new: str) -> str:
37
+ """The bump that turns `previous` into `new`, e.g. trunk-semver's previous-version and version."""
38
+ p, n = parse(previous), parse(new)
39
+ if n <= p:
40
+ raise VersionError(f"{new} is not newer than {previous}")
41
+ if n[0] != p[0]:
42
+ return "major"
43
+ if n[1] != p[1]:
44
+ return "minor"
45
+ return "patch"
46
+
47
+
48
+ def bump_for_branch(branch: str) -> str:
49
+ """trunk-semver's branch rule, for runs without it: feature/* and feat/* are minor, the rest patch."""
50
+ return "minor" if branch.startswith(("feature/", "feat/")) else "patch"
@@ -0,0 +1,312 @@
1
+ Metadata-Version: 2.4
2
+ Name: verbump-cli
3
+ Version: 1.0.0
4
+ Summary: Version and changelog bumps for repos
5
+ Author: Gonçalo Faria
6
+ License-Expression: MIT
7
+ Project-URL: Action, https://github.com/TheKathan/verbump-action
8
+ Project-URL: Source, https://github.com/TheKathan/verbump
9
+ Project-URL: Changelog, https://github.com/TheKathan/verbump/blob/main/CHANGELOG.md
10
+ Keywords: version,semver,changelog,release,monorepo,bump,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ <h1 align="center">verbump</h1>
29
+
30
+ <p align="center">
31
+ <a href="https://github.com/TheKathan/verbump/actions/workflows/test.yml"><img alt="tests" src="https://github.com/TheKathan/verbump/actions/workflows/test.yml/badge.svg"></a>
32
+ <a href="https://pypi.org/project/verbump-cli/"><img alt="PyPI" src="https://img.shields.io/pypi/v/verbump-cli"></a>
33
+ <img alt="license" src="https://img.shields.io/github/license/TheKathan/verbump">
34
+ <img alt="Python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB">
35
+ <img alt="dependencies: none" src="https://img.shields.io/badge/dependencies-none-brightgreen">
36
+ </p>
37
+
38
+ **Version and changelog bumps for repos: only the packages that changed move.**
39
+
40
+ verbump finds the packages in your repo that changed since their own last tag, writes each one's new version into its files, and turns its changelog's `## [Unreleased]` notes into the release. One package or fifty, Python, Node, .NET, Java, Rust, Go or anything with a version in a file. One CLI, one `verbump.toml`, no dependencies.
41
+
42
+ **Try it in one command:** `uvx --from verbump-cli verbump demo`
43
+
44
+ <p align="center"><img alt="verbump in a repo with two packages. verbump status shows api at 1.4.0 with 0 changed files and web at 0.3.0 with 2 changed files. git log shows a commit feat(web): checkout page that touched apps/web. verbump apply --branch feat/checkout prints web: 0.3.0 -> 0.4.0 (minor, tag web/v0.4.0) and writes apps/web/package.json and apps/web/CHANGELOG.md. git diff shows the version going from 0.3.0 to 0.4.0, and verbump notes web prints the release notes: Added, Checkout page." src="https://raw.githubusercontent.com/TheKathan/verbump/main/assets/demo.gif" width="800"><br><sub>A PR from <code>feat/checkout</code> changed only <code>apps/web</code>: web gets a minor bump and its release notes, api stays at 1.4.0.</sub></p>
45
+
46
+ ## Try it in 60 seconds
47
+
48
+ With verbump [installed](#quick-start), this builds the repo from the recording in a scratch folder and releases it:
49
+
50
+ ```bash
51
+ verbump demo --dir verbump-try --setup-only && cd verbump-try
52
+ verbump status # api and web, both tagged; web has changes
53
+ verbump plan --branch feat/checkout # web: 0.3.0 -> 0.4.0 (minor, tag web/v0.4.0)
54
+ verbump apply --branch feat/checkout # writes apps/web/package.json and CHANGELOG.md
55
+ verbump notes web # the notes for the GitHub release
56
+ ```
57
+
58
+ Or run `verbump demo` for a narrated replay in a temporary folder.
59
+
60
+ ## Why verbump
61
+
62
+ - **Only what changed moves.** Each package is released from its own last tag, so a fix in one service doesn't bump the other nine.
63
+ - **Release notes you already wrote.** The `Unreleased` notes become the version's section and the GitHub release body; with no notes, the commit subjects that touched the package.
64
+ - **Any stack.** It reads and writes versions in `package.json`, `pyproject.toml`, `Cargo.toml`, `.csproj`, `pom.xml`, Gradle, Helm charts, Dockerfiles and more, or any file you give a pattern. Go-style tags work too.
65
+ - **You choose the bump.** A flag, the PR's branch name, or [trunk-semver](https://github.com/TheKathan/trunk-semver)'s outputs. A `release/<package>/X.Y.Z` branch cuts an exact version.
66
+ - **Safe to re-run.** Tags are created once and never moved; a second run finds nothing to release.
67
+ - **Nothing to run.** Python 3.11+ and the standard library; no server, no plugins per language.
68
+
69
+ ## What it's for
70
+
71
+ | Use | What you get |
72
+ |---|---|
73
+ | **A repo with several services or libraries** | Per-package versions, tags (`api/v1.5.0`) and changelogs, from one config. [Example](https://github.com/TheKathan/verbump/blob/main/examples/monorepo/verbump.toml) |
74
+ | **A single package** | `tag_format = "v{version}"` and one package at `.`; verbump releases itself this way. [Its config](https://github.com/TheKathan/verbump/blob/main/verbump.toml) |
75
+ | **GitHub Actions** | [verbump-action](https://github.com/TheKathan/verbump-action) bumps, commits, tags, pushes and publishes a GitHub release per package, on any stack. |
76
+ | **Any other CI** | Four commands: `apply`, `check`, `tag`, `notes`. [Recipe](#using-it-in-ci) |
77
+
78
+ ## Quick start
79
+
80
+ Install it. The PyPI package is `verbump-cli`, because the name `verbump` belongs to an unrelated project. The command it installs is `verbump`.
81
+
82
+ ```bash
83
+ pipx install verbump-cli # or: uv tool install verbump-cli, pip install verbump-cli
84
+ verbump --version
85
+ ```
86
+
87
+ Declare your packages in `verbump.toml` at the repo root. `verbump init` writes a starter file. A minimal one looks like this:
88
+
89
+ ```toml
90
+ tag_format = "{name}/v{version}"
91
+
92
+ [packages.api]
93
+ path = "services/api"
94
+ files = ["pyproject.toml", "src/api/__init__.py"]
95
+
96
+ [packages.web]
97
+ path = "apps/web"
98
+ files = ["package.json"]
99
+ ```
100
+
101
+ See what would release. Here a commit changed `services/api` since its last tag `api/v1.4.0`, and nothing changed in `apps/web`:
102
+
103
+ ```console
104
+ $ verbump plan --bump minor
105
+ api: 1.4.0 -> 1.5.0 (minor, tag api/v1.5.0)
106
+ ```
107
+
108
+ Write the new versions and changelogs. `apply` changes files only. It does not commit or tag.
109
+
110
+ ```console
111
+ $ verbump apply --bump minor --plan-out plan.json
112
+ api: 1.4.0 -> 1.5.0 (minor, tag api/v1.5.0)
113
+ wrote services/api/pyproject.toml
114
+ wrote services/api/src/api/__init__.py
115
+ wrote services/api/CHANGELOG.md
116
+ ```
117
+
118
+ The `wrote` lines go to stderr. Commit the changes, then `verbump tag --plan plan.json` creates the tags. For a full CI recipe, see [Using it in CI](#using-it-in-ci).
119
+
120
+ ## How it works
121
+
122
+ ```
123
+ merge PR feat/orders ─► trunk-semver: v0.4.0 → v0.5.0 ─► bump = minor
124
+ │
125
+ verbump: which packages changed since their last tag?
126
+ services/api (api/v1.4.0..HEAD touches it) → 1.5.0
127
+ apps/web (no changes) → stays 0.3.0
128
+ libs/shared (watched by api) → counted for api
129
+ │
130
+ writes pyproject.toml, __init__.py, services/api/CHANGELOG.md
131
+ commit "chore(release): api 1.5.0" → tag api/v1.5.0 → GitHub release "api 1.5.0"
132
+ ```
133
+
134
+ The bump can come from trunk-semver, as drawn, or from `--bump` or a branch name. The commit, tag and release steps are your CI's job; verbump plans them and writes the files.
135
+
136
+ - **What counts as a change.** A package changed if any file under its `path` or its `watch` paths changed since the package's last tag, other than files that match `exclude`. A package with no tag yet counts every tracked file under those paths.
137
+ - **Version source.** Each package's version files are the source of truth. If the files and the last tag disagree, verbump bumps from the higher of the two. If a package's version files disagree with each other, verbump stops with an error.
138
+ - **First release.** A package with no tag yet is released at the version its files already have. A package with no version files starts from `initial_version` (`0.0.0`) and is bumped.
139
+ - **Changelog.** The notes under `## [Unreleased]` become `## [X.Y.Z] - <date>`. If nobody wrote notes, the section lists the subjects of the commits that touched the package since its last tag, leaving out `chore(release)` and merge commits. A missing `CHANGELOG.md` is created. A section for that version that already exists is left alone.
140
+ - **Exact versions.** A PR from `release/<package>/X.Y.Z` releases that package at exactly X.Y.Z, changed or not. In a single-package repo, `release/X.Y.Z` is enough. Use this for a new major, which bumps can't reach. An exact version must be newer than the package's last release.
141
+ - **Safe to re-run.** Tags point at the release commit, so a second run finds nothing new to release. If the planned tag already exists, verbump stops instead of moving it. Only the optional major tag (`vX`) is moved.
142
+
143
+ ## Configuration
144
+
145
+ `verbump.toml` at the repo root. Use `-c <file>` for another path.
146
+
147
+ ```toml
148
+ tag_format = "{name}/v{version}" # default for every package
149
+ changelog = "CHANGELOG.md" # relative to the package path; "" for none
150
+
151
+ [packages.api]
152
+ path = "services/api"
153
+ files = ["pyproject.toml", "src/api/__init__.py"]
154
+ watch = ["libs/shared"] # other repo paths whose changes release this package
155
+ exclude = ["*.md", "docs/*"] # changes only here don't release it
156
+
157
+ [packages.web]
158
+ path = "apps/web"
159
+ files = ["package.json"]
160
+ ```
161
+
162
+ Package names use letters, digits, `.`, `_` and `-`. verbump rejects unknown keys.
163
+
164
+ ### Keys
165
+
166
+ `tag_format`, `changelog` and `major_tag` can be set at the top level as defaults for every package, and again per package.
167
+
168
+ | Key | Where | Default | Meaning |
169
+ |---|---|---|---|
170
+ | `tag_format` | top level, package | `{name}/v{version}` | Tag name. Uses `{name}`, `{path}` and `{version}`; `{version}` appears exactly once. `{path}` can't be used at the repo root. Two packages can't share a tag. |
171
+ | `changelog` | top level, package | `CHANGELOG.md` | Changelog file, relative to the package `path`. `""` turns it off. |
172
+ | `major_tag` | top level, package | `false` | Also move a major tag, such as `api/v1`, on each release. |
173
+ | `commit_message` | top level | `chore(release): {releases}` | Commit message in the plan. `{releases}` becomes `api 1.5.0, web 0.3.1`. |
174
+ | `path` | package | `.` | The package folder, relative to the repo root. It must stay inside the repo. |
175
+ | `files` | package | none | Files that carry the version, relative to `path`. See [Supported files](#supported-files). |
176
+ | `watch` | package | none | Other repo paths whose changes also release this package. |
177
+ | `exclude` | package | none | Glob patterns. Changes only to matching files don't release the package. Patterns match the repo path or the path relative to the package. |
178
+ | `initial_version` | package | `0.0.0` | Where a package with no tag and no version files starts. |
179
+
180
+ For a single-package repo, use `tag_format = "v{version}"` and one package at `path = "."`. This repo's own [`verbump.toml`](https://github.com/TheKathan/verbump/blob/main/verbump.toml) is an example. [`examples/monorepo/verbump.toml`](https://github.com/TheKathan/verbump/blob/main/examples/monorepo/verbump.toml) is a monorepo with Python, Node, .NET, Maven, Go and Helm packages.
181
+
182
+ ### Files as tables
183
+
184
+ A file can be a table, to pick a kind for any file name or to give your own pattern:
185
+
186
+ ```toml
187
+ files = [
188
+ { path = "chart/Chart.yaml", kind = "helm" }, # a kind for any file name
189
+ { path = "deploy/values.yaml", # or your own pattern
190
+ pattern = 'tag:\s*"(?P<version>\d+\.\d+\.\d+)"',
191
+ after = "image:", # search only after this text
192
+ replace_all = true }, # rewrite every match, not just the first
193
+ ]
194
+ ```
195
+
196
+ | Key | Meaning |
197
+ |---|---|
198
+ | `path` | The file, relative to the package `path`. Required. |
199
+ | `kind` | A built-in matcher: `json`, `toml`, `yaml`, `helm`, `plain`, `python`, `msbuild`, `assemblyinfo`, `maven`, `properties`, `gradle`, `ruby`, `elixir` or `dockerfile`. |
200
+ | `pattern` | Your own regex. It needs a `(?P<version>...)` group. It wins over `kind`. |
201
+ | `after` | Search only after the first occurrence of this text. |
202
+ | `replace_all` | Rewrite every match. By default only the first match is rewritten, and when there are several matches the first is read. |
203
+
204
+ ## Supported files
205
+
206
+ verbump picks the kind of a file from its name, so most configs only list paths. Any other file needs a `kind` or a `pattern`.
207
+
208
+ | Stack | Files | Matches |
209
+ |---|---|---|
210
+ | Node, PHP, Deno | `package.json`, `composer.json`, `deno.json`, `jsr.json`, `manifest.json` | `"version": "X.Y.Z"` |
211
+ | Python | `pyproject.toml` | `version = "X.Y.Z"` at the start of a line |
212
+ | Python | `setup.cfg` | `version = X.Y.Z` |
213
+ | Python | `*.py` | `__version__ = "X.Y.Z"` |
214
+ | Rust | `Cargo.toml` | `version = "X.Y.Z"` at the start of a line |
215
+ | .NET | `*.csproj`, `*.fsproj`, `*.vbproj`, `*.props` | `<Version>` or `<VersionPrefix>` |
216
+ | .NET | `AssemblyInfo.cs` | every `Assembly[File\|Informational]Version("X.Y.Z...")` |
217
+ | Java, Kotlin | `pom.xml` | the project's `<version>`, after `</parent>` when there is one |
218
+ | Java, Kotlin | `build.gradle`, `build.gradle.kts` | `version = "X.Y.Z"` or `version 'X.Y.Z'` |
219
+ | Java, Kotlin | `gradle.properties` | `version=X.Y.Z` |
220
+ | Ruby | `version.rb`, `*.gemspec` | `VERSION = "X.Y.Z"`, `.version = "X.Y.Z"` |
221
+ | Elixir | `mix.exs` | `version: "X.Y.Z"` |
222
+ | Dart, Flutter | `pubspec.yaml` | `version: X.Y.Z` (a `+build` suffix is kept) |
223
+ | Helm | `Chart.yaml` | `version: X.Y.Z`. Set `kind = "helm"` to move `appVersion` too. |
224
+ | Docker | `Dockerfile` | `LABEL ... version="X.Y.Z"` |
225
+ | Any | `VERSION`, `VERSION.txt`, `version.txt`, `.version` | the whole file |
226
+ | Go, Swift, ... | no file | the version lives only in the tags; use `tag_format = "{path}/v{version}"` for Go modules |
227
+
228
+ A `v` prefix is read in `Chart.yaml` with `kind = "helm"` and in plain version files. Versions are always `X.Y.Z`.
229
+
230
+ ## Commands
231
+
232
+ ```
233
+ verbump [-C ROOT] [-c CONFIG] <command>
234
+ ```
235
+
236
+ | Command | What it does |
237
+ |---|---|
238
+ | `status [--json]` | Lists each package with its file version, last tag and number of changed files. |
239
+ | `plan [options]` | Shows what would release. Changes nothing. |
240
+ | `apply [options] [--date YYYY-MM-DD]` | Writes versions and changelogs, and prints the plan. Doesn't commit or tag. `--date` defaults to today, UTC. |
241
+ | `tag --plan FILE` | Creates the annotated tags in a saved plan, and moves its major tags. Doesn't push. |
242
+ | `notes <package> [<version>]` | Prints a version's changelog section, for a release body. Without a version, it uses the package's file version. |
243
+ | `set <package> <version> [--date D]` | Writes an exact version into one package's files and changelog. |
244
+ | `check` | Prints each package's file version. Exits 1 if a package's version files disagree. |
245
+ | `init` | Writes a starter `verbump.toml`. It won't overwrite an existing one. |
246
+ | `demo [--dir D] [--setup-only]` | A narrated release in a throwaway repo with a Python and a Node package. `--dir` keeps the repo; `--setup-only` only builds it and prints its path. |
247
+
248
+ Global options: `-C <repo root>` (default `.`) and `-c <config file>` (default `<root>/verbump.toml`).
249
+
250
+ ### Choosing the bump
251
+
252
+ `plan` and `apply` take the bump from one of these sources:
253
+
254
+ - `--bump major|minor|patch`.
255
+ - `--previous` and `--version-from`, which are trunk-semver's `previous-version` and `version` outputs. verbump works out the bump from the two. They go together.
256
+ - `--branch <name>`, which applies trunk-semver's branch rule without trunk-semver.
257
+ - Nothing, which means a patch.
258
+
259
+ `--bump` and `--branch` can't be combined. If you pass several sources, `--bump` wins, then `--previous` with `--version-from`, then `--branch`.
260
+
261
+ ### Other options for `plan` and `apply`
262
+
263
+ | Option | Meaning |
264
+ |---|---|
265
+ | `--exact NAME=X.Y.Z` | Release a package at exactly this version, changed or not. Repeatable. |
266
+ | `--release-branch <branch>` | If the branch is `release/<package>/X.Y.Z` (or `release/X.Y.Z` with one package), it sets `--exact`. Other branches are ignored. |
267
+ | `--only NAME` | Limit the run to this package. Repeatable. |
268
+ | `--json` | Print the plan as JSON instead of text. |
269
+ | `--plan-out FILE` | Also write the plan as JSON to this file. |
270
+
271
+ ### Output
272
+
273
+ `plan` and `apply` print one line per release, as `name: previous -> new (reason, tag T)`. The previous version is `-` for a package with no tag. With nothing to release they print `nothing to release`. The reason is the bump, `first release` or `exact`.
274
+
275
+ The plan JSON has the shape `{"bump", "commit_message", "releases": [{"name", "path", "previous", "version", "tag", "major_tag", "changelog", "reason", "changed"}]}`.
276
+
277
+ ## Using it in CI
278
+
279
+ In GitHub Actions, use [TheKathan/verbump-action](https://github.com/TheKathan/verbump-action). It installs `verbump-cli` from PyPI, then:
280
+
281
+ 1. applies the bump;
282
+ 2. commits, tags and pushes;
283
+ 3. publishes a GitHub release per package.
284
+
285
+ It takes the bump as an input, from any source (trunk-semver, a branch name or a fixed value). It doesn't run trunk-semver itself.
286
+
287
+ Anywhere else, the CLI does the same work. This recipe takes the bump from the merged PR's branch name:
288
+
289
+ ```bash
290
+ verbump apply --branch "$PR_BRANCH" --release-branch "$PR_BRANCH" --plan-out plan.json
291
+ verbump check
292
+ git commit -am "$(jq -r .commit_message plan.json)"
293
+ verbump tag --plan plan.json
294
+ git push origin HEAD:main --follow-tags
295
+ ```
296
+
297
+ `--follow-tags` pushes the annotated tags. Major tags are lightweight and moved, so push them yourself, for example with `git push --force origin <major_tag>`. To create a GitHub release for each package, loop over the plan and use `verbump notes <name> <version>` as the release body.
298
+
299
+ This repo releases itself with [`.github/workflows/release.yml`](https://github.com/TheKathan/verbump/blob/main/.github/workflows/release.yml), running verbump from its own source. It runs in this order:
300
+
301
+ 1. Run trunk-semver against a local mirror, so its repo-wide tag never reaches GitHub, and take the bump from its `previous-version` and `version`. When it gives no previous version, use the PR's branch name.
302
+ 2. Run `verbump apply` and `verbump check`.
303
+ 3. Commit, tag and push.
304
+ 4. Publish the GitHub release.
305
+ 5. Build the sdist and the wheel.
306
+ 6. Upload them to PyPI, when the `PUBLISH_PYPI` variable is `true`.
307
+
308
+ ## Contribute
309
+
310
+ Bugs, questions and ideas go to [issues](https://github.com/TheKathan/verbump/issues), and pull requests are welcome: see [CONTRIBUTING](https://github.com/TheKathan/verbump/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/TheKathan/verbump/blob/main/CODE_OF_CONDUCT.md). Issues about the GitHub Action go to [verbump-action](https://github.com/TheKathan/verbump-action/issues). If verbump saves you a release chore, a star helps others find it.
311
+
312
+ [CHANGELOG](https://github.com/TheKathan/verbump/blob/main/CHANGELOG.md) · [SECURITY](https://github.com/TheKathan/verbump/blob/main/SECURITY.md) · [Publishing](https://github.com/TheKathan/verbump/blob/main/docs/publishing.md) · [LICENSE](https://github.com/TheKathan/verbump/blob/main/LICENSE) (MIT)
@@ -0,0 +1,16 @@
1
+ verbump/__init__.py,sha256=KWGbh9aCZAJ5WO-xrOpmbw2EDpd67XRM7FcEjzbUCP4,77
2
+ verbump/__main__.py,sha256=-OcM1HV8tbqNJ_49S-4W2j8oWSl-4KHfLf245qcMNDc,37
3
+ verbump/changelog.py,sha256=3JzDgwWU_ov7S0S-E43vK-PVdGbV9IdQESRqv4XiTtU,2198
4
+ verbump/cli.py,sha256=UKzIAF6i0g8XhqD_PsUG0ye5F_AuwR2dUx2teGyV0ow,9447
5
+ verbump/config.py,sha256=jp1sauUVEIDHqgFAPhyc_DzDBtOoeXGnZaRqYdClga4,6188
6
+ verbump/demo.py,sha256=mVtffql3OIcztqjz0Z3d5IE6ekukItOI9UQLHTv17Jw,4063
7
+ verbump/files.py,sha256=-H4G9J6Cgpef5OouVRdBeGlTvOIQrEgDVCuPGmy70Po,5079
8
+ verbump/gitutil.py,sha256=T8Nl4pyWO48fq851DyEVyIbDJ_-4xdlSAbtHjEEYrWA,1427
9
+ verbump/release.py,sha256=3OG3a8KFoCDA-yL3668v45i9f0UgMsSh9iMJzFqqL4Y,5507
10
+ verbump/semver.py,sha256=p6JgYbqcHdSMdsb27WkyF3FwJgk3x8jTItEMgkOBY7Q,1479
11
+ verbump_cli-1.0.0.dist-info/licenses/LICENSE,sha256=7xfhHrWbWZP8d3mzgR332iKkKjx0_JOzCbXZx5cjtkk,1071
12
+ verbump_cli-1.0.0.dist-info/METADATA,sha256=ooBaR5v4_tFoH6GFPFuSSUjCBXLhPaxKzyb7S6XoiaQ,19269
13
+ verbump_cli-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ verbump_cli-1.0.0.dist-info/entry_points.txt,sha256=A8NC1mSekstNNkMKgLNn11Fmwn2D-y-iO77v20A3Mpk,45
15
+ verbump_cli-1.0.0.dist-info/top_level.txt,sha256=7XJfWZOANycbCanXmw3iyGKSuzAF6dQltLoX2GHZc6U,8
16
+ verbump_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verbump = verbump.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gonçalo Faria
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ verbump