GRM-37: refactor: Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
This commit is contained in:
@@ -9,16 +9,17 @@ affect the GRM tool itself (user-facing) or only the CI/CD infrastructure
|
||||
- **CI workflow** — skips molecule tests and release dry-run when only
|
||||
workflow files changed
|
||||
|
||||
Classification rules:
|
||||
Classification strategy (safe-by-default):
|
||||
|
||||
User-facing paths (tool changes → release needed):
|
||||
- src/gitea_runner_manager/** — Python CLI source
|
||||
- ansible/** — Ansible role
|
||||
- pyproject.toml — Package metadata
|
||||
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/CD automation scripts
|
||||
- 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
|
||||
@@ -32,10 +33,19 @@ Classification rules:
|
||||
- .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/classify_changes.py [--base <ref>] [--head <ref>]
|
||||
python3 scripts/classify_changes.py --base v0.3.0 --head HEAD
|
||||
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
|
||||
@@ -47,12 +57,36 @@ import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
# Paths that count as user-facing (tool changes)
|
||||
USER_FACING_PATTERNS = frozenset(
|
||||
# Explicit allowlist of workflow-only path patterns.
|
||||
# Anything NOT matching these is treated as user-facing (safe default).
|
||||
WORKFLOW_ONLY_PATTERNS = frozenset(
|
||||
[
|
||||
"src/gitea_runner_manager/",
|
||||
"ansible/",
|
||||
"pyproject.toml",
|
||||
# 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/",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -80,9 +114,22 @@ def get_changed_files(base: str, head: str) -> list[str]:
|
||||
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)."""
|
||||
return any(file_path.startswith(pattern) or file_path == pattern for pattern in USER_FACING_PATTERNS)
|
||||
"""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]]:
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
Queries the Gitea API for registered runners at three levels:
|
||||
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
|
||||
2. Organization level: GET /orgs/{org}/actions/runners
|
||||
3. Instance (admin) level: GET /admin/actions/runners
|
||||
|
||||
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
|
||||
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
|
||||
|
||||
Outputs:
|
||||
- ``--count``: prints the number of available runners
|
||||
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
|
||||
dynamic matrix in Gitea Actions
|
||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
||||
|
||||
Usage:
|
||||
python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm
|
||||
python3 scripts/ci/discover_runners.py --indices
|
||||
python3 scripts/ci/discover_runners.py --count
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
|
||||
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Query the Gitea API for registered runners at all levels.
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
|
||||
# 1. Repository-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/repos/{owner}/{repo}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/orgs/{owner}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/admin/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
"""
|
||||
# Try API query if we have a token
|
||||
if token:
|
||||
api_count = query_runners(api_url, token, owner, repo)
|
||||
if api_count > 0:
|
||||
return api_count
|
||||
|
||||
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
|
||||
env_count = os.environ.get("MOLECULE_RUNNERS")
|
||||
if env_count:
|
||||
try:
|
||||
count = int(env_count)
|
||||
if count > 0:
|
||||
return count
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fall back to default
|
||||
return DEFAULT_MAX_RUNNERS
|
||||
|
||||
|
||||
def generate_indices(count: int) -> list[int]:
|
||||
"""Generate a list of runner indices [0, 1, ..., count-1]."""
|
||||
return list(range(count))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--owner", default=None, help="Repository owner (for API query).")
|
||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
||||
def main(owner: str | None, repo: str | None, output_count: bool, output_indices: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
|
||||
if repo is None:
|
||||
repo = os.environ.get("GRM_REPO_NAME", "grm")
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
if output_indices:
|
||||
click.echo(json.dumps(indices))
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
Usage:
|
||||
python3 scripts/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
||||
python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,8 +19,9 @@ import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent / "docs"
|
||||
CLI_FILE = Path(__file__).resolve().parent.parent / "src" / "gitea_runner_manager" / "cli.py"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
REQUIRED_MODULES = [
|
||||
@@ -43,6 +44,7 @@ REQUIRED_SCRIPTS = [
|
||||
"notify_failure.py",
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.classify_changes import has_user_facing_changes
|
||||
from scripts.ci.classify_changes import has_user_facing_changes
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -32,7 +32,7 @@ from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent / "docs"
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user