376 lines
12 KiB
Python
376 lines
12 KiB
Python
#!/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()
|