Compare commits

...
46 Commits
Author SHA1 Message Date
grm-ci-bot 2d2eaa3291 release: v0.18.7 [skip ci] 2026-08-06 09:30:18 +00:00
kiretoandemo 8176a62885 GRM-159: fix: move StartLimit to [Unit] and make prune timer reload conditional
Post-merge / detect-and-configure (push) Successful in 3m49s
Post-merge / release-and-maintain (push) Successful in 3m43s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-06 09:23:29 +00:00
gitea-actions-bot 443756a508 chore: update badge URLs to commit 36201d0d [skip ci] 2026-08-06 00:00:05 +00:00
grm-ci-bot 340222e041 release: v0.18.6 [skip ci] 2026-08-05 23:59:22 +00:00
kiretoandemo 179e47bbb2 GRM-158: fix: pre-configure daemon.json before rootless setuptool + add DBUS_SESSION_BUS_ADDRESS
Post-merge / detect-and-configure (push) Successful in 1m2s
Post-merge / release-and-maintain (push) Successful in 1m38s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 23:57:25 +00:00
gitea-actions-bot 68d16577b3 chore: update badge URLs to commit 169df915 [skip ci] 2026-08-05 20:21:20 +00:00
grm-ci-bot 38607d9f29 release: v0.18.5 [skip ci] 2026-08-05 20:20:43 +00:00
kiretoandemo 90139b306b GRM-157: fix: pin Docker 28.x + disable containerd snapshotter + tune prune/disk
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m31s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 20:18:38 +00:00
gitea-actions-bot dd475bec0d chore: update badge URLs to commit 83a5b577 [skip ci] 2026-08-05 13:53:01 +00:00
grm-ci-bot 0f0ada3576 release: v0.18.4 [skip ci] 2026-08-05 13:52:28 +00:00
emo 185e41c49e GRM-156: fix: harden rootless Docker daemon resilience on CI runners
Post-merge / detect-and-configure (push) Successful in 1m9s
Post-merge / release-and-maintain (push) Successful in 1m15s
2026-08-05 13:50:34 +00:00
gitea-actions-bot 103741b3ab chore: update badge URLs to commit 7136903b [skip ci] 2026-08-04 14:04:34 +00:00
grm-ci-bot b770d1debf release: v0.18.3 [skip ci] 2026-08-04 14:03:59 +00:00
gitea-adminandemo 2f11489be0 GRM-2: fix: switch default network driver to slirp4netns (pasta TCP RST bug)
Post-merge / detect-and-configure (push) Successful in 1m8s
Post-merge / release-and-maintain (push) Successful in 1m22s
Co-authored-by: oblachno Admin <admin@oblachno.oblachno.fyi>
2026-08-04 14:01:56 +00:00
gitea-actions-bot 8e9e58fedb chore: update badge URLs to commit 69e62973 [skip ci] 2026-07-17 02:03:11 +00:00
emil 70d985ebaa GRM-155: chore: bump devx to v0.47.2
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-17 02:00:23 +00:00
gitea-actions-bot 66f071b676 chore: update badge URLs to commit 7ee78d01 [skip ci] 2026-07-16 17:42:23 +00:00
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
grm-ci-bot 62b37d045e release: v0.18.0 [skip ci] 2026-07-12 01:14:36 +00:00
emil 390fcb9d4b GRM-144: feat(runner): enable IPv6 in rootless Docker via pasta network driver
Post-merge / detect-type (push) Waiting to run
Post-merge / configure-repo (push) Waiting to run
Post-merge / release (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / badges (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / vikunja (push) Waiting to run
Post-merge / publish (push) Waiting to run
2026-07-12 01:12:43 +00:00
gitea-actions-bot 9f8adf14bc chore: update badge URLs to commit 625e2176 [skip ci] 2026-07-11 23:58:43 +00:00
grm-ci-bot 92e4d2fd2b release: v0.17.2 [skip ci] 2026-07-11 23:57:11 +00:00
emil f30764d60f GRM-145: refactor: adopt devx v0.40.0
Post-merge / publish (push) Waiting to run
Post-merge / detect-type (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / release (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / vikunja (push) Waiting to run
Post-merge / configure-repo (push) Waiting to run
Post-merge / badges (push) Waiting to run
2026-07-11 23:55:15 +00:00
gitea-actions-bot a0e3fc09c9 chore: update badge URLs to commit c25e7760 [skip ci] 2026-07-09 11:57:18 +00:00
grm-ci-bot bb9e8e6c4b release: v0.17.1 [skip ci] 2026-07-09 11:56:36 +00:00
emil d8312ff62c GRM-143: fix: disable IPv6 in rootless Docker daemon on runners
Post-merge / detect-type (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / configure-repo (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / vikunja (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / release (push) Waiting to run
Post-merge / badges (push) Waiting to run
2026-07-09 11:54:18 +00:00
gitea-actions-bot 39a8f20df2 chore: update badge URLs to commit feb0219b [skip ci] 2026-07-08 21:15:24 +00:00
111 changed files with 1650 additions and 1154 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
+1 -1
View File
@@ -51,7 +51,7 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
CI_GITEA_USERNAME=your-gitea-username
# Role-based Gitea API tokens (devx 0.38.0+)
# Role-based Gitea API tokens (devx 0.40.0+)
# DEVELOPER_GITEA_API_TOKEN is used by local `grm trigger-workflow` and `make create-pr`.
# CI_GITEA_API_TOKEN is used by CI workflows (and accepted as a fallback for local tools).
# REVIEWER_GITEA_API_TOKEN is used by CI to post APPROVE reviews; it must belong to a
+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 }}"
+12
View File
@@ -0,0 +1,12 @@
extends: existence
message: "Don't attribute human qualities to software or hardware ('%s')."
link: https://developers.google.com/style/anthropomorphism
level: suggestion
ignorecase: true
# Limited to the two verbs the guide itself names. Broader lists (wants, knows,
# thinks) can't tell a software subject from a human one: on a 950-file corpus
# they produced 8 false positives ('the customer wants', 'your audience knows')
# for every 2 real ones.
tokens:
- sees
- tells
+7 -2
View File
@@ -1,8 +1,13 @@
extends: existence
message: "'%s' should be in lowercase."
link: 'https://developers.google.com/style/colons'
nonword: true
level: warning
scope: sentence
# The match is the word itself, not ': X', and `nonword` is off. Both are
# required for a project Vocab to work: Vale compares accept.txt entries
# against the matched text, and `nonword: true` opts out of that entirely.
# So a proper noun after a colon can be exempted by adding it to accept.txt.
# The guide's other exemption, notice labels, is handled by the lookbehinds;
# headings are already excluded by `scope: sentence`. See issue #20.
tokens:
- '(?<!:[^ ]+?):\s[A-Z]'
- '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
+1 -1
View File
@@ -6,4 +6,4 @@ level: error
nonword: true
tokens:
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)|May|Jun(?:e)|Jul(?:y)|Aug(?:ust)|Sep(?:tember)?|Oct(?:ober)|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
+14
View File
@@ -0,0 +1,14 @@
extends: existence
message: "Avoid the unverifiable claim '%s'."
link: https://developers.google.com/style/excessive-claims
level: suggestion
ignorecase: true
# The guide also names 'never', 'always', and 'ensure', but in technical writing
# those are usually legitimate instructions ('never commit secrets') rather than
# product claims: they accounted for 125 of 142 hits on a 950-file corpus.
# 'best practices' is a fixed term, not a superlative.
tokens:
- 'best(?! practices?)'
- simplest
- fastest
- guarantees?
+6 -4
View File
@@ -3,11 +3,13 @@ message: "Avoid first-person pronouns such as '%s'."
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
ignorecase: true
level: warning
nonword: true
# The 'I' tokens use lookaround rather than consuming the surrounding
# whitespace. Matching ' I ' made the alert span cover both spaces, which shows
# up as a too-wide underline in editors, and read as "such as ' I '". Dropping
# `nonword` also lets a project Vocab apply, which it can't when set. See PR #50.
tokens:
- (?:^|\s)I\s
- (?:^|\s)I,\s
- \bI'm\b
- '(?<=^|\s)I(?=[\s,])'
- "\\bI'm\\b"
- \bme\b
- \bmy\b
- \bmine\b
+5 -2
View File
@@ -4,8 +4,11 @@ link: "https://developers.google.com/style/capitalization#capitalization-in-titl
level: warning
scope: heading
match: $sentence
indicators:
- ":"
# No `indicators: [":"]` here. That makes Vale require a capital after a colon,
# which is the Microsoft convention this rule was originally copied from. This
# guide says the opposite: "the first word after a colon is generally
# lowercase" (developers.google.com/style/colons), and Colons.yml enforces
# exactly that. See issue #58.
exceptions:
- Azure
- CLI
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid the jargon '%s'."
link: https://developers.google.com/style/jargon
level: suggestion
ignorecase: true
# The guide also cites 'solution', 'support', and 'workload' as overloaded
# terms, but those have ordinary technical meanings and accounted for every hit
# on a 950-file corpus, so only the unambiguous figurative terms are listed.
tokens:
- break-glass
- camel ?case
- out-of-the-box
- swim ?lane
+6 -2
View File
@@ -6,6 +6,10 @@ level: error
nonword: true
action:
name: replace
# The delimiter is a lookahead so the replacement doesn't swallow the comma or
# space that follows (issue #18). `$` is included so the abbreviation is still
# caught at the end of a heading, table cell, or block, which accounted for 8
# of 10 occurrences on a 950-file corpus.
swap:
'\b(?:eg|e\.g\.)(?=[\s,;])': for example
'\b(?:ie|i\.e\.)(?=[\s,;])': that is
'\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
'\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
+22 -1
View File
@@ -3,5 +3,26 @@ message: "Use the Oxford comma in '%s'."
link: 'https://developers.google.com/style/commas'
scope: sentence
level: warning
nonword: true
# List items may be several words long, not just one. Four guards keep the
# false-positive rate down:
#
# 1. The comma can't be the one closing a fronted subordinate clause
# ('When your alarm rings, you turn it off and tumble out of bed.') --
# that comma separates clauses, not list items. Only the first comma of
# such a sentence is exempt, so 'When it rains, apples, pears or bananas
# get wet.' is still caught.
# 2. The item can't open with a clause-introducer (', which ...',
# ', specifically ...').
# 3. The item can't open with a subject pronoun followed by a verb, which
# marks a compound predicate rather than a list ('..., you walk to the
# fridge and get a snack.'). A pronoun directly followed by 'and'/'or'
# is a real list item, so ', you and me.' still matches.
# 4. Neither item may contain an auxiliary verb, which is another compound
# predicate signal (', it has some downsides and is officially
# discouraged.').
#
# The trailing anchor allows end-of-scope so list fragments ('Apples, pears
# or bananas') are still caught.
tokens:
- '(?:[^,]+,){1,}\s\w+\s(?:and|or)'
- '(?<!^(?i:when|whenever|while|if|unless|until|although|though|because|since|after|before|once|whereas|whether|as)\b[^,]{0,80}),\s(?!(?:which|who|whom|whose|that|where|when|while|because|since|although|though|if|unless|so|but|and|or|however|therefore|thus|specifically|especially|namely|then|take|see|note|consider|make|use|either|neither)\b)(?!(?i:i|you|we|they|he|she|it)\s+(?!(?:and|or)\b))(?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+ (?:and|or) (?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+(?:[.?!]|$)'
+9 -1
View File
@@ -3,5 +3,13 @@ message: "Use parentheses judiciously."
link: 'https://developers.google.com/style/parentheses'
nonword: true
level: suggestion
# `[^)]` rather than `.+`: a greedy match ran from the first '(' on a line to
# the last ')', so 'Text (one) and more (two).' produced a single alert
# covering everything between them. See issue #30.
# A bare 3-5 letter acronym is skipped: Acronyms.yml requires acronyms to be
# defined as 'Spelled Out Term (ACRONYM)', so flagging those parentheses would
# put the two rules in direct conflict. The acronym has to be the whole
# parenthetical — '(NASA rocket program)' is an ordinary aside and still
# flags. Length matches the {3,5} in Acronyms.yml. See PR #59.
tokens:
- '\(.+\)'
- '\((?![A-Z]{3,5}\))[^)]+\)'
+13
View File
@@ -0,0 +1,13 @@
extends: existence
message: "Avoid time-based words like '%s' in product documentation."
link: https://developers.google.com/style/timeless-documentation
level: suggestion
ignorecase: true
# The guide also names 'now' and 'new', but both have common senses that aren't
# time-anchored ('create a new project'): adding them took a 950-file corpus of
# technical documentation from 14 hits to 117. 'recently' is left out too — every
# hit in that corpus was the UI idiom 'recently used'.
tokens:
- currently
- latest
- soon
+4 -2
View File
@@ -4,5 +4,7 @@ link: "https://developers.google.com/style/units-of-measure"
nonword: true
level: error
tokens:
- \b\d+(?:B|kB|MB|GB|TB)
- \b\d+(?:ns|ms|s|min|h|d)
- '\b\d+(?:B|kB|MB|GB|TB)\b'
- '\b\d+(?:ns|ms|min|h|d)\b'
# Seconds are split out so a decade ('1990s') isn't read as a unit.
- '\b\d+s\b(?<!\b(?:19|20)\d\ds\b)'
+3 -54
View File
@@ -2,79 +2,28 @@ extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
# Case matters here: each key's own capitalization is what's being corrected,
# so ignorecase would make these match their own replacements. The rest of the
# word list lives in WordListCase.yml.
ignorecase: false
action:
name: replace
swap:
"(?:API Console|dev|developer) key": API key
"(?:cell ?phone|smart ?phone)": phone|mobile phone
"(?:dev|developer|APIs) console": API console
"(?:e-mail|Email|E-mail)": email
"(?:file ?path|path ?name)": path
"(?:kill|terminate|abort)": stop|exit|cancel|end
"(?:OAuth ?2|Oauth)": OAuth 2.0
"(?:ok|Okay)": OK|okay
"(?:WiFi|wifi)": Wi-Fi
'[\.]+apk': APK
'3\-D': 3D
'Google (?:I\-O|IO)': Google I/O
"tap (?:&|and) hold": touch & hold
"un(?:check|select)": clear
above: preceding
account name: username
action bar: app bar
admin: administrator
Ajax: AJAX
a\.k\.a|aka: or|also known as
Android device: Android-powered device
android: Android
API explorer: APIs Explorer
application: app
approx\.: approximately
authN: authentication
authZ: authorization
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
CLI: command-line tool
click on: click|click in
Cloud: Google Cloud Platform|GCP
Container Engine: Kubernetes Engine
content type: media type
curated roles: predefined roles
data are: data is
Developers Console: Google API Console|API Console
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
Google account: Google Account
Google accounts: Google Accounts
Googling: search with Google
grayed-out: unavailable
HTTPs: HTTPS
in order to: to
ingest: import|load
k8s: Kubernetes
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
SHA1: SHA-1|HAS-SHA1
sign into: sign in to
sign-?on: single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
touch: tap
url: URL
vs\.: versus
World Wide Web: web
+68
View File
@@ -0,0 +1,68 @@
extends: substitution
message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list"
level: warning
# The case-insensitive half of the word list, so sentence-initial use is caught
# ('Touch the screen', not only 'touch the screen'). Entries that must stay
# case-sensitive are in WordList.yml.
ignorecase: true
action:
name: replace
swap:
"(?:API Console|dev|developer) key": API key
"(?:cell ?phone|smart ?phone)": phone|mobile phone
"(?:dev|developer|APIs) console": API console
"(?:e-mail|Email|E-mail)": email
"(?:file ?path|path ?name)": path
"(?:kill|terminate|abort)": stop|exit|cancel|end
# Longest form first: with the shortest alternative leading, 'OAuth 2' matched
# only 'OAuth', so applying the suggestion produced 'OAuth 2.0 2'. The rule is
# already case-insensitive, so the inline (?i) is redundant. See issue #41.
'\bOauth2\.0\b|\bOAuth ?2\b(?!\.0)|\bOauth\b(?! ?2)': OAuth 2.0
"(?:ok|Okay)": OK|okay
"(?:WiFi|wifi)": Wi-Fi
'[\.]+apk': APK
'3\-D': 3D
'Google (?:I\-O|IO)': Google I/O
"tap (?:&|and) hold": touch & hold
"un(?:check|select)": clear
above: preceding
account name: username
action bar: app bar
admin: administrator
a\.k\.a|aka: or|also known as
application: app
approx\.: approximately
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
click on: click|click in
content type: media type
curated roles: predefined roles
data are: data is
disabled?: turn off|off
ephemeral IP address: ephemeral external IP address
fewer data: less data
file name: filename
firewalls: firewall rules
functionality: capability|feature
grayed-out: unavailable
in order to: to
ingest: import|load
long press: touch & hold
network IP address: internal IP address
omnibox: address bar
open-source: open source
overview screen: recents screen
regex: regular expression
sign into: sign in to
'(?<!single )sign-?on': single sign-on
static IP address: static external IP address
stylesheet: style sheet
synch: sync
tablename: table name
tablet: device
'touch(?! ?(?:&|and) hold)': tap
vs\.: versus
+60 -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
- **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
@@ -84,6 +84,12 @@ as a defense-in-depth measure, but branch protection is the primary gate.
### 1. Create Vikunja Task
Create a task in Vikunja project 6 via `make create-task -- --title "Task title" --description "<h2>...</h2>"` (requires `VIKUNJA_TOKEN` in `.env`). This prints the `GRM-N` identifier and next-step instructions.
**IMPORTANT:** The task title must NOT include the `GRM-N:` prefix.
The `make create-pr` and `check_auto_merge_ready` commands automatically
prepend `GRM-N: ` to the Vikunja task title when forming the PR title.
If the Vikunja task title already includes the prefix, the PR title will
have a double prefix and auto-merge validation will fail.
### 2. Create Branch
```bash
git checkout master && git pull
@@ -117,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:
@@ -172,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
@@ -191,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.
@@ -218,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
@@ -379,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
@@ -487,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
@@ -536,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
@@ -566,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 |
@@ -576,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 |
@@ -602,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.
+60
View File
@@ -2,6 +2,66 @@
All notable changes to this project will be documented in this file.
## [0.18.7] - 2026-08-06
### Bug Fixes
- Move StartLimit to [Unit] and make prune timer reload conditional
## [0.18.6] - 2026-08-05
### Bug Fixes
- Pre-configure daemon.json before rootless setuptool + add DBUS_SESSION_BUS_ADDRESS
## [0.18.5] - 2026-08-05
### Bug Fixes
- Pin Docker 28.x + disable containerd snapshotter + tune prune/disk
## [0.18.4] - 2026-08-05
### Bug Fixes
- Harden rootless Docker daemon resilience on CI runners
## [0.18.3] - 2026-08-04
### Bug Fixes
- Switch default network driver to slirp4netns (pasta TCP RST bug)
## [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
- *(runner)* Enable IPv6 in rootless Docker via pasta network driver
## [0.17.2] - 2026-07-11
### Refactor
- Adopt devx v0.40.0
## [0.17.1] - 2026-07-09
### Bug Fixes
- Disable IPv6 in rootless Docker daemon on runners
## [0.17.0] - 2026-07-08
### Features
+6 -5
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
@@ -87,7 +88,7 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
_TOKEN="$$CI_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
if [ -n "$$_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$${_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
if [ -n "$$_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$${_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; git config --global url."https://$$CI_GITEA_USERNAME:$${_TOKEN}@git.oblachno.oblachno.fyi/".insteadOf "https://git.oblachno.oblachno.fyi/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
@@ -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/b91226026139acdf8530c66164c913b824faf164/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/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,55 +0,0 @@
---
gitea_runner_version: "1.0.8"
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
skip_runner_registration: false
# Per-runner user (rootless isolation)
gitea_runner_user_prefix: "grm-"
gitea_runner_base_home: "/home"
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ runner_name }}"
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# Base paths (instance-scoped via runner_name)
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner"
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
gitea_runner_binary_path: "/usr/local/bin/gitea_runner"
# Prune configuration
gitea_runner_prune_until: "24h"
gitea_runner_prune_schedule: "daily"
gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Health check configuration
gitea_runner_healthcheck_interval: "5min"
gitea_runner_healthcheck_boot_delay: "2min"
gitea_runner_healthcheck_disk_threshold: 85
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
# Admin token for runner deregistration via Gitea API.
# If not set, falls back to registration_token (which likely lacks admin scope).
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
gitea_admin_token: ""
# Removal defaults
remove_systemd_template: true
remove_runner_user: true
# Runner configuration
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# Docker installation (for rootless dependencies)
docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
docker_apt_source_line: >-
deb [arch={{ docker_apt_arch }} signed-by={{ docker_gpg_key_path }}]
https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
{{ ansible_facts['distribution_release'] }} stable
# Set to false in CI/molecule to skip rootless daemon startup (needs kernel userns)
docker_rootless_setup: true
@@ -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,112 +0,0 @@
---
- name: Ensure keyrings directory exists (Debian/Ubuntu)
ansible.builtin.file:
path: "/etc/apt/keyrings"
state: directory
mode: "0755"
when: ansible_facts['os_family'] == 'Debian'
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
ansible.builtin.shell: |
set -o pipefail
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
args:
creates: "{{ docker_gpg_key_path }}"
executable: /bin/bash
when: ansible_facts['os_family'] == 'Debian'
- name: Add Docker APT repository (Debian/Ubuntu)
ansible.builtin.copy:
dest: /etc/apt/sources.list.d/docker.list
content: "{{ docker_apt_source_line }}\n"
mode: "0644"
register: docker_apt_repo
when: ansible_facts['os_family'] == 'Debian'
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
ansible.builtin.apt:
update_cache: true
when:
- ansible_facts['os_family'] == 'Debian'
- docker_apt_repo is changed
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
name:
- uidmap
- slirp4netns
- fuse-overlayfs
- docker-ce
- docker-ce-cli
- docker-ce-rootless-extras
- containerd.io
- docker-compose-plugin
- rsync
state: present
when: ansible_facts['os_family'] == 'Debian'
- name: Update pacman cache (Arch Linux)
community.general.pacman:
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
changed_when: false
- name: Install rootless Docker dependencies (Arch Linux)
community.general.pacman:
name:
- docker
- docker-compose
- slirp4netns
- fuse-overlayfs
- rsync
state: present
when: ansible_facts['os_family'] == 'Archlinux'
- name: Check if rootless Docker is already set up
ansible.builtin.stat:
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
register: rootless_docker_check
- name: Set up rootless Docker for runner user
ansible.builtin.command: dockerd-rootless-setuptool.sh install
args:
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
when:
- docker_rootless_setup
- not rootless_docker_check.stat.exists
- name: Start rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user start docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when: docker_rootless_setup
- name: Wait for rootless Docker daemon to be ready
ansible.builtin.command: docker version
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_ready
until: docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: docker_rootless_setup
@@ -1,30 +0,0 @@
---
- name: Create systemd user service file
ansible.builtin.template:
src: gitea-runner-user.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
- name: Reload systemd user daemon
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
@@ -0,0 +1,103 @@
---
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 }}{{ gitea_runner_name }}"
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
# 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 }}/{{ 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
gitea_runner_prune_until: "24h"
# Every 6 hours — daily is insufficient for CI runners that build dozens
# of images per day. Accumulation between daily runs can trigger Docker
# daemon instability (containerd snapshotter GC holds locks, blocking
# container operations).
gitea_runner_prune_schedule: "*-*-* 00/6:00:00"
gitea_runner_prune_label: "gitea-runner=true"
# Service configuration
gitea_runner_service_restart_sec: "5"
# Health check configuration
# 2min interval — catches hung daemons before multiple CI jobs fail between checks.
# The previous 5min interval was too coarse: a stuck daemon could fail 3+ molecule
# jobs in the window between healthcheck runs.
gitea_runner_healthcheck_interval: "2min"
gitea_runner_healthcheck_boot_delay: "2min"
gitea_runner_healthcheck_disk_threshold: 75
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
# Docker daemon resilience settings (applied to daemon.json).
# live-restore: containers survive daemon restarts — prevents stuck container
# states when the healthcheck restarts a hung daemon.
# shutdown-timeout: grace period (seconds) for containers to stop on daemon
# shutdown/restart. Default 15s is too short for DinD containers with nested
# processes (molecule tests). 30s gives SIGTERM time to propagate.
# max-concurrent-downloads/uploads: limits parallel transfers to reduce daemon
# memory pressure when multiple CI jobs pull images simultaneously.
# default-ulimits: prevents FD exhaustion in container processes.
gitea_runner_docker_live_restore: true
gitea_runner_docker_shutdown_timeout: 30
gitea_runner_docker_max_concurrent_downloads: 3
gitea_runner_docker_max_concurrent_uploads: 3
gitea_runner_docker_default_nofile: 65536
# 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_runner_admin_token: ""
# Removal defaults
gitea_runner_remove_systemd_template: true
gitea_runner_remove_user: true
# Runner configuration
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# Docker installation (for rootless dependencies)
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)
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: "slirp4netns" (default) or "pasta" (IPv6 support)
# slirp4netns is the default because pasta has a TCP proxy bug that sends RST
# packets with wrong sequence numbers, breaking TCP connections from Docker
# containers to external hosts. slirp4netns doesn't have IPv6 support.
# See: https://bugs.passt.top/show_bug.cgi?id=52
gitea_runner_docker_rootless_net_driver: "slirp4netns"
# IPv6 subnet for rootless Docker containers (ULA range, not routable on internet)
gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
# Pre-pull Docker images that CI runners need (avoids pulling on every CI run).
# The runner container image (ci-full) is large (~3.3GB) and the healthcheck's
# disk-space prune only removes dangling images, so pre-pulled tagged images persist.
# Set to [] to skip pre-pulling. Images are pulled as the runner user via rootless Docker.
gitea_runner_pre_pull_images: []
@@ -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:
@@ -49,6 +49,9 @@
- "'Type=oneshot' in prune_service.content | b64decode"
- "'docker system prune' in prune_service.content | b64decode"
- "'docker volume prune' in prune_service.content | b64decode"
- "'docker container prune' in prune_service.content | b64decode"
- "'docker network prune' in prune_service.content | b64decode"
- "'docker builder prune' in prune_service.content | b64decode"
fail_msg: "Prune service template is missing expected directives"
- name: Read rendered prune timer template
@@ -99,8 +102,26 @@
ansible.builtin.assert:
that:
- "'docker info' in healthcheck_script.content | b64decode"
- "'timeout 10 docker info' in healthcheck_script.content | b64decode"
- "'systemctl --user restart docker.service' in healthcheck_script.content | b64decode"
- "'systemctl --user restart gitea-runner.service' in healthcheck_script.content | b64decode"
- "'docker system prune' in healthcheck_script.content | b64decode"
- "'docker container prune' in healthcheck_script.content | b64decode"
- "'docker network prune' in healthcheck_script.content | b64decode"
- "'status=removing' in healthcheck_script.content | b64decode"
- "'status=stopping' in healthcheck_script.content | b64decode"
- "'docker rm -f' in healthcheck_script.content | b64decode"
- "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
fail_msg: "Healthcheck script template is missing expected content"
- name: Assert healthcheck script does NOT use aggressive prune (-af)
ansible.builtin.assert:
that:
- "'prune -af' not in healthcheck_script.content | b64decode"
- "'image prune -af' not in healthcheck_script.content | b64decode"
- "'system prune -af' not in healthcheck_script.content | b64decode"
- "'volume prune -af' not in healthcheck_script.content | b64decode"
fail_msg: >-
Healthcheck script uses 'prune -af' which removes ALL images
(including tagged runner images like ci-full). Use 'prune -f'
(dangling only) to preserve tagged images.
@@ -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,59 @@
---
- 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) }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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
@@ -29,10 +29,11 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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
@@ -40,7 +41,8 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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,106 @@
---
- 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: Wait for runner user service to be 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 }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
register: gitea_runner_service_check
changed_when: false
retries: 10
delay: 2
until: gitea_runner_service_check.stdout | default('') | trim == 'active'
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."
@@ -17,6 +17,9 @@
- name: Include healthcheck setup
ansible.builtin.include_tasks: healthcheck.yml
- name: Include pre-pull images
ansible.builtin.include_tasks: pre_pull_images.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not skip_runner_registration
when: not gitea_runner_skip_registration
@@ -0,0 +1,28 @@
---
# Pre-pull Docker images that CI runners need to avoid pulling them on
# every CI run. The runner container image (ci-full) is large (~3.3GB)
# and pulling it on every run causes timeouts and disk pressure.
#
# The healthcheck script's disk-space prune only removes dangling images
# (not tagged ones), so pre-pulled images persist between CI runs.
#
# Set gitea_runner_pre_pull_images to a list of image refs to pull, or
# empty list to skip pre-pulling.
- name: Pre-pull Docker images for CI runner
ansible.builtin.command: "docker pull {{ item }}"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
register: gitea_runner_pre_pull_result
changed_when: "'Status: Downloaded' in gitea_runner_pre_pull_result.stdout or 'Status: Downloaded' in gitea_runner_pre_pull_result.stderr"
retries: 3
delay: 5
until: gitea_runner_pre_pull_result is success
loop: "{{ gitea_runner_pre_pull_images }}"
when:
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_pull_images | length > 0
@@ -6,6 +6,7 @@
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_prune_service
- name: Create docker-prune user timer file
ansible.builtin.template:
@@ -14,6 +15,7 @@
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_prune_timer
- name: Reload systemd user daemon for prune timer
ansible.builtin.command: systemctl --user daemon-reload
@@ -21,10 +23,12 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- systemd_available.stat.exists
- docker_rootless_setup
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_prune_service is changed or gitea_runner_prune_timer is changed
- name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
@@ -32,7 +36,8 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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 }}"
@@ -26,8 +26,9 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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:
@@ -0,0 +1,327 @@
---
- name: Ensure keyrings directory exists (Debian/Ubuntu)
ansible.builtin.file:
path: "/etc/apt/keyrings"
state: directory
mode: "0755"
when: ansible_facts['os_family'] == 'Debian'
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
ansible.builtin.shell: |
set -o pipefail
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" \
| gpg --dearmor --yes -o {{ gitea_runner_docker_gpg_key_path }}
args:
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: "{{ gitea_runner_docker_apt_source_line }}\n"
mode: "0644"
register: gitea_runner_docker_apt_repo
when: ansible_facts['os_family'] == 'Debian'
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
ansible.builtin.apt:
update_cache: true
when:
- ansible_facts['os_family'] == 'Debian'
- gitea_runner_docker_apt_repo is changed
# Install Docker packages from the upstream Docker APT repository.
# We do NOT pin to 28.x because recent Ubuntu releases (e.g. 26.04/plucky)
# may not have 28.x packages in the Docker repo, and Docker 29 is safe
# for rootless mode when the daemon.json disables the containerd snapshotter
# and sets a conservative default nofile ulimit (see daemon.json tasks below).
- name: Install rootless Docker dependencies (Debian/Ubuntu)
ansible.builtin.apt:
name:
- uidmap
- slirp4netns
- passt
- fuse-overlayfs
- docker-ce
- docker-ce-cli
- docker-ce-rootless-extras
- containerd.io
- docker-compose-plugin
- rsync
state: present
register: gitea_runner_docker_install
when: ansible_facts['os_family'] == 'Debian'
- name: Update pacman cache (Arch Linux)
community.general.pacman:
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
changed_when: false
- name: Install rootless Docker dependencies (Arch Linux)
community.general.pacman:
name:
- docker
- docker-compose
- slirp4netns
- 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: 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' if gitea_runner_docker_rootless_net_driver == 'pasta' else 'builtin' }}"
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
{% endif %}
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
# Write daemon.json BEFORE the setuptool starts dockerd, so Docker 29
# starts with containerd snapshotter disabled from the very first boot.
# Without this, Docker 29 uses containerd snapshots by default, which
# causes instability in rootless mode.
- name: Ensure Docker config directory exists (pre-setup)
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/docker"
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 daemon.json (disable containerd snapshotter)
ansible.builtin.copy:
dest: "{{ gitea_runner_home }}/.config/docker/daemon.json"
content: |
{
"live-restore": {{ gitea_runner_docker_live_restore | to_json }},
"shutdown-timeout": {{ gitea_runner_docker_shutdown_timeout }},
"max-concurrent-downloads": {{ gitea_runner_docker_max_concurrent_downloads }},
"max-concurrent-uploads": {{ gitea_runner_docker_max_concurrent_uploads }},
"default-ulimits": {
"nofile": {"Name": "nofile", "Hard": {{ gitea_runner_docker_default_nofile }}, "Soft": {{ gitea_runner_docker_default_nofile }}}
},
"features": {
"containerd-snapshotter": false
},
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
"ipv6": true,
"ip6tables": true,
"fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}",
"dns": ["10.0.2.3", "8.8.8.8"]
{% else %}
"ipv6": false,
"dns": ["8.8.8.8", "1.1.1.1"]
{% endif %}
}
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
args:
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
DOCKERD_ROOTLESS_ROOTLESSKIT_NET: "{{ gitea_runner_docker_rootless_net_driver }}"
when:
- 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
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when: gitea_runner_docker_rootless_setup
- name: Enable rootless Docker daemon (systemd user service)
ansible.builtin.command: systemctl --user enable docker
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when: gitea_runner_docker_rootless_setup
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: "{{ gitea_runner_home }}/.config/docker"
state: directory
mode: "0755"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
when: gitea_runner_docker_rootless_setup
- name: Configure rootless Docker network driver
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' if gitea_runner_docker_rootless_net_driver == 'pasta' else 'builtin' }}"
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
{% endif %}
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
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
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when:
- gitea_runner_docker_rootless_setup
- gitea_runner_docker_network_override is changed
- name: Configure rootless Docker daemon
ansible.builtin.copy:
dest: "{{ gitea_runner_home }}/.config/docker/daemon.json"
content: |
{
"live-restore": {{ gitea_runner_docker_live_restore | to_json }},
"shutdown-timeout": {{ gitea_runner_docker_shutdown_timeout }},
"max-concurrent-downloads": {{ gitea_runner_docker_max_concurrent_downloads }},
"max-concurrent-uploads": {{ gitea_runner_docker_max_concurrent_uploads }},
"default-ulimits": {
"nofile": {"Name": "nofile", "Hard": {{ gitea_runner_docker_default_nofile }}, "Soft": {{ gitea_runner_docker_default_nofile }}}
},
"features": {
"containerd-snapshotter": false
},
{% if gitea_runner_docker_rootless_net_driver == 'pasta' %}
"ipv6": true,
"ip6tables": true,
"fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}",
"dns": ["10.0.2.3", "8.8.8.8"]
{% else %}
"ipv6": false,
"dns": ["8.8.8.8", "1.1.1.1"]
{% endif %}
}
mode: "0644"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
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
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
changed_when: true
when:
- 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
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid }}/bus"
register: gitea_runner_docker_ready
until: gitea_runner_docker_ready.rc == 0
retries: 10
delay: 2
changed_when: false
when: gitea_runner_docker_rootless_setup
@@ -0,0 +1,47 @@
---
- name: Create systemd user service file
ansible.builtin.template:
src: gitea-runner-user.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_service_file
- name: Reload systemd user daemon
ansible.builtin.command: systemctl --user daemon-reload
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_service_file is changed
- name: Restart gitea-runner if service file changed
ansible.builtin.command: systemctl --user restart gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_service_file is changed
- name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- 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
@@ -8,7 +8,8 @@
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
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,7 @@
environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
register: docker_version_output
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
register: gitea_runner_docker_version_output
changed_when: false
when: docker_rootless_setup
when: gitea_runner_docker_rootless_setup
@@ -7,3 +7,6 @@ Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}"
ExecStart=/usr/bin/docker container prune -f
ExecStart=/usr/bin/docker network prune -f
ExecStart=/usr/bin/docker builder prune -f
@@ -3,6 +3,8 @@ Description=Gitea Actions Runner (rootless)
After=docker.service
Requires=docker.service
PartOf=docker.service
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
@@ -14,8 +16,6 @@ ExecStop=/bin/kill -TERM $MAINPID
TimeoutStopSec=30
Restart=always
RestartSec={{ gitea_runner_service_restart_sec }}
StartLimitIntervalSec=300
StartLimitBurst=10
[Install]
WantedBy=default.target
@@ -7,18 +7,29 @@ DOCKER_HOST="unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}"
export DOCKER_HOST XDG_RUNTIME_DIR
# 1. Check Docker daemon responsiveness
if ! docker info >/dev/null 2>&1; then
echo "ERROR: Docker daemon not responding at ${DOCKER_HOST}"
# 1. Check Docker daemon responsiveness (with timeout — a bare `docker info` can
# hang indefinitely on a stuck daemon, blocking the healthcheck itself).
if ! timeout 10 docker info >/dev/null 2>&1; then
echo "ERROR: Docker daemon not responding at ${DOCKER_HOST} (timed out after 10s)"
systemctl --user restart docker.service
sleep 3
if ! docker info >/dev/null 2>&1; then
if ! timeout 10 docker info >/dev/null 2>&1; then
echo "CRITICAL: Docker daemon still down after restart"
exit 1
fi
echo "RECOVERED: Docker daemon restarted successfully"
fi
# 1b. Clean up stuck containers — containers in "removing" or "stopping" state
# for too long cause "cannot kill container: did not receive an exit event"
# errors in molecule destroy phases. Force-remove them so subsequent CI jobs
# don't inherit the stuck state.
stuck_containers=$(timeout 10 docker ps -a --filter "status=removing" --filter "status=stopping" --format '{% raw %}{{.ID}}{% endraw %}' 2>/dev/null || true)
if [[ -n "$stuck_containers" ]]; then
echo "WARN: Found stuck containers (removing/stopping), force-cleaning"
echo "$stuck_containers" | xargs -r docker rm -f 2>/dev/null || true
fi
# 2. Check gitea-runner service is active
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then
@@ -37,10 +48,13 @@ fi
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources"
docker system prune -af --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true
docker volume prune -af --filter "label={{ gitea_runner_prune_label }}" || true
# Also prune dangling images (no label)
docker image prune -af || true
docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true
docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" || true
# Only prune dangling (untagged) images — keep tagged runner images (ci-full, ci-quality)
docker image prune -f || true
# Clean up stopped containers and dangling networks that accumulate from failed jobs
docker container prune -f || true
docker network prune -f || true
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after prune: ${disk_pct}%"
fi
+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/b91226026139acdf8530c66164c913b824faf164/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/b91226026139acdf8530c66164c913b824faf164/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/36201d0d5738f90a181519b2d56262d36fd04005/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.
+26 -7
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"]
@@ -173,11 +173,30 @@ Each runner operates under a dedicated unprivileged system user. The Docker daem
- User namespace mapping via `/etc/subuid` and `/etc/subgid` (range: 100000-165535)
- Rootless Docker socket at `/run/user/<UID>/docker.sock`
- `slirp4netns` for user-mode networking
- `pasta` for user-mode networking with IPv6 support (replaces `slirp4netns`, which lacks outgoing IPv6)
- `fuse-overlayfs` for rootless container storage
The rootless Docker daemon is configured via a systemd user override
(`docker.service.d/override.conf`) that sets:
- `DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta` — use pasta as the network driver
- `DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit` — pasta's native port forwarding
- `DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6` — enable IPv6 routing
The daemon.json enables IPv6 with a ULA subnet (`fd00:dead:beef::/48`)
for container addressing. This ensures runner containers can reach
both IPv4 and IPv6 services (e.g., the Gitea registry) without
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

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