"""Release helper. Automates the release flow for this repo: 1) Updates `release.py` VERSION 2) Commits with the version as the commit message 3) Pushes to the configured git remote 4) Builds a Docker image tagged with the version (and optionally `latest`) 5) Pushes the Docker tags to the registry Usage: python release_tool.py v1.0.2 --image git.alphen.cloud/bramval/rssfeedviewer --also-latest """ from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent RELEASE_PY = REPO_ROOT / "release.py" def run(cmd: list[str], *, cwd: Path = REPO_ROOT, dry_run: bool = False) -> None: printable = " ".join(cmd) print(f"$ {printable}") if dry_run: return subprocess.run(cmd, cwd=str(cwd), check=True) def get_stdout(cmd: list[str], *, cwd: Path = REPO_ROOT) -> str: out = subprocess.check_output(cmd, cwd=str(cwd)) return out.decode("utf-8", errors="replace") def ensure_git_clean() -> None: status = get_stdout(["git", "status", "--porcelain"]).strip() if status: raise RuntimeError( "Working tree is not clean. Please commit/stash changes first.\n" + status ) def update_release_py(version: str, *, dry_run: bool) -> None: content = ( '"""Release metadata.\n\n' "This module exists so the running application and/or deployments can introspect\n" "which version is currently deployed.\n" '"""\n\n' f'VERSION = "{version}"\n' ) # If the file exists, try to surgically replace VERSION; otherwise create it. if RELEASE_PY.exists(): existing = RELEASE_PY.read_text(encoding="utf-8") if re.search(r"^VERSION\s*=\s*\".*\"\s*$", existing, flags=re.MULTILINE): updated = re.sub( r"^VERSION\s*=\s*\".*\"\s*$", f'VERSION = "{version}"', existing, flags=re.MULTILINE, ) if dry_run: print(f"[dry-run] Would update {RELEASE_PY.name} VERSION to {version}") else: RELEASE_PY.write_text(updated, encoding="utf-8") return if dry_run: print(f"[dry-run] Would write {RELEASE_PY.name} with VERSION={version}") return RELEASE_PY.write_text(content, encoding="utf-8") def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description="Release helper for git + docker") parser.add_argument( "version", help="Version string used as git commit message and docker tag (e.g. v1.0.2)", ) parser.add_argument( "--image", default="git.alphen.cloud/bramval/rssfeedviewer", help="Docker image repository (no tag), e.g. git.alphen.cloud/bramval/rssfeedviewer", ) parser.add_argument( "--also-latest", action="store_true", help="Also tag and push :latest in addition to the version tag", ) parser.add_argument( "--skip-git", action="store_true", help="Skip git commit/push steps", ) parser.add_argument( "--skip-docker", action="store_true", help="Skip docker build/push steps", ) parser.add_argument( "--dry-run", action="store_true", help="Print commands without executing them", ) args = parser.parse_args(argv) version = args.version.strip() if not version: parser.error("version must be non-empty") # In dry-run mode we want to be able to preview the flow even if the working # tree is currently dirty. if not args.skip_git and not args.dry_run: ensure_git_clean() elif not args.skip_git and args.dry_run: print("[dry-run] Skipping clean working-tree check") update_release_py(version, dry_run=args.dry_run) if not args.skip_git: run(["git", "add", "release.py"], dry_run=args.dry_run) run(["git", "commit", "-m", version], dry_run=args.dry_run) run(["git", "push"], dry_run=args.dry_run) if not args.skip_docker: tags: list[str] = [f"{args.image}:{version}"] if args.also_latest: tags.append(f"{args.image}:latest") build_cmd = ["docker", "build"] for t in tags: build_cmd += ["-t", t] build_cmd.append(".") run(build_cmd, dry_run=args.dry_run) for t in tags: run(["docker", "push", t], dry_run=args.dry_run) print("Done.") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))