This commit is contained in:
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATES_DIR = ROOT / "app" / "templates"
|
||||
PAGES_DIR = TEMPLATES_DIR / "pages"
|
||||
BASE_FILE = TEMPLATES_DIR / "base.html"
|
||||
CSS_DIR = ROOT / "app" / "static" / "css"
|
||||
|
||||
RULE_EXTENDS = "POL001"
|
||||
RULE_INLINE_STYLE = "POL002"
|
||||
RULE_EXTRA_ASSETS = "POL003"
|
||||
RULE_HEX_OUTSIDE_TOKENS = "POL004"
|
||||
RULE_PX_SPACING = "POL005"
|
||||
RULE_BASE_ASSETS = "POL006"
|
||||
|
||||
EXTENDS_BASE_RE = re.compile(r"\{%-?\s*extends\s+\"base\.html\"\s*-?%\}")
|
||||
INLINE_STYLE_RE = re.compile(r"<style\b|style\s*=\s*\"", re.IGNORECASE)
|
||||
ASSET_LINK_RE = re.compile(r"<link[^>]+rel=\"stylesheet\"|<script[^>]+src=\"", re.IGNORECASE)
|
||||
HEX_RE = re.compile(r"#[0-9a-fA-F]{3,8}")
|
||||
PX_SPACING_RE = re.compile(
|
||||
r"(?:margin|padding|gap|row-gap|column-gap)\s*:\s*[^;]*\d+px",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def err(errors: list[str], path: Path, line_no: int, rule: str, message: str) -> None:
|
||||
rel = path.relative_to(ROOT)
|
||||
errors.append(f"{rel}:{line_no}: {rule} {message}")
|
||||
|
||||
|
||||
def check_base_assets(errors: list[str]) -> None:
|
||||
if not BASE_FILE.exists():
|
||||
errors.append(f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} Missing base.html")
|
||||
return
|
||||
|
||||
base_content = BASE_FILE.read_text(encoding="utf-8")
|
||||
css_hits = re.findall(r'/static/css/[^\"]+', base_content)
|
||||
js_hits = re.findall(r'/static/js/[^\"]+', base_content)
|
||||
|
||||
expected_css = ["/static/css/app.css?v={{ asset_version }}"]
|
||||
expected_js = ["/static/js/app.js?v={{ asset_version }}"]
|
||||
|
||||
if css_hits != expected_css:
|
||||
errors.append(
|
||||
f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} expected CSS include {expected_css}, found {css_hits}"
|
||||
)
|
||||
|
||||
if js_hits != expected_js:
|
||||
errors.append(
|
||||
f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} expected JS include {expected_js}, found {js_hits}"
|
||||
)
|
||||
|
||||
|
||||
def check_pages_extend_base(errors: list[str]) -> None:
|
||||
page_files = sorted(PAGES_DIR.glob("*.html"))
|
||||
if not page_files:
|
||||
errors.append(f"{PAGES_DIR.relative_to(ROOT)}:1: {RULE_EXTENDS} No page templates found")
|
||||
return
|
||||
|
||||
for path in page_files:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if not EXTENDS_BASE_RE.search(content):
|
||||
err(
|
||||
errors,
|
||||
path,
|
||||
1,
|
||||
RULE_EXTENDS,
|
||||
"page template must contain {% extends \"base.html\" %}",
|
||||
)
|
||||
|
||||
|
||||
def check_templates_inline_and_assets(errors: list[str]) -> None:
|
||||
for path in sorted(TEMPLATES_DIR.rglob("*.html")):
|
||||
for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if INLINE_STYLE_RE.search(line):
|
||||
err(errors, path, idx, RULE_INLINE_STYLE, "inline style or <style> is forbidden")
|
||||
|
||||
if path != BASE_FILE and ASSET_LINK_RE.search(line):
|
||||
err(
|
||||
errors,
|
||||
path,
|
||||
idx,
|
||||
RULE_EXTRA_ASSETS,
|
||||
"asset includes are only allowed in templates/base.html",
|
||||
)
|
||||
|
||||
|
||||
def check_css_rules(errors: list[str]) -> None:
|
||||
for path in sorted(CSS_DIR.rglob("*.css")):
|
||||
is_tokens = path.name == "tokens.css"
|
||||
for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if not is_tokens and HEX_RE.search(line):
|
||||
err(errors, path, idx, RULE_HEX_OUTSIDE_TOKENS, "hex colors are only allowed in tokens.css")
|
||||
|
||||
if not is_tokens and PX_SPACING_RE.search(line):
|
||||
err(errors, path, idx, RULE_PX_SPACING, "hard px spacing is forbidden outside tokens.css")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
|
||||
check_base_assets(errors)
|
||||
check_pages_extend_base(errors)
|
||||
check_templates_inline_and_assets(errors)
|
||||
check_css_rules(errors)
|
||||
|
||||
if errors:
|
||||
print("Policy checks failed:")
|
||||
for item in errors:
|
||||
print(f"{item}")
|
||||
print("Hint: fix violations or move token values into app/static/css/tokens.css")
|
||||
return 1
|
||||
|
||||
print("Policy checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user