Files
grm/scripts/ci/classify_changes.py
T

298 lines
11 KiB
Python

#!/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 <ref>] [--head <ref>]
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()