diff --git a/README.md b/README.md index aa41b9e..330fe48 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,26 @@ docker compose up --build Open: http://127.0.0.1:5000 +## Release (git + docker) + +There is a helper script that: + +- updates `release.py` with the version +- commits + pushes to git using the version as the commit message +- builds + pushes Docker images tagged with the version (and optionally `latest`) + +Example: + +```bash +python release_tool.py v1.0.2 --image git.alphen.cloud/bramval/rssfeedviewer --also-latest +``` + +Dry run (prints commands only): + +```bash +python release_tool.py v1.0.2 --dry-run +``` + ## Controls - Click/tap: next headline diff --git a/release.py b/release.py index 70f8235..9800e23 100644 --- a/release.py +++ b/release.py @@ -4,4 +4,4 @@ This module exists so the running application and/or deployments can introspect which version is currently deployed. """ -VERSION = "v1.0.1" +VERSION = "v1.0.1" \ No newline at end of file diff --git a/release_tool.py b/release_tool.py new file mode 100644 index 0000000..7b79a39 --- /dev/null +++ b/release_tool.py @@ -0,0 +1,151 @@ +"""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:]))