#!/usr/bin/env python3 """Release helper. What it does (in order): 1) Ask/provide a commit message and version. 2) Commit & push to the `openslide` git remote. 3) Build + push Docker image tags: - git.alphen.cloud/bramval/openslide: - git.alphen.cloud/bramval/openslide:latest Usage examples: python scripts/release.py --version 1.2.3 --message "Release 1.2.3" python scripts/release.py # interactive prompts Notes: - Assumes you are already authenticated for git + the Docker registry (docker login). """ from __future__ import annotations import argparse import re import subprocess import sys from dataclasses import dataclass from typing import Iterable, Sequence DEFAULT_GIT_REMOTE = "openslide" DEFAULT_IMAGE = "git.alphen.cloud/bramval/openslide" @dataclass(frozen=True) class ReleaseInfo: version: str message: str _DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None: printable = " ".join(cmd) print(f"> {printable}") if dry_run: return subprocess.run(cmd, check=True) def _capture(cmd: Sequence[str]) -> str: return subprocess.check_output(cmd, text=True).strip() def _require_tool(name: str) -> None: try: subprocess.run([name, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) except FileNotFoundError as e: raise SystemExit(f"Required tool not found in PATH: {name}") from e def _validate_version(tag: str) -> str: tag = tag.strip() if not tag: raise ValueError("Version may not be empty") if not _DOCKER_TAG_RE.match(tag): raise ValueError( "Invalid docker tag for version. Use only letters, digits, '.', '_' or '-'. " "(1..128 chars, must start with [A-Za-z0-9])" ) return tag def get_release_info(*, version: str | None, message: str | None) -> ReleaseInfo: """Collect commit message + version before doing the rest.""" if version is None: version = input("Version tag (e.g. 1.2.3): ").strip() version = _validate_version(version) if message is None: message = input(f"Commit message [Release {version}]: ").strip() or f"Release {version}" return ReleaseInfo(version=version, message=message) def git_commit_and_push(*, remote: str, message: str, dry_run: bool = False) -> None: # Stage all changes _run(["git", "add", "-A"], dry_run=dry_run) # Only commit if there is something to commit porcelain = _capture(["git", "status", "--porcelain"]) # empty => clean if porcelain: _run(["git", "commit", "-m", message], dry_run=dry_run) else: print("No working tree changes detected; skipping git commit.") branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"]) _run(["git", "push", remote, branch], dry_run=dry_run) def docker_build_and_push(*, image: str, version: str, dry_run: bool = False) -> None: version_tag = f"{image}:{version}" latest_tag = f"{image}:latest" _run(["docker", "build", "-t", version_tag, "-t", latest_tag, "."], dry_run=dry_run) _run(["docker", "push", version_tag], dry_run=dry_run) _run(["docker", "push", latest_tag], dry_run=dry_run) def main(argv: Iterable[str]) -> int: parser = argparse.ArgumentParser(description="Commit + push + docker publish helper.") parser.add_argument("--version", "-v", help="Docker version tag (e.g. 1.2.3)") parser.add_argument("--message", "-m", help="Git commit message") parser.add_argument("--remote", default=DEFAULT_GIT_REMOTE, help=f"Git remote to push to (default: {DEFAULT_GIT_REMOTE})") parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker image name (default: {DEFAULT_IMAGE})") parser.add_argument("--dry-run", action="store_true", help="Print commands without executing") args = parser.parse_args(list(argv)) _require_tool("git") _require_tool("docker") info = get_release_info(version=args.version, message=args.message) print(f"\nReleasing version: {info.version}") print(f"Commit message: {info.message}") print(f"Git remote: {args.remote}") print(f"Docker image: {args.image}\n") git_commit_and_push(remote=args.remote, message=info.message, dry_run=args.dry_run) docker_build_and_push(image=args.image, version=info.version, dry_run=args.dry_run) print("\nDone.") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))