feat(release): local release script with unified version bump and Gitea publish
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user