Compare commits

..
124 Commits
Author SHA1 Message Date
grm-ci-bot fb76ac91ef release: v0.4.0
Publish Release / publish (push) Failing after 16s
Sync Wiki / sync-wiki (push) Successful in 1m58s
2026-06-21 23:25:29 +02:00
emil 0c54efbf6b GRM-40: fix: wiki links, add --strict integrity check for wiki sync (#34) 2026-06-21 21:14:17 +00:00
emil 945344f960 GRM-39: fix: use content_base64 for Gitea wiki API, add --verify flag (#33) 2026-06-21 21:03:00 +00:00
grm-ci-bot 713e752860 release: v0.3.2
Publish Release / publish (push) Failing after 24s
Sync Wiki / sync-wiki (push) Successful in 1m46s
2026-06-21 22:51:43 +02:00
emil e0ae01e36e GRM-38: fix: set PYTHONPATH=. for release.py to find scripts.ci module (#32) 2026-06-21 20:50:14 +00:00
emil 5511cba1b0 GRM-37: refactor: Split CI scripts, fix release PYTHONPATH, dynamic runner discovery 2026-06-21 20:44:39 +00:00
emil 56eb241b77 GRM-37: feat: Smart CI and release skipping for workflow-only changes 2026-06-21 20:15:28 +00:00
grm-ci-bot 6b3f2a5866 release: v0.3.1
Publish Release / publish (push) Failing after 16s
Sync Wiki / sync-wiki (push) Successful in 1m49s
2026-06-21 21:56:44 +02:00
emil 6f181e85e1 GRM-36: fix: use correct Gitea 1.26 wiki API endpoints
Updated sync_wiki.py to use correct Gitea 1.26 wiki API: POST /wiki/new for create, PATCH /wiki/page/{sub_url} for update, GET /wiki/pages returns sub_url. Tests updated to match.

Closes GRM-36
2026-06-21 19:55:18 +00:00
grm-ci-bot 539bfea516 release: v0.3.0
Publish Release / publish (push) Failing after 21s
Sync Wiki / sync-wiki (push) Failing after 1m30s
2026-06-21 21:47:07 +02:00
emil 5b05db4e6d 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
2026-06-21 19:45:34 +00:00
grm-ci-bot 7fe85423b4 release: v0.2.2
Publish Release / publish (push) Failing after 25s
2026-06-21 21:17:11 +02:00
emil 76d9983514 GRM-35: fix: bypass commit-msg hook for release commits
Release commits use --no-verify to bypass the commit-msg hook since they are generated by the release script, not by a developer.

Closes GRM-35
2026-06-21 19:15:55 +00:00
emil 3dcdde80ad GRM-35: fix: enforce tests pass before tagging a release
The release script now runs lint and tests before committing or tagging. If either fails, the release is aborted. Also fixes test_cli_version to use __version__ dynamically.

Closes GRM-35
2026-06-21 19:10:30 +00:00
grm-ci-bot 2e5ca5a88f release: v0.2.1
Publish Release / publish (push) Failing after 25s
2026-06-21 19:53:32 +02:00
emil e7f8e4ac66 GRM-35: fix: strip git-cliff header from CHANGELOG.md updates
The update_changelog function now strips the git-cliff header before inserting into CHANGELOG.md, preventing duplicate headers.

Closes GRM-35
2026-06-21 17:53:18 +00:00
grm-ci-bot a1b493f2a3 release: v0.2.0
Publish Release / publish (push) Failing after 25s
2026-06-21 19:49:23 +02:00
emil 63e25d1245 GRM-35: fix: release push permission and notify_failure label IDs
Fix two issues found during release workflow testing.

Closes GRM-35
2026-06-21 17:49:12 +00:00
emil 996a8dc806 GRM-35: feat: fix 12 critical workflow gaps in release pipeline
Addresses all 12 critical gaps in the automated semantic versioning, tagging, and release workflow.

Closes GRM-35
2026-06-21 17:44:27 +00:00
emil ea6276ff38 GRM-34: fix: skip commit when version file unchanged in release.py 2026-06-21 16:18:02 +00:00
emil f0afa8171a GRM-34: fix: handle same-version update in release.py 2026-06-21 15:32:07 +00:00
emil 09699696c0 GRM-34: fix: use full path for git-cliff version check in install step 2026-06-21 14:48:02 +00:00
emil ea8b71da1a GRM-34: fix: use mktemp for git-cliff extraction to avoid file conflicts 2026-06-21 14:03:12 +00:00
emil a7eb4d1a68 GRM-34: fix: install git-cliff to user-writable dir and fix archlinux idempotence 2026-06-21 13:18:54 +00:00
emil e00d40dd54 GRM-34: fix: move release commit skip check into release.py 2026-06-21 10:54:58 +00:00
emil 63fb259bac GRM-34: feat: add automated semver versioning, tagging, and releases with git-cliff 2026-06-21 09:53:56 +00:00
emil 5c1d848311 GRM-33: feat: add mandatory PR review step to workflow 2026-06-21 06:02:50 +00:00
emil 1717d55013 GRM-32: fix: security, dead code, idempotence, and documentation cleanup
Post-merge Vikunja update / vikunja (push) Successful in 5s
CI / quality (push) Successful in 1m5s
CI / molecule-tests (0) (push) Successful in 18m37s
CI / molecule-tests (2) (push) Successful in 18m51s
CI / molecule-tests (1) (push) Successful in 19m6s
Publish Release / publish (push) Failing after 9s
2026-06-21 00:14:31 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a676adb025 Rootless refactor + fix auto-merge + molecule platform matrix (GRM-31)
Post-merge Vikunja update / vikunja (push) Successful in 5s
CI / molecule-tests (2) (push) Successful in 20m9s
CI / quality (push) Successful in 1m8s
CI / molecule-tests (0) (push) Successful in 21m4s
CI / molecule-tests (1) (push) Successful in 21m15s
Three-part effort:
1. Rootless refactor: removes docker/binary modes, unifies to rootless Docker with per-runner system users
2. Auto-merge fix: fix status check context mismatch in branch protection, add retry/wait logic to auto_merge.py
3. Molecule platform matrix: add OS platform matrix to CI (ubuntu-2204, ubuntu-2404, debian-12, archlinux), distribute (scenario, platform) pairs across runners
4. Cross-runner molecule cancellation via Gitea API polling
5. Sequential molecule execution within each runner
6. Fix idempotence, systemd user bus, and Arch Linux package name issues

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:11:48 +00:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 1d85a6da9d fix: set runner_name in deregister verify.yml
CI / molecule-tests (0) (pull_request) Successful in 20m12s
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (1) (pull_request) Successful in 19m35s
CI / molecule-tests (2) (pull_request) Successful in 19m54s
The deregister scenario's verify.yml was missing the runner_name var,
which is required because gitea_runner_data_dir depends on it via
defaults/main.yml. Without it, the verify phase fails with
"'runner_name' is undefined".

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:45:47 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3822f6fe9a fix: add default(0) to gitea_runner_uid in environment blocks
CI / quality (pull_request) Successful in 1m4s
CI / molecule-tests (2) (pull_request) Failing after 6m11s
CI / molecule-tests (1) (pull_request) Failing after 6m20s
CI / molecule-tests (0) (pull_request) Failing after 6m22s
Ansible evaluates environment blocks even when when conditions are
false. The deregister scenario sets skip_runner_registration: true
but the environment block still references gitea_runner_uid, causing
"variable is undefined" errors. Add default(0) filter to prevent
this.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:34:07 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7b0e700fe5 fix: use gnupg instead of gpg package name on Arch Linux
CI / quality (pull_request) Successful in 1m7s
CI / molecule-tests (2) (pull_request) Failing after 5m54s
CI / molecule-tests (1) (pull_request) Failing after 6m2s
CI / molecule-tests (0) (pull_request) Failing after 6m3s
The Arch Linux pacman package for GPG is called 'gnupg', not 'gpg'.
The molecule prepare.yml was trying to install a non-existent 'gpg'
package, causing failures on the archlinux platform.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:22:20 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b7a04f37de fix: make user_setup and download tasks idempotent
CI / molecule-tests (2) (pull_request) Failing after 4m11s
CI / molecule-tests (1) (pull_request) Failing after 4m16s
CI / quality (pull_request) Successful in 1m6s
CI / molecule-tests (0) (pull_request) Failing after 4m9s
The "Enable lingering" task always reported changed=true, and the
"Download gitea_runner binary" task used force=true which always
re-downloads. Both caused molecule idempotence tests to fail.

- Check /var/lib/systemd/linger/<user> before enabling lingering
- Set force=false on get_url so binary is only downloaded if missing

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:13:19 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fcd26dd110 fix: guard handler systemctl --user calls with docker_rootless_setup
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (0) (pull_request) Failing after 3m13s
CI / molecule-tests (2) (pull_request) Failing after 3m20s
CI / molecule-tests (1) (pull_request) Failing after 3m23s
The "Restart gitea-runner" handler was not guarded by
docker_rootless_setup, causing failures in CI containers without a
systemd user bus. Also add failed_when: false to all lifecycle
side_effect.yml systemctl --user tasks.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:06:27 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8579a4064f fix: guard all systemctl --user tasks with docker_rootless_setup
CI / quality (pull_request) Successful in 1m4s
CI / molecule-tests (0) (pull_request) Failing after 3m26s
CI / molecule-tests (1) (pull_request) Failing after 3m26s
CI / molecule-tests (2) (pull_request) Failing after 3m14s
The daemon-reload, service restart, and service check tasks in
service.yml, prune.yml, update_runner.yml, and integration_test.yml
were not guarded by docker_rootless_setup. In CI containers without
a systemd user bus, these tasks fail with "Failed to connect to bus".

Also fix the integration_test.yml validation task to not fail on
service status when docker_rootless_setup is false.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 00:00:07 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c271293b2d fix: run molecule pairs sequentially within each CI runner
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (2) (pull_request) Failing after 3m8s
CI / molecule-tests (0) (pull_request) Failing after 3m15s
CI / molecule-tests (1) (pull_request) Failing after 3m18s
Parallel molecule execution within a single runner caused conflicts
(shared temp directories, Docker network collisions). Rewrote
molecule_ci_guard.py to run pairs sequentially while still polling
the Gitea API for cross-runner cancellation.

Each pair now gets its own subprocess with proper environment setup
(MOLECULE_PLATFORM_NAME/IMAGE/COMMAND), and output streams directly
to CI logs for debugging.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:53:13 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 6ef38631fc fix: stream molecule subprocess output to CI logs
CI / quality (pull_request) Successful in 1m10s
CI / molecule-tests (1) (pull_request) Failing after 2m9s
CI / molecule-tests (0) (pull_request) Failing after 2m12s
CI / molecule-tests (2) (pull_request) Failing after 4m17s
run_molecule_parallel.py was capturing stdout/stderr, which hid the
actual molecule failure details from CI logs. Inherit the parent
stdout/stderr instead so failures are visible for debugging.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:44:16 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 92ca7ef7bb feat: cross-runner molecule cancellation via Gitea API polling
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (2) (pull_request) Failing after 2m11s
CI / molecule-tests (0) (pull_request) Failing after 2m19s
CI / molecule-tests (1) (pull_request) Failing after 2m25s
Gitea Actions does not implement fail-fast/max-parallel for matrix jobs,
so a failing runner does not stop the others. Added molecule_ci_guard.py
which polls the Gitea API in a background thread. If any other molecule
runner reports failure, the current runner kills its molecule subprocess
and exits early.

CI returns to a 3-runner matrix; each runner executes its assigned pairs
in parallel via run_molecule_parallel.py, guarded by molecule_ci_guard.py.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:37:52 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 78c9b635e9 fix: catch TimeoutExpired in parallel runner wait loop
CI / quality (pull_request) Successful in 1m6s
CI / molecule-tests (pull_request) Failing after 1m55s
The initial wait loop used proc.wait(timeout=0.5) which could raise
subprocess.TimeoutExpired and crash the runner. Added a try/except and
increased timeout to 5s so the runner polls correctly without crashing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:30:36 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d65b2190e2 ci: single molecule job running all pairs in parallel
CI / molecule-tests (pull_request) Failing after 54s
CI / quality (pull_request) Successful in 1m4s
Gitea Actions does not honor fail-fast/max-parallel for cancelling
other matrix runners when one fails. Use a single molecule job that
runs all (scenario, platform) pairs via run_molecule_parallel.py.
This gives true parallel execution + immediate termination on the
first failure, which is what we need to debug efficiently.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:25:49 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 69b96e4785 feat: parallel molecule runner with kill-on-first-failure
CI / quality (pull_request) Successful in 1m2s
CI / molecule-tests (1) (pull_request) Failing after 55s
CI / molecule-tests (0) (pull_request) Failing after 1m11s
CI / molecule-tests (2) (pull_request) Failing after 1m21s
Added scripts/run_molecule_parallel.py to run a runner's assigned
(scenario, platform) pairs in parallel. If any subprocess fails, the
remaining ones are terminated with SIGTERM/SIGKILL and the runner
exits immediately. This gives fast feedback without continuing to run
tests that are guaranteed to fail for the same reason.

CI workflow now calls this script per matrix runner. Added fail-fast and
max-parallel for best-effort cancellation across runners.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:19:25 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a57a5c328c ci: single runner, ubuntu-2204 only, fail-fast on first molecule failure
CI / molecule-tests (pull_request) Failing after 3m2s
CI / quality (pull_request) Successful in 1m6s
Use a single molecule test job (no matrix) on ubuntu-2204 only. This
stops the workflow immediately when the first scenario fails instead of
wasting time running 3 parallel runners that all fail for the same
reason. Multi-platform will be restored once the base scenarios pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 23:08:10 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 312b8241df fix: gate all Docker-dependent tasks behind docker_rootless_setup
CI / molecule-tests (2) (pull_request) Failing after 3m18s
CI / molecule-tests (0) (pull_request) Failing after 3m45s
CI / molecule-tests (1) (pull_request) Failing after 3m48s
CI / quality (pull_request) Successful in 1m6s
The validate.yml had an unconditional 'docker version' check, and
service.yml/prune.yml unconditionally enabled services that need
Docker running. Added when: docker_rootless_setup to:
- validate.yml: Verify rootless Docker connectivity
- service.yml: Enable and start gitea-runner service
- prune.yml: Enable and start docker-prune timer
Also made lifecycle side_effect tolerant of service start failure
since Docker daemon isn't available in molecule containers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:50:25 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 227db4c457 ci: run molecule runners sequentially (max-parallel: 1)
CI / quality (pull_request) Successful in 1m6s
CI / molecule-tests (1) (pull_request) Failing after 2m39s
CI / molecule-tests (0) (pull_request) Failing after 2m51s
CI / molecule-tests (2) (pull_request) Failing after 3m7s
With fail-fast: true and max-parallel: 1, runner 0 must complete
before runner 1 starts. If runner 0 fails, runners 1 and 2 are
cancelled. This gives immediate feedback on the first failure
instead of waiting for all 3 to fail in parallel.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:42:58 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> da86eb0e9d fix: skip rootless Docker daemon startup in molecule tests
CI / quality (pull_request) Successful in 1m5s
CI / molecule-tests (0) (pull_request) Failing after 2m39s
CI / molecule-tests (1) (pull_request) Failing after 2m44s
CI / molecule-tests (2) (pull_request) Failing after 3m7s
Rootless Docker requires newuidmap/newgidmap kernel support which
doesn't work in nested Docker containers (Operation not permitted).
Added docker_rootless_setup variable (default true) to skip the
daemon startup steps. Set to false in all molecule converge playbooks
so tests verify package installation, user creation, service file
rendering, and config without requiring a working rootless daemon.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:37:31 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 084ea49523 fix: fail-fast CI, write Docker apt source directly, fix arch mapping
CI / molecule-tests (0) (pull_request) Failing after 2m32s
CI / molecule-tests (1) (pull_request) Failing after 2m52s
CI / quality (pull_request) Successful in 1m3s
CI / molecule-tests (2) (pull_request) Failing after 3m18s
Three changes:
1. CI: add set -e and fail-fast: true to stop on first molecule failure
   instead of continuing (all pairs fail for same reason anyway)
2. Docker APT repo: use copy module to write sources.list directly
   instead of apt_repository module which wasn't picking up the repo
3. Fix arch mapping: ansible_facts returns x86_64, Docker repo needs amd64

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:31:08 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8af88efeb5 fix: add apt source debug tasks, fix arch mapping for Docker repo
CI / quality (pull_request) Successful in 1m2s
CI / molecule-tests (0) (pull_request) Failing after 1m59s
CI / molecule-tests (1) (pull_request) Failing after 2m23s
CI / molecule-tests (2) (pull_request) Failing after 2m32s
ansible_facts['architecture'] returns x86_64 but Docker APT repo
expects amd64. Added docker_apt_arch mapping. Also added debug tasks
to show apt sources and apt-cache search results for docker-ce.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:24:02 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 36207be565 fix: separate apt update after adding Docker repo, use variable for repo string
CI / molecule-tests (1) (pull_request) Failing after 2m8s
CI / quality (pull_request) Successful in 1m6s
CI / molecule-tests (0) (pull_request) Failing after 2m3s
CI / molecule-tests (2) (pull_request) Failing after 1m57s
The apt_repository update_cache option wasn't reliably picking up the
new Docker APT repo. Split into separate apt update step. Also moved
the long repo string to a default variable to satisfy yaml line-length
lint rule.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:15:28 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 02909bf8b4 fix: install curl, gpg, ca-certificates in molecule prepare
CI / quality (pull_request) Successful in 1m4s
CI / molecule-tests (0) (pull_request) Failing after 2m4s
CI / molecule-tests (1) (pull_request) Failing after 2m7s
CI / molecule-tests (2) (pull_request) Failing after 2m29s
The geerlingguy Docker containers don't include curl or gpg, which
are needed by the rootless Docker role to download and dearmor the
Docker APT repository GPG key. Added these prerequisites to the
molecule common prepare playbook.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 22:07:55 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 119d70e137 fix: use bash for gpg dearmor (pipefail not available in sh)
CI / molecule-tests (0) (pull_request) Failing after 2m1s
CI / molecule-tests (2) (pull_request) Failing after 3m40s
CI / molecule-tests (1) (pull_request) Failing after 3m47s
CI / quality (pull_request) Successful in 1m3s
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:57:24 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 74db5f28c7 fix: dearmor Docker GPG key with gpg --dearmor for apt_repository
CI / molecule-tests (2) (pull_request) Failing after 2m8s
CI / quality (pull_request) Successful in 1m3s
CI / molecule-tests (0) (pull_request) Failing after 1m59s
CI / molecule-tests (1) (pull_request) Failing after 2m25s
The deb822_repository module isn't available in the CI Ansible
collection. Reverted to apt_repository but now properly dearmors
the GPG key using gpg --dearmor before referencing it in signed-by.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:46:33 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8aa00c7091 fix: use deb822_repository for Docker APT repo (proper GPG handling)
CI / quality (pull_request) Successful in 1m4s
CI / molecule-tests (2) (pull_request) Failing after 2m20s
CI / molecule-tests (0) (pull_request) Failing after 2m3s
CI / molecule-tests (1) (pull_request) Failing after 2m6s
The apt_repository module with signed-by wasn't working because the
downloaded GPG key wasn't properly dearmored. The deb822_repository
module handles GPG key download and dearmoring automatically.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:35:19 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2a803c611b fix: add Docker APT repository before installing docker-ce
CI / quality (pull_request) Successful in 1m3s
CI / molecule-tests (2) (pull_request) Failing after 2m25s
CI / molecule-tests (0) (pull_request) Failing after 3m33s
CI / molecule-tests (1) (pull_request) Failing after 3m46s
The rootless_docker.yml task was trying to apt install docker-ce
without first adding the Docker APT repository, causing package not
found errors on Debian/Ubuntu containers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:23:54 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 43b2a01361 fix: use systemd as container command for rootless molecule tests
CI / quality (pull_request) Successful in 1m3s
CI / molecule-tests (1) (pull_request) Failing after 2m35s
CI / molecule-tests (0) (pull_request) Failing after 3m30s
CI / molecule-tests (2) (pull_request) Failing after 3m26s
Rootless Docker requires loginctl enable-linger and systemctl --user,
which need systemd as PID 1 inside the container. Updated all platform
entries to use /lib/systemd/systemd (or /usr/lib/systemd/systemd for
Arch) as the container command instead of sleep infinity.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:12:43 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d4ea2eef61 fix: parse pytest output with warnings in check_test_speed
CI / quality (pull_request) Successful in 1m2s
CI / molecule-tests (0) (pull_request) Failing after 3m18s
CI / molecule-tests (2) (pull_request) Failing after 3m33s
CI / molecule-tests (1) (pull_request) Failing after 3m39s
The regex only matched "N passed in X.XXs" but pytest can output
"N passed, M warnings in X.XXs". Updated regex to handle both.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:06:41 +02:00
Emil SimeonovandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55c2746569 refactor: rootless Docker, fix auto-merge, molecule platform matrix
CI / molecule-tests (0) (pull_request) Has been skipped
CI / molecule-tests (1) (pull_request) Has been skipped
CI / molecule-tests (2) (pull_request) Has been skipped
CI / quality (pull_request) Failing after 1m4s
Three major improvements:

1. Rootless Docker refactor: Removes docker/binary modes, unifies to
   rootless Docker with per-runner system users. Each runner gets its
   own rootless Docker daemon, systemd user service, and isolated
   environment. Simplifies CLI (removes --mode option), Ansible role
   (single code path), and molecule scenarios (removes binary scenario).

2. Auto-merge fix: Fixes status check context mismatch in branch
   protection (was requiring "lint", "unit-tests", "molecule-tests" but
   actual contexts are "CI / quality", "CI / molecule-tests*"). Adds
   retry/wait logic to auto_merge.py that polls commit statuses for up
   to 15 minutes before attempting merge, eliminating the chicken-and-egg
   problem where auto-merge would fail because CI hadn't completed yet.

3. Molecule platform matrix: Adds OS platform matrix to CI — all 6
   scenarios now run on all 4 supported OSes (ubuntu-2204, ubuntu-2404,
   debian-12, archlinux) = 24 test pairs distributed across 3 parallel
   runners. Updates distribute_molecule.py to distribute (scenario,
   platform) pairs. Updates Makefile with molecule-all target for
   local multi-platform testing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 21:02:52 +02:00
emil 0c6c735000 GRM-30: feat: add runner labels support and refactor i18n to JSON
Post-merge Vikunja update / vikunja (push) Successful in 7s
CI / quality (push) Successful in 1m5s
CI / molecule-tests (1) (push) Successful in 6m26s
CI / molecule-tests (0) (push) Successful in 8m50s
CI / molecule-tests (2) (push) Successful in 5m55s
2026-06-20 18:42:39 +00:00
emil c8970da942 GRM-29: fix: use PUT instead of POST for Vikunja task comments
Post-merge Vikunja update / vikunja (push) Successful in 5s
CI / quality (push) Successful in 1m5s
CI / molecule-tests (2) (push) Successful in 7m7s
CI / molecule-tests (1) (push) Successful in 7m13s
CI / molecule-tests (0) (push) Successful in 9m8s
2026-06-20 18:08:36 +00:00
emil 3364355c73 GRM-28: fix: Vikunja task resolution pagination in post_merge.py
Post-merge Vikunja update / vikunja (push) Failing after 8s
CI / quality (push) Successful in 1m5s
CI / molecule-tests (1) (push) Successful in 6m44s
CI / molecule-tests (2) (push) Successful in 7m7s
CI / molecule-tests (0) (push) Successful in 9m35s
2026-06-20 17:48:08 +00:00
emil 0fadb7c504 GRM-27: fix: CI workflows for rootless runner compatibility
Post-merge Vikunja update / vikunja (push) Failing after 5s
CI / quality (push) Successful in 1m3s
CI / molecule-tests (0) (push) Failing after 3m35s
CI / molecule-tests (1) (push) Failing after 5m53s
CI / molecule-tests (2) (push) Failing after 5m59s
2026-06-20 17:01:55 +00:00
emil ea18793963 GRM-26: fix: CI pipeline for rootless Docker runners
Post-merge Vikunja update / vikunja (push) Failing after 5s
CI / quality (push) Successful in 1m3s
CI / molecule-tests (2) (push) Successful in 6m57s
CI / molecule-tests (1) (push) Successful in 7m3s
CI / molecule-tests (0) (push) Successful in 9m17s
2026-06-20 16:16:05 +00:00
emil 9c51c8b62c GRM-25: feat: auto-delete branch after merge in configure_repo script
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
2026-06-19 21:46:45 +00:00
emilandEmil Simeonov d949bd3444 GRM-24: feat: bandit integration (#1)
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
Co-authored-by: Emil Simeonov <emil@theliberatededge.org>
Reviewed-on: #1
2026-06-19 21:17:39 +00:00
Emil Simeonov d8c31238bd GRM-24: docs: document bandit in README, CONTRIBUTING and .gitignore
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
2026-06-19 21:13:58 +02:00
Emil Simeonov ae27417a5f GRM-24: fix: resolve bandit security warnings in source code and tests 2026-06-19 21:13:42 +02:00
Emil Simeonov e9c0f22fc0 GRM-24: chore: add bandit dependency, Makefile target and pre-commit hook 2026-06-19 21:13:21 +02:00
Emil Simeonov fefddda715 GRM-20: refactor(scripts): centralize constants, API clients, and HTTP status codes
- Add shared config.py with API URLs, regexes, timeouts, pagination
- Add GiteaClient and VikunjaClient in api_clients.py with pooled sessions
- Add APIError exception for unified HTTP error handling
- Refactor all scripts to use shared modules and http.HTTPStatus
- Rewrite unit tests to mock clients and use HTTPStatus constants
- Add tests for api_clients and config modules
- Achieve 100% test coverage
2026-06-19 21:00:21 +02:00
Emil Simeonov 790b5c3769 GRM-20: refactor: rework all scripts to use click and i18n
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
- Replace argparse/print/sys.exit with click commands and ClickException
- Translate all user-facing messages via _()
- Add friendly Oops! / Nice! prompts
- Wrap HTTP errors in all scripts with user-friendly translated messages
- Update all unit tests to use CliRunner and expect ClickException
- Add 100% branch coverage for new HTTP error handling branches
- Add missing translation keys to i18n.py
- Fix pre-commit hook to use venv Python for validate_commit_msg.py
2026-06-19 20:11:20 +02:00
Emil Simeonov b2acaefaf9 GRM-20: refactor: use http.HTTPStatus constants instead of magic numbers
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
2026-06-19 19:36:08 +02:00
Emil Simeonov e1b589efab GRM-20: feat: user-friendly click errors with i18n in configure_repo
Post-merge Vikunja update / vikunja (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
2026-06-19 19:33:04 +02:00
Emil Simeonov 4fa3aeb54c GRM-20: refactor: standardise pre-commit hooks on make targets
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
2026-06-19 15:49:53 +02:00
Emil Simeonov 7d25e059f7 GRM-20: fix: remove molecule tests from pre-push hooks
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
Post-merge Vikunja update / vikunja (push) Has been cancelled
2026-06-19 15:45:05 +02:00
Emil Simeonov 4d2ac8d449 GRM-20: feat: replace inline workflow scripts with tested Python modules 2026-06-19 15:34:36 +02:00
Emil Simeonov 3048d7ade7 GRM-20: fix: enforce GRM-N: conventional on master commits and PR titles
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
2026-06-19 15:10:32 +02:00
Emil Simeonov 129cfe3c79 GRM-20: fix molecule idempotence with mode-specific systemd templates
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
2026-06-19 14:12:58 +02:00
Emil Simeonov 1a917e7a5d GRM-23: fix: improve make setup with version guard, pre-push hooks and commit-msg validator
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
2026-06-19 13:18:17 +02:00
Emil Simeonov d4766da5f9 GRM-20: fix: skip systemd operations in lifecycle molecule when unavailable
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
The lifecycle scenario runs in a Docker container without systemd
as PID 1. The side_effect and verify playbooks used systemd module
operations unconditionally, causing failures like:

  System has not been booted with systemd as init system

Add a systemd availability check (/run/systemd/system stat) to both
playbooks and conditionally skip systemd tasks when running in
environments without systemd (e.g. Molecule Docker containers).
2026-06-19 11:43:55 +02:00
Emil Simeonov c05d7c9c4f GRM-20: fix: set runner_mode to binary in multi-instance converge
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
The multi-instance scenario verify playbook expects config files at
/etc/gitea-runner/<name>/config.yaml (binary mode path). Without
runner_mode set, the role defaulted to docker mode, which places the
config in /var/lib/gitea-runner/<name>/config.yaml instead.

Add runner_mode: binary to both converge plays so config placement
matches the verify assertions.
2026-06-19 10:51:36 +02:00
Emil Simeonov c91626a8ff GRM-20: Ensure runner data directory exists in binary mode
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled
The binary_mode.yml task file did not create gitea_runner_data_dir
when runner registration was skipped (as in molecule tests). This
caused the binary scenario verify playbook to fail because the
data directory assertion expected it to exist.

Add an explicit directory creation step before config creation,
mirroring the docker_mode.yml structure.
2026-06-19 04:55:06 +02:00
Emil Simeonov ad7c6255d5 GRM-20: Use CURDIR for molecule base path to fix scenario loop
The molecule target iterates through 7 scenarios. The previous
relative cd into ansible/roles/gitea-runner failed after the first
iteration because the shell was already inside that directory.
Using $(CURDIR) ensures each loop iteration starts from the project
root.
2026-06-19 04:54:57 +02:00
Emil Simeonov e1173aeeb8 GRM-22: Add developer documentation and update project configuration
- Add CONTRIBUTING.md with branch naming, commit format, and PR workflow
- Add TROUBLESHOOTING.md with common issues and solutions
- Update README.md with CI badge and commit convention section
- Update pyproject.toml with pythonpath and coverage settings for scripts
2026-06-19 04:28:04 +02:00
Emil Simeonov 4da724ce53 GRM-21: Implement commit validation, CI/CD workflows and repo automation
- Add scripts/validate_commit_msg.py with conventional commit enforcement
- Add scripts/configure_repo.py for Gitea branch protection and labels
- Add scripts/__init__.py for Python package importability
- Create Gitea Actions workflows: ci, auto-merge, post-merge, publish
- Update .pre-commit-config.yaml with commit-msg hook
- Update pyproject.toml pythonpath and coverage for scripts
- Add comprehensive unit tests for both scripts with 100% coverage
2026-06-19 04:27:43 +02:00
Emil Simeonov 1d3d2487ac GRM-20: Fix molecule verify playbooks and update Makefile targets
- Add runner_name variable to default, binary, and lifecycle verify playbooks
- Update molecule target to test all 7 scenarios sequentially
- Add molecule-docker and molecule-binary platform matrix targets
- Disable checkmake maxbodylength rule to accommodate longer recipes
2026-06-19 04:26:57 +02:00
Emil Simeonov 39ef86647c GRM-19: add molecule tests for template content, deregister, and update workflows
- template-content: Verifies rendered systemd template contains correct
  directives for docker mode (Type=oneshot, RemainAfterExit=yes) and
  prune service/timer content.
- deregister: Installs runner, creates fake .runner file, runs deregister
  tasks, verifies .runner file is removed.
- update: Installs docker and binary runners, runs update tasks, verifies
  image/binary and data directories remain intact after update.

These scenarios bridge gaps where the deregister task file and update
workflows were not covered by existing molecule tests.
2026-06-19 03:24:19 +02:00
Emil Simeonov 5fb282e38e GRM-19: fix ansible playbooks to use include_role for role defaults loading
Replace include_tasks with include_role + tasks_from in disable, remove,
start, enable, stop, and status playbooks. include_tasks does not load
role defaults, causing undefined variable errors (e.g. gitea_runner_data_dir)
when deregistering or registering runners.
2026-06-19 03:18:24 +02:00
Emil Simeonov 3d943b57dc GRM-19: refactor: resolve_runner returns gitea_url, add --url CLI option, force remove improvements, code quality fixes
- _resolve_runner now returns gitea_url from registry so disable/remove
  can reuse the URL stored at install time without requiring env vars.
- Added --url option to install, disable, and remove CLI commands.
- remove(force=True) no longer requires gitea_url or token.
- Moved _parse_status outside the for loop in list_runners.
- Updated all translations and tests to match.
2026-06-19 03:12:04 +02:00
Emil Simeonov a9adb71a08 GRM-17: fix docker inspect Jinja2 conflict and add per-iteration exception handling
- docker inspect -f "{{.State.Status}}" used Go template braces that
  conflicted with Ansible Jinja2 templating in the shell module.
  Ansible tried to parse {{.State.Status}} as a Jinja2 variable (which
  starts with a dot, making it invalid), causing a local template error.
  The outer except Exception caught this immediately, so the fallback
  loop never reached the legacy container name or systemctl checks.

- Replaced with: docker inspect <name> | python3 -c JSON parsing,
  which avoids any brace syntax and uses python3 (already required by
  Ansible on managed nodes).

- Added per-iteration try/except inside the fallback loop so a failure
  on one container name continues to the next fallback instead of
  aborting the entire check.

- Added tests for fallback behavior and binary mode exception path.

128 tests, 100% coverage, ruff + pyright clean
2026-06-19 02:53:26 +02:00
Emil Simeonov 1bbe3b4f43 GRM-17: fix docker mode status detection and systemd template
- systemd template for docker mode now uses Type=oneshot + RemainAfterExit=yes
  so that systemctl is-active returns active when the container is running.
  Previously docker run -d exited immediately, causing systemd to mark the
  service as inactive even though the container was still up.
- grm list now tries multiple container name fallbacks for docker mode:
  1. gitea-runner-{name} (current naming)
  2. gitea-runner-{host} (legacy installs where name defaulted to host)
  3. systemctl is-active gitea-runner@{name} (for installs with fixed template)
- All tests pass, 100% coverage, ruff + pyright clean
2026-06-19 02:47:14 +02:00
Emil Simeonov 12e6ce1601 GRM-17: fix grm list status — use docker inspect for docker mode, add host/user context
- Docker mode runners now check container status via docker inspect
  instead of systemctl is-active, avoiding false unknown when systemd
  service is missing or stderr output is discarded
- Binary mode still uses systemctl is-active with stderr suppressed
- Both modes now show a translated context message before the check so
  users know which host/user each BECOME password prompt belongs to
- Better ansible output filtering: strip CHANGED/FAILED/UNREACHABLE
  header lines and separator noise
- Map Docker container states (running/exited/dead) to systemd vocabulary
- All new user-facing messages fully translated (en/bg/de/ru/zh)
- 125 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 02:35:53 +02:00
Emil Simeonov 51f204f90a GRM-17: fix grm list still showing unknown status for active runners
- run_ad_hoc() now accepts ask_become_pass and check parameters
- list_runners() passes ask_become_pass=True so --ask-become-pass is
  added when running in a TTY, matching playbook behavior
- list_runners() passes check=False so systemctl is-active non-zero
  exit codes (inactive=3, unknown=4) don't raise exceptions; the
  actual status string is parsed from stdout instead
- TTY guard prevents --ask-become-pass from hanging in non-interactive
  environments (CI, scripts)
- 123 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 02:24:03 +02:00
Emil Simeonov ebb1088a8c GRM-18: feat: make --ask-become-pass the default behavior
- Change --ask-become-pass from opt-in to opt-out across all commands
  (install, update, start, stop, enable, disable, status, remove)
- Use Click toggle pattern: --ask-become-pass/--no-ask-become-pass with
  default=True so users are always prompted for sudo unless they
  explicitly opt out
- Update i18n translations for both help texts
- Update all CLI tests to expect ask_become_pass=True as default and
  add test for --no-ask-become-pass
- Update README: remove --ask-become-pass from examples, document
  --no-ask-become-pass for passwordless-sudo setups
- 120 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 02:11:38 +02:00
Emil Simeonov 18760f6a2a GRM-17: fix: make grm list retrieve runner status correctly
- run_ad_hoc() now raises AnsibleError on non-zero exit, surfacing
  stderr instead of silently returning empty stdout
- list_runners() passes become=True to run_ad_hoc since systemctl
  is-active requires root privileges
- Add i18n translations for ad-hoc failure messages
- Add unit test for run_ad_hoc failure case
- Update list_runners test to expect become=True
- 119 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 02:05:06 +02:00
Emil Simeonov 677745ac99 GRM-16: feat: add --force flag to grm remove for unreachable runners
- Add force parameter to RunnerManager.remove() — skips remote Ansible
  playbook and only removes the local registry entry
- Add --force/-f CLI flag to grm remove command
- Add translations for --force help text across all 5 languages
- Add unit tests for force skip and CLI flag propagation
- 118 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 02:00:45 +02:00
Emil Simeonov 6ede871054 GRM-15: fix: eliminate duplicate console output, restore GRM_LOG_LEVEL filtering
- Remove console StreamHandler from get_logger() — say() already handles
  console output via click.echo(); having both caused every message to
  appear twice
- Move GRM_LOG_LEVEL filtering into ui.say() via _console_level() so
  console verbosity is still user-controllable while the log file always
  captures everything at DEBUG
- Remove [GRM] prefix from say() calls — no longer needed without
  duplicate logger output, giving cleaner user-facing messages
- Update test_logging_config.py: remove console handler tests and
  _level_from_env tests (now in test_ui.py), expect 1 handler only
- Add test_ui.py coverage for _console_level and say() level filtering
- Update README to document single-path console output via click.echo
- 116 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 01:54:05 +02:00
Emil Simeonov 47d5df0aed GRM-15: feat: add colorized output for better visual feedback
- Extend ui.say() with optional color parameter using click.style()
- Console output gets tinted; log file always stores plain text (no ANSI)
- executor.py: cyan for start, yellow for status, green for done, red for errors
- report.py: bright_cyan header, green completed, red failed, yellow in-progress,
  white pending
- Update README with colorized output documentation
- 117 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 01:32:45 +02:00
Emil Simeonov 3f7507500b GRM-14: feat: use click.echo() for user-facing messages with dual logging
- Create ui.py with say() helper that routes messages to both click.echo()
  (console/stdout, user-facing) and logging.getLogger('grm') (file audit trail)
- Update executor.py: replace logger.info() with say() for start, status, done
  messages; use say(level=ERROR, err=True) before raising AnsibleError
- Update report.py: replace logger.info() with say() for operation report lines
- Update all unit tests to patch say() instead of using capsys or get_logger
- Add test_ui.py with coverage for say() calling both click.echo and logging
- 117 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 01:30:14 +02:00
Emil Simeonov 3b3e002f7f GRM-13: feat: replace print() with stdlib logging module
- Create logging_config.py with get_logger() providing dual handlers:
  - Console handler (stderr) controlled by GRM_LOG_LEVEL env var (default INFO)
  - File handler (~/.local/state/grm/logs/grm.log) capturing everything at DEBUG
- Replace all print() calls in executor.py and report.py with logger.info()/error()
- Add error logging before raising AnsibleError in executor.run()
- Add GRM_LOG_LEVEL to README configuration table and logging documentation
- Update all unit tests to mock logger instead of using capsys
- 114 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 01:24:35 +02:00
Emil Simeonov 815b59c537 GRM-12: feat: add translated operation report for success and failure cases
- Create StepTracker context manager in new report.py module
- Track each step of lifecycle operations (install, update, start, stop, enable,
  disable, status, remove) with begin/done markers
- On success: report lists all completed steps with ✓ icons
- On failure: report shows failed step (✗), completed steps before failure (✓),
  and pending steps that never started (○)
- Add translations for report header, status labels, and registry step names
  in all 5 supported languages (EN/BG/DE/RU/ZH)
- 105 tests, 100% coverage, pyright clean, ruff clean
2026-06-19 01:16:39 +02:00
Emil Simeonov 3b68302274 GRM-11: i18n: translate all remaining user-facing strings 2026-06-19 00:57:26 +02:00
Emil Simeonov b3ac955515 GRM-10: refactor: deduplicate CLI, remove dead code, move validation to business layer 2026-06-19 00:57:26 +02:00
Emil Simeonov 4c08606d0c GRM-9: feat: add runner registry for simplified CLI UX 2026-06-19 00:57:26 +02:00
Emil Simeonov a575a89026 GRM-16: test: add molecule lifecycle scenarios, integration tests, and docs
- Add multi-instance molecule scenario verifying isolated data/config dirs
- Add lifecycle molecule scenario testing stop/disable/enable/start sequence
- Update default and binary verify playbooks for template unit assertions
- Add integration tests for full CLI lifecycle and multi-instance support
- Add pytest integration marker and --no-cov Makefile target
- Update README with lifecycle commands, multi-instance examples, and architecture
- Update Makefile with start/stop/enable/disable/status/remove targets
- Update .env.example with GRM_LANG documentation
2026-06-18 23:14:02 +02:00
Emil Simeonov adb758f5f5 GRM-15: feat: add lifecycle CLI commands and RunnerManager extensions
- Add start, stop, enable, disable, status, remove methods to RunnerManager
- Add corresponding CLI subcommands: grm start/stop/enable/disable/status/remove
- Add i18n translations for lifecycle commands across all supported languages
- Add comprehensive unit tests for lifecycle methods and CLI commands
- Fix environment variable leakage in CLI tests for GITEA_URL
2026-06-18 22:59:50 +02:00
Emil Simeonov e5964ca5a9 GRM-14: feat: add systemd template units and multi-instance Ansible support
- Add instance-scoped base data/config directories in defaults
- Create gitea-runner@.service.j2 template supporting Docker and binary modes
- Refactor service.yml to install systemd template unit instances
- Remove direct container lifecycle from docker_mode.yml (delegate to systemd)
- Add deregister.yml for runner deregistration on disable/remove
- Create lifecycle playbooks: start, stop, enable, disable, status, remove
- Update handlers, integration_test, docker_update, binary_update for template units
2026-06-18 22:59:04 +02:00
Emil Simeonov 047ee05fa1 GRM-7: feat: integrate AnsibleExecutor and i18n into CLI and RunnerManager 2026-06-18 20:17:07 +02:00
Emil Simeonov 564c12e782 GRM-8: feat: add AnsibleExecutor and i18n modules 2026-06-18 20:16:50 +02:00
Emil Simeonov 6414f2306c GRM-13: fix: rewrite integration test to verify .runner file and container health instead of unreliable API checks 2026-06-18 09:34:45 +02:00
Emil Simeonov 1ad7c0a816 GRM-12: fix: convert runner config from TOML to YAML format 2026-06-18 04:47:41 +02:00
Emil Simeonov 68dcbc9b16 GRM-12: docs: update .env.example with GITEA_ADMIN_TOKEN and scope requirements 2026-06-18 04:41:09 +02:00
Emil Simeonov 9378451a12 GRM-12: fix: remove recursive var definitions from install-runner.yml 2026-06-18 04:40:11 +02:00
Emil Simeonov e08c11ea83 GRM-12: feat: add GITEA_ADMIN_TOKEN support for integration test 2026-06-18 04:37:05 +02:00
Emil Simeonov 81dd11e720 GRM-12: fix: make integration test conditional on admin API accessibility 2026-06-18 04:34:28 +02:00
Emil Simeonov a05cd1c7ee GRM-12: fix: set Docker working dir to /data for .runner persistence 2026-06-18 04:29:24 +02:00
Emil Simeonov 55fee77c91 GRM-11: fix: override Docker container entrypoint to bypass run.sh wrapper 2026-06-18 04:23:14 +02:00
Emil Simeonov b48038a3a9 GRM-10: fix: add timeout to runner registration to prevent indefinite hangs 2026-06-18 04:11:43 +02:00
Emil Simeonov 994c30da57 GRM-9: test: add parameterized multi-platform Molecule testing (Arch, Ubuntu 24/26, Debian 12/13) 2026-06-18 04:09:27 +02:00
Emil Simeonov aef24352c1 GRM-8: fix: remove recursive variable definitions in install and update playbooks 2026-06-18 03:50:08 +02:00
Emil Simeonov 8c385dcbdc GRM-7: docs: overhaul README and add project documentation 2026-06-18 03:36:20 +02:00
Emil Simeonov 2530ec54cc GRM-6: fix: resolve idempotence issues and testing infrastructure 2026-06-18 03:36:20 +02:00
Emil Simeonov cc000c226c GRM-5: feat: parameterize all hardcoded configuration values as Ansible variables 2026-06-18 03:36:20 +02:00
Emil Simeonov 0cea9b9490 GRM-4: refactor: consolidate systemd checks and deduplicate role structure 2026-06-18 03:36:19 +02:00
Emil Simeonov b3838eb180 GRM-3: refactor: migrate source terminology from act_runner to gitea_runner 2026-06-18 03:36:19 +02:00
Emil Simeonov 3c8654342f GRM-2: refactor: remove dead code and legacy artifacts 2026-06-18 03:36:19 +02:00
Emil Simeonov 170b53ad28 GRM-1: feat: initial implementation of Gitea Runner Manager 2026-06-17 18:15:28 +02:00
162 changed files with 17149 additions and 240 deletions
+10
View File
@@ -0,0 +1,10 @@
# Ansible-lint configuration
exclude_paths:
- .venv/
- .cache/
- molecule/
- .molecule/
- .pytest_cache/
skip_list:
# Rootless Docker uses `systemctl --user` which the systemd module doesn't support
- command-instead-of-module
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="20" role="img"
aria-label="coverage: 100%">
<title>coverage: 100%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="104" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="66" height="20" fill="#555"/>
<rect x="66" width="38" height="20" fill="#4c1"/>
<rect width="104" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="33" y="14">coverage</text>
<text x="85" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="76" height="20" role="img"
aria-label="docs: 100%">
<title>docs: 100%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="76" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="38" height="20" fill="#555"/>
<rect x="38" width="38" height="20" fill="#4c1"/>
<rect width="76" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="19" y="14">docs</text>
<text x="57" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 879 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="20" role="img"
aria-label="python: 3.12">
<title>python: 3.12</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#007ec6"/>
<rect width="90" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="26" y="14">python</text>
<text x="71" y="14">3.12</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="20" role="img"
aria-label="code quality: A">
<title>code quality: A</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="124" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="94" height="20" fill="#555"/>
<rect x="94" width="30" height="20" fill="#4c1"/>
<rect width="124" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="47" y="14">code quality</text>
<text x="109" y="14">A</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="132" height="20" role="img"
aria-label="tests: 243 passing">
<title>tests: 243 passing</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="132" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="45" height="20" fill="#555"/>
<rect x="45" width="87" height="20" fill="#4c1"/>
<rect width="132" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="22" y="14">tests</text>
<text x="88" y="14">243 passing</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 906 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="118" height="20" role="img"
aria-label="version: v0.21.0">
<title>version: v0.21.0</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="118" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="59" height="20" fill="#555"/>
<rect x="59" width="59" height="20" fill="#007ec6"/>
<rect width="118" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="29" y="14">version</text>
<text x="88" y="14">v0.21.0</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 903 B

+4
View File
@@ -0,0 +1,4 @@
[checkmake]
# Disable the phony rule which flags common .PHONY placement patterns
# as it produces false positives for standard Makefile layouts
disable=maxbodylength
+35
View File
@@ -0,0 +1,35 @@
# Gitea instance URL (used for runner registration and API validation)
GITEA_URL=https://git.example.com
# Runner registration token from Gitea admin panel:
# Admin → Actions → Runners → Create Registration Token
GITEA_REGISTRATION_TOKEN=your-registration-token
# Gitea API token for optional post-install API checks (informational only).
# The integration test primarily verifies the runner by checking:
# 1. The .runner registration file exists and is valid
# 2. The container/service is running
# If set, API checks are performed as a bonus but do NOT affect pass/fail.
# Required scopes: read:user, read:repository, read:admin (or just "admin")
# Generate token at: Settings → Applications → Generate New Token
# REPO_TOKEN=your-admin-api-token
# Integration test API retries (optional, default: 3).
# Number of times to retry API checks waiting for runner to appear.
# GITEA_INTEGRATION_RETRIES=3
# Default SSH user for remote hosts (optional, overrides --user)
# GITEA_RUNNER_USER=ubuntu
# Default SSH private key path (optional, overrides --key)
# GITEA_RUNNER_KEY=~/.ssh/id_ed25519
# Default runner labels for Gitea Actions (optional, overrides --labels)
# Format: <label>:<docker-image>[:<command>]
# Use an official Gitea runner image with Node.js, Python and Docker CLI.
# Avoid bare OS images like alpine:latest because actions/checkout@v4 needs Node.
# GITEA_RUNNER_LABELS=docker:docker://gitea/runner-images:ubuntu-latest
# UI language for GRM console messages (optional, default: en)
# Supported: en, bg, de, ru, zh
# GRM_LANG=en
+25
View File
@@ -0,0 +1,25 @@
name: Auto-merge
on:
pull_request:
types: [labeled]
jobs:
merge:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/auto_merge.py \
"${{ github.head_ref }}" \
"${{ github.event.pull_request.title }}" \
"${{ github.repository }}" \
"${{ github.event.number }}" \
"${{ github.event.label.name }}"
+152
View File
@@ -0,0 +1,152 @@
name: CI
on:
pull_request:
types: [opened, synchronize]
push:
branches: [master]
workflow_dispatch:
jobs:
quality:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
- name: Lint all
run: |
. .venv/bin/activate
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
make pytest-cov
- name: Check unit test speed
run: |
. .venv/bin/activate
python3 scripts/check_test_speed.py --max-seconds 10
- name: Documentation coverage check
run: |
. .venv/bin/activate
PYTHONPATH=src python3 scripts/ci/doc_coverage.py
release-dry-run:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup
- name: Install git-cliff
run: |
GIT_CLIFF_VERSION="2.13.0"
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
TMPDIR="$(mktemp -d)"
curl -sL "$URL" | tar xz -C "$TMPDIR"
mkdir -p "$HOME/.local/bin"
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
chmod +x "$HOME/.local/bin/git-cliff"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Release dry-run validation
run: |
. .venv/bin/activate
PYTHONPATH=. python3 scripts/ci/release.py --dry-run || true
detect-changes:
runs-on: docker
outputs:
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: detect
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="origin/master"
HEAD="${{ github.event.pull_request.head.sha }}"
else
BASE="HEAD~1"
HEAD="HEAD"
fi
# Check if any Ansible-related files changed
ANSIBLE_CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- ansible/ .ansible-lint 2>/dev/null | head -1)
if [ -n "$ANSIBLE_CHANGED" ]; then
echo "ansible-changed=true" >> "$GITHUB_OUTPUT"
echo "Ansible files changed — molecule tests will run."
else
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
echo "No Ansible files changed — skipping molecule tests."
fi
# Check if any user-facing files changed (src/, ansible/, pyproject.toml)
USER_FACING=$(git diff --name-only "$BASE" "$HEAD" -- src/gitea_runner_manager/ ansible/ pyproject.toml 2>/dev/null | head -1)
if [ -n "$USER_FACING" ]; then
echo "user-facing-changed=true" >> "$GITHUB_OUTPUT"
echo "User-facing files changed — release dry-run will run."
else
echo "user-facing-changed=false" >> "$GITHUB_OUTPUT"
echo "No user-facing files changed — skipping release dry-run."
fi
discover-runners:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
outputs:
runner-count: ${{ steps.discover.outputs.runner-count }}
runner-indices: ${{ steps.discover.outputs.runner-indices }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
- name: Discover available runners
id: discover
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
. .venv/bin/activate
OUTPUT=$(python3 scripts/ci/discover_runners.py --owner "${{ github.repository_owner }}" --repo "${{ github.event.repository.name }}")
echo "$OUTPUT"
# Parse outputs
RUNNER_COUNT=$(echo "$OUTPUT" | grep '^count=' | cut -d= -f2)
RUNNER_INDICES=$(echo "$OUTPUT" | grep '^indices=' | cut -d= -f2)
echo "runner-count=$RUNNER_COUNT" >> "$GITHUB_OUTPUT"
echo "runner-indices=$RUNNER_INDICES" >> "$GITHUB_OUTPUT"
molecule-tests:
needs: [quality, detect-changes, discover-runners]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
strategy:
matrix:
runner-index: ${{ fromJSON(needs.discover-runners.outputs.runner-indices) }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup
- name: Discover assigned test pairs
run: |
. .venv/bin/activate
PAIRS=$(python3 scripts/ci/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners ${{ needs.discover-runners.outputs.runner-count }})
echo "Assigned pairs: $PAIRS"
echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV
- name: Run molecule tests
run: |
set -euo pipefail
. .venv/bin/activate
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
env:
GITEA_URL: ${{ github.server_url }}
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
RUN_ID: ${{ github.run_id }}
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
+23
View File
@@ -0,0 +1,23 @@
name: Post-merge Vikunja update
on:
push:
branches: [master]
jobs:
vikunja:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/post_merge.py \
"$(git log -1 --pretty=%B)" \
--commit-sha "$(git rev-parse HEAD)"
+53
View File
@@ -0,0 +1,53 @@
name: Publish Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install git-cliff
run: |
GIT_CLIFF_VERSION="2.13.0"
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
TMPDIR="$(mktemp -d)"
curl -sL "$URL" | tar xz -C "$TMPDIR"
mkdir -p "$HOME/.local/bin"
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
chmod +x "$HOME/.local/bin/git-cliff"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/git-cliff" --version
- name: Install build tools
run: |
python3 -m pip install --break-system-packages build twine requests python-dotenv click
- name: Validate PYPI_TOKEN
run: |
if [ -z "${{ secrets.PYPI_TOKEN }}" ]; then
echo "::warning::PYPI_TOKEN is not set — package will be built but not published to PyPI."
fi
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/publish.py \
"${{ github.ref_name }}" \
"${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
python3 scripts/ci/notify_failure.py \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+49
View File
@@ -0,0 +1,49 @@
name: Release
on:
push:
branches: [master]
jobs:
release:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment
run: make setup
- name: Install git-cliff
run: |
GIT_CLIFF_VERSION="2.13.0"
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
TMPDIR="$(mktemp -d)"
curl -sL "$URL" | tar xz -C "$TMPDIR"
mkdir -p "$HOME/.local/bin"
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
chmod +x "$HOME/.local/bin/git-cliff"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/git-cliff" --version
- name: Configure git
run: |
git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi"
- name: Run release
env:
PYTHONPATH: .
run: |
. .venv/bin/activate
python3 scripts/ci/release.py
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .
run: |
. .venv/bin/activate
python3 scripts/ci/notify_failure.py \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "release" \
--commit "${{ github.sha }}"
+31
View File
@@ -0,0 +1,31 @@
name: Sync Wiki
on:
push:
branches: [master]
push:
tags:
- 'v*'
jobs:
sync-wiki:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
run: make setup
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
- name: Tag wiki on release
if: startsWith(github.ref, 'refs/tags/v')
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
echo "Release tag ${{ github.ref_name }} — wiki synced with release"
+37
View File
@@ -0,0 +1,37 @@
# Environment
.env
.venv/
venv/
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Ansible
*.retry
.molecule/
# Coverage
.coverage
htmlcov/
# Security scanner
.bandit
bandit-report.*
# Misc
*.log
.DS_Store
activate.sh
activate.fish
activate.zsh
+64
View File
@@ -0,0 +1,64 @@
repos:
- repo: local
hooks:
- id: validate-commit-msg
name: validate commit message
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
language: system
stages: [commit-msg]
pass_filenames: true
- id: lint-ruff
name: ruff lint
entry: make lint-ruff
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: lint-format
name: ruff format check
entry: make lint-format
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: typecheck
name: pyright type check
entry: make typecheck
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: lint-bandit
name: bandit security scan
entry: make lint-bandit
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: ansible-lint
name: ansible-lint
entry: make ansible-lint
language: system
types: [yaml]
pass_filenames: false
stages: [pre-commit]
- id: pytest-cov
name: pytest with 100% coverage
entry: make pytest-cov
language: system
types: [python]
pass_filenames: false
stages: [pre-push]
- id: commit-msg
name: validate commit message
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
language: system
stages: [commit-msg]
pass_filenames: true
+1
View File
@@ -0,0 +1 @@
3.12
+312
View File
@@ -0,0 +1,312 @@
# AGENTS.md — Project Conventions for GRM
## 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
```
## Architecture
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
- **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
## PR Workflow (Mandatory)
Every change to master goes through this workflow. No exceptions.
### 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.
### 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 below
- 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/ci/review_pr.py`:
```bash
REPO_TOKEN=<token> python3 scripts/ci/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/ci/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 (see below)
### CI Path Filtering
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 — this prevents non-Ansible changes
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule
test infrastructure flakiness.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically via `scripts/ci/discover_runners.py`. The `discover-runners`
job queries the Gitea API for runners at all levels (repo, org, instance)
and generates a dynamic matrix. If the API can't see instance-level runners
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
then to a default of 3.
**When adding/removing Gitea runners:**
1. Repo/org-level runners are auto-detected via the API
2. For instance-level runners, update the `MOLECULE_RUNNERS` repo variable
3. The workflow automatically scales the matrix to match available runners
### Automated Release Pipeline
After a PR is merged to master, the release pipeline runs automatically:
1. **Release workflow** (`.gitea/workflows/release.yml`):
- Triggers on push to master
- Sets up full dev environment (`make setup`) so lint and tests can run
- Runs `scripts/ci/release.py` which:
- **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`,
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
- Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **aborts immediately** — no commit, no tag
- Commits with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes 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/ci/notify_failure.py`
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using `scripts/ci/classify_changes.py`:
**Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file
types from accidentally skipping releases.
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/**` — Gitea Actions workflows
- `scripts/ci/**` — CI/CD automation scripts
- `scripts/setup.sh`, `scripts/molecule_all.sh`, `scripts/__init__.py` — Shell scripts and package init
- `docs/**` — Documentation
- `tests/**` — Test files
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config
- `.env.example`, `.gitignore`, `.ruff.toml` — Config
- `hooks/**` — Git hooks
**User-facing paths** (tool changes → release needed) — everything else:
- `src/gitea_runner_manager/**` — Python CLI source
- `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata
- `scripts/check_test_speed.py`, `scripts/configure_repo.py`, `scripts/install_checkmake.py` — Dev tools
- Any new file type not in the allowlist
**Script directory structure:**
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `setup.sh`, `molecule_all.sh`
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `doc_coverage.py`, `sync_wiki.py`, etc.
**CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
- **Release dry-run**: Only runs when user-facing files change
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: Skips entirely when no user-facing files changed since last tag
**AI agents must follow these rules:**
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
- Do NOT bump the version or create tags for workflow-only changes
- The `classify_changes.py` script enforces this automatically — no manual intervention needed
- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`.
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
- Triggers on tag push (`v*`)
- 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
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
### 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. This
ensures all merged work appears in the changelog.
### 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) |
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` |
## 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`)
## Ansible Role Structure
```
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)
## Molecule Scenarios
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
Platform list is defined in `scripts/ci/distribute_molecule.py` (single source of truth)
## 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
## Documentation-as-Code
All documentation lives in `/docs/` and is synced to the Gitea wiki automatically.
### Structure
```
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping
├── user/ # User documentation
│ ├── getting-started.md
│ ├── installation.md
│ ├── cli-commands.md
│ ├── troubleshooting.md
│ └── faq.md
└── tech/ # Technical documentation
├── architecture.md
├── development-setup.md
├── ci-cd-workflow.md
├── testing-strategy.md
├── decision-log.md
└── contributing.md
```
### Wiki Sync
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
- **On release tag**: Same sync runs, plus the wiki is tagged with the release version
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md``Getting-Started`)
- README.md is a lean entry point with links to the wiki — no detailed content
### Documentation Coverage
- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented
- Runs as a CI step in the quality job
- Goal: 100% coverage for public CLI commands and major architectural components
### Updating Documentation
1. Edit files in `/docs/`
2. If adding a new page, add it to `docs/mapping.json`
3. Commit and create a PR (standard PR workflow)
4. On merge, wiki is automatically synced
+148
View File
@@ -0,0 +1,148 @@
# Changelog
All notable changes to this project will be documented in this file.
## [0.4.0] - 2026-06-21
### Features
- Smart CI and release skipping for workflow-only changes
### Bug Fixes
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
- Wiki links, add --strict integrity check for wiki sync (#34)
### Other
- V0.3.2
### Refactor
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
## [0.3.2] - 2026-06-21
### Bug Fixes
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
### Other
- Smart CI and release skipping for workflow-only changes
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
## [0.3.1] - 2026-06-21
### Bug Fixes
- Use correct Gitea 1.26 wiki API endpoints
## [0.3.0] - 2026-06-21
### Features
- Implement documentation-as-code with wiki sync and doc-coverage
## [0.2.2] - 2026-06-21
### Bug Fixes
- Enforce tests pass before tagging a release
- Bypass commit-msg hook for release commits
## [0.2.1] - 2026-06-21
### Bug Fixes
- Strip git-cliff header from CHANGELOG.md updates
## [0.2.0] - 2026-06-21
### Features
- Fix 12 critical workflow gaps in release pipeline
### Bug Fixes
- Release push permission and notify_failure label IDs
## [Unreleased]
### Added
- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push.
- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation.
- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases.
- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available).
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`).
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
### Changed
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
- `gitea_runner_data_dir` — Runtime data directory
- `gitea_runner_config_dir` — Config directory
- `gitea_runner_binary_path` — Binary install path
- `gitea_runner_prune_until` — Prune age filter
- `gitea_runner_prune_schedule` — Prune timer schedule
- `gitea_runner_prune_label` — Docker label for pruning
- `gitea_runner_service_restart_sec` — systemd restart interval
- `gitea_runner_service_user` — Service user
- `gitea_runner_log_level` — Runner log level
- `gitea_runner_container_label` — Container label
- `gitea_runner_file` — Runner metadata file
- `docker_gpg_key_path` — Docker GPG key path
- Added `console_scripts` entry point in `pyproject.toml` (`grm = "gitea_runner_manager.cli:cli"`).
- Added shared `molecule/common/prepare.yml` to eliminate duplicated prepare playbooks.
- Extracted repeated systemd availability check into `tasks/systemd_check.yml`.
- Added idempotence checks to all Molecule scenarios.
- Comprehensive README overhaul with Architecture, Configuration, Development, Testing, and Troubleshooting sections.
- API URLs and repo configuration in `config.py` are now overridable via environment variables (`GRM_GITEA_API_URL`, `GRM_VIKUNJA_API_URL`, `GRM_REPO_OWNER`, `GRM_REPO_NAME`, `GRM_VIKUNJA_PROJECT_ID`).
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup.
### Security
- **Critical fix**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line (CWE-214). Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
### Changed
- Replaced legacy runner terminology with `gitea_runner` / `gitea-runner` / `Gitea Runner`.
- Updated default Docker image from `gitea/gitea_runner` to `gitea/runner`.
- `Makefile` now uses the installed `grm` console script instead of `python grm`.
- `pyproject.toml` ruff and pyright target versions updated from `py311` to `py312` to match `requires-python = ">=3.12"`.
- `BRANCH_PROTECTION_CONFIG` updated with correct Gitea Actions status check contexts (including `(pull_request)` suffix) and `required_approvals: 0` for auto-merge.
- `CONVENTIONAL_RE` no longer matches `BREAKING CHANGE` as a commit type (it is a footer, not a type).
- `rootless_docker.yml` apt cache update now only runs when the Docker repo file changes (idempotent, but always refreshes on first add).
- `service.yml` and `prune.yml` template creation tasks are not guarded by `docker_rootless_setup` (templates just create files, they don't need Docker; molecule tests set `docker_rootless_setup: false` but still verify the service file exists).
- `molecule_all.sh` now sources the platform list from `distribute_molecule.py` to avoid duplication.
### Removed
- Deleted `setup.py` (redundant with `pyproject.toml`).
- Deleted `grm` shell entrypoint script (replaced by `console_scripts`).
- Deleted `initial-plan.md` and `tests/integration/test_provision.py` (dead code).
- Removed empty `__init__.py` files from `tests/` directories.
- Removed unused `runner_validated` fact from `validate.yml`.
- Removed duplicate `prune.yml` and `integration_test.yml` includes from `install_runner.yml` (already included from `main.yml`).
- Removed dead `tasks/config.yml` (never included by any playbook).
- Removed dead `templates/gitea-runner.service.j2` (legacy system-level service, replaced by rootless `gitea-runner-user.service.j2`).
- Removed dead "Reload systemd" handler (system-level reload, never notified, wrong scope for user services).
- Removed dead `scripts/run_molecule_parallel.py` and its test (replaced by `molecule_ci_guard.py`).
### Fixed
- Molecule idempotence failures caused by non-idempotent service restart.
- Missing `/etc/docker` directory handling in Molecule tests.
- `ansible-lint` formatting warnings (yaml empty lines).
- Verify playbooks now explicitly load role defaults so parameterized variables are available during verification.
- Duplicate execution of prune and integration test tasks during installation (were included from both `main.yml` and `install_runner.yml`).
- apt cache update reporting `changed` on every run due to `cache_valid_time: 0`.
+58
View File
@@ -0,0 +1,58 @@
# Contributing to GRM
Thank you for contributing to Gitea Runner Manager (GRM)!
## Branch Naming
All feature branches **must** include a `GRM-N` prefix corresponding to the Vikunja task identifier. Examples:
- `GRM-19`
- `GRM-19-fix-bug`
- `GRM-42-add-update-command`
The `GRM-N` prefix is mandatory — CI extracts it for merge messages and Vikunja updates.
## Commit Format
### Feature branches
Use **conventional commits** on feature branches:
```
feat: add new command
fix: resolve timeout issue
chore: update dependencies
docs: improve README
```
Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `ci`, `build`, `revert`, `BREAKING CHANGE`.
**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches.
### Master branch (squash merges)
Squash commits on `master` must follow:
```
GRM-N: <conventional commit message>
```
Example: `GRM-24: fix: resolve molecule idempotence`.
This format is enforced by the auto-merge workflow, which validates the PR title is a conventional commit before squash-merging and prepending the task ID.
## Local Testing
```bash
make test-all # Runs pytest-cov + molecule
make lint-all # Runs ruff, pyright, bandit, ansible-lint, checkmake
make lint-bandit # Security scan with bandit
make pytest-cov # Unit tests with 100% coverage enforcement
make molecule # All 6 molecule scenarios
```
## Code Quality
- **ruff**: Line length 120
- **pyright**: Strict mode
- **bandit**: Security scan for Python code (no high/medium severity issues)
- **Test coverage**: 100% required
- **ansible-lint**: For all Ansible content
+232
View File
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
grm
Copyright (C) 2026 emil
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
grm Copyright (C) 2026 emil
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+118
View File
@@ -0,0 +1,118 @@
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean
PYTHON := python3
VENV := .venv
BIN := $(VENV)/bin
CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/checkmake)
all: setup
setup: $(VENV)/bin/activate .env activate-scripts checkmake
@bash scripts/setup.sh "$(BIN)"
.env:
@if [ ! -f .env ]; then \
cp .env.example .env; \
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
$(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel
activate-scripts: $(VENV)/bin/activate
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
install-hooks:
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@echo "Git hooks installed."
checkmake:
@python3 scripts/install_checkmake.py
install:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
update:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make update HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
start:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
stop:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
enable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
disable:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
status:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
remove:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
lint-ruff:
$(BIN)/ruff check src/ tests/
lint-format:
$(BIN)/ruff format --check src/ tests/
typecheck:
$(BIN)/pyright
lint: lint-ruff lint-format typecheck lint-bandit
lint-bandit:
$(BIN)/bandit -r src/ scripts/ scripts/ci/
ansible-lint:
$(BIN)/ansible-lint ansible/
makefile-lint:
@$(CHECKMAKE) Makefile
lint-all: lint ansible-lint makefile-lint
test-unit:
$(BIN)/pytest tests/unit/ -v --no-cov
test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov
pytest-cov:
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100
MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
# Quick local test: Ubuntu 22.04 only, all scenarios
molecule:
@set -e; for s in default multi-instance lifecycle template-content deregister update; do if [ "$$s" = "default" ]; then $(MOLECULE_BASE) test; else $(MOLECULE_BASE) test -s $$s; fi; done
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
molecule-all:
@bash scripts/molecule_all.sh
test: test-all
test-all: pytest-cov molecule
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
rm -rf .coverage htmlcov/ .molecule/
+45
View File
@@ -0,0 +1,45 @@
# Gitea Runner Manager (GRM)
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts.
> **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).
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
## Quick Start
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
make setup
cp .env.example .env # Edit with your Gitea URL and registration token
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
## Documentation
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
### User Documentation
- [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started) — Installation, quick start, first run
- [Installation](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Installation) — Prerequisites, setup, multiple instances
- [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands) — All commands with arguments and options
- [Troubleshooting](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) — Common issues and solutions
- [FAQ](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/FAQ) — Frequently asked questions
### Technical Documentation
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions
- [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup) — Environment setup, dependencies, local testing
- [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CI-CD-Workflow) — How CI works, release process, branch protection
- [Testing Strategy](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Testing-Strategy) — Unit, integration, and Molecule tests
- [Decision Log](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Decision-Log) — Key technical decisions and rationale
- [Contributing Guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing-Guide) — Coding standards, PR workflow, commit rules
## License
GPL-3.0
+17
View File
@@ -0,0 +1,17 @@
# Troubleshooting
| 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` |
+34
View File
@@ -0,0 +1,34 @@
---
- name: Disable Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
+28
View File
@@ -0,0 +1,28 @@
---
- name: Enable Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Enable gitea-runner user service
ansible.builtin.command: systemctl --user enable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
+3
View File
@@ -0,0 +1,3 @@
---
# Default variables for all hosts
ansible_python_interpreter: /usr/bin/python3
+6
View File
@@ -0,0 +1,6 @@
---
- name: Install Gitea Actions runner
hosts: all
become: true
roles:
- role: gitea-runner
+13
View File
@@ -0,0 +1,13 @@
# Gitea Runner Manager inventory example
# Each line represents a target host for runner installation.
#
# Required variables per host:
# ansible_user — SSH login user
# ansible_ssh_private_key_file — Path to SSH private key
#
# Optional variables per host:
# gitea_runner_version=1.0.8 — Runner binary version
[runners]
192.168.1.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519
runner.example.com ansible_user=arch ansible_ssh_private_key_file=~/.ssh/id_ed25519
+109
View File
@@ -0,0 +1,109 @@
---
- name: Remove Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Get runner user UID
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
register: runner_uid_result
changed_when: false
failed_when: false
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ runner_uid_result.stdout }}"
when: runner_uid_result.rc == 0
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
changed_when: true
failed_when: false
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
- name: Remove systemd user unit file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
state: absent
when: remove_systemd_template | default(false)
- name: Kill remaining processes of runner user
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
failed_when: false
changed_when: true
- name: Wait for processes to terminate
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
failed_when: false
changed_when: false
- name: Disable lingering for runner user
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
failed_when: false
changed_when: true
- name: Remove runner user and home directory
ansible.builtin.user:
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
state: absent
remove: true
when: remove_runner_user | default(true)
failed_when: false
- name: Remove subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
state: absent
failed_when: false
- name: Remove subgid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
state: absent
failed_when: false
- name: Remove runner data directory
ansible.builtin.file:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}"
state: absent
- name: Remove runner config directory
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}"
state: absent
+5
View File
@@ -0,0 +1,5 @@
collections:
- name: community.general
version: ">=13.0.1"
- name: ansible.posix
version: ">=1.5.4"
@@ -0,0 +1,40 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-22.04"
skip_runner_registration: false
# Per-runner user (rootless isolation)
gitea_runner_user_prefix: "grm-"
gitea_runner_base_home: "/home"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ runner_name }}"
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# Base paths (instance-scoped via runner_name)
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
gitea_runner_binary_path: "/usr/local/bin/gitea_runner"
# Prune configuration
gitea_runner_prune_until: "24h"
gitea_runner_prune_schedule: "daily"
gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Runner configuration
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# Docker installation (for rootless dependencies)
docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
docker_apt_source_line: >-
deb [arch={{ docker_apt_arch }} signed-by={{ docker_gpg_key_path }}]
https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
{{ ansible_facts['distribution_release'] }} stable
# Set to false in CI/molecule to skip rootless daemon startup (needs kernel userns)
docker_rootless_setup: true
@@ -0,0 +1,12 @@
---
- name: Restart gitea-runner
ansible.builtin.command: systemctl --user restart gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- ansible_facts is defined
- ansible_facts['service_mgr'] | default('') == 'systemd'
- docker_rootless_setup
@@ -0,0 +1,34 @@
---
- name: Prepare
hosts: all
become: true
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 0
when: ansible_facts['os_family'] == 'Debian'
- name: Install prerequisites for rootless Docker role (Debian/Ubuntu)
ansible.builtin.apt:
name:
- curl
- gpg
- python3-debian
- ca-certificates
state: present
when: ansible_facts['os_family'] == 'Debian'
- name: Update pacman cache
community.general.pacman:
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
- name: Install prerequisites for rootless Docker role (Arch Linux)
community.general.pacman:
name:
- curl
- gnupg
- ca-certificates
state: present
when: ansible_facts['os_family'] == 'Archlinux'
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,39 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,77 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "molecule-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check runner user exists
ansible.builtin.user:
name: "{{ gitea_runner_service_user }}"
register: user_info
check_mode: true
- name: Assert runner user exists
ansible.builtin.assert:
that:
- user_info.state == "present"
fail_msg: "Runner system user was not created"
- name: Check runner binary exists
ansible.builtin.stat:
path: "{{ gitea_runner_binary_path }}"
register: binary_stat
- name: Assert runner binary exists
ansible.builtin.assert:
that:
- binary_stat.stat.exists
fail_msg: "Gitea runner binary is missing"
- name: Check systemd user service exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert user service exists
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd user service is missing"
- name: Check instance data directory exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_dir_stat
- name: Assert instance data directory exists
ansible.builtin.assert:
that:
- data_dir_stat.stat.exists
fail_msg: "Instance data directory is missing"
- name: Check config file exists in config directory
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}/config.yaml"
register: config_stat
- name: Assert config file exists
ansible.builtin.assert:
that:
- config_stat.stat.exists
fail_msg: "Config file is missing"
- name: Check prune timer exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: timer_stat
- name: Assert prune timer exists
ansible.builtin.assert:
that:
- timer_stat.stat.exists
fail_msg: "Docker prune timer is missing"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "deregister-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,40 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
side_effect: side_effect.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,33 @@
---
- name: Create fake runner registration file
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Ensure fake .runner file exists
ansible.builtin.copy:
dest: "{{ gitea_runner_data_dir }}/.runner"
content: |
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Deregister runner
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: false
tasks:
- name: Include deregistration tasks
ansible.builtin.include_role:
name: gitea-runner
tasks_from: deregister.yml
@@ -0,0 +1,32 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check registration file was removed
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Assert registration file no longer exists
ansible.builtin.assert:
that:
- not runner_file_stat.stat.exists
fail_msg: "Registration file (.runner) was not removed by deregistration"
- name: Check systemd user service still exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert user service still exists after deregister
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd user service was incorrectly removed"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "lifecycle-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,40 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
side_effect: side_effect.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,40 @@
---
- name: Stop runner instance
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Stop gitea-runner user service
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user stop gitea-runner"
changed_when: true
failed_when: false
- name: Disable gitea-runner user service
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user disable gitea-runner"
changed_when: true
failed_when: false
- name: Re-enable and start runner
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Enable gitea-runner user service
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user enable gitea-runner"
changed_when: true
failed_when: false
- name: Start gitea-runner user service
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user start gitea-runner"
changed_when: true
failed_when: false
@@ -0,0 +1,32 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check systemd user service exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert user service exists
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd user service is missing"
- name: Check instance data directory exists after lifecycle
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_dir_stat
- name: Assert instance data directory exists
ansible.builtin.assert:
that:
- data_dir_stat.stat.exists
fail_msg: "Instance data directory is missing after lifecycle"
@@ -0,0 +1,24 @@
---
- name: Converge first runner instance
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-a"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
- name: Converge second runner instance
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-b"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,39 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,74 @@
---
- name: Verify
hosts: all
become: true
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check first runner user exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_home }}/grm-molecule-runner-a"
register: home_a_stat
- name: Assert first runner user home exists
ansible.builtin.assert:
that:
- home_a_stat.stat.exists
fail_msg: "First runner user home is missing"
- name: Check second runner user exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_home }}/grm-molecule-runner-b"
register: home_b_stat
- name: Assert second runner user home exists
ansible.builtin.assert:
that:
- home_b_stat.stat.exists
fail_msg: "Second runner user home is missing"
- name: Check first instance data directory exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_data_dir }}/molecule-runner-a"
register: data_a_stat
- name: Assert first instance data directory exists
ansible.builtin.assert:
that:
- data_a_stat.stat.exists
fail_msg: "First instance data directory is missing"
- name: Check second instance data directory exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_data_dir }}/molecule-runner-b"
register: data_b_stat
- name: Assert second instance data directory exists
ansible.builtin.assert:
that:
- data_b_stat.stat.exists
fail_msg: "Second instance data directory is missing"
- name: Check first instance config exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_config_dir }}/molecule-runner-a/config.yaml"
register: config_a_stat
- name: Assert first instance config exists
ansible.builtin.assert:
that:
- config_a_stat.stat.exists
fail_msg: "First instance config file is missing"
- name: Check second instance config exists
ansible.builtin.stat:
path: "{{ gitea_runner_base_config_dir }}/molecule-runner-b/config.yaml"
register: config_b_stat
- name: Assert second instance config exists
ansible.builtin.assert:
that:
- config_b_stat.stat.exists
fail_msg: "Second instance config file is missing"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "template-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,39 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,61 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "template-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check systemd user service exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert user service exists
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd user service is missing"
- name: Read rendered user service template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_template
- name: Assert user service template contains expected directives
ansible.builtin.assert:
that:
- "'Type=simple' in service_template.content | b64decode"
- "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
- "'Restart=on-failure' in service_template.content | b64decode"
- "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
- "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
fail_msg: "User service template is missing expected directives"
- name: Read rendered prune service template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
register: prune_service
- name: Assert prune service contains expected directives
ansible.builtin.assert:
that:
- "'Type=oneshot' in prune_service.content | b64decode"
- "'docker system prune' in prune_service.content | b64decode"
- "'docker volume prune' in prune_service.content | b64decode"
fail_msg: "Prune service template is missing expected directives"
- name: Read rendered prune timer template
ansible.builtin.slurp:
src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: prune_timer
- name: Assert prune timer contains expected directives
ansible.builtin.assert:
that:
- "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
- "'Persistent=true' in prune_timer.content | b64decode"
fail_msg: "Prune timer template is missing expected directives"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "update-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -0,0 +1,40 @@
---
driver:
name: docker
platforms:
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: false
provisioner:
name: ansible
playbooks:
converge: converge.yml
prepare: ../common/prepare.yml
side_effect: side_effect.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,11 @@
---
- name: Update runner
hosts: all
become: true
vars:
runner_name: "update-test-runner"
tasks:
- name: Include update tasks
ansible.builtin.include_role:
name: gitea-runner
tasks_from: update_runner.yml
@@ -0,0 +1,43 @@
---
- name: Verify
hosts: all
become: true
vars:
runner_name: "update-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check runner binary still exists after update
ansible.builtin.stat:
path: "{{ gitea_runner_binary_path }}"
register: binary_stat
- name: Assert binary executable exists after update
ansible.builtin.assert:
that:
- binary_stat.stat.exists
fail_msg: "Runner binary missing after update"
- name: Check systemd user service still exists
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert user service exists after update
ansible.builtin.assert:
that:
- service_stat.stat.exists
fail_msg: "Systemd user service missing after update"
- name: Check instance data directory still exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_stat
- name: Assert data directory exists after update
ansible.builtin.assert:
that:
- data_stat.stat.exists
fail_msg: "Runner data directory missing after update"
@@ -0,0 +1,46 @@
---
- name: Check if runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner with Gitea via CLI
ansible.builtin.command: >
{{ gitea_runner_binary_path }} delete
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--no-interactive
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
register: deregister_output
changed_when: deregister_output.rc == 0
failed_when: false
- name: Remove runner registration file
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner"
state: absent
when: runner_file_stat.stat.exists | default(false) | bool
@@ -0,0 +1,48 @@
---
- name: Get latest gitea_runner release info
ansible.builtin.uri:
url: https://gitea.com/api/v1/repos/gitea/runner/releases/latest
return_content: true
body_format: json
headers:
Accept: application/json
register: gitea_runner_release
when: gitea_runner_version | default('latest') == 'latest'
changed_when: false
retries: 3
delay: 5
until: gitea_runner_release is not failed
- name: Set gitea_runner version from latest release
ansible.builtin.set_fact:
gitea_runner_version: "{{ gitea_runner_release.json.tag_name }}"
when: gitea_runner_version | default('latest') == 'latest'
- name: Set gitea_runner download version (strip v prefix)
ansible.builtin.set_fact:
gitea_runner_download_version: "{{ gitea_runner_version | regex_replace('^v', '') }}"
- name: Set gitea_runner download URL
ansible.builtin.set_fact:
gitea_runner_url: >-
{{ 'https://gitea.com/gitea/runner/releases/download/v' ~ gitea_runner_download_version
~ '/gitea-runner-' ~ gitea_runner_download_version ~ '-linux-'
~ (ansible_facts['architecture'] | regex_replace('x86_64', 'amd64')) }}
- name: Ensure /usr/local/bin directory exists
ansible.builtin.file:
path: /usr/local/bin
state: directory
mode: "0755"
- name: Download gitea_runner binary
ansible.builtin.get_url:
url: "{{ gitea_runner_url }}"
dest: "{{ gitea_runner_binary_path }}"
mode: "0755"
force: false
register: gitea_runner_download
notify: Restart gitea-runner
retries: 3
delay: 5
until: gitea_runner_download is not failed
@@ -0,0 +1,21 @@
---
- name: Include gitea_runner download
ansible.builtin.include_tasks: download_gitea_runner.yml
- name: Create gitea_runner config file
ansible.builtin.template:
src: gitea-runner-config.yaml.j2
dest: "{{ gitea_runner_config_dir }}/config.yaml"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Include validation
ansible.builtin.include_tasks: validate.yml
- name: Include registration
ansible.builtin.include_tasks: register.yml
when: not skip_runner_registration
- name: Include service setup
ansible.builtin.include_tasks: service.yml
@@ -0,0 +1,102 @@
---
- name: Check runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner user service active
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: service_check
changed_when: false
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Validate runner installation
ansible.builtin.fail:
msg: >
Runner '{{ runner_name }}' is not properly installed:
{% if not (runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed.
{% endif %}
{% if docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active' %}
- Systemd user service is not active.
{% endif %}
when: >
not (runner_file_stat.stat.exists | default(false))
or (docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active')
- name: Report runner status
ansible.builtin.debug:
msg: >
Runner '{{ runner_name }}' is installed and running.
Registered: {{ runner_file_stat.stat.exists | default(false) }}
{% if runner_reg.id is defined %}Runner ID: {{ runner_reg.id }}{% endif %}
{% if runner_reg.uuid is defined %}UUID: {{ runner_reg.uuid }}{% endif %}
{% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %}
Service: {{ service_check.stdout | default('unknown') | trim }}
- name: Optional Gitea API verification
when:
- gitea_url is defined
- gitea_admin_token is defined
- gitea_admin_token | length > 0
block:
- name: Check admin runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/admin/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: admin_api_response
ignore_errors: true
- name: Check repo runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: repo_api_response
ignore_errors: true
- name: Report API status (informational only)
ansible.builtin.debug:
msg: >
API checks (informational only — not used for pass/fail):
Admin API: {{ admin_api_response.status | default('no response') }}.
Repo API: {{ repo_api_response.status | default('no response') }}.
{% if admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in admin API.
{% endif %}
{% if repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in repo API.
{% endif %}
rescue:
- name: API check failed
ansible.builtin.debug:
msg: "API verification skipped due to connection or permission error."
+19
View File
@@ -0,0 +1,19 @@
---
- name: Include systemd availability check
ansible.builtin.include_tasks: systemd_check.yml
- name: Include user setup
ansible.builtin.include_tasks: user_setup.yml
- name: Include rootless Docker setup
ansible.builtin.include_tasks: rootless_docker.yml
- name: Include runner install
ansible.builtin.include_tasks: install_runner.yml
- name: Include prune setup
ansible.builtin.include_tasks: prune.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
@@ -0,0 +1,38 @@
---
- name: Create docker-prune user service file
ansible.builtin.template:
src: docker-prune.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Create docker-prune user timer file
ansible.builtin.template:
src: docker-prune.timer.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Reload systemd user daemon for prune timer
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
@@ -0,0 +1,33 @@
---
- name: Ensure work directory exists
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
- name: Register runner with Gitea
ansible.builtin.command: >
{{ gitea_runner_binary_path }} register
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--labels {{ runner_labels }}
--no-interactive
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when: not runner_registered.stat.exists
register: register_output
changed_when: "'already exists' not in register_output.stdout | default('')"
timeout: 60
@@ -0,0 +1,112 @@
---
- name: Ensure keyrings directory exists (Debian/Ubuntu)
ansible.builtin.file:
path: "/etc/apt/keyrings"
state: directory
mode: "0755"
when: ansible_facts['os_family'] == 'Debian'
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
ansible.builtin.shell: |
set -o pipefail
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
args:
creates: "{{ docker_gpg_key_path }}"
executable: /bin/bash
when: ansible_facts['os_family'] == 'Debian'
- name: Add Docker APT repository (Debian/Ubuntu)
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.list
content: "{{ docker_apt_source_line }}\n"
mode: "0644"
register: docker_apt_repo
when: ansible_facts['os_family'] == 'Debian'
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
ansible.builtin.apt:
update_cache: true
when:
- ansible_facts['os_family'] == 'Debian'
- docker_apt_repo is changed
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
name:
- uidmap
- slirp4netns
- fuse-overlayfs
- docker-ce
- docker-ce-cli
- docker-ce-rootless-extras
- containerd.io
- docker-compose-plugin
- rsync
state: present
when: ansible_facts['os_family'] == 'Debian'
- name: Update pacman cache (Arch Linux)
community.general.pacman:
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
changed_when: false
- name: Install rootless Docker dependencies (Arch Linux)
community.general.pacman:
name:
- docker
- docker-compose
- slirp4netns
- fuse-overlayfs
- rsync
state: present
when: ansible_facts['os_family'] == 'Archlinux'
- name: Check if rootless Docker is already set up
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
register: rootless_docker_check
- name: Set up rootless Docker for runner user
ansible.builtin.command: dockerd-rootless-setuptool.sh install
args:
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- docker_rootless_setup
- not rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user start docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
- name: Wait for rootless Docker daemon to be ready
ansible.builtin.command: docker version
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_ready
until: docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: docker_rootless_setup
@@ -0,0 +1,30 @@
---
- name: Create systemd user service file
ansible.builtin.template:
src: gitea-runner-user.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Reload systemd user daemon
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
@@ -0,0 +1,5 @@
---
- name: Check if systemd is available
ansible.builtin.stat:
path: /run/systemd/system
register: systemd_available
@@ -0,0 +1,14 @@
---
- name: Include gitea_runner download
ansible.builtin.include_tasks: download_gitea_runner.yml
- name: Restart gitea-runner user service
ansible.builtin.command: systemctl --user restart gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- systemd_available.stat.exists | default(false) | bool
- docker_rootless_setup
changed_when: true
@@ -0,0 +1,70 @@
---
- name: Create per-runner system user
ansible.builtin.user:
name: "{{ gitea_runner_service_user }}"
home: "{{ gitea_runner_home }}"
shell: /bin/bash
system: true
create_home: true
register: runner_user
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ runner_user.uid }}"
- name: Check if lingering is already enabled
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}"
register: linger_stat
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ gitea_runner_service_user }}:"
line: "{{ gitea_runner_service_user }}:100000:65536"
create: true
mode: "0644"
- name: Ensure subgid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ gitea_runner_service_user }}:"
line: "{{ gitea_runner_service_user }}:100000:65536"
create: true
mode: "0644"
- name: Ensure XDG_RUNTIME_DIR exists
ansible.builtin.file:
path: "/run/user/{{ gitea_runner_uid }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0700"
- name: Ensure runner data directory exists
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Ensure runner config directory exists
ansible.builtin.file:
path: "{{ gitea_runner_config_dir }}"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
- name: Ensure systemd user directory exists
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/systemd/user"
state: directory
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0755"
@@ -0,0 +1,26 @@
---
- name: Check gitea_runner binary exists
ansible.builtin.stat:
path: "{{ gitea_runner_binary_path }}"
register: gitea_runner_stat
- name: Fail if gitea_runner binary is missing
ansible.builtin.fail:
msg: "gitea_runner binary not found at {{ gitea_runner_binary_path }}"
when: not gitea_runner_stat.stat.exists
- name: Verify gitea_runner is executable
ansible.builtin.command: "{{ gitea_runner_binary_path }} --version"
register: gitea_runner_version_output
changed_when: false
- name: Verify rootless Docker connectivity
ansible.builtin.command: docker version
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_version_output
changed_when: false
when: docker_rootless_setup
@@ -0,0 +1,9 @@
[Unit]
Description=Docker prune for Gitea runner resources
[Service]
Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
@@ -0,0 +1,9 @@
[Unit]
Description=Daily Docker prune for Gitea runner resources
[Timer]
OnCalendar={{ gitea_runner_prune_schedule }}
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
log:
level: "{{ gitea_runner_log_level }}"
runner:
file: "{{ gitea_runner_file }}"
fetch_timeout: 50s
fetch_interval: 2s
container:
label: "{{ gitea_runner_container_label }}"
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
@@ -0,0 +1,17 @@
[Unit]
Description=Gitea Actions Runner (rootless)
After=docker.service
[Service]
Type=simple
ExecStart={{ gitea_runner_binary_path }} daemon --config {{ gitea_runner_config_dir }}/config.yaml
WorkingDirectory={{ gitea_runner_data_dir }}
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStop=/bin/kill -TERM $MAINPID
TimeoutStopSec=30
Restart=on-failure
RestartSec={{ gitea_runner_service_restart_sec }}
[Install]
WantedBy=default.target
+32
View File
@@ -0,0 +1,32 @@
---
- name: Start Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
register: runner_registered
- name: Include registration if not registered
ansible.builtin.include_role:
name: gitea-runner
tasks_from: register.yml
when:
- not runner_registered.stat.exists
- not skip_runner_registration | default(false)
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
+34
View File
@@ -0,0 +1,34 @@
---
- name: Status of Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Check systemd user service status
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
register: service_status
changed_when: false
when: systemd_available.stat.exists
- name: Report service status
ansible.builtin.debug:
msg: "Service gitea-runner: {{ service_status.stdout | default('unknown') | trim }}"
when: systemd_available.stat.exists
- name: Check runner registration file
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
register: runner_file_stat
- name: Report runner registration
ansible.builtin.debug:
msg: "Runner registration file exists: {{ runner_file_stat.stat.exists | default(false) }}"
+19
View File
@@ -0,0 +1,19 @@
---
- name: Stop Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
changed_when: true
+10
View File
@@ -0,0 +1,10 @@
---
- name: Update Gitea Actions runner
hosts: all
become: true
vars: {}
tasks:
- name: Update runner
ansible.builtin.include_role:
name: gitea-runner
tasks_from: update_runner.yml
+67
View File
@@ -0,0 +1,67 @@
# git-cliff configuration for GRM
# https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
trim = true
render_always = true
[git]
conventional_commits = true
filter_unconventional = true
require_conventional = false
split_commits = false
protect_breaking_commits = false
filter_commits = false
fail_on_unmatched_commit = false
use_branch_tags = false
topo_order = false
topo_order_commits = true
sort_commits = "oldest"
recurse_submodules = false
commit_preprocessors = [
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
{ pattern = "^GRM-\\d+\\s+", replace = "" },
]
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->Security" },
{ message = "^revert", group = "<!-- 9 -->Revert" },
{ message = ".*", group = "<!-- 10 -->Other" },
]
[bump]
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="20" role="img"
aria-label="coverage: 100%">
<title>coverage: 100%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="104" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="66" height="20" fill="#555"/>
<rect x="66" width="38" height="20" fill="#4c1"/>
<rect width="104" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="33" y="14">coverage</text>
<text x="85" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 894 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="76" height="20" role="img"
aria-label="docs: 100%">
<title>docs: 100%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="76" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="38" height="20" fill="#555"/>
<rect x="38" width="38" height="20" fill="#4c1"/>
<rect width="76" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="19" y="14">docs</text>
<text x="57" y="14">100%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 879 B

