#!/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/ci/** — CI/CD automation scripts - scripts/*.sh — Shell scripts (setup, molecule runners) - scripts/__init__.py — Package init for scripts - docs/** — Documentation - tests/** — Test files - hooks/** — Git hooks - AGENTS.md — Agent conventions - 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/** — Python CLI source - 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/", "scripts/ci/", "scripts/setup.sh", "scripts/molecule_all.sh", "scripts/__init__.py", # Documentation "docs/", "AGENTS.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.""" 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() @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.") def main(base: str | None, head: str, quiet: bool) -> None: if base is None: base = get_latest_tag() if not base: 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 quiet: click.echo("false") else: click.echo(_("No changes between {base} and {head}.", base=base, head=head)) 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()