GRM-59: fix: use commit SHA URLs for badges to bypass Gitea cache
This commit is contained in:
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+5
-5
@@ -5,11 +5,11 @@ A lean command-line tool to automate the installation, configuration, and lifecy
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
|
||||
## User Documentation
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate badge SVG files and push them to the ``badges`` branch.
|
||||
|
||||
Replaces the inline shell script in the post-merge workflow with a
|
||||
tested Python equivalent.
|
||||
Also updates README.md and docs/index.md on master with cache-busting
|
||||
``raw/commit/<sha>/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
|
||||
@@ -16,6 +17,7 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -23,12 +25,28 @@ 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/<name>.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.
|
||||
|
||||
@@ -50,8 +68,11 @@ def generate_badges(output_dir: str) -> None:
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> None:
|
||||
"""Push generated badges to the orphan ``badges`` branch."""
|
||||
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
|
||||
@@ -68,15 +89,84 @@ def push_to_badges_branch(badges_dir: str) -> None:
|
||||
_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/<name>.svg URLs with raw/commit/<sha>/<name>.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/<sha>/`` 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.")
|
||||
def main(output_dir: str, branch: str) -> None:
|
||||
@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)
|
||||
push_to_badges_branch(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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
@@ -65,13 +65,82 @@ class TestPushToBadgesBranch:
|
||||
svg = badges_dir / "badge1.svg"
|
||||
svg.write_text("<svg></svg>")
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.push_to_badges_branch(str(badges_dir))
|
||||
sha_result = MagicMock()
|
||||
sha_result.stdout = "abc123\n"
|
||||
default_result = MagicMock()
|
||||
with patch(
|
||||
"subprocess.run",
|
||||
side_effect=[default_result] * 7 + [sha_result],
|
||||
) as mock_run:
|
||||
sha = push_badges.push_to_badges_branch(str(badges_dir))
|
||||
|
||||
# Should have called git config, checkout, rm, add, commit, push
|
||||
assert mock_run.call_count >= 6
|
||||
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
|
||||
assert mock_run.call_count >= 8
|
||||
# Verify the SVG was copied to cwd
|
||||
assert (tmp_path / "badge1.svg").exists()
|
||||
assert sha == "abc123"
|
||||
|
||||
|
||||
class TestUpdateBadgeUrls:
|
||||
def test_replaces_branch_url(self) -> None:
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert "raw/commit/abc123def456/tests.svg" in result
|
||||
assert "raw/branch/badges" not in result
|
||||
|
||||
def test_replaces_commit_url(self) -> None:
|
||||
"""Old commit SHA URLs should be replaced with the new one."""
|
||||
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
|
||||
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
|
||||
content = f"[]"
|
||||
result = push_badges.update_badge_urls(content, new_sha)
|
||||
assert f"raw/commit/{new_sha}/tests.svg" in result
|
||||
assert old_sha not in result
|
||||
|
||||
def test_no_badge_urls(self) -> None:
|
||||
content = "# No badges here\nJust text."
|
||||
result = push_badges.update_badge_urls(content, "abc123")
|
||||
assert result == content
|
||||
|
||||
def test_multiple_badges(self) -> None:
|
||||
content = (
|
||||
"[]\n"
|
||||
"[]\n"
|
||||
"[]"
|
||||
)
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert result.count("raw/commit/abc123def456/") == 3
|
||||
assert "raw/branch/badges" not in result
|
||||
|
||||
def test_preserves_non_badge_urls(self) -> None:
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123")
|
||||
assert result == content
|
||||
|
||||
|
||||
class TestUpdateReadmeWithBadgeSha:
|
||||
def test_updates_readme(self, tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
with patch("subprocess.run"):
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
content = readme.read_text()
|
||||
assert "raw/commit/abc123def456/tests.svg" in content
|
||||
|
||||
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("# No badges here")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
# Should checkout master but not commit/push
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
assert not any("commit" in c for c in calls)
|
||||
assert not any("push" in c for c in calls)
|
||||
|
||||
def test_missing_readme_skips(self, tmp_path: Path) -> None:
|
||||
with patch("subprocess.run"):
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
# Should not raise
|
||||
|
||||
|
||||
class TestMain:
|
||||
@@ -83,7 +152,7 @@ class TestMain:
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -93,7 +162,7 @@ class TestMain:
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -106,7 +175,7 @@ class TestMain:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = runner.invoke(
|
||||
push_badges.main,
|
||||
["--output-dir", str(badges_dir), "--branch", "develop"],
|
||||
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
# Verify fetch was called with the custom branch
|
||||
@@ -120,5 +189,37 @@ class TestMain:
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
||||
result = runner.invoke(push_badges.main, [])
|
||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run"),
|
||||
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
||||
):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code == 0
|
||||
mock_update.assert_called_once()
|
||||
|
||||
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("subprocess.run"),
|
||||
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
||||
):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
||||
assert result.exit_code == 0
|
||||
mock_update.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user