Compare commits

..
20 Commits
Author SHA1 Message Date
grm-ci-bot 7e18d285ab release: v0.18.2 [skip ci] 2026-07-16 17:41:52 +00:00
emil f6ba60bda6 GRM-154: fix: load tun module and pre-configure systemd override for Arch rootless Docker
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-16 17:39:31 +00:00
gitea-actions-bot 0545388b34 chore: update badge URLs to commit bf77093c [skip ci] 2026-07-16 15:59:16 +00:00
grm-ci-bot eb0a56350b release: v0.18.1 [skip ci] 2026-07-16 15:58:51 +00:00
emil f712a4493e GRM-152: fix: fetch rootless Docker scripts on Arch Linux
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-16 15:56:59 +00:00
gitea-actions-bot 9289b19162 chore: update badge URLs to commit 1249a783 [skip ci] 2026-07-16 15:45:18 +00:00
emil 185251090f GRM-153: chore: bump devx from v0.45.1 to v0.47.1
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-16 15:43:09 +00:00
gitea-actions-bot fff930920f chore: update badge URLs to commit 66400175 [skip ci] 2026-07-14 13:41:08 +00:00
emil 88eca1f6b8 GRM-151: chore: bump devx from 0.44.1 to 0.45.1
Post-merge / release-and-maintain (push) Waiting to run
Post-merge / detect-and-configure (push) Waiting to run
2026-07-14 13:39:11 +00:00
gitea-actions-bot 1718a415c8 chore: update badge URLs to commit eda0ab83 [skip ci] 2026-07-14 02:09:11 +00:00
emil 3f27ee1423 GRM-150: chore: bump devx from 0.44.1 to 0.45.0
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-14 02:07:13 +00:00
gitea-actions-bot 2cf267bace chore: update badge URLs to commit 88b0ef4c [skip ci] 2026-07-14 01:17:35 +00:00
emil 87513f9e8f GRM-149: chore: bump devx to 0.44.1
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-14 01:15:37 +00:00
gitea-actions-bot 8b1a959bc9 chore: update badge URLs to commit f35c689f [skip ci] 2026-07-13 03:26:32 +00:00
emil f6a4f1fe43 GRM-148: chore: bump devx to v0.41.1, update deps and runner version
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-13 03:24:35 +00:00
gitea-actions-bot 9ea7ae656b chore: update badge URLs to commit 64f6f08d [skip ci] 2026-07-12 20:04:24 +00:00
emil 70240a13cf GRM-147: docs: add retrospective for CI consolidation and devx adoption
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-12 20:02:03 +00:00
gitea-actions-bot fd7b786db4 chore: update badge URLs to commit cfe82892 [skip ci] 2026-07-12 01:55:49 +00:00
emil c4fe70979c GRM-146: ci: consolidate CI and post-merge workflows
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-12 01:53:47 +00:00
gitea-actions-bot dd9fc601fb chore: update badge URLs to commit 793fed7a [skip ci] 2026-07-12 01:15:10 +00:00
92 changed files with 955 additions and 929 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ If `.venv` doesn't exist, run `make setup` first.
**Always run `make pytest-cov` before pushing** — CI enforces 100%
coverage and will fail the PR if any lines are uncovered. This is the
most common cause of CI quality job failures after code changes. The
most common cause of CI validate job failures after code changes. The
pre-push git hook only validates Vikunja task existence, not tests.
### API Response Type Checking
+77 -139
View File
@@ -6,21 +6,38 @@ on:
workflow_dispatch:
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
quality:
# Single validation job that merges: quality, detect-changes,
# release-dry-run, pre-merge-check, pr-review, and discover-runners.
# Uses ci-full image (has git-cliff for release-dry-run).
# Saves ~5x checkout+setup overhead vs 6 separate jobs.
validate:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
defaults:
run:
shell: bash
outputs:
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
runner-count: ${{ steps.discover-runners.outputs.runner-count }}
runner-indices: ${{ steps.discover-runners.outputs.runner-indices }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
# --- quality steps ---
- name: Lint all
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -32,7 +49,6 @@ jobs:
make pytest-cov
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_DOC_VERSIONS_PKG: grm
DEVX_VALE_LEVEL: warning
@@ -45,8 +61,6 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/grm/translations.json
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
@@ -67,52 +81,10 @@ jobs:
else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi
release-dry-run:
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
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Release dry-run validation
env:
PYTHONPATH: src
DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
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 }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
# --- detect-changes step ---
- name: Detect changed paths
id: detect
env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate 2>/dev/null || true
@@ -120,22 +92,10 @@ jobs:
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
pre-merge-check:
needs: [quality, detect-changes]
if: github.event_name == 'pull_request'
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: 0
- name: Set up environment
run: make setup-image EXTRAS=ci
# --- validate-pr + pr-review steps (PR only) ---
- name: Validate auto-merge preconditions
if: github.event_name == 'pull_request'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
@@ -143,7 +103,6 @@ jobs:
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
PYTHONPATH: ${{ env.PYTHONPATH }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \
@@ -151,45 +110,58 @@ jobs:
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
discover-runners:
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 }}
runner-indices: ${{ steps.discover.outputs.runner-indices }}
steps:
- uses: actions/checkout@v4
- name: Set up environment
- name: Run automated PR review
if: github.event_name == 'pull_request'
run: |
. .venv/bin/activate 2>/dev/null || true
set -euo pipefail
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
# --- release-dry-run step (conditional) ---
- name: Release dry-run validation
if: steps.detect.outputs.user-facing-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Discover available runners
id: discover
DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
# --- discover-runners step (conditional on ansible-changed) ---
- name: Discover available molecule runners
id: discover-runners
if: steps.detect.outputs.ansible-changed == 'true'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \
--github-output
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
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 "ci/validate" \
--commit "${{ github.sha }}"
molecule-tests:
needs: [quality, detect-changes, discover-runners]
if: needs.detect-changes.outputs.ansible-changed == 'true'
needs: [validate]
if: needs.validate.outputs.ansible-changed == 'true'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
timeout-minutes: 15
strategy:
fail-fast: true
max-parallel: 3
max-parallel: 6
matrix:
runner-index: [1, 2, 3, 4, 5, 6]
steps:
@@ -206,16 +178,25 @@ jobs:
- name: Discover assigned test pairs
env:
RUNNER_INDEX: ${{ matrix.runner-index }}
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
PYTHONPATH: src
MAX_RUNNERS: 6
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \
--github-env --skip-if-excess
--github-env
- name: Run molecule tests
if: env.SKIP != 'true'
env:
GITEA_URL: ${{ github.server_url }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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 }}
DOCKER_HOST: unix:///var/run/docker.sock
run: |
. .venv/bin/activate 2>/dev/null || true
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
@@ -228,58 +209,17 @@ jobs:
echo "$_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 }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Run automated PR review
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate 2>/dev/null || true
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 the branch name, validates the PR title, and squash-merges.
# Auto-merge runs after validate + molecule-tests pass (or molecule is skipped).
# 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, pre-merge-check, pr-review, molecule-tests, release-dry-run]
needs: [validate, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pre-merge-check.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')
needs.validate.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
@@ -301,7 +241,6 @@ jobs:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
@@ -310,12 +249,11 @@ jobs:
--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, pre-merge-check)."
--body "Auto-approved: all CI checks passed (validate, molecule-tests)."
- name: Squash merge with task ID
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
HEAD_REF: ${{ github.head_ref }}
+84 -216
View File
@@ -1,101 +1,121 @@
name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs
# for release, publish, wiki sync, badges, and Vikunja task updates.
# Runs on every push to master (after CI workflow merges a PR).
# Consolidated into 2 jobs (from 7) to reduce runner overhead:
# detect-and-configure ──→ release-and-maintain
#
# Job dependency graph:
# Job 1: detect release commit, validate commit msg, configure repo
# (branch protection, labels).
# Job 2: release + publish + sync-wiki + vikunja + badges.
# Individual steps are conditional on job 1 outputs.
#
# 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 (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
#
# 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 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 step always runs (even on release commits) so version
# badge picks up the new __version__. It runs last so it sees the
# new version if release created one.
#
# 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.
# step builds and publishes the package to the Gitea PyPI registry.
# The release commit's post-merge run still updates badges. Other
# steps (sync-wiki, vikunja) skip on release commits.
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: post-merge-${{ github.ref }}
cancel-in-progress: true
env:
PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs:
detect-type:
detect-and-configure:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
outputs:
is-release: ${{ steps.check.outputs.is-release }}
is-automated: ${{ steps.check.outputs.is-automated }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
DEVX_REPO_NAME: grm
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / validate (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
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: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Validate latest commit message
if: steps.check.outputs.is-automated == 'false'
env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate 2>/dev/null || true
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
- name: Detect changed paths
id: detect
if: steps.check.outputs.is-release == 'false'
env:
DEVX_TASK_PREFIX: GRM
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "HEAD~1" \
--head "HEAD" \
--github-output
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
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/detect-and-configure" \
--commit "${{ github.sha }}"
release:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
release-and-maintain:
needs: [detect-and-configure]
if: always() && needs.detect-and-configure.result == 'success'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment
env:
@@ -106,10 +126,11 @@ jobs:
run: |
git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi"
# --- release + publish (only if not a release commit) ---
- name: Run release
id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env:
PYTHONPATH: src
DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
@@ -117,207 +138,54 @@ jobs:
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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/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_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Build and publish release
if: steps.release-tag.outputs.tag != ''
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
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_API_TOKEN: ${{ secrets.CI_GITEA_API_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]
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: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
git fetch --tags
git checkout "${{ steps.release-tag.outputs.tag }}"
python3 -m devx.ci.publish "${{ steps.release-tag.outputs.tag }}" "${{ github.repository }}" --auto-login
# --- sync-wiki + vikunja (skip on automated/release commits) ---
- name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
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/sync-wiki" \
--commit "${{ github.sha }}"
badges:
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.CI_GITEA_API_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
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/badges" \
--commit "${{ github.sha }}"
vikunja:
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: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Update Vikunja task
if: needs.detect-and-configure.outputs.is-automated == 'false'
env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
# --- badges (always run — even on release commits) ---
- name: Generate and push badges
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: |
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/vikunja" \
--commit "${{ github.sha }}"
configure-repo:
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: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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)"
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
export PATH="$HOME/.local/bin:$PATH"
# Fetch latest master to pick up any release commit that was pushed
git fetch origin master
git reset --hard origin/master
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_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/configure-repo" \
--workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}"
+54 -65
View File
@@ -51,13 +51,13 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
Both run via `make workflow-check` and are part of `make lint-all`.
The pre-commit hook runs actionlint automatically when workflow files change.
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
The CI `validate` job runs `make setup-image` (which installs all tools) then `make lint-all`.
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture
- **Python CLI** (`src/grm/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup with pasta networking (IPv6 support)
- **Ansible Role** (`ansible/roles/gitea_runner/`) — Idempotent role for rootless Docker runner setup with pasta networking (IPv6 support)
- **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
@@ -69,13 +69,13 @@ Every change to master goes through this workflow. No exceptions.
Branch protection and labels are automatically configured by
`devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`),
which runs as a `configure-repo` job in
which runs as a step in the `detect-and-configure` job in
the post-merge workflow on every push to master.
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
- **Require status checks**: CI quality + molecule tests must pass
- **Require status checks**: CI validate + molecule tests must pass
- **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically
@@ -123,8 +123,9 @@ architecture, code quality, security, i18n, testing, performance,
UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging.
**Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`). This job posts a review with
**Automated review (CI `validate` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review` as a step in the `validate` job.
This posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
the **[auto]** items in the checklist:
@@ -178,10 +179,10 @@ rejected.
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)
3. Wait for all CI checks to pass (including the `validate` job)
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)
6. The release-and-maintain job automatically versions, tags, and publishes (see below)
**If the branch is behind master** (another PR merged first), auto-merge
automatically rebases the PR's head branch via the Gitea API. This triggers
@@ -197,21 +198,22 @@ No manual rebase needed. To rebase manually: `make rebase` (local) or
### CI Path Filtering
The CI workflow includes a `pre-merge-check` job (runs after quality +
detect-changes) that validates branch format, PR title, and Vikunja task
match. This fails fast before expensive molecule tests run.
The CI workflow's `validate` job includes a pre-merge validation step
that validates branch format, PR title, and Vikunja task match. This
fails fast before expensive molecule tests run.
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.
The `validate` job also includes a `detect-changes` step that checks
whether any files under `ansible/` or `.ansible-lint` have changed. If
no Ansible files are changed, molecule tests are skipped — this prevents
non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from
being blocked by molecule test infrastructure flakiness.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically via `devx.molecule.discover_runners`. The `discover-runners`
job queries the Gitea API for runners at all levels (repo, org, instance)
dynamically via `devx.molecule.discover_runners`. The `validate` job
includes a `discover-runners` step (conditional on ansible-changed) that
queries the Gitea API for runners at all levels (repo, org, instance)
and generates a dynamic matrix. If the API can't see instance-level runners
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
then to a default of 3.
@@ -224,47 +226,27 @@ then to a default of 3.
### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow**
(`.gitea/workflows/post-merge.yml`) runs automatically. This single
workflow consolidates release, wiki sync, badge generation, and
Vikunja task updates:
(`.gitea/workflows/post-merge.yml`) runs automatically. Consolidated
into 2 jobs (from 7) to reduce runner overhead:
1. **detect-type** — Checks if the commit is a regular merge or a
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits (the `[skip ci]` tag also prevents re-triggering).
1. **detect-and-configure** — Configures repo (branch protection, labels),
detects release commit, validates commit message. Outputs `is-release`
and `is-automated` for the next job.
2. **release** — Runs `devx.ci.release` which:
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
- Uses **git-cliff** to calculate the next semver version from conventional commits
- Updates `__version__` in `src/grm/__init__.py` (single source of truth)
- Updates `CHANGELOG.md` with the new version section
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
- If lint or tests fail, **aborts immediately** — no commit, no tag
- Commits with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents
re-triggering post-merge on the release commit)
- Creates an annotated tag `vX.Y.Z` on the release commit
- Pushes both the commit and tag to master
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not only when release succeeds), so docs-only
changes still update the wiki.
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
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. Runs for ALL
non-release commits (not only when release succeeds), so infrastructure-only
changes still update the task tracker.
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.
2. **release-and-maintain** — Runs all post-merge maintenance as
conditional steps:
- **release** (if not a release commit) — Runs `devx.ci.release` which
checks for user-facing changes via `classify_changes` (skips if only
workflow/infrastructure files changed), uses git-cliff for semver,
updates `__version__`, updates `CHANGELOG.md`, runs lint+tests, commits
with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes to master.
- **publish** (if release created a tag) — Builds and publishes the
package to the Gitea PyPI registry. Checks out the release tag
within the same job.
- **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
- **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
- **badges** (always) — Generates and pushes quality badge SVGs to the
`badges` branch. Fetches latest master first to pick up release commits.
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -385,9 +367,9 @@ 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 job** (in `post-merge.yml`, needs: release):
- Runs after the release job creates a tag
- Gets the tag from `needs.release.outputs.tag`
2. **Publish step** (in the `release-and-maintain` job, runs after the release step creates a tag):
- Runs after the release step creates a tag
- Gets the tag from the release step's output
- Builds the Python package
- Publishes to the Gitea PyPI registry
- Creates a Gitea release with git-cliff-generated release notes
@@ -493,6 +475,12 @@ 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 only create files)
- On Arch Linux, `rootless_docker.yml` fetches the rootless setup scripts
(`dockerd-rootless-setuptool.sh`, `dockerd-rootless.sh`) from `moby/moby` `contrib/`
at a pinned ref (`gitea_runner_rootless_scripts_ref`) into `/usr/bin` and installs
`rootlesskit` — Arch's `docker` package ships neither. These fetch tasks run
regardless of `docker_rootless_setup` so CI exercises them on the archlinux platform.
See ADR-011 in the decision log.
## Molecule Scenarios
@@ -542,7 +530,7 @@ docs/
### Documentation Coverage
- `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
- Runs as a CI step in the validate job with `--fail-on-missing` (blocks CI if docs are missing)
- Enforced: 100% coverage for public CLI commands and major architectural components
### Updating Documentation
@@ -572,7 +560,7 @@ the user should not need to specify which profile to use.
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
| `ci-investigator` | Investigate CI failures (validate, molecule-tests, release-and-maintain) |
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync for grm docs |
@@ -582,7 +570,7 @@ the user should not need to specify which profile to use.
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, molecule-tests, release, publish, sync-wiki) | `ci-investigator` | Background |
| CI run failure (validate, molecule-tests, release-and-maintain) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Molecule tests need to run | `molecule-runner` | Background |
| Dependency upgrade requested | `dep-upgrader` | Background |
@@ -608,8 +596,9 @@ tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
Standard labels are created automatically by `configure_repo` (runs as
a step in `detect-and-configure` in post-merge on every master push).
If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## [0.18.2] - 2026-07-16
### Bug Fixes
- Load tun module and pre-configure systemd override for Arch rootless Docker
## [0.18.1] - 2026-07-16
### Bug Fixes
- Fetch rootless Docker scripts on Arch Linux
## [0.18.0] - 2026-07-12
### Features
+5 -4
View File
@@ -50,13 +50,14 @@ setup: $(VENV)/bin/activate .env activate-scripts configure-gitea-pypi
$(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
# (validate job steps: detect-changes, discover-runners, pr-review;
# release-and-maintain job steps: sync-wiki, badges)
# badges step runs generate_badges.py which needs ruff, pyright, bandit
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)
# Setup for the validate CI job (lint + test deps, actionlint tool)
setup-quality: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools
@@ -183,7 +184,7 @@ test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov
MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea_runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
# Quick local test: Ubuntu 22.04 only, all scenarios
molecule:
+10 -10
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![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/625e21765e37af0c66c56e2424254607583dab76/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -266,8 +266,8 @@ One of GRM's core features is the ability to run multiple isolated runners on th
- **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>/`
- **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
@@ -341,13 +341,13 @@ GRM consists of two layers:
1. **Python CLI** (`src/grm/`) — 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.
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.
```text
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
└── 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)
+6 -6
View File
@@ -6,13 +6,13 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -30,14 +30,14 @@
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
when: not gitea_runner_skip_registration | default(false)
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
+3 -3
View File
@@ -6,13 +6,13 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Enable gitea-runner user service
ansible.builtin.command: systemctl --user enable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- 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 | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
+1 -1
View File
@@ -3,4 +3,4 @@
hosts: all
become: true
roles:
- role: gitea-runner
- role: gitea_runner
+34 -34
View File
@@ -6,11 +6,11 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Get runner user UID
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
register: runner_uid_result
changed_when: false
failed_when: false
@@ -23,20 +23,20 @@
- 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 | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_systemd_available.stat.exists
changed_when: true
failed_when: false
- name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_systemd_available.stat.exists
changed_when: true
failed_when: false
@@ -47,7 +47,7 @@
args:
executable: /bin/bash
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
@@ -57,7 +57,7 @@
- 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) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
@@ -67,7 +67,7 @@
- name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
changed_when: true
@@ -75,120 +75,120 @@
- name: Include deregistration
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
when: not skip_runner_registration | default(false)
when: not gitea_runner_skip_registration | default(false)
- name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists
when: gitea_runner_systemd_available.stat.exists
changed_when: true
failed_when: 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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/docker-prune.timer"
state: absent
failed_when: false
- name: Remove healthcheck user service file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.service"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/runner-healthcheck.service"
state: absent
failed_when: false
- name: Remove healthcheck user timer file
ansible.builtin.file:
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
state: absent
failed_when: false
- name: Remove healthcheck script
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}/healthcheck.sh"
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_runner_name) }}/healthcheck.sh"
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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/gitea-runner.service"
state: absent
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) }}"
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: true
- name: Wait for processes to terminate
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: false
- name: Disable lingering for runner user
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
failed_when: false
changed_when: true
- name: Remove runner user and home directory
ansible.builtin.user:
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
name: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
state: absent
remove: true
when: remove_runner_user | default(true)
when: gitea_runner_remove_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.local/share/docker"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.cache/act"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_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"
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user"
state: absent
when: not (remove_runner_user | default(true))
when: not (gitea_runner_remove_user | default(true))
failed_when: false
- name: Remove subuid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subuid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent
failed_when: false
- name: Remove subgid entry for runner user
ansible.builtin.lineinfile:
path: /etc/subgid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent
failed_when: false
- name: Remove runner data directory
ansible.builtin.file:
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}"
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ gitea_runner_name) }}"
state: absent
- name: Remove runner config directory
ansible.builtin.file:
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}"
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_runner_name) }}"
state: absent
+1 -1
View File
@@ -2,6 +2,6 @@ collections:
- name: community.general
version: "==13.1.0"
- name: ansible.posix
version: "==2.2.0"
version: "==2.2.1"
- name: community.docker
version: "==5.2.1"
+2 -2
View File
@@ -7,12 +7,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "deregister-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "lifecycle-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- 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
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "template-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,12 +0,0 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "update-test-runner"
skip_runner_registration: true
docker_rootless_setup: false
roles:
- role: gitea-runner
@@ -1,58 +0,0 @@
---
- name: Check if runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
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
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when:
- runner_file_stat.stat.exists | default(false) | bool
- not skip_runner_registration
- 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"
state: absent
when: runner_file_stat.stat.exists | default(false) | bool
@@ -1,102 +0,0 @@
---
- name: Check runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_content
when: runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
runner_reg: >
{{ (runner_file_content.content | b64decode | from_json)
if (runner_file_content is defined and runner_file_content.content is defined)
else {} }}
when: runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner user service active
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: service_check
changed_when: false
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Validate runner installation
ansible.builtin.fail:
msg: >
Runner '{{ runner_name }}' is not properly installed:
{% if not (runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed.
{% endif %}
{% if docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active' %}
- Systemd user service is not active.
{% endif %}
when: >
not (runner_file_stat.stat.exists | default(false))
or (docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active')
- name: Report runner status
ansible.builtin.debug:
msg: >
Runner '{{ runner_name }}' is installed and running.
Registered: {{ runner_file_stat.stat.exists | default(false) }}
{% if runner_reg.id is defined %}Runner ID: {{ runner_reg.id }}{% endif %}
{% if runner_reg.uuid is defined %}UUID: {{ runner_reg.uuid }}{% endif %}
{% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %}
Service: {{ service_check.stdout | default('unknown') | trim }}
- name: Optional Gitea API verification
when:
- gitea_url is defined
- gitea_admin_token is defined
- gitea_admin_token | length > 0
block:
- name: Check admin runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/admin/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: admin_api_response
ignore_errors: true
- name: Check repo runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
headers:
Authorization: "token {{ gitea_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: repo_api_response
ignore_errors: true
- name: Report API status (informational only)
ansible.builtin.debug:
msg: >
API checks (informational only — not used for pass/fail):
Admin API: {{ admin_api_response.status | default('no response') }}.
Repo API: {{ repo_api_response.status | default('no response') }}.
{% if admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in admin API.
{% endif %}
{% if repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
Runner found in repo API.
{% endif %}
rescue:
- name: API check failed
ansible.builtin.debug:
msg: "API verification skipped due to connection or permission error."
@@ -1,19 +1,19 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
skip_runner_registration: false
gitea_runner_version: "2.0.1"
gitea_runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
gitea_runner_skip_registration: false
# Per-runner user (rootless isolation)
gitea_runner_user_prefix: "grm-"
gitea_runner_base_home: "/home"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ runner_name }}"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ gitea_runner_name }}"
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# Base paths (instance-scoped via runner_name)
# Base paths (instance-scoped via gitea_runner_name)
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
gitea_runner_binary_path: "/usr/local/bin/gitea_runner"
# Prune configuration
@@ -33,11 +33,11 @@ gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck
# 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: ""
gitea_runner_admin_token: ""
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
gitea_runner_remove_systemd_template: true
gitea_runner_remove_user: true
# Runner configuration
gitea_runner_log_level: "info"
@@ -45,18 +45,28 @@ gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# Docker installation (for rootless dependencies)
docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
docker_apt_source_line: >-
deb [arch={{ docker_apt_arch }} signed-by={{ docker_gpg_key_path }}]
gitea_runner_docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
gitea_runner_docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
gitea_runner_docker_apt_source_line: >-
deb [arch={{ gitea_runner_docker_apt_arch }} signed-by={{ gitea_runner_docker_gpg_key_path }}]
https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
{{ ansible_facts['distribution_release'] }} stable
# Set to false in CI/molecule to skip rootless daemon startup (needs kernel userns)
docker_rootless_setup: true
gitea_runner_docker_rootless_setup: true
# Rootless Docker helper scripts (dockerd-rootless-setuptool.sh / dockerd-rootless.sh).
# Arch Linux's "docker" package does not ship these (unlike Debian's docker-ce-rootless-extras),
# and no official Arch package provides them. They are fetched from the upstream moby/moby
# "contrib/" directory at the git ref below. The scripts are stable bash wrappers that are
# version-agnostic with respect to the dockerd binary, so a pinned ref is safe.
gitea_runner_rootless_scripts_ref: "v28.5.1"
# Install dir MUST match the location of the "docker" / "dockerd" / "rootlesskit" binaries so
# that dockerd-rootless-setuptool.sh (which derives BIN from its own dirname) finds them co-located.
gitea_runner_rootless_scripts_install_dir: "/usr/bin"
# Rootless Docker network driver: "pasta" (IPv6 support) or "slirp4netns" (IPv4 only)
# pasta has proper outgoing IPv6 support; slirp4netns does not (known limitation).
docker_rootless_net_driver: "pasta"
gitea_runner_docker_rootless_net_driver: "pasta"
# IPv6 subnet for rootless Docker containers (ULA range, not routable on internet)
docker_ipv6_cidr: "fd00:dead:beef::/48"
gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
@@ -9,4 +9,4 @@
when:
- ansible_facts is defined
- ansible_facts['service_mgr'] | default('') == 'systemd'
- docker_rootless_setup
- gitea_runner_docker_rootless_setup
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "molecule-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "molecule-test-runner"
gitea_runner_name: "molecule-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "deregister-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -13,7 +13,7 @@
ansible.builtin.copy:
dest: "{{ gitea_runner_data_dir }}/.runner"
content: |
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ gitea_runner_name }}", "address": "http://localhost:3000"}
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
@@ -22,12 +22,12 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: false
gitea_runner_skip_registration: false
tasks:
- name: Include deregistration tasks
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: deregister.yml
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "deregister-test-runner"
gitea_runner_name: "deregister-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -12,12 +12,12 @@
- name: Check registration file was removed
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat
register: gitea_runner_file_stat
- name: Assert registration file no longer exists
ansible.builtin.assert:
that:
- not runner_file_stat.stat.exists
- not gitea_runner_file_stat.stat.exists
fail_msg: "Registration file (.runner) was not removed by deregistration"
- name: Check systemd user service still exists
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "lifecycle-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -23,7 +23,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "lifecycle-test-runner"
gitea_runner_name: "lifecycle-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -5,11 +5,11 @@
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-a"
skip_runner_registration: true
docker_rootless_setup: false
gitea_runner_name: "molecule-runner-a"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea-runner
- role: gitea_runner
- name: Converge second runner instance
hosts: all
@@ -17,8 +17,8 @@
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-b"
skip_runner_registration: true
docker_rootless_setup: false
gitea_runner_name: "molecule-runner-b"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea-runner
- role: gitea_runner
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "remove-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -2,7 +2,7 @@
- name: Remove runner via remove-runner playbook
ansible.builtin.import_playbook: "../../../../remove-runner.yml"
vars:
runner_name: "remove-test-runner"
gitea_runner_name: "remove-test-runner"
registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000"
skip_runner_registration: true
gitea_runner_skip_registration: true
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "remove-test-runner"
gitea_runner_name: "remove-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "template-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "template-test-runner"
gitea_runner_name: "template-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,12 @@
---
- name: Converge
hosts: all
become: true
vars:
gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing"
gitea_runner_name: "update-test-runner"
gitea_runner_skip_registration: true
gitea_runner_docker_rootless_setup: false
roles:
- role: gitea_runner
@@ -3,9 +3,9 @@
hosts: all
become: true
vars:
runner_name: "update-test-runner"
gitea_runner_name: "update-test-runner"
tasks:
- name: Include update tasks
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: update_runner.yml
@@ -3,7 +3,7 @@
hosts: all
become: true
vars:
runner_name: "update-test-runner"
gitea_runner_name: "update-test-runner"
pre_tasks:
- name: Load role defaults
ansible.builtin.include_vars:
@@ -0,0 +1,58 @@
---
- name: Check if runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_content
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
gitea_runner_reg: >
{{ (gitea_runner_file_content.content | b64decode | from_json)
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
else {} }}
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Deregister runner from Gitea via API
ansible.builtin.command: >
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
-H "Authorization: token {{ gitea_runner_admin_token | default(registration_token) }}"
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ gitea_runner_reg.id }}"
args:
chdir: "{{ gitea_runner_data_dir }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when:
- gitea_runner_file_stat.stat.exists | default(false) | bool
- not gitea_runner_skip_registration
- gitea_runner_reg.id is defined
register: gitea_runner_deregister_output
changed_when: gitea_runner_deregister_output.rc == 0
failed_when: false
- name: Warn if deregistration failed
ansible.builtin.debug:
msg: >-
WARNING: Runner deregistration from Gitea failed (rc={{ gitea_runner_deregister_output.rc | default('N/A') }}).
The runner entry may remain in Gitea's admin UI as offline.
Use an admin token (gitea_runner_admin_token var) to enable automatic cleanup,
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
when:
- gitea_runner_file_stat.stat.exists | default(false) | bool
- not gitea_runner_skip_registration
- gitea_runner_deregister_output is defined
- gitea_runner_deregister_output.rc | default(1) != 0
- name: Remove runner registration file
ansible.builtin.file:
path: "{{ gitea_runner_data_dir }}/.runner"
state: absent
when: gitea_runner_file_stat.stat.exists | default(false) | bool
@@ -31,8 +31,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start healthcheck user timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
@@ -42,5 +42,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -15,7 +15,7 @@
- name: Include registration
ansible.builtin.include_tasks: register.yml
when: not skip_runner_registration
when: not gitea_runner_skip_registration
- name: Include service setup
ansible.builtin.include_tasks: service.yml
@@ -0,0 +1,102 @@
---
- name: Check runner registration file exists
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_stat
- name: Read runner registration file
ansible.builtin.slurp:
src: "{{ gitea_runner_data_dir }}/.runner"
register: gitea_runner_file_content
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Parse runner registration data
ansible.builtin.set_fact:
gitea_runner_reg: >
{{ (gitea_runner_file_content.content | b64decode | from_json)
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
else {} }}
when: gitea_runner_file_stat.stat.exists | default(false) | bool
- name: Verify runner user service active
ansible.builtin.command: systemctl --user is-active gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: gitea_runner_service_check
changed_when: false
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Validate runner installation
ansible.builtin.fail:
msg: >
Runner '{{ gitea_runner_name }}' is not properly installed:
{% if not (gitea_runner_file_stat.stat.exists | default(false)) %}
- Registration file (.runner) is missing. Registration may have failed.
{% endif %}
{% if gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active' %}
- Systemd user service is not active.
{% endif %}
when: >
not (gitea_runner_file_stat.stat.exists | default(false))
or (gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active')
- name: Report runner status
ansible.builtin.debug:
msg: >
Runner '{{ gitea_runner_name }}' is installed and running.
Registered: {{ gitea_runner_file_stat.stat.exists | default(false) }}
{% if gitea_runner_reg.id is defined %}Runner ID: {{ gitea_runner_reg.id }}{% endif %}
{% if gitea_runner_reg.uuid is defined %}UUID: {{ gitea_runner_reg.uuid }}{% endif %}
{% if gitea_runner_reg.address is defined %}Gitea: {{ gitea_runner_reg.address }}{% endif %}
Service: {{ gitea_runner_service_check.stdout | default('unknown') | trim }}
- name: Optional Gitea API verification
when:
- gitea_url is defined
- gitea_runner_admin_token is defined
- gitea_runner_admin_token | length > 0
block:
- name: Check admin runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/admin/runners"
headers:
Authorization: "token {{ gitea_runner_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: gitea_runner_admin_api_response
ignore_errors: true
- name: Check repo runners API
ansible.builtin.uri:
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
headers:
Authorization: "token {{ gitea_runner_admin_token }}"
method: GET
status_code: [200, 401, 403, 404]
return_content: true
body_format: json
register: gitea_runner_repo_api_response
ignore_errors: true
- name: Report API status (informational only)
ansible.builtin.debug:
msg: >
API checks (informational only — not used for pass/fail):
Admin API: {{ gitea_runner_admin_api_response.status | default('no response') }}.
Repo API: {{ gitea_runner_repo_api_response.status | default('no response') }}.
{% if gitea_runner_admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
Runner found in admin API.
{% endif %}
{% if gitea_runner_repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
Runner found in repo API.
{% endif %}
rescue:
- name: API check failed
ansible.builtin.debug:
msg: "API verification skipped due to connection or permission error."
@@ -19,4 +19,4 @@
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
when: not gitea_runner_skip_registration
@@ -23,8 +23,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
@@ -34,5 +34,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -10,15 +10,15 @@
- name: Check if runner is already registered
ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered
register: gitea_runner_registered
- name: Register runner with Gitea
ansible.builtin.command: >
{{ gitea_runner_binary_path }} register
--token {{ registration_token }}
--name {{ runner_name }}
--name {{ gitea_runner_name }}
--instance {{ gitea_url }}
--labels {{ runner_labels }}
--labels {{ gitea_runner_labels }}
--no-interactive
args:
chdir: "{{ gitea_runner_data_dir }}"
@@ -27,7 +27,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when: not runner_registered.stat.exists
register: register_output
changed_when: "'already exists' not in register_output.stdout | default('')"
when: not gitea_runner_registered.stat.exists
register: gitea_runner_register_output
changed_when: "'already exists' not in gitea_runner_register_output.stdout | default('')"
timeout: 60
@@ -6,14 +6,14 @@
- name: Resolve runner service user
ansible.builtin.set_fact:
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ gitea_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 }}"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
- name: Resolve runner service user UID
ansible.builtin.getent:
@@ -9,18 +9,19 @@
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
ansible.builtin.shell: |
set -o pipefail
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" \
| gpg --dearmor --yes -o {{ gitea_runner_docker_gpg_key_path }}
args:
creates: "{{ docker_gpg_key_path }}"
creates: "{{ gitea_runner_docker_gpg_key_path }}"
executable: /bin/bash
when: ansible_facts['os_family'] == 'Debian'
- name: Add Docker APT repository (Debian/Ubuntu)
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.list
content: "{{ docker_apt_source_line }}\n"
content: "{{ gitea_runner_docker_apt_source_line }}\n"
mode: "0644"
register: docker_apt_repo
register: gitea_runner_docker_apt_repo
when: ansible_facts['os_family'] == 'Debian'
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
@@ -28,7 +29,7 @@
update_cache: true
when:
- ansible_facts['os_family'] == 'Debian'
- docker_apt_repo is changed
- gitea_runner_docker_apt_repo is changed
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
@@ -61,13 +62,85 @@
- passt
- fuse-overlayfs
- rsync
# rootlesskit is the userspace networking/namespace driver for rootless Docker.
# It is NOT a dependency of the "docker" package on Arch and must be installed explicitly.
- rootlesskit
state: present
when: ansible_facts['os_family'] == 'Archlinux'
# The tun kernel module is required by rootlesskit (both slirp4netns and pasta
# drivers create a tap device inside a user namespace). On Arch Linux, CONFIG_TUN=m
# so the module must be loaded. If the running kernel doesn't match the installed
# kernel (e.g. after a pacman -Syu that upgraded linux but didn't reboot), modprobe
# will fail — in that case we warn but don't fail, as a reboot will fix it.
- name: Load tun kernel module for rootless networking
community.general.modprobe:
name: tun
state: present
ignore_errors: true
register: gitea_runner_tun_module
when: ansible_facts['os_family'] == 'Archlinux'
- name: Warn if tun module could not be loaded (kernel mismatch — reboot needed)
ansible.builtin.debug:
msg: >-
WARNING: Could not load the tun kernel module. This is likely because the
running kernel ({{ ansible_facts['kernel'] }}) does not match the installed
kernel modules. A reboot is required before rootless Docker can start.
when:
- ansible_facts['os_family'] == 'Archlinux'
- gitea_runner_tun_module is failed
# Arch Linux's "docker" package does not ship the rootless setup scripts (dockerd-rootless-setuptool.sh
# and dockerd-rootless.sh), unlike Debian/Ubuntu's docker-ce-rootless-extras. No official Arch package
# provides them, so fetch them from the upstream moby/moby contrib/ directory. They are installed
# alongside the docker binaries (/usr/bin) because dockerd-rootless-setuptool.sh derives its BIN
# directory from its own location and expects docker/dockerd/rootlesskit to be co-located there.
- name: Fetch rootless Docker setup scripts (Arch Linux)
ansible.builtin.get_url:
url: "https://raw.githubusercontent.com/moby/moby/{{ gitea_runner_rootless_scripts_ref }}/contrib/{{ item.name }}"
dest: "{{ gitea_runner_rootless_scripts_install_dir }}/{{ item.name }}"
mode: "0755"
owner: root
group: root
loop:
- name: dockerd-rootless-setuptool.sh
- name: dockerd-rootless.sh
when: ansible_facts['os_family'] == 'Archlinux'
- name: Check if rootless Docker is already set up
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
register: rootless_docker_check
register: gitea_runner_rootless_docker_check
# Create the systemd override BEFORE running the setuptool so that when
# the setuptool starts docker.service, it picks up the pasta network driver
# instead of the default slirp4netns (which may fail on some kernels).
- name: Ensure systemd user override directory exists
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d"
state: directory
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when:
- gitea_runner_docker_rootless_setup
- not gitea_runner_rootless_docker_check.stat.exists
- name: Pre-configure rootless Docker network driver override
ansible.builtin.copy:
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d/override.conf"
content: |
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when:
- gitea_runner_docker_rootless_setup
- not gitea_runner_rootless_docker_check.stat.exists
- name: Set up rootless Docker for runner user
ansible.builtin.command: dockerd-rootless-setuptool.sh install
@@ -77,9 +150,10 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DOCKERD_ROOTLESS_ROOTLESSKIT_NET: "{{ gitea_runner_docker_rootless_net_driver }}"
when:
- docker_rootless_setup
- not rootless_docker_check.stat.exists
- gitea_runner_docker_rootless_setup
- not gitea_runner_rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user start docker
@@ -88,7 +162,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
@@ -97,7 +171,7 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Ensure Docker config directory exists
ansible.builtin.file:
@@ -106,30 +180,21 @@
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when: docker_rootless_setup
- name: Ensure systemd user override directory exists
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d"
state: directory
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
- name: Configure rootless Docker to use pasta with IPv6
ansible.builtin.copy:
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d/override.conf"
content: |
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ docker_rootless_net_driver }}"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
register: docker_network_override
when: docker_rootless_setup
register: gitea_runner_docker_network_override
when: gitea_runner_docker_rootless_setup
- name: Reload systemd user daemon if network config changed
ansible.builtin.command: systemctl --user daemon-reload
@@ -139,8 +204,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- docker_rootless_setup
- docker_network_override is changed
- gitea_runner_docker_rootless_setup
- gitea_runner_docker_network_override is changed
- name: Configure rootless Docker daemon with IPv6 enabled
ansible.builtin.copy:
@@ -149,13 +214,13 @@
{
"ipv6": true,
"ip6tables": true,
"fixed-cidr-v6": "{{ docker_ipv6_cidr }}"
"fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}"
}
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
register: docker_ipv6_config
when: docker_rootless_setup
register: gitea_runner_docker_ipv6_config
when: gitea_runner_docker_rootless_setup
- name: Restart rootless Docker if config changed
ansible.builtin.command: systemctl --user restart docker
@@ -165,8 +230,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- docker_rootless_setup
- docker_ipv6_config is changed or docker_network_override is changed
- gitea_runner_docker_rootless_setup
- gitea_runner_docker_ipv6_config is changed or gitea_runner_docker_network_override is changed
- name: Wait for rootless Docker daemon to be ready
ansible.builtin.command: docker version
@@ -175,9 +240,9 @@
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_ready
until: docker_ready.rc == 0
register: gitea_runner_docker_ready
until: gitea_runner_docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
@@ -15,8 +15,8 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
@@ -26,5 +26,5 @@
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
@@ -2,4 +2,4 @@
- name: Check if systemd is available
ansible.builtin.stat:
path: /run/systemd/system
register: systemd_available
register: gitea_runner_systemd_available
@@ -9,6 +9,6 @@
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- systemd_available.stat.exists | default(false) | bool
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists | default(false) | bool
- gitea_runner_docker_rootless_setup
changed_when: true
@@ -6,21 +6,21 @@
shell: /bin/bash
system: true
create_home: true
register: runner_user
register: gitea_runner_user
- name: Set runner UID fact
ansible.builtin.set_fact:
gitea_runner_uid: "{{ runner_user.uid }}"
gitea_runner_uid: "{{ gitea_runner_user.uid }}"
- name: Check if lingering is already enabled
ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}"
register: linger_stat
register: gitea_runner_linger_stat
- name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists
when: systemd_available.stat.exists
changed_when: not gitea_runner_linger_stat.stat.exists
when: gitea_runner_systemd_available.stat.exists
- name: Ensure subuid entry for runner user
ansible.builtin.lineinfile:
@@ -21,6 +21,6 @@
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_version_output
register: gitea_runner_docker_version_output
changed_when: false
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
+4 -4
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Check if runner is already registered
@@ -21,11 +21,11 @@
- name: Include registration if not registered
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: register.yml
when:
- not runner_registered.stat.exists
- not skip_runner_registration | default(false)
- not gitea_runner_skip_registration | default(false)
- name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner
+2 -2
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Check systemd user service status
+2 -2
View File
@@ -6,12 +6,12 @@
tasks:
- name: Include systemd availability check
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: systemd_check.yml
- name: Resolve runner UID
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service
+1 -1
View File
@@ -6,5 +6,5 @@
tasks:
- name: Update runner
ansible.builtin.include_role:
name: gitea-runner
name: gitea_runner
tasks_from: update_runner.yml
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![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/625e21765e37af0c66c56e2424254607583dab76/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/625e21765e37af0c66c56e2424254607583dab76/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/bf77093ceedcbb44e60ebfdbd10ebf17643377d5/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -0,0 +1,105 @@
# Retrospective: CI Consolidation and devx Adoption
## Date
2026-07-12
## Context
The grm repo (Gitea Runner Manager) underwent CI workflow consolidation
and adopted the latest devx package (v0.40.0 → v0.40.1) during this
period. The self-approval fallback fix in devx v0.40.1 required passing
`CI_GITEA_API_TOKEN` to the approval step in grm's CI workflow. This
retrospective covers grm v0.17.2 through v0.18.0.
## Scope
PRs: GRM-144 (IPv6/pasta), GRM-145 (devx v0.40.0 adoption), GRM-146 (CI
consolidation). Plus the self-approval fallback cherry-pick. ~12 commits.
## Timeline of Key Events
| Event | Description |
|-------|-------------|
| GRM-144 merged | IPv6 support via pasta network driver (v0.18.0) |
| GRM-145 merged | Adopted devx v0.40.0, removed redundant crypto/vault wrappers |
| GRM-146 merged | Consolidated CI and post-merge workflows (7→2 jobs) |
| devx v0.40.1 bump | Cherry-picked self-approval fallback into grm CI |
No CI failures were specific to grm during this period. The grm CI
passed cleanly on all runs. The only issue was the cross-repo
self-approval bug (inherited from devx), which was fixed by bumping
devx to v0.40.1 and passing `CI_GITEA_API_TOKEN` to the approval step.
## What Served Us Well
- **Clean CI consolidation.** GRM-146 merged 7 CI jobs into 2
(validate + molecule-tests) without any CI failures. The
consolidation pattern was already proven in devx (DEVX-126), so the
application to grm was straightforward.
- **devx adoption was smooth.** GRM-145 adopted devx v0.40.0 and removed
redundant crypto/vault wrappers. The refactoring was clean — no test
failures, no coverage drops.
- **Molecule tests stable.** All 6 molecule scenarios passed on every
CI run. The pasta networking change (GRM-144) was well-tested with
molecule before merge.
- **Pre-push hook caught missing Vikunja tasks.** The pre-push hook
validates Vikunja task existence, preventing pushes without
corresponding tasks.
## What Could Be Improved
### 1. Cross-Repo Dependency Propagation
When devx v0.40.1 was released with the self-approval fix, grm needed
to bump its devx version and update the CI workflow to pass
`CI_GITEA_API_TOKEN`. This was a manual process — there's no automated
mechanism to detect that a devx release affects downstream repos.
**Impact:** The self-approval fix was in devx for ~30 min before grm
was updated. If the timing had been different, grm PRs could have been
blocked.
**Lesson:** When releasing a devx fix that affects CI workflows in
downstream repos, bump devx in all repos in the same session. Consider
a "dependabot" style check that flags outdated devx versions.
### 2. No Repo-Specific Retrospective Directory
The grm repo didn't have a `docs/retrospectives/` directory until now.
Previous retrospectives were only in the infra repo. This meant grm-
specific lessons weren't being captured.
**Impact:** Low — grm had fewer issues during this period. But going
forward, grm-specific learnings should be documented here.
**Fix:** Created `docs/retrospectives/` directory with this
retrospective.
## Improvements Implemented
### 1. CI Workflow Consolidation (MEDIUM impact)
Merged 7 CI jobs into 2 (validate + molecule-tests), matching the
pattern established in devx. Reduced runner overhead by ~4 min per CI
run.
### 2. devx v0.40.1 Adoption (HIGH impact)
Bumped devx to v0.40.1, picking up the self-approval fallback fix.
Updated CI workflow to pass `CI_GITEA_API_TOKEN` to the approval step.
### 3. IPv6 Support via Pasta (MEDIUM impact)
GRM-144 enabled IPv6 in rootless Docker via the pasta network driver,
replacing the previous slirp4netns setup. This improves network
performance and enables IPv6 connectivity for runner containers.
## Action Items for Future Sessions
1. **Bump devx in all downstream repos when a CI-affecting fix is
released.** Don't leave repos on stale devx versions.
2. **Document grm-specific lessons in this retrospective directory.**
Don't rely on the infra retrospective to cover grm issues.
3. **When consolidating CI workflows, verify that all status checks
referenced by branch protection are still present.** The
consolidation renamed `quality` to `validate`, requiring a branch
protection update.
+13 -6
View File
@@ -3,7 +3,7 @@
GRM consists of two layers:
1. **Python CLI** (`src/grm/`) — 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.
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
@@ -26,7 +26,7 @@ The Ansible role handles all remote state: user creation, package installation,
grm install <host>
└── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner
└── 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)
@@ -53,7 +53,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
| `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 |
| `rootless_docker.yml` | Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), provisions the rootless setup scripts on Arch (not shipped by the `docker` package), 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 |
@@ -83,8 +83,8 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
- **Home**: `/home/grm-<name>/`
- **Data**: `/var/lib/gitea-runner/<name>/`
- **Config**: `/etc/gitea-runner/<name>/`
- **Data**: `/var/lib/gitea_runner/<name>/`
- **Config**: `/etc/gitea_runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
@@ -100,7 +100,7 @@ flowchart TD
EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
ANS["ansible-playbook subprocess"]
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
ROLE["Ansible Role<br/>ansible/roles/gitea_runner/"]
USER["user_setup.yml<br/>create system user + lingering"]
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
INSTALL["install_runner.yml<br/>download, config, register, service"]
@@ -190,6 +190,13 @@ per-workaround DNS hacks.
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.
#### Platform-specific rootless provisioning
The rootless setup scripts (`dockerd-rootless-setuptool.sh` and `dockerd-rootless.sh`) are provided differently per OS:
- **Debian/Ubuntu** — shipped by the `docker-ce-rootless-extras` package (installed via the Docker APT repo).
- **Arch Linux** — the `docker` package does **not** include these scripts, and no official Arch package provides them. The role fetches them from the upstream `moby/moby` `contrib/` directory at a pinned, overridable git ref (`gitea_runner_rootless_scripts_ref`, default `v28.5.1`) and installs them into `/usr/bin` — co-located with `docker`/`dockerd`/`rootlesskit`, which is required because `dockerd-rootless-setuptool.sh` derives its `BIN` directory from its own location and expects those binaries alongside it. The `rootlesskit` package (a required rootless runtime dependency that is not pulled in by Arch's `docker` package) is also installed explicitly.
### Secret handling
Registration tokens and admin API tokens are never exposed on the command line. The `RunnerManager._extra_vars_file()` context manager:
+42 -33
View File
@@ -6,10 +6,9 @@ GRM uses a fully automated CI/CD pipeline built on Gitea Actions. Every change t
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `ci.yml` | PR opened/synchronized | Quality checks (lint, test, coverage) + molecule tests |
| `ci.yml` | PR opened/synchronized | Validate (lint, test, coverage, detect-changes, release-dry-run, pr-review, discover-runners) + 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 |
| `post-merge.yml` | Push to `master` | Detect-and-configure + release-and-maintain (release, publish, wiki sync, badges, Vikunja task update) |
Every change to master goes through a mandatory PR workflow. No exceptions.
@@ -101,14 +100,14 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
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
6. The release-and-maintain job automatically versions, tags, and publishes
### 9. Post-Merge Automation
After the squash-merge:
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `devx.ci.post_merge` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master`. The `detect-and-configure` job configures the repo and detects the commit type. The `release-and-maintain` job then runs the release, publish, sync-wiki, badges, and Vikunja steps as appropriate.
- The **release step** (in the `release-and-maintain` job) automatically versions, tags, and publishes (see below).
## Branch Protection (Required Gitea Settings)
@@ -116,42 +115,46 @@ Configure the following branch protection rules for `master` in Gitea repo setti
- **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge
- **Require status checks**: CI quality + molecule tests must pass
- **Require status checks**: CI validate + molecule tests must pass
- **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
## CI Path Filtering
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` step in the `validate` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
The `detect-changes` job:
The `detect-changes` step:
- For pull requests: compares `origin/master` against the PR head SHA
- For pushes to master: compares `HEAD~1` against `HEAD`
- Outputs `ansible-changed` as `true` or `false`
The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`.
The `molecule-tests` job depends on the `validate` job (which includes the `detect-changes` step), and only runs if `ansible-changed == 'true'`.
CI triggers only on `opened` and `synchronize` PR events (not `labeled`).
## CI Quality Job
## CI Validate Job
The `quality` job in `.gitea/workflows/ci.yml` runs:
The `validate` job in `.gitea/workflows/ci.yml` consolidates the former quality, detect-changes, release-dry-run, pre-merge-check, pr-review, and discover-runners jobs into a single job. It runs:
1. `make setup` — full environment setup
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
3. `make pytest-cov` — unit tests with 100% coverage enforcement
4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation (release-dry-run step)
6. Pre-merge validation step — validates branch format, PR title, and Vikunja task match
7. `detect-changes` step — checks whether Ansible files changed (gates molecule tests)
8. `pr-review` step — automated PR review via `devx.ci.pr_review`
9. `discover-runners` step — dynamic runner discovery for molecule tests (conditional on ansible-changed)
## Automated Release Pipeline
After a PR is merged to master, the release pipeline runs automatically.
### Release Workflow (`.gitea/workflows/release.yml`)
### Release Step (in the release-and-maintain job)
- Triggers on push to `master`
- Runs as a conditional step in the `release-and-maintain` job (skipped for release commits)
- Sets up full dev environment (`make setup`) so lint and tests can run
- Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot`
@@ -169,9 +172,10 @@ After a PR is merged to master, the release pipeline runs automatically.
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
### Publish Workflow (`.gitea/workflows/publish.yml`)
### Publish Step (in the release-and-maintain job)
- Triggers on tag push (`v*`)
- Runs as a conditional step in the `release-and-maintain` job (only if the release step created a tag)
- Checks out the release tag within the same job
- Installs git-cliff (version 2.13.0)
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
- Validates `PYPI_TOKEN` is set (warns if missing)
@@ -190,12 +194,17 @@ 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`
- 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`
- Consolidated from 7 jobs into 2 jobs to reduce runner overhead
- **detect-and-configure** — Configures repo (branch protection, labels), detects release commit, validates commit message. Outputs `is-release` and `is-automated` for the next job.
- **detect-type step** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent steps skip for release commits (the `[skip ci]` tag also prevents re-triggering).
- **validate-commit-msg step** — Validates the commit message follows conventional commit format.
- **configure-repo step** — Runs `devx.tools.configure_repo` to set up branch protection and labels.
- **release-and-maintain** — Runs all post-merge maintenance as conditional steps:
- **release step** (if not a release commit) — Runs `devx.ci.release` (see Automated Release Pipeline below)
- **publish step** (if release created a tag) — Builds and publishes the package to the Gitea PyPI registry
- **sync-wiki step** (if not automated) — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
- **vikunja step** (if not automated) — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
- **badges step** (always) — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs even if release fails or is skipped so the version badge always reflects the latest state.
### Smart CI: User-Facing vs Workflow-Only Changes
@@ -218,16 +227,16 @@ from accidentally skipping releases. Classification is config-driven via
**CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: `release.py` calls `classify_changes` to check if any
- **Release dry-run**: Only runs when user-facing files change (release-dry-run step in the validate job)
- **Validate job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release step**: `release.py` calls `classify_changes` to check if any
user-facing files changed since the last tag. If not, the release is skipped
entirely — no version bump, no tag, no publish.
### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners
dynamically. The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for
dynamically. The `discover-runners` step in the `validate` job runs `devx.molecule.discover_runners` which queries the Gitea API for
registered runners at three levels (repo, org, instance) and generates
a matrix of runner indices. If the API query fails (e.g., no admin
access for instance-level runners), it falls back to the
@@ -263,15 +272,15 @@ feature branches.
### Release Commit Detection
The `detect-type` job in the post-merge workflow runs
The `detect-type` step in the `detect-and-configure` job (post-merge workflow) runs
`devx.ci.detect_release_commit` to check whether the latest commit
is a release commit (format: `release: vX.Y.Z`). When a release commit
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
are skipped — the tag push triggers the publish workflow instead.
is detected, all subsequent steps in the `release-and-maintain` job (release, publish, sync-wiki, vikunja)
are skipped — the tag push triggers the publish step instead.
### Badge Generation and Push
The `badges` job in the post-merge workflow runs
The `badges` step in the `release-and-maintain` job (post-merge workflow) runs
`devx.ci.push_badges` which:
1. Fetches the latest master and hard-resets to it (picks up release commits)
2. Generates quality badge SVG files via `devx.tools.generate_badges`
@@ -279,8 +288,8 @@ The `badges` job in the post-merge workflow runs
4. Copies SVG files to the branch root
5. Force-pushes the branch to the remote
The badges job depends on the `release` job and uses `if: always()` so it
runs even if release fails or is skipped. This ensures the version badge
The badges step runs with `if: always()` so it
runs even if the release step fails or is skipped. This ensures the version badge
always reflects the actual state of the repository after any release
commits have been pushed.
+4 -4
View File
@@ -77,7 +77,7 @@ Every change to master goes through this workflow. No exceptions.
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.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
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-and-maintain job automatically versions and tags
### 1. Create Vikunja task
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
@@ -165,17 +165,17 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
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
6. The release-and-maintain job 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)
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`:
Branch protection is automatically configured by the `configure-repo` step in the `detect-and-configure` job of 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
- **Require status checks**: CI quality + molecule tests must pass
- **Require status checks**: CI validate + molecule tests must pass
- **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,7 +16,7 @@
│ ├── ui.py # Colorised console output
│ └── translations.json # Translation strings
├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role
│ ├── roles/gitea_runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files)
@@ -222,7 +222,7 @@ The pre-commit hook runs actionlint automatically when workflow files change.
|--------|-------------|
| `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-quality` | Setup for the validate 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` |
+4 -4
View File
@@ -16,11 +16,11 @@ From the `Makefile`:
- `test-unit``pytest tests/unit/ -v --no-cov` (unit tests without coverage)
- `pytest-cov``pytest tests/ -v --cov=src/grm --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 for all code in `src/grm/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/grm/`. The CI validate job runs `make pytest-cov` on every PR, and the release step in the release-and-maintain job 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.
The CI validate 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
@@ -43,11 +43,11 @@ make molecule # Quick: all 6 scenarios on Ubuntu 22.04
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
```
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.
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.
### Scenarios
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
Seven Molecule scenarios are defined under `ansible/roles/gitea_runner/molecule/`:
| Scenario | Description | What it verifies |
|----------|-------------|------------------|
+1 -1
View File
@@ -335,7 +335,7 @@ grm health prod-runner
Output shows NAME, HOST, HEALTHY (yes/no), and MESSAGE columns. The command exits with code 1 if any runner is unhealthy.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea-runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
## trigger-workflow
+2
View File
@@ -105,6 +105,8 @@ grm remove prod-runner --force
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.
On Arch Linux, the rootless Docker setup scripts (`dockerd-rootless-setuptool.sh`, `dockerd-rootless.sh`) and the `rootlesskit` runtime are provisioned automatically — Arch's `docker` package does not ship them. No extra setup is required.
## How to 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).
+9
View File
@@ -2,6 +2,14 @@
## Installation Issues
### `No such file or directory: dockerd-rootless-setuptool.sh` (Arch Linux)
**Symptom:** `grm install` fails on an Arch Linux host during rootless Docker setup with `Error executing command: [Errno 2] No such file or directory: b'dockerd-rootless-setuptool.sh'`.
**Cause:** Arch's `docker` package does not ship the rootless setup scripts (unlike Debian/Ubuntu's `docker-ce-rootless-extras`), and `rootlesskit` is not a dependency of the `docker` package.
**Solution:** This is handled automatically by GRM ≥ v0.19.0. Ensure you are on the latest release (`git checkout $(git describe --tags --abbrev=0)`). The role fetches the scripts from upstream `moby/moby` and installs `rootlesskit` automatically. If it still fails, verify the host can reach `raw.githubusercontent.com` (the script source) and check the Ansible log under `~/.local/state/grm/logs/`.
### Ansible connection fails (UNREACHABLE)
**Symptom:** Ansible reports `UNREACHABLE` when trying to connect to the target host.
@@ -179,6 +187,7 @@ If Docker is not installed, install it via your package manager or [Docker's off
| Symptom | Likely Cause | Solution |
|---------|-------------|----------|
| Ansible UNREACHABLE | SSH connection failed | Verify `--user`, `--key`, and host reachability |
| `No such file ... dockerd-rootless-setuptool.sh` (Arch) | Arch `docker` pkg lacks rootless scripts + `rootlesskit` | Fixed in v0.19.0; update GRM to latest release |
| `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 |
+10 -9
View File
@@ -27,25 +27,26 @@ version = {attr = "grm.__version__"}
[project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
# (validate job steps: detect-changes, discover-runners, pr-review;
# release-and-maintain job steps: sync-wiki, badges, etc.)
ci = [
"pytest==9.1.1",
"pytest-cov==7.1.0",
"build==1.5.0",
"build==1.5.1",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx==0.40.0",
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.1",
]
# Lint and type-checking tools (quality job)
# Lint and type-checking tools (validate job)
lint = [
"ruff==0.15.20",
"ruff==0.15.21",
"pyright==1.1.411",
"bandit==1.9.4",
"pip-audit==2.10.1",
"pre-commit==4.6.0",
"ansible-lint==26.4.0",
"ansible-lint==26.6.0",
]
# Molecule testing (molecule-tests job)
# Molecule testing (molecule-tests job — stays as a separate CI job)
molecule = [
"molecule==26.6.0",
"molecule-docker==2.1.0",
@@ -54,7 +55,7 @@ molecule = [
dev = [
"grm[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx==0.40.0",
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.1",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
@@ -107,7 +108,7 @@ repo_name = "grm"
ci_scripts_dir = ""
# Molecule test weights for LPT scheduling.
# GRM has a single role (gitea-runner) with 7 scenarios.
# 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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.18.0"
__version__ = "0.18.2"
+1 -1
View File
@@ -157,7 +157,7 @@ class AnsibleExecutor:
with open(log_file) as f:
content = f.read()
match = re.search(
r'TASK \[gitea-runner : Report runner status\].*?"msg":\s*"([^"]+)"',
r'TASK \[gitea_runner : Report runner status\].*?"msg":\s*"([^"]+)"',
content,
re.DOTALL,
)
+9 -9
View File
@@ -100,14 +100,14 @@ class RunnerManager:
extra_vars: dict[str, str | int] = {
"registration_token": token,
"runner_name": name,
"gitea_runner_name": name,
"gitea_url": gitea_url,
"gitea_runner_integration_retries": integration_retries,
}
if admin_token:
extra_vars["gitea_admin_token"] = admin_token
if labels is not None:
extra_vars["runner_labels"] = labels
extra_vars["gitea_runner_labels"] = labels
with track_steps() as tracker:
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
@@ -216,7 +216,7 @@ class RunnerManager:
"start-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -243,7 +243,7 @@ class RunnerManager:
"stop-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -270,7 +270,7 @@ class RunnerManager:
"restart-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -297,7 +297,7 @@ class RunnerManager:
"enable-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -331,7 +331,7 @@ class RunnerManager:
"disable-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
{"gitea_runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -358,7 +358,7 @@ class RunnerManager:
"status-runner.yml",
actual_host,
actual_user,
{"runner_name": name},
{"gitea_runner_name": name},
actual_key,
ask_become_pass,
description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host),
@@ -401,7 +401,7 @@ class RunnerManager:
"remove-runner.yml",
actual_host,
actual_user,
{"runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
{"gitea_runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
actual_key,
ask_become_pass,
description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host),
+2 -2
View File
@@ -70,7 +70,7 @@ class TestAnsibleExecutorRun:
def test_run_extracts_status(self, tmp_path: Path) -> None:
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"TASK [gitea_runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
@@ -89,7 +89,7 @@ class TestAnsibleExecutorExtractStatus:
def test_extract_status_found(self, tmp_path: Path) -> None:
executor = AnsibleExecutor()
log_content = (
"TASK [gitea-runner : Report runner status]\n"
"TASK [gitea_runner : Report runner status]\n"
"ok: [127.0.0.1] => {\n"
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
"}\n"
+11 -11
View File
@@ -49,7 +49,7 @@ class TestRunnerManager:
assert "--extra-vars" in cmd_str
assert "@/tmp/fake-vars.json" in cmd_str
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["runner_name"] == "192.168.1.10"
assert manager._captured_extra_vars["gitea_runner_name"] == "192.168.1.10"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Installing Gitea Runner on 192.168.1.10" in mock_executor.run.call_args.kwargs["description"]
mock_registry.add.assert_called_once_with(
@@ -75,7 +75,7 @@ class TestRunnerManager:
assert "--private-key" in cmd_str
assert "/key" in cmd_str
assert manager._captured_extra_vars["registration_token"] == "preset"
assert manager._captured_extra_vars["runner_name"] == "my-runner"
assert manager._captured_extra_vars["gitea_runner_name"] == "my-runner"
assert "--ask-become-pass" not in cmd_str
mock_registry.add.assert_called_once_with(
name="my-runner",
@@ -126,7 +126,7 @@ class TestRunnerManager:
gitea_url="https://git.example.com",
labels="docker:docker://alpine:latest",
)
assert manager._captured_extra_vars["runner_labels"] == "docker:docker://alpine:latest"
assert manager._captured_extra_vars["gitea_runner_labels"] == "docker:docker://alpine:latest"
def test_install_no_labels(self) -> None:
mock_registry = MagicMock()
@@ -145,7 +145,7 @@ class TestRunnerManager:
manager._executor = mock_executor
manager.install("host1", "root", token="tok", gitea_url="https://git.example.com", labels="")
assert manager._captured_extra_vars["runner_labels"] == ""
assert manager._captured_extra_vars["gitea_runner_labels"] == ""
def test_install_with_admin_token(self) -> None:
mock_registry = MagicMock()
@@ -265,7 +265,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "start-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Starting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_start_with_override(self) -> None:
@@ -304,7 +304,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "stop-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Stopping Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_restart(self) -> None:
@@ -318,7 +318,7 @@ class TestRunnerManager:
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 manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Restarting Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_enable(self) -> None:
@@ -332,7 +332,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "enable-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Enabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_disable(self) -> None:
@@ -351,7 +351,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "disable-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Disabling Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
@@ -396,7 +396,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "status-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert "Checking status of Gitea Runner r1 on host" in mock_executor.run.call_args.kwargs["description"]
def test_remove(self) -> None:
@@ -415,7 +415,7 @@ class TestRunnerManager:
cmd = mock_executor.run.call_args.args[0]
cmd_str = " ".join(cmd)
assert "remove-runner.yml" in cmd_str
assert manager._captured_extra_vars["runner_name"] == "r1"
assert manager._captured_extra_vars["gitea_runner_name"] == "r1"
assert manager._captured_extra_vars["registration_token"] == "tok"
assert manager._captured_extra_vars["gitea_url"] == "https://git.example.com"
assert "Removing Gitea Runner r1 from host" in mock_executor.run.call_args.kwargs["description"]