217 lines
7.3 KiB
Python
217 lines
7.3 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/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 <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/",
|
|
"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()
|