#!/usr/bin/env python3 """Generate badge SVG files and push them to the ``badges`` branch. Also updates README.md and docs/index.md on master with cache-busting ``raw/commit//badge.svg`` URLs so that browsers always fetch the latest badge version (Gitea caches ``raw/branch/`` URLs for 6 hours). The script fetches the latest master before generating badges so that the version badge always reflects the current state of the repository (even if a release commit was pushed moments before by the parallel release job). Usage:: python3 scripts/ci/push_badges.py """ from __future__ import annotations import re import subprocess # nosec B404 import sys from pathlib import Path from typing import Any import click REPO_ROOT = Path(__file__).resolve().parent.parent.parent # Badge filenames that get pushed to the badges branch BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"] # Files that contain badge URLs and need to be updated FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"] # Pattern to match raw/branch/badges/.svg URLs _BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)") def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: """Run a command and return the result.""" return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603 def _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: """Run a command and capture stdout.""" return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) # nosec B603 def fetch_latest_master(branch: str = "master") -> None: """Fetch and hard-reset to the latest remote branch. Ensures the working tree reflects the absolute latest state of the remote, which is critical when the release job may have just pushed a new version commit. """ _run(["git", "fetch", "origin", branch]) # nosec B607 _run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607 click.echo(f"Synced to latest origin/{branch}") def generate_badges(output_dir: str) -> None: """Generate badge SVG files using generate_badges.py.""" _run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir]) badges = list(Path(output_dir).glob("*.svg")) if not badges: raise click.ClickException("No badge SVG files generated") click.echo(f"Generated {len(badges)} badge files") def push_to_badges_branch(badges_dir: str) -> str: """Push generated badges to the orphan ``badges`` branch. Returns the commit SHA of the pushed badges branch. """ _run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607 _run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607 _run(["git", "checkout", "--orphan", "badges"]) # nosec B607 _run(["git", "rm", "-rf", "."]) # nosec B607 # Copy badge files to root import shutil for svg in Path(badges_dir).glob("*.svg"): shutil.copy2(svg, Path.cwd() / svg.name) _run(["git", "add", "./*.svg"]) # nosec B607 _run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607 _run(["git", "push", "origin", "badges", "--force"]) # nosec B607 click.echo("Badges pushed to badges branch") # Get the commit SHA of the badges branch result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607 sha = result.stdout.strip() click.echo(f"Badges commit SHA: {sha}") return sha def update_badge_urls(content: str, badges_sha: str) -> str: """Replace raw/branch/badges/.svg URLs with raw/commit//.svg. This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a URL that changes each time the badges branch is updated. """ return _BADGE_URL_RE.sub( lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}", content, ) def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None: """Update README.md and docs/index.md with cache-busting badge URLs. Switches back to master, replaces ``raw/branch/badges/`` URLs with ``raw/commit//`` URLs, commits and pushes. """ root = repo_root or REPO_ROOT # Switch back to master _run(["git", "checkout", "master"]) # nosec B607 _run(["git", "fetch", "origin", "master"]) # nosec B607 _run(["git", "reset", "--hard", "origin/master"]) # nosec B607 updated_any = False for filename in FILES_WITH_BADGE_URLS: filepath = root / filename if not filepath.exists(): continue content = filepath.read_text() new_content = update_badge_urls(content, badges_sha) if new_content != content: filepath.write_text(new_content) click.echo(f"Updated badge URLs in {filename}") updated_any = True if not updated_any: click.echo("No badge URLs found to update — README already up to date") return _run(["git", "add", "README.md", "docs/index.md"]) # nosec B607 _run( [ "git", "commit", "--no-verify", "-m", f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]", ] ) # nosec B607 _run(["git", "push", "origin", "master"]) # nosec B607 click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}") @click.command() @click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.") @click.option("--branch", default="master", help="Branch to sync before generating badges.") @click.option( "--no-readme-update", is_flag=True, default=False, help="Skip updating README with cache-busting URLs (for local testing).", ) def main(output_dir: str, branch: str, no_readme_update: bool) -> None: """Generate badges and push them to the badges branch.""" fetch_latest_master(branch) generate_badges(output_dir) badges_sha = push_to_badges_branch(output_dir) if not no_readme_update: update_readme_with_badge_sha(badges_sha) if __name__ == "__main__": # pragma: no cover main() # pragma: no cover