#!/usr/bin/env python3 """Release helper: git commit/push + docker build/push. Flow: 1) Prompt for version 2) git add -A 3) git commit -m "Version " (skips if nothing to commit) 4) git push -u origin main (ensures origin points to target repo) 5) docker build + push tags: and latest """ from __future__ import annotations import argparse import re import shutil import subprocess from dataclasses import dataclass @dataclass(frozen=True) class Config: target_git_url: str = "https://git.alphen.cloud/bramval/PsalmbordOnlineCE.git" docker_image: str = "git.alphen.cloud/bramval/psalmbordonlinece" branch: str = "main" VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$") def run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: """Run command and stream output; return CompletedProcess.""" print(f"\n$ {' '.join(cmd)}") return subprocess.run(cmd, text=True, check=check) def capture(cmd: list[str]) -> str: """Run command and capture stdout.""" return subprocess.check_output(cmd, text=True).strip() def ensure_tools() -> None: for tool in ("git", "docker"): if shutil.which(tool) is None: raise SystemExit(f"Error: '{tool}' is not available in PATH") def ensure_git_repo() -> None: try: out = subprocess.check_output(["git", "rev-parse", "--is-inside-work-tree"], text=True).strip() except subprocess.CalledProcessError: raise SystemExit("Error: not a git repository (run from within the repo)") if out.lower() != "true": raise SystemExit("Error: not a git work tree") def prompt_version() -> str: version = input("Enter version (e.g. 1.2.3): ").strip() if not version: raise SystemExit("Error: version cannot be empty") if not VERSION_RE.match(version): raise SystemExit( "Error: invalid version. Use only letters/numbers and . _ - (max 128 chars)" ) return version def prompt_yes_no(question: str, default_no: bool = True) -> bool: prompt = "(y/N)" if default_no else "(Y/n)" ans = input(f"{question} {prompt}: ").strip().lower() if not ans: return not default_no return ans in ("y", "yes") def ensure_origin(config: Config) -> None: try: origin_url = capture(["git", "remote", "get-url", "origin"]) except subprocess.CalledProcessError: origin_url = "" if not origin_url: print(f"Remote 'origin' not found; adding {config.target_git_url}") run(["git", "remote", "add", "origin", config.target_git_url]) return if origin_url != config.target_git_url: print( f"Remote 'origin' is '{origin_url}' -> updating to '{config.target_git_url}'" ) run(["git", "remote", "set-url", "origin", config.target_git_url]) def parse_args(config: Config) -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Commit+push to PsalmbordOnlineCE and build+push docker image (version + latest)." ) ) parser.add_argument( "--version", dest="version", help="Version string used for commit message (Version ) and Docker tag", ) parser.add_argument( "--yes", action="store_true", help="Skip confirmation prompts", ) parser.add_argument( "--dry-run", action="store_true", help="Print what would happen without committing/pushing/building/pushing docker", ) parser.add_argument( "--branch", default=config.branch, help=f"Branch to push (default: {config.branch})", ) parser.add_argument( "--remote-url", default=config.target_git_url, help=f"Remote URL to set for origin (default: {config.target_git_url})", ) parser.add_argument( "--docker-image", default=config.docker_image, help=f"Docker image name (default: {config.docker_image})", ) return parser.parse_args() def main() -> int: config = Config() args = parse_args(config) ensure_tools() ensure_git_repo() version = (args.version or "").strip() or prompt_version() if not VERSION_RE.match(version): raise SystemExit( "Error: invalid version. Use only letters/numbers and . _ - (max 128 chars)" ) config = Config( target_git_url=args.remote_url, docker_image=args.docker_image, branch=args.branch, ) commit_message = f"Version {version}" # Branch warning try: current_branch = capture(["git", "branch", "--show-current"]) except subprocess.CalledProcessError: current_branch = "" if current_branch and current_branch != config.branch: print( f"Warning: you are on branch '{current_branch}' (expected '{config.branch}')." ) if not args.yes and not prompt_yes_no( f"Continue and push '{config.branch}' anyway?", default_no=True ): return 1 ensure_origin(config) print("\n== Git: status ==") run(["git", "status", "-sb"], check=False) if args.dry_run: print("\n== Dry run ==") print("Would run:") for c in ( ["git", "add", "-A"], ["git", "commit", "-m", commit_message], ["git", "push", "-u", "origin", config.branch], [ "docker", "build", "-t", f"{config.docker_image}:{version}", "-t", f"{config.docker_image}:latest", ".", ], ["docker", "push", f"{config.docker_image}:{version}"], ["docker", "push", f"{config.docker_image}:latest"], ): print(f" - {' '.join(c)}") return 0 if not args.yes and not prompt_yes_no( "Proceed with commit+push and docker push?", default_no=True ): print("Aborted.") return 1 print("\n== Git: add/commit/push ==") run(["git", "add", "-A"]) # commit may fail if nothing to commit try: run(["git", "commit", "-m", commit_message]) print(f"Committed: {commit_message}") except subprocess.CalledProcessError: print("No changes to commit (or commit failed). Continuing...") try: run(["git", "push", "-u", "origin", config.branch]) except subprocess.CalledProcessError as e: print( "\nGit push failed. If you're using Gitea over HTTPS, you likely need to authenticate with a Personal Access Token (PAT) instead of your password.\n" "- Ensure you can push: git remote -v\n" "- Configure credentials (Windows): Credential Manager / Git Credential Manager\n" "- Or switch to SSH remote and ensure your key is added on the server\n" ) return int(getattr(e, "returncode", 1) or 1) print("\n== Docker: build/tag/push ==") print(f"Docker image: {config.docker_image}") print(f"Tags: {version}, latest") run( [ "docker", "build", "-t", f"{config.docker_image}:{version}", "-t", f"{config.docker_image}:latest", ".", ] ) try: run(["docker", "push", f"{config.docker_image}:{version}"]) run(["docker", "push", f"{config.docker_image}:latest"]) except subprocess.CalledProcessError as e: print( "\nDocker push failed. Make sure you're logged in:\n" " docker login git.alphen.cloud\n" ) return int(getattr(e, "returncode", 1) or 1) print("\nDone.") print("If docker push failed due to auth, run: docker login git.alphen.cloud") return 0 if __name__ == "__main__": # Make Ctrl+C exit nicely try: raise SystemExit(main()) except KeyboardInterrupt: print("\nAborted (Ctrl+C).") raise SystemExit(130)