Add release helper script

This commit is contained in:
2026-01-29 16:59:13 +01:00
parent dd0f0815e1
commit fa7d66fbcb
3 changed files with 172 additions and 1 deletions

View File

@@ -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

151
release_tool.py Normal file
View File

@@ -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:]))