diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..6ccb573 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,158 @@ +#!/usr/bin/env -S uv run +"""Release build for paskia (PyPI) and paskia-js (npm). + +Usage: release.py [patch|minor|major] (default: patch) + +Bumps the version from the latest vX.Y.Z tag, commits "Release x.y.z" with +the paskia-js version bump and tags it, then builds both packages from a +clean slate. On failure the release commit and tag are rolled back. +Publishing is left to the user; the command is printed on success. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Build outputs removed before building +ARTIFACTS = ["dist", "paskia-js/dist", "paskia/frontend-build", "build"] + +# Dependency state removed for a fresh upstream resolve (all untracked) +JS_DIRS = ["paskia-js", "frontend"] +JS_JUNK = ["node_modules", "package-lock.json", "deno.lock", "bun.lock"] + +BUMPS = ("patch", "minor", "major") + + +def run(cmd: list[str], cwd: Path = REPO_ROOT) -> None: + print(f"### {' '.join(cmd)}") + subprocess.run(cmd, cwd=cwd, check=True) # noqa: S603 + + +def abort(msg: str) -> None: + print(f"error: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def git(*args: str) -> str: + return subprocess.run( # noqa: S603 + ["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True + ).stdout.strip() + + +def check_clean_tree() -> None: + status = git("status", "--porcelain") + if status: + print(status, file=sys.stderr) + abort("working tree is not clean; commit or stash all changes first") + + +def latest_version() -> tuple[int, int, int]: + """Highest vX.Y.Z tag, as a tuple.""" + tags = [] + for tag in git("tag", "--list", "v*").splitlines(): + parts = tag.removeprefix("v").split(".") + if len(parts) == 3 and all(p.isdigit() for p in parts): + tags.append(tuple(int(p) for p in parts)) + if not tags: + abort("no existing vX.Y.Z tags found") + return max(tags) + + +def next_version(bump: str) -> tuple[int, int, int]: + major, minor, patch = latest_version() + if bump == "major": + return (major + 1, 0, 0) + if bump == "minor": + return (major, minor + 1, 0) + return (major, minor, patch + 1) + + +def set_js_version(version: str) -> None: + pkg_path = REPO_ROOT / "paskia-js/package.json" + pkg = json.loads(pkg_path.read_text()) + if pkg.get("version") == version: + return + print(f"paskia-js/package.json: {pkg.get('version')} -> {version}") + pkg["version"] = version + pkg_path.write_text(json.dumps(pkg, indent=2) + "\n") + + +def remove(path: Path, rel: str) -> None: + if not path.exists(): + return + print(f"rm -rf {rel}") + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +def clean() -> None: + for rel in ARTIFACTS: + remove(REPO_ROOT / rel, rel) + for d in JS_DIRS: + for junk in JS_JUNK: + remove(REPO_ROOT / d / junk, f"{d}/{junk}") + + +def main() -> None: + bump = sys.argv[1] if len(sys.argv) == 2 else "patch" + if len(sys.argv) > 2 or bump not in BUMPS: + print(__doc__) + raise SystemExit(1) + + check_clean_tree() + version_tuple = next_version(bump) + version = ".".join(str(p) for p in version_tuple) + tag = f"v{version}" + if tag in git("tag", "--list", tag).splitlines(): + abort(f"tag {tag} already exists") + print(f"Release version: {version}") + + previous_head = git("rev-parse", "HEAD") + released = False + try: + # Clean before committing: only the uv build (hatch-vcs) depends on + # the tag, so the release commit can be made from a clean slate. + clean() + set_js_version(version) + run(["git", "add", "paskia-js/package.json"]) + run(["git", "commit", "-m", f"Release {version}"]) + run(["git", "tag", tag]) + released = True + + # uv build runs the hatch hook that builds paskia-js and the Vue + # frontend into paskia/frontend-build with fresh dependencies. + run(["uv", "build"]) + # Explicit paskia-js build: verifies the package standalone and + # leaves paskia-js/dist ready for npm publish. + run(["npm", "install"], cwd=REPO_ROOT / "paskia-js") + run(["npm", "run", "build"], cwd=REPO_ROOT / "paskia-js") + except BaseException: + if released: + print("Build failed; rolling back the release commit and tag.", file=sys.stderr) + subprocess.run(["git", "tag", "-d", tag], cwd=REPO_ROOT, check=False) # noqa: S603 + # The tree was clean before the release commit, so a hard reset + # back to it is safe. + subprocess.run(["git", "reset", "--hard", previous_head], cwd=REPO_ROOT, check=False) # noqa: S603 + raise + + # Push the release commit to the tracking remote, then the new tag. + # Not rolled back on failure: the local release is intact, just push again. + try: + run(["git", "push"]) + run(["git", "push", "--tags"]) + except subprocess.CalledProcessError: + abort("push failed; the release commit and tag exist locally, push manually") + + print(f"\nBuild completed successfully for version {version}.") + print("To publish, review the artifacts and run:") + print(" uv publish && cd paskia-js && npm publish") + + +if __name__ == "__main__": + main()