From 8bde4cd12b90f0eedfcf1bfb34c7faf96cb68004 Mon Sep 17 00:00:00 2001 From: emil Date: Mon, 22 Jun 2026 06:15:33 +0000 Subject: [PATCH] GRM-52: fix: badges job runs after release to reflect actual state --- .gitea/workflows/post-merge.yml | 15 +++++++-- AGENTS.md | 3 ++ docs/tech/ci-cd-workflow.md | 15 +++++---- scripts/ci/push_badges.py | 35 ++++++++++++++++----- tests/unit/test_push_badges.py | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 4d0826d..acefe24 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -8,9 +8,13 @@ name: Post-merge # # detect-type ──┬── release (skip if release commit) # ├── sync-wiki (skip if release commit) -# ├── badges (skip if release commit) +# ├── badges (runs after release, even if it fails) # └── vikunja (skip if release commit) # +# The badges job depends on release so it picks up the latest version +# number. It uses `if: always()` to run even if release fails or is +# skipped, ensuring badges always reflect the current repo state. +# # When release.py creates a "release: vX.Y.Z" commit, all jobs skip # because it's a release commit. The tag push triggers publish.yml. @@ -98,15 +102,20 @@ jobs: --commit "${{ github.sha }}" badges: - needs: [detect-type] - if: needs.detect-type.outputs.is-release == 'false' + needs: [detect-type, release] + if: always() && needs.detect-type.outputs.is-release == 'false' runs-on: docker timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: master token: ${{ secrets.REPO_TOKEN }} + - name: Fetch latest master + run: | + git fetch origin master + git reset --hard origin/master - name: Set up environment run: make setup - name: Generate and push badges diff --git a/AGENTS.md b/AGENTS.md index 614c267..d404696 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,9 @@ Vikunja task updates: 3. **sync-wiki** — Syncs documentation to the Gitea wiki. 4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch. + Runs **after** the release job (even if release fails or is skipped) so the + version badge always reflects the latest state. The script fetches the + latest master before generating badges to pick up any release commits. 5. **vikunja** — Marks the corresponding Vikunja task as done. diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 2b30c1b..38bb463 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -233,13 +233,16 @@ are skipped — the tag push triggers the publish workflow instead. The `badges` job in the post-merge workflow runs `scripts/ci/push_badges.py` which: -1. Generates quality badge SVG files via `scripts/generate_badges.py` -2. Creates an orphan `badges` branch -3. Copies SVG files to the branch root -4. Force-pushes the branch to the remote +1. Fetches the latest master and hard-resets to it (picks up release commits) +2. Generates quality badge SVG files via `scripts/generate_badges.py` +3. Creates an orphan `badges` branch +4. Copies SVG files to the branch root +5. Force-pushes the branch to the remote -This replaces the previous inline shell script with a tested Python -equivalent that handles all git operations in a single script. +The badges job depends on the `release` job and uses `if: always()` so it +runs even if release fails or is skipped. This ensures the version badge +always reflects the actual state of the repository after any release +commits have been pushed. ## git-cliff Commit Preprocessing diff --git a/scripts/ci/push_badges.py b/scripts/ci/push_badges.py index fbf5d79..b5e210c 100644 --- a/scripts/ci/push_badges.py +++ b/scripts/ci/push_badges.py @@ -4,6 +4,11 @@ Replaces the inline shell script in the post-merge workflow with a tested Python equivalent. +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 @@ -24,6 +29,18 @@ def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, check=True, text=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]) @@ -35,10 +52,10 @@ def generate_badges(output_dir: str) -> None: def push_to_badges_branch(badges_dir: str) -> None: """Push generated badges to the orphan ``badges`` branch.""" - _run(["git", "config", "user.name", "gitea-actions-bot"]) - _run(["git", "config", "user.email", "actions@oblachno.fyi"]) - _run(["git", "checkout", "--orphan", "badges"]) - _run(["git", "rm", "-rf", "."]) + _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 @@ -46,16 +63,18 @@ def push_to_badges_branch(badges_dir: str) -> None: for svg in Path(badges_dir).glob("*.svg"): shutil.copy2(svg, Path.cwd() / svg.name) - _run(["git", "add", "./*.svg"]) - _run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) - _run(["git", "push", "origin", "badges", "--force"]) + _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") @click.command() @click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.") -def main(output_dir: str) -> None: +@click.option("--branch", default="master", help="Branch to sync before generating badges.") +def main(output_dir: str, branch: str) -> None: """Generate badges and push them to the badges branch.""" + fetch_latest_master(branch) generate_badges(output_dir) push_to_badges_branch(output_dir) diff --git a/tests/unit/test_push_badges.py b/tests/unit/test_push_badges.py index d0b2047..bc08ef0 100644 --- a/tests/unit/test_push_badges.py +++ b/tests/unit/test_push_badges.py @@ -10,6 +10,34 @@ from click.testing import CliRunner import scripts.ci.push_badges as push_badges +class TestFetchLatestMaster: + def test_fetch_and_reset(self) -> None: + with patch("subprocess.run") as mock_run: + push_badges.fetch_latest_master("master") + # Should call git fetch and git reset --hard + calls = [str(c.args[0]) for c in mock_run.call_args_list] + assert any("fetch" in c for c in calls) + assert any("reset" in c for c in calls) + + def test_custom_branch(self) -> None: + with patch("subprocess.run") as mock_run: + push_badges.fetch_latest_master("develop") + calls = [list(c.args[0]) for c in mock_run.call_args_list] + # fetch call should include the branch name + fetch_call = [c for c in calls if "fetch" in c][0] + assert "develop" in fetch_call + # reset call should include origin/develop + reset_call = [c for c in calls if "reset" in c][0] + assert "origin/develop" in reset_call + + def test_fetch_failure_raises(self) -> None: + import subprocess + + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")): + with pytest.raises(subprocess.CalledProcessError): + push_badges.fetch_latest_master() + + class TestGenerateBadges: def test_success(self, tmp_path: Path) -> None: output_dir = tmp_path / ".badges" @@ -67,3 +95,30 @@ class TestMain: with patch("subprocess.run"): result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)]) assert result.exit_code != 0 + + def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + badges_dir = tmp_path / ".badges" + badges_dir.mkdir() + (badges_dir / "badge1.svg").touch() + + runner = CliRunner() + with patch("subprocess.run") as mock_run: + result = runner.invoke( + push_badges.main, + ["--output-dir", str(badges_dir), "--branch", "develop"], + ) + assert result.exit_code == 0 + # Verify fetch was called with the custom branch + calls = [list(c.args[0]) for c in mock_run.call_args_list] + fetch_call = [c for c in calls if "fetch" in c][0] + assert "develop" in fetch_call + + def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + import subprocess + + runner = CliRunner() + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")): + result = runner.invoke(push_badges.main, []) + assert result.exit_code != 0