Files
maddin 6fbd1bb3c2
CI / checks (push) Has been cancelled
chore: initialize public repository
2026-03-22 12:57:09 +00:00

223 lines
7.2 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
VERSION_FILE = ROOT / "VERSION"
ENV_EXAMPLE = ROOT / ".env.example"
ENV_STAGE_EXAMPLE = ROOT / ".env.stage.example"
COMPOSE_FILE = ROOT / "docker-compose.yml"
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
BREAKING_RE = re.compile(r"(^|\n)BREAKING CHANGE:|^[a-z]+(?:\([^)]+\))?!:", re.IGNORECASE | re.MULTILINE)
FEAT_RE = re.compile(r"^feat(?:\([^)]+\))?:", re.IGNORECASE)
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
@classmethod
def parse(cls, value: str) -> "Version":
match = SEMVER_RE.fullmatch(value.strip())
if not match:
raise ValueError(f"Ungueltige SemVer-Version: {value}")
return cls(*(int(part) for part in match.groups()))
@classmethod
def parse_tag(cls, value: str) -> "Version | None":
match = TAG_RE.fullmatch(value.strip())
if not match:
return None
return cls(*(int(part) for part in match.groups()))
def bump(self, kind: str) -> "Version":
if kind == "major":
return Version(self.major + 1, 0, 0)
if kind == "minor":
return Version(self.major, self.minor + 1, 0)
if kind == "patch":
return Version(self.major, self.minor, self.patch + 1)
raise ValueError(f"Unbekannter Bump-Typ: {kind}")
def run_git(*args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def latest_live_tag() -> tuple[str | None, Version | None]:
output = run_git("tag", "--list", "v*")
best_tag: str | None = None
best_version: Version | None = None
for line in output.splitlines():
version = Version.parse_tag(line)
if version is None:
continue
if best_version is None or version > best_version:
best_tag = line
best_version = version
return best_tag, best_version
def commits_since(ref: str | None) -> list[str]:
range_spec = f"{ref}..HEAD" if ref else "HEAD"
output = run_git("log", "--format=%s%n%b%x1e", range_spec)
return [chunk.strip() for chunk in output.split("\x1e") if chunk.strip()]
def detect_bump(commits: list[str]) -> str:
if not commits:
return "patch"
for commit in commits:
if BREAKING_RE.search(commit):
return "major"
for commit in commits:
subject = commit.splitlines()[0].strip()
if FEAT_RE.match(subject):
return "minor"
return "patch"
def current_repo_version() -> Version:
return Version.parse(VERSION_FILE.read_text(encoding="utf-8").strip())
def replace_or_append(path: Path, prefix: str, value: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
for idx, line in enumerate(lines):
if line.startswith(prefix):
lines[idx] = f"{prefix}{value}"
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
lines.append(f"{prefix}{value}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def replace_regex(path: Path, pattern: str, replacement: str) -> None:
content = path.read_text(encoding="utf-8")
updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE)
if count != 1:
raise RuntimeError(f"Konnte Muster in {path} nicht eindeutig ersetzen")
path.write_text(updated, encoding="utf-8")
def apply_version(version: Version) -> None:
VERSION_FILE.write_text(f"{version}\n", encoding="utf-8")
replace_or_append(ENV_EXAMPLE, "APP_VERSION=", str(version))
replace_or_append(ENV_STAGE_EXAMPLE, "APP_VERSION=", str(version))
replace_regex(COMPOSE_FILE, r"(APP_VERSION:\s+\$\{APP_VERSION:-)([^}]+)(\})", rf"\g<1>{version}\3")
def ensure_clean_worktree() -> None:
status = run_git("status", "--porcelain")
if status:
raise RuntimeError("Git-Worktree ist nicht sauber. Bitte zuerst committen oder stagen.")
def cmd_suggest(_: argparse.Namespace) -> int:
tag, base = latest_live_tag()
if base is None:
base = current_repo_version()
commits = commits_since(tag)
bump = detect_bump(commits)
next_version = base if not commits else base.bump(bump)
print(f"latest_live_tag={tag or 'none'}")
print(f"base_version={base}")
print(f"detected_bump={bump}")
print(f"next_version={next_version}")
return 0
def cmd_apply_auto(args: argparse.Namespace) -> int:
if args.require_clean:
ensure_clean_worktree()
tag, base = latest_live_tag()
if base is None:
base = current_repo_version()
commits = commits_since(tag)
bump = detect_bump(commits)
next_version = base if not commits else base.bump(bump)
apply_version(next_version)
print(f"applied_version={next_version}")
print(f"detected_bump={bump}")
return 0
def cmd_apply(args: argparse.Namespace) -> int:
if args.require_clean:
ensure_clean_worktree()
version = Version.parse(args.version)
apply_version(version)
print(f"applied_version={version}")
return 0
def cmd_tag_live(_: argparse.Namespace) -> int:
version = current_repo_version()
tag_name = f"v{version}"
existing = run_git("tag", "--list", tag_name)
if existing.strip():
print(f"tag_exists={tag_name}")
return 0
subprocess.run(["git", "tag", tag_name], cwd=ROOT, check=True)
print(f"tag_created={tag_name}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Automatische Versionierung fuer Stundenfuchs")
sub = parser.add_subparsers(dest="command", required=True)
suggest = sub.add_parser("suggest", help="Naechste Version aus Commits seit letztem Live-Tag ableiten")
suggest.set_defaults(func=cmd_suggest)
apply_auto = sub.add_parser("apply-auto", help="Naechste Version automatisch anwenden")
apply_auto.add_argument("--require-clean", action="store_true", help="Abbrechen, wenn der Worktree nicht sauber ist")
apply_auto.set_defaults(func=cmd_apply_auto)
apply_manual = sub.add_parser("apply", help="Konkrete Version anwenden")
apply_manual.add_argument("version", help="SemVer-Version, z. B. 1.0.4")
apply_manual.add_argument("--require-clean", action="store_true", help="Abbrechen, wenn der Worktree nicht sauber ist")
apply_manual.set_defaults(func=cmd_apply)
tag_live = sub.add_parser("tag-live", help="Aktuelle Repo-Version als Live-Tag anlegen")
tag_live.set_defaults(func=cmd_tag_live)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return args.func(args)
except subprocess.CalledProcessError as exc:
print(exc.stderr or str(exc), file=sys.stderr)
return exc.returncode or 1
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())