From f3ca381ab3c4efe239bdb5d0c2ec5abf77a5e615 Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 17:25:43 +0800 Subject: [PATCH] feat(release): local release script with unified version bump and Gitea publish Co-Authored-By: Claude Opus 4.7 --- scripts/release.py | 291 +++++++++++++++++++++++++++++++++++ tests/test_release_script.py | 102 ++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 scripts/release.py create mode 100644 tests/test_release_script.py diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..f04734d --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,291 @@ +"""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: + boundary = "----evosci-release" + data = path.read_bytes() + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n' + f"Content-Type: application/octet-stream\r\n\r\n" + ).encode() + data + f"\r\n--{boundary}--\r\n".encode() + req = urllib.request.Request( + f"{base}/api/v1/repos/{repo}/releases/{release_id}/assets?name={path.name}", + data=body, + method="POST", + headers={ + "Authorization": f"token {token}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + with urllib.request.urlopen(req, timeout=300): + pass + + +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(): + 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, + ) + if branch.stdout.strip() != "main": + raise RuntimeError(f"{r} is not on main") + + 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 + text = pyproject.read_text(encoding="utf-8") + pyproject.write_text( + text.replace(f'version = "{current}"', f'version = "{target}"', 1), + encoding="utf-8", + ) + 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() + run(["git", "-C", str(r), "commit", "-m", msg]).check_returncode() + run(["git", "-C", str(r), "tag", f"v{target}"]).check_returncode() + run(["git", "-C", str(r), "push", "origin", "main", 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 + 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()) diff --git a/tests/test_release_script.py b/tests/test_release_script.py new file mode 100644 index 0000000..d8a0748 --- /dev/null +++ b/tests/test_release_script.py @@ -0,0 +1,102 @@ +"""Tests for scripts/release.py (loaded by path, not as a package).""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +SPEC = importlib.util.spec_from_file_location( + "release_script", + str(Path(__file__).resolve().parent.parent / "scripts" / "release.py"), +) +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +def test_compute_next_version_bumps(): + assert release.compute_next_version("0.2.2", "patch") == "0.2.3" + assert release.compute_next_version("0.2.2", "minor") == "0.3.0" + assert release.compute_next_version("0.2.2", "major") == "1.0.0" + assert release.compute_next_version("0.2.2", "0.5.1") == "0.5.1" + with pytest.raises(ValueError): + release.compute_next_version("0.2.2", "banana") + + +def test_extract_changelog_sections(): + text = ( + "# Changelog\n\n## [0.2.3] - 2026-08-12\n\n- Added foo\n- Fixed bar\n\n" + "## [0.2.2] - 2026-08-01\n\n- Old\n" + ) + assert release.extract_changelog(text, "0.2.3") == "- Added foo\n- Fixed bar" + assert release.extract_changelog(text, "0.2.2") == "- Old" + assert release.extract_changelog(text, "9.9.9") is None + + +def test_write_checksums(tmp_path): + a = tmp_path / "a.whl" + a.write_bytes(b"aaa") + b = tmp_path / "b.tgz" + b.write_bytes(b"bbb") + dest = tmp_path / "checksums.txt" + release.write_checksums([a, b], dest) + import hashlib + + lines = dest.read_text().splitlines() + assert f"{hashlib.sha256(b'aaa').hexdigest()} a.whl" in lines + assert f"{hashlib.sha256(b'bbb').hexdigest()} b.tgz" in lines + + +def test_release_state_resume(tmp_path): + state_file = tmp_path / "state.json" + st = release.ReleaseState(state_file) + assert not st.is_done("bump") + st.mark_done("bump") + st2 = release.ReleaseState(state_file) + assert st2.is_done("bump") + assert not st2.is_done("build") + + +def test_main_dry_run_makes_no_changes(tmp_path): + backend = tmp_path / "backend" + webui = tmp_path / "webui" + backend.mkdir() + webui.mkdir() + (backend / "pyproject.toml").write_text('[project]\nversion = "0.2.2"\n') + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + + class R: + returncode = 0 + stdout = "main" if "branch" in cmd else "" + stderr = "" + + def check_returncode(self): + pass + + return R() + + rc = release.main( + ["patch", "--webui-repo", str(webui), "--dry-run"], + run=fake_run, + repo_root=backend, + ) + assert rc == 0 + assert '"0.2.2"' in (backend / "pyproject.toml").read_text() # dry-run changes nothing + + +def test_main_requires_token_without_dry_run(tmp_path, monkeypatch): + backend = tmp_path / "backend" + webui = tmp_path / "webui" + backend.mkdir() + webui.mkdir() + (backend / "pyproject.toml").write_text('[project]\nversion = "0.2.2"\n') + monkeypatch.delenv("EVOSCIENTIST_RELEASE_TOKEN", raising=False) + rc = release.main( + ["patch", "--webui-repo", str(webui)], + run=lambda *a, **k: None, + repo_root=backend, + ) + assert rc == 2