GRM-51: refactor: convert shell scripts and inline workflow scripts to Python

This commit is contained in:
2026-06-22 05:53:31 +00:00
parent bec7b59671
commit b6e87a519b
28 changed files with 1582 additions and 219 deletions
+38 -1
View File
@@ -172,6 +172,17 @@ def get_latest_tag() -> str:
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).")
@@ -182,10 +193,22 @@ def get_latest_tag() -> str:
default="all",
help="Check specific category: all (default), ansible, or user-facing.",
)
def main(base: str | None, head: str, quiet: bool, check: str) -> None:
@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:
@@ -194,12 +217,26 @@ def main(base: str | None, head: str, quiet: bool, check: str) -> None:
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"]
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
Usage::
python3 scripts/ci/detect_release_commit.py
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import click
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
def get_commit_message() -> str:
"""Get the subject of the latest git commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%s"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def is_release_commit(message: str) -> bool:
"""Check if a commit message matches the release commit format."""
return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
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()
def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
msg = get_commit_message()
click.echo(f"Commit message: {msg}")
is_release = is_release_commit(msg)
write_github_output("is-release", "true" if is_release else "false")
if is_release:
click.echo("Release commit — skipping all post-merge jobs.")
else:
click.echo("Regular merge commit — running all post-merge jobs.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+25 -1
View File
@@ -128,7 +128,20 @@ def generate_indices(count: int) -> list[str]:
@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:
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(
owner: str | None,
repo: str | None,
output_count: bool,
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if owner is None:
@@ -139,6 +152,17 @@ def main(owner: str | None, repo: str | None, output_count: bool, output_indices
count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count)
if github_output:
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"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
return
if output_count:
click.echo(str(count))
return
+49 -2
View File
@@ -100,6 +100,17 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_ENV file."""
import os
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option(
"--runner-index",
@@ -127,7 +138,27 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
is_flag=True,
help="List all supported platforms, one per line.",
)
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
@click.option(
"--github-env",
"github_env",
is_flag=True,
default=False,
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def cli(
runner_index: int | None,
max_runners: int,
list_all: bool,
list_platforms: bool,
github_env: bool,
skip_if_excess: bool,
) -> None:
scenarios = discover_scenarios()
if list_all:
for s in scenarios:
@@ -144,10 +175,26 @@ def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platfor
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("TEST_PAIRS", "")
_write_github_env("SKIP", "true")
return
# Convert 1-based CLI index to 0-based internal index
zero_based = runner_index - 1
assigned = pairs_for_runner(pairs, zero_based, max_runners)
click.echo(" ".join(p.encode() for p in assigned))
encoded = " ".join(p.encode() for p in assigned)
if github_env:
_write_github_env("TEST_PAIRS", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned pairs: {encoded}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
+2
View File
@@ -45,6 +45,8 @@ REQUIRED_SCRIPTS = [
"post_merge.py",
"classify_changes.py",
"discover_runners.py",
"detect_release_commit.py",
"push_badges.py",
]
+36 -2
View File
@@ -7,6 +7,7 @@ Usage:
import os
import re
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -19,6 +20,32 @@ from gitea_runner_manager.i18n import _
load_dotenv(override=True)
def _get_git_commit_message() -> str:
"""Get the full commit message of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%B"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def _get_git_commit_sha() -> str:
"""Get the SHA of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
return result.stdout.strip()
def extract_task_id(commit_msg: str) -> str:
"""Extract GRM-N task identifier from the first line of commit message."""
first_line = commit_msg.split("\n")[0]
@@ -69,9 +96,16 @@ def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
@click.command()
@click.argument("commit_msg")
@click.argument("commit_msg", required=False)
@click.option("--commit-sha", default="", help="Commit SHA")
def main(commit_msg: str, commit_sha: str) -> None:
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
def main(commit_msg: str | None, commit_sha: str, from_git: bool) -> None:
if from_git:
commit_msg = _get_git_commit_message()
if not commit_sha:
commit_sha = _get_git_commit_sha()
if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git)")
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Generate badge SVG files and push them to the ``badges`` branch.
Replaces the inline shell script in the post-merge workflow with a
tested Python equivalent.
Usage::
python3 scripts/ci/push_badges.py
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Any
import click
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command and return the result."""
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
def generate_badges(output_dir: str) -> None:
"""Generate badge SVG files using generate_badges.py."""
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg"))
if not badges:
raise click.ClickException("No badge SVG files generated")
click.echo(f"Generated {len(badges)} badge files")
def push_to_badges_branch(badges_dir: str) -> None:
"""Push generated badges to the orphan ``badges`` branch."""
_run(["git", "config", "user.name", "gitea-actions-bot"])
_run(["git", "config", "user.email", "actions@oblachno.fyi"])
_run(["git", "checkout", "--orphan", "badges"])
_run(["git", "rm", "-rf", "."])
# Copy badge files to root
import shutil
for svg in Path(badges_dir).glob("*.svg"):
shutil.copy2(svg, Path.cwd() / svg.name)
_run(["git", "add", "./*.svg"])
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"])
_run(["git", "push", "origin", "badges", "--force"])
click.echo("Badges pushed to badges branch")
@click.command()
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
def main(output_dir: str) -> None:
"""Generate badges and push them to the badges branch."""
generate_badges(output_dir)
push_to_badges_branch(output_dir)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Install CI/CD development tools that are not Python packages.
Handles installation of:
- actionlint (workflow YAML linter)
- git-cliff (changelog generator)
- act_runner (Gitea Actions local runner, optional)
Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available.
Usage::
python3 scripts/install_tools.py # install all
python3 scripts/install_tools.py --tool actionlint # install one
python3 scripts/install_tools.py --list # list status
"""
from __future__ import annotations
import os
import platform
import shutil
import tarfile
import tempfile
import urllib.request
from pathlib import Path
import click
TARGET_DIR = Path.home() / ".local" / "bin"
ACTIONLINT_VERSION = "1.7.12"
GIT_CLIFF_VERSION = "2.13.0"
ACT_RUNNER_VERSION = "0.2.11"
def _arch() -> str:
"""Return the architecture string used by release assets."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _ensure_target_dir() -> Path:
"""Ensure the target directory exists and return it."""
TARGET_DIR.mkdir(parents=True, exist_ok=True)
return TARGET_DIR
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest``."""
urllib.request.urlretrieve(url, dest) # nosec B310
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "archive.tar.gz"
_download(url, tarball)
with tarfile.open(tarball, "r:gz") as tar:
tar.extractall(tmpdir) # nosec B202
# Find the binary in the extracted tree
extracted = Path(tmpdir).rglob(binary_name)
found = next(extracted, None)
if found is None:
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
shutil.copy2(found, dest)
dest.chmod(0o755)
return dest
def _download_binary(url: str, binary_name: str) -> Path:
"""Download a standalone binary and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
_download(url, dest)
dest.chmod(0o755)
return dest
def _is_installed(name: str) -> bool:
"""Check if a tool is already on PATH or in TARGET_DIR."""
if shutil.which(name) is not None:
return True
return (TARGET_DIR / name).exists()
def install_actionlint() -> bool:
"""Install actionlint if not already present. Returns True if installed/skipped."""
if _is_installed("actionlint"):
click.echo("actionlint: already installed")
return True
arch = _arch()
url = (
f"https://github.com/rhysd/actionlint/releases/download/"
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "actionlint")
click.echo(f"actionlint: installed to {dest}")
return True
def install_git_cliff() -> bool:
"""Install git-cliff if not already present. Returns True if installed/skipped."""
if _is_installed("git-cliff"):
click.echo("git-cliff: already installed")
return True
arch = _arch()
url = (
f"https://github.com/orhun/git-cliff/releases/download/"
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
)
dest = _download_and_extract_tarball(url, "git-cliff")
click.echo(f"git-cliff: installed to {dest}")
return True
def install_act_runner() -> bool:
"""Install act_runner if not already present. Returns True if installed/skipped."""
if _is_installed("act_runner"):
click.echo("act_runner: already installed")
return True
arch = _arch()
url = (
f"https://gitea.com/gitea/act_runner/releases/download/"
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
)
dest = _download_binary(url, "act_runner")
click.echo(f"act_runner: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner"]
def _install_tool(name: str) -> bool:
"""Install a single tool by name."""
if name == "actionlint":
return install_actionlint()
if name == "git-cliff":
return install_git_cliff()
if name == "act_runner":
return install_act_runner()
raise click.ClickException(f"Unknown tool: {name}")
def list_tools() -> None:
"""Print the installation status of all tools."""
for name in TOOL_NAMES:
status = "installed" if _is_installed(name) else "not installed"
click.echo(f" {name}: {status}")
@click.command()
@click.option("--tool", type=click.Choice(TOOL_NAMES), help="Install a specific tool.")
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
def main(tool: str | None, list_status: bool) -> None:
"""Install CI/CD development tools to ~/.local/bin."""
if list_status:
list_tools()
return
tools_to_install = [tool] if tool else TOOL_NAMES
failed: list[str] = []
for name in tools_to_install:
try:
_install_tool(name)
except Exception as exc:
click.echo(f" {name}: FAILED — {exc}", err=True)
failed.append(name)
if failed:
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
# Remind user to add ~/.local/bin to PATH if not already there
path_env = os.environ.get("PATH", "")
if str(TARGET_DIR) not in path_env:
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Run all molecule scenarios on all supported OS platforms.
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
Sequential execution CI uses the parallel matrix instead.
Usage::
python3 scripts/molecule_all.py
python3 scripts/molecule_all.py --bin .venv/bin
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from scripts.ci.distribute_molecule import PLATFORMS
ROLE_DIR = Path("ansible/roles/gitea-runner")
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
"""Run a single molecule scenario. Returns the exit code."""
cmd = [molecule_bin, "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
click.echo(f"--- Scenario: {scenario} ---")
result = subprocess.run( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
)
return result.returncode
def _run_platform(
molecule_bin: str,
platform: dict[str, str],
role_dir: Path,
scenarios: list[str],
base_env: dict[str, str],
) -> int:
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
env = dict(base_env)
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
if platform.get("command"):
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
else:
env.pop("MOLECULE_PLATFORM_COMMAND", None)
click.echo(f"=== Platform: {platform['name']} ===")
for scenario in scenarios:
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
if rc != 0:
return rc
return 0
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
"""Run all molecule scenarios on all supported OS platforms sequentially."""
molecule_bin = str(Path(bin_dir) / "molecule")
if not Path(molecule_bin).exists():
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
if not ROLE_DIR.exists():
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
base_env = dict(os.environ)
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
for platform in PLATFORMS:
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
if rc != 0:
click.echo(f"FAILED on platform {platform['name']}", err=True)
sys.exit(rc)
click.echo("All molecule scenarios passed on all platforms.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Run all molecule scenarios on all supported OS platforms.
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
# Platform list is sourced from scripts/distribute_molecule.py to avoid duplication.
set -euo pipefail
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
# Read platforms from distribute_molecule.py (single source of truth)
PLATFORMS_OUTPUT="$("$MOLECULE_BIN" python "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms 2>/dev/null || \
python3 "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms)"
for p in $PLATFORMS_OUTPUT; do
IFS="|" read -r name image command <<< "$p"
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
if [ -n "$command" ]; then
export MOLECULE_PLATFORM_COMMAND="$command"
else
unset MOLECULE_PLATFORM_COMMAND
fi
echo "=== Platform: $name ==="
for s in default multi-instance lifecycle template-content deregister update; do
echo "--- Scenario: $s on $name ---"
(
cd "$ROLE_DIR"
if [ "$s" = "default" ]; then
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
else
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
fi
)
done
done
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
Replaces the previous ``scripts/setup.sh`` with a tested Python equivalent.
Usage::
python3 scripts/setup.py --bin .venv/bin
"""
from __future__ import annotations
import subprocess # nosec B404
from pathlib import Path
import click
def _run(cmd: list[str], bin_dir: str) -> None:
"""Run a command, streaming output to stdout/stderr."""
click.echo(f" $ {' '.join(cmd)}")
subprocess.run(cmd, check=True) # nosec B603
def _install_python_deps(bin_dir: str) -> None:
"""Install the project with dev extras in editable mode."""
pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", ".[dev]"], bin_dir)
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
def _install_pre_commit_hooks(bin_dir: str) -> None:
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
pre_commit = str(Path(bin_dir) / "pre-commit")
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
def _verify(bin_dir: str) -> None:
"""Print versions of installed tools for verification."""
grm = str(Path(bin_dir) / "grm")
pre_commit = str(Path(bin_dir) / "pre-commit")
for tool in [grm, pre_commit]:
try:
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
if result.returncode == 0:
click.echo(f" {result.stdout.strip()}")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
"""Install Python deps, Ansible collections, and pre-commit hooks."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo("Installing Python dependencies...")
_install_python_deps(bin_dir)
click.echo("Installing Ansible collections...")
_install_ansible_collections(bin_dir)
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
click.echo("")
click.echo("Setup complete.")
click.echo("Activate the virtual environment with one of:")
click.echo(" source .venv/bin/activate (generic)")
click.echo(" source activate.sh (bash)")
click.echo(" source activate.fish (fish)")
click.echo(" source activate.zsh (zsh)")
click.echo("")
_verify(bin_dir)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -e
BIN="${1:-.venv/bin}"
"$BIN/pip" install -e ".[dev]"
"$BIN/ansible-galaxy" collection install -r ansible/requirements.yml
"$BIN/pre-commit" install
"$BIN/pre-commit" install --hook-type commit-msg
"$BIN/pre-commit" install --hook-type pre-push
echo ""
echo "Setup complete."
echo "Activate the virtual environment with one of:"
echo " source .venv/bin/activate (generic)"
echo " source activate.sh (bash)"
echo " source activate.fish (fish)"
echo " source activate.zsh (zsh)"
# Verification
"$BIN/grm" --version 2>/dev/null || true
"$BIN/pre-commit" --version 2>/dev/null || true