+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 |
+271
View File
@@ -0,0 +1,271 @@
# 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/ci/review_pr.py`:
```bash
REPO_TOKEN=<token> python3 scripts/ci/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/ci/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/ci/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/ci/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/ci/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/ci/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/ci/publish.py` for build and publish orchestration
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
- Triggers on `pull_request` labeled events
- Runs `scripts/ci/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/ci/post_merge.py` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project uses
`scripts/ci/classify_changes.py` to classify changed files into two categories:
**User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source
- `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
**CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any
user-facing files changed since the last tag. If not, the release is skipped
entirely — no version bump, no tag, no publish.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically. The `discover-runners` job runs `scripts/ci/discover_runners.py` which queries the Gitea API for
registered runners at three levels (repo, org, instance) and generates
a matrix of runner indices. If the API query fails (e.g., no admin
access for instance-level runners), it falls back to the
`MOLECULE_RUNNERS` repo variable, then to a default of 3.
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
matrix, and passes the runner count to `distribute_molecule.py
--max-runners` so test pairs are evenly distributed.
When adding or removing Gitea runners:
1. If runners are registered at the repo/org level, they're auto-detected
2. If runners are at the instance level, update the `MOLECULE_RUNNERS` repo variable
3. The workflow automatically scales the matrix to match available runners
## 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` |
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 2 seconds.
set -e
python3 scripts/check_test_speed.py
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# pre-push hook: fail if unit tests take longer than 2 seconds.
set -e
python3 scripts/check_test_speed.py
+73
View File
@@ -0,0 +1,73 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gitea-runner-manager"
dynamic = ["version"]
description = "Lean CLI to manage Gitea Actions runners"
readme = "README.md"
license = {text = "GPL-3.0"}
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
dependencies = [
"requests>=2.34.2",
"python-dotenv>=1.2.2",
"click>=8.4.1",
"ansible>=14.0.0",
]
[project.scripts]
grm = "gitea_runner_manager.cli:cli"
[tool.setuptools.dynamic]
version = {attr = "gitea_runner_manager.__version__"}
[project.optional-dependencies]
dev = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.17",
"pyright>=1.1.410",
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"bandit>=1.8.2",
"pre-commit>=4.6.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
[tool.ruff]
target-version = "py312"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"]
ignore = ["SIM117"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pyright]
include = ["src", "scripts", "scripts/ci"]
pythonVersion = "3.12"
strict = ["src/gitea_runner_manager"]
-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="90" height="20" role="img"
aria-label="python: 3.12">
<title>python: 3.12</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#007ec6"/>
<rect width="90" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="26" y="14">python</text>
<text x="71" y="14">3.12</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 888 B

-20
View File
@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124" height="20" role="img"
aria-label="code quality: A">
<title>code quality: A</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="124" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="94" height="20" fill="#555"/>
<rect x="94" width="30" height="20" fill="#4c1"/>
<rect width="124" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="47" y="14">code quality</text>
<text x="109" y="14">A</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

View File
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Run unit tests and enforce a maximum execution-time budget.
Usage:
python3 scripts/check_test_speed.py [--max-seconds N]
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import click
from gitea_runner_manager.i18n import _
DEFAULT_MAX_SECONDS = 2.0
TEST_COMMAND = ["make", "test-unit"]
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr)."""
result = subprocess.run( # nosec B603
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
return result.stdout, result.stderr
def parse_duration(output: str) -> float:
"""Extract elapsed seconds from pytest summary line.
Raises:
click.ClickException: when the timing line cannot be found.
"""
for line in output.splitlines():
match = _TIMING_RE.search(line)
if match:
return float(match.group(2))
raise click.ClickException(_("Could not parse test execution time from output."))
def check_speed(duration: float, max_seconds: float) -> None:
"""Validate duration is within budget; raise on violation."""
if duration > max_seconds:
raise click.ClickException(
_(
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
duration=duration,
max=max_seconds,
)
)
def main(max_seconds: float) -> None:
"""Run tests, parse timing, and enforce the budget."""
stdout, stderr = run_tests()
combined = stdout + "\n" + stderr
click.echo(combined, err=False)
duration = parse_duration(combined)
check_speed(duration, max_seconds)
click.echo(
_(
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
duration=duration,
max=max_seconds,
)
)
@click.command()
@click.option(
"--max-seconds",
type=float,
default=DEFAULT_MAX_SECONDS,
show_default=True,
help="Maximum allowed execution time in seconds.",
)
def cli(max_seconds: float) -> None:
main(max_seconds)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover

Some files were not shown because too many files have changed in this diff Show More