#!/usr/bin/env python3 """ Release script — auto-versioning and auto-changelog. Usage: python release.py minor # 1.5.0 → 1.6.0 python release.py patch # 1.5.0 → 1.5.1 python release.py major # 1.5.0 → 2.0.0 python release.py 2.0.0 # explicit version python release.py minor --desc "Release desc" # custom description """ import argparse import json import os import re import subprocess import sys import urllib.request import urllib.error from datetime import date PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def read_file(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() def write_file(path: str, content: str) -> None: with open(path, "w", encoding="utf-8") as f: f.write(content) def get_current_version() -> str: """Read __version__ from version.py.""" text = read_file(os.path.join(PROJECT_DIR, "version.py")) m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text) if not m: print("ERROR: cannot parse __version__ from version.py") sys.exit(1) return m.group(1) def parse_semver(ver: str) -> tuple[int, int, int]: parts = ver.split(".") if len(parts) != 3 or not all(p.isdigit() for p in parts): print(f"ERROR: invalid semver '{ver}'") sys.exit(1) return int(parts[0]), int(parts[1]), int(parts[2]) def bump_version(current: str, bump_type: str) -> str: major, minor, patch = parse_semver(current) if bump_type == "major": return f"{major + 1}.0.0" elif bump_type == "minor": return f"{major}.{minor + 1}.0" elif bump_type == "patch": return f"{major}.{minor}.{patch + 1}" else: # explicit version parse_semver(bump_type) # validate return bump_type # --------------------------------------------------------------------------- # Git log → changelog # --------------------------------------------------------------------------- def get_last_tag() -> str | None: """Return the most recent tag, or None.""" try: result = subprocess.run( ["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True, cwd=PROJECT_DIR, ) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: pass return None def get_commits_since(tag: str | None) -> list[str]: """Return list of commit messages since *tag* (or all if tag is None).""" if tag: cmd = ["git", "log", f"{tag}..HEAD", "--pretty=format:%s"] else: cmd = ["git", "log", "--pretty=format:%s"] try: result = subprocess.run(cmd, capture_output=True, text=True, cwd=PROJECT_DIR) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip().splitlines() except FileNotFoundError: pass return [] def categorize_commits(commits: list[str]) -> dict[str, list[str]]: """Group commits into Added / Fixed / Changed.""" cats: dict[str, list[str]] = {"Added": [], "Fixed": [], "Changed": []} add_re = re.compile(r"^(add|feat|new)\b", re.IGNORECASE) fix_re = re.compile(r"^fix\b", re.IGNORECASE) for msg in commits: msg = msg.strip() if not msg: continue if add_re.search(msg): cats["Added"].append(msg) elif fix_re.search(msg): cats["Fixed"].append(msg) else: cats["Changed"].append(msg) return cats def build_changelog_section( new_version: str, categories: dict[str, list[str]], description: str | None, ) -> str: """Build markdown section for CHANGELOG.md.""" today = date.today().isoformat() lines = [f"## [{new_version}] - {today}", ""] if description: lines.append(description) lines.append("") for cat in ("Added", "Fixed", "Changed"): items = categories.get(cat, []) if items: lines.append(f"### {cat}") for item in items: lines.append(f"- {item}") lines.append("") # if nothing was categorized, add a placeholder if not any(categories.values()): lines.append("### Changed") lines.append(f"- Bump version to {new_version}") lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # File updaters # --------------------------------------------------------------------------- def update_version_py(new_version: str) -> None: path = os.path.join(PROJECT_DIR, "version.py") text = read_file(path) text = re.sub( r'(__version__\s*=\s*["\'])[^"\']+(["\'])', rf"\g<1>{new_version}\2", text, ) write_file(path, text) print(f" version.py → {new_version}") def update_claude_md(new_version: str) -> None: path = os.path.join(PROJECT_DIR, "CLAUDE.md") if not os.path.exists(path): print(" CLAUDE.md — not found, skipping") return text = read_file(path) text = re.sub( r"(##\s*Текущая версия:\s*)\S+", rf"\g<1>{new_version}", text, ) write_file(path, text) print(f" CLAUDE.md → {new_version}") def update_readme(old_version: str, new_version: str) -> None: path = os.path.join(PROJECT_DIR, "README.md") if not os.path.exists(path): print(" README.md — not found, skipping") return text = read_file(path) old_tag = f"ServerManager-v{old_version}" new_tag = f"ServerManager-v{new_version}" if old_tag in text: text = text.replace(old_tag, new_tag) write_file(path, text) print(f" README.md → {old_tag} → {new_tag}") else: print(f" README.md — '{old_tag}' not found, skipping") def update_changelog(section: str) -> None: path = os.path.join(PROJECT_DIR, "CHANGELOG.md") if os.path.exists(path): old = read_file(path) # Insert after the first line ("# Changelog\n") header_re = re.compile(r"^(#\s*Changelog\s*\n)", re.MULTILINE) m = header_re.search(old) if m: insert_pos = m.end() new_text = old[:insert_pos] + "\n" + section + "\n" + old[insert_pos:].lstrip("\n") else: new_text = section + "\n" + old else: new_text = "# Changelog\n\n" + section write_file(path, new_text) print(f" CHANGELOG.md → new section added") # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="Auto-versioning and auto-changelog for ServerManager.", ) parser.add_argument( "version", help="Bump type (major/minor/patch) or explicit version (e.g. 2.0.0)", ) parser.add_argument( "--desc", default=None, help="Custom description for the changelog section", ) args = parser.parse_args() old_version = get_current_version() bump_type = args.version.lower() if bump_type in ("major", "minor", "patch"): new_version = bump_version(old_version, bump_type) else: new_version = bump_version(old_version, args.version) print(f"\nRelease: {old_version} → {new_version}\n") # Collect git log last_tag = get_last_tag() commits = get_commits_since(last_tag) categories = categorize_commits(commits) section = build_changelog_section(new_version, categories, args.desc) # Update files print("Updating files:") update_version_py(new_version) update_claude_md(new_version) update_readme(old_version, new_version) update_changelog(section) # Summary print(f"\nDone! Version is now {new_version}.") print("Next steps:") print(" 1. Review changes: git diff") print(" 2. Build: python build.py --clean") print(" 3. Commit, tag & push") print(f" 4. python release.py --publish {new_version}") # --------------------------------------------------------------------------- # Gitea release publishing # --------------------------------------------------------------------------- def _gitea_api(endpoint: str, method: str = "GET", data: dict | None = None, binary_path: str | None = None) -> dict | None: """Call Gitea API. Reads credentials from git remote 'sensey'.""" import base64 try: result = subprocess.run( ["git", "remote", "get-url", "sensey"], capture_output=True, text=True, cwd=PROJECT_DIR, ) remote_url = result.stdout.strip() except Exception: print("ERROR: cannot read 'sensey' remote") return None m = re.match(r"https://([^:]+):([^@]+)@([^/]+)/(.+?)(?:\.git)?$", remote_url) if not m: print(f"ERROR: cannot parse remote URL: {remote_url}") return None user, password, host, repo_path = m.groups() base = f"https://{host}/api/v1/repos/{repo_path}" url = f"{base}/{endpoint}" if endpoint else base headers = { "Authorization": "Basic " + base64.b64encode( f"{user}:{password}".encode() ).decode(), } if binary_path: headers["Content-Type"] = "application/octet-stream" with open(binary_path, "rb") as f: body = f.read() elif data is not None: headers["Content-Type"] = "application/json" body = json.dumps(data).encode() else: body = None req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: print(f"Gitea API error {e.code}: {e.read().decode()}") return None def publish(version: str): """Create a Gitea release and upload binaries from releases/ folder.""" tag = f"v{version}" if not version.startswith("v") else version ver = tag.lstrip("v") print(f"\nPublishing release {tag} to Gitea...") # Read changelog section for this version changelog_path = os.path.join(PROJECT_DIR, "CHANGELOG.md") body = "" if os.path.exists(changelog_path): text = read_file(changelog_path) pattern = rf"(## \[{re.escape(ver)}\].*?)(?=\n## \[|\Z)" m = re.search(pattern, text, re.DOTALL) if m: body = m.group(1).strip() # Create release release = _gitea_api("releases", "POST", { "tag_name": tag, "name": tag, "body": body or f"Release {tag}", "draft": False, "prerelease": False, }) if not release: print("ERROR: failed to create release") sys.exit(1) release_id = release["id"] print(f" Release created: {release['html_url']}") # Upload matching binaries releases_dir = os.path.join(PROJECT_DIR, "releases") if os.path.isdir(releases_dir): for fname in sorted(os.listdir(releases_dir)): if f"-v{ver}-" in fname: fpath = os.path.join(releases_dir, fname) print(f" Uploading {fname}...", end=" ") asset = _gitea_api( f"releases/{release_id}/assets?name={fname}", "POST", binary_path=fpath, ) if asset: size_mb = asset.get("size", 0) / (1024 * 1024) print(f"OK ({size_mb:.1f} MB)") else: print("FAILED") print(f"\nRelease {tag} published!") if __name__ == "__main__": if "--publish" in sys.argv: idx = sys.argv.index("--publish") if idx + 1 < len(sys.argv): publish(sys.argv[idx + 1]) else: publish(get_current_version()) sys.exit(0) main()