Files
PsalmbordOnlineCE/release.py
2026-01-28 15:57:24 +01:00

259 lines
7.8 KiB
Python

#!/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 <version>" (skips if nothing to commit)
4) git push -u origin main (ensures origin points to target repo)
5) docker build + push tags: <version> 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 <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)