"""Local release script: unified version bump + build + Gitea release. Usage: python scripts/release.py patch [--webui-repo PATH] [--dry-run] [--resume] [--skip-tests] [--breaking-db] """ from __future__ import annotations import argparse import hashlib import json import os import re import subprocess import sys import urllib.request from pathlib import Path STEPS = ["precheck", "test", "bump", "build", "checksums", "tag", "release", "verify"] STATE_FILE = ".release-state.json" def compute_next_version(current: str, bump: str) -> str: if re.fullmatch(r"\d+\.\d+\.\d+", bump): return bump parts = [int(p) for p in current.split(".")][:3] while len(parts) < 3: parts.append(0) if bump == "patch": parts[2] += 1 elif bump == "minor": parts[1] += 1 parts[2] = 0 elif bump == "major": parts[0] += 1 parts[1] = 0 parts[2] = 0 else: raise ValueError(f"unknown bump: {bump}") return ".".join(str(p) for p in parts) def extract_changelog(text: str, version: str) -> str | None: pattern = re.compile( rf"^## \[?{re.escape(version)}\]?[^\n]*\n(.*?)(?=^## |\Z)", re.MULTILINE | re.DOTALL, ) match = pattern.search(text) if not match: return None body = match.group(1).strip() return body or None def write_checksums(artifacts: list[Path], dest: Path) -> None: lines = [] for artifact in artifacts: digest = hashlib.sha256(artifact.read_bytes()).hexdigest() lines.append(f"{digest} {artifact.name}") dest.write_text("\n".join(lines) + "\n", encoding="utf-8") class ReleaseState: def __init__(self, path: Path): self.path = path self.done: list[str] = [] if path.exists(): try: self.done = json.loads(path.read_text(encoding="utf-8")).get("done", []) except (json.JSONDecodeError, OSError): self.done = [] def is_done(self, step: str) -> bool: return step in self.done def mark_done(self, step: str) -> None: if step not in self.done: self.done.append(step) self.path.write_text(json.dumps({"done": self.done}, indent=2), encoding="utf-8") def _current_backend_version(pyproject: Path) -> str: match = re.search(r'^version = "([^"]+)"', pyproject.read_text(encoding="utf-8"), re.MULTILINE) if not match: raise RuntimeError(f"version not found in {pyproject}") return match.group(1) def _gitea_api(base: str, token: str, method: str, path: str, payload: dict | None = None) -> dict: body = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request( f"{base}/api/v1{path}", data=body, method=method, headers={ "Authorization": f"token {token}", "Content-Type": "application/json", "User-Agent": "EvoScientist release-script", }, ) with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read()) def _upload_asset(base: str, token: str, repo: str, release_id: int, path: Path) -> None: # curl -F: urllib's hand-rolled multipart gets a 403 from Gitea subprocess.run( [ "curl", "-sS", "-f", "-X", "POST", "-H", f"Authorization: token {token}", "-F", f"attachment=@{path}", f"{base}/api/v1/repos/{repo}/releases/{release_id}/assets?name={path.name}", ], check=True, capture_output=True, text=True, ) def main(argv=None, *, run=subprocess.run, repo_root: Path | None = None) -> int: parser = argparse.ArgumentParser(prog="release.py") parser.add_argument("bump") parser.add_argument("--webui-repo", default="../EvoScientist-WebUI") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--resume", action="store_true") parser.add_argument("--skip-tests", action="store_true") parser.add_argument("--breaking-db", action="store_true") args = parser.parse_args(argv) backend = (repo_root or Path(__file__).resolve().parent.parent).resolve() webui = ( Path(args.webui_repo) if Path(args.webui_repo).is_absolute() else (backend / args.webui_repo).resolve() ) pyproject = backend / "pyproject.toml" current = _current_backend_version(pyproject) target = compute_next_version(current, args.bump) base = os.environ.get("EVOSCIENTIST_UPDATE_BASE_URL", "https://git.foksai.com").rstrip("/") repo = os.environ.get("EVOSCIENTIST_UPDATE_REPO", "ouyangbo/EvoScientist") token = os.environ.get("EVOSCIENTIST_RELEASE_TOKEN", "") if not args.dry_run and not token: print("EVOSCIENTIST_RELEASE_TOKEN is required (or use --dry-run)", file=sys.stderr) return 2 state_path = backend / STATE_FILE if args.resume: state = ReleaseState(state_path) else: state_path.unlink(missing_ok=True) state = ReleaseState(Path(os.devnull)) if args.dry_run else ReleaseState(state_path) print(f"release plan: {current} -> {target} (backend={backend}, webui={webui})") def step(name, fn): if state.is_done(name): print(f"[skip] {name}") return None print(f"[run ] {name}") result = fn() if not args.dry_run: state.mark_done(name) return result def precheck(): allowed = { b.strip() for b in os.environ.get("RELEASE_BRANCHES", "main").split(",") if b.strip() } for r in (backend, webui): out = run(["git", "-C", str(r), "status", "--porcelain"], capture_output=True, text=True) if out.stdout.strip(): raise RuntimeError(f"{r} has uncommitted changes") branch = run( ["git", "-C", str(r), "branch", "--show-current"], capture_output=True, text=True, ).stdout.strip() if branch not in allowed: raise RuntimeError(f"{r} is on {branch}, not in RELEASE_BRANCHES {sorted(allowed)}") def tests(): if args.skip_tests: return run([sys.executable, "-m", "pytest", "-x", "-q"], cwd=backend).check_returncode() run(["npx", "vitest", "run"], cwd=webui).check_returncode() def bump(): if args.dry_run: print(f" would set version {target} in pyproject.toml and webui package.json") return if current != target: text = pyproject.read_text(encoding="utf-8") pyproject.write_text( text.replace(f'version = "{current}"', f'version = "{target}"', 1), encoding="utf-8", ) webui_pkg = json.loads((webui / "package.json").read_text(encoding="utf-8")) if webui_pkg.get("version") != target: run(["npm", "version", target, "--no-git-tag-version"], cwd=webui).check_returncode() dist = backend / "dist" / f"release-{target}" def build(): if args.dry_run: print(" would run: uv build; npm run build && npm pack") return dist.mkdir(parents=True, exist_ok=True) run(["uv", "build", "--out-dir", str(dist)], cwd=backend).check_returncode() run(["npm", "run", "build"], cwd=webui).check_returncode() run(["npm", "pack", "--pack-destination", str(dist)], cwd=webui).check_returncode() def checksums(): if args.dry_run: return artifacts = [ p for p in dist.iterdir() if p.suffix in {".whl", ".tgz", ".gz"} and p.name != "checksums.txt" ] write_checksums(artifacts, dist / "checksums.txt") def tag(): msg = f"chore: release v{target}" for r in (backend, webui): if args.dry_run: print(f" would commit+tag v{target} in {r}") continue run(["git", "-C", str(r), "add", "-A"]).check_returncode() dirty = run( ["git", "-C", str(r), "status", "--porcelain"], capture_output=True, text=True, ).stdout.strip() if dirty: run(["git", "-C", str(r), "commit", "-m", msg]).check_returncode() have_tag = run( ["git", "-C", str(r), "tag", "-l", f"v{target}"], capture_output=True, text=True, ).stdout.strip() if not have_tag: run(["git", "-C", str(r), "tag", f"v{target}"]).check_returncode() branch = run( ["git", "-C", str(r), "branch", "--show-current"], capture_output=True, text=True, ).stdout.strip() run(["git", "-C", str(r), "push", "origin", branch, f"v{target}"]).check_returncode() def create_release(): if args.dry_run: print(f" would create release v{target} on {base}/{repo}") return notes = [] for changelog in (backend / "CHANGELOG.md", webui / "CHANGELOG.md"): if changelog.exists(): section = extract_changelog(changelog.read_text(encoding="utf-8"), target) if section: notes.append(f"### {changelog.parent.name}\n\n{section}") body = "\n\n".join(notes) or f"Release v{target}" if args.breaking_db: body = "BREAKING-DB\n\n" + body try: rel = _gitea_api(base, token, "GET", f"/repos/{repo}/releases/tags/v{target}") print(f" reusing existing release v{target} (id {rel['id']})") except urllib.error.HTTPError as e: if e.code != 404: raise rel = _gitea_api( base, token, "POST", f"/repos/{repo}/releases", { "tag_name": f"v{target}", "name": f"v{target}", "body": body, "prerelease": bool(args.breaking_db), }, ) release_id = rel["id"] for artifact in dist.iterdir(): if artifact.name == "checksums.txt" or artifact.suffix in {".whl", ".tgz", ".gz"}: _upload_asset(base, token, repo, release_id, artifact) def verify(): if args.dry_run: return sums = (dist / "checksums.txt").read_text(encoding="utf-8") for line in sums.splitlines(): digest, name = line.split() url = f"{base}/{repo}/releases/download/v{target}/{name}" req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) with urllib.request.urlopen(req, timeout=120) as resp: actual = hashlib.sha256(resp.read()).hexdigest() if actual != digest: raise RuntimeError(f"uploaded asset checksum mismatch: {name}") print("release verified") step("precheck", precheck) step("test", tests) step("bump", bump) step("build", build) step("checksums", checksums) step("tag", tag) step("release", create_release) step("verify", verify) print(f"done: v{target}") return 0 if __name__ == "__main__": sys.exit(main())