#!/usr/bin/env python3 """Classify git changes as user-facing or workflow-only. Determines whether changes between two git refs (e.g., last tag and HEAD) affect the GRM tool itself (user-facing) or only the CI/CD infrastructure (workflow-only). This is used by: - **release.py** — skips release when only workflow files changed - **CI workflow** — skips molecule tests and release dry-run when only workflow files changed Classification strategy (safe-by-default): Any file that is NOT in the explicit workflow-only allowlist is treated as user-facing. This ensures new file types default to requiring a release rather than silently skipping it. Workflow-only paths (infrastructure → no release needed): - .gitea/workflows/** — Gitea Actions workflows - scripts/** — All scripts (CI/CD, dev tools, setup) - src/gitea_runner_manager/__init__.py — Version file (release artifact) - src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI) - docs/** — Documentation - tests/** — Test files - hooks/** — Git hooks - AGENTS.md — Agent conventions - REVIEW_CHECKLIST.md — Review checklist - README.md — README (lean, links to wiki) - CHANGELOG.md — Changelog (generated) - TROUBLESHOOTING.md — Troubleshooting guide - cliff.toml — git-cliff config - Makefile — Build automation - .pre-commit-config.yaml — Pre-commit config - .ansible-lint — Ansible lint config - .env.example — Environment template - .gitignore — Git ignore rules - .ruff.toml — Ruff config (if separate) - .github/** — GitHub config (if present) Everything else is user-facing (tool changes → release needed), including but not limited to: - src/gitea_runner_manager/*.py — Python CLI source (except __init__.py) - ansible/** — Ansible role - pyproject.toml — Package metadata - Any new file type not in the allowlist Usage: python3 scripts/ci/classify_changes.py [--base ] [--head ] python3 scripts/ci/classify_changes.py --base v0.3.0 --head HEAD """ from __future__ import annotations import subprocess # nosec B404 import sys import click from gitea_runner_manager.i18n import _ # Explicit allowlist of workflow-only path patterns. # Anything NOT matching these is treated as user-facing (safe default). WORKFLOW_ONLY_PATTERNS = frozenset( [ # CI/CD infrastructure ".gitea/", # All scripts are infrastructure (CI/CD, dev tools, setup) # User-facing code lives in src/gitea_runner_manager/ "scripts/", # Version file — only contains __version__, not user-facing code. # Version bumps are a release artifact, not a feature. "src/gitea_runner_manager/__init__.py", # Gitea API client — used only by CI/CD scripts, not by the GRM CLI. "src/gitea_runner_manager/api_clients.py", # Documentation "docs/", "AGENTS.md", "REVIEW_CHECKLIST.md", "README.md", "CHANGELOG.md", "TROUBLESHOOTING.md", # Tests "tests/", # Config / build automation "cliff.toml", "Makefile", ".pre-commit-config.yaml", ".ansible-lint", ".env.example", ".gitignore", ".ruff.toml", # Hooks "hooks/", # GitHub (if ever added) ".github/", ] ) def run_git(args: list[str]) -> str: """Run a git command and return stdout.""" result = subprocess.run( # nosec B603 args, capture_output=True, text=True, check=False, ) if result.returncode != 0: raise click.ClickException( _("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip()) ) return result.stdout.strip() def get_changed_files(base: str, head: str) -> list[str]: """Get list of files changed between base and head refs.""" output = run_git(["git", "diff", "--name-only", base, head]) if not output: return [] return output.split("\n") def is_workflow_only(file_path: str) -> bool: """Check if a file path is workflow-only (infrastructure, not the tool itself). Uses an explicit allowlist — anything not in the list is treated as user-facing (safe default that prevents accidental release skips). """ return any(file_path.startswith(pattern) or file_path == pattern for pattern in WORKFLOW_ONLY_PATTERNS) def is_user_facing(file_path: str) -> bool: """Check if a file path is user-facing (affects the GRM tool). Inverse of is_workflow_only — anything not explicitly workflow-only is treated as user-facing. """ return not is_workflow_only(file_path) def classify_changes(files: list[str]) -> dict[str, list[str]]: """Classify changed files into user-facing and workflow-only. Returns a dict with keys "user_facing" and "workflow_only". """ user_facing: list[str] = [] workflow_only: list[str] = [] for f in files: if is_user_facing(f): user_facing.append(f) else: workflow_only.append(f) return {"user_facing": user_facing, "workflow_only": workflow_only} def has_user_facing_changes(base: str, head: str) -> bool: """Check if any user-facing files changed between base and head. Imported by ``scripts/ci/release.py`` to decide whether a release is needed. This is a cross-CI import that requires ``PYTHONPATH=.``. """ files = get_changed_files(base, head) return any(is_user_facing(f) for f in files) def get_latest_tag() -> str: """Get the latest git tag, or empty string if none exists.""" result = subprocess.run( # nosec B603 B607 ["git", "describe", "--tags", "--abbrev=0"], capture_output=True, text=True, check=False, ) if result.returncode != 0: return "" return result.stdout.strip() def _write_github_output(key: str, value: str) -> None: """Append a key=value line to the $GITHUB_OUTPUT file.""" import os gh_output = os.environ.get("GITHUB_OUTPUT") if not gh_output: raise click.ClickException("GITHUB_OUTPUT environment variable is not set") with open(gh_output, "a") as f: # noqa: PTH123 f.write(f"{key}={value}\n") @click.command() @click.option("--base", default=None, help="Base ref (default: latest tag).") @click.option("--head", default="HEAD", help="Head ref (default: HEAD).") @click.option("--quiet", is_flag=True, default=False, help="Only output true/false.") @click.option( "--check", type=click.Choice(["all", "ansible", "user-facing"]), default="all", help="Check specific category: all (default), ansible, or user-facing.", ) @click.option( "--github-output", "github_output", is_flag=True, default=False, help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", ) def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None: if base is None: base = get_latest_tag() if not base: if github_output: _write_github_output("ansible-changed", "true") _write_github_output("user-facing-changed", "true") click.echo("No tags found — treating all changes as user-facing.") return if quiet: click.echo("true") else: click.echo(_("No tags found — treating all changes as user-facing.")) return files = get_changed_files(base, head) if not files: if github_output: _write_github_output("ansible-changed", "false") _write_github_output("user-facing-changed", "false") click.echo(f"No changes between {base} and {head}.") return if quiet: click.echo("false") else: click.echo(_("No changes between {base} and {head}.", base=base, head=head)) return if github_output: ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"] user_files = [f for f in files if is_user_facing(f)] _write_github_output("ansible-changed", "true" if ansible_files else "false") _write_github_output("user-facing-changed", "true" if user_files else "false") click.echo(f"Ansible files changed: {bool(ansible_files)}") click.echo(f"User-facing files changed: {bool(user_files)}") return if check == "ansible": # Check only for Ansible-related file changes ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"] has_ansible = bool(ansible_files) if quiet: click.echo("true" if has_ansible else "false") return click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files))) for f in ansible_files: click.echo(f" {f}") click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes")) return if check == "user-facing": # Check only for user-facing file changes (inverse of workflow-only) user_files = [f for f in files if is_user_facing(f)] has_user = bool(user_files) if quiet: click.echo("true" if has_user else "false") return click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files))) for f in user_files: click.echo(f" {f}") click.echo( _("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes") ) return result = classify_changes(files) has_user = bool(result["user_facing"]) if quiet: click.echo("true" if has_user else "false") return click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files))) click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"]))) for f in result["user_facing"]: click.echo(f" {f}") click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"]))) for f in result["workflow_only"]: click.echo(f" {f}") if has_user: status = "USER-FACING changes detected — release needed" else: status = "Workflow-only changes — no release needed" click.echo(_("\nResult: {status}", status=status)) if not has_user: sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release) if __name__ == "__main__": # pragma: no cover main()