verkit 0.1.1__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ env:
12
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
13
+
14
+ jobs:
15
+ lint:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ # https://github.com/actions/checkout
19
+ - uses: actions/checkout@v6
20
+ with:
21
+ fetch-depth: 0
22
+
23
+ # https://github.com/astral-sh/setup-uv
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v7
26
+ with:
27
+ enable-cache: true
28
+
29
+ - name: Set up Python
30
+ run: uv python install
31
+
32
+ - name: Run ruff
33
+ run: uv run ruff check .
34
+
35
+ - name: Run mypy
36
+ run: uv run mypy src
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
10
+
11
+ jobs:
12
+ publish:
13
+ name: Build and publish
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ id-token: write
17
+ contents: write
18
+ attestations: write
19
+
20
+ steps:
21
+ # https://github.com/actions/checkout
22
+ - uses: actions/checkout@v6
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ # https://github.com/astral-sh/setup-uv
27
+ - name: Install uv
28
+ uses: astral-sh/setup-uv@v7
29
+ with:
30
+ enable-cache: true
31
+
32
+ - name: Set up Python
33
+ run: uv python install
34
+
35
+ - name: Build package
36
+ run: uv build
37
+
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+ with:
41
+ verbose: true
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ *.egg-info/
verkit-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: verkit
3
+ Version: 0.1.1
4
+ Summary: Polyglot project version inspector, promoter, tagger, and release workflow engine
5
+ Author: Mark Stouffer
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: rich>=10.0.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # verkit
12
+
13
+ Polyglot project version inspector, promoter, tagger, and release workflow engine.
14
+
15
+ `verkit` provides a unified Python API and CLI tool to manage version releases across multi-language projects (`pyproject.toml`, `package.json`, `Cargo.toml`, `*.csproj`, `pom.xml`, `build.gradle`).
16
+
17
+ ---
18
+
19
+ ## Supported Ecosystems
20
+ | Project Type | Version File | Parser / Bump Engine |
21
+ | :--- | :--- | :--- |
22
+ | **Python** | `pyproject.toml` | Regex & `bump-my-version` |
23
+ | **Node.js** | `package.json` | JSON parser & `npm version` |
24
+ | **Rust** | `Cargo.toml` | Regex (`^version = "..."`) |
25
+ | **.NET** | `*.csproj` | XML Regex (`<Version>...</Version>`) |
26
+ | **Java (Maven)** | `pom.xml` | XML Regex (`<version>...</version>`) |
27
+ | **Java (Gradle)** | `build.gradle` / `.kts` | Regex (`version = "..."`) |
28
+
29
+ ---
30
+
31
+ ## Installation
32
+ ```bash
33
+ pip install verkit
34
+ # or using uv
35
+ uv add verkit
36
+ ```
37
+
38
+ ---
39
+
40
+ ## CLI Usage
41
+
42
+ ### Inspect version info
43
+ Inspect current working tree and HEAD committed versions:
44
+ ```bash
45
+ verkit inspect
46
+ ```
47
+
48
+ ### Promote version
49
+ Bump version (`major`, `minor`, or `patch`) and automatically commit or amend local release commit:
50
+ ```bash
51
+ verkit promote minor
52
+ ```
53
+
54
+ ### Tag and push release
55
+ Verify HEAD is in sync, create `vX.Y.Z` git tag, and push branch + tag:
56
+ ```bash
57
+ verkit tag
58
+ ```
59
+
60
+ ### One-shot atomic release
61
+ Combine promote, tag, and push in a single step:
62
+ ```bash
63
+ verkit release patch
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Python API Usage
69
+
70
+ ```python
71
+ import verkit
72
+
73
+ # 1. Inspect version info
74
+ info = verkit.inspect_project()
75
+ print(f"Working version: {info.version} (from {info.source})")
76
+
77
+ committed = verkit.inspect_committed()
78
+ print(f"HEAD committed version: {committed.version}")
79
+
80
+ # 2. Promote version
81
+ new_version = verkit.promote_version("minor")
82
+
83
+ # 3. Tag and push release
84
+ tag_name = verkit.tag_version(push=True)
85
+
86
+ # 4. Atomic release
87
+ verkit.release_version("patch", push=True)
88
+ ```
verkit-0.1.1/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # verkit
2
+
3
+ Polyglot project version inspector, promoter, tagger, and release workflow engine.
4
+
5
+ `verkit` provides a unified Python API and CLI tool to manage version releases across multi-language projects (`pyproject.toml`, `package.json`, `Cargo.toml`, `*.csproj`, `pom.xml`, `build.gradle`).
6
+
7
+ ---
8
+
9
+ ## Supported Ecosystems
10
+ | Project Type | Version File | Parser / Bump Engine |
11
+ | :--- | :--- | :--- |
12
+ | **Python** | `pyproject.toml` | Regex & `bump-my-version` |
13
+ | **Node.js** | `package.json` | JSON parser & `npm version` |
14
+ | **Rust** | `Cargo.toml` | Regex (`^version = "..."`) |
15
+ | **.NET** | `*.csproj` | XML Regex (`<Version>...</Version>`) |
16
+ | **Java (Maven)** | `pom.xml` | XML Regex (`<version>...</version>`) |
17
+ | **Java (Gradle)** | `build.gradle` / `.kts` | Regex (`version = "..."`) |
18
+
19
+ ---
20
+
21
+ ## Installation
22
+ ```bash
23
+ pip install verkit
24
+ # or using uv
25
+ uv add verkit
26
+ ```
27
+
28
+ ---
29
+
30
+ ## CLI Usage
31
+
32
+ ### Inspect version info
33
+ Inspect current working tree and HEAD committed versions:
34
+ ```bash
35
+ verkit inspect
36
+ ```
37
+
38
+ ### Promote version
39
+ Bump version (`major`, `minor`, or `patch`) and automatically commit or amend local release commit:
40
+ ```bash
41
+ verkit promote minor
42
+ ```
43
+
44
+ ### Tag and push release
45
+ Verify HEAD is in sync, create `vX.Y.Z` git tag, and push branch + tag:
46
+ ```bash
47
+ verkit tag
48
+ ```
49
+
50
+ ### One-shot atomic release
51
+ Combine promote, tag, and push in a single step:
52
+ ```bash
53
+ verkit release patch
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Python API Usage
59
+
60
+ ```python
61
+ import verkit
62
+
63
+ # 1. Inspect version info
64
+ info = verkit.inspect_project()
65
+ print(f"Working version: {info.version} (from {info.source})")
66
+
67
+ committed = verkit.inspect_committed()
68
+ print(f"HEAD committed version: {committed.version}")
69
+
70
+ # 2. Promote version
71
+ new_version = verkit.promote_version("minor")
72
+
73
+ # 3. Tag and push release
74
+ tag_name = verkit.tag_version(push=True)
75
+
76
+ # 4. Atomic release
77
+ verkit.release_version("patch", push=True)
78
+ ```
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "verkit"
7
+ version = "0.1.1"
8
+ description = "Polyglot project version inspector, promoter, tagger, and release workflow engine"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Mark Stouffer" }
14
+ ]
15
+ dependencies = [
16
+ "rich>=10.0.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ verkit = "verkit.cli:main"
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ pythonpath = ["src"]
@@ -0,0 +1,14 @@
1
+ """Polyglot project version inspector, promoter, tagger, and release engine."""
2
+
3
+ from verkit.inspector import VersionInfo, inspect_project, inspect_committed
4
+ from verkit.promoter import promote_version
5
+ from verkit.tagger import tag_version
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "VersionInfo",
10
+ "inspect_project",
11
+ "inspect_committed",
12
+ "promote_version",
13
+ "tag_version",
14
+ ]
@@ -0,0 +1,73 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from rich.console import Console
5
+
6
+ from verkit.inspector import inspect_committed, inspect_project
7
+ from verkit.promoter import promote_version
8
+ from verkit.tagger import release_version, tag_version
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ prog="verkit",
14
+ description="Polyglot project version inspector, promoter, tagger, and release workflow tool",
15
+ )
16
+ subparsers = parser.add_subparsers(dest="command", help="Subcommand to run")
17
+
18
+ # verkit inspect
19
+ subparsers.add_parser("inspect", help="Inspect working tree and HEAD version")
20
+
21
+ # verkit promote <major|minor|patch>
22
+ promote_p = subparsers.add_parser("promote", help="Bump version and commit/amend")
23
+ promote_p.add_argument("part", choices=["major", "minor", "patch"])
24
+ promote_p.add_argument(
25
+ "--no-amend",
26
+ dest="allow_amend",
27
+ action="store_false",
28
+ help="Always create a new commit",
29
+ )
30
+
31
+ # verkit tag
32
+ tag_p = subparsers.add_parser("tag", help="Create git tag on HEAD and push")
33
+ tag_p.add_argument(
34
+ "--no-push",
35
+ dest="push",
36
+ action="store_false",
37
+ help="Skip pushing to origin",
38
+ )
39
+
40
+ # verkit release <major|minor|patch>
41
+ rel_p = subparsers.add_parser("release", help="Atomic promote + tag + push")
42
+ rel_p.add_argument("part", choices=["major", "minor", "patch"])
43
+ rel_p.add_argument(
44
+ "--no-push",
45
+ dest="push",
46
+ action="store_false",
47
+ help="Promote and tag locally only",
48
+ )
49
+
50
+ args = parser.parse_args()
51
+ console = Console()
52
+
53
+ if not args.command or args.command == "inspect":
54
+ info = inspect_project()
55
+ committed = inspect_committed()
56
+ console.print(f"[bold blue]Working version:[/bold blue] {info.version} (from {info.source or 'unknown'})")
57
+ console.print(f"[dim]Committed version (HEAD):[/dim] {committed.version}")
58
+ return
59
+
60
+ try:
61
+ if args.command == "promote":
62
+ promote_version(args.part, console=console, allow_amend=getattr(args, "allow_amend", True))
63
+ elif args.command == "tag":
64
+ tag_version(console=console, push=getattr(args, "push", True))
65
+ elif args.command == "release":
66
+ release_version(args.part, console=console, push=getattr(args, "push", True))
67
+ except Exception as e:
68
+ console.print(f"[red]Error: {e}[/red]")
69
+ sys.exit(1)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,142 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class VersionInfo:
12
+ version: str
13
+ source: Optional[str] = None
14
+
15
+
16
+ def get_project_version(root: Optional[Path] = None) -> VersionInfo:
17
+ """Read the current project version from various project files (working tree)."""
18
+ root = root or Path.cwd()
19
+
20
+ # Check pyproject.toml
21
+ pyproject = root / "pyproject.toml"
22
+ if pyproject.exists():
23
+ try:
24
+ with pyproject.open("r", encoding="utf-8") as f:
25
+ content = f.read()
26
+ match = re.search(r'version\s*=\s*"(.*?)"', content)
27
+ if match:
28
+ return VersionInfo(version=match.group(1), source="pyproject.toml")
29
+ except Exception:
30
+ pass
31
+
32
+ # Check package.json
33
+ package_json = root / "package.json"
34
+ if package_json.exists():
35
+ try:
36
+ with package_json.open("r", encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ if "version" in data:
39
+ return VersionInfo(version=data["version"], source="package.json")
40
+ except Exception:
41
+ pass
42
+
43
+ # Check Cargo.toml (Rust)
44
+ cargo = root / "Cargo.toml"
45
+ if cargo.exists():
46
+ try:
47
+ with cargo.open("r", encoding="utf-8") as f:
48
+ content = f.read()
49
+ match = re.search(r'^version\s*=\s*"(.*?)"', content, re.MULTILINE)
50
+ if match:
51
+ return VersionInfo(version=match.group(1), source="Cargo.toml")
52
+ except Exception:
53
+ pass
54
+
55
+ # Check *.csproj (.NET)
56
+ for csproj in root.glob("*.csproj"):
57
+ try:
58
+ with csproj.open("r", encoding="utf-8") as f:
59
+ content = f.read()
60
+ match = re.search(r"<Version>(.*?)</Version>", content)
61
+ if match:
62
+ return VersionInfo(version=match.group(1), source=csproj.name)
63
+ except Exception:
64
+ pass
65
+
66
+ # Check pom.xml (Java/Maven)
67
+ pom = root / "pom.xml"
68
+ if pom.exists():
69
+ try:
70
+ with pom.open("r", encoding="utf-8") as f:
71
+ content = f.read()
72
+ match = re.search(r"<version>(.*?)</version>", content)
73
+ if match:
74
+ return VersionInfo(version=match.group(1), source="pom.xml")
75
+ except Exception:
76
+ pass
77
+
78
+ # Check build.gradle (Java/Gradle)
79
+ gradle_kts = root / "build.gradle.kts"
80
+ gradle = root / "build.gradle"
81
+ gradle_file = None
82
+ if gradle_kts.exists():
83
+ gradle_file = gradle_kts
84
+ elif gradle.exists():
85
+ gradle_file = gradle
86
+
87
+ if gradle_file:
88
+ try:
89
+ with gradle_file.open("r", encoding="utf-8") as f:
90
+ content = f.read()
91
+ match = re.search(r'version\s*=\s*["\'](.*?)["\']', content)
92
+ if match:
93
+ return VersionInfo(version=match.group(1), source=gradle_file.name)
94
+ except Exception:
95
+ pass
96
+
97
+ return VersionInfo(version="unknown", source=None)
98
+
99
+
100
+ def inspect_project(root: Optional[Path] = None) -> VersionInfo:
101
+ return get_project_version(root)
102
+
103
+
104
+ def inspect_committed(root: Optional[Path] = None) -> VersionInfo:
105
+ """Read the version from HEAD (committed code), not working tree."""
106
+ files_to_check = [
107
+ ("pyproject.toml", r'version\s*=\s*"(.*?)"'),
108
+ ("package.json", None),
109
+ ("Cargo.toml", r'^version\s*=\s*"(.*?)"'),
110
+ ]
111
+
112
+ git_c = ["git"]
113
+ if root is not None:
114
+ git_c = ["git", "-C", str(root)]
115
+
116
+ for filename, pattern in files_to_check:
117
+ try:
118
+ result = subprocess.run(
119
+ git_c + ["show", f"HEAD:{filename}"],
120
+ capture_output=True,
121
+ text=True,
122
+ check=False,
123
+ shell=(os.name == "nt"),
124
+ )
125
+ if result.returncode == 0:
126
+ if filename == "package.json":
127
+ try:
128
+ data = json.loads(result.stdout)
129
+ if "version" in data:
130
+ return VersionInfo(
131
+ version=data["version"], source="package.json"
132
+ )
133
+ except Exception:
134
+ pass
135
+ elif pattern:
136
+ match = re.search(pattern, result.stdout, re.MULTILINE)
137
+ if match:
138
+ return VersionInfo(version=match.group(1), source=filename)
139
+ except Exception:
140
+ pass
141
+
142
+ return VersionInfo(version="unknown", source=None)
@@ -0,0 +1,305 @@
1
+ import os
2
+ import re
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import List, Optional, Tuple
6
+
7
+ from rich.console import Console
8
+
9
+ from verkit.inspector import get_project_version, inspect_committed
10
+
11
+ _VERSION_FILES = ("pyproject.toml", "package.json", "uv.lock", "Cargo.toml")
12
+
13
+
14
+ def _git(*args: str, cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
15
+ cmd = ["git"]
16
+ if cwd is not None:
17
+ cmd += ["-C", str(cwd)]
18
+ cmd.extend(args)
19
+ return subprocess.run(
20
+ cmd,
21
+ capture_output=True,
22
+ text=True,
23
+ check=False,
24
+ shell=(os.name == "nt"),
25
+ )
26
+
27
+
28
+ def _find_git_root(path: Path) -> Optional[Path]:
29
+ res = _git("rev-parse", "--show-toplevel", cwd=path)
30
+ if res.returncode == 0 and res.stdout.strip():
31
+ return Path(res.stdout.strip()).resolve()
32
+ return None
33
+
34
+
35
+ def _can_amend_version_safely(git_root: Path) -> Tuple[bool, str]:
36
+ """Whether folding the version bump into HEAD via amend is safe."""
37
+ res = _git("rev-parse", "HEAD", cwd=git_root)
38
+ if res.returncode != 0 or not res.stdout.strip():
39
+ return False, "no HEAD commit"
40
+
41
+ tags_res = _git(
42
+ "tag", "--points-at", "HEAD", "--format=%(refname:short)", cwd=git_root
43
+ )
44
+ if tags_res.returncode == 0 and tags_res.stdout.strip():
45
+ tags = [t for t in tags_res.stdout.strip().splitlines() if t]
46
+ return False, f"HEAD is tagged ({', '.join(tags)})"
47
+
48
+ branch_res = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=git_root)
49
+ branch = branch_res.stdout.strip() if branch_res.returncode == 0 else ""
50
+ if not branch or branch == "HEAD":
51
+ return False, "detached HEAD"
52
+
53
+ upstream_res = _git(
54
+ "rev-parse", "--abbrev-ref", "@{upstream}", cwd=git_root
55
+ )
56
+ if upstream_res.returncode != 0 or not upstream_res.stdout.strip():
57
+ return True, "local branch has no tracking upstream"
58
+ upstream = upstream_res.stdout.strip()
59
+
60
+ counts = _git(
61
+ "rev-list", "--count", f"{upstream}..HEAD", cwd=git_root
62
+ )
63
+ if counts.returncode != 0:
64
+ return False, f"could not compare HEAD to {upstream}"
65
+ try:
66
+ ahead = int(counts.stdout.strip())
67
+ except ValueError:
68
+ return False, "invalid rev-list output"
69
+
70
+ if ahead <= 0:
71
+ return (
72
+ False,
73
+ f"HEAD is already published to {upstream} (ahead count: {ahead})",
74
+ )
75
+ return True, f"HEAD is ahead of {upstream} by {ahead} unpushed commit(s)"
76
+
77
+
78
+ def _stage_version_files(git_root: Path, project_root: Path) -> List[str]:
79
+ staged: List[str] = []
80
+ for name in _VERSION_FILES:
81
+ target = project_root / name
82
+ if target.exists():
83
+ res = _git("add", str(target), cwd=git_root)
84
+ if res.returncode == 0:
85
+ try:
86
+ rel = str(target.relative_to(git_root))
87
+ except ValueError:
88
+ rel = name
89
+ staged.append(rel)
90
+ return staged
91
+
92
+
93
+ def _increment_semver(current_v: str, part: str) -> str:
94
+ """Increment a semver string (e.g. 0.1.0 -> 0.1.1) in pure Python."""
95
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)(.*)$", current_v.strip())
96
+ if not match:
97
+ raise ValueError(f"Version {current_v!r} is not a valid semver X.Y.Z string")
98
+
99
+ major, minor, patch, extra = (
100
+ int(match.group(1)),
101
+ int(match.group(2)),
102
+ int(match.group(3)),
103
+ match.group(4),
104
+ )
105
+ if part == "major":
106
+ major += 1
107
+ minor = 0
108
+ patch = 0
109
+ elif part == "minor":
110
+ minor += 1
111
+ patch = 0
112
+ elif part == "patch":
113
+ patch += 1
114
+ else:
115
+ raise ValueError(f"Unknown bump part: {part!r}")
116
+
117
+ return f"{major}.{minor}.{patch}{extra}"
118
+
119
+
120
+ def _bump_project_version(part: str, project_root: Path, source: str) -> str:
121
+ info = get_project_version(project_root)
122
+ if info.version == "unknown":
123
+ raise RuntimeError("Cannot bump version: current project version is unknown.")
124
+
125
+ new_v = _increment_semver(info.version, part)
126
+
127
+ if source == "pyproject.toml":
128
+ file_path = project_root / "pyproject.toml"
129
+ content = file_path.read_text(encoding="utf-8")
130
+ updated = re.sub(
131
+ r'(version\s*=\s*)"(.*?)"', f'\\1"{new_v}"', content, count=1
132
+ )
133
+ file_path.write_text(updated, encoding="utf-8")
134
+
135
+ # Also sync uv.lock if present
136
+ uv_lock = project_root / "uv.lock"
137
+ if uv_lock.exists():
138
+ try:
139
+ lock_text = uv_lock.read_text(encoding="utf-8")
140
+ # Update package version entry in uv.lock if matching current version
141
+ pattern = rf'(name\s*=\s*"{re.escape(project_root.name)}"\nversion\s*=\s*)"{re.escape(info.version)}"'
142
+ if re.search(pattern, lock_text):
143
+ lock_updated = re.sub(pattern, f'\\1"{new_v}"', lock_text, count=1)
144
+ uv_lock.write_text(lock_updated, encoding="utf-8")
145
+ except Exception:
146
+ pass
147
+
148
+ elif source == "package.json":
149
+ file_path = project_root / "package.json"
150
+ content = file_path.read_text(encoding="utf-8")
151
+ updated = re.sub(
152
+ r'("version"\s*:\s*)"(.*?)"', f'\\1"{new_v}"', content, count=1
153
+ )
154
+ file_path.write_text(updated, encoding="utf-8")
155
+
156
+ elif source == "Cargo.toml":
157
+ file_path = project_root / "Cargo.toml"
158
+ content = file_path.read_text(encoding="utf-8")
159
+ updated = re.sub(
160
+ r'^(version\s*=\s*)"(.*?)"',
161
+ f'\\1"{new_v}"',
162
+ content,
163
+ count=1,
164
+ flags=re.MULTILINE,
165
+ )
166
+ file_path.write_text(updated, encoding="utf-8")
167
+
168
+ elif source.endswith(".csproj"):
169
+ file_path = project_root / source
170
+ content = file_path.read_text(encoding="utf-8")
171
+ updated = re.sub(
172
+ r"(<Version>)(.*?)(</Version>)", f"\\1{new_v}\\3", content, count=1
173
+ )
174
+ file_path.write_text(updated, encoding="utf-8")
175
+
176
+ elif source == "pom.xml":
177
+ file_path = project_root / "pom.xml"
178
+ content = file_path.read_text(encoding="utf-8")
179
+ updated = re.sub(
180
+ r"(<version>)(.*?)(</version>)", f"\\1{new_v}\\3", content, count=1
181
+ )
182
+ file_path.write_text(updated, encoding="utf-8")
183
+
184
+ elif "gradle" in source:
185
+ file_path = project_root / source
186
+ content = file_path.read_text(encoding="utf-8")
187
+ updated = re.sub(
188
+ r'(version\s*=\s*["\'])(.*?)(["\'])', f"\\1{new_v}\\3", content, count=1
189
+ )
190
+ file_path.write_text(updated, encoding="utf-8")
191
+
192
+ else:
193
+ raise RuntimeError(
194
+ f"Cannot bump version: unsupported project file source {source!r}"
195
+ )
196
+
197
+ verify_info = get_project_version(project_root)
198
+ if verify_info.version != new_v:
199
+ raise RuntimeError(
200
+ f"Version bump failed: expected {new_v}, got {verify_info.version}"
201
+ )
202
+ return new_v
203
+
204
+
205
+ def _commit_version_bump(
206
+ console: Optional[Console],
207
+ git_root: Path,
208
+ project_root: Path,
209
+ new_v: str,
210
+ *,
211
+ allow_amend: bool = True,
212
+ ) -> str:
213
+ staged = _stage_version_files(git_root, project_root)
214
+ if not staged:
215
+ raise RuntimeError("No version files found to stage after bump.")
216
+
217
+ diff_staged = _git("diff", "--cached", "--name-only", cwd=git_root)
218
+ if diff_staged.returncode == 0 and diff_staged.stdout.strip():
219
+ staged_all = set(diff_staged.stdout.strip().splitlines())
220
+ unrelated = staged_all - set(staged)
221
+ if unrelated:
222
+ raise RuntimeError(
223
+ "Refusing to commit version bump: unrelated files are staged: "
224
+ + ", ".join(sorted(unrelated))
225
+ )
226
+
227
+ amend = False
228
+ if allow_amend:
229
+ ok, reason = _can_amend_version_safely(git_root)
230
+ if ok:
231
+ amend = True
232
+ msg_res = _git("log", "-1", "--format=%B", cwd=git_root)
233
+ existing_msg = (
234
+ msg_res.stdout.strip()
235
+ if msg_res.returncode == 0
236
+ else "chore: bump version"
237
+ )
238
+ commit_res = _git("commit", "--amend", "-m", existing_msg, cwd=git_root)
239
+ if commit_res.returncode != 0:
240
+ err = (commit_res.stderr or commit_res.stdout or "").strip()
241
+ raise RuntimeError(f"Failed to amend version bump: {err}")
242
+ if console:
243
+ console.print(
244
+ f"[dim]Amended version {new_v} into HEAD ({reason})[/dim]"
245
+ )
246
+
247
+ if not amend:
248
+ msg = f"chore(release): v{new_v}"
249
+ commit_res = _git("commit", "-m", msg, cwd=git_root)
250
+ if commit_res.returncode != 0:
251
+ err = (commit_res.stderr or commit_res.stdout or "").strip()
252
+ if "nothing to commit" in err.lower():
253
+ if console:
254
+ console.print(
255
+ f"[dim]Version bump for {new_v} produced no changes to commit.[/dim]"
256
+ )
257
+ else:
258
+ raise RuntimeError(f"Failed to commit version bump: {err}")
259
+ else:
260
+ if console:
261
+ console.print(f"[bold green]Committed version bump: {msg}[/bold green]")
262
+
263
+ committed_info = inspect_committed(git_root)
264
+ if committed_info.version != new_v:
265
+ raise RuntimeError(
266
+ f"Post-commit verification failed: HEAD has version {committed_info.version!r}, "
267
+ f"expected {new_v!r}."
268
+ )
269
+
270
+ return "amend" if amend else "commit"
271
+
272
+
273
+ def promote_version(
274
+ part: str,
275
+ *,
276
+ project_root: Optional[Path] = None,
277
+ console: Optional[Console] = None,
278
+ allow_amend: bool = True,
279
+ ) -> str:
280
+ """Bump project version and commit it. Returns the new version string."""
281
+ project_root = (project_root or Path.cwd()).resolve()
282
+ git_root = _find_git_root(project_root)
283
+ if not git_root:
284
+ raise RuntimeError("Not inside a git repository.")
285
+
286
+ working_info = get_project_version(project_root)
287
+ if not working_info.source or working_info.version == "unknown":
288
+ raise RuntimeError(
289
+ "Could not find a project version file (pyproject.toml / package.json)."
290
+ )
291
+
292
+ pre_committed_info = inspect_committed(git_root)
293
+ if console:
294
+ console.print(
295
+ f"[blue]Bumping {part} from working {working_info.version} "
296
+ f"(HEAD {pre_committed_info.version}) via {working_info.source}...[/blue]"
297
+ )
298
+
299
+ new_v = _bump_project_version(part, project_root, working_info.source)
300
+ if console:
301
+ console.print(f"[bold green]Promoted to version {new_v}[/bold green]")
302
+ _commit_version_bump(
303
+ console, git_root, project_root, new_v, allow_amend=allow_amend
304
+ )
305
+ return new_v
@@ -0,0 +1,162 @@
1
+ import os
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Optional, Tuple
5
+
6
+ from rich.console import Console
7
+
8
+ from verkit.inspector import get_project_version, inspect_committed
9
+
10
+
11
+ def _git(*args: str, cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
12
+ cmd = ["git"]
13
+ if cwd is not None:
14
+ cmd += ["-C", str(cwd)]
15
+ cmd.extend(args)
16
+ return subprocess.run(
17
+ cmd,
18
+ capture_output=True,
19
+ text=True,
20
+ check=False,
21
+ shell=(os.name == "nt"),
22
+ )
23
+
24
+
25
+ def _find_git_root(path: Path) -> Optional[Path]:
26
+ res = _git("rev-parse", "--show-toplevel", cwd=path)
27
+ if res.returncode == 0 and res.stdout.strip():
28
+ return Path(res.stdout.strip()).resolve()
29
+ return None
30
+
31
+
32
+ def _git_head_sha(git_root: Path) -> Optional[str]:
33
+ res = _git("rev-parse", "HEAD", cwd=git_root)
34
+ if res.returncode == 0 and res.stdout.strip():
35
+ return res.stdout.strip()
36
+ return None
37
+
38
+
39
+ def _git_tag_target(git_root: Path, tag_name: str) -> Optional[str]:
40
+ res = _git("rev-parse", f"refs/tags/{tag_name}^{{}}", cwd=git_root)
41
+ if res.returncode == 0 and res.stdout.strip():
42
+ return res.stdout.strip()
43
+ res2 = _git("rev-parse", f"refs/tags/{tag_name}", cwd=git_root)
44
+ if res2.returncode == 0 and res2.stdout.strip():
45
+ return res2.stdout.strip()
46
+ return None
47
+
48
+
49
+ def _push_branch_and_tag(
50
+ console: Optional[Console],
51
+ git_root: Path,
52
+ tag_name: str,
53
+ *,
54
+ push_branch: bool = True,
55
+ ):
56
+ if push_branch:
57
+ branch_res = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=git_root)
58
+ branch = branch_res.stdout.strip() if branch_res.returncode == 0 else ""
59
+ if not branch or branch == "HEAD":
60
+ raise RuntimeError("Cannot push branch from detached HEAD state.")
61
+ if console:
62
+ console.print(f"[blue]Pushing branch {branch} to origin...[/blue]")
63
+ res = _git("push", "origin", branch, cwd=git_root)
64
+ if res.returncode != 0:
65
+ err = (res.stderr or res.stdout or "").strip()
66
+ raise RuntimeError(f"Failed to push branch {branch}: {err}")
67
+ if console:
68
+ console.print(f"[bold green]Pushed branch {branch}[/bold green]")
69
+
70
+ if console:
71
+ console.print(f"[blue]Pushing tag {tag_name} to origin...[/blue]")
72
+ res = _git("push", "origin", tag_name, cwd=git_root)
73
+ if res.returncode != 0:
74
+ err = (res.stderr or res.stdout or "").strip()
75
+ raise RuntimeError(f"Failed to push tag {tag_name}: {err}")
76
+ if console:
77
+ console.print(f"[bold green]Pushed tag {tag_name}[/bold green]")
78
+
79
+
80
+ def tag_version(
81
+ *,
82
+ project_root: Optional[Path] = None,
83
+ console: Optional[Console] = None,
84
+ push: bool = True,
85
+ push_branch: bool = True,
86
+ ) -> str:
87
+ """Create ``vX.Y.Z`` on HEAD from the *committed* version and optionally push."""
88
+ project_root = (project_root or Path.cwd()).resolve()
89
+ git_root = _find_git_root(project_root)
90
+ if not git_root:
91
+ raise RuntimeError("Not inside a git repository.")
92
+
93
+ committed_info = inspect_committed(git_root)
94
+ if committed_info.version == "unknown":
95
+ raise RuntimeError("Could not read version from HEAD. Promote first.")
96
+
97
+ working_info = get_project_version(project_root)
98
+ if working_info.version != "unknown" and working_info.version != committed_info.version:
99
+ raise RuntimeError(
100
+ f"Working tree version {working_info.version} differs from HEAD {committed_info.version}. "
101
+ "Commit or discard local version edits before tagging."
102
+ )
103
+
104
+ tag_name = f"v{committed_info.version}"
105
+ head = _git_head_sha(git_root)
106
+ if not head:
107
+ raise RuntimeError("Could not resolve HEAD.")
108
+
109
+ existing = _git_tag_target(git_root, tag_name)
110
+ if existing:
111
+ head_full = _git("rev-parse", head, cwd=git_root).stdout.strip() or head
112
+ existing_full = (
113
+ _git("rev-parse", existing, cwd=git_root).stdout.strip() or existing
114
+ )
115
+ if existing_full == head_full:
116
+ if console:
117
+ console.print(f"[dim]Tag {tag_name} already points at HEAD[/dim]")
118
+ else:
119
+ raise RuntimeError(
120
+ f"Tag {tag_name} already exists but points at {existing_full[:12]}, "
121
+ f"not HEAD ({head_full[:12]})."
122
+ )
123
+ else:
124
+ res = _git("tag", tag_name, cwd=git_root)
125
+ if res.returncode != 0:
126
+ err = (res.stderr or res.stdout or "").strip()
127
+ raise RuntimeError(f"Failed to create tag {tag_name}: {err}")
128
+ if console:
129
+ console.print(
130
+ f"[bold green]Tagged HEAD as {tag_name}[/bold green] "
131
+ f"[dim]({head[:12]})[/dim]"
132
+ )
133
+
134
+ if push:
135
+ _push_branch_and_tag(console, git_root, tag_name, push_branch=push_branch)
136
+ elif console:
137
+ console.print(
138
+ f"[dim]Skipped push. When ready: git push && git push origin {tag_name}[/dim]"
139
+ )
140
+
141
+ return tag_name
142
+
143
+
144
+ def release_version(
145
+ part: str,
146
+ *,
147
+ project_root: Optional[Path] = None,
148
+ console: Optional[Console] = None,
149
+ push: bool = True,
150
+ push_branch: bool = True,
151
+ ) -> Tuple[str, str]:
152
+ """Atomic promote + tag + push release workflow."""
153
+ from verkit.promoter import promote_version
154
+
155
+ new_v = promote_version(part, project_root=project_root, console=console)
156
+ tag_name = tag_version(
157
+ project_root=project_root,
158
+ console=console,
159
+ push=push,
160
+ push_branch=push_branch,
161
+ )
162
+ return new_v, tag_name
@@ -0,0 +1,20 @@
1
+ from verkit.inspector import inspect_project
2
+
3
+ def test_inspect_python_project(tmp_path):
4
+ pyproject = tmp_path / "pyproject.toml"
5
+ pyproject.write_text('[project]\nname = "myproject"\nversion = "1.2.3"\n')
6
+ info = inspect_project(tmp_path)
7
+ assert info.version == "1.2.3"
8
+ assert info.source == "pyproject.toml"
9
+
10
+ def test_inspect_node_project(tmp_path):
11
+ pkg = tmp_path / "package.json"
12
+ pkg.write_text('{"name": "my-node-app", "version": "2.0.0"}')
13
+ info = inspect_project(tmp_path)
14
+ assert info.version == "2.0.0"
15
+ assert info.source == "package.json"
16
+
17
+ def test_inspect_unknown_project(tmp_path):
18
+ info = inspect_project(tmp_path)
19
+ assert info.version == "unknown"
20
+ assert info.source is None
@@ -0,0 +1,7 @@
1
+ from verkit.promoter import _increment_semver
2
+
3
+ def test_increment_semver():
4
+ assert _increment_semver("0.1.0", "patch") == "0.1.1"
5
+ assert _increment_semver("0.1.0", "minor") == "0.2.0"
6
+ assert _increment_semver("0.1.0", "major") == "1.0.0"
7
+ assert _increment_semver("1.2.3-rc1", "patch") == "1.2.4-rc1"
verkit-0.1.1/uv.lock ADDED
@@ -0,0 +1,80 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.10'",
6
+ "python_full_version < '3.10'",
7
+ ]
8
+
9
+ [[package]]
10
+ name = "markdown-it-py"
11
+ version = "3.0.0"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ resolution-markers = [
14
+ "python_full_version < '3.10'",
15
+ ]
16
+ dependencies = [
17
+ { name = "mdurl" },
18
+ ]
19
+ sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
20
+ wheels = [
21
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
22
+ ]
23
+
24
+ [[package]]
25
+ name = "markdown-it-py"
26
+ version = "4.2.0"
27
+ source = { registry = "https://pypi.org/simple" }
28
+ resolution-markers = [
29
+ "python_full_version >= '3.10'",
30
+ ]
31
+ dependencies = [
32
+ { name = "mdurl" },
33
+ ]
34
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
35
+ wheels = [
36
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
37
+ ]
38
+
39
+ [[package]]
40
+ name = "mdurl"
41
+ version = "0.1.2"
42
+ source = { registry = "https://pypi.org/simple" }
43
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "pygments"
50
+ version = "2.20.0"
51
+ source = { registry = "https://pypi.org/simple" }
52
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "rich"
59
+ version = "15.0.0"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ dependencies = [
62
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
63
+ { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
64
+ { name = "pygments" },
65
+ ]
66
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
67
+ wheels = [
68
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
69
+ ]
70
+
71
+ [[package]]
72
+ name = "verkit"
73
+ version = "0.1.1"
74
+ source = { editable = "." }
75
+ dependencies = [
76
+ { name = "rich" },
77
+ ]
78
+
79
+ [package.metadata]
80
+ requires-dist = [{ name = "rich", specifier = ">=10.0.0" }]