GRM-36: feat: implement documentation-as-code with wiki sync and doc-coverage

Add /docs/ directory with user and technical documentation extracted from README, AGENTS.md, and source code. Add scripts/sync_wiki.py to sync docs to Gitea wiki via API. Add scripts/doc_coverage.py to check CLI commands, modules, and CI scripts are documented. Add sync-wiki.yml workflow for auto-sync on merge and release. Slim down README.md to lean entry point. 28 new unit tests, 100% coverage maintained.

Closes GRM-36
This commit is contained in:
2026-06-21 19:45:34 +00:00
parent 7fe85423b4
commit 5b05db4e6d
21 changed files with 1899 additions and 415 deletions
+28
View File
@@ -0,0 +1,28 @@
# GRM — Gitea Runner Manager
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
## User Documentation
- [Getting Started](Getting-Started) — Installation, quick start, first run
- [Installation](Installation) — Prerequisites, setup, multiple instances
- [CLI Commands](CLI-Commands) — All commands with arguments and options
- [Troubleshooting](Troubleshooting) — Common issues and solutions
- [FAQ](FAQ) — Frequently asked questions
## Technical Documentation
- [Architecture](Architecture) — High-level design, component interactions, data flow
- [Development Setup](Development-Setup) — Environment setup, dependencies, local testing
- [CI/CD Workflow](CI-CD-Workflow) — How CI works, release process, branch protection
- [Testing Strategy](Testing-Strategy) — Unit, integration, and Molecule tests
- [Decision Log](Decision-Log) — Key technical decisions and rationale
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit rules
## Quick Links
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
- [CI/CD Pipeline](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
- [Changelog](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/CHANGELOG.md)
+14
View File
@@ -0,0 +1,14 @@
{
"index.md": "Home",
"user/getting-started.md": "Getting-Started",
"user/installation.md": "Installation",
"user/cli-commands.md": "CLI-Commands",
"user/troubleshooting.md": "Troubleshooting",
"user/faq.md": "FAQ",
"tech/architecture.md": "Architecture",
"tech/development-setup.md": "Development-Setup",
"tech/ci-cd-workflow.md": "CI-CD-Workflow",
"tech/testing-strategy.md": "Testing-Strategy",
"tech/decision-log.md": "Decision-Log",
"tech/contributing.md": "Contributing-Guide"
}
+97
View File
@@ -0,0 +1,97 @@
# Architecture
GRM consists of two layers:
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
## Component Tree
```
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service)
├── prune.yml (Docker prune timer)
└── integration_test.yml (validate service is active)
```
The Ansible role task execution order (from `AGENTS.md`):
```
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
```
- `install_runner.yml` handles: download, config, validate, register, service
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
## Per-Runner Isolation
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
- **Home**: `/home/grm-<name>/`
- **Data**: `/var/lib/gitea-runner/<name>/`
- **Config**: `/etc/gitea-runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
## Component Interactions
```mermaid
flowchart TD
CLI["Python CLI<br/>src/gitea_runner_manager/<br/>(Click)"]
RM["RunnerManager<br/>runner_manager.py"]
EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
ANS["ansible-playbook subprocess"]
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
USER["user_setup.yml<br/>create system user + lingering"]
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
INSTALL["install_runner.yml<br/>download, config, register, service"]
PRUNE["prune.yml<br/>Docker prune timer"]
TEST["integration_test.yml<br/>validate service active"]
GITEA["Gitea instance<br/>registration + API"]
SYSTEMD["systemd user service<br/>gitea-runner.service"]
CLI --> RM
RM --> REG
RM --> EXEC
EXEC -->|subprocess| ANS
ANS --> ROLE
ROLE --> USER
ROLE --> DOCKER
ROLE --> INSTALL
ROLE --> PRUNE
ROLE --> TEST
INSTALL -->|register| GITEA
INSTALL --> SYSTEMD
DOCKER --> SYSTEMD
```
## Additional Components
From `AGENTS.md`, the project also includes:
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
## Python Modules
The Python CLI layer (`src/gitea_runner_manager/`) consists of the following modules:
| Module | Description |
|--------|-------------|
| `cli.py` | Click-based CLI entry point — defines all commands (install, update, start, stop, enable, disable, status, remove, list) |
| `runner_manager.py` | Ansible orchestration + registry integration — delegates to executor and manages runner lifecycle |
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files |
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata |
| `i18n.py` | Internationalization translations (en, bg, de, ru, zh) |
| `exceptions.py` | Custom exceptions (`GRMError`, `APIError`) |
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts |
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
+231
View File
@@ -0,0 +1,231 @@
# CI/CD Workflow
Every change to master goes through a mandatory PR workflow. No exceptions.
## PR Workflow
### 1. Create Vikunja Task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
### 2. Create Branch
```bash
git checkout master && git pull
git checkout -b GRM-N-short-description
```
### 3. Implement Changes
- Write code following conventions
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
### 4. Commit (Conventional Commits)
Branch commits use conventional commit format (no `GRM-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### 5. Push and Create PR
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
- PR body: summary of changes, `Closes GRM-N`
- Add `ready-to-merge` label **only after review is complete**
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
Review the full diff (`git diff master...HEAD`) focusing on:
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
- **Technical excellence**:
- Architecture compliance and evolution
- Single Responsibility Principle (SRP)
- Deduplication (no copy-paste, single source of truth)
- Code smells detection and removal
- Best industry practices
- Industry-grade code quality
- Reusability
- Clean code
- Readability
- Maintainability
- Extensibility
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
- **Security**: No secrets in logs/process list, input validation, no injection vectors
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `scripts/review_pr.py`:
```bash
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
```
### 7. Address Review Comments
Fix each comment one by one, commit, and push. Re-review until satisfied.
### 8. Approve and Merge
Once all comments are addressed:
```bash
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
--event APPROVE \
--body "All comments addressed. LGTM."
```
Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format and match against Vikunja task title
2. **Check** that at least one APPROVE review exists
3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
### 9. Post-Merge Automation
After the squash-merge:
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
## Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality + molecule tests must pass
- **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
## CI Path Filtering
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
The `detect-changes` job:
- For pull requests: compares `origin/master` against the PR head SHA
- For pushes to master: compares `HEAD~1` against `HEAD`
- Outputs `ansible-changed` as `true` or `false`
The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`.
CI triggers only on `opened` and `synchronize` PR events (not `labeled`).
## CI Quality Job
The `quality` job in `.gitea/workflows/ci.yml` runs:
1. `make setup` — full environment setup
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
3. `make pytest-cov` — unit tests with 100% coverage enforcement
4. `python3 scripts/check_test_speed.py --max-seconds 10` — verify unit tests run fast
5. `PYTHONPATH=src python3 scripts/release.py --dry-run` — release dry-run validation
## Automated Release Pipeline
After a PR is merged to master, the release pipeline runs automatically.
### Release Workflow (`.gitea/workflows/release.yml`)
- Triggers on push to `master`
- Sets up full dev environment (`make setup`) so lint and tests can run
- Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot`
- Runs `scripts/release.py` which uses **git-cliff** to:
- Calculate the next semver version from conventional commits since the last tag
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **abort immediately** — no commit, no tag
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
- Create an annotated tag `vX.Y.Z` on the release commit
- Push both the commit and tag to master
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
### Publish Workflow (`.gitea/workflows/publish.yml`)
- Triggers on tag push (`v*`)
- Installs git-cliff (version 2.13.0)
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
- Validates `PYPI_TOKEN` is set (warns if missing)
- Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Creates a Gitea release with git-cliff-generated release notes
- Uses `scripts/publish.py` for build and publish orchestration
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
- Triggers on `pull_request` labeled events
- Runs `scripts/auto_merge.py` with the branch name, PR title, repository, PR number, and label name
- Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master`
- Runs `scripts/post_merge.py` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done
## git-cliff Commit Preprocessing
Merge commits on master have the format `GRM-N <conventional commit>`. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing:
```toml
commit_preprocessors = [
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
{ pattern = "^GRM-\\d+\\s+", replace = "" },
]
```
This ensures all merged work appears in the changelog.
### git-cliff Configuration Highlights (`cliff.toml`)
- `conventional_commits = true` — parse conventional commit format
- `filter_unconventional = true` — skip non-conventional commits
- `render_always = true` — always render the changelog
- `trim = true` — trim whitespace
- Commit parsers group commits into: Features, Bug Fixes, Documentation, Performance, Refactor, Styling, Testing, Miscellaneous Tasks, Security, Revert, Other
- `chore(release): prepare for`, `chore(deps.*)`, `chore(pr)`, `chore(pull)` commits are skipped
- `sort_commits = "oldest"` — oldest commits first
## Version Bumping Rules (git-cliff)
| Commit type | Version bump |
|-------------|-------------|
| `feat:` | minor (0.X.0) |
| `fix:` | patch (0.0.X) |
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
From `cliff.toml` `[bump]` section:
- `features_always_bump_minor = true`
- `breaking_always_bump_major = false`
- `initial_tag = "0.1.0"`
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
## Title Format Summary
| What | Format | Example |
|------|--------|---------|
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
| Branch commits | `<conventional commit>` | `feat: add review script` |
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
+100
View File
@@ -0,0 +1,100 @@
# Contributing Guide
## Key Conventions
- Python 3.12+ required (ruff/pyright target `py312`)
- 100% test coverage required (`--cov-fail-under=100`)
- Conventional commits on feature branches (no `GRM-N:` prefix)
- Branch names must include `GRM-N` task ID
- Line length: 120 chars
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
## Code Style Rules
- **Python version**: 3.12+ (ruff and pyright target `py312`)
- **Line length**: 120 characters
- **Test coverage**: 100% required (`--cov-fail-under=100`)
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake
## Commit Rules
Branch commits use conventional commit format (no `GRM-N:` prefix):
```
feat: add new feature
fix: resolve bug
docs: update README
```
### Version Bumping Rules
| Commit type | Version bump |
|-------------|-------------|
| `feat:` | minor (0.X.0) |
| `fix:` | patch (0.0.X) |
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
## Branch Naming
| What | Format | Example |
|------|--------|---------|
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
| Branch commits | `<conventional commit>` | `feat: add review script` |
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
## PR Workflow Summary
Every change to master goes through this workflow. No exceptions.
1. **Create Vikunja task** — get a `GRM-N` identifier (Vikunja project 6)
2. **Create branch**`GRM-N-short-description`
3. **Implement** — write code, tests (100% coverage), update docs
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
### Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality + molecule tests must pass
- **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
```
## Ansible Role Conventions
```
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
```
- `install_runner.yml` handles: download, config, validate, register, service
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
+75
View File
@@ -0,0 +1,75 @@
# Decision Log
Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `AGENTS.md`.
---
## ADR-001: Dynamic Versioning via `__init__.py`
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`scripts/release.py`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
---
## ADR-002: Rootless Docker per Runner
**Date:** Project inception (documented in README Architecture)
**Decision:** Each runner instance runs in an isolated rootless Docker environment under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host.
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
---
## ADR-003: Conventional Commits + git-cliff for Automated Versioning
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
**Rationale:** `scripts/release.py` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
---
## ADR-004: Enforce Tests Pass Before Tagging a Release
**Date:** 2026-06-21 (v0.2.2)
**Decision:** The release workflow runs `make lint-ruff` and `make pytest-cov` before creating a release commit or tag. If lint or tests fail, the release aborts immediately — no commit, no tag.
**Rationale:** This ensures every tagged release is healthy. A `--skip-tests` flag exists for emergency use only but is not recommended. This decision was made as a bug fix after identifying that releases could be tagged without verifying test health. Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits.
**Source:** `CHANGELOG.md` (0.2.2 — Bug Fixes: "Enforce tests pass before tagging a release"), `AGENTS.md` (Automated Release Pipeline)
---
## ADR-005: Branch Protection + Auto-Merge Workflow
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`scripts/auto_merge.py`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
---
## ADR-006: Path-Based CI Filtering for Molecule Tests
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** The CI workflow includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped.
**Rationale:** This prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. Molecule tests are only relevant when Ansible files change. The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`. CI triggers only on `opened` and `synchronize` PR events (not `labeled`) to avoid redundant runs.
**Source:** `AGENTS.md` (CI Path Filtering), `.gitea/workflows/ci.yml` (detect-changes job)
+108
View File
@@ -0,0 +1,108 @@
# Development Setup
## Project Structure
```
.
├── src/gitea_runner_manager/ # Python CLI source
│ ├── cli.py # Click commands
│ ├── runner_manager.py # Ansible orchestration + registry integration
│ ├── executor.py # Ansible subprocess execution
│ ├── registry.py # Local JSON runner registry
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
│ └── exceptions.py # Custom exceptions
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files
│ │ ├── templates/ # Jinja2 templates
│ │ └── molecule/ # Test scenarios
│ ├── install-runner.yml # Install playbook
│ ├── update-runner.yml # Update playbook
│ ├── start-runner.yml # Start playbook
│ ├── stop-runner.yml # Stop playbook
│ ├── enable-runner.yml # Enable playbook
│ ├── disable-runner.yml # Disable playbook
│ ├── status-runner.yml # Status playbook
│ └── remove-runner.yml # Remove playbook
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── Makefile # Build & test automation
└── pyproject.toml # Python project metadata
```
## Setup Development Environment
```bash
make setup # Creates venv, installs deps, sets up hooks
source .venv/bin/activate
```
The `make setup` target (from the `Makefile`):
- Verifies Python 3.12+ is installed
- Creates a virtualenv in `.venv`
- Installs/updates `pip`, `setuptools`, and `wheel`
- Creates `.env` from `.env.example` if not present
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
- Installs `checkmake` via `scripts/install_checkmake.py`
- Runs `scripts/setup.sh` to install dependencies and hooks
### Developer Quick Start
```bash
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
cd gitea-runner-manager
pyenv install 3.12
pyenv local 3.12
make setup
```
### Configure Gitea Credentials
```bash
cp .env.example .env
# Edit .env:
# GITEA_URL=https://git.example.com
# GITEA_REGISTRATION_TOKEN=your-registration-token
```
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
#### Admin API Token (optional)
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
2. **Systemd user service** is active (proves daemon is polling for jobs)
API checks, if enabled, are purely informational and do not affect pass/fail.
## Running Linters
```bash
make lint # Python (ruff + pyright + bandit)
make lint-bandit # Security scan only
make ansible-lint # Ansible
make makefile-lint # Makefile
```
The full lint target (`make lint-all`) runs all of the above:
```bash
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
```
Individual lint targets from the `Makefile`:
| Target | Description |
|--------|-------------|
| `lint-ruff` | `ruff check src/ tests/` |
| `lint-format` | `ruff format --check src/ tests/` |
| `typecheck` | `pyright` |
| `lint-bandit` | `bandit -r src/ scripts/` |
| `ansible-lint` | `ansible-lint ansible/` |
| `makefile-lint` | `checkmake Makefile` |
| `lint` | ruff + format check + pyright + bandit |
| `lint-all` | lint + ansible-lint + makefile-lint |
+87
View File
@@ -0,0 +1,87 @@
# Testing Strategy
## Unit Tests
```bash
make test-unit
```
Runs pytest with 100% coverage requirement.
From the `Makefile`:
- `test-unit``pytest tests/unit/ -v --no-cov` (unit tests without coverage)
- `pytest-cov``pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required.
## Molecule Tests
```bash
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
Runs six scenarios:
- **default** — Rootless Docker runner installation
- **multi-instance** — Two isolated runner instances on the same host
- **lifecycle** — Stop, disable, re-enable, and start sequence
- **template-content** — Verify rendered systemd user service and prune templates
- **deregister** — Runner deregistration
- **update** — Runner binary update
All scenarios test idempotence (second run produces zero changes).
### Platforms
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
The platform list is defined in `scripts/distribute_molecule.py` (single source of truth).
### CI Test Distribution
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `scripts/distribute_molecule.py --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `scripts/molecule_ci_guard.py` with those pairs.
## Integration Tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
From the `Makefile`:
- `test-integration``pytest tests/integration/ -v --no-cov`
## Full Test Suite
```bash
make test-all # Runs unit tests + linters + molecule
```
From the `Makefile`:
- `test-all``pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
## Build & Test Commands Summary
From `AGENTS.md`:
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make molecule # All 6 scenarios on Ubuntu 22.04
make molecule-all # All 6 scenarios on all 4 supported OSes
make test-all # pytest-cov + molecule
```
## Known Issues
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
+233
View File
@@ -0,0 +1,233 @@
# CLI Commands
GRM provides the following CLI commands for managing Gitea Actions runners. The base command is `grm`.
## install
Install and configure a Gitea Runner on a remote host.
```bash
grm install <host> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `host` | Remote host (IP address or hostname) |
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
| `--name` | `-n` | hostname | Gitea Runner name |
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
| `--url` | — | `GITEA_URL` env | Gitea URL |
| `--admin-token` | `-a` | `REPO_TOKEN` env | Gitea admin API token for integration test |
| `--integration-retries` | `-r` | `3` (`GITEA_INTEGRATION_RETRIES` env) | Integration test API retries |
| `--labels` | `-l` | `GITEA_RUNNER_LABELS` env | Runner labels for Gitea Actions. Example: `docker:docker://alpine:latest` |
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
## update
Update the Gitea Runner binary on a remote host.
```bash
grm update <host> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `host` | Remote host (IP address or hostname) |
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
| `--version` | `-v` | — | Specific Gitea Runner version |
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
## start
Start a registered Gitea Runner.
```bash
grm start <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm start prod-runner
# Override stored values:
grm start prod-runner --host 192.168.1.11 --user root
```
## stop
Stop a registered Gitea Runner.
```bash
grm stop <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## enable
Enable a registered Gitea Runner to start on boot.
```bash
grm enable <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## disable
Disable a registered Gitea Runner and deregister it.
```bash
grm disable <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--host` | — | from registry | Override host from registry |
| `--user` | `-u` | from registry | Override user from registry |
| `--key` | `-k` | from registry | Override SSH key from registry |
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
| `--url` | — | `GITEA_URL` env | Gitea URL |
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm disable prod-runner --token <token>
```
## status
Check the status of a registered Gitea Runner.
```bash
grm status <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options (common lifecycle options):**
| Option | Short | Description |
|--------|-------|-------------|
| `--host` | — | Override host from registry |
| `--user` | `-u` | Override user from registry |
| `--key` | `-k` | Override SSH key from registry |
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
## remove
Remove a registered Gitea Runner completely.
```bash
grm remove <runner_name> [options]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `runner_name` | Name of the registered runner |
**Options:**
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--host` | — | from registry | Override host from registry |
| `--user` | `-u` | from registry | Override user from registry |
| `--key` | `-k` | from registry | Override SSH key from registry |
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
| `--url` | — | `GITEA_URL` env | Gitea URL |
| `--force` | `-f` | — | Skip remote cleanup and only remove the local registry entry |
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
**Example:**
```bash
grm remove prod-runner --token <token>
```
## list
List all registered runners with live status.
```bash
grm list
```
This command takes no arguments or options. It displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners stored in the local registry at `~/.local/share/grm/runners.json`.
+25
View File
@@ -0,0 +1,25 @@
# FAQ
### How do I obtain the Gitea registration token?
The runner registration token is obtained from your Gitea instance: **Admin → Actions → Runners → Create Registration Token**. Set it as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
### How do I skip the sudo password prompt for automation?
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
### Can I run multiple runners on the same host?
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
### Why does my runner appear offline after installation?
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
### What does the "Event loop is closed" warning mean?
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
### Where are runner connection details stored?
GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
+88
View File
@@ -0,0 +1,88 @@
# Getting Started
## Developer Setup
```bash
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
cd gitea-runner-manager
pyenv install 3.12
pyenv local 3.12
make setup
```
## Configure Gitea Credentials
```bash
cp .env.example .env
# Edit .env:
# GITEA_URL=https://git.example.com
# GITEA_REGISTRATION_TOKEN=your-registration-token
```
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
### Admin API Token (optional)
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
2. **Systemd user service** is active (proves daemon is polling for jobs)
API checks, if enabled, are purely informational and do not affect pass/fail.
## Install a Runner
Using the CLI (you will be prompted for the sudo password by default):
```bash
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
Using Make:
```bash
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
## Verify Runner
The installer performs an automated integration test that verifies:
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
2. **Systemd user service is active** — this proves the daemon is polling for jobs
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
## View Logs
**GRM application logs** (Python CLI output):
```bash
# Application log file (all messages including DEBUG)
cat ~/.local/state/grm/logs/grm.log
# Enable debug logging in the current session
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
**Runner logs** (on the remote host):
```bash
# Runner logs (via systemd user service)
sudo -u grm-<name> journalctl --user -u gitea-runner -f
```
The GRM application writes to two destinations:
| Destination | Level | Content |
|-------------|-------|---------|
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
+55
View File
@@ -0,0 +1,55 @@
# Installation
## Prerequisites
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
## Supported Operating Systems
- Arch Linux
- Ubuntu 22.04 / 24.04
- Debian 12
All supported OSes are tested in CI via molecule scenarios on every PR.
## Quick Start Install
Using the CLI (you will be prompted for the sudo password by default):
```bash
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
## Make Install
Using Make:
```bash
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
## Runner Registry
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
```bash
# List all registered runners with live systemd status
grm list
```
## Multiple Instances on the Same Host
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
```bash
# Install two runners on the same host
grm install 192.168.1.10 --user ubuntu --name workflow-runner
grm install 192.168.1.10 --user ubuntu --name build-runner
# Manage them independently by name
grm stop workflow-runner
grm status build-runner
```
+50
View File
@@ -0,0 +1,50 @@
# Troubleshooting
## "Event loop is closed" warning
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
## Runner appears offline after installation
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Check logs for registration errors.
## Integration test fails
The test checks two things:
1. **`.runner` file missing or invalid** — Registration failed. Check:
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
- Runner logs for registration errors
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
2. **Service not running** — Daemon failed to start. Check:
- `sudo -u grm-<name> systemctl --user status gitea-runner`
- Logs for connection errors
## Rootless Docker: service fails to start
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
## Common Issues Reference Table
| Symptom | Likely Cause | Solution |
|---------|-------------|----------|
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |