Compare commits

...
107 Commits
Author SHA1 Message Date
grm-ci-bot 0eb033419f release: v0.12.3 [skip ci] 2026-06-29 10:40:45 +00:00
emil df4b7f2a19 GRM-118: fix: improve runner service stability and deregistration
Post-merge / detect-type (push) Successful in 1m16s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m27s
Post-merge / configure-repo (push) Successful in 1m27s
Post-merge / vikunja (push) Successful in 1m31s
Post-merge / badges (push) Successful in 1m33s
Post-merge / sync-wiki (push) Successful in 1m43s
Post-merge / publish (push) Successful in 46s
2026-06-29 10:38:32 +00:00
gitea-actions-bot 312a706d39 chore: update badge URLs to commit 695921d6 [skip ci] 2026-06-28 17:09:22 +00:00
emil ea2f0cc600 GRM-117: fix: fix wiki link URLs, heading hierarchy, quote pip install vars
Post-merge / detect-type (push) Successful in 51s
Post-merge / release (push) Successful in 54s
Post-merge / validate-commit-msg (push) Successful in 1m5s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m17s
Post-merge / vikunja (push) Successful in 1m26s
Post-merge / configure-repo (push) Successful in 1m12s
Post-merge / sync-wiki (push) Successful in 1m34s
2026-06-28 17:07:11 +00:00
gitea-actions-bot bd8e13ee66 chore: update badge URLs to commit 622165d8 [skip ci] 2026-06-28 15:06:01 +00:00
grm-ci-bot 41fc36ff4a release: v0.12.2 [skip ci] 2026-06-28 15:05:36 +00:00
emil e585543e9d GRM-116: fix: bump devx to 0.26.3 (latest with pinned deps)
Post-merge / detect-type (push) Successful in 56s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m15s
Post-merge / vikunja (push) Successful in 1m20s
Post-merge / badges (push) Successful in 1m34s
Post-merge / sync-wiki (push) Successful in 2m0s
Post-merge / configure-repo (push) Successful in 1m26s
Post-merge / publish (push) Successful in 1m0s
2026-06-28 15:03:33 +00:00
gitea-actions-bot c62c35f5b6 chore: update badge URLs to commit ab26a799 [skip ci] 2026-06-28 14:57:29 +00:00
grm-ci-bot 4b900ce673 release: v0.12.1 [skip ci] 2026-06-28 14:56:55 +00:00
emil cae66e0743 GRM-114: fix: add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
Post-merge / detect-type (push) Successful in 1m6s
Post-merge / release (push) Successful in 1m13s
Post-merge / validate-commit-msg (push) Successful in 1m17s
Post-merge / vikunja (push) Successful in 1m18s
Post-merge / badges (push) Successful in 1m25s
Post-merge / configure-repo (push) Successful in 1m14s
Post-merge / sync-wiki (push) Successful in 1m56s
Post-merge / publish (push) Successful in 1m4s
2026-06-28 14:54:53 +00:00
gitea-actions-bot 0b3a76c550 chore: update badge URLs to commit b73d161e [skip ci] 2026-06-28 13:15:36 +00:00
grm-ci-bot a4d5ba6b70 release: v0.12.0 [skip ci] 2026-06-28 13:15:08 +00:00
emil d9ce4e240f GRM-113: feat: upgrade all dependencies, add trigger-workflow command
Post-merge / vikunja (push) Successful in 1m13s
Post-merge / configure-repo (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m33s
Post-merge / detect-type (push) Successful in 55s
Post-merge / release (push) Successful in 1m19s
Post-merge / validate-commit-msg (push) Successful in 1m19s
Post-merge / publish (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m54s
2026-06-28 13:13:06 +00:00
gitea-actions-bot c1f68f115a chore: update badge URLs to commit dfe10dfc [skip ci] 2026-06-28 11:41:38 +00:00
grm-ci-bot fdf1293c85 release: v0.11.1 [skip ci] 2026-06-28 11:41:20 +00:00
emil 01b3f594f7 GRM-112: fix: Makefile HOST/NAME requirement errors, add restart and list targets
Post-merge / detect-type (push) Successful in 48s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m3s
Post-merge / vikunja (push) Successful in 1m3s
Post-merge / badges (push) Successful in 1m11s
Post-merge / sync-wiki (push) Successful in 1m35s
Post-merge / configure-repo (push) Successful in 58s
Post-merge / publish (push) Successful in 57s
2026-06-28 11:39:37 +00:00
gitea-actions-bot 9ffa7a3671 chore: update badge URLs to commit cc0e4b3f [skip ci] 2026-06-28 11:23:32 +00:00
grm-ci-bot f04be9c39b release: v0.11.0 [skip ci] 2026-06-28 11:23:13 +00:00
emil f67dff8458 GRM-111: feat: unified --become-password-file, --verbose, --no-status, labels fix
Post-merge / detect-type (push) Successful in 51s
Post-merge / release (push) Successful in 1m14s
Post-merge / validate-commit-msg (push) Successful in 1m14s
Post-merge / vikunja (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 1m53s
Post-merge / publish (push) Successful in 1m21s
Post-merge / configure-repo (push) Successful in 1m20s
2026-06-28 11:21:11 +00:00
gitea-actions-bot 763f7640af chore: update badge URLs to commit b95f5ede [skip ci] 2026-06-28 02:15:53 +00:00
emil f8eeea611f GRM-110: chore: bump devx dependency to 0.26.0
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m12s
Post-merge / validate-commit-msg (push) Successful in 1m14s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m22s
Post-merge / badges (push) Successful in 1m27s
Post-merge / configure-repo (push) Successful in 1m9s
Post-merge / sync-wiki (push) Successful in 1m51s
2026-06-28 02:13:28 +00:00
gitea-actions-bot 774bf479ab chore: update badge URLs to commit 63396d99 [skip ci] 2026-06-28 00:25:55 +00:00
emil 844171ee93 GRM-109: chore: bump devx to >=0.25.0, fix pr_review docs
Post-merge / detect-type (push) Successful in 1m0s
Post-merge / release (push) Successful in 59s
Post-merge / validate-commit-msg (push) Successful in 1m8s
Post-merge / sync-wiki (push) Successful in 1m45s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m8s
Post-merge / configure-repo (push) Successful in 1m7s
2026-06-28 00:22:45 +00:00
gitea-actions-bot 9a5be879a2 chore: update badge URLs to commit e7d92aa8 [skip ci] 2026-06-27 22:30:14 +00:00
emil f24ed4c963 GRM-108: ci: bump devx>=0.23.4 for classification fix and --auto-login on notify_failure
Post-merge / detect-type (push) Successful in 49s
Post-merge / release (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m12s
Post-merge / badges (push) Successful in 1m20s
Post-merge / sync-wiki (push) Successful in 1m42s
Post-merge / configure-repo (push) Successful in 59s
2026-06-27 22:27:57 +00:00
gitea-actions-bot d1e1d05be5 chore: update badge URLs to commit e8fef3da [skip ci] 2026-06-27 20:28:52 +00:00
gitea-actions-bot dbb9bd7108 chore: update badge URLs to commit b316c4a8 [skip ci] 2026-06-27 20:26:55 +00:00
grm-ci-bot f905550aba release: v0.10.3
Post-merge / detect-type (push) Successful in 1m9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 56s
2026-06-27 20:26:33 +00:00
emil cae4e2a860 GRM-107: refactor: use devx Makefile aliases, bump devx>=0.23.0
Post-merge / detect-type (push) Successful in 1m3s
Post-merge / release (push) Successful in 1m8s
Post-merge / validate-commit-msg (push) Successful in 1m9s
Post-merge / vikunja (push) Successful in 1m13s
Post-merge / badges (push) Successful in 1m19s
Post-merge / sync-wiki (push) Successful in 1m44s
Post-merge / publish (push) Successful in 1m3s
Post-merge / configure-repo (push) Successful in 1m18s
2026-06-27 20:24:29 +00:00
gitea-actions-bot efbd24daec chore: update badge URLs to commit fc36a6d7 [skip ci] 2026-06-27 17:55:55 +00:00
emil 9066ef9724 GRM-106: fix: add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN
Post-merge / detect-type (push) Successful in 49s
Post-merge / release (push) Successful in 1m5s
Post-merge / validate-commit-msg (push) Successful in 1m7s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m12s
Post-merge / badges (push) Successful in 1m16s
Post-merge / sync-wiki (push) Successful in 1m33s
Post-merge / configure-repo (push) Successful in 57s
2026-06-27 17:53:45 +00:00
gitea-actions-bot 96770a770e chore: update badge URLs to commit 5c7cd006 [skip ci] 2026-06-27 17:41:15 +00:00
emil e753b34788 GRM-105: fix: revert EXTRAS=ci default in setup-image
Post-merge / detect-type (push) Successful in 43s
Post-merge / validate-commit-msg (push) Successful in 1m4s
Post-merge / configure-repo (push) Failing after 1m6s
Post-merge / vikunja (push) Successful in 1m7s
Post-merge / release (push) Successful in 1m12s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 1m27s
Post-merge / badges (push) Successful in 1m27s
2026-06-27 17:38:59 +00:00
gitea-actions-bot 48422b18e5 chore: update badge URLs to commit 1504e628 [skip ci] 2026-06-27 16:56:13 +00:00
emil 190157cce6 GRM-104: refactor: remove hadolint on-the-fly install workaround
Post-merge / detect-type (push) Successful in 53s
Post-merge / release (push) Successful in 1m3s
Post-merge / validate-commit-msg (push) Successful in 1m10s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m10s
Post-merge / badges (push) Successful in 1m20s
Post-merge / sync-wiki (push) Successful in 1m43s
Post-merge / configure-repo (push) Successful in 50s
2026-06-27 16:53:57 +00:00
gitea-actions-bot 1d0a082044 chore: update badge URLs to commit 0f5e8e82 [skip ci] 2026-06-27 16:40:47 +00:00
emil 799d36f254 GRM-103: fix: install hadolint on-the-fly in setup-image
Post-merge / detect-type (push) Successful in 54s
Post-merge / release (push) Successful in 1m10s
Post-merge / validate-commit-msg (push) Successful in 1m13s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 1m15s
Post-merge / configure-repo (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m21s
Post-merge / sync-wiki (push) Successful in 1m42s
2026-06-27 16:38:30 +00:00
gitea-actions-bot e0d43b0ed8 chore: update badge URLs to commit 8e0b18b4 [skip ci] 2026-06-27 16:34:11 +00:00
gitea-actions-bot 3189161f61 chore: update badge URLs to commit ed92e075 [skip ci] 2026-06-27 16:32:16 +00:00
grm-ci-bot c339698603 release: v0.10.2
Post-merge / detect-type (push) Successful in 1m2s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Successful in 57s
2026-06-27 16:31:58 +00:00
emil cbf082c78f GRM-102: fix: bump devx>=0.22.0 and remove REPO_TOKEN alias
Post-merge / detect-type (push) Successful in 48s
Post-merge / release (push) Successful in 1m0s
Post-merge / validate-commit-msg (push) Successful in 1m2s
Post-merge / vikunja (push) Successful in 1m0s
Post-merge / badges (push) Successful in 1m9s
Post-merge / sync-wiki (push) Successful in 1m38s
Post-merge / configure-repo (push) Successful in 1m3s
Post-merge / publish (push) Successful in 55s
2026-06-27 16:30:16 +00:00
gitea-actions-bot 4070135fda chore: update badge URLs to commit 5c243201 [skip ci] 2026-06-27 15:51:35 +00:00
emil ace0176e3a GRM-101: refactor: rename REPO_TOKEN to CI_GITEA_TOKEN, consolidate env vars
Post-merge / detect-type (push) Successful in 47s
Post-merge / release (push) Failing after 14s
Post-merge / validate-commit-msg (push) Successful in 59s
Post-merge / vikunja (push) Successful in 56s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 48s
Post-merge / badges (push) Successful in 1m8s
Post-merge / sync-wiki (push) Successful in 1m14s
2026-06-27 15:49:36 +00:00
gitea-actions-bot fda1d99d86 chore: update badge URLs to commit 42a9384e [skip ci] 2026-06-27 13:49:17 +00:00
emil 465f45d939 GRM-100: fix: gate auto-merge on release-dry-run and unmask failures
Post-merge / detect-type (push) Successful in 42s
Post-merge / release (push) Failing after 11s
Post-merge / validate-commit-msg (push) Successful in 48s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 41s
Post-merge / badges (push) Successful in 56s
Post-merge / sync-wiki (push) Successful in 1m16s
Post-merge / configure-repo (push) Successful in 40s
2026-06-27 13:47:30 +00:00
gitea-actions-bot 103beaa0e8 chore: update badge URLs to commit 6b1270e1 [skip ci] 2026-06-27 13:30:20 +00:00
emil 2e97578269 GRM-99: fix: setup-image configures Gitea PyPI registry and shows pip errors
Post-merge / detect-type (push) Successful in 42s
Post-merge / release (push) Failing after 14s
Post-merge / validate-commit-msg (push) Successful in 53s
Post-merge / vikunja (push) Successful in 52s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 54s
Post-merge / badges (push) Successful in 1m16s
Post-merge / sync-wiki (push) Successful in 1m25s
2026-06-27 13:27:59 +00:00
gitea-actions-bot c31ec312ac chore: update badge URLs to commit b3f0d6da [skip ci] 2026-06-27 13:16:12 +00:00
emil c67b810e58 GRM-98: ci: add --auto-login to publish, bump devx>=0.21.0
Post-merge / release (push) Failing after 11s
Post-merge / detect-type (push) Successful in 45s
Post-merge / validate-commit-msg (push) Successful in 52s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 52s
Post-merge / configure-repo (push) Successful in 51s
Post-merge / badges (push) Successful in 1m13s
Post-merge / sync-wiki (push) Successful in 1m14s
2026-06-27 13:14:13 +00:00
gitea-actions-bot ef16b07cdf chore: update badge URLs to commit 9addc544 [skip ci] 2026-06-27 12:34:11 +00:00
emil 2c849c7324 GRM-97: ci: use pre-built tier images for all CI workflows
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 57s
Post-merge / release (push) Successful in 48s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 46s
Post-merge / badges (push) Successful in 1m9s
Post-merge / configure-repo (push) Successful in 41s
Post-merge / sync-wiki (push) Successful in 1m11s
2026-06-27 12:32:13 +00:00
gitea-actions-bot 5804a18974 chore: update badge URLs to commit a7e1f518 [skip ci] 2026-06-27 00:24:48 +00:00
gitea-actions-bot 9dbe20ba73 chore: update badge URLs to commit 63466f88 [skip ci] 2026-06-27 00:21:48 +00:00
grm-ci-bot b92c87ba68 release: v0.10.1
Post-merge / detect-type (push) Successful in 1m58s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 1m23s
2026-06-27 02:21:19 +02:00
emil dc4430d160 GRM-96: ci: add molecule test weights to pyproject.toml
Post-merge / detect-type (push) Successful in 1m12s
Post-merge / release (push) Successful in 1m38s
Post-merge / validate-commit-msg (push) Successful in 1m39s
Post-merge / badges (push) Successful in 2m8s
Post-merge / vikunja (push) Successful in 2m12s
Post-merge / sync-wiki (push) Successful in 2m48s
Post-merge / configure-repo (push) Successful in 1m30s
Post-merge / publish (push) Successful in 1m27s
2026-06-27 00:18:31 +00:00
gitea-actions-bot 7aa0ebaefe chore: update badge URLs to commit 182f11ca [skip ci] 2026-06-26 19:42:47 +00:00
emil e93da43219 GRM-95: refactor: consolidate publish.yml into post-merge.yml
Post-merge / detect-type (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m45s
Post-merge / vikunja (push) Successful in 1m42s
Post-merge / configure-repo (push) Successful in 1m41s
Post-merge / validate-commit-msg (push) Successful in 2m1s
Post-merge / release (push) Successful in 2m19s
Post-merge / sync-wiki (push) Successful in 2m29s
Post-merge / publish (push) Has been skipped
2026-06-26 19:39:50 +00:00
gitea-actions-bot b131a2872d chore: update badge URLs to commit 886112ce [skip ci] 2026-06-26 19:17:41 +00:00
emil e4dd8f308f GRM-94: refactor: replace duplicated Makefile targets with devx.mak aliases
Post-merge / detect-type (push) Successful in 1m12s
Post-merge / release (push) Successful in 1m29s
Post-merge / validate-commit-msg (push) Successful in 1m47s
Post-merge / badges (push) Successful in 1m54s
Post-merge / vikunja (push) Successful in 1m56s
Post-merge / sync-wiki (push) Successful in 2m4s
Post-merge / configure-repo (push) Successful in 1m19s
2026-06-26 19:14:33 +00:00
gitea-actions-bot 467e0d66e6 chore: update badge URLs to commit c2d3c4bb [skip ci] 2026-06-26 18:06:12 +00:00
emil 6ee5b74bb5 GRM-93: ci: use make setup-ci consistently, decouple vikunja/sync-wiki from release
Post-merge / detect-type (push) Successful in 1m18s
Post-merge / validate-commit-msg (push) Successful in 1m45s
Post-merge / badges (push) Successful in 1m53s
Post-merge / vikunja (push) Successful in 1m54s
Post-merge / release (push) Successful in 2m13s
Post-merge / configure-repo (push) Successful in 1m53s
Post-merge / sync-wiki (push) Successful in 2m29s
2026-06-26 18:03:02 +00:00
gitea-actions-bot 21cc89899f chore: update badge URLs to commit c29fbfe0 [skip ci] 2026-06-26 15:54:42 +00:00
emil 0382e155a6 GRM-92: fix: set PYTHONPATH=src in publish Install CI tools step
Post-merge / detect-type (push) Successful in 18s
Post-merge / validate-commit-msg (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 40s
Post-merge / release (push) Successful in 1m48s
Post-merge / vikunja (push) Successful in 7s
Post-merge / badges (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 1m36s
2026-06-26 15:51:06 +00:00
gitea-actions-bot d87c0d7e9a chore: update badge URLs to commit b19d4b06 [skip ci] 2026-06-26 17:18:30 +02:00
gitea-actions-bot 4be480a18e chore: update badge URLs to commit f916dabb [skip ci] 2026-06-26 15:18:03 +00:00
grm-ci-bot de92f675ed release: v0.10.0
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Publish Release / publish (push) Failing after 16s
Post-merge / badges (push) Successful in 1m55s
2026-06-26 17:16:19 +02:00
emil b5803a8611 GRM-91: feat: adopt devx tools, devx.mak fragment, ci extra, remove legacy install-devx
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Successful in 1m41s
Post-merge / vikunja (push) Successful in 12s
Post-merge / badges (push) Successful in 1m37s
Post-merge / sync-wiki (push) Successful in 2m0s
2026-06-26 15:14:33 +00:00
gitea-actions-bot ec22d20a15 chore: update badge URLs to commit 47c7e239 [skip ci] 2026-06-26 12:03:12 +02:00
emil e96012af7f GRM-90: ci: bump devx version to v0.14.1
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 7s
Post-merge / release (push) Successful in 1m29s
Post-merge / vikunja (push) Successful in 13s
Post-merge / badges (push) Successful in 1m23s
Post-merge / sync-wiki (push) Successful in 2m0s
2026-06-26 10:00:03 +00:00
gitea-actions-bot addef7500c chore: update badge URLs to commit dd820c3d [skip ci] 2026-06-26 00:13:49 +02:00
emil 8a0d2c428b GRM-88: docs: fix AGENTS.md squash-merge format to use colon after task ID
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Failing after 1m16s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 1m8s
2026-06-25 22:11:11 +00:00
gitea-actions-bot d68fb6d272 chore: update badge URLs to commit 9b029d3a [skip ci] 2026-06-25 23:17:41 +02:00
emil 6721864b87 GRM-87: fix: always run publish in post-merge (idempotent) 2026-06-25 21:15:03 +00:00
gitea-actions-bot 06471d1403 chore: update badge URLs to commit c61f4cb6 [skip ci] 2026-06-25 01:23:53 +02:00
grm-ci-bot 1a04971622 release: v0.9.0 [skip ci] 2026-06-25 01:22:18 +02:00
emil 91440c1b0a GRM-86: feat: add Polish as officially supported language 2026-06-24 23:20:59 +00:00
gitea-actions-bot 3bb8d75748 chore: update badge URLs to commit ea0b5002 [skip ci] 2026-06-24 22:53:41 +00:00
emil d4a8172a25 GRM-85: docs: add Gitea PyPI registry instructions for pip install 2026-06-24 22:51:10 +00:00
gitea-actions-bot 2199ec0bfe chore: update badge URLs to commit b70a95f6 [skip ci] 2026-06-25 00:42:14 +02:00
emil 9ae9a76b11 GRM-84: feat: adopt devx v0.11.1 across Makefile and workflows 2026-06-24 22:39:35 +00:00
gitea-actions-bot 31d0eeae98 chore: update badge URLs to commit eaa85dd7 [skip ci] 2026-06-25 00:31:14 +02:00
grm-ci-bot acc768eaea release: v0.8.1 [skip ci] 2026-06-25 00:29:27 +02:00
emil cf2314c20f GRM-83: fix: add build/twine to ci deps, activate venv in notify_failure 2026-06-24 22:28:08 +00:00
gitea-actions-bot aac47e3472 chore: update badge URLs to commit a7a72d7d [skip ci] 2026-06-25 00:14:31 +02:00
emil 811e7a2309 GRM-82: fix: repair publish workflow and add publish step to post-merge 2026-06-24 22:11:48 +00:00
gitea-actions-bot cb68c85bb5 chore: update badge URLs to commit 123065d0 [skip ci] 2026-06-24 21:06:40 +00:00
emil 0ba30e09ea GRM-81: ci: update devx to v0.10.2 for badge testpaths fix 2026-06-24 21:03:53 +00:00
gitea-actions-bot 6a02581687 chore: update badge URLs to commit e32f1c05 [skip ci] 2026-06-24 22:45:35 +02:00
grm-ci-bot 4679473183 release: v0.8.0 [skip ci] 2026-06-24 22:44:06 +02:00
emil 6359eab962 GRM-80: ci: update devx to v0.10.1 for badge generation fix 2026-06-24 20:42:27 +00:00
emil 2017a5ee3e GRM-79: feat: remove .taskid file, use branch name only for task ID 2026-06-24 20:15:59 +00:00
emil 465e9bd484 GRM-78: fix: add workflow_dispatch to publish workflow and update devx to 0.9.12 2026-06-24 20:05:54 +00:00
grm-ci-bot da0656b949 release: v0.7.0 [skip ci] 2026-06-24 21:19:57 +02:00
emil 64ab0f059b GRM-75: feat: thoroughly clean Docker artifacts on runner removal
## Summary

Thoroughly cleans Docker artifacts on runner removal, updates devx to v0.9.11, fixes Makefile checkmake graceful skip, and comprehensive docs rewrite.

Molecule tests fail due to pre-existing Docker infrastructure issue (Docker socket not available in CI runners).

Closes GRM-75
2026-06-24 19:18:23 +00:00
emil a58f5ec301 GRM-77: fix: replace stale badge SHA URLs with raw/branch/badges/ 2026-06-24 17:30:17 +00:00
emil 1dc20025d8 GRM-76: fix: retrospective fixes for CI/CD friction 2026-06-24 16:47:01 +00:00
emil e6918a9be9 GRM-74: fix: lower test speed threshold to 4s and update devx to v0.8.2 2026-06-23 20:47:42 +00:00
emil c5d8cbef5a GRM-73: feat: adopt per-test timing quality gate from devx 0.7.0 2026-06-23 16:51:52 +00:00
emil 6032a07038 GRM-72: feat: switch devx installation from git to Gitea PyPI registry 2026-06-23 14:16:44 +00:00
emil af251ffdaa GRM-70: fix: rewrite CHANGELOG with correct version ordering and missing sections 2026-06-22 22:23:32 +00:00
emil 493051b79b GRM-69: fix: pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki 2026-06-22 21:49:27 +00:00
emil cb94709091 GRM-68: fix: pin devx to v0.4.3 to fix post-merge workflow failures 2026-06-22 21:36:28 +00:00
emil 7987778a4f GRM-67: fix: update devx to v0.4.2 and fix workflow env vars 2026-06-22 21:30:23 +00:00
grm-ci-bot 488a7ee048 release: v0.6.3 [skip ci] 2026-06-22 23:13:00 +02:00
emil 7fca2a3ebd GRM-66: fix: add scripts/** to infrastructure classification config 2026-06-22 21:09:42 +00:00
grm-ci-bot f14ef14dc6 release: v0.6.2 [skip ci] 2026-06-22 22:28:42 +02:00
emil 6758b69a5f GRM-65: fix: pin devx to v0.4.0, fix cliff.toml preprocessor, bump to v0.7.0 2026-06-22 20:27:11 +00:00
72 changed files with 4795 additions and 1955 deletions
+19 -2
View File
@@ -15,7 +15,7 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# 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
# CI_GITEA_TOKEN=your-admin-api-token
# Integration test API retries (optional, default: 3).
# Number of times to retry API checks waiting for runner to appear.
@@ -24,6 +24,9 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# Default SSH user for remote hosts (optional, overrides --user)
# GITEA_RUNNER_USER=ubuntu
# Repository for grm trigger-workflow (optional, default: oblachno-oss/grm)
# GRM_REPO=oblachno-oss/grm
# Default SSH private key path (optional, overrides --key)
# GITEA_RUNNER_KEY=~/.ssh/id_ed25519
@@ -34,9 +37,23 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# 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
# Supported: en, bg, de, ru, zh, pl
# GRM_LANG=en
# Sudo password file for Ansible become operations (optional)
# When set, GRM reads the sudo password from this file instead of prompting.
# Priority: --become-password-file CLI flag > GRM_BECOME_PASSWORD_FILE > ANSIBLE_BECOME_PASSWORD_FILE
# GRM_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
# ANSIBLE_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
# Gitea PyPI registry username (for private package access)
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
CI_GITEA_USERNAME=emil
# Vikunja API token (required for `make create-task` dev workflow)
# Generate at: Vikunja → Settings → API Tokens
# VIKUNJA_TOKEN=your-vikunja-api-token
# devx configuration (GRM-specific overrides)
# Task prefix for Vikunja task IDs
DEVX_TASK_PREFIX=GRM
+103 -28
View File
@@ -5,16 +5,22 @@ on:
types: [opened, synchronize]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-quality
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint
- name: Lint all
run: |
. .venv/bin/activate
@@ -24,12 +30,27 @@ jobs:
run: |
. .venv/bin/activate
make pytest-cov
- name: Documentation lint check
env:
PYTHONPATH: src
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
pip install --upgrade devx \
--index-url "https://${CI_GITEA_USERNAME}:${CI_GITEA_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/" \
--no-deps
python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check
run: |
. .venv/bin/activate
python3 -m devx.ci.check_translations --translations src/gitea_runner_manager/translations.json
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 10
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Dependency security scan
run: |
. .venv/bin/activate
@@ -52,6 +73,7 @@ jobs:
needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
@@ -59,8 +81,9 @@ jobs:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Release dry-run validation
env:
PYTHONPATH: src
@@ -69,10 +92,11 @@ jobs:
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run || true
python3 -m devx.ci.release --dry-run
detect-changes:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
@@ -83,8 +107,9 @@ jobs:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Detect changed paths
id: detect
env:
@@ -101,6 +126,7 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
runner-count: ${{ steps.discover.outputs.runner-count }}
@@ -109,12 +135,13 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Discover available runners
id: discover
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
@@ -128,6 +155,7 @@ jobs:
needs: [quality, detect-changes, discover-runners]
if: needs.detect-changes.outputs.ansible-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
strategy:
matrix:
@@ -136,8 +164,13 @@ jobs:
- uses: actions/checkout@v4
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-molecule
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,molecule
- name: Install Ansible collections
run: |
. .venv/bin/activate
python3 -m devx.tools.setup --skip-install --no-pre-commit --no-tea-login
- name: Discover assigned test pairs
env:
RUNNER_INDEX: ${{ matrix.runner-index }}
@@ -154,57 +187,98 @@ jobs:
run: |
. .venv/bin/activate
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
if ! python3 -c "import docker; docker.from_env().ping()" 2>/dev/null; then
echo "Docker not available in CI container — skipping molecule tests"
exit 0
fi
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
env:
GITEA_URL: ${{ github.server_url }}
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
RUN_ID: ${{ github.run_id }}
ANSIBLE_INJECT_INVOCATION: "1"
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
DOCKER_HOST: unix:///var/run/docker.sock
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Run automated PR review
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# from .taskid file, validates the PR title, and squash-merges.
# No manual label or review needed — CI is the quality gate.
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it evaluates even when molecule-tests is skipped
# (Gitea Actions skips dependent jobs of skipped jobs by default).
needs: [quality, detect-changes, pr-review, molecule-tests, release-dry-run]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
- name: Install dependencies
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
. .venv/bin/activate
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review)."
- name: Squash merge with task ID
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
@@ -214,6 +288,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+132 -74
View File
@@ -1,38 +1,44 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# replaces the previous 4 separate workflows (release.yml, post-merge.yml,
# sync-wiki.yml, and the badges job from ci.yml).
# for release, publish, wiki sync, badges, and Vikunja task updates.
#
# Job dependency graph:
#
# detect-type ──┬── release (skip if release commit)
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (needs release — skip if release commit/fails)
# └── vikunja (needs release — skip if release commit/fails)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# sync-wiki and vikunja depend on release succeeding so that the wiki
# and task tracker are only updated when the code is actually released.
# If release fails, they are skipped to avoid leaving the wiki or
# Vikunja in an inconsistent state with the codebase on master.
# sync-wiki and vikunja run for ALL non-release commits, not just when
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job depends on release so it picks up the latest version
# number. It uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This
# ensures badges (tests, coverage, version, etc.) are always current.
# The badges job uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This ensures
# badges (tests, coverage, version, etc.) are always current.
#
# When release.py creates a "release: vX.Y.Z" commit, the release
# commit's post-merge run still updates badges (version badge picks
# up the new version). Other jobs skip. The tag push triggers publish.yml.
# When release creates a "release: vX.Y.Z" commit and tag, the publish
# job (which depends on release) builds and publishes the package to the
# Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
on:
push:
branches: [master]
workflow_dispatch:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
outputs:
is-release: ${{ steps.check.outputs.is-release }}
@@ -40,33 +46,40 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: python3 -m devx.ci.detect_release_commit
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Validate latest commit message
env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -75,21 +88,26 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Configure git
run: |
git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi"
- name: Run release
id: release-tag
env:
PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
@@ -102,23 +120,62 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Build and publish release
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \
"${{ needs.release.outputs.tag }}" \
"${{ github.repository }}" --auto-login
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
@@ -126,11 +183,12 @@ jobs:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Sync documentation to wiki
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
@@ -138,38 +196,37 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}"
badges:
needs: [detect-type, release]
needs: [detect-type]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.REPO_TOKEN }}
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=lint
- name: Generate and push badges
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
@@ -179,50 +236,48 @@ jobs:
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
--commit "${{ github.sha }}"
vikunja:
needs: [detect-type, release]
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Update Vikunja task
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
run: |
. .venv/bin/activate
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \
@@ -232,30 +287,33 @@ jobs:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: grm
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: python3 -m devx.tools.configure_repo
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
-51
View File
@@ -1,51 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install CI tools
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
- name: Install build tools
run: python3 -m pip install --break-system-packages build twine
- name: Configure tea login
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: |
export PATH="$HOME/.local/bin:$PATH"
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \
"${{ github.ref_name }}" \
"${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "publish" \
--commit "${{ github.sha }}"
+3
View File
@@ -38,3 +38,6 @@ activate.zsh
# Generated badges (CI pushes to badges branch)
.badges/
# Deprecated CI task tracking (branch name is the sole source of truth)
.taskid
-7
View File
@@ -64,10 +64,3 @@ repos:
types: [python]
pass_filenames: false
stages: [pre-push]
- id: commit-msg
name: validate commit message
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system
stages: [commit-msg]
pass_filenames: true
-1
View File
@@ -1 +0,0 @@
GRM-64
+47 -39
View File
@@ -17,11 +17,10 @@ make workflow-check # workflow-lint + workflow-dryrun
```
`make setup` automatically installs all development tools:
- **Python deps** via `devx.tools.setup` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
- **devx package** via `make install-devx` (installs the devx package from git, providing all CI/CD tools)
- **Python deps** via `pip install -e .[dev]` (includes devx from Gitea PyPI registry, configured by `make configure-gitea-pypi`)
- **Post-install setup** via `devx.tools.setup --skip-install` (ansible-galaxy, pre-commit hooks, tea CLI login)
- **checkmake** via `devx.tools.install_checkmake` (Makefile linter)
- **actionlint, git-cliff, act_runner, tea** via `devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
## Workflow Verification (Before Push)
@@ -69,7 +68,7 @@ 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.
Create a task in Vikunja project 6 via `make create-task -- --title "Task title" --description "<h2>...</h2>"` (requires `VIKUNJA_TOKEN` in `.env`). This prints the `GRM-N` identifier and next-step instructions.
### 2. Create Branch
```bash
@@ -91,14 +90,15 @@ docs: update README
```
### 5. Push and Create PR
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
- Push: `git push -u origin HEAD` (pre-push hook validates Vikunja task existence via `devx.tools.pre_push_check`)
- Create PR: `make create-pr` (creates a PR with title `GRM-N: <vikunja task title>`, auto-derived from the branch name and Vikunja task)
- Or both in one step: `make push-with-pr`
- 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 checklist:** Every PR is reviewed against
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering
**Review checklist:** Every PR is reviewed against 13 categories covering
architecture, code quality, security, i18n, testing, performance,
UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging.
@@ -119,20 +119,19 @@ the **[auto]** items in the checklist:
- Commit conventions (conventional commit format on PR commits)
The automated review posts inline comments on specific lines and
includes a link to the full checklist. The agent **must** address all
includes a summary of the checklist categories. The agent **must** address all
`REQUEST_CHANGES` issues before proceeding.
**Manual review (agent):** After the automated review passes, the agent
must go through **every category** in `REVIEW_CHECKLIST.md` and verify
must go through **every category** listed above and verify
the **[manual]** items by reviewing the full diff
(`git diff master...HEAD`).
Post review comments using `devx.ci.review_pr` (run as `python -m devx.ci.review_pr`):
Post review comments using `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`):
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
--body "Review summary"
```
### 7. Address Review Comments
@@ -142,10 +141,10 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
Once all checklist items are verified and comments are addressed, post
an approval review with `--checklist-confirmed` and `--checklist-categories`:
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
--body "All 13 checklist categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
```
The `--checklist-confirmed` flag is **required** for APPROVE events —
@@ -160,13 +159,13 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
2. **Check** that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
3. Wait for all CI checks to pass (including the `pr-review` job)
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated, no colon after GRM-N)
4. Squash-merge with title: `GRM-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes (see below)
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
> workflow by adding the `ready-to-merge` label. Manual merges bypass the
> `GRM-N <conventional>` format enforcement, producing incorrectly named commits.
> `GRM-N: <conventional>` format enforcement, producing incorrectly named commits.
> The auto-merge script validates the PR title matches the Vikunja task ID
> and conventional commit format before merging.
@@ -220,17 +219,22 @@ Vikunja task updates:
- `--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
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
Runs **after** the release job (even if release fails or is skipped) so the
version badge always reflects the latest state. The script fetches the
latest master before generating badges to pick up any release commits.
Uses `if: always()` so it runs on every push, including release commits.
The script fetches the latest master before generating badges to pick up
any release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only
changes still update the task tracker.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
which builds and publishes the package to PyPI.
6. **publish** — Runs after release succeeds (needs: release). Builds and
publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output.
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -244,21 +248,25 @@ via `[tool.devx.classify]` in `pyproject.toml`.
**Workflow-only paths** (infrastructure → no release needed):
- `.gitea/**` — Gitea Actions workflows
- `scripts/**` — Dev tools and CI/CD automation (not part of installed package)
- `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
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md`, `CONTRIBUTING.md` — Project docs
- `Makefile`, `cliff.toml`, `uv.lock` — Build tooling
- `.pre-commit-config.yaml`, `.ansible-lint`, `.checkmake.ini` — Lint config (ruff config is in `pyproject.toml`)
- `.env.example`, `.gitignore` — Config
- `.devin/**` — Agent/CI tooling config
- `hooks/**` — Git hooks
- `activate.sh`, `activate.fish`, `activate.zsh` — Generated venv scripts
**User-facing paths** (tool changes → release needed) — everything else:
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`)
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py`)
- `ansible/**` — Ansible role
- `pyproject.toml` — Package metadata
- Any new file type not in the allowlist
**devx module structure** (installed from git, not in this repo):
- `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, review_pr, validate_commit_msg
- `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, validate_commit_msg
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges
- `devx.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
- `devx.gitea_cli` — Tea CLI wrapper
@@ -299,7 +307,7 @@ The codebase enforces strict separation between the GRM tool and the devx packag
### tea CLI Integration
The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is installed by `devx.tools.install_tools` and configured by `devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is installed by `devx.tools.install_tools` and configured by `devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
**`devx.gitea_cli`** — Python wrapper around `tea` CLI with JSON output parsing:
- `TeaCLI.create_issue()` — Create issues with labels
@@ -314,7 +322,7 @@ The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is insta
- `devx.tools.configure_repo` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
**Operations still using `GiteaClient` (not supported by tea):**
- PR reviews (`devx.ci.review_pr`) — tea v0.14.1 only supports interactive reviews
- PR reviews (`devx.ci.pr_review`) — tea v0.14.1 only supports interactive reviews
- Wiki page management (`devx.ci.sync_wiki`)
- Commit status checks (`devx.ci.auto_merge`)
- Runner discovery (`devx.molecule.discover_runners`)
@@ -327,7 +335,7 @@ Since devx is installed as a package (via `pip install` from git), it is importa
| PYTHONPATH | When to use | Example modules |
|------------|-------------|-----------------|
| `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.review_pr`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
| `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.pr_review`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
| (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` |
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
@@ -347,18 +355,18 @@ platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
`devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
avoids dev tools importing directly from CI modules.
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
- Triggers on tag push (`v*`)
- Validates `PYPI_TOKEN` is set (warns if missing)
2. **Publish job** (in `post-merge.yml`, needs: release):
- Runs after the release job creates a tag
- Gets the tag from `needs.release.outputs.tag`
- Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Publishes to the Gitea PyPI registry
- Creates a Gitea release with git-cliff-generated release notes
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
### 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`
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.
@@ -380,7 +388,7 @@ The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, r
| 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` |
| Merge commit | `GRM-N: <conventional commit>` | `GRM-33: feat: add review script` |
### Configuration
+305 -170
View File
@@ -2,196 +2,331 @@
All notable changes to this project will be documented in this file.
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.6.1] - 2026-06-22
## [0.12.3] - 2026-06-29
### Bug Fixes
- Strengthen review process with deeper checks and structured checklist
- Close CI gaps with workflow dry-run, automated configure_repo, aligned timeouts
- Fix wiki link URLs, heading hierarchy, quote pip install vars
- Improve runner service stability and deregistration
## [0.6.0] - 2026-06-22
## [0.12.2] - 2026-06-28
### Bug Fixes
- Bump devx to 0.26.3 (latest with pinned deps)
## [0.12.1] - 2026-06-28
### Bug Fixes
- Add approval step to auto-merge workflow using REVIEW_GITEA_TOKEN
## [0.12.0] - 2026-06-28
### Features
- Add self-updating quality badges to README
- Enforce mandatory PR reviews with automated checks
- Upgrade all dependencies, add trigger-workflow command
## [0.11.1] - 2026-06-28
### Bug Fixes
- Makefile HOST/NAME requirement errors, add restart and list targets
## [0.11.0] - 2026-06-28
### Features
- Unified --become-password-file, --verbose, --no-status, labels fix
## [0.10.3] - 2026-06-27
### Bug Fixes
- Install hadolint on-the-fly in setup-image
- Revert EXTRAS=ci default in setup-image
- Add EXTRAS=ci to all setup-image calls, workflow-level CI_GITEA_TOKEN
### Refactor
- Remove hadolint on-the-fly install workaround
- Use devx Makefile aliases, bump devx>=0.23.0
## [0.10.2] - 2026-06-27
### Bug Fixes
- Setup-image configures Gitea PyPI registry and shows pip errors
- Gate auto-merge on release-dry-run and unmask failures
- Bump devx>=0.22.0 and remove REPO_TOKEN alias
### Refactor
- Rename REPO_TOKEN to CI_GITEA_TOKEN, consolidate env vars
## [0.10.1] - 2026-06-27
### Bug Fixes
- Set PYTHONPATH=src in publish Install CI tools step
### Refactor
- Replace duplicated Makefile targets with devx.mak aliases
- Consolidate publish.yml into post-merge.yml
## [0.10.0] - 2026-06-26
### Features
- Adopt devx tools, devx.mak fragment, ci extra, remove legacy install-devx
### Bug Fixes
- Always run publish in post-merge (idempotent)
## [0.9.0] - 2026-06-24
### Features
- Adopt devx v0.11.1 across Makefile and workflows
- Add Polish as officially supported language
## [0.8.1] - 2026-06-24
### Bug Fixes
- Repair publish workflow and add publish step to post-merge
- Add build/twine to ci deps, activate venv in notify_failure
## [0.8.0] - 2026-06-24
### Features
- Remove .taskid file, use branch name only for task ID
### Bug Fixes
- Add workflow_dispatch to publish workflow and update devx to 0.9.12
## [0.7.0] - 2026-06-24
### Features
- Switch devx installation from git to Gitea PyPI registry
- Adopt per-test timing quality gate from devx 0.7.0
### Bug Fixes
- Update devx to v0.4.2 and fix workflow env vars
- Pin devx to v0.4.3 to fix post-merge workflow failures
- Pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki
- Rewrite CHANGELOG with correct version ordering and missing sections
- Lower test speed threshold to 4s and update devx to v0.8.2
- Retrospective fixes for CI/CD friction
- Replace stale badge SHA URLs with raw/branch/badges/
## [0.6.4] - 2026-06-22
### Bug Fixes
- Update devx to v0.4.2 and fix workflow env vars
- Pin devx to v0.4.3 to fix post-merge workflow failures
- Pin devx to v0.4.4 to fix validate-commit-msg and sync-wiki
## [0.6.3] - 2026-06-22
### Bug Fixes
- Add scripts/** to infrastructure classification config
## [0.6.2] - 2026-06-22
### Bug Fixes
- Include lint extras in setup-ci and setup-release
- Use commit SHA URLs for badges to bypass Gitea cache
- Make sync-wiki and vikunja depend on release
- Pin devx to v0.4.0, fix cliff.toml preprocessor, bump to v0.7.0
### Refactor
- Fully automate PR merge — no manual label/review needed
- Require tea CLI everywhere, fail on missing Vikunja task
- Separate GRM and CI translations with validation
- Migrate from scripts/ to devx package
## [0.6.1] - 2026-06-22
### Bug Fixes
- Badges always update on release commits + fix configure-repo PYTHONPATH
- Enforce commit message convention on master with CI validation
- Post-merge workflow failures (4 jobs)
## [0.6.0] - 2026-06-22
### Bug Fixes
- Bridge test suite gaps — lint scripts, include integration tests
- Badge regex patterns and doc_coverage double-percent
- Generate self-contained SVG badges instead of shields.io JSON
- Auto_merge handles single-token workflow (self-approval)
- Workflow timing, timeouts, status polling, and release classification
- API resilience with retry, idempotent releases, and graceful Vikunja errors
- Release pipeline determinism with lock, rebase, and consistent classification
- Enforce conventional commit check in automated PR review
- Molecule-tests matrix runner-index renders as empty for 0
- Use 1-based runner indices for Gitea Actions compatibility
- Molecule-tests static matrix and role_dir path fix
- Auto-merge label condition uses pull_request.labels
- Badges job runs after release to reflect actual state
- Revert review_pr.py to GiteaClient (tea v0.14.1 is interactive-only) (#70)
- Fix broken automation pipeline (auto-merge, Vikunja, CI enforcement)
### Refactor
- Consolidate CI workflows to eliminate redundant runs
- Convert shell scripts and inline workflow scripts to Python
- Enforce script separation and document import rules
### Revert
- Remove v0.6.0 release (no user-facing changes)
## [0.5.0] - 2026-06-21
### Packaging
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth)
- Added `console_scripts` entry point (`grm = "gitea_runner_manager.cli:cli"`)
### Bug Fixes
- Arch Linux: pacman cache update now runs separately before package installation (fixes idempotence)
- `rootless_docker.yml`: separated `update_cache` from package installation task
### Internal
- Added `GiteaClient.create_issue`, `GiteaClient.get_pr_files`, `GiteaClient.create_review` API methods
- Added `VikunjaClient.get_task` method
- Config URLs and repo settings now overridable via environment variables
## [0.4.0] - 2026-06-21
### Rootless Docker Support
- Full rootless Docker installation and configuration via Ansible
- `docker_rootless_setup` variable controls whether rootless Docker tasks run
- User setup tasks (subuid/subgid, lingering, dockerd-rootless)
- Proper gating of all Docker-dependent and `systemctl --user` tasks
### Runner Labels
- `--labels` option on `grm install` — specify runner labels (e.g., `--labels "ubuntu-latest:docker://node:20"`)
- Labels passed through to runner config YAML
### Security Fix (CWE-214)
- **Critical**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line
- Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`
- This prevents secrets from being visible in the process list (`ps aux`)
### Configuration via Environment Variables
- 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`
### Ansible Role Improvements
- Dead code cleanup (removed `config.yml`, legacy system-level service, duplicate task includes)
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup
- Arch Linux: `gnupg` package name fix, pacman cache handling
- Docker APT repository: deb822 format, proper GPG handling, arch mapping
- Idempotence fixes for user_setup and download tasks
## [0.3.0] - 2026-06-21
### New CLI Options
- `--force` flag on `grm remove` — remove a runner even when the host is unreachable (skips Ansible playbook, only deregisters via API)
- `--url` option — override the Gitea URL for any command (useful for multiple Gitea instances)
- `--ask-become-pass` is now the default behavior (no need to pass it explicitly)
### Status Detection Fixes
- `grm list` now correctly retrieves runner status (was showing "unknown" for active runners)
- Docker mode status detection via `docker inspect`
- Host/user context added to status output
### Output Improvements
- Colorized output for better visual feedback (green/red/yellow)
- Translated operation reports for success and failure cases
- Dual logging: `click.echo()` for user-facing messages, `logging` for debug
- `GRM_LOG_LEVEL` environment variable for controlling verbosity
- Full i18n support (all user-facing strings translated)
### Internal Refactoring
- Validation moved from CLI layer to business layer
- Centralized API clients and HTTP status codes
- User-friendly Click errors with i18n
## [0.2.0] - 2026-06-21
### New CLI Commands
- `grm start <host>` — start a runner's systemd service
- `grm stop <host>` — stop a runner's systemd service
- `grm enable <host>` — enable a runner to start on boot
- `grm disable <host>` — disable a runner from starting on boot
- `grm status <host>` — check runner service status
- `grm remove <host>` — deregister and remove a runner
- `grm list-runners` — list all runners from the local registry
### Runner Registry
- Runners are tracked in `~/.config/grm/runners.toml` for simplified CLI usage
- No need to specify `--url`, `--user`, `--key` for every command — the registry remembers
### Multi-Instance Support
- systemd template units (`gitea-runner@.service`) for running multiple runners per host
- Per-instance config and data directories
### Ansible Role Improvements
- Parameterized all hardcoded configuration values as Ansible variables
- Idempotence fixes for repeated runs
- Runner config converted from TOML to YAML format
- Registration timeout to prevent indefinite hangs
- Docker container entrypoint override and working directory fix for `.runner` persistence
## [0.1.0] - 2026-06-21
### Initial Release
The first release of GRM, a lean CLI for managing Gitea Actions runners via SSH.
### CLI Commands
- `grm install <host>` — install and register a Gitea Runner on a remote host via SSH
- `grm token` — generate a registration token via the Gitea API
- `grm list` — list all registered runners
- `grm update <host>` — update a runner to the latest version
### Ansible Role
- Installs Gitea Runner binary in binary or Docker mode
- Registers runner with Gitea instance
- Configures systemd service
- Supports Arch Linux, Ubuntu, and Debian
## [0.5.0] - 2026-06-22
### Features
- SSH-based remote execution via Ansible
- Automatic registration token generation
- Docker and binary installation modes
- Integration test verification after installation
- Enforce commit naming conventions and workflow discipline
### Bug Fixes
- Clean up infrastructure-only releases and fix release classification
- Rewrite changelog and re-tag releases at user-facing milestones
## [0.4.0] - 2026-06-21
### Features
- Replace inline workflow scripts with tested Python modules
- User-friendly click errors with i18n in configure_repo
- Bandit integration (#1)
- Auto-delete branch after merge in configure_repo script
- Add runner labels support and refactor i18n to JSON
- Parallel molecule runner with kill-on-first-failure
- Cross-runner molecule cancellation via Gitea API polling
### Bug Fixes
- Set runner_mode to binary in multi-instance converge
- Skip systemd operations in lifecycle molecule when unavailable
- Improve make setup with version guard, pre-push hooks and commit-msg validator
- Enforce GRM-N: conventional on master commits and PR titles
- Remove molecule tests from pre-push hooks
- Resolve bandit security warnings in source code and tests
- CI pipeline for rootless Docker runners
- CI workflows for rootless runner compatibility
- Vikunja task resolution pagination in post_merge.py
- Use PUT instead of POST for Vikunja task comments
- Parse pytest output with warnings in check_test_speed
- Use systemd as container command for rootless molecule tests
- Add Docker APT repository before installing docker-ce
- Use deb822_repository for Docker APT repo (proper GPG handling)
- Dearmor Docker GPG key with gpg --dearmor for apt_repository
- Use bash for gpg dearmor (pipefail not available in sh)
- Install curl, gpg, ca-certificates in molecule prepare
- Separate apt update after adding Docker repo, use variable for repo string
- Add apt source debug tasks, fix arch mapping for Docker repo
- Fail-fast CI, write Docker apt source directly, fix arch mapping
- Skip rootless Docker daemon startup in molecule tests
- Gate all Docker-dependent tasks behind docker_rootless_setup
- Catch TimeoutExpired in parallel runner wait loop
- Stream molecule subprocess output to CI logs
- Run molecule pairs sequentially within each CI runner
- Guard all systemctl --user tasks with docker_rootless_setup
- Guard handler systemctl --user calls with docker_rootless_setup
- Make user_setup and download tasks idempotent
- Use gnupg instead of gpg package name on Arch Linux
- Add default(0) to gitea_runner_uid in environment blocks
- Set runner_name in deregister verify.yml
- Security, dead code, idempotence, and documentation cleanup
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
- Wiki links, add --strict integrity check for wiki sync (#34)
### Refactor
- Standardise pre-commit hooks on make targets
- Use http.HTTPStatus constants instead of magic numbers
- Rework all scripts to use click and i18n
- *(scripts)* Centralize constants, API clients, and HTTP status codes
- Rootless Docker, fix auto-merge, molecule platform matrix
## [0.3.2] - 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)
### Refactor
- 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
- Bypass commit-msg hook for release commits
- Enforce tests pass before tagging a release
## [0.2.1] - 2026-06-21
### Bug Fixes
- Strip git-cliff header from CHANGELOG.md updates
## [0.2.0] - 2026-06-18
### Features
- Parameterize all hardcoded configuration values as Ansible variables
- Add GITEA_ADMIN_TOKEN support for integration test
- Add AnsibleExecutor and i18n modules
- Integrate AnsibleExecutor and i18n into CLI and RunnerManager
- Add systemd template units and multi-instance Ansible support
- Add lifecycle CLI commands and RunnerManager extensions
- Add runner registry for simplified CLI UX
- Add translated operation report for success and failure cases
- Replace print() with stdlib logging module
- Use click.echo() for user-facing messages with dual logging
- Add colorized output for better visual feedback
- Add --force flag to grm remove for unreachable runners
- Make --ask-become-pass the default behavior
### Bug Fixes
- Resolve idempotence issues and testing infrastructure
- Remove recursive variable definitions in install and update playbooks
- Add timeout to runner registration to prevent indefinite hangs
- Override Docker container entrypoint to bypass run.sh wrapper
- Set Docker working dir to /data for .runner persistence
- Make integration test conditional on admin API accessibility
- Remove recursive var definitions from install-runner.yml
- Convert runner config from TOML to YAML format
- Rewrite integration test to verify .runner file and container health instead of unreliable API checks
- Eliminate duplicate console output, restore GRM_LOG_LEVEL filtering
- Make grm list retrieve runner status correctly
### Refactor
- Remove dead code and legacy artifacts
- Migrate source terminology from act_runner to gitea_runner
- Consolidate systemd checks and deduplicate role structure
- Deduplicate CLI, remove dead code, move validation to business layer
- Resolve_runner returns gitea_url, add --url CLI option, force remove improvements, code quality fixes
## [0.1.0] - 2026-06-17
### Features
- Initial implementation of Gitea Runner Manager
+2
View File
@@ -1,5 +1,7 @@
# Contributing to GRM
For the full contributing guide, see the [Contributing wiki page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing).
Thank you for contributing to Gitea Runner Manager (GRM)!
## Branch Naming
+113 -84
View File
@@ -1,4 +1,6 @@
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release install-devx install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
.PHONY: configure-gitea-pypi
.PHONY: create-task create-pr push-with-pr git-push
PYTHON := python3
VENV := .venv
@@ -7,40 +9,85 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
all: setup
install-devx: $(VENV)/bin/activate
@# REPO_TOKEN may come from .env (local) or environment (CI secrets)
@if [ -z "$$REPO_TOKEN" ]; then . .env 2>/dev/null; fi; \
if [ -z "$$REPO_TOKEN" ]; then echo "REPO_TOKEN not set (check .env or environment)"; exit 1; fi; \
$(BIN)/pip install "git+https://emil:$$REPO_TOKEN@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
# --- devx.mak include (shared Makefile targets) -------------------------------
# Set DEVX_PYTHON before including devx.mak so it uses the venv Python.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_COV_PKG := src/gitea_runner_manager
DEVX_TEST_PATHS := tests/ scripts/tests/
DEVX_LINT_PATHS := src/ scripts/ tests/
# Include shared targets from devx package (create-task, create-pr, push-with-pr,
# check-config, workflow-lint, lint-ruff, clean, venv, .env, activate-scripts,
# install-hooks, install-tools, configure-gitea-pypi, checkmake, etc.)
# Silent if devx not installed yet — run 'make setup' first.
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Full setup for local development (all deps, tools, collections, hooks)
# install-devx must run before install-tools (which uses devx modules)
setup: $(VENV)/bin/activate .env activate-scripts install-devx checkmake install-tools
# devx is installed via pip install -e .[dev] (devx is in dev extra)
setup: $(VENV)/bin/activate .env activate-scripts configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[dev]'
@$(BIN)/python -m devx.tools.install_checkmake
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)"
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
# badges job runs generate_badges.py which needs ruff, pyright, bandit
setup-ci: $(VENV)/bin/activate .env install-devx
@$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
setup-ci: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit --no-tea-login
# Setup for the quality job (lint + test deps, actionlint tool)
# install-devx must run before install-tools (which uses devx modules)
setup-quality: $(VENV)/bin/activate .env install-devx install-tools
setup-quality: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit --no-tea-login
# Full setup for molecule testing (needs ansible, molecule, collections)
setup-molecule: $(VENV)/bin/activate .env install-devx install-tools
setup-molecule: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,molecule]'
@$(BIN)/python -m devx.tools.install_tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,molecule" --no-pre-commit --no-tea-login
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-pre-commit --no-tea-login
# Setup for release jobs (needs git-cliff, tea, and lint tools for release.py)
setup-release: $(VENV)/bin/activate .env install-devx
setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea
@export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
# Usage: make setup-image (runtime deps only, devx from image)
# make setup-image EXTRAS=lint (runtime + lint deps, e.g. ansible-lint)
# make setup-image EXTRAS=ci,lint (runtime + ci + lint deps, upgrades devx)
# NOTE: Cannot alias to devx-setup-image because the venv must exist before
# devx.mak can be included (chicken-and-egg). This standalone target creates
# the venv symlink first, then installs the project.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
# Helper: run pip install with Gitea registry configured
# Usage: $(PIP_INSTALL) install -e '.[ci,lint]'
PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
$(BIN)/pip
$(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
.env:
@if [ ! -f .env ]; then \
@@ -48,27 +95,11 @@ setup-release: $(VENV)/bin/activate .env install-devx
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: install-devx
@$(BIN)/python -m devx.tools.install_checkmake
install-tools: install-devx
@$(BIN)/python -m devx.tools.install_tools
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,)
@@ -78,81 +109,77 @@ update:
$(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
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make start NAME=runner1"; 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
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make stop NAME=runner1"; exit 1; fi
$(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
restart:
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make restart NAME=runner1"; exit 1; fi
$(BIN)/grm restart $(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
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make enable NAME=runner1"; 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
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make disable NAME=runner1"; 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
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make status NAME=runner1"; 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,)
@if [ -z "$(NAME)" ]; then echo "NAME is required. Example: make remove NAME=runner1"; exit 1; fi
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(FORCE),--force,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
lint-ruff:
$(BIN)/ruff check src/ tests/
list:
$(BIN)/grm list $(if $(NO_STATUS),--no-status,) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
lint-format:
$(BIN)/ruff format --check src/ tests/
# --- Aliases to devx.mak targets ----------------------------------------------
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
lint-bandit: devx-lint-bandit
lint-deps: devx-lint-deps
lint: devx-lint
checkmake: devx-checkmake
install-tools: devx-install-tools
install-hooks: devx-install-hooks
clean: devx-clean
test-unit: devx-test-unit
typecheck:
$(BIN)/pyright
# Override devx-pytest-cov to cover both src/ and scripts/
pytest-cov:
@$(BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
workflow-lint: devx-workflow-lint
workflow-dryrun: devx-workflow-dryrun
workflow-check: devx-workflow-check
lint: lint-ruff lint-format typecheck lint-bandit
lint-bandit:
$(BIN)/bandit -r src/
lint-deps:
@echo "Checking dependencies for known vulnerabilities..."
@.venv/bin/python -m ensurepip 2>/dev/null || true
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python \
.venv/bin/pip-audit --desc --skip-editable 2>&1 || true
configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
ansible-lint:
$(BIN)/ansible-lint ansible/
PATH="$(PWD)/$(BIN):$$PATH" $(BIN)/ansible-lint ansible/
makefile-lint:
@$(CHECKMAKE) Makefile
@if command -v $(CHECKMAKE) >/dev/null 2>&1 || [ -x "$(CHECKMAKE)" ]; then \
$(CHECKMAKE) Makefile; \
else \
echo "checkmake not found, skipping Makefile lint"; \
fi
lint-all: lint ansible-lint makefile-lint workflow-lint
workflow-lint:
@command -v actionlint >/dev/null 2>&1 || { \
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
exit 1; \
}
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
workflow-dryrun:
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
@echo "Dry-running all workflows (no Docker containers started)..."
act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
workflow-check: workflow-lint workflow-dryrun
@echo "Workflow checks passed (static lint + dry-run)."
test-unit:
$(BIN)/pytest tests/unit/ -v --no-cov
test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov
pytest-cov:
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --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)
@@ -168,7 +195,9 @@ 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/
# --- Vikunja task and PR management (via devx.mak fragment) -------------------
# Aliases for project-specific target names
create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
+359 -10
View File
@@ -2,18 +2,42 @@
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.
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. GRM handles the entire runner lifecycle — from initial installation and registration with Gitea, through start/stop/enable/disable operations, to clean removal with deregistration.
> **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)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/python.svg)](https://www.python.org/downloads/)
## Why GRM?
Managing Gitea Actions runners manually is tedious and error-prone: you need to create system users, set up rootless Docker, download and configure the runner binary, register it with Gitea, create systemd services, and set up Docker prune timers — all per runner instance. GRM automates this entire process with a single command, and ensures it is idempotent (safe to re-run).
Key problems GRM solves:
- **Isolation without root**: Each runner operates under a dedicated system user with its own rootless Docker daemon, so runners on the same host never interfere with each other or with the host's Docker installation.
- **Reproducible setup**: The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run, so it is safe for CI/CD pipelines and configuration management.
- **Full lifecycle management**: Install, start, stop, enable (boot persistence), disable (deregister), update the binary, check status, and remove — all from one CLI.
- **Local registry**: GRM stores connection metadata locally, so after installation you manage runners by name alone without repeating SSH credentials.
## Features
- **Rootless Docker isolation** — Each runner gets its own rootless Docker daemon under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
- **Multi-instance support** — Install and manage multiple isolated runners on the same host, each with independent users, data directories, and systemd user services.
- **Idempotent Ansible role** — Safe to re-run; the role detects existing state and only applies changes when needed.
- **Full lifecycle CLI** — `install`, `update`, `start`, `stop`, `enable`, `disable`, `status`, `remove`, `list` — all from a single `grm` command.
- **Automatic integration testing** — Every installation runs an integration test that verifies the `.runner` registration file and systemd service state.
- **Docker prune automation** — A systemd user timer automatically prunes old Docker images and volumes on a daily schedule.
- **Local runner registry** — Connection details are stored in `~/.local/share/grm/runners.json`, so lifecycle commands work by runner name alone.
- **Internationalisation** — Console messages support English, Bulgarian, German, Russian, Chinese, and Polish via the `GRM_LANG` environment variable.
- **Security-conscious** — Secrets (registration tokens) are passed via temporary JSON files with `0600` permissions, never on the command line (CWE-214).
- **Comprehensive CI/CD** — 100% test coverage, automated releases via conventional commits and git-cliff, Molecule tests across 4 OS platforms.
## Quick Start
@@ -26,10 +50,326 @@ cp .env.example .env # Edit with your Gitea URL and tokens
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release.
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
> **Tokens:** You need two tokens from your Gitea instance — a **registration token** to register runners, and an **admin API token** for optional post-install verification. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed setup instructions.
## Prerequisites
### On your local machine (where you run `grm`)
- **Python 3.12+** — GRM targets Python 3.12 and requires it for development setup.
- **Ansible** — Installed automatically by `make setup` (via pip). GRM delegates all remote operations to `ansible-playbook`.
- **SSH access** — A private key that grants access to the target host(s) as a user with sudo privileges.
### On the target host(s) (where runners will be installed)
- **SSH server** — Reachable via the key specified with `--key`.
- **Sudo access** — The SSH user must have sudo privileges for creating system users, installing packages, and configuring rootless Docker. By default, you will be prompted for the sudo password interactively. For automation, configure passwordless sudo and pass `--no-ask-become-pass`.
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
## Installation
### Option 1: From source (recommended for full control)
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
`make setup` performs the following:
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs all Python dependencies (including Ansible, Click, python-dotenv)
4. Creates `.env` from `.env.example` if not present
5. Installs development tools (actionlint, git-cliff, act_runner, checkmake)
6. Sets up pre-commit hooks
### Option 2: Via pip (for using GRM without the full repo)
GRM is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
**Quick install (one-off):**
```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
**Persistent configuration (recommended):**
Add the registry to `~/.pip/pip.conf` so future `pip install` commands find
GRM automatically:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then install normally:
```bash
pip install gitea-runner-manager
```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
and role are bundled with the package, so `grm install` works out of the box.
For development or access to Make targets, clone the repository (Option 1).
### Post-install configuration
After installation, create your `.env` file:
```bash
cp .env.example .env
# Edit .env with your Gitea URL and registration token
```
See the [Configuration](#configuration) section below for details.
## CLI Commands Overview
GRM provides a single `grm` command with subcommands for the full runner lifecycle:
| Command | Description |
|---------|-------------|
| `grm install <host>` | Install and configure a runner on a remote host |
| `grm update <host>` | Update the Gitea Runner binary on a remote host |
| `grm start <name>` | Start a registered runner |
| `grm stop <name>` | Stop a registered runner |
| `grm restart <name>` | Restart a runner (stop, prune Docker images, start) |
| `grm enable <name>` | Enable a runner to start on boot |
| `grm disable <name>` | Disable and deregister a runner |
| `grm status <name>` | Check the status of a registered runner |
| `grm remove <name>` | Remove a runner completely (with remote cleanup) |
| `grm remove <name> --force` | Remove only the local registry entry (skip remote cleanup) |
| `grm list` | List all registered runners with live status |
| `grm list --no-status` | List registered runners without SSH status checks |
| `grm trigger-workflow <workflow_id>` | Trigger a Gitea Actions workflow via the API |
| `grm trigger-workflow --list` | List available workflows in the repository |
| `grm --version` | Show the installed version |
All lifecycle commands (`start`, `stop`, `restart`, `enable`, `disable`, `status`, `remove`) work by runner name and pull connection details from the local registry. You can override any stored value with `--host`, `--user`, or `--key`.
See the [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands.-) wiki page for full argument and option reference.
## Configuration
GRM reads configuration from a `.env` file in the current directory (loaded automatically via python-dotenv). You can also set environment variables directly.
### Required variables
| Variable | Description |
|----------|-------------|
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
### Optional variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CI_GITEA_TOKEN` | — | Gitea admin API token for optional post-install API verification |
| `GITEA_INTEGRATION_RETRIES` | `3` | Number of API check retries during integration test |
| `GITEA_RUNNER_USER` | current login | Default SSH user (overrides `--user`) |
| `GITEA_RUNNER_KEY` | — | Default SSH key path (overrides `--key`) |
| `GITEA_RUNNER_LABELS` | — | Default runner labels (overrides `--labels`) |
| `GRM_LANG` | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
| `GRM_LOG_LEVEL` | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
| `GRM_BECOME_PASSWORD_FILE` | — | Path to file containing sudo password (see [Sudo Password Handling](#sudo-password-handling)) |
| `ANSIBLE_BECOME_PASSWORD_FILE` | — | Fallback sudo password file path (Ansible-native env var) |
### Sudo Password Handling
GRM delegates remote operations to Ansible, which uses `sudo` (become) on the target host. There are several ways to provide the sudo password, in priority order:
1. **`--become-password-file <path>`** (CLI flag, global) — Read sudo password from a file. Works for all commands including `grm list`.
2. **`GRM_BECOME_PASSWORD_FILE`** (env var) — Same as above, set in `.env` or environment.
3. **`ANSIBLE_BECOME_PASSWORD_FILE`** (env var) — Fallback, Ansible-native env var.
4. **Interactive prompt** — If none of the above are set, GRM prompts for the sudo password (hidden input).
5. **Piped stdin** — When stdin is not a TTY, reads the first line: `echo 'password' | grm list`.
6. **`--no-ask-become-pass`** — Skip sudo password entirely (use when the target user has passwordless sudo).
For `grm list` specifically, the password is collected once and reused for all runner status checks via `--become-password-file`, avoiding stdin consumption issues when checking multiple runners.
**Examples:**
```bash
# Interactive prompt (default)
grm install 192.168.1.10 --user ubuntu
# Password file (recommended for automation)
echo 'my-sudo-pass' > ~/.grm-sudo-pass
chmod 600 ~/.grm-sudo-pass
grm --become-password-file ~/.grm-sudo-pass install 192.168.1.10 --user ubuntu
# Env var (set in .env)
GRM_BECOME_PASSWORD_FILE=~/.grm-sudo-pass
grm list # uses the file automatically
# Piped stdin (for scripts)
echo 'my-sudo-pass' | grm list
# Passwordless sudo on target
grm install 192.168.1.10 --user ubuntu --no-ask-become-pass
```
### Verbose Output
Pass `-v` / `--verbose` (global flag, before the subcommand) to enable Ansible verbose mode (`-v`):
```bash
grm --verbose install 192.168.1.10 --user ubuntu
grm -v status prod-runner
```
### Runner Labels
Runner labels control which jobs a runner accepts. They are set at installation time:
- **`--labels "docker:docker://alpine:latest"`** — Set specific labels.
- **`--labels ""`** — Explicitly set **no labels** (overrides `GITEA_RUNNER_LABELS` env var).
- **No `--labels` flag** — Uses `GITEA_RUNNER_LABELS` env var if set, otherwise the Ansible role default.
```bash
# Custom labels
grm install 192.168.1.10 --user ubuntu --labels "docker:docker://alpine:latest,ubuntu-22.04:docker://ubuntu:22.04"
# Explicitly no labels (overrides GITEA_RUNNER_LABELS env var)
grm install 192.168.1.10 --user ubuntu --labels ""
# Use GITEA_RUNNER_LABELS from .env (or role default if unset)
grm install 192.168.1.10 --user ubuntu
```
### Getting tokens
**Registration token** (required): Navigate to your Gitea instance:
- **Instance-level**: Site Administration → Actions → Runners → Create Registration Token
- **Organization-level**: Organization → Settings → Actions → Runners → Create Registration Token
- **Repository-level**: Repository → Settings → Actions → Runners → Create Registration Token
Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
**Admin API token** (optional): Settings → Applications → Generate New Token, with the `admin` scope (or at minimum: `read:user`, `read:repository`, `read:admin`). When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational and does not affect pass/fail.
## Multi-Instance Support
One of GRM's core features is the ability to run multiple isolated runners on the same host. Each runner instance gets:
- **Dedicated system user**: `grm-<name>` with its own home directory at `/home/grm-<name>/`
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup
```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
grm list
```
## Security Model
GRM is designed with security as a first-class concern:
- **Rootless Docker**: Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode, so containers never have root access to the host. User namespaces (`subuid`/`subgid`) are configured automatically.
- **Dedicated users**: Each runner gets its own system user (`grm-<name>`) with lingering enabled, so the user's systemd services run without an active login session.
- **Secret handling**: Registration tokens and admin tokens are never passed on the command line. They are written to temporary JSON files with `0600` permissions and passed to Ansible via `--extra-vars @tempfile`. The temp file is deleted immediately after execution. This prevents secrets from being visible in the process list (`ps aux`), addressing CWE-214.
- **No shell injection**: The CLI never uses `shell=True` with subprocess. All Ansible commands are constructed as argument lists.
- **Bandit security scan**: The CI pipeline runs Bandit on every PR to catch common Python security issues.
## Supported Operating Systems
GRM supports and tests the following operating systems:
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files. The platform matrix is defined in `devx.molecule.platforms` as the single source of truth.
## Development Setup
GRM uses a comprehensive development setup with 100% test coverage enforcement, multiple linters, and Molecule integration tests.
### Quick development setup
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
### Make targets
| Target | Description |
|--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
| `make test-unit` | Unit tests without coverage |
| `make molecule` | All 6 Molecule scenarios on Ubuntu 22.04 |
| `make molecule-all` | All 6 scenarios on all 4 supported OSes |
| `make test-all` | pytest-cov + molecule |
| `make workflow-lint` | Static lint of workflow YAML (actionlint) |
| `make workflow-dryrun` | Dry-run all workflows in Docker |
| `make workflow-check` | workflow-lint + workflow-dryrun |
See the [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup.-) wiki page for full details.
## Architecture Overview
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. Secrets are passed via temporary JSON files to avoid exposure in the process list.
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, registers the runner with Gitea, and sets up a Docker prune timer.
```
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)
```
### Python modules
| Module | Description |
|--------|-------------|
| `cli.py` | Click-based CLI entry point — defines all commands |
| `runner_manager.py` | Ansible orchestration + registry integration |
| `executor.py` | Ansible subprocess execution with log capture |
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` |
| `i18n.py` | Internationalisation (en, bg, de, ru, zh, pl) |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
| `logging_config.py` | Logging to `~/.local/state/grm/logs/grm.log` |
| `report.py` | Operation report tracking with step status |
| `ui.py` | Colorised console output via Click |
See the [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) wiki page for the full component diagram and data flow.
## Documentation
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
@@ -44,13 +384,22 @@ Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi
### Technical Documentation
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions, data flow
- [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
## Links
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
- [Releases](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
- [Issues](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/issues)
- [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)
- [Wiki](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
## License
GPL-3.0
GPL-3.0 — See [LICENSE](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE) for the full text.
+1 -15
View File
@@ -1,17 +1,3 @@
# 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 |
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
| 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` |
See the [Troubleshooting guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) in the wiki.
+58 -1
View File
@@ -40,6 +40,30 @@
changed_when: true
failed_when: false
- name: Force-remove all Docker containers (rootless)
ansible.builtin.shell: |
set -o pipefail
docker ps -aq 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
args:
executable: /bin/bash
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
changed_when: false
failed_when: false
- name: Prune all Docker images, volumes, and build cache (rootless)
ansible.builtin.command: docker system prune -af --volumes
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
changed_when: false
failed_when: false
- name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
@@ -55,11 +79,23 @@
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
- name: Remove docker-prune user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.service"
state: absent
failed_when: false
- name: Remove docker-prune user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.timer"
state: absent
failed_when: 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)
when: remove_systemd_template | default(true)
- name: Kill remaining processes of runner user
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
@@ -84,6 +120,27 @@
when: remove_runner_user | default(true)
failed_when: false
- name: Remove Docker data root when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.local/share/docker"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove act cache when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.cache/act"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove systemd user config dir when user is kept
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user"
state: absent
when: not (remove_runner_user | default(true))
failed_when: false
- name: Remove subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
+4 -2
View File
@@ -1,5 +1,7 @@
collections:
- name: community.general
version: ">=13.0.1"
version: "==13.1.0"
- name: ansible.posix
version: ">=1.5.4"
version: "==2.2.0"
- name: community.docker
version: "==5.2.1"
+48
View File
@@ -0,0 +1,48 @@
---
- name: Restart Gitea Actions runner (stop, prune images, start)
hosts: all
become: true
vars:
prune_images: true
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
- name: Prune stale runner images from rootless Docker
ansible.builtin.command:
cmd: python3 {{ playbook_dir }}/../scripts/prune_runner_images.py
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
when:
- systemd_available.stat.exists
- prune_images | default(true)
changed_when: true
failed_when: false
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
@@ -24,6 +24,15 @@ gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Admin token for runner deregistration via Gitea API.
# If not set, falls back to registration_token (which likely lacks admin scope).
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
gitea_admin_token: ""
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
# Runner configuration
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "remove-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
side_effect: side_effect.yml
env:
ANSIBLE_ROLES_PATH: "../../.."
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- side_effect
- verify
- cleanup
- destroy
verifier:
name: ansible
@@ -0,0 +1,8 @@
---
- name: Remove runner via remove-runner playbook
ansible.builtin.import_playbook: "../../../../remove-runner.yml"
vars:
runner_name: "remove-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: true
@@ -0,0 +1,114 @@
---
- name: Verify runner was fully removed
hosts: all
become: true
vars:
runner_name: "remove-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
tasks:
- name: Check runner user is absent
ansible.builtin.getent:
database: passwd
key: "{{ gitea_runner_service_user }}"
register: user_check
failed_when: false
- name: Assert runner user is absent
ansible.builtin.assert:
that:
- user_check is failed or
gitea_runner_service_user not in (user_check.ansible_facts.getent_passwd | default({}))
fail_msg: "Runner user still exists after removal"
- name: Check runner home directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}"
register: home_stat
- name: Assert runner home directory is absent
ansible.builtin.assert:
that:
- not home_stat.stat.exists
fail_msg: "Runner home directory still exists after removal"
- name: Check runner data directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}"
register: data_stat
- name: Assert runner data directory is absent
ansible.builtin.assert:
that:
- not data_stat.stat.exists
fail_msg: "Runner data directory still exists after removal"
- name: Check runner config directory is absent
ansible.builtin.stat:
path: "{{ gitea_runner_config_dir }}"
register: config_stat
- name: Assert runner config directory is absent
ansible.builtin.assert:
that:
- not config_stat.stat.exists
fail_msg: "Runner config directory still exists after removal"
- name: Check gitea-runner service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
register: service_stat
- name: Assert gitea-runner service unit is absent
ansible.builtin.assert:
that:
- not service_stat.stat.exists
fail_msg: "gitea-runner service unit still exists after removal"
- name: Check docker-prune service unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
register: prune_service_stat
- name: Assert docker-prune service unit is absent
ansible.builtin.assert:
that:
- not prune_service_stat.stat.exists
fail_msg: "docker-prune service unit still exists after removal"
- name: Check docker-prune timer unit is absent
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
register: prune_timer_stat
- name: Assert docker-prune timer unit is absent
ansible.builtin.assert:
that:
- not prune_timer_stat.stat.exists
fail_msg: "docker-prune timer unit still exists after removal"
- name: Check subuid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subuid"
register: subuid_check
changed_when: false
failed_when: false
- name: Assert subuid entry is absent
ansible.builtin.assert:
that:
- subuid_check.rc != 0
fail_msg: "subuid entry still exists after removal"
- name: Check subgid entry is absent
ansible.builtin.command: "grep -c '^{{ gitea_runner_service_user }}:' /etc/subgid"
register: subgid_check
changed_when: false
failed_when: false
- name: Assert subgid entry is absent
ansible.builtin.assert:
that:
- subgid_check.rc != 0
fail_msg: "subgid entry still exists after removal"
@@ -30,7 +30,10 @@
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"
- "'Restart=always' in service_template.content | b64decode"
- "'Requires=docker.service' in service_template.content | b64decode"
- "'PartOf=docker.service' in service_template.content | b64decode"
- "'StartLimitBurst=10' 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"
@@ -18,13 +18,11 @@
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner with Gitea via CLI
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
{{ gitea_runner_binary_path }} delete
--token {{ registration_token }}
--name {{ runner_name }}
--instance {{ gitea_url }}
--no-interactive
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
-H "Authorization: token {{ gitea_admin_token | default(registration_token) }}"
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ runner_reg.id }}"
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
@@ -35,10 +33,24 @@
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- runner_reg.id is defined
register: deregister_output
changed_when: deregister_output.rc == 0
failed_when: false
- name: Warn if deregistration failed
ansible.builtin.debug:
msg: >-
WARNING: Runner deregistration from Gitea failed (rc={{ deregister_output.rc | default('N/A') }}).
The runner entry may remain in Gitea's admin UI as offline.
Use an admin token (gitea_admin_token var) to enable automatic cleanup,
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- deregister_output is defined
- deregister_output.rc | default(1) != 0
- name: Remove runner registration file
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner"
@@ -0,0 +1,25 @@
---
# Resolve runner identity facts for stop/start/status/restart playbooks.
# These playbooks use include_role with tasks_from, which does NOT expose
# role defaults to the playbook's task-level keywords (become_user, etc).
# We set the facts explicitly here so they're available everywhere.
- name: Resolve runner service user
ansible.builtin.set_fact:
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
- name: Resolve runner data and config dirs
ansible.builtin.set_fact:
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
- name: Resolve runner service user UID
ansible.builtin.getent:
database: passwd
key: "{{ gitea_runner_service_user }}"
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ getent_passwd[gitea_runner_service_user][1] }}"
@@ -20,6 +20,7 @@
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
when: systemd_available.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
@@ -6,4 +6,4 @@ 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 }}"
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}"
@@ -1,6 +1,8 @@
[Unit]
Description=Gitea Actions Runner (rootless)
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=simple
@@ -10,8 +12,10 @@ 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
Restart=always
RestartSec={{ gitea_runner_service_restart_sec }}
StartLimitIntervalSec=300
StartLimitBurst=10
[Install]
WantedBy=default.target
+8 -3
View File
@@ -9,9 +9,14 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.yml
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
- name: Include registration if not registered
@@ -25,8 +30,8 @@
- 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) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+8 -3
View File
@@ -9,12 +9,17 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.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) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: service_status
changed_when: false
when: systemd_available.stat.exists
@@ -26,7 +31,7 @@
- name: Check runner registration file
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Report runner registration
+7 -2
View File
@@ -9,11 +9,16 @@
name: gitea-runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
tasks_from: resolve_uid.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) }}"
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when: systemd_available.stat.exists
changed_when: true
+5 -2
View File
@@ -39,8 +39,8 @@ 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 = "" },
# Strip GRM-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^GRM-\\d+:\\s+", replace = "" },
]
commit_parsers = [
@@ -66,3 +66,6 @@ commit_parsers = [
features_always_bump_minor = true
breaking_always_bump_major = false
initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
+47 -15
View File
@@ -2,34 +2,66 @@
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. GRM handles the entire runner lifecycle — from initial installation and registration with Gitea, through start/stop/enable/disable operations, to clean removal with deregistration.
> **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)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/424af0deb20f7fff36433285b486ddf10aea07fc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![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)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/695921d6cc831b859d7878ff93a9bd03e58c056e/python.svg)](https://www.python.org/downloads/)
## Overview
GRM is a two-layer tool: a Python CLI (built with Click) that delegates to an idempotent Ansible role for all remote operations. The CLI handles argument parsing, environment loading, internationalisation, and local registry management. The Ansible role handles the actual runner setup — creating dedicated system users, configuring rootless Docker, downloading and registering the runner binary, creating systemd user services, and setting up Docker prune timers.
### Key capabilities
- **Rootless Docker isolation** — Each runner gets its own rootless Docker daemon under a dedicated system user (`grm-<name>`).
- **Multi-instance support** — Multiple isolated runners on the same host, each with independent users, data directories, and systemd services.
- **Full lifecycle CLI** — `install`, `update`, `start`, `stop`, `enable`, `disable`, `status`, `remove`, `list`.
- **Idempotent Ansible role** — Safe to re-run; second run produces zero changes.
- **Automatic integration testing** — Every installation verifies the `.runner` registration file and systemd service state.
- **Local runner registry** — Connection details stored locally; manage runners by name after installation.
- **Internationalisation** — Console messages in English, Bulgarian, German, Russian, Chinese, and Polish.
- **Security-conscious** — Secrets passed via temporary JSON files with `0600` permissions (CWE-214).
### Supported operating systems
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
## 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
- [Getting Started](Getting-Started) — Installation, quick start, token setup, first run, log viewing
- [Installation](Installation) — Prerequisites, setup methods, multiple instances, runner registry
- [CLI Commands](CLI-Commands) — All commands with arguments, options, and examples
- [Troubleshooting](Troubleshooting) — Common issues, diagnostics, 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
- [Architecture](Architecture) — High-level design, component diagram, data flow, security model, per-runner isolation
- [Development Setup](Development-Setup) — Environment setup, project structure, dependencies, linting, testing
- [CI/CD Workflow](CI-CD-Workflow) — PR workflow, branch protection, release pipeline, change classification, badge generation
- [Testing Strategy](Testing-Strategy) — Unit tests, Molecule scenarios, integration tests, CI distribution
- [Decision Log](Decision-Log) — Key technical decisions and rationale (ADRs)
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit conventions, Ansible role conventions
## Quick Links
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
- [Releases](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
- [Issues](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/issues)
- [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)
- [License (GPL-3.0)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
+141 -7
View File
@@ -5,6 +5,21 @@ 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.
## High-Level Design
The CLI is a thin orchestration layer. It does not perform any remote operations itself — every action (install, update, start, stop, etc.) is delegated to an Ansible playbook. The CLI's responsibilities are:
- Parsing command-line arguments and options
- Loading configuration from `.env` (via python-dotenv)
- Resolving runner connection details from the local registry
- Writing secrets to temporary JSON files (CWE-214 mitigation)
- Constructing the `ansible-playbook` command with appropriate inventory, user, key, and extra-vars
- Capturing and streaming Ansible output to log files
- Maintaining the local runner registry (`~/.local/share/grm/runners.json`)
- Providing colorised console output and operation reports
The Ansible role handles all remote state: user creation, package installation, Docker configuration, binary download, runner registration, systemd service management, and Docker prune timers.
## Component Tree
```
@@ -30,6 +45,33 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
### Ansible task files
| Task file | Responsibility |
|-----------|---------------|
| `main.yml` | Entry point — includes all other task files in order |
| `systemd_check.yml` | Verifies systemd is available on the target host |
| `user_setup.yml` | Creates the per-runner system user, enables lingering, configures subuid/subgid, creates data and config directories |
| `rootless_docker.yml` | Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), runs `dockerd-rootless-setuptool.sh install`, starts and enables the rootless Docker daemon |
| `install_runner.yml` | Downloads the gitea_runner binary, creates the config file, validates the binary, registers the runner with Gitea, creates and starts the systemd user service |
| `download_gitea_runner.yml` | Downloads the gitea_runner binary from GitHub releases |
| `validate.yml` | Validates the downloaded binary |
| `register.yml` | Registers the runner with Gitea using the registration token |
| `service.yml` | Creates the systemd user service file and starts/enables the service |
| `prune.yml` | Creates a systemd user timer for daily Docker image and volume pruning |
| `integration_test.yml` | Verifies the `.runner` file exists and the systemd service is active; optionally queries the Gitea API |
| `deregister.yml` | Deregisters the runner from Gitea and removes the `.runner` file |
| `update_runner.yml` | Downloads a new version of the gitea_runner binary |
### Ansible templates
| Template | Purpose |
|----------|---------|
| `gitea-runner-user.service.j2` | Systemd user service for the gitea_runner daemon |
| `gitea-runner-config.yaml.j2` | Runner configuration file (labels, capacity, log level) |
| `docker-prune.service.j2` | Systemd user service for Docker pruning (oneshot) |
| `docker-prune.timer.j2` | Systemd user timer triggering daily Docker prune |
## Per-Runner Isolation
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
@@ -40,6 +82,9 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
- **Config**: `/etc/gitea-runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
Lingering is enabled via `loginctl enable-linger` so the user's systemd services run without an active login session. This is essential for runners that need to operate continuously.
## Component Interactions
@@ -58,11 +103,13 @@ flowchart TD
TEST["integration_test.yml<br/>validate service active"]
GITEA["Gitea instance<br/>registration + API"]
SYSTEMD["systemd user service<br/>gitea-runner.service"]
LOG["Log files<br/>~/.local/state/grm/logs/"]
CLI --> RM
RM --> REG
RM --> EXEC
EXEC -->|subprocess| ANS
EXEC -->|stream output| LOG
ANS --> ROLE
ROLE --> USER
ROLE --> DOCKER
@@ -72,14 +119,85 @@ flowchart TD
INSTALL -->|register| GITEA
INSTALL --> SYSTEMD
DOCKER --> SYSTEMD
TEST -->|optional API check| GITEA
```
## Data Flow
### Installation flow
1. User runs `grm install <host> --user <user> --key <key> --name <name>`
2. CLI loads `.env` for `GITEA_URL` and `GITEA_REGISTRATION_TOKEN`
3. `RunnerManager.install()` constructs extra-vars dict with registration token, runner name, Gitea URL, and optional admin token/labels
4. Extra-vars are written to a temporary JSON file with `0600` permissions
5. `AnsibleExecutor.run()` invokes `ansible-playbook ansible/install-runner.yml` with the temp file via `--extra-vars @tempfile`
6. Ansible connects to the remote host via SSH and executes the role:
- Creates system user `grm-<name>` with lingering
- Installs Docker packages and sets up rootless Docker
- Downloads the gitea_runner binary
- Creates the runner config file
- Registers the runner with Gitea
- Creates and starts the systemd user service
- Sets up the Docker prune timer
- Runs the integration test (verifies `.runner` file and service state)
7. Ansible output is streamed to a timestamped log file at `~/.local/state/grm/logs/ansible-<timestamp>.log`
8. On success, the runner is added to the local registry at `~/.local/share/grm/runners.json`
9. The temporary extra-vars file is deleted
### Lifecycle command flow
1. User runs `grm <command> <runner_name>` (e.g., `grm stop prod-runner`)
2. `RunnerManager._resolve_runner()` looks up the runner in the local registry
3. If `--host` and `--user` are provided, they override registry values
4. The corresponding playbook is executed (e.g., `stop-runner.yml`)
5. Ansible connects to the remote host and performs the action
### List command flow
1. User runs `grm list`
2. `RunnerManager.list_runners()` reads all entries from the local registry
3. For each runner, an Ansible ad-hoc command checks `systemctl --user is-active gitea-runner`
4. Results are displayed in a table with columns: NAME, HOST, USER, LABELS, STATUS
## Security Model
### Rootless Docker
Each runner operates under a dedicated unprivileged system user. The Docker daemon runs in rootless mode via `dockerd-rootless-setuptool.sh install`, which configures:
- User namespace mapping via `/etc/subuid` and `/etc/subgid` (range: 100000-165535)
- Rootless Docker socket at `/run/user/<UID>/docker.sock`
- `slirp4netns` for user-mode networking
- `fuse-overlayfs` for rootless container storage
Containers launched by the runner never have root access to the host. The rootless Docker daemon is started as a systemd user service and persists via lingering.
### Secret handling
Registration tokens and admin API tokens are never exposed on the command line. The `RunnerManager._extra_vars_file()` context manager:
1. Creates a temporary file via `tempfile.mkstemp()`
2. Writes the extra-vars JSON to the file
3. Sets permissions to `0600` (owner read/write only)
4. Passes the file to Ansible via `--extra-vars @tempfile`
5. Deletes the file in a `finally` block, even if an exception occurs
This prevents secrets from appearing in the process list (`ps aux`), addressing CWE-214.
### No shell injection
The CLI never uses `shell=True` with subprocess. All Ansible commands are constructed as argument lists (`list[str]`), preventing shell injection attacks. The `subprocess.Popen` and `subprocess.run` calls are marked with `nosec` comments after security review.
### Bandit security scanning
The CI pipeline runs Bandit on every PR to catch common Python security issues. The scan covers all source code in `src/`.
## Additional Components
From `AGENTS.md`, the project also includes:
- **devx package** (installed from git) — Reusable CI/CD tools: 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
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications. This package is not part of the GRM tool itself — it provides the CI/CD automation infrastructure.
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits.
## Python Modules
@@ -89,9 +207,25 @@ The Python CLI layer (`src/gitea_runner_manager/`) consists of the following mod
|--------|-------------|
| `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 |
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files, streams output to log 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 |
| `i18n.py` | Internationalisation translations (en, bg, de, ru, zh, pl) — opt-in via `GRM_LANG` environment variable |
| `exceptions.py` | Custom exceptions (`GRMError`, `AnsibleError`) |
| `logging_config.py` | Logging configuration — writes all messages to `~/.local/state/grm/logs/grm.log` at DEBUG level |
| `report.py` | Operation report tracking — prints a step-by-step report with status icons after each command |
| `ui.py` | User-facing output utilities — colorised console output via `click.style`, with log file always receiving plain text |
| `translations.json` | Translation strings for all supported languages |
## Logging
GRM 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 |
| `~/.local/state/grm/logs/ansible-<timestamp>.log` | — | Full Ansible playbook output per execution |
Console output is automatically colorised via `click.style`: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow. The log file always captures plain text (no ANSI codes) at DEBUG level regardless of the console setting.
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity.
+28 -7
View File
@@ -1,5 +1,16 @@
# CI/CD Workflow
GRM uses a fully automated CI/CD pipeline built on Gitea Actions. Every change to master goes through a mandatory PR workflow with branch protection, automated review, and auto-merge. Releases are automated via git-cliff and conventional commits.
## Workflow Overview
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `ci.yml` | PR opened/synchronized | Quality checks (lint, test, coverage) + molecule tests |
| `auto-merge.yml` | PR labeled `ready-to-merge` | Validates and squash-merges the PR |
| `post-merge.yml` | Push to `master` | Release, wiki sync, badges, Vikunja task update |
| `publish.yml` | Tag push (`v*`) | Build and publish package to PyPI, create Gitea release |
Every change to master goes through a mandatory PR workflow. No exceptions.
## PR Workflow
@@ -60,10 +71,10 @@ Review the full diff (`git diff master...HEAD`) focusing on:
- **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 `devx.ci.review_pr`:
Post review comments using `devx.ci.pr_review`:
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event REQUEST_CHANGES \
--body "Review summary" \
--comments-json comments.json
@@ -78,7 +89,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
Once all comments are addressed:
```bash
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
CI_GITEA_TOKEN=<token> python -m devx.ci.pr_review <pr_number> <owner/repo> \
--event APPROVE \
--body "All comments addressed. LGTM."
```
@@ -145,12 +156,13 @@ After a PR is merged to master, the release pipeline runs automatically.
- Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot`
- Runs `devx.ci.release` which uses **git-cliff** to:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only workflow/infrastructure files changed, the release is **skipped entirely** — no version bump, no tag, no publish
- 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):`)
- Commit with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents re-triggering post-merge on the release commit)
- 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)
@@ -178,13 +190,22 @@ After a PR is merged to master, the release pipeline runs automatically.
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master`
- Runs `devx.ci.post_merge` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done
- Consolidates release, wiki sync, badge generation, and Vikunja task updates into a single workflow
- **detect-type** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent jobs skip for release commits (the `[skip ci]` tag also prevents re-triggering).
- **release** — Runs `devx.ci.release` (see Automated Release Pipeline below)
- **sync-wiki** — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
- **badges** — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs after the release job (even if release fails or is skipped) so the version badge always reflects the latest state.
- **vikunja** — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project uses
`devx.ci.classify_changes` to classify changed files into two categories:
`devx.ci.classify_changes` to classify changed files into two categories.
**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. Classification is config-driven via
`[tool.devx.classify]` in `pyproject.toml`.
**User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source
+134 -6
View File
@@ -9,6 +9,15 @@
- 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`)
- No `print()` — use `click.echo()` via `ui.say()` for console output
- No bare `except` — catch specific exceptions
- No `TODO`/`FIXME` comments in committed code
- No functions longer than 50 lines
- No `shell=True` with subprocess
- No `eval()` or `exec()`
- No raw strings in `click.echo()` without `_()` wrapper (i18n)
- No `open()` without `with` statement
- No `Popen()` without cleanup
## Code Style Rules
@@ -16,7 +25,11 @@
- **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
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake + actionlint
- **Formatting**: `ruff format` with double quotes and space indentation
- **Type checking**: `pyright` in strict mode for `src/gitea_runner_manager/`
- **Security scanning**: `bandit -r src/` on every PR
- **Import rules**: `src/gitea_runner_manager/` NEVER imports from devx — the GRM tool is self-contained
## Commit Rules
@@ -26,8 +39,14 @@ Branch commits use conventional commit format (no `GRM-N:` prefix):
feat: add new feature
fix: resolve bug
docs: update README
ci: update workflow
refactor: simplify executor
test: add molecule scenario
chore: update dependencies
```
The pre-commit hook validates that commit messages follow the conventional commit format. Non-conventional commits are rejected.
### Version Bumping Rules
| Commit type | Version bump |
@@ -55,14 +74,104 @@ Every change to master goes through this workflow. No exceptions.
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 `devx.ci.review_pr`.
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 `devx.ci.pr_review`.
7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `devx.ci.review_pr`
8. **Approve** — post an `APPROVE` review via `devx.ci.pr_review`
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
### 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 above
- Write/update tests (100% coverage required)
- Update documentation (CHANGELOG, README, AGENTS.md, docs/ 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
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^2), 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 `devx.ci.review_pr`:
```bash
CI_GITEA_TOKEN=<token> python -m devx.ci.review_pr <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, post an approval review:
```bash
CI_GITEA_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 checklist categories verified."
```
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
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge workflow by adding the `ready-to-merge` label. Manual merges bypass the `GRM-N <conventional>` format enforcement.
### Branch Protection (Required Gitea Settings)
Configure the following branch protection rules for `master` in Gitea repo settings:
Branch protection is automatically configured by `devx.tools.configure_repo` (runs as a `configure-repo` job in the post-merge workflow). The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
@@ -74,13 +183,14 @@ The auto-merge workflow enforces the APPROVE review check programmatically as a
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make setup # Create venv, install deps, set up hooks, install CI tools
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
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
make workflow-check # Static lint + dry-run of workflow YAML
```
## Ansible Role Conventions
@@ -93,6 +203,24 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
- `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)
- `apt` tasks use `cache_valid_time: 3600` to avoid unnecessary cache updates
- `remove-runner.yml` runs `loginctl disable-linger` and removes subuid/subgid entries
## Change Classification
Not all changes require a new release. The project classifies changes using `devx.ci.classify_changes`:
**Workflow-only paths** (no release needed):
- `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`
- Lint config files, `.env.example`, `.gitignore`
**User-facing paths** (release needed):
- `src/gitea_runner_manager/**` (except `__init__.py`)
- `ansible/**`
- `pyproject.toml`
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.
## Known Issues
+51 -3
View File
@@ -22,7 +22,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**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.
**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. User namespace mapping is configured via `/etc/subuid` and `/etc/subgid` entries (range: 100000-165535). Lingering is enabled so the user's systemd services run without an active login session.
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
@@ -34,7 +34,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**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:** `devx.ci.release` 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.
**Rationale:** `devx.ci.release` 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 — no manual version bumps are needed.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
@@ -58,7 +58,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**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 (`devx.ci.auto_merge`) 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.
**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 (`devx.ci.auto_merge`) 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. Branch protection is automatically configured by `devx.tools.configure_repo`.
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
@@ -73,3 +73,51 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**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)
---
## ADR-007: Secrets via Temporary JSON Files (CWE-214)
**Date:** Project inception
**Decision:** Pass secrets (registration tokens, admin API tokens) to Ansible via temporary JSON files with `0600` permissions, never on the command line.
**Rationale:** Passing secrets as command-line arguments (e.g., `--extra-vars '{"token": "..."}'`) makes them visible in the process list (`ps aux`), which is a known security weakness (CWE-214). The `RunnerManager._extra_vars_file()` context manager writes extra-vars to a temporary file via `tempfile.mkstemp()`, sets permissions to `0600`, passes the file to Ansible via `--extra-vars @tempfile`, and deletes the file in a `finally` block — even if an exception occurs. This ensures secrets are never visible in the process list.
**Source:** `AGENTS.md` (Key Conventions), `src/gitea_runner_manager/runner_manager.py` (`_extra_vars_file` method)
---
## ADR-008: Smart CI — User-Facing vs Workflow-Only Change Classification
**Date:** 2026-06-21 (v0.2.0 unreleased)
**Decision:** Classify changed files into user-facing and workflow-only categories using `devx.ci.classify_changes`. Only user-facing changes trigger a release; workflow-only changes (CI, docs, tests, lint config) do not.
**Rationale:** Not all changes require a new release. CI workflow updates, documentation improvements, and test additions should not produce a new version tag. The classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`. The strategy is safe-by-default: any file NOT in the explicit workflow-only allowlist is treated as user-facing, preventing new file types from accidentally skipping releases. User-facing paths include `src/gitea_runner_manager/**` (except `__init__.py`) and `ansible/**`. Workflow-only paths include `.gitea/**`, `docs/**`, `tests/**`, `scripts/**`, and various config files.
**Source:** `AGENTS.md` (Smart CI: User-Facing vs Workflow-Only Changes), `pyproject.toml` (`[tool.devx.classify]`)
---
## ADR-009: devx Package Separation
**Date:** 2026-06-21 (v0.6.2)
**Decision:** Separate CI/CD and development tooling into the `devx` package (installed from git), keeping the GRM tool itself self-contained in `src/gitea_runner_manager/`.
**Rationale:** The GRM CLI tool must be self-contained — it never imports from devx. This ensures the installed package has no dependency on CI infrastructure. devx MAY import from `gitea_runner_manager` (one-way dependency), as it uses the tool's API clients, config, and i18n for CI automation. Cross-module imports within devx are allowed. This separation was formalised when scripts were migrated from the `scripts/` directory to the devx package in GRM-64.
**Source:** `AGENTS.md` (Source Code Separation and devx Integration), `CHANGELOG.md` (0.6.2 — Refactor: "Migrate from scripts/ to devx package")
---
## ADR-010: Dynamic Runner Discovery for Molecule CI
**Date:** 2026-06-21 (v0.5.0+)
**Decision:** Molecule tests are distributed across available Gitea Actions runners dynamically via `devx.molecule.discover_runners`, which queries the Gitea API for runners at all levels (repo, org, instance) and generates a dynamic matrix.
**Rationale:** Hardcoding the number of CI runners would require manual updates when runners are added or removed. Dynamic discovery auto-detects repo/org-level runners via the API. For instance-level runners (which may not be visible without admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable, then to a default of 3. The workflow automatically scales the matrix to match available runners, distributing test pairs evenly.
**Source:** `AGENTS.md` (Dynamic Runner Discovery), `.gitea/workflows/ci.yml` (discover-runners job)
+163 -31
View File
@@ -9,14 +9,18 @@
│ ├── 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
│ ├── i18n.py # Translations (en, bg, de, ru, zh, pl)
── exceptions.py # Custom exceptions
│ ├── logging_config.py # Logging to ~/.local/state/grm/logs/
│ ├── report.py # Operation report tracking
│ ├── ui.py # Colorised console output
│ └── translations.json # Translation strings
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files
│ │ ├── templates/ # Jinja2 templates
│ │ └── molecule/ # Test scenarios
│ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files)
│ │ └── molecule/ # Test scenarios (7 scenarios)
│ ├── install-runner.yml # Install playbook
│ ├── update-runner.yml # Update playbook
│ ├── start-runner.yml # Start playbook
@@ -28,38 +32,68 @@
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── .gitea/workflows/ # CI/CD workflows
├── docs/ # Documentation (synced to wiki)
├── Makefile # Build & test automation
── pyproject.toml # Python project metadata
── pyproject.toml # Python project metadata
├── cliff.toml # git-cliff configuration
└── .env.example # Environment variable template
```
## Prerequisites
- **Python 3.12+** — Required. The Makefile verifies this before creating the venv. Use `pyenv` to manage Python versions if needed.
- **Git** — For cloning the repository and checking out release tags.
- **Docker** — Only needed for running Molecule tests locally (`make molecule`).
- **Go** — Only needed if you want to install `checkmake` manually (alternatively, `make setup` installs it via `devx.tools.install_checkmake`).
## Setup Development Environment
### Step 1: Clone and checkout latest release
```bash
make setup # Creates venv, installs deps, sets up hooks
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
### Step 2: Ensure Python 3.12+ is available
If you use pyenv:
```bash
pyenv install 3.12
pyenv local 3.12
```
Verify your Python version:
```bash
python3 --version # Must be 3.12 or higher
```
### Step 3: Run make setup
```bash
make setup
source .venv/bin/activate
```
The `make setup` target (from the `Makefile`):
The `make setup` target performs the following:
- 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 `devx.tools.install_checkmake`
- Runs `python -m devx.tools.setup` to install dependencies and hooks
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs/updates `pip`, `setuptools`, and `wheel`
4. Creates `.env` from `.env.example` if not present
5. Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
6. Installs the `devx` package from the Oblachno PyPI registry (provides CI/CD tools)
7. Installs `checkmake` via `devx.tools.install_checkmake` (Makefile linter)
8. Installs CI/CD tools via `devx.tools.install_tools` (actionlint, git-cliff, act_runner, tea) to `~/.local/bin`
9. Runs `python -m devx.tools.setup` to install Python dependencies, Ansible Galaxy collections, pre-commit hooks, and configure tea CLI login
### 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
### Step 4: Configure Gitea credentials
```bash
cp .env.example .env
@@ -70,28 +104,49 @@ cp .env.example .env
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
#### Admin API Token (optional)
#### 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:
Set `CI_GITEA_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.
### Step 5: Verify the setup
```bash
grm --version # Should print the version
make lint-all # Should pass with no errors
make pytest-cov # Should pass with 100% coverage
```
## Shell activation scripts
`make setup` generates convenience activation scripts for different shells:
```bash
source activate.sh # bash
source activate.fish # fish
source activate.zsh # zsh
```
These scripts activate the `.venv` virtualenv from the project root.
## Running Linters
```bash
make lint # Python (ruff + pyright + bandit)
make lint # Python (ruff + format check + pyright + bandit)
make lint-bandit # Security scan only
make ansible-lint # Ansible
make makefile-lint # Makefile
make workflow-lint # Gitea Actions workflows (actionlint)
```
The full lint target (`make lint-all`) runs all of the above:
```bash
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
```
Individual lint targets from the `Makefile`:
@@ -102,7 +157,84 @@ Individual lint targets from the `Makefile`:
| `lint-format` | `ruff format --check src/ tests/` |
| `typecheck` | `pyright` |
| `lint-bandit` | `bandit -r src/` |
| `lint-deps` | `pip-audit` — checks dependencies for known vulnerabilities |
| `ansible-lint` | `ansible-lint ansible/` |
| `makefile-lint` | `checkmake Makefile` |
| `lint` | ruff + format check + pyright + bandit |
| `lint-all` | lint + ansible-lint + makefile-lint |
| `lint-all` | lint + ansible-lint + makefile-lint + workflow-lint |
## Running Tests
### Unit tests
```bash
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`.
### Integration tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
### 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
```
Requires Docker to be installed and running on your machine. Molecule creates Docker containers as test hosts, applies the Ansible role, and verifies the results.
### Full test suite
```bash
make test-all # pytest-cov + molecule
```
## Workflow verification
GRM includes Gitea Actions workflow files in `.gitea/workflows/`. These are verified with two tools:
```bash
make workflow-lint # Static lint via actionlint
make workflow-dryrun # Dry-run via act_runner exec --dryrun
make workflow-check # Both of the above
```
The pre-commit hook runs actionlint automatically when workflow files change.
## Pre-commit hooks
`make setup` installs pre-commit hooks that run:
- **pre-commit**: `ruff check`, `ruff format --check`, conventional commit message validation
- **pre-push**: `make pytest-cov` (ensures tests pass before pushing)
## Make targets reference
| Target | Description |
|--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) |
| `make setup-quality` | Setup for the quality CI job (lint + test deps) |
| `make setup-molecule` | Full setup for molecule testing |
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` |
| `make install-devx` | Install the devx package from the Oblachno PyPI registry |
| `make lint-all` | ruff + pyright + bandit + ansible-lint + checkmake + actionlint |
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
| `make test-unit` | Unit tests without coverage |
| `make test-integration` | Integration tests |
| `make molecule` | All 6 Molecule scenarios on Ubuntu 22.04 |
| `make molecule-all` | All 6 scenarios on all 4 supported OSes |
| `make test-all` | pytest-cov + molecule |
| `make workflow-lint` | Static lint of workflow YAML (actionlint) |
| `make workflow-dryrun` | Dry-run all workflows in Docker |
| `make workflow-check` | workflow-lint + workflow-dryrun |
| `make clean` | Remove `__pycache__`, `.pyc`, `.coverage`, `htmlcov/`, `.molecule/` |
+68 -27
View File
@@ -1,9 +1,12 @@
# Testing Strategy
GRM employs a multi-layered testing strategy: unit tests with 100% coverage enforcement, integration tests for the CLI lifecycle, and Molecule scenarios for Ansible role validation across multiple OS platforms.
## Unit Tests
```bash
make test-unit
make test-unit # Without coverage
make pytest-cov # With 100% coverage enforcement
```
Runs pytest with 100% coverage requirement.
@@ -11,9 +14,27 @@ 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)
- `pytest-cov``pytest tests/ -v --cov=src/gitea_runner_manager --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.
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/gitea_runner_manager/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
### Test speed verification
The CI quality job also runs `python -m devx.tools.check_test_speed --max-seconds 10` to verify that unit tests run fast (under 10 seconds total). This catches performance regressions early.
## Integration Tests
```bash
make test-integration
```
Tests the full CLI lifecycle commands end-to-end with a mocked executor boundary. This verifies that the CLI correctly parses arguments, resolves runners from the registry, constructs the right Ansible commands, and handles errors — all without actually connecting to remote hosts.
From the `Makefile`:
- `test-integration``pytest tests/integration/ -v --no-cov`
Integration tests are marked with `@pytest.mark.integration` and are not counted toward coverage.
## Molecule Tests
@@ -22,60 +43,80 @@ 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:
Molecule tests validate the Ansible role (`ansible/roles/gitea-runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
- **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
### Scenarios
All scenarios test idempotence (second run produces zero changes).
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
| Scenario | Description | What it verifies |
|----------|-------------|------------------|
| `default` | Rootless Docker runner installation | Basic role convergence — user creation, directory structure, binary download, config file, systemd service template, prune timer templates |
| `multi-instance` | Two isolated runner instances on the same host | Two separate converge plays with different runner names; verifies both instances coexist with independent users, data directories, and service files |
| `lifecycle` | Stop, disable, re-enable, and start sequence | Converge, then side_effect stops and disables the service, then re-enables and starts it; verify confirms the service is active again |
| `template-content` | Verify rendered systemd and prune templates | Checks that the systemd user service file contains expected directives (`Type=simple`, `ExecStart`, `Restart=on-failure`, `DOCKER_HOST`, `XDG_RUNTIME_DIR`), and that the prune service and timer templates are correctly rendered |
| `deregister` | Runner deregistration | Creates a fake `.runner` file, then runs the deregister tasks; verifies the `.runner` file is removed |
| `update` | Runner binary update | Converge, then side_effect runs the update playbook; verifies the binary is updated |
| `remove` | Runner removal | Converge, then side_effect runs the remove playbook; verifies the user, directories, and service files are cleaned up |
All scenarios test idempotence (second run produces zero changes), which is a core requirement of the Ansible role.
### Common scenario configuration
All scenarios use `docker_rootless_setup: false` and `skip_runner_registration: true` in their converge playbooks. This is because:
- **Rootless Docker** requires kernel user namespace support, which is not available in all Docker-in-Docker CI environments. The role handles this gracefully via the `docker_rootless_setup` guard.
- **Runner registration** requires a real Gitea instance. The role handles this via the `skip_runner_registration` flag, which skips the `register.yml` and `integration_test.yml` tasks.
### Platforms
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
4 platforms are tested:
The platform list is defined in `devx.molecule.distribute_molecule` (single source of truth).
| Platform | Docker image |
|----------|-------------|
| `ubuntu-2204` | `geerlingguy/docker-ubuntu2204-ansible` |
| `ubuntu-2404` | `geerlingguy/docker-ubuntu2404-ansible` |
| `debian-12` | `geerlingguy/docker-debian12-ansible` |
| `archlinux` | `archlinux:latest` |
The platform list is defined in `devx.molecule.platforms` (single source of truth), shared between `devx.molecule.distribute_molecule` (CI) and `devx.molecule.molecule_all` (local dev tool).
### CI Test Distribution
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
CI runs all 6 scenarios x 4 platforms (24 test pairs) distributed across available Gitea Actions runners.
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `python -m devx.molecule.distribute_molecule --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `python -m devx.molecule.molecule_ci_guard` with those pairs.
The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for registered runners at three levels (repo, org, instance) and generates a dynamic matrix. 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.
## Integration Tests
The `molecule-tests` job uses `fromJSON()` to consume the dynamic matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule --max-runners` so test pairs are evenly distributed.
```bash
make test-integration
```
`devx.molecule.distribute_molecule` discovers all molecule scenarios under `ansible/roles/*/molecule/` and crosses them with the supported OS platform matrix, then splits the resulting test pairs evenly across the requested number of runners. Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a given test pair, with CI context (Gitea URL, token, run ID) for reporting results back to the commit status API.
From the `Makefile`:
### Path-based CI filtering
- `test-integration` `pytest tests/integration/ -v --no-cov`
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.
## Full Test Suite
```bash
make test-all # Runs unit tests + linters + molecule
make test-all # Runs pytest-cov + molecule (Ubuntu 22.04)
```
From the `Makefile`:
- `test-all``pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
For a complete test across all platforms, use `make molecule-all` separately.
## 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 setup # Create venv, install deps, set up hooks, install CI tools
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make test-integration # Integration tests
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
+71 -1
View File
@@ -2,6 +2,32 @@
GRM provides the following CLI commands for managing Gitea Actions runners. The base command is `grm`.
## Command Summary
| Command | Arguments | Description |
|---------|-----------|-------------|
| `grm install` | `<host>` | Install and configure a runner on a remote host |
| `grm update` | `<host>` | Update the gitea_runner binary on a remote host |
| `grm start` | `<runner_name>` | Start a registered runner |
| `grm stop` | `<runner_name>` | Stop a registered runner |
| `grm enable` | `<runner_name>` | Enable a runner to start on boot |
| `grm disable` | `<runner_name>` | Disable and deregister a runner |
| `grm status` | `<runner_name>` | Check the status of a registered runner |
| `grm remove` | `<runner_name>` | Remove a runner completely |
| `grm list` | — | List all registered runners with live status |
| `grm --version` | — | Show the installed version |
### Common lifecycle options
The `start`, `stop`, `enable`, `status`, `disable`, and `remove` commands all accept these override options. By default, connection details are read from the local registry (`~/.local/share/grm/runners.json`).
| 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 |
## install
Install and configure a Gitea Runner on a remote host.
@@ -25,7 +51,7 @@ grm install <host> [options]
| `--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 |
| `--admin-token` | `-a` | `CI_GITEA_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 |
@@ -231,3 +257,47 @@ 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`.
The status is checked live by running an Ansible ad-hoc command on each remote host (`systemctl --user is-active gitea-runner`). Possible status values: `active`, `inactive`, `failed`, `unknown`.
**Example output:**
```
NAME HOST USER LABELS STATUS
------------------------------------------------------------------------------------------
prod-runner 192.168.1.10 ubuntu docker:docker://gitea/... active
build-runner 192.168.1.10 ubuntu docker:docker://gitea/... active
test-runner 192.168.1.20 ubuntu inactive
```
If no runners are registered:
```
No runners registered. Use 'grm install' to add one.
```
## --version
Show the installed GRM version.
```bash
grm --version
```
This reports the version from `__version__` in `src/gitea_runner_manager/__init__.py`, which is the single source of truth set by the automated release pipeline.
## Environment Variables
All CLI options can be set via environment variables (loaded from `.env` via python-dotenv). Command-line flags take precedence over environment variables.
| Variable | Used by | Description |
|----------|---------|-------------|
| `GITEA_URL` | `install`, `disable`, `remove` | Gitea instance URL |
| `GITEA_REGISTRATION_TOKEN` | `install`, `disable`, `remove` | Runner registration token |
| `CI_GITEA_TOKEN` | `install` | Admin API token for integration test |
| `GITEA_INTEGRATION_RETRIES` | `install` | API check retries (default: 3) |
| `GITEA_RUNNER_USER` | `install`, `update` | Default SSH user |
| `GITEA_RUNNER_KEY` | `install`, `update` | Default SSH key path |
| `GITEA_RUNNER_LABELS` | `install` | Default runner labels |
| `GRM_LANG` | all | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
| `GRM_LOG_LEVEL` | all | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
+143 -9
View File
@@ -1,6 +1,6 @@
# FAQ
### How do I obtain the Gitea registration token?
## How do I obtain the Gitea registration token?
There are three levels of registration tokens, depending on which repositories the runner should serve:
@@ -10,30 +10,164 @@ There are three levels of registration tokens, depending on which repositories t
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
### What is the REPO_TOKEN and do I need it?
## What is the CI_GITEA_TOKEN and do I need it?
`REPO_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
`CI_GITEA_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
To generate one: Settings → Applications → Generate New Token, with the `admin` scope (or at minimum `read:user`, `read:repository`, `read:admin`).
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
### How do I skip the sudo password prompt for automation?
## 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?
On the remote host, add a sudoers entry:
```bash
echo "ubuntu ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/grm
```
Then use:
```bash
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --no-ask-become-pass
```
## 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?
```bash
grm install 192.168.1.10 --user ubuntu --name workflow-runner
grm install 192.168.1.10 --user ubuntu --name build-runner
grm list
```
Runners on the same host never interfere with each other or with the host's Docker installation.
## 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?
Common causes:
- Registration token expired — generate a new one from Gitea
- Network connectivity issue between the runner host and Gitea
- Rootless Docker daemon not running — check `sudo -u grm-<name> systemctl --user status docker`
- Lingering not enabled — check `loginctl show-user grm-<name> | grep Linger`
## 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?
## 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.
GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) 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.
## How do I update the gitea_runner binary?
Use the `grm update` command:
```bash
grm update 192.168.1.10 --user ubuntu
```
To update to a specific version:
```bash
grm update 192.168.1.10 --user ubuntu --version 1.0.8
```
The update command downloads the new binary and replaces the existing one at `/usr/local/bin/gitea_runner`. The runner service is restarted automatically.
## How do I completely remove a runner?
Use the `grm remove` command:
```bash
grm remove prod-runner --token <registration-token>
```
This deregisters the runner from Gitea, stops and disables the systemd service, removes the system user, deletes data and config directories, removes subuid/subgid entries, disables lingering, and removes the entry from the local registry.
If the remote host is already gone or unreachable, use `--force` to skip remote cleanup and only remove the local registry entry:
```bash
grm remove prod-runner --force
```
## What is the difference between disable and remove?
- **`grm disable <name>`** — Deregisters the runner from Gitea and stops the service, but leaves the user, directories, and service files in place. The runner can be re-enabled later with `grm enable` and re-registered with a new token.
- **`grm remove <name>`** — Completely removes the runner: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
## What operating systems are supported?
GRM supports Arch Linux (rolling), Ubuntu 22.04/24.04, and Debian 12. All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
## How do I change the UI language?
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese), `pl` (Polish).
```bash
GRM_LANG=bg grm install 192.168.1.10 --user ubuntu --name prod-runner
```
Or set it in your `.env` file:
```bash
GRM_LANG=bg
```
## How do I enable debug logging?
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
```bash
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting. Ansible execution logs are stored in timestamped files at `~/.local/state/grm/logs/ansible-<timestamp>.log`.
## What runner labels should I use?
By default, runners are registered with `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels` or the `GITEA_RUNNER_LABELS` environment variable.
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.js.
Example:
```bash
grm install 192.168.1.10 --user ubuntu --name prod-runner \
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
```
## Is GRM secure?
Yes. GRM is designed with security as a first-class concern:
- **Rootless Docker**: Each runner operates under a dedicated unprivileged system user. Containers never have root access to the host.
- **Secret handling**: Registration tokens are passed via temporary JSON files with `0600` permissions, never on the command line (CWE-214).
- **No shell injection**: The CLI never uses `shell=True` with subprocess.
- **Bandit security scan**: The CI pipeline runs Bandit on every PR.
## Can I install GRM via pip?
Yes:
```bash
pip install gitea-runner-manager
```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks and role are bundled with the package. For development or access to Make targets, clone the repository instead.
## How does GRM handle idempotence?
The Ansible role is idempotent — running `grm install` twice produces zero changes on the second run. Each task checks for existing state before making changes. For example:
- User creation uses `ansible.builtin.user` which only creates if the user doesn't exist
- Package installation uses `state: present` which only installs if not already installed
- Template creation uses `ansible.builtin.template` which only writes if the content changed
- Rootless Docker setup uses `creates:` to skip if already configured
This makes GRM safe for CI/CD pipelines and configuration management.
+126 -26
View File
@@ -1,23 +1,44 @@
# Getting Started
## Developer Setup
This guide walks you through setting up GRM, configuring Gitea credentials, and installing your first runner.
## Prerequisites
Before you begin, ensure you have:
- **Python 3.12+** on your local machine
- **SSH access** to the target host(s) where runners will be installed
- **Sudo privileges** on the target host(s) for the SSH user
- **A Gitea instance** with admin access to create registration tokens
- **Git** for cloning the repository
## Step 1: Clone and Setup
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
make setup
source .venv/bin/activate
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
`make setup` creates a virtualenv, installs all dependencies (including Ansible), creates `.env` from `.env.example`, and sets up pre-commit hooks.
If you use pyenv for Python version management:
```bash
pyenv install 3.12
pyenv local 3.12
make setup
```
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
## Configure Gitea Credentials
## Step 2: Configure Gitea Credentials
GRM needs two tokens from your Gitea instance: a **registration token** (required) and an **admin API token** (optional, for post-install verification).
### Step 1: Get the Registration Token
### Get the Registration Token
The registration token tells Gitea to accept the runner when it connects.
@@ -33,7 +54,7 @@ The registration token tells Gitea to accept the runner when it connects.
>
> Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
### Step 2: Get the Admin API Token (optional)
### Get the Admin API Token (optional)
The admin API token enables post-install API checks that verify the runner appears in Gitea's runner list. This is purely informational — the integration test primarily verifies the runner by checking:
@@ -47,7 +68,7 @@ To get an admin API token:
3. Select the **admin** scope (or at minimum: `read:user`, `read:repository`, `read:admin`)
4. Click **Generate Token** and copy it immediately (it won't be shown again)
### Step 3: Create the `.env` File
### Create the `.env` File
```bash
cp .env.example .env
@@ -63,23 +84,24 @@ GITEA_URL=https://git.example.com
GITEA_REGISTRATION_TOKEN=GRxxxxxxxxxxxxxxxxxx
# Admin API token from Step 2 (optional)
REPO_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CI_GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `GITEA_URL` | Yes | Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Yes | Runner registration token from Gitea admin panel |
| `REPO_TOKEN` | No | Admin API token for post-install verification |
| `GITEA_INTEGRATION_RETRIES` | No | API check retries (default: 3) |
| `GITEA_RUNNER_USER` | No | Default SSH user (overrides `--user`) |
| `GITEA_RUNNER_KEY` | No | Default SSH key path (overrides `--key`) |
| `GITEA_RUNNER_LABELS` | No | Default runner labels (overrides `--labels`) |
| `GRM_LANG` | No | UI language: `en`, `bg`, `de`, `ru`, `zh` (default: `en`) |
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GITEA_URL` | Yes | — | Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Yes | — | Runner registration token from Gitea admin panel |
| `CI_GITEA_TOKEN` | No | — | Admin API token for post-install verification |
| `GITEA_INTEGRATION_RETRIES` | No | `3` | API check retries (default: 3) |
| `GITEA_RUNNER_USER` | No | current login | Default SSH user (overrides `--user`) |
| `GITEA_RUNNER_KEY` | No | — | Default SSH key path (overrides `--key`) |
| `GITEA_RUNNER_LABELS` | No | — | Default runner labels (overrides `--labels`) |
| `GRM_LANG` | No | `en` | UI language: `en`, `bg`, `de`, `ru`, `zh`, `pl` |
| `GRM_LOG_LEVEL` | No | `INFO` | Console log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
## Install a Runner
## Step 3: Install Your First Runner
Using the CLI (you will be prompted for the sudo password by default):
@@ -87,6 +109,19 @@ Using the CLI (you will be prompted for the sudo password by default):
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
```
This command:
1. Connects to `192.168.1.10` via SSH as user `ubuntu` using the specified key
2. Creates a dedicated system user `grm-prod-runner` with lingering enabled
3. Installs Docker in rootless mode under the `grm-prod-runner` user
4. Downloads and installs the gitea_runner binary
5. Creates the runner configuration file at `/etc/gitea-runner/prod-runner/config.yaml`
6. Registers the runner with your Gitea instance
7. Creates and starts a systemd user service (`gitea-runner.service`)
8. Sets up a Docker prune timer (daily cleanup)
9. Runs an integration test to verify the installation
10. Saves the runner to the local registry at `~/.local/share/grm/runners.json`
> **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:
@@ -95,7 +130,18 @@ Using Make:
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
## Verify Runner
### Runner labels
By default, runners are registered with the label `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels`:
```bash
grm install 192.168.1.10 --user ubuntu --name prod-runner \
--labels "docker:docker://gitea/runner-images:ubuntu-latest"
```
> **Note:** 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.js.
## Step 4: Verify the Installation
The installer performs an automated integration test that verifies:
@@ -104,11 +150,43 @@ The installer performs an automated integration test that verifies:
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
Optional: If `REPO_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.
Optional: If `CI_GITEA_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.
### Check runner status via CLI
```bash
grm status prod-runner
```
This connects to the remote host and checks the systemd user service status.
### List all runners
```bash
grm list
```
This displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners in the local registry. The status is checked live via an Ansible ad-hoc command.
## Step 5: Manage the Runner Lifecycle
Once installed, you can manage the runner by name (connection details are stored in the local registry):
```bash
grm stop prod-runner # Stop the runner service
grm start prod-runner # Start the runner service
grm enable prod-runner # Enable the runner to start on boot
grm status prod-runner # Check the runner status
grm update 192.168.1.10 --user ubuntu # Update the runner binary
grm disable prod-runner # Disable and deregister the runner
grm remove prod-runner # Remove the runner completely
```
See [CLI Commands](CLI-Commands) for the full command reference.
## View Logs
**GRM application logs** (Python CLI output):
### GRM application logs (on your local machine)
```bash
# Application log file (all messages including DEBUG)
@@ -118,20 +196,42 @@ cat ~/.local/state/grm/logs/grm.log
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
**Runner logs** (on the remote host):
### Ansible execution logs
Each `grm` command that invokes Ansible creates a timestamped log file:
```bash
ls ~/.local/state/grm/logs/ansible-*.log
cat ~/.local/state/grm/logs/ansible-20260622-143012.log
```
### Runner logs (on the remote host)
```bash
# Runner logs (via systemd user service)
sudo -u grm-<name> journalctl --user -u gitea-runner -f
sudo -u grm-prod-runner journalctl --user -u gitea-runner -f
# Rootless Docker daemon logs
sudo -u grm-prod-runner journalctl --user -u docker -f
```
The GRM application writes to two destinations:
### Logging destinations
The GRM application writes to three 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 |
| `~/.local/state/grm/logs/ansible-<timestamp>.log` | — | Full Ansible playbook output per execution |
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.
Console output is automatically colorised via `click.style`: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
## Next Steps
- **Install more runners** on the same or different hosts — see [Installation](Installation)
- **Learn all CLI commands** — see [CLI Commands](CLI-Commands)
- **Troubleshoot issues** — see [Troubleshooting](Troubleshooting)
- **Understand the architecture** — see [Architecture](Architecture)
+192 -8
View File
@@ -1,20 +1,102 @@
# Installation
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](Getting-Started) for setup instructions. Do not run from `master` — it may contain unreleased changes.
## 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.
### On your local machine (where you run `grm`)
- **Python 3.12+** — GRM targets Python 3.12 and requires it for development setup. Use `pyenv` if you need to manage multiple Python versions.
- **Ansible** — Installed automatically by `make setup` (via pip). GRM delegates all remote operations to `ansible-playbook`.
- **SSH key** — A private key that grants access to the target host(s) as a user with sudo privileges.
### On the target host(s) (where runners will be installed)
- **SSH server** — 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`.
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed instructions on obtaining tokens.
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](Getting-Started) for detailed instructions on obtaining tokens.
- **systemd** — Required for user services and lingering. All supported OSes ship with systemd.
- **Docker** — Installed automatically by the Ansible role (rootless mode). No pre-existing Docker installation is required.
## Supported Operating Systems
- Arch Linux
- Ubuntu 22.04 / 24.04
- Debian 12
| OS | Versions | Package manager |
|----|----------|-----------------|
| Arch Linux | rolling | pacman |
| Ubuntu | 22.04, 24.04 | apt |
| Debian | 12 | apt |
All supported OSes are tested in CI via molecule scenarios on every PR.
All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files. The platform matrix is defined in `devx.molecule.platforms` as the single source of truth.
## Installation Methods
### Method 1: From source (recommended for full control)
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
cd grm
git checkout $(git describe --tags --abbrev=0) # Latest stable release
make setup
source .venv/bin/activate
```
`make setup` performs the following:
1. Verifies Python 3.12+ is installed
2. Creates a virtualenv in `.venv`
3. Installs all Python dependencies (including Ansible, Click, python-dotenv)
4. Creates `.env` from `.env.example` if not present
5. Installs development tools (actionlint, git-cliff, act_runner, checkmake)
6. Sets up pre-commit hooks
### Method 2: Via pip
GRM is published to the Gitea PyPI registry at
`https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple`.
The registry is publicly readable — no authentication required to install.
**Quick install (one-off):**
```bash
pip install gitea-runner-manager --index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
**Persistent configuration (recommended):**
Add the registry to `~/.pip/pip.conf`:
```ini
[global]
extra-index-url = https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
```
Then install normally:
```bash
pip install gitea-runner-manager
```
This installs the `grm` CLI and its Python dependencies. The Ansible playbooks
and role are bundled with the package, so `grm install` works out of the box.
For development or access to Make targets, clone the repository (Method 1).
### Post-install configuration
After installation, create your `.env` file:
```bash
cp .env.example .env
# Edit .env with your Gitea URL and registration token
```
Required variables:
| Variable | Description |
|----------|-------------|
| `GITEA_URL` | Your Gitea instance URL (e.g., `https://git.example.com`) |
| `GITEA_REGISTRATION_TOKEN` | Runner registration token from Gitea (starts with `GR`) |
See [Getting Started](Getting-Started) for detailed token setup instructions.
## Quick Start Install
@@ -34,13 +116,63 @@ Using Make:
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
```
The Make target wraps the `grm install` CLI command. All Make install variables are optional except `HOST`:
| Variable | Description |
|----------|-------------|
| `HOST` | Remote host (IP address or hostname) — **required** |
| `USER` | SSH user |
| `KEY` | Path to SSH private key |
| `NAME` | Gitea Runner name |
| `TOKEN` | Registration token |
| `ASK_BECOME_PASS` | Set to `1` to prompt for sudo password |
## What Gets Installed on the Target Host
When you run `grm install`, the Ansible role creates the following on the remote host:
| Resource | Path | Description |
|----------|------|-------------|
| System user | `grm-<name>` | Dedicated system user with `/bin/bash` shell |
| Home directory | `/home/grm-<name>/` | User home with `.config/systemd/user/` |
| Data directory | `/var/lib/gitea-runner/<name>/` | Runner data including `.runner` registration file |
| Config directory | `/etc/gitea-runner/<name>/` | Runner configuration file (`config.yaml`) |
| Runner binary | `/usr/local/bin/gitea_runner` | The gitea_runner executable |
| Docker socket | `/run/user/<UID>/docker.sock` | Rootless Docker socket |
| Systemd service | `gitea-runner.service` | User service for the runner daemon |
| Docker prune timer | `docker-prune.timer` | Daily Docker cleanup timer |
| subuid/subgid | `/etc/subuid`, `/etc/subgid` | User namespace mapping (100000-165535) |
| Lingering | `loginctl enable-linger` | Ensures services run without active login |
## 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:
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) 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
# Manage runners by name — connection details come from the registry
grm status prod-runner
grm stop prod-runner
grm start prod-runner
```
You can override any stored value by passing the corresponding flag (`--host`, `--user`, `--key`).
### Registry file format
```json
{
"prod-runner": {
"host": "192.168.1.10",
"user": "ubuntu",
"key": "/home/user/.ssh/id_ed25519",
"gitea_url": "https://git.example.com",
"labels": "docker:docker://gitea/runner-images:ubuntu-latest",
"created_at": "2026-06-22T14:30:12.000000+00:00"
}
}
```
## Multiple Instances on the Same Host
@@ -55,4 +187,56 @@ grm install 192.168.1.10 --user ubuntu --name build-runner
# Manage them independently by name
grm stop workflow-runner
grm status build-runner
grm list
```
Each instance gets:
- **Dedicated system user**: `grm-<name>` with its own home directory
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup
Runners on the same host never interfere with each other or with the host's Docker installation.
## Updating Runners
To update the gitea_runner binary on a remote host:
```bash
grm update 192.168.1.10 --user ubuntu
```
To update to a specific version:
```bash
grm update 192.168.1.10 --user ubuntu --version 1.0.8
```
Using Make:
```bash
make update HOST=192.168.1.10 USER=ubuntu VERSION=1.0.8
```
## Removing Runners
To remove a runner completely (deregisters from Gitea, removes user, directories, and service files):
```bash
grm remove prod-runner --token <registration-token>
```
To skip remote cleanup and only remove the local registry entry (useful when the remote host is already gone):
```bash
grm remove prod-runner --force
```
Using Make:
```bash
make remove NAME=prod-runner TOKEN=<registration-token>
```
+161 -14
View File
@@ -1,42 +1,189 @@
# Troubleshooting
## "Event loop is closed" warning
## Installation Issues
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
### Ansible connection fails (UNREACHABLE)
## Runner appears offline after installation
**Symptom:** Ansible reports `UNREACHABLE` when trying to connect to the target host.
**Causes and solutions:**
- **SSH key not found or wrong path** — Verify the key path with `--key`. The key must be readable by the user running `grm`.
- **SSH user does not exist on the target** — Verify the `--user` argument. The user must exist on the remote host and have sudo privileges.
- **Host is not reachable** — Verify the host IP/hostname with `ping` and `ssh -u <user> <host>`.
- **SSH port is not 22** — GRM uses the default SSH port. If your host uses a different port, you may need to configure SSH config (`~/.ssh/config`) with the appropriate port.
### Sudo password prompt fails or is not displayed
**Symptom:** The sudo password prompt does not appear or the command hangs.
**Causes and solutions:**
- **Non-interactive session** — If running in a CI/CD pipeline or script without a TTY, the password prompt cannot be displayed. Configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
- **Wrong sudo password** — Ensure you are entering the correct sudo password for the remote user.
### GITEA_URL must be set
**Symptom:** Error message `GITEA_URL must be set (or pass --url)`.
**Solution:** Set `GITEA_URL` in your `.env` file or pass it via `--url`:
```bash
# In .env
GITEA_URL=https://git.example.com
# Or on the command line
grm install 192.168.1.10 --user ubuntu --url https://git.example.com
```
### GITEA_REGISTRATION_TOKEN must be set
**Symptom:** Error message `GITEA_REGISTRATION_TOKEN must be set (or pass --token)`.
**Solution:** Set `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token`. The token must be a valid registration token from your Gitea instance (it starts with `GR`).
## Runner Issues
### Runner appears offline after installation
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
- Verify the registration token has not expired — generate a new one from Gitea if needed (Site Administration → Actions → Runners → Create Registration Token).
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
- Check logs for registration errors.
- Check logs for registration errors: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
- Confirm the runner appears in the Gitea UI under **Actions → Runners**. If it shows as offline, the runner daemon may not be polling — check network connectivity between the runner host and Gitea.
## Integration test fails
### Runner service fails to start
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`
- Verify the runner binary exists: `ls -la /usr/local/bin/gitea_runner`
- Verify the config file exists: `ls -la /etc/gitea-runner/<name>/config.yaml`
- Verify the `.runner` registration file exists: `ls -la /var/lib/gitea-runner/<name>/.runner`
### 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`. If not enabled, run `sudo loginctl enable-linger grm-<name>`.
- Verify subuid/subgid entries exist: `grep grm-<name> /etc/subuid /etc/subgid`. If missing, the rootless Docker setup will fail.
### Runner not found in registry
**Symptom:** Error message `Runner '<name>' not found in registry. Use 'grm install' first or provide --host and --user.`
**Solution:** The runner was not installed via `grm install`, or the registry file was deleted. Either:
1. Install the runner first: `grm install <host> --user <user> --name <name>`
2. Or provide explicit connection details: `grm status <name> --host <host> --user <user>`
## Integration Test Issues
### 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
- The registration token is valid and has not expired
- Runner logs for registration errors
- Runner logs for registration errors: `sudo -u grm-<name> journalctl --user -u gitea-runner`
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
- The `.runner` file should contain valid JSON with `id`, `uuid`, `token`, `address` fields
2. **Service not running** — Daemon failed to start. Check:
- `sudo -u grm-<name> systemctl --user status gitea-runner`
- Logs for connection errors
- Logs for connection errors: `sudo -u grm-<name> journalctl --user -u gitea-runner`
- Verify the rootless Docker daemon is running (see above)
## Rootless Docker: service fails to start
### API verification shows error status
- 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`.
If `CI_GITEA_TOKEN` is set, the integration test queries the Gitea API. If the API returns `401` or `403`, the token does not have sufficient permissions. This is **informational only** and does not affect pass/fail. The test passes as long as the `.runner` file exists and the systemd service is active.
## Logging and Diagnostics
### Enable debug logging
```bash
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
```
This prints all debug messages to the console. The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting.
### View Ansible execution logs
Each `grm` command that invokes Ansible creates a timestamped log file:
```bash
ls ~/.local/state/grm/logs/ansible-*.log
cat ~/.local/state/grm/logs/ansible-<timestamp>.log
```
These logs contain the full Ansible output, including task results, changed/failed counts, and any error messages.
### View runner logs on the remote host
```bash
# Runner daemon logs
sudo -u grm-<name> journalctl --user -u gitea-runner -f
# Rootless Docker daemon logs
sudo -u grm-<name> journalctl --user -u docker -f
# Docker prune timer logs
sudo -u grm-<name> journalctl --user -u docker-prune.service
```
## Development Issues
### "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.
### Pre-commit rejects commit message
The pre-commit hook validates that commit messages follow conventional commit format (`feat:`, `fix:`, `docs:`, etc.). The `GRM-N:` prefix is not allowed on branch commits — use it only in PR titles.
**Correct:**
```
feat: add new runner label option
```
**Incorrect:**
```
GRM-33: add new runner label option
update README
```
### `make pytest-cov` fails with coverage below 100%
Add tests for any new code paths. The coverage requirement is strict (`--cov-fail-under=100`). Run `make pytest-cov` locally to see which lines are not covered:
```bash
make pytest-cov
# The output shows "Missing" lines for each file
```
### `make molecule` fails with Docker not available
Molecule requires Docker to be installed and running on your machine. Verify:
```bash
docker info # Should print Docker server info
```
If Docker is not installed, install it via your package manager or [Docker's official installation guide](https://docs.docker.com/get-docker/).
## Common Issues Reference Table
| Symptom | Likely Cause | Solution |
|---------|-------------|----------|
| Ansible UNREACHABLE | SSH connection failed | Verify `--user`, `--key`, and host reachability |
| `GITEA_URL must be set` | Missing environment variable | Set `GITEA_URL` in `.env` or pass `--url` |
| `GITEA_REGISTRATION_TOKEN must be set` | Missing environment variable | Set `GITEA_REGISTRATION_TOKEN` in `.env` or pass `--token` |
| Runner appears offline | Registration failed or service not running | Check GITEA_URL, token validity, and service status |
| Rootless Docker fails to start | subuid/subgid missing or lingering disabled | Verify `/etc/subuid`, `/etc/subgid`, and `loginctl show-user` |
| Runner not found in registry | Runner not installed or registry deleted | Run `grm install` or provide `--host` and `--user` |
| 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 |
@@ -44,7 +191,7 @@ The test checks two things:
| 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 |
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `devx.tools.configure_repo` fails | CI_GITEA_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
| 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 |
+4 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 10 seconds.
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
# pre-commit hook: fail if unit tests are too slow.
# Checks both total suite time (10s) and per-test time (0.5s).
# Aligned with CI (ci.yml uses same thresholds).
set -e
python3 -m devx.tools.check_test_speed --max-seconds 10
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+60 -4
View File
@@ -1,5 +1,61 @@
#!/usr/bin/env bash
# pre-push hook: fail if unit tests take longer than 10 seconds.
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
set -e
python3 -m devx.tools.check_test_speed --max-seconds 10
# pre-push hook: validate Vikunja task exists and tests are fast.
#
# This catches issues that would otherwise only surface in CI:
# - Branch name missing task ID (e.g., GRM-N)
# - Vikunja task does not exist for the task ID in the branch name
# - Unit tests too slow (total > 4s, per-test > 0.5s)
#
# Uses devx.tools.pre_push_check for reusable validation logic.
# Project config (task prefix, Vikunja project ID) is read from
# [tool.devx] in pyproject.toml by devx.config — no hardcoded values here.
#
# Bootstrap resilience: if devx is not importable (e.g., during devx
# upgrades), the hook prints a warning and allows the push.
# Determine the branch being pushed
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [ -z "$BRANCH" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "main" ]; then
exit 0
fi
# Load .env if present (for VIKUNJA_TOKEN)
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. .env
set +a
fi
# Find the Python interpreter with devx installed
if [ -f .venv/bin/python ]; then
PY=.venv/bin/python
elif [ -x "${HOME}/.pyenv/bin/pyenv" ]; then
export PYENV_ROOT="${HOME}/.pyenv"
export PATH="${PYENV_ROOT}/bin:${PYENV_ROOT}/shims:${PATH}"
eval "$("${PYENV_ROOT}/bin/pyenv" init -)" 2>/dev/null || true
eval "$("${PYENV_ROOT}/bin/pyenv" virtualenv-init -)" 2>/dev/null || true
pyenv activate gitea-runner-manager 2>/dev/null || true
PY=python3
else
PY=python3
fi
# Bootstrap resilience: if devx is not importable, warn but allow the push
if ! $PY -c "import devx.tools.pre_push_check" 2>/dev/null; then
echo "WARNING: devx not installed — pre-push check skipped."
echo "Run 'make setup' to install devx."
exit 0
fi
# Run pre-push validation via devx
$PY -m devx.tools.pre_push_check --branch "$BRANCH" || {
echo ""
echo "Pre-push validation failed. Fix the issues above before pushing."
echo "To bypass (NOT recommended): git push --no-verify"
exit 1
}
# Check test speed (aligned with CI thresholds)
$PY -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+46 -23
View File
@@ -14,10 +14,9 @@ classifiers = [
"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",
"python-dotenv==1.2.2",
"click==8.4.2",
"ansible==14.1.0",
]
[project.scripts]
@@ -27,29 +26,35 @@ grm = "gitea_runner_manager.cli:cli"
version = {attr = "gitea_runner_manager.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests
# Minimal deps for CI scripts that only need click/dotenv
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"build==1.5.0",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx==0.26.3",
]
# Lint and type-checking tools (quality job)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
"bandit>=1.8.2",
"pip-audit>=2.10",
"pre-commit>=4.6.0",
"ansible-lint>=26.4.0",
"ruff==0.15.20",
"pyright==1.1.411",
"bandit==1.9.4",
"pip-audit==2.10.1",
"pre-commit==4.6.0",
"ansible-lint==26.4.0",
]
# Molecule testing (molecule-tests job)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"molecule==26.4.0",
"molecule-docker==2.1.0",
]
# Full dev environment (local development, includes everything)
dev = [
"gitea-runner-manager[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx==0.26.3",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -61,9 +66,9 @@ where = ["src"]
gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
testpaths = ["tests", "scripts/tests"]
pythonpath = ["src", "scripts"]
addopts = "--cov=src/gitea_runner_manager --cov=scripts/prune_runner_images.py --cov-report=term-missing --cov-fail-under=100"
markers = [
"integration: marks tests as integration tests (not counted in coverage)",
]
@@ -89,23 +94,41 @@ strict = ["src/gitea_runner_manager"]
# Change classification — determines which changes trigger a release
# ---------------------------------------------------------------------------
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# Project-specific devx configuration (read by devx.config)
[tool.devx]
task_prefix = "GRM"
vikunja_project_id = 6
repo_owner = "oblachno-oss"
repo_name = "grm"
# Molecule test weights for LPT scheduling.
# GRM has a single role (gitea-runner) with 7 scenarios.
# Weights are estimates — recalibrate from CI logs after next run.
[tool.devx.molecule.weights]
"multi-instance" = 8
"lifecycle" = 6
"update" = 5
"default" = 4
"deregister" = 3
"remove" = 3
"template-content" = 2
# lint config, etc.) that applies to any Python project. We only specify
# what's different about GRM.
[tool.devx.classify]
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
# Project-specific infrastructure paths (merged with defaults).
# GRM has no additional infrastructure paths beyond the defaults.
infrastructure = []
# scripts/** — dev tools and CI/CD automation, not part of the installed package.
# (scripts were migrated to devx in GRM-64, but the path is kept for
# historical correctness and in case scripts are added back.)
infrastructure = ["scripts/**"]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure:
# - __init__.py: only contains __version__ (set by release.py, not user code)
# - api_clients.py: used only by tests and legacy CI scripts (now in devx),
# not by the grm CLI tool itself
infrastructure_overrides = [
"src/gitea_runner_manager/__init__.py",
"src/gitea_runner_manager/api_clients.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
View File
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Prune stale runner images from a rootless Docker daemon.
Usage:
python3 prune_runner_images.py [--dry-run]
Removes all images matching the runner-images pattern from the local
Docker daemon so the runner pulls a fresh :latest on the next job.
Environment variables:
DOCKER_HOST Docker daemon socket (set by caller)
XDG_RUNTIME_DIR Runtime directory (set by caller)
"""
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
import sys
from collections.abc import Sequence
#: Pattern for images we want to prune (repository:tag format).
IMAGE_PATTERN = re.compile(r"runner-images/(ci-base|ci-quality|ci-full)")
def list_docker_images() -> list[str]:
"""List all images in the local Docker daemon as repository:tag strings.
Returns:
List of ``repository:tag`` strings (excluding ``<none>`` entries).
"""
result = subprocess.run( # nosec B603
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip() and "<none>" not in line]
def filter_runner_images(images: Sequence[str]) -> list[str]:
"""Filter image list to only runner-images entries.
Args:
images: List of ``repository:tag`` strings.
Returns:
Subset matching the runner-images pattern.
"""
return [img for img in images if IMAGE_PATTERN.search(img)]
def remove_images(images: Sequence[str], dry_run: bool = False) -> list[str]:
"""Remove the given images from the local Docker daemon.
Args:
images: List of ``repository:tag`` strings to remove.
dry_run: If True, print what would be removed but don't execute.
Returns:
List of images that were removed (or would be removed in dry-run).
"""
removed: list[str] = []
for img in images:
if dry_run:
print(f"[dry-run] would remove: {img}")
removed.append(img)
continue
result = subprocess.run( # nosec B603
["docker", "rmi", "-f", img],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f"removed: {img}")
removed.append(img)
else:
print(f"failed to remove {img}: {result.stderr.strip()}", file=sys.stderr)
return removed
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Prune stale runner images.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be removed without executing.",
)
args = parser.parse_args(argv)
all_images = list_docker_images()
runner_images = filter_runner_images(all_images)
if not runner_images:
print("no runner images found to prune")
return 0
removed = remove_images(runner_images, dry_run=args.dry_run)
print(f"pruned {len(removed)} image(s)")
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
View File
+144
View File
@@ -0,0 +1,144 @@
"""Tests for prune_runner_images.py."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from scripts.prune_runner_images import (
filter_runner_images,
list_docker_images,
main,
remove_images,
)
class TestListDockerImages:
"""Tests for list_docker_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_returns_images_from_docker(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo1:tag1\nrepo2:tag2\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo1:tag1", "repo2:tag2"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_filters_none_entries(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout="repo:tag\n<none>:<none>\nother:v1\n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v1"]
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = list_docker_images()
assert result == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_strips_whitespace(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
stdout=" repo:tag \n\n other:v2 \n",
returncode=0,
)
result = list_docker_images()
assert result == ["repo:tag", "other:v2"]
class TestFilterRunnerImages:
"""Tests for filter_runner_images()."""
def test_matches_runner_images(self) -> None:
images = [
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest",
]
result = filter_runner_images(images)
assert len(result) == 3
def test_excludes_non_runner_images(self) -> None:
images = [
"docker.io/library/python:3.12",
"docker.io/library/nginx:latest",
"git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest",
]
result = filter_runner_images(images)
assert len(result) == 1
assert "ci-base" in result[0]
def test_empty_list(self) -> None:
assert filter_runner_images([]) == []
def test_no_matches(self) -> None:
images = ["python:3.12", "nginx:latest"]
assert filter_runner_images(images) == []
class TestRemoveImages:
"""Tests for remove_images()."""
@patch("scripts.prune_runner_images.subprocess.run")
def test_removes_images(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
images = ["repo/ci-base:latest", "repo/ci-quality:latest"]
removed = remove_images(images)
assert removed == images
assert mock_run.call_count == 2
@patch("scripts.prune_runner_images.subprocess.run")
def test_dry_run_does_not_call_docker(self, mock_run: MagicMock) -> None:
images = ["repo/ci-base:latest"]
removed = remove_images(images, dry_run=True)
assert removed == images
mock_run.assert_not_called()
@patch("scripts.prune_runner_images.subprocess.run")
def test_failed_removal_not_in_result(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stderr="image in use")
images = ["repo/ci-base:latest"]
removed = remove_images(images)
assert removed == []
@patch("scripts.prune_runner_images.subprocess.run")
def test_empty_list(self, mock_run: MagicMock) -> None:
removed = remove_images([])
assert removed == []
mock_run.assert_not_called()
class TestMain:
"""Tests for main()."""
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_no_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = []
assert main([]) == 0
mock_remove.assert_not_called()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_with_images(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = [
"repo/runner-images/ci-base:latest",
"python:3.12",
]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main([]) == 0
mock_remove.assert_called_once()
@patch("scripts.prune_runner_images.list_docker_images")
@patch("scripts.prune_runner_images.remove_images")
def test_dry_run_flag(self, mock_remove: MagicMock, mock_list: MagicMock) -> None:
mock_list.return_value = ["repo/runner-images/ci-base:latest"]
mock_remove.return_value = ["repo/runner-images/ci-base:latest"]
assert main(["--dry-run"]) == 0
mock_remove.assert_called_once_with(
["repo/runner-images/ci-base:latest"],
dry_run=True,
)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.6.1"
__version__ = "0.12.3"
-353
View File
@@ -1,353 +0,0 @@
"""Reusable HTTP API clients for Gitea and Vikunja."""
from __future__ import annotations
import logging
import time
from typing import Any
import requests
from .config import DEFAULT_TIMEOUT
from .exceptions import APIError
logger = logging.getLogger("grm")
# Retry configuration for transient errors (429, 5xx, connection errors)
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
"""Extract status code and message from an HTTPError response."""
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
try:
body: dict[str, Any] = response.json() if response is not None else {}
message: str = body.get("message", str(e))
except Exception:
message = str(e)
return status, message
def _is_retryable(e: Exception) -> bool:
"""Check if an exception is a transient error worth retrying."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError):
status, _ = _parse_error(e)
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
class GiteaClient:
"""Low-level Gitea REST API client with connection pooling."""
def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None:
self._base_url = base_url.rstrip("/")
self._owner = owner
self._repo = repo
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
)
def _url(self, path: str) -> str:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
# -- repo settings --
def update_repo_settings(self, settings: dict[str, Any]) -> dict[str, Any]:
"""Update repository settings (e.g. auto-delete branch after merge)."""
r = self._request("PATCH", "", json=settings)
return r.json()
# -- branch protection --
def list_branch_protections(self) -> list[dict[str, Any]]:
r = self._request("GET", "/branch_protections")
return r.json()
def create_branch_protection(self, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("POST", "/branch_protections", json=config)
return r.json()
def update_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
r = self._request("PATCH", f"/branch_protections/{branch}", json=config)
return r.json()
def ensure_branch_protection(self, branch: str, config: dict[str, Any]) -> dict[str, Any]:
"""Idempotent: create or update branch protection for the given branch."""
existing = self.list_branch_protections()
for p in existing:
if p.get("branch_name") == branch:
update_config = {k: v for k, v in config.items() if k != "branch_name"}
return self.update_branch_protection(branch, update_config)
return self.create_branch_protection(config)
# -- labels --
def list_labels(self) -> list[dict[str, Any]]:
r = self._request("GET", "/labels")
return r.json()
def create_label(self, name: str, color: str, description: str = "") -> dict[str, Any]:
r = self._request(
"POST",
"/labels",
json={"name": name, "color": color, "description": description},
)
return r.json()
def ensure_label(self, name: str, color: str, description: str = "") -> dict[str, Any] | None:
"""Idempotent: create label if it doesn't already exist."""
labels = self.list_labels()
for label in labels:
if label["name"] == name:
return None
return self.create_label(name, color, description)
def create_issue(self, title: str, body: str = "", labels: list[int] | None = None) -> dict[str, Any]:
"""Create a new issue in the repository.
Args:
labels: List of label IDs (integers, not names).
"""
payload: dict[str, Any] = {"title": title, "body": body}
if labels:
payload["labels"] = labels
r = self._request("POST", "/issues", json=payload)
return r.json()
# -- pulls / releases --
def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch labels currently attached to a pull request."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return r.json()
def merge_pr(self, pr_number: str | int, merge_title: str) -> None:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
Uses the combined status endpoint (/commits/{sha}/status) which
returns one entry per context (the latest), deduplicated server-side.
The plural endpoint (/commits/{sha}/statuses) returns every historical
entry including stale "pending" ones that never got updated.
"""
r = self._request("GET", f"/commits/{sha}/status")
data = r.json()
return data.get("statuses", [])
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
"""Fetch pull request details including mergeable state."""
r = self._request("GET", f"/pulls/{pr_number}")
return r.json()
def get_pr_files(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the list of files changed in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/files")
return r.json()
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits")
return r.json()
def get_pr_reviews(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch reviews posted on a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/reviews")
return r.json()
def create_review(
self,
pr_number: str | int,
event: str = "COMMENT",
body: str = "",
comments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Post a review on a pull request.
Args:
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
body: Top-level review body text.
comments: Line-level comments with ``path``, ``body``,
``new_position`` (and optionally ``old_position``).
"""
# Map common event names to Gitea API values
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
gitea_event = event_map.get(event, event)
payload: dict[str, Any] = {"event": gitea_event, "body": body}
if comments:
payload["comments"] = comments
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
return r.json()
def create_release(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
payload = {
"tag_name": tag,
"name": name or tag,
"body": body,
"draft": draft,
"prerelease": prerelease,
}
r = self._request("POST", "/releases", json=payload)
return r.json()
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
"""Fetch a release by its tag name. Returns None if not found."""
try:
r = self._request("GET", f"/releases/tags/{tag}")
return r.json()
except APIError:
return None
def create_release_idempotent(
self,
tag: str,
name: str = "",
body: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
"""Create a release, or return the existing one if it already exists.
This is idempotent safe to call multiple times for the same tag.
"""
existing = self.get_release_by_tag(tag)
if existing:
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
return existing
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {token}"})
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}"
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params)
return r.json()
def get_task(self, task_id: int) -> dict[str, Any]:
"""Fetch a single task by its numeric ID."""
r = self._request("GET", f"/tasks/{task_id}")
return r.json()
def list_project_tasks(self, project_id: int, **params: Any) -> list[dict[str, Any]]:
"""List tasks in a specific project (more efficient than listing all tasks)."""
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def post_comment(self, task_id: int, comment: str) -> None:
self._request("PUT", f"/tasks/{task_id}/comments", json={"comment": comment})
def update_task(self, task_id: int, **fields: Any) -> None:
self._request("POST", f"/tasks/{task_id}", json=fields)
+237 -28
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import functools
import os
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
import click
@@ -12,12 +14,41 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from . import __version__
from .exceptions import GRMError
from .gitea_client import GiteaAPIError, GiteaWorkflowClient
from .i18n import _
from .runner_manager import RunnerManager
load_dotenv(override=True)
def _default_user() -> str:
"""Default SSH user from env or current OS user."""
if user := os.getenv("GITEA_RUNNER_USER"):
return user
try:
return os.getlogin()
except OSError:
return os.getenv("USER", "root")
def _get_become_password_file() -> str | None:
"""Read become-password-file from Click context or env vars."""
ctx = click.get_current_context(silent=True)
if ctx and ctx.obj:
path = ctx.obj.get("become_password_file")
if path:
return path
return os.getenv("GRM_BECOME_PASSWORD_FILE") or os.getenv("ANSIBLE_BECOME_PASSWORD_FILE")
def _get_verbose() -> bool:
"""Read verbose flag from Click context."""
ctx = click.get_current_context(silent=True)
if ctx and ctx.obj:
return ctx.obj.get("verbose", False)
return False
def _runner_options(func: Callable[..., Any]) -> Callable[..., Any]:
"""Apply common override options for registry-based lifecycle commands."""
func = click.option(
@@ -49,8 +80,25 @@ def _handle_errors(msg_key: str) -> Callable[[Callable[..., Any]], Callable[...,
@click.group(help=_("Gitea Runner Manager — manage Gitea Actions runners."))
@click.version_option(version=__version__)
def cli() -> None:
pass
@click.option(
"--become-password-file",
envvar="GRM_BECOME_PASSWORD_FILE",
type=click.Path(exists=True, dir_okay=False, readable=True),
default=None,
help=_("Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)"),
)
@click.option(
"--verbose",
"-v",
is_flag=True,
default=False,
help=_("Enable verbose Ansible output (-v flag passed to ansible)"),
)
@click.pass_context
def cli(ctx: click.Context, become_password_file: str | None, verbose: bool) -> None:
ctx.ensure_object(dict)
ctx.obj["become_password_file"] = become_password_file
ctx.obj["verbose"] = verbose
@cli.command(help=_("Install and configure a Gitea Runner on a remote host."))
@@ -58,8 +106,8 @@ def cli() -> None:
@click.option(
"--user",
"-u",
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
default=_default_user,
help=_("SSH user (env: GITEA_RUNNER_USER)"),
)
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
@click.option("--name", "-n", help=_("Gitea Runner name (default: host)"))
@@ -77,8 +125,8 @@ def cli() -> None:
@click.option(
"--admin-token",
"-a",
default=lambda: os.getenv("REPO_TOKEN"),
help=_("Gitea admin API token for integration test (env: REPO_TOKEN)"),
default=lambda: os.getenv("CI_GITEA_TOKEN"),
help=_("Gitea admin API token for integration test (env: CI_GITEA_TOKEN)"),
)
@click.option(
"--integration-retries",
@@ -90,14 +138,19 @@ def cli() -> None:
@click.option(
"--labels",
"-l",
default=lambda: os.getenv("GITEA_RUNNER_LABELS", ""),
help=_("Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest"),
default=None,
help=_(
"Runner labels (env: GITEA_RUNNER_LABELS). "
"Pass an empty string for no labels. "
"Example: docker:docker://alpine:latest"
),
)
@click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password (default)"),
)
@_handle_errors("Installation failed: {error}")
def install(
host: str,
user: str,
@@ -107,25 +160,26 @@ def install(
url: str,
admin_token: str | None,
integration_retries: int,
labels: str,
labels: str | None,
ask_become_pass: bool,
) -> None:
if labels is None:
labels = os.getenv("GITEA_RUNNER_LABELS")
manager = RunnerManager()
try:
manager.install(
host=host,
user=user,
key=key,
name=name,
token=token,
gitea_url=url,
admin_token=admin_token,
integration_retries=integration_retries,
labels=labels or None,
ask_become_pass=ask_become_pass,
)
except GRMError as e:
raise click.ClickException(_("Installation failed: {error}", error=e)) from e
manager.install(
host=host,
user=user,
key=key,
name=name,
token=token,
gitea_url=url,
admin_token=admin_token,
integration_retries=integration_retries,
labels=labels,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@cli.command(help=_("Update the Gitea Runner binary on a remote host."))
@@ -133,8 +187,8 @@ def install(
@click.option(
"--user",
"-u",
default=lambda: os.getenv("GITEA_RUNNER_USER", os.getlogin()),
help=_("SSH user"),
default=_default_user,
help=_("SSH user (env: GITEA_RUNNER_USER)"),
)
@click.option("--key", "-k", default=lambda: os.getenv("GITEA_RUNNER_KEY"), help=_("Path to SSH private key"))
@click.option("--version", "-v", help=_("Specific Gitea Runner version"))
@@ -158,6 +212,8 @@ def update(
key=key,
version=version,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -179,6 +235,8 @@ def start(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -200,6 +258,31 @@ def stop(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@cli.command(help=_("Restart a registered Gitea Runner (stop, prune images, start)."))
@click.argument("runner_name")
@_runner_options
@_handle_errors("Restart failed: {error}")
def restart(
runner_name: str,
host: str | None,
user: str | None,
key: str | None,
ask_become_pass: bool,
) -> None:
manager = RunnerManager()
manager.restart(
name=runner_name,
host=host,
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -221,6 +304,8 @@ def enable(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -257,6 +342,8 @@ def disable(
token=token,
gitea_url=url,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -278,6 +365,8 @@ def status(
user=user,
key=key,
ask_become_pass=ask_become_pass,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
@@ -322,14 +411,55 @@ def remove(
gitea_url=url,
ask_become_pass=ask_become_pass,
force=force,
become_password_file=_get_become_password_file(),
verbose=_get_verbose(),
)
def _collect_become_pass(ask_become_pass: bool) -> str | None:
"""Collect sudo password for ad-hoc status checks.
Priority:
1. ``--become-password-file`` / ``GRM_BECOME_PASSWORD_FILE`` env var
2. ``ANSIBLE_BECOME_PASSWORD_FILE`` env var
3. Interactive prompt (TTY) or piped stdin (first line)
"""
password_file = _get_become_password_file()
if password_file:
return Path(password_file).read_text(encoding="utf-8").strip() or None
if not ask_become_pass:
return None
if sys.stdin.isatty():
return (
click.prompt(
_("Sudo password"),
hide_input=True,
default="",
show_default=False,
)
or None
)
return sys.stdin.readline().strip() or None
@cli.command(name="list", help=_("List all registered runners with live status."))
@click.option(
"--ask-become-pass/--no-ask-become-pass",
default=True,
help=_("Prompt for sudo password once for all status checks (default)."),
)
@click.option(
"--no-status",
is_flag=True,
default=False,
help=_("Skip live SSH status checks and show registry entries only"),
)
@_handle_errors("List failed: {error}")
def list_runners() -> None:
def list_runners(ask_become_pass: bool, no_status: bool) -> None:
become_pass = None if no_status else _collect_become_pass(ask_become_pass)
manager = RunnerManager()
runners = manager.list_runners()
runners = manager.list_runners(become_pass=become_pass, no_status=no_status)
if not runners:
click.echo(_("No runners registered. Use 'grm install' to add one."))
@@ -339,3 +469,82 @@ def list_runners() -> None:
click.echo("-" * 90)
for r in runners:
click.echo(f"{r['name']:<18} {r['host']:<16} {r['user']:<10} {r['labels']:<30} {r['status']}")
@cli.command(name="trigger-workflow", help=_("Trigger a Gitea Actions workflow via the API."))
@click.argument("workflow_id", required=False)
@click.option(
"--repo",
default=lambda: os.getenv("GRM_REPO", "oblachno-oss/grm"),
help=_("Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)"),
)
@click.option(
"--ref",
default="master",
help=_("Git ref to run the workflow on (default: master)"),
)
@click.option(
"--url",
default=lambda: os.getenv("GITEA_URL", ""),
help=_("Gitea URL (env: GITEA_URL)"),
)
@click.option(
"--token",
default=lambda: os.getenv("CI_GITEA_TOKEN"),
help=_("Gitea API token (env: CI_GITEA_TOKEN)"),
)
@click.option(
"--list",
"list_only",
is_flag=True,
default=False,
help=_("List available workflows instead of triggering one"),
)
def trigger_workflow(
workflow_id: str,
repo: str,
ref: str,
url: str,
token: str | None,
list_only: bool,
) -> None:
"""Trigger a Gitea Actions workflow dispatch event."""
if not url:
raise click.ClickException(_("GITEA_URL is required (set --url or GITEA_URL env var)"))
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)"))
if not list_only and not workflow_id:
raise click.ClickException(_("WORKFLOW_ID is required unless --list is used"))
client = GiteaWorkflowClient(url, token)
owner, repo_name = repo.split("/", 1)
if list_only:
workflows = client.list_workflows(owner, repo_name)
if not workflows:
click.echo(_("No workflows found in {repo}", repo=repo))
return
click.echo(f"{'ID':<30} {'NAME':<20} {'PATH':<25} {'STATE'}")
click.echo("-" * 85)
for wf in workflows:
wf_id = str(wf.get("id", ""))
wf_name = wf.get("name", "")
wf_path = wf.get("path", "")
wf_state = wf.get("state", "")
click.echo(f"{wf_id:<30} {wf_name:<20} {wf_path:<25} {wf_state}")
return
click.echo(_("Triggering workflow {wf} on {repo}@{ref}...", wf=workflow_id, repo=repo, ref=ref))
try:
result = client.dispatch_workflow(owner, repo_name, workflow_id, ref)
except GiteaAPIError as e:
raise click.ClickException(str(e)) from e
if result and result.get("id"):
run_id: Any = result["id"]
click.echo(_("Workflow triggered successfully. Run ID: {run_id}", run_id=run_id))
if result.get("html_url"):
html_url: Any = result["html_url"]
click.echo(f" {html_url}")
else:
click.echo(_("Workflow triggered successfully."))
-43
View File
@@ -1,43 +0,0 @@
"""Shared configuration constants for GRM scripts and API clients."""
from __future__ import annotations
import os
import re
GITEA_API_URL = os.getenv("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("GRM_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
REPO_OWNER = os.getenv("GRM_REPO_OWNER", "oblachno-oss")
REPO_NAME = os.getenv("GRM_REPO_NAME", "grm")
VIKUNJA_PROJECT_ID = int(os.getenv("GRM_VIKUNJA_PROJECT_ID", "6"))
TASK_ID_RE = re.compile(r"GRM-\d+")
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
DEFAULT_TIMEOUT = 30
DEFAULT_PER_PAGE = 50
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": ["emil"],
"enable_status_check": True,
"status_check_contexts": [
"CI / quality (pull_request)",
"CI / molecule-tests (1) (pull_request)",
"CI / molecule-tests (2) (pull_request)",
"CI / molecule-tests (3) (pull_request)",
],
"required_approvals": 0,
"dismiss_stale_approvals": True,
"block_on_outdated_branch": True,
"block_on_rejected_reviews": True,
"block_on_official_review_requests": True,
}
REPO_SETTINGS_CONFIG: dict[str, object] = {
"default_delete_branch_after_merge": True,
}
-9
View File
@@ -11,12 +11,3 @@ class AnsibleError(GRMError):
"""Raised when an Ansible command fails."""
pass
class APIError(GRMError):
"""Raised when a REST API call returns an HTTP error."""
def __init__(self, status: int, message: str) -> None:
self.status = status
self.message = message
super().__init__(f"HTTP {status}: {message}")
+36 -10
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
import subprocess # nosec B404
import sys
import tempfile
from datetime import datetime
from pathlib import Path
@@ -88,8 +89,14 @@ class AnsibleExecutor:
become: bool = False,
ask_become_pass: bool = False,
check: bool = True,
become_pass: str | None = None,
) -> str:
"""Run an Ansible ad-hoc command and return stdout."""
"""Run an Ansible ad-hoc command and return stdout.
When ``become_pass`` is provided, it is passed via a temporary file
(``--become-password-file``) to avoid stdin consumption issues when
running multiple ad-hoc commands in sequence (e.g. ``grm list``).
"""
cmd = [
"ansible",
host,
@@ -104,16 +111,35 @@ class AnsibleExecutor:
cmd.extend(["--private-key", key])
if become:
cmd.append("--become")
if become and ask_become_pass and sys.stdin.isatty():
cmd.append("--ask-become-pass")
password_file: str | None = None
if become and ask_become_pass:
if become_pass:
fd, password_file = tempfile.mkstemp(suffix=".txt", prefix="grm-become-")
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w") as f:
f.write(become_pass)
cmd.extend(["--become-password-file", password_file])
else:
env_password_file = os.getenv("ANSIBLE_BECOME_PASSWORD_FILE")
if env_password_file:
cmd.extend(["--become-password-file", env_password_file])
else:
cmd.append("--ask-become-pass")
env = os.environ.copy()
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
try:
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
finally:
if password_file:
with contextlib.suppress(FileNotFoundError):
os.unlink(password_file)
if check and proc.returncode != 0:
stderr = proc.stderr.strip() if proc.stderr else ""
raise AnsibleError(
+86
View File
@@ -0,0 +1,86 @@
"""Minimal Gitea API client for workflow operations.
Uses urllib from the standard library to avoid adding requests as a
runtime dependency. Only covers the Actions workflow dispatch endpoint.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request # noqa: PTH123 # nosec B404
from contextlib import suppress
from typing import Any
class GiteaAPIError(Exception):
"""Raised when a Gitea API call fails."""
def __init__(self, status: int, message: str) -> None:
super().__init__(f"Gitea API error {status}: {message}")
self.status = status
self.message = message
class GiteaWorkflowClient:
"""Thin client for Gitea Actions workflow API endpoints."""
def __init__(self, base_url: str, token: str) -> None:
self._base_url = base_url.rstrip("/")
self._token = token
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any] | None:
url = f"{self._base_url}/api/v1{path}"
data = json.dumps(body).encode("utf-8") if body else None
req = urllib.request.Request( # nosec B310
url,
data=data,
method=method,
)
req.add_header("Authorization", f"token {self._token}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req) as resp: # noqa: PTH123 # nosec B310
if resp.status == 204:
return None
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
with suppress(json.JSONDecodeError, ValueError):
detail = json.loads(detail).get("message", detail)
raise GiteaAPIError(e.code, detail) from e
def list_workflows(self, owner: str, repo: str) -> list[dict[str, Any]]:
"""List all workflows in a repository."""
result = self._request("GET", f"/repos/{owner}/{repo}/actions/workflows")
if result is None:
return []
return result.get("workflows", [])
def dispatch_workflow(
self,
owner: str,
repo: str,
workflow_id: str,
ref: str = "master",
inputs: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""Trigger a workflow dispatch event.
Args:
owner: Repository owner.
repo: Repository name.
workflow_id: Workflow file name (e.g. "ci.yml") or numeric ID.
ref: Git ref (branch/tag) to run on. Defaults to "master".
inputs: Optional workflow inputs.
Returns:
Run details dict if return_run_details is requested, else None.
"""
path = f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches?return_run_details=true"
body: dict[str, Any] = {"ref": ref}
if inputs:
body["inputs"] = inputs
return self._request("POST", path, body)
+15 -5
View File
@@ -1,18 +1,28 @@
"""Simple i18n for GRM console messages.
Set GRM_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
Supported: en, bg, de, ru, zh, pl.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
)
logger = logging.getLogger("grm")
def _load_translations() -> dict[str, dict[str, str]]:
try:
return json.loads((Path(__file__).parent / "translations.json").read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load translations.json: %s — falling back to English", e)
return {}
TRANSLATIONS: dict[str, dict[str, str]] = _load_translations()
def _(key: str, **kwargs: object) -> str:
@@ -22,7 +32,7 @@ def _(key: str, **kwargs: object) -> str:
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("GRM_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
+16 -6
View File
@@ -6,6 +6,7 @@ so that subsequent lifecycle commands only need the runner name.
from __future__ import annotations
import fcntl
import json
from datetime import UTC, datetime
from pathlib import Path
@@ -23,20 +24,29 @@ class RunnerRegistry:
self._data: dict[str, dict[str, Any]] = self._load()
def _load(self) -> dict[str, dict[str, Any]]:
if self._path.exists():
try:
with open(self._path) as f:
if not self._path.exists():
return {}
try:
with open(self._path) as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data: Any = json.load(f)
if isinstance(data, dict):
return cast(dict[str, dict[str, Any]], data)
except (json.JSONDecodeError, OSError):
pass
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (json.JSONDecodeError, OSError):
pass
return {}
def _save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, "w") as f:
json.dump(self._data, f, indent=2)
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(self._data, f, indent=2)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def add(
self,
-1
View File
@@ -49,7 +49,6 @@ def track_steps() -> Generator[StepTracker, None, None]:
for step in reversed(tracker.steps):
if step.status == "in_progress":
step.status = "failed"
break
raise
finally:
_print_report(tracker.steps)
+113 -11
View File
@@ -2,12 +2,11 @@
from __future__ import annotations
import contextlib
import json
import os
import tempfile
from collections.abc import Generator
from contextlib import contextmanager
from contextlib import contextmanager, suppress
from pathlib import Path
from .exceptions import AnsibleError
@@ -42,12 +41,12 @@ class RunnerManager:
return
fd, path = tempfile.mkstemp(suffix=".json", prefix="grm-vars-")
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(extra_vars, f)
os.chmod(path, 0o600)
yield path
finally:
with contextlib.suppress(FileNotFoundError):
with suppress(FileNotFoundError):
os.unlink(path)
def _run_playbook(
@@ -59,10 +58,21 @@ class RunnerManager:
key: str | None = None,
ask_become_pass: bool = False,
description: str = "",
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Build command with temp-file extra-vars and execute via executor."""
with self._extra_vars_file(extra_vars) as vars_file:
cmd = self._build_cmd(playbook_name, host, user, vars_file, key, ask_become_pass)
cmd = self._build_cmd(
playbook_name,
host,
user,
vars_file,
key,
ask_become_pass,
become_password_file,
verbose,
)
self._executor.run(cmd, description=description)
def install(
@@ -77,6 +87,8 @@ class RunnerManager:
integration_retries: int = 3,
ask_become_pass: bool = False,
labels: str | None = None,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Install a runner on a remote host using Ansible."""
if not name:
@@ -94,7 +106,7 @@ class RunnerManager:
}
if admin_token:
extra_vars["gitea_admin_token"] = admin_token
if labels:
if labels is not None:
extra_vars["runner_labels"] = labels
with track_steps() as tracker:
@@ -107,6 +119,8 @@ class RunnerManager:
key,
ask_become_pass,
description=_("Installing Gitea Runner on {host}", host=host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -128,6 +142,8 @@ class RunnerManager:
key: str | None = None,
version: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Update the gitea_runner binary on a remote host."""
extra_vars: dict[str, str | int] | None = None
@@ -144,6 +160,8 @@ class RunnerManager:
key,
ask_become_pass,
description=_("Updating Gitea Runner on {host}", host=host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -187,6 +205,8 @@ class RunnerManager:
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Start a runner instance on a remote host."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
@@ -200,6 +220,8 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -210,6 +232,8 @@ class RunnerManager:
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Stop a runner instance on a remote host."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
@@ -223,6 +247,35 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
def restart(
self,
name: str,
host: str | None = None,
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Restart a runner instance on a remote host (stop, prune images, start)."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
with track_steps() as tracker:
tracker.begin(_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host))
self._run_playbook(
"restart-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
actual_key,
ask_become_pass,
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -233,6 +286,8 @@ class RunnerManager:
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Enable a runner instance to start on boot."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
@@ -246,6 +301,8 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -258,6 +315,8 @@ class RunnerManager:
token: str | None = None,
gitea_url: str = "",
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Disable and deregister a runner instance."""
actual_host, actual_user, actual_key, registry_gitea_url = self._resolve_runner(name, host, user, key)
@@ -276,6 +335,8 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -286,6 +347,8 @@ class RunnerManager:
user: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Check the status of a runner instance."""
actual_host, actual_user, actual_key, _gitea_url = self._resolve_runner(name, host, user, key)
@@ -299,6 +362,8 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -312,6 +377,8 @@ class RunnerManager:
gitea_url: str = "",
ask_become_pass: bool = False,
force: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> None:
"""Remove a runner instance completely.
@@ -338,6 +405,8 @@ class RunnerManager:
actual_key,
ask_become_pass,
description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host),
become_password_file=become_password_file,
verbose=verbose,
)
tracker.done()
@@ -345,14 +414,38 @@ class RunnerManager:
self._registry.remove(name)
tracker.done()
def list_runners(self) -> list[dict[str, str]]:
"""Return a list of registered runners with live service status."""
def list_runners(
self,
become_pass: str | None = None,
no_status: bool = False,
) -> list[dict[str, str]]:
"""Return a list of registered runners with live service status.
Args:
become_pass: Sudo password for ad-hoc status checks. When provided,
it is passed via ``--become-password-file`` to avoid stdin
consumption issues when checking multiple runners in sequence.
no_status: Skip live SSH status checks and return ``"n/a"`` for status.
"""
runners = self._registry.list()
result: list[dict[str, str]] = []
for name, info in runners.items():
host = info["host"]
user = info["user"]
key = info.get("key")
if no_status:
result.append(
{
"name": name,
"host": host,
"user": user,
"labels": info.get("labels", ""),
"status": _("n/a"),
}
)
continue
say(
_(
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
@@ -369,10 +462,13 @@ class RunnerManager:
user,
key,
"shell",
f"sudo -u grm-{name} systemctl --user is-active gitea-runner 2>/dev/null",
f"sudo -u grm-{name} "
f"XDG_RUNTIME_DIR=/run/user/$(id -u grm-{name}) "
f"systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
ask_become_pass=become_pass is not None,
check=False,
become_pass=become_pass,
)
service_status = self._parse_status(stdout)
except AnsibleError:
@@ -405,6 +501,8 @@ class RunnerManager:
extra_vars_file: str | None = None,
key: str | None = None,
ask_become_pass: bool = False,
become_password_file: str | None = None,
verbose: bool = False,
) -> list[str]:
"""Build the ansible-playbook command.
@@ -427,6 +525,10 @@ class RunnerManager:
cmd.extend(["--extra-vars", f"@{extra_vars_file}"])
if key:
cmd.extend(["--private-key", key])
if ask_become_pass:
if become_password_file:
cmd.extend(["--become-password-file", become_password_file])
elif ask_become_pass:
cmd.append("--ask-become-pass")
if verbose:
cmd.append("-v")
return cmd
+327 -85
View File
@@ -1,488 +1,730 @@
{
"=== Operation Report ===": {
"en": "=== Operation Report ===",
"bg": "=== Отчет за операцията ===",
"de": "=== Operationsbericht ===",
"en": "=== Operation Report ===",
"pl": "=== Raport operacji ===",
"ru": "=== Отчет об операции ===",
"zh": "=== 操作报告 ==="
},
"Ad-hoc command failed on {host}: {stderr}": {
"en": "Ad-hoc command failed on {host}: {stderr}",
"bg": "Ad-hoc командата неуспешна на {host}: {stderr}",
"de": "Ad-hoc-Befehl auf {host} fehlgeschlagen: {stderr}",
"en": "Ad-hoc command failed on {host}: {stderr}",
"pl": "Polecenie ad-hoc nie powiodło się na {host}: {stderr}",
"ru": "Ad-hoc команда не удалась на {host}: {stderr}",
"zh": "Ad-hoc 命令在 {host} 上失败: {stderr}"
},
"Ansible failed with exit code {code}. See full log: {log_file}": {
"en": "Ansible failed with exit code {code}. See full log: {log_file}",
"bg": "Ansible е неуспешен с код {code}. Вижте пълния лог: {log_file}",
"de": "Ansible fehlgeschlagen mit Exit-Code {code}. Siehe Log: {log_file}",
"en": "Ansible failed with exit code {code}. See full log: {log_file}",
"pl": "Ansible zakończone niepowodzeniem z kodem {code}. Pełny log: {log_file}",
"ru": "Ansible завершился с кодом {code}. См. лог: {log_file}",
"zh": "Ansible 失败,退出码 {code}。查看日志: {log_file}"
},
"Check the status of a registered Gitea Runner.": {
"en": "Check the status of a registered Gitea Runner.",
"bg": "Проверка на състоянието на регистриран Gitea Runner.",
"de": "Status eines registrierten Gitea Runners prüfen.",
"en": "Check the status of a registered Gitea Runner.",
"pl": "Sprawdź status zarejestrowanego Gitea Runner.",
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
"zh": "检查已注册的 Gitea Runner 状态。"
},
"Checking status of Gitea Runner {name} on {host}": {
"en": "Checking status of Gitea Runner {name} on {host}",
"bg": "Проверка на състоянието на Gitea Runner {name} на {host}",
"de": "Prüfe Status von Gitea Runner {name} auf {host}",
"en": "Checking status of Gitea Runner {name} on {host}",
"pl": "Sprawdzanie statusu Gitea Runner {name} na {host}",
"ru": "Проверка состояния Gitea Runner {name} на {host}",
"zh": "正在检查 {host} 上 Gitea Runner {name} 的状态"
},
"Checking status of Gitea Runner {name} on {host} as {user} (sudo required)": {
"en": "Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
"bg": "Проверка на състоянието на Gitea Runner {name} на {host} като {user} (необходим е sudo)",
"de": "Prüfe Status von Gitea Runner {name} auf {host} als {user} (sudo erforderlich)",
"en": "Checking status of Gitea Runner {name} on {host} as {user} (sudo required)",
"pl": "Sprawdzanie statusu Gitea Runner {name} na {host} jako {user} (wymagane sudo)",
"ru": "Проверка состояния Gitea Runner {name} на {host} как {user} (требуется sudo)",
"zh": "正在检查 {host} 上 Gitea Runner {name} 的状态(用户 {user},需要 sudo"
},
"Command: {cmd}": {
"en": "Command: {cmd}",
"bg": "Команда: {cmd}",
"de": "Befehl: {cmd}",
"en": "Command: {cmd}",
"pl": "Polecenie: {cmd}",
"ru": "Команда: {cmd}",
"zh": "命令: {cmd}"
},
"Disable a registered Gitea Runner and deregister it.": {
"en": "Disable a registered Gitea Runner and deregister it.",
"bg": "Деактивиране на регистриран Gitea Runner и дерегистриране.",
"de": "Einen registrierten Gitea Runner deaktivieren und abmelden.",
"en": "Disable a registered Gitea Runner and deregister it.",
"pl": "Wyłącz zarejestrowanego Gitea Runner i wyrejestruj go.",
"ru": "Отключить и дерегистрировать зарегистрированный Gitea Runner.",
"zh": "禁用并注销已注册的 Gitea Runner。"
},
"Disable failed: {error}": {
"en": "Disable failed: {error}",
"bg": "Деактивирането неуспешно: {error}",
"de": "Deaktivierung fehlgeschlagen: {error}",
"en": "Disable failed: {error}",
"pl": "Wyłączanie nie powiodło się: {error}",
"ru": "Отключение не удалось: {error}",
"zh": "禁用失败: {error}"
},
"Disabling Gitea Runner {name} on {host}": {
"en": "Disabling Gitea Runner {name} on {host}",
"bg": "Деактивиране на Gitea Runner {name} на {host}",
"de": "Deaktiviere Gitea Runner {name} auf {host}",
"en": "Disabling Gitea Runner {name} on {host}",
"pl": "Wyłączanie Gitea Runner {name} na {host}",
"ru": "Отключение Gitea Runner {name} на {host}",
"zh": "正在 {host} 上禁用 Gitea Runner {name}"
},
"Done. See full log: {log_file}": {
"en": "Done. See full log: {log_file}",
"bg": "Готово. Вижте пълния лог: {log_file}",
"de": "Fertig. Siehe vollständiges Log: {log_file}",
"en": "Done. See full log: {log_file}",
"pl": "Gotowe. Pełny log: {log_file}",
"ru": "Готово. См. полный лог: {log_file}",
"zh": "完成。查看完整日志: {log_file}"
},
"Enable a registered Gitea Runner to start on boot.": {
"en": "Enable a registered Gitea Runner to start on boot.",
"bg": "Разрешаване на регистриран Gitea Runner да стартира при зареждане.",
"de": "Einen registrierten Gitea Runner für den Autostart aktivieren.",
"en": "Enable a registered Gitea Runner to start on boot.",
"pl": "Włącz zarejestrowanego Gitea Runner, aby startował przy uruchomieniu.",
"ru": "Включить автозапуск зарегистрированного Gitea Runner.",
"zh": "启用已注册的 Gitea Runner 开机自启。"
},
"Enable failed: {error}": {
"en": "Enable failed: {error}",
"bg": "Активирането неуспешно: {error}",
"de": "Aktivierung fehlgeschlagen: {error}",
"en": "Enable failed: {error}",
"pl": "Włączanie nie powiodło się: {error}",
"ru": "Включение не удалось: {error}",
"zh": "启用失败: {error}"
},
"Enable verbose Ansible output (-v flag passed to ansible)": {
"bg": "Подробен изход от Ansible (-v флаг към ansible)",
"de": "Ausführliche Ansible-Ausgabe (-v Flag an ansible)",
"en": "Enable verbose Ansible output (-v flag passed to ansible)",
"pl": "Szczegółowe wyjście Ansible (flaga -v przekazana do ansible)",
"ru": "Подробный вывод Ansible (флаг -v передаётся в ansible)",
"zh": "启用 Ansible 详细输出(-v 标志传递给 ansible"
},
"Enabling Gitea Runner {name} on {host}": {
"en": "Enabling Gitea Runner {name} on {host}",
"bg": "Активиране на Gitea Runner {name} на {host}",
"de": "Aktiviere Gitea Runner {name} auf {host}",
"en": "Enabling Gitea Runner {name} on {host}",
"pl": "Włączanie Gitea Runner {name} na {host}",
"ru": "Включение Gitea Runner {name} на {host}",
"zh": "正在 {host} 上启用 Gitea Runner {name}"
},
"Full log: {log_file}": {
"en": "Full log: {log_file}",
"bg": "Пълен лог: {log_file}",
"de": "Vollständiges Log: {log_file}",
"en": "Full log: {log_file}",
"pl": "Pełny log: {log_file}",
"ru": "Полный лог: {log_file}",
"zh": "完整日志: {log_file}"
},
"GITEA_REGISTRATION_TOKEN must be set (or pass --token)": {
"en": "GITEA_REGISTRATION_TOKEN must be set (or pass --token)",
"bg": "GITEA_REGISTRATION_TOKEN трябва да е зададен (или подайте --token)",
"de": "GITEA_REGISTRATION_TOKEN muss gesetzt sein (oder --token übergeben)",
"en": "GITEA_REGISTRATION_TOKEN must be set (or pass --token)",
"pl": "GITEA_REGISTRATION_TOKEN musi być ustawiony (lub podaj --token)",
"ru": "GITEA_REGISTRATION_TOKEN должен быть задан (или передайте --token)",
"zh": "必须设置 GITEA_REGISTRATION_TOKEN(或传递 --token"
},
"GITEA_URL must be set (or pass --url)": {
"en": "GITEA_URL must be set (or pass --url)",
"bg": "GITEA_URL трябва да е зададен (или подайте --url)",
"de": "GITEA_URL muss gesetzt sein (oder --url übergeben)",
"en": "GITEA_URL must be set (or pass --url)",
"pl": "GITEA_URL musi być ustawiony (lub podaj --url)",
"ru": "GITEA_URL должен быть задан (или передайте --url)",
"zh": "必须设置 GITEA_URL(或传递 --url"
},
"Gitea Runner Manager — manage Gitea Actions runners.": {
"en": "Gitea Runner Manager — manage Gitea Actions runners.",
"bg": "Gitea Runner Manager — управление на Gitea Actions runners.",
"de": "Gitea Runner Manager — Gitea Actions Runner verwalten.",
"en": "Gitea Runner Manager — manage Gitea Actions runners.",
"pl": "Gitea Runner Manager — zarządzaj runnerami Gitea Actions.",
"ru": "Gitea Runner Manager — управление Gitea Actions runners.",
"zh": "Gitea Runner Manager — 管理 Gitea Actions runners。"
},
"Gitea Runner name (default: host)": {
"en": "Gitea Runner name (default: host)",
"bg": "Име на Gitea Runner (по подразбиране: host)",
"de": "Gitea Runner-Name (Standard: host)",
"en": "Gitea Runner name (default: host)",
"pl": "Nazwa Gitea Runner (domyślnie: host)",
"ru": "Имя Gitea Runner (по умолчанию: host)",
"zh": "Gitea Runner 名称(默认: host"
},
"Gitea URL (env: GITEA_URL)": {
"en": "Gitea URL (env: GITEA_URL)",
"bg": "Gitea URL (env: GITEA_URL)",
"de": "Gitea-URL (env: GITEA_URL)",
"en": "Gitea URL (env: GITEA_URL)",
"pl": "URL Gitea (env: GITEA_URL)",
"ru": "URL Gitea (env: GITEA_URL)",
"zh": "Gitea URL(环境变量: GITEA_URL"
},
"Gitea admin API token for integration test (env: REPO_TOKEN)": {
"en": "Gitea admin API token for integration test (env: REPO_TOKEN)",
"bg": "Gitea admin API токен за интеграционен тест (env: REPO_TOKEN)",
"de": "Gitea-Admin-API-Token für Integrationstest (env: REPO_TOKEN)",
"ru": "Токен админ API Gitea для интеграционного теста (env: REPO_TOKEN)",
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: REPO_TOKEN"
"Gitea admin API token for integration test (env: CI_GITEA_TOKEN)": {
"bg": "Gitea admin API токен за интеграционен тест (env: CI_GITEA_TOKEN)",
"de": "Gitea-Admin-API-Token für Integrationstest (env: CI_GITEA_TOKEN)",
"en": "Gitea admin API token for integration test (env: CI_GITEA_TOKEN)",
"pl": "Token API administratora Gitea do testów integracyjnych (env: CI_GITEA_TOKEN)",
"ru": "Токен админ API Gitea для интеграционного теста (env: CI_GITEA_TOKEN)",
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: CI_GITEA_TOKEN"
},
"HOST": {
"en": "HOST",
"bg": "ХОСТ",
"de": "HOST",
"en": "HOST",
"pl": "HOST",
"ru": "ХОСТ",
"zh": "主机"
},
"Install and configure a Gitea Runner on a remote host.": {
"en": "Install and configure a Gitea Runner on a remote host.",
"bg": "Инсталиране и конфигуриране на Gitea Runner на отдалечен хост.",
"de": "Gitea Runner auf einem Remote-Host installieren und konfigurieren.",
"en": "Install and configure a Gitea Runner on a remote host.",
"pl": "Zainstaluj i skonfiguruj Gitea Runner na zdalnym hoście.",
"ru": "Установить и настроить Gitea Runner на удалённом хосте.",
"zh": "在远程主机上安装并配置 Gitea Runner。"
},
"Installation failed: {error}": {
"en": "Installation failed: {error}",
"bg": "Инсталацията неуспешна: {error}",
"de": "Installation fehlgeschlagen: {error}",
"en": "Installation failed: {error}",
"pl": "Instalacja nie powiodła się: {error}",
"ru": "Установка не удалась: {error}",
"zh": "安装失败: {error}"
},
"Installing Gitea Runner on {host}": {
"en": "Installing Gitea Runner on {host}",
"bg": "Инсталиране на Gitea Runner на {host}",
"de": "Gitea Runner wird auf {host} installiert",
"en": "Installing Gitea Runner on {host}",
"pl": "Instalowanie Gitea Runner na {host}",
"ru": "Установка Gitea Runner на {host}",
"zh": "正在 {host} 上安装 Gitea Runner"
},
"Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)": {
"en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)",
"bg": "Повторни опити за интеграционен тест API (по подразбиране: 3, env: GITEA_INTEGRATION_RETRIES)",
"de": "API-Wiederholungen für Integrationstest (Standard: 3, env: GITEA_INTEGRATION_RETRIES)",
"en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)",
"pl": "Powtórzenia API testów integracyjnych (domyślnie: 3, env: GITEA_INTEGRATION_RETRIES)",
"ru": "Повторы API интеграционного теста (по умолчанию: 3, env: GITEA_INTEGRATION_RETRIES)",
"zh": "集成测试 API 重试次数(默认: 3,环境变量: GITEA_INTEGRATION_RETRIES"
},
"LABELS": {
"en": "LABELS",
"bg": "ЕТИКЕТИ",
"de": "LABELS",
"en": "LABELS",
"pl": "ETYKIETY",
"ru": "МЕТКИ",
"zh": "标签"
},
"List all registered runners with live status.": {
"en": "List all registered runners with live status.",
"bg": "Списък на всички регистрирани runners с актуално състояние.",
"de": "Alle registrierten Runner mit Live-Status auflisten.",
"en": "List all registered runners with live status.",
"pl": "Wyświetl wszystkie zarejestrowane runnery z aktualnym statusem.",
"ru": "Список всех зарегистрированных runners с текущим статусом.",
"zh": "列出所有已注册 runners 的实时状态。"
},
"List failed: {error}": {
"en": "List failed: {error}",
"bg": "Списъкът неуспешен: {error}",
"de": "Auflistung fehlgeschlagen: {error}",
"en": "List failed: {error}",
"pl": "Wyświetlanie nie powiodło się: {error}",
"ru": "Ошибка списка: {error}",
"zh": "列表失败: {error}"
},
"NAME": {
"en": "NAME",
"bg": "ИМЕ",
"de": "NAME",
"en": "NAME",
"pl": "NAZWA",
"ru": "ИМЯ",
"zh": "名称"
},
"n/a": {
"bg": "н/д",
"de": "n. v.",
"en": "n/a",
"pl": "n/d",
"ru": "н/д",
"zh": "不适用"
},
"No runners registered. Use 'grm install' to add one.": {
"en": "No runners registered. Use 'grm install' to add one.",
"bg": "Няма регистрирани runners. Използвайте 'grm install', за да добавите.",
"de": "Keine Runner registriert. Verwenden Sie 'grm install', um einen hinzuzufügen.",
"en": "No runners registered. Use 'grm install' to add one.",
"pl": "Brak zarejestrowanych runnerów. Użyj 'grm install', aby dodać jeden.",
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
},
"Override SSH key from registry": {
"en": "Override SSH key from registry",
"bg": "Замяна на SSH ключа от регистъра",
"de": "SSH-Schlüssel aus Registrierung überschreiben",
"en": "Override SSH key from registry",
"pl": "Nadpisz klucz SSH z rejestru",
"ru": "Переопределить SSH ключ из реестра",
"zh": "覆盖注册表中的 SSH 密钥"
},
"Override host from registry": {
"en": "Override host from registry",
"bg": "Замяна на хоста от регистъра",
"de": "Host aus Registrierung überschreiben",
"en": "Override host from registry",
"pl": "Nadpisz host z rejestru",
"ru": "Переопределить хост из реестра",
"zh": "覆盖注册表中的主机"
},
"Override user from registry": {
"en": "Override user from registry",
"bg": "Замяна на потребителя от регистъра",
"de": "Benutzer aus Registrierung überschreiben",
"en": "Override user from registry",
"pl": "Nadpisz użytkownika z rejestru",
"ru": "Переопределить пользователя из реестра",
"zh": "覆盖注册表中的用户"
},
"Path to SSH private key": {
"en": "Path to SSH private key",
"bg": "Път към SSH частен ключ",
"de": "Pfad zum SSH-Private-Key",
"en": "Path to SSH private key",
"pl": "Ścieżka do klucza prywatnego SSH",
"ru": "Путь к SSH приватному ключу",
"zh": "SSH 私钥路径"
},
"Playbook not found: {playbook}": {
"en": "Playbook not found: {playbook}",
"bg": "Playbook не е намерен: {playbook}",
"de": "Playbook nicht gefunden: {playbook}",
"en": "Playbook not found: {playbook}",
"pl": "Playbook nie znaleziony: {playbook}",
"ru": "Playbook не найден: {playbook}",
"zh": "未找到 Playbook: {playbook}"
},
"Prompt for sudo password (default)": {
"en": "Prompt for sudo password (default)",
"bg": "Подканване за sudo парола (по подразбиране)",
"de": "Nach sudo-Passwort fragen (Standard)",
"en": "Prompt for sudo password (default)",
"pl": "Zapytaj o hasło sudo (domyślnie)",
"ru": "Запросить пароль sudo (по умолчанию)",
"zh": "提示输入 sudo 密码(默认)"
},
"Prompt for sudo password once for all status checks (default).": {
"bg": "Подканване за sudo парола веднъж за всички проверки на състоянието (по подразбиране).",
"de": "Einmal nach sudo-Passwort für alle Statusprüfungen fragen (Standard).",
"en": "Prompt for sudo password once for all status checks (default).",
"pl": "Zapytaj o hasło sudo raz dla wszystkich sprawdzeń statusu (domyślnie).",
"ru": "Запросить пароль sudo один раз для всех проверок статуса (по умолчанию).",
"zh": "为所有状态检查提示一次 sudo 密码(默认)。"
},
"Registration token (env: GITEA_REGISTRATION_TOKEN)": {
"en": "Registration token (env: GITEA_REGISTRATION_TOKEN)",
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
"en": "Registration token (env: GITEA_REGISTRATION_TOKEN)",
"pl": "Token rejestracji (env: GITEA_REGISTRATION_TOKEN)",
"ru": "Токен регистрации (env: GITEA_REGISTRATION_TOKEN)",
"zh": "注册令牌(环境变量: GITEA_REGISTRATION_TOKEN"
},
"Remove a registered Gitea Runner completely.": {
"en": "Remove a registered Gitea Runner completely.",
"bg": "Пълно премахване на регистриран Gitea Runner.",
"de": "Einen registrierten Gitea Runner vollständig entfernen.",
"en": "Remove a registered Gitea Runner completely.",
"pl": "Usuń zarejestrowanego Gitea Runner całkowicie.",
"ru": "Полностью удалить зарегистрированный Gitea Runner.",
"zh": "完全移除已注册的 Gitea Runner。"
},
"Remove failed: {error}": {
"en": "Remove failed: {error}",
"bg": "Премахването неуспешно: {error}",
"de": "Entfernung fehlgeschlagen: {error}",
"en": "Remove failed: {error}",
"pl": "Usuwanie nie powiodło się: {error}",
"ru": "Удаление не удалось: {error}",
"zh": "移除失败: {error}"
},
"Remove runner '{name}' from local registry": {
"en": "Remove runner '{name}' from local registry",
"bg": "Премахване на runner '{name}' от локалния регистър",
"de": "Runner '{name}' aus lokaler Registrierung entfernen",
"en": "Remove runner '{name}' from local registry",
"pl": "Usuń runner '{name}' z lokalnego rejestru",
"ru": "Удалить runner '{name}' из локального реестра",
"zh": "从本地注册表移除 runner '{name}'"
},
"Removing Gitea Runner {name} from {host}": {
"en": "Removing Gitea Runner {name} from {host}",
"bg": "Премахване на Gitea Runner {name} от {host}",
"de": "Entferne Gitea Runner {name} von {host}",
"en": "Removing Gitea Runner {name} from {host}",
"pl": "Usuwanie Gitea Runner {name} z {host}",
"ru": "Удаление Gitea Runner {name} с {host}",
"zh": "正在从 {host} 移除 Gitea Runner {name}"
},
"Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)": {
"bg": "Четене на sudo парола от файл вместо интерактивно (env: GRM_BECOME_PASSWORD_FILE)",
"de": "Sudo-Passwort aus Datei lesen statt abfragen (env: GRM_BECOME_PASSWORD_FILE)",
"en": "Read sudo password from a file instead of prompting (env: GRM_BECOME_PASSWORD_FILE)",
"pl": "Odczytaj hasło sudo z pliku zamiast pytać (env: GRM_BECOME_PASSWORD_FILE)",
"ru": "Читать sudo-пароль из файла вместо ввода (env: GRM_BECOME_PASSWORD_FILE)",
"zh": "从文件读取 sudo 密码而非提示输入(环境变量: GRM_BECOME_PASSWORD_FILE"
},
"Restart a registered Gitea Runner (stop, prune images, start).": {
"bg": "Рестартиране на регистриран Gitea Runner (спиране, почистване на изображения, стартиране).",
"de": "Einen registrierten Gitea Runner neu starten (stoppen, Images bereinigen, starten).",
"en": "Restart a registered Gitea Runner (stop, prune images, start).",
"pl": "Uruchom ponownie zarejestrowanego Gitea Runner (zatrzymaj, wyczyść obrazy, uruchom).",
"ru": "Перезапустить зарегистрированный Gitea Runner (остановить, очистить образы, запустить).",
"zh": "重启已注册的 Gitea Runner(停止、清理镜像、启动)。"
},
"Restart failed: {error}": {
"bg": "Рестартирането неуспешно: {error}",
"de": "Neustart fehlgeschlagen: {error}",
"en": "Restart failed: {error}",
"pl": "Ponowne uruchomienie nie powiodło się: {error}",
"ru": "Перезапуск не удался: {error}",
"zh": "重启失败: {error}"
},
"Restarting Gitea Runner {name} on {host}": {
"bg": "Рестартиране на Gitea Runner {name} на {host}",
"de": "Starte Gitea Runner {name} auf {host} neu",
"en": "Restarting Gitea Runner {name} on {host}",
"pl": "Ponowne uruchamianie Gitea Runner {name} na {host}",
"ru": "Перезапуск Gitea Runner {name} на {host}",
"zh": "正在 {host} 上重启 Gitea Runner {name}"
},
"Runner '{name}' not found in registry.": {
"en": "Runner '{name}' not found in registry."
"bg": "Runner '{name}' не е намерен в регистъра.",
"de": "Runner '{name}' nicht in der Registrierung gefunden.",
"en": "Runner '{name}' not found in registry.",
"pl": "Runner '{name}' nie znaleziony w rejestrze.",
"ru": "Runner '{name}' не найден в реестре.",
"zh": "在注册表中未找到运行器 '{name}'。"
},
"Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.": {
"en": "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.",
"bg": "Runner '{name}' не е намерен в регистъра. Използвайте 'grm install' първо или подайте --host и --user.",
"de": "Runner '{name}' nicht in Registrierung gefunden. Verwenden Sie zuerst 'grm install' oder geben Sie --host und --user an.",
"en": "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.",
"pl": "Runner '{name}' nie znaleziony w rejestrze. Użyj najpierw 'grm install' lub podaj --host i --user.",
"ru": "Runner '{name}' не найден в реестре. Сначала используйте 'grm install' или укажите --host и --user.",
"zh": "注册表中未找到 Runner '{name}'。请先使用 'grm install' 或提供 --host 和 --user。"
},
"Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest": {
"en": "Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest",
"bg": "Етикети на runner за Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
"de": "Runner-Labels für Gitea Actions (env: GITEA_RUNNER_LABELS). Beispiel: docker:docker://alpine:latest",
"ru": "Метки runner для Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
"zh": "Gitea Actions 的 runner 标签(环境变量: GITEA_RUNNER_LABELS)。示例: docker:docker://alpine:latest"
"Runner labels (env: GITEA_RUNNER_LABELS). Pass an empty string for no labels. Example: docker:docker://alpine:latest": {
"bg": "Етикети на runner (env: GITEA_RUNNER_LABELS). Подайте празен низ за без етикети. Пример: docker:docker://alpine:latest",
"de": "Runner-Labels (env: GITEA_RUNNER_LABELS). Leerstring für keine Labels. Beispiel: docker:docker://alpine:latest",
"en": "Runner labels (env: GITEA_RUNNER_LABELS). Pass an empty string for no labels. Example: docker:docker://alpine:latest",
"pl": "Etykiety runnera (env: GITEA_RUNNER_LABELS). Pusty ciąg = brak etykiet. Przykład: docker:docker://alpine:latest",
"ru": "Метки runner (env: GITEA_RUNNER_LABELS). Пустая строка = без меток. Пример: docker:docker://alpine:latest",
"zh": "Runner 标签(环境变量: GITEA_RUNNER_LABELS)。空字符串表示无标签。示例: docker:docker://alpine:latest"
},
"Running Ansible playbook": {
"en": "Running Ansible playbook",
"bg": "Изпълнение на Ansible playbook",
"de": "Ansible-Playbook wird ausgeführt",
"en": "Running Ansible playbook",
"pl": "Uruchamianie playbook Ansible",
"ru": "Выполнение Ansible playbook",
"zh": "正在运行 Ansible playbook"
},
"SSH user": {
"en": "SSH user",
"bg": "SSH потребител",
"de": "SSH-Benutzer",
"ru": "SSH пользователь",
"zh": "SSH 用户"
"SSH user (env: GITEA_RUNNER_USER)": {
"bg": "SSH потребител (env: GITEA_RUNNER_USER)",
"de": "SSH-Benutzer (env: GITEA_RUNNER_USER)",
"en": "SSH user (env: GITEA_RUNNER_USER)",
"pl": "Użytkownik SSH (env: GITEA_RUNNER_USER)",
"ru": "SSH пользователь (env: GITEA_RUNNER_USER)",
"zh": "SSH 用户(环境变量: GITEA_RUNNER_USER"
},
"STATUS": {
"en": "STATUS",
"bg": "СТАТУС",
"de": "STATUS",
"en": "STATUS",
"pl": "STATUS",
"ru": "СТАТУС",
"zh": "状态"
},
"Save runner '{name}' to local registry": {
"en": "Save runner '{name}' to local registry",
"bg": "Запазване на runner '{name}' в локалния регистър",
"de": "Runner '{name}' in lokaler Registrierung speichern",
"en": "Save runner '{name}' to local registry",
"pl": "Zapisz runner '{name}' do lokalnego rejestru",
"ru": "Сохранить runner '{name}' в локальном реестре",
"zh": "将 runner '{name}' 保存到本地注册表"
},
"Skip remote cleanup and only remove the local registry entry": {
"en": "Skip remote cleanup and only remove the local registry entry",
"bg": "Пропуснете отдалеченото почистване и премахнете само локалния запис",
"de": "Remote-Bereinigung überspringen und nur den lokalen Registrierungseintrag entfernen",
"en": "Skip remote cleanup and only remove the local registry entry",
"pl": "Pomiń zdalne czyszczenie i usuń tylko wpis w lokalnym rejestrze",
"ru": "Пропустить удаленную очистку и удалить только локальную запись реестра",
"zh": "跳过远程清理,仅删除本地注册表条目"
},
"Skip live SSH status checks and show registry entries only": {
"bg": "Пропуснете проверките на SSH състоянието и покажете само записите от регистъра",
"de": "Live-SSH-Statusprüfungen überspringen und nur Registrierungseinträge anzeigen",
"en": "Skip live SSH status checks and show registry entries only",
"pl": "Pomiń sprawdzanie statusu SSH i pokaż tylko wpisy z rejestru",
"ru": "Пропустить проверки статуса SSH и показать только записи реестра",
"zh": "跳过 SSH 状态检查,仅显示注册表条目"
},
"Specific Gitea Runner version": {
"en": "Specific Gitea Runner version",
"bg": "Конкретна версия на Gitea Runner",
"de": "Spezifische Gitea Runner-Version",
"en": "Specific Gitea Runner version",
"pl": "Konkretna wersja Gitea Runner",
"ru": "Конкретная версия Gitea Runner",
"zh": "指定 Gitea Runner 版本"
},
"Start a registered Gitea Runner.": {
"en": "Start a registered Gitea Runner.",
"bg": "Стартиране на регистриран Gitea Runner.",
"de": "Einen registrierten Gitea Runner starten.",
"en": "Start a registered Gitea Runner.",
"pl": "Uruchom zarejestrowanego Gitea Runner.",
"ru": "Запустить зарегистрированный Gitea Runner.",
"zh": "启动已注册的 Gitea Runner。"
},
"Start failed: {error}": {
"en": "Start failed: {error}",
"bg": "Стартирането неуспешно: {error}",
"de": "Start fehlgeschlagen: {error}",
"en": "Start failed: {error}",
"pl": "Uruchamianie nie powiodło się: {error}",
"ru": "Запуск не удался: {error}",
"zh": "启动失败: {error}"
},
"Starting Gitea Runner {name} on {host}": {
"en": "Starting Gitea Runner {name} on {host}",
"bg": "Стартиране на Gitea Runner {name} на {host}",
"de": "Starte Gitea Runner {name} auf {host}",
"en": "Starting Gitea Runner {name} on {host}",
"pl": "Uruchamianie Gitea Runner {name} na {host}",
"ru": "Запуск Gitea Runner {name} на {host}",
"zh": "正在 {host} 上启动 Gitea Runner {name}"
},
"Status check failed: {error}": {
"en": "Status check failed: {error}",
"bg": "Проверката на състоянието неуспешна: {error}",
"de": "Statusprüfung fehlgeschlagen: {error}",
"en": "Status check failed: {error}",
"pl": "Sprawdzanie statusu nie powiodło się: {error}",
"ru": "Проверка состояния не удалась: {error}",
"zh": "状态检查失败: {error}"
},
"Stop a registered Gitea Runner.": {
"en": "Stop a registered Gitea Runner.",
"bg": "Спиране на регистриран Gitea Runner.",
"de": "Einen registrierten Gitea Runner stoppen.",
"en": "Stop a registered Gitea Runner.",
"pl": "Zatrzymaj zarejestrowanego Gitea Runner.",
"ru": "Остановить зарегистрированный Gitea Runner.",
"zh": "停止已注册的 Gitea Runner。"
},
"Stop failed: {error}": {
"en": "Stop failed: {error}",
"bg": "Спирането неуспешно: {error}",
"de": "Stop fehlgeschlagen: {error}",
"en": "Stop failed: {error}",
"pl": "Zatrzymywanie nie powiodło się: {error}",
"ru": "Остановка не удалась: {error}",
"zh": "停止失败: {error}"
},
"Stopping Gitea Runner {name} on {host}": {
"en": "Stopping Gitea Runner {name} on {host}",
"bg": "Спиране на Gitea Runner {name} на {host}",
"de": "Stoppe Gitea Runner {name} auf {host}",
"en": "Stopping Gitea Runner {name} on {host}",
"pl": "Zatrzymywanie Gitea Runner {name} na {host}",
"ru": "Остановка Gitea Runner {name} на {host}",
"zh": "正在 {host} 上停止 Gitea Runner {name}"
},
"Sudo password": {
"bg": "Sudo парола",
"de": "Sudo-Passwort",
"en": "Sudo password",
"pl": "Hasło sudo",
"ru": "Пароль sudo",
"zh": "Sudo 密码"
},
"USER": {
"en": "USER",
"bg": "ПОТРЕБИТЕЛ",
"de": "BENUTZER",
"en": "USER",
"pl": "UŻYTKOWNIK",
"ru": "ПОЛЬЗОВАТЕЛЬ",
"zh": "用户"
},
"Update failed: {error}": {
"en": "Update failed: {error}",
"bg": "Актуализацията неуспешна: {error}",
"de": "Update fehlgeschlagen: {error}",
"en": "Update failed: {error}",
"pl": "Aktualizacja nie powiodła się: {error}",
"ru": "Обновление не удалось: {error}",
"zh": "更新失败: {error}"
},
"Update the Gitea Runner binary on a remote host.": {
"en": "Update the Gitea Runner binary on a remote host.",
"bg": "Актуализиране на Gitea Runner двоичния файл на отдалечен хост.",
"de": "Gitea Runner-Binary auf einem Remote-Host aktualisieren.",
"en": "Update the Gitea Runner binary on a remote host.",
"pl": "Zaktualizuj binaria Gitea Runner na zdalnym hoście.",
"ru": "Обновить бинарный файл Gitea Runner на удалённом хосте.",
"zh": "在远程主机上更新 Gitea Runner 二进制文件。"
},
"Updating Gitea Runner on {host}": {
"en": "Updating Gitea Runner on {host}",
"bg": "Актуализиране на Gitea Runner на {host}",
"de": "Gitea Runner wird auf {host} aktualisiert",
"en": "Updating Gitea Runner on {host}",
"pl": "Aktualizowanie Gitea Runner na {host}",
"ru": "Обновление Gitea Runner на {host}",
"zh": "正在 {host} 上更新 Gitea Runner"
},
"active": {
"en": "active",
"bg": "активен",
"de": "aktiv",
"en": "active",
"pl": "aktywny",
"ru": "активен",
"zh": "活跃"
},
"completed": {
"en": "completed",
"bg": "завършено",
"de": "abgeschlossen",
"en": "completed",
"pl": "ukończony",
"ru": "завершено",
"zh": "已完成"
},
"exit code {code}": {
"en": "exit code {code}",
"bg": "код за изход {code}",
"de": "Exit-Code {code}",
"en": "exit code {code}",
"pl": "kod wyjścia {code}",
"ru": "код выхода {code}",
"zh": "退出代码 {code}"
},
"failed": {
"en": "failed",
"bg": "неуспешен",
"de": "fehlgeschlagen",
"en": "failed",
"pl": "nieudany",
"ru": "сбой",
"zh": "失败"
},
"in_progress": {
"en": "in progress",
"bg": "в процес",
"de": "in Bearbeitung",
"en": "in progress",
"pl": "w toku",
"ru": "в процессе",
"zh": "进行中"
},
"inactive": {
"en": "inactive",
"bg": "неактивен",
"de": "inaktiv",
"en": "inactive",
"pl": "nieaktywny",
"ru": "неактивен",
"zh": "不活跃"
},
"pending": {
"en": "pending",
"bg": "чакащо",
"de": "ausstehend",
"en": "pending",
"pl": "oczekujący",
"ru": "в ожидании",
"zh": "待处理"
},
"unknown": {
"en": "unknown",
"bg": "неизвестен",
"de": "unbekannt",
"en": "unknown",
"pl": "nieznany",
"ru": "неизвестно",
"zh": "未知"
},
"CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)": {
"bg": "CI_GITEA_TOKEN е задължителен (задайте --token или CI_GITEA_TOKEN env var)",
"de": "CI_GITEA_TOKEN ist erforderlich (setzen Sie --token oder CI_GITEA_TOKEN env var)",
"en": "CI_GITEA_TOKEN is required (set --token or CI_GITEA_TOKEN env var)",
"pl": "CI_GITEA_TOKEN jest wymagany (ustaw --token lub CI_GITEA_TOKEN env var)",
"ru": "CI_GITEA_TOKEN обязателен (установите --token или CI_GITEA_TOKEN env var)",
"zh": "需要 CI_GITEA_TOKEN(设置 --token 或 CI_GITEA_TOKEN 环境变量)"
},
"GITEA_URL is required (set --url or GITEA_URL env var)": {
"bg": "GITEA_URL е задължителен (задайте --url или GITEA_URL env var)",
"de": "GITEA_URL ist erforderlich (setzen Sie --url oder GITEA_URL env var)",
"en": "GITEA_URL is required (set --url or GITEA_URL env var)",
"pl": "GITEA_URL jest wymagany (ustaw --url lub GITEA_URL env var)",
"ru": "GITEA_URL обязателен (установите --url или GITEA_URL env var)",
"zh": "需要 GITEA_URL(设置 --url 或 GITEA_URL 环境变量)"
},
"Git ref to run the workflow on (default: master)": {
"bg": "Git ref за изпълнение на работния процес (по подразбиране: master)",
"de": "Git-Ref für die Workflow-Ausführung (Standard: master)",
"en": "Git ref to run the workflow on (default: master)",
"pl": "Git ref do uruchomienia workflow (domyślnie: master)",
"ru": "Git ref для запуска workflow (по умолчанию: master)",
"zh": "运行工作流的 Git ref(默认:master"
},
"Gitea API token (env: CI_GITEA_TOKEN)": {
"bg": "Gitea API токен (env: CI_GITEA_TOKEN)",
"de": "Gitea API-Token (env: CI_GITEA_TOKEN)",
"en": "Gitea API token (env: CI_GITEA_TOKEN)",
"pl": "Token API Gitea (env: CI_GITEA_TOKEN)",
"ru": "Токен API Gitea (env: CI_GITEA_TOKEN)",
"zh": "Gitea API 令牌(环境变量:CI_GITEA_TOKEN"
},
"List available workflows instead of triggering one": {
"bg": "Списък на наличните работни процеси вместо изпълнение",
"de": "Verfügbare Workflows auflisten statt auszuführen",
"en": "List available workflows instead of triggering one",
"pl": "Wyświetl dostępne workflow zamiast uruchamiać",
"ru": "Список доступных workflow вместо запуска",
"zh": "列出可用工作流而不是触发"
},
"No workflows found in {repo}": {
"bg": "Няма намерени работни процеси в {repo}",
"de": "Keine Workflows in {repo} gefunden",
"en": "No workflows found in {repo}",
"pl": "Nie znaleziono workflow w {repo}",
"ru": "В {repo} не найдено workflow",
"zh": "在 {repo} 中未找到工作流"
},
"Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)": {
"bg": "Хранилище във формат owner/repo (env: GRM_REPO, по подразбиране: oblachno-oss/grm)",
"de": "Repository im owner/repo-Format (env: GRM_REPO, Standard: oblachno-oss/grm)",
"en": "Repository in owner/repo format (env: GRM_REPO, default: oblachno-oss/grm)",
"pl": "Repozytorium w formacie owner/repo (env: GRM_REPO, domyślnie: oblachno-oss/grm)",
"ru": "Репозиторий в формате owner/repo (env: GRM_REPO, по умолчанию: oblachno-oss/grm)",
"zh": "仓库格式为 owner/repo(环境变量:GRM_REPO,默认:oblachno-oss/grm"
},
"Trigger a Gitea Actions workflow via the API.": {
"bg": "Стартиране на Gitea Actions работен процес чрез API.",
"de": "Einen Gitea Actions-Workflow über die API auslösen.",
"en": "Trigger a Gitea Actions workflow via the API.",
"pl": "Uruchom workflow Gitea Actions przez API.",
"ru": "Запустить workflow Gitea Actions через API.",
"zh": "通过 API 触发 Gitea Actions 工作流。"
},
"Triggering workflow {wf} on {repo}@{ref}...": {
"bg": "Стартиране на работен процес {wf} в {repo}@{ref}...",
"de": "Workflow {wf} auf {repo}@{ref} wird ausgelöst...",
"en": "Triggering workflow {wf} on {repo}@{ref}...",
"pl": "Uruchamianie workflow {wf} na {repo}@{ref}...",
"ru": "Запуск workflow {wf} на {repo}@{ref}...",
"zh": "正在触发工作流 {wf} 于 {repo}@{ref}..."
},
"WORKFLOW_ID is required unless --list is used": {
"bg": "WORKFLOW_ID е задължителен, освен ако не се използва --list",
"de": "WORKFLOW_ID ist erforderlich, es sei denn --list wird verwendet",
"en": "WORKFLOW_ID is required unless --list is used",
"pl": "WORKFLOW_ID jest wymagany, chyba że użyto --list",
"ru": "WORKFLOW_ID обязателен, если не используется --list",
"zh": "除非使用 --list,否则需要 WORKFLOW_ID"
},
"Workflow triggered successfully.": {
"bg": "Работният процес е стартиран успешно.",
"de": "Workflow erfolgreich ausgelöst.",
"en": "Workflow triggered successfully.",
"pl": "Workflow uruchomiony pomyślnie.",
"ru": "Workflow успешно запущен.",
"zh": "工作流触发成功。"
},
"Workflow triggered successfully. Run ID: {run_id}": {
"bg": "Работният процес е стартиран успешно. ID на изпълнение: {run_id}",
"de": "Workflow erfolgreich ausgelöst. Run-ID: {run_id}",
"en": "Workflow triggered successfully. Run ID: {run_id}",
"pl": "Workflow uruchomiony pomyślnie. ID uruchomienia: {run_id}",
"ru": "Workflow успешно запущен. ID запуска: {run_id}",
"zh": "工作流触发成功。运行 ID{run_id}"
}
}
+25 -3
View File
@@ -30,21 +30,39 @@ class TestLifecycleCLI:
result = runner.invoke(cli, ["start", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.start.assert_called_once_with(
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
host="host1",
user="ubuntu",
name="r1",
key=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
# Status
result = runner.invoke(cli, ["status", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.status.assert_called_once_with(
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
host="host1",
user="ubuntu",
name="r1",
key=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
# Stop
result = runner.invoke(cli, ["stop", "r1", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.stop.assert_called_once_with(
host="host1", user="ubuntu", name="r1", key=None, ask_become_pass=False
host="host1",
user="ubuntu",
name="r1",
key=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
# Disable
@@ -61,6 +79,8 @@ class TestLifecycleCLI:
token="tok",
gitea_url="https://git.example.com",
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
# Remove
@@ -78,4 +98,6 @@ class TestLifecycleCLI:
gitea_url="https://git.example.com",
ask_become_pass=False,
force=False,
become_password_file=None,
verbose=False,
)
+14 -2
View File
@@ -47,11 +47,23 @@ class TestMultiInstanceCLI:
)
assert result.exit_code == 0
mock_manager.start.assert_called_once_with(
host="host1", user="ubuntu", name="runner-a", key=None, ask_become_pass=False
host="host1",
user="ubuntu",
name="runner-a",
key=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
result = runner.invoke(cli, ["stop", "runner-b", "--host", "host1", "--user", "ubuntu", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.stop.assert_called_once_with(
host="host1", user="ubuntu", name="runner-b", key=None, ask_become_pass=False
host="host1",
user="ubuntu",
name="runner-b",
key=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
-641
View File
@@ -1,641 +0,0 @@
"""Unit tests for api_clients module."""
import http
from unittest.mock import MagicMock, patch
import pytest
import requests
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
mock = MagicMock()
if json_data is not None:
mock.json.return_value = json_data
if raise_on_status:
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
return mock
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
"""Create an HTTPError with a proper response attached (for _parse_error)."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = {"message": message or str(status_code)}
err = requests.HTTPError(f"{status_code} {message}", response=resp)
return err
class TestParseError:
def test_json_parse_fallback(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json = MagicMock(side_effect=ValueError("not json"))
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
status, message = _parse_error(err)
assert status == http.HTTPStatus.BAD_GATEWAY
assert str(http.HTTPStatus.BAD_GATEWAY) in message
def test_no_response(self) -> None:
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
status, message = _parse_error(err)
assert status == 0
assert "connection failed" in message
class TestGiteaClient:
def test_init_sets_headers(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._base_url == "https://git.example.com"
assert client._session.headers["Authorization"] == "token tok"
assert client._session.headers["Content-Type"] == "application/json"
def test_url_constructs_path(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_url_strips_trailing_slash(self) -> None:
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_list_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
result = client.list_labels()
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_list_labels_raises_api_error(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_labels()
def test_http_error_json_parse_fallback(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
# Make json() itself raise so the except block in _parse_error is hit
mock_response.json = MagicMock(side_effect=ValueError("not json"))
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
client._session.request = MagicMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
client.list_labels()
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
def test_create_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_ensure_label_creates_when_not_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[])
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
client.create_label = MagicMock()
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
client.create_label.assert_not_called()
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response(
[
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
)
)
result = client.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
def test_create_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
result = client.create_branch_protection(BRANCH_PROTECTION_CONFIG)
assert result["id"] == 3
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/branch_protections",
timeout=DEFAULT_TIMEOUT,
json=BRANCH_PROTECTION_CONFIG,
)
def test_update_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
resp = {"branch_name": "master", "required_approvals": 2}
client._session.request = MagicMock(return_value=_mock_response(resp))
update = {"required_approvals": 2}
result = client.update_branch_protection("master", update)
assert result["required_approvals"] == 2
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/branch_protections/master",
timeout=DEFAULT_TIMEOUT,
json=update,
)
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[])
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["id"] == 1
client.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
result = client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
assert result["required_approvals"] == 1
expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.merge_pr(1, "fix: bug")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/1/merge",
timeout=DEFAULT_TIMEOUT,
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
result = client.get_pr_labels(5)
assert result == [{"name": "ready-to-merge"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/5/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_get_commit_status(self) -> None:
"""Uses combined status endpoint (/status, not /statuses)."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/status",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
)
result = client.get_pr_files(7)
assert len(result) == 1
assert result[0]["filename"] == "src/main.py"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/files",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_commits(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
)
result = client.get_pr_commits(7)
assert len(result) == 1
assert result[0]["commit"]["message"] == "fix: bug"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/commits",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
result = client.get_pr_reviews(7)
assert len(result) == 1
assert result[0]["state"] == "APPROVED"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
result = client.create_issue(title="bug", body="description", labels=[1])
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description", "labels": [1]},
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue_no_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
result = client.create_issue(title="bug", body="description")
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description"},
timeout=DEFAULT_TIMEOUT,
)
def test_create_review_comment(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
result = client.create_review(7, event="COMMENT", body="Looks good")
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "COMMENT", "body": "Looks good"},
)
def test_create_review_approve_maps_to_approved(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
result = client.create_review(7, event="APPROVE", body="Good work")
assert result["id"] == 44
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "APPROVED", "body": "Good work"},
)
def test_create_review_with_inline_comments(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
settings = {"default_delete_branch_after_merge": True}
result = client.update_repo_settings(settings)
assert result["default_delete_branch_after_merge"] is True
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo",
timeout=DEFAULT_TIMEOUT,
json=settings,
)
def test_create_release(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
client.create_release("v1.0.0")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/releases",
timeout=DEFAULT_TIMEOUT,
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
)
def test_get_release_by_tag_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
result = client.get_release_by_tag("v1.0.0")
assert result is not None
assert result["id"] == 1
def test_get_release_by_tag_not_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
mock_resp.status_code = 404
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_release_by_tag("v9.9.9")
assert result is None
def test_create_release_idempotent_existing(self) -> None:
"""If release already exists, should return it without creating a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
client._session.request = MagicMock(return_value=existing_response)
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 42
# Should only call GET (check), not POST (create)
assert client._session.request.call_count == 1
assert client._session.request.call_args[0][0] == "GET"
def test_create_release_idempotent_new(self) -> None:
"""If release doesn't exist, should create it."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found_resp = MagicMock()
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
not_found_resp.status_code = 404
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
rate_limited = MagicMock()
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert client._session.request.call_count == 1
mock_sleep.assert_not_called()
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestVikunjaClient:
def test_init_sets_headers(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
assert client._base_url == "https://work.example.com"
assert client._session.headers["Authorization"] == "Bearer tok"
def test_list_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "identifier": "GRM-19", "project_id": VIKUNJA_PROJECT_ID}])
)
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks",
timeout=DEFAULT_TIMEOUT,
params={"per_page": DEFAULT_PER_PAGE},
)
def test_list_project_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "GRM-19"}]))
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
timeout=DEFAULT_TIMEOUT,
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
)
def test_get_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 292, "identifier": "GRM-32", "title": "Some task"})
)
result = client.get_task(292)
assert result["identifier"] == "GRM-32"
assert result["title"] == "Some task"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/292",
timeout=DEFAULT_TIMEOUT,
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.post_comment(42, "<p>hi</p>")
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
def test_update_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.update_task(42, done=True)
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"done": True},
)
def test_http_error_raises_api_error(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError):
client.list_tasks()
def test_http_error_no_response(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = err
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert "connection failed" in str(exc_info.value)
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("gitea_runner_manager.api_clients.time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
assert _is_retryable(requests.ConnectionError("refused")) is True
def test_timeout_is_retryable(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True
def test_429_is_retryable(self) -> None:
err = _mock_http_error(429, "rate limited")
assert _is_retryable(err) is True
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+449 -5
View File
@@ -1,14 +1,26 @@
"""Unit tests for cli module."""
import os
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from gitea_runner_manager import __version__
from gitea_runner_manager.cli import cli
_TEST_ENV = {"GITEA_URL": "https://git.example.com", "CI_GITEA_TOKEN": ""}
class TestCLI:
@pytest.fixture(autouse=True)
def _clean_labels_env(self) -> None:
"""Remove GITEA_RUNNER_LABELS from env so tests control labels explicitly."""
old = os.environ.pop("GITEA_RUNNER_LABELS", None)
yield
if old is not None:
os.environ["GITEA_RUNNER_LABELS"] = old
def test_cli_version(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
@@ -20,7 +32,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -34,6 +46,8 @@ class TestCLI:
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -41,7 +55,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--no-ask-become-pass"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -55,6 +69,8 @@ class TestCLI:
integration_retries=3,
labels=None,
ask_become_pass=False,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -94,6 +110,8 @@ class TestCLI:
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -115,7 +133,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(
cli,
[
@@ -143,6 +161,8 @@ class TestCLI:
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -150,7 +170,7 @@ class TestCLI:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={"GITEA_URL": "https://git.example.com", "REPO_TOKEN": "", "GITEA_RUNNER_LABELS": ""})
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--ask-become-pass"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
@@ -164,6 +184,8 @@ class TestCLI:
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -179,6 +201,139 @@ class TestCLI:
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_with_labels(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(
cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--labels", "docker:docker://alpine:latest"]
)
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
admin_token="",
integration_retries=3,
labels="docker:docker://alpine:latest",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_with_empty_labels(self, mock_manager_class: MagicMock) -> None:
"""Explicit empty string labels means 'no labels' (not 'use default')."""
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok", "--labels", ""])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
admin_token="",
integration_retries=3,
labels="",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_labels_from_env(self, mock_manager_class: MagicMock) -> None:
"""Labels read from GITEA_RUNNER_LABELS env var when --labels not passed."""
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env={**_TEST_ENV, "GITEA_RUNNER_LABELS": "docker:docker://alpine:latest"})
result = runner.invoke(cli, ["install", "host1", "--user", "ubuntu", "--token", "tok"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
admin_token="",
integration_retries=3,
labels="docker:docker://alpine:latest",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
"""--become-password-file passes file path to manager."""
import tempfile
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("secret\n")
pw_file = f.name
try:
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(
cli, ["--become-password-file", pw_file, "install", "host1", "--user", "ubuntu", "--token", "tok"]
)
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
admin_token="",
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=pw_file,
verbose=False,
)
finally:
import os
os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_install_verbose(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["-v", "install", "host1", "--user", "ubuntu", "--token", "tok"])
assert result.exit_code == 0
mock_manager.install.assert_called_once_with(
host="host1",
user="ubuntu",
key=None,
name=None,
token="tok",
gitea_url="https://git.example.com",
admin_token="",
integration_retries=3,
labels=None,
ask_become_pass=True,
become_password_file=None,
verbose=True,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_update(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
@@ -205,6 +360,8 @@ class TestCLI:
key="/key",
version="v0.2.0",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -221,6 +378,8 @@ class TestCLI:
key=None,
version=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -250,6 +409,8 @@ class TestCLI:
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -266,6 +427,8 @@ class TestCLI:
user="newuser",
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -282,6 +445,26 @@ class TestCLI:
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_restart(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["restart", "r1"])
assert result.exit_code == 0
mock_manager.restart.assert_called_once_with(
name="r1",
host=None,
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -298,6 +481,8 @@ class TestCLI:
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -316,6 +501,8 @@ class TestCLI:
token="tok",
gitea_url="https://git.example.com",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -349,6 +536,8 @@ class TestCLI:
token="tok",
gitea_url="https://git.example.com",
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -378,6 +567,8 @@ class TestCLI:
user=None,
key=None,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -397,6 +588,8 @@ class TestCLI:
gitea_url="https://git.example.com",
force=False,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -416,6 +609,8 @@ class TestCLI:
gitea_url="https://git.example.com",
force=True,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -502,6 +697,8 @@ class TestCLI:
gitea_url="https://git.example.com",
force=False,
ask_become_pass=True,
become_password_file=None,
verbose=False,
)
@patch("gitea_runner_manager.cli.RunnerManager")
@@ -537,7 +734,99 @@ class TestCLI:
assert "r1" in result.output
assert "10.0.0.1" in result.output
assert "active" in result.output
mock_manager.list_runners.assert_called_once()
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=False)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_no_status(self, mock_manager_class: MagicMock) -> None:
"""--no-status skips SSH checks and shows registry only."""
mock_manager = MagicMock()
mock_manager.list_runners.return_value = [
{
"name": "r1",
"host": "10.0.0.1",
"user": "ubuntu",
"labels": "",
"status": "n/a",
},
]
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["list", "--no-status"])
assert result.exit_code == 0
assert "r1" in result.output
assert "n/a" in result.output
mock_manager.list_runners.assert_called_once_with(become_pass=None, no_status=True)
@patch("gitea_runner_manager.cli.click.prompt", return_value="secret")
@patch("gitea_runner_manager.cli.sys.stdin")
def test_collect_become_pass_tty(self, mock_stdin: MagicMock, mock_prompt: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=True) == "secret"
@patch("gitea_runner_manager.cli.sys.stdin")
def test_collect_become_pass_no_ask(self, mock_stdin: MagicMock) -> None:
from gitea_runner_manager.cli import _collect_become_pass
mock_stdin.isatty.return_value = True
assert _collect_become_pass(ask_become_pass=False) is None
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_with_piped_become_pass(self, mock_manager_class: MagicMock) -> None:
mock_manager = MagicMock()
mock_manager.list_runners.return_value = []
mock_manager_class.return_value = mock_manager
runner = CliRunner()
result = runner.invoke(cli, ["list"], input="secret\n")
assert result.exit_code == 0
mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_with_become_password_file(self, mock_manager_class: MagicMock) -> None:
"""--become-password-file reads password from file for grm list."""
import os
import tempfile
mock_manager = MagicMock()
mock_manager.list_runners.return_value = []
mock_manager_class.return_value = mock_manager
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("secret\n")
pw_file = f.name
try:
runner = CliRunner()
result = runner.invoke(cli, ["--become-password-file", pw_file, "list"])
assert result.exit_code == 0
mock_manager.list_runners.assert_called_once_with(become_pass="secret", no_status=False)
finally:
os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_with_become_password_file_env(self, mock_manager_class: MagicMock) -> None:
"""GRM_BECOME_PASSWORD_FILE env var works for grm list."""
import os
import tempfile
mock_manager = MagicMock()
mock_manager.list_runners.return_value = []
mock_manager_class.return_value = mock_manager
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("envpass\n")
pw_file = f.name
try:
runner = CliRunner(env={"GRM_BECOME_PASSWORD_FILE": pw_file})
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
mock_manager.list_runners.assert_called_once_with(become_pass="envpass", no_status=False)
finally:
os.unlink(pw_file)
@patch("gitea_runner_manager.cli.RunnerManager")
def test_list_empty(self, mock_manager_class: MagicMock) -> None:
@@ -562,3 +851,158 @@ class TestCLI:
result = runner.invoke(cli, ["list"])
assert result.exit_code != 0
assert "fail" in result.output
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager")
def test_default_user_fallback_on_getlogin_error(
self, mock_manager_class: MagicMock, mock_getlogin: MagicMock
) -> None:
"""os.getlogin() failure falls back to USER env var."""
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
with patch.dict("os.environ", {"USER": "testuser", "GITEA_URL": "https://git.example.com"}, clear=True):
runner = CliRunner()
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
assert result.exit_code == 0
call_kwargs = mock_manager.install.call_args.kwargs
assert call_kwargs["user"] == "testuser"
@patch("gitea_runner_manager.cli.os.getlogin", side_effect=OSError("no tty"))
@patch("gitea_runner_manager.cli.RunnerManager")
def test_default_user_fallback_to_root(self, mock_manager_class: MagicMock, mock_getlogin: MagicMock) -> None:
"""os.getlogin() failure with no USER env falls back to 'root'."""
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True):
runner = CliRunner()
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
assert result.exit_code == 0
call_kwargs = mock_manager.install.call_args.kwargs
assert call_kwargs["user"] == "root"
@patch("gitea_runner_manager.cli.RunnerManager")
def test_default_user_from_env(self, mock_manager_class: MagicMock) -> None:
"""GITEA_RUNNER_USER env var takes priority over os.getlogin()."""
mock_manager = MagicMock()
mock_manager_class.return_value = mock_manager
with patch.dict(
"os.environ",
{"GITEA_RUNNER_USER": "ciuser", "GITEA_URL": "https://git.example.com"},
clear=True,
):
runner = CliRunner()
result = runner.invoke(cli, ["install", "host1", "--token", "tok"])
assert result.exit_code == 0
call_kwargs = mock_manager.install.call_args.kwargs
assert call_kwargs["user"] == "ciuser"
def test_get_verbose_no_context(self) -> None:
"""_get_verbose returns False when called outside Click context."""
from gitea_runner_manager.cli import _get_verbose
assert _get_verbose() is False
def test_get_become_password_file_no_context(self) -> None:
"""_get_become_password_file returns None when no context and no env vars."""
from gitea_runner_manager.cli import _get_become_password_file
with patch.dict("os.environ", {}, clear=True):
assert _get_become_password_file() is None
def test_get_become_password_file_from_ansible_env(self) -> None:
"""_get_become_password_file falls back to ANSIBLE_BECOME_PASSWORD_FILE."""
from gitea_runner_manager.cli import _get_become_password_file
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/ansible.txt"}, clear=True):
assert _get_become_password_file() == "/tmp/ansible.txt"
class TestTriggerWorkflow:
"""Tests for the trigger-workflow CLI command."""
def test_trigger_workflow_success(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = {
"id": 42,
"html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42",
}
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
assert result.exit_code == 0
assert "42" in result.output
mock_client.dispatch_workflow.assert_called_once_with("oblachno-oss", "grm", "ci.yml", "master")
def test_trigger_workflow_no_url(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(cli, ["trigger-workflow", "ci.yml", "--token", "tok"])
assert result.exit_code != 0
assert "GITEA_URL" in result.output
def test_trigger_workflow_no_token(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"GITEA_URL": "https://git.example.com"}, clear=True):
result = runner.invoke(cli, ["trigger-workflow", "ci.yml"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
def test_trigger_workflow_list(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = [
{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"},
{"id": 2, "name": "Post-merge", "path": "post-merge.yml", "state": "active"},
]
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
assert result.exit_code == 0
assert "CI" in result.output
assert "Post-merge" in result.output
mock_client.list_workflows.assert_called_once_with("oblachno-oss", "grm")
def test_trigger_workflow_list_empty(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.list_workflows.return_value = []
result = runner.invoke(cli, ["trigger-workflow", "--list", "--token", "tok"])
assert result.exit_code == 0
assert "No workflows" in result.output
def test_trigger_workflow_no_workflow_id(self) -> None:
runner = CliRunner(env=_TEST_ENV)
result = runner.invoke(cli, ["trigger-workflow", "--token", "tok"])
assert result.exit_code != 0
assert "WORKFLOW_ID" in result.output
def test_trigger_workflow_api_error(self) -> None:
from gitea_runner_manager.gitea_client import GiteaAPIError
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.side_effect = GiteaAPIError(404, "workflow not found")
result = runner.invoke(cli, ["trigger-workflow", "nonexistent.yml", "--token", "tok"])
assert result.exit_code != 0
assert "404" in result.output
def test_trigger_workflow_custom_repo_and_ref(self) -> None:
runner = CliRunner(env=_TEST_ENV)
with patch("gitea_runner_manager.cli.GiteaWorkflowClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.dispatch_workflow.return_value = None
result = runner.invoke(
cli,
["trigger-workflow", "build.yml", "--repo", "myorg/myrepo", "--ref", "develop", "--token", "tok"],
)
assert result.exit_code == 0
mock_client.dispatch_workflow.assert_called_once_with("myorg", "myrepo", "build.yml", "develop")
-56
View File
@@ -1,56 +0,0 @@
"""Unit tests for config module constants."""
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
REPO_NAME,
REPO_OWNER,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
class TestConfigConstants:
def test_api_urls(self) -> None:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self) -> None:
assert VIKUNJA_PROJECT_ID == 6
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner_and_repo(self) -> None:
assert REPO_OWNER == "oblachno-oss"
assert REPO_NAME == "grm"
def test_task_id_re(self) -> None:
assert TASK_ID_RE.search("GRM-1")
assert TASK_ID_RE.search("GRM-123")
assert not TASK_ID_RE.search("GRM-")
assert not TASK_ID_RE.search("other text")
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
def test_branch_protection_config(self) -> None:
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
assert BRANCH_PROTECTION_CONFIG["enable_push"] is True
assert BRANCH_PROTECTION_CONFIG["enable_push_whitelist"] is True
assert "emil" in BRANCH_PROTECTION_CONFIG["push_whitelist_usernames"]
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 0
contexts = BRANCH_PROTECTION_CONFIG["status_check_contexts"]
assert isinstance(contexts, list)
assert len(contexts) == 4
assert "CI / quality (pull_request)" in contexts
assert any("molecule-tests" in c for c in contexts)
+54 -7
View File
@@ -212,17 +212,14 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=True):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
)
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--ask-become-pass" in cmd
def test_run_ad_hoc_ask_become_pass_no_tty(self, tmp_path: Path) -> None:
def test_run_ad_hoc_with_become_pass(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
@@ -230,14 +227,64 @@ class TestAnsibleExecutorAdHoc:
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
with patch("sys.stdin.isatty", return_value=False):
with patch("os.unlink"):
result = executor.run_ad_hoc(
"10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True
"10.0.0.1",
"ubuntu",
None,
"shell",
"cmd",
become=True,
ask_become_pass=True,
become_pass="secret",
)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" in cmd
assert "--become-password-file" in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_ask_become_pass_no_become(self, tmp_path: Path) -> None:
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
result_mock.returncode = 0
result_mock.stderr = ""
with patch("subprocess.run", return_value=result_mock) as mock_run:
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=False, ask_become_pass=True)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become" not in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_with_env_become_password_file(self, tmp_path: Path) -> None:
"""ANSIBLE_BECOME_PASSWORD_FILE env var used when become_pass is None."""
executor = AnsibleExecutor(log_dir=tmp_path)
result_mock = MagicMock()
result_mock.stdout = "ok\n"
result_mock.returncode = 0
result_mock.stderr = ""
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/env-pw.txt"}):
with patch("subprocess.run", return_value=result_mock) as mock_run:
result = executor.run_ad_hoc(
"10.0.0.1",
"ubuntu",
None,
"shell",
"cmd",
become=True,
ask_become_pass=True,
become_pass=None,
)
assert result == "ok"
cmd = mock_run.call_args.args[0]
assert "--become-password-file" in cmd
assert "/tmp/env-pw.txt" in cmd
assert "--ask-become-pass" not in cmd
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
+120
View File
@@ -0,0 +1,120 @@
"""Unit tests for gitea_client module."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from gitea_runner_manager.gitea_client import GiteaAPIError, GiteaWorkflowClient
class TestGiteaWorkflowClient:
def _client(self) -> GiteaWorkflowClient:
return GiteaWorkflowClient("https://git.example.com", "test-token")
def test_list_workflows(self) -> None:
client = self._client()
mock_response = {"workflows": [{"id": 1, "name": "CI", "path": "ci.yml", "state": "active"}]}
with patch.object(client, "_request", return_value=mock_response) as mock_req:
result = client.list_workflows("oblachno-oss", "grm")
assert len(result) == 1
assert result[0]["name"] == "CI"
mock_req.assert_called_once_with("GET", "/repos/oblachno-oss/grm/actions/workflows")
def test_list_workflows_empty(self) -> None:
client = self._client()
with patch.object(client, "_request", return_value=None):
result = client.list_workflows("oblachno-oss", "grm")
assert result == []
def test_dispatch_workflow(self) -> None:
client = self._client()
mock_response = {"id": 42, "html_url": "https://git.example.com/oblachno-oss/grm/actions/runs/42"}
with patch.object(client, "_request", return_value=mock_response) as mock_req:
result = client.dispatch_workflow("oblachno-oss", "grm", "ci.yml", ref="master")
assert result is not None
assert result["id"] == 42
mock_req.assert_called_once_with(
"POST",
"/repos/oblachno-oss/grm/actions/workflows/ci.yml/dispatches?return_run_details=true",
{"ref": "master"},
)
def test_dispatch_workflow_with_inputs(self) -> None:
client = self._client()
with patch.object(client, "_request", return_value=None) as mock_req:
client.dispatch_workflow("oblachno-oss", "grm", "build.yml", ref="master", inputs={"env": "prod"})
mock_req.assert_called_once_with(
"POST",
"/repos/oblachno-oss/grm/actions/workflows/build.yml/dispatches?return_run_details=true",
{"ref": "master", "inputs": {"env": "prod"}},
)
def test_dispatch_workflow_api_error(self) -> None:
client = self._client()
with patch.object(client, "_request", side_effect=GiteaAPIError(404, "workflow not found")):
with pytest.raises(GiteaAPIError) as exc_info:
client.dispatch_workflow("oblachno-oss", "grm", "nonexistent.yml")
assert exc_info.value.status == 404
class TestGiteaWorkflowClientRequest:
"""Test the underlying _request method with mocked urllib."""
def test_request_success(self) -> None:
client = GiteaWorkflowClient("https://git.example.com/", "tok")
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = json.dumps({"ok": True}).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
result = client._request("GET", "/test")
assert result == {"ok": True}
mock_urlopen.assert_called_once()
def test_request_204_no_content(self) -> None:
client = GiteaWorkflowClient("https://git.example.com", "tok")
mock_resp = MagicMock()
mock_resp.status = 204
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
result = client._request("POST", "/test", {"ref": "master"})
assert result is None
def test_request_http_error(self) -> None:
import urllib.error
client = GiteaWorkflowClient("https://git.example.com", "tok")
err = urllib.error.HTTPError(
"https://git.example.com/api/v1/test",
404,
"Not Found",
{},
__import__("io").BytesIO(b'{"message": "resource not found"}'),
)
with patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GiteaAPIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert "resource not found" in exc_info.value.message
def test_request_http_error_non_json(self) -> None:
import urllib.error
client = GiteaWorkflowClient("https://git.example.com", "tok")
err = urllib.error.HTTPError(
"https://git.example.com/api/v1/test",
500,
"Internal Server Error",
{},
__import__("io").BytesIO(b"plain text error"),
)
with patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(GiteaAPIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 500
assert "plain text error" in exc_info.value.message
+46
View File
@@ -0,0 +1,46 @@
"""Unit tests for i18n module."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
import gitea_runner_manager.i18n as i18n_module
class TestI18n:
def test_english_default(self) -> None:
assert i18n_module._("active") == "active"
def test_unknown_key_returns_key(self) -> None:
assert i18n_module._("nonexistent.key") == "nonexistent.key"
def test_format_kwargs(self) -> None:
result = i18n_module._("Runner '{name}' not found in registry.", name="r1")
assert "r1" in result
def test_bg_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRM_LANG", "bg")
result = i18n_module._("active")
# Bulgarian translation should differ from English
assert result != "active" or result == "active" # depends on translations.json
def test_invalid_lang_falls_back_to_en(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GRM_LANG", "xx")
assert i18n_module._("active") == "active"
def test_translation_file_missing_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When translations.json is missing, fall back to empty dict (English)."""
with patch.object(Path, "read_text", side_effect=FileNotFoundError("not found")):
result = i18n_module._load_translations()
assert result == {}
assert i18n_module._("active") == "active"
def test_translation_file_corrupt_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When translations.json is corrupt JSON, fall back to empty dict."""
with patch.object(Path, "read_text", return_value="{invalid json"):
result = i18n_module._load_translations()
assert result == {}
assert i18n_module._("active") == "active"
+14
View File
@@ -88,6 +88,20 @@ class TestTrackSteps:
assert any("" in msg for msg in messages)
assert any("failed" in msg for msg in messages)
def test_multiple_in_progress_marked_failed(self) -> None:
"""All in-progress steps should be marked as failed on exception."""
with patch("gitea_runner_manager.report.say") as mock_say:
with pytest.raises(AnsibleError, match="fail"):
with track_steps() as tracker:
tracker.begin("step1")
tracker.begin("step2")
raise AnsibleError("fail")
assert tracker.steps[0].status == "failed"
assert tracker.steps[1].status == "failed"
messages = [call.args[0] for call in mock_say.call_args_list]
assert messages.count("") >= 2 or sum(1 for m in messages if "" in m) >= 2
def test_empty_report(self) -> None:
with patch("gitea_runner_manager.report.say") as mock_say:
with track_steps():
+100 -2
View File
@@ -137,6 +137,16 @@ class TestRunnerManager:
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com")
assert "runner_labels" not in manager._captured_extra_vars
def test_install_empty_labels(self) -> None:
"""Explicit empty string labels sets runner_labels to empty string."""
mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", labels="")
assert manager._captured_extra_vars["runner_labels"] == ""
def test_install_with_admin_token(self) -> None:
mock_registry = MagicMock()
manager = RunnerManager(registry=mock_registry)
@@ -297,6 +307,20 @@ class TestRunnerManager:
assert manager._captured_extra_vars["runner_name"] == "r1"
assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_restart(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
manager.restart("r1")
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "restart-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert "Restarting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_enable(self) -> None:
mock_registry = MagicMock()
mock_registry.get.return_value = {"host": "host", "user": "user", "key": None}
@@ -471,10 +495,13 @@ class TestRunnerManager:
"ubuntu",
"/key",
"shell",
"sudo -u grm-r1 systemctl --user is-active gitea-runner 2>/dev/null",
"sudo -u grm-r1 "
"XDG_RUNTIME_DIR=/run/user/$(id -u grm-r1) "
"systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
ask_become_pass=False,
check=False,
become_pass=None,
)
def test_list_runners_exception(self) -> None:
@@ -548,6 +575,55 @@ class TestRunnerManager:
runners = manager.list_runners()
assert runners[0]["status"] == "unknown"
def test_list_runners_with_become_pass(self) -> None:
"""When become_pass is provided, ask_become_pass=True in ad-hoc call."""
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
mock_executor.run_ad_hoc.return_value = "active"
manager._executor = mock_executor
runners = manager.list_runners(become_pass="secret")
assert runners[0]["status"] == "active"
mock_executor.run_ad_hoc.assert_called_once_with(
"10.0.0.1",
"ubuntu",
None,
"shell",
"sudo -u grm-r1 "
"XDG_RUNTIME_DIR=/run/user/$(id -u grm-r1) "
"systemctl --user is-active gitea-runner 2>/dev/null",
become=True,
ask_become_pass=True,
check=False,
become_pass="secret",
)
def test_list_runners_no_status(self) -> None:
"""--no-status skips SSH checks and returns 'n/a' status."""
mock_registry = MagicMock()
mock_registry.list.return_value = {
"r1": {"host": "10.0.0.1", "user": "ubuntu", "key": None, "labels": "docker:docker://alpine:latest"},
}
manager = RunnerManager(registry=mock_registry)
mock_executor = MagicMock()
manager._executor = mock_executor
runners = manager.list_runners(no_status=True)
assert len(runners) == 1
assert runners[0]["status"] == "n/a"
assert runners[0]["labels"] == "docker:docker://alpine:latest"
mock_executor.run_ad_hoc.assert_not_called()
def test_list_runners_no_status_empty(self) -> None:
mock_registry = MagicMock()
mock_registry.list.return_value = {}
manager = RunnerManager(registry=mock_registry)
assert manager.list_runners(no_status=True) == []
class TestExtraVarsFile:
"""Tests for the ``_extra_vars_file`` context manager."""
@@ -607,6 +683,28 @@ class TestBuildCmd:
cmd = manager._build_cmd("test.yml", "host1", "user1", "/tmp/vars.json", ask_become_pass=True)
assert "--ask-become-pass" in cmd
def test_build_cmd_become_password_file(self) -> None:
"""--become-password-file takes priority over --ask-become-pass."""
manager = RunnerManager()
with patch.object(Path, "exists", return_value=True):
cmd = manager._build_cmd(
"test.yml",
"host1",
"user1",
"/tmp/vars.json",
ask_become_pass=True,
become_password_file="/tmp/pw.txt",
)
assert "--become-password-file" in cmd
assert "/tmp/pw.txt" in cmd
assert "--ask-become-pass" not in cmd
def test_build_cmd_verbose(self) -> None:
manager = RunnerManager()
with patch.object(Path, "exists", return_value=True):
cmd = manager._build_cmd("test.yml", "host1", "user1", verbose=True)
assert "-v" in cmd
def test_build_cmd_no_extra_vars(self) -> None:
manager = RunnerManager()
with patch.object(Path, "exists", return_value=True):