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 +3 -0
- verbump/__main__.py +3 -0
- verbump/changelog.py +53 -0
- verbump/cli.py +237 -0
- verbump/config.py +158 -0
- verbump/demo.py +101 -0
- verbump/files.py +149 -0
- verbump/gitutil.py +39 -0
- verbump/release.py +152 -0
- verbump/semver.py +50 -0
- verbump_cli-1.0.0.dist-info/METADATA +312 -0
- verbump_cli-1.0.0.dist-info/RECORD +16 -0
- verbump_cli-1.0.0.dist-info/WHEEL +5 -0
- verbump_cli-1.0.0.dist-info/entry_points.txt +2 -0
- verbump_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- verbump_cli-1.0.0.dist-info/top_level.txt +1 -0
verbump/__init__.py
ADDED
verbump/__main__.py
ADDED
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,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
|