Compare commits

...
46 Commits
Author SHA1 Message Date
grm-ci-bot 340222e041 release: v0.18.6 [skip ci] 2026-08-05 23:59:22 +00:00
kireto 179e47bbb2 GRM-158: fix: pre-configure daemon.json before rootless setuptool + add DBUS_SESSION_BUS_ADDRESS
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
kireto 90139b306b GRM-157: fix: pin Docker 28.x + disable containerd snapshotter + tune prune/disk
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 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-admin 2f11489be0 GRM-2: fix: switch default network driver to slirp4netns (pasta TCP RST bug)
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 / release-and-maintain (push) Failing after 512h5m52s
Post-merge / detect-and-configure (push) Failing after 512h7m25s
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 / release-and-maintain (push) Failing after 520h26m59s
Post-merge / detect-and-configure (push) Failing after 520h28m23s
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 / release-and-maintain (push) Failing after 522h9m48s
Post-merge / detect-and-configure (push) Failing after 522h10m55s
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 / release-and-maintain (push) Failing after 522h23m40s
Post-merge / detect-and-configure (push) Failing after 522h24m46s
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) Failing after 572h27m49s
Post-merge / detect-and-configure (push) Failing after 572h28m47s
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 / release-and-maintain (push) Failing after 583h59m44s
Post-merge / detect-and-configure (push) Failing after 584h0m42s
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 / release-and-maintain (push) Failing after 584h51m23s
Post-merge / detect-and-configure (push) Failing after 584h52m20s
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 / release-and-maintain (push) Failing after 606h42m20s
Post-merge / detect-and-configure (push) Failing after 606h43m22s
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) Failing after 614h5m52s
Post-merge / release-and-maintain (push) Failing after 614h4m47s
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 / release-and-maintain (push) Failing after 632h13m15s
Post-merge / detect-and-configure (push) Failing after 632h14m17s
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 / validate-commit-msg (push) Canceled after 632h56m33s
Post-merge / release (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / badges (push) Waiting to run
Post-merge / vikunja (push) Waiting to run
Post-merge / configure-repo (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 / vikunja (push) Failing after 634h13m1s
Post-merge / release (push) Failing after 634h13m56s
Post-merge / detect-type (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / badges (push) Waiting to run
Post-merge / configure-repo (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 / vikunja (push) Failing after 694h14m48s
Post-merge / release (push) Failing after 694h14m52s
Post-merge / detect-type (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / badges (push) Waiting to run
Post-merge / configure-repo (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
grm-ci-bot 240f08fff4 release: v0.17.0 [skip ci] 2026-07-08 21:14:47 +00:00
emil d38a64b0e1 GRM-142: feat: bump devx to 0.38.0 and migrate to role-based Gitea tokens
Post-merge / vikunja (push) Failing after 708h55m19s
Post-merge / release (push) Failing after 708h56m38s
Post-merge / detect-type (push) Waiting to run
Post-merge / validate-commit-msg (push) Waiting to run
Post-merge / publish (push) Waiting to run
Post-merge / sync-wiki (push) Waiting to run
Post-merge / badges (push) Waiting to run
Post-merge / configure-repo (push) Waiting to run
2026-07-08 21:12:29 +00:00
gitea-actions-bot 89bd40c4a8 chore: update badge URLs to commit b9122602 [skip ci] 2026-07-07 22:17:56 +00:00
113 changed files with 1649 additions and 1165 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% **Always run `make pytest-cov` before pushing** — CI enforces 100%
coverage and will fail the PR if any lines are uncovered. This is the 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. pre-push git hook only validates Vikunja task existence, not tests.
### API Response Type Checking ### API Response Type Checking
+12 -1
View File
@@ -15,7 +15,8 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# If set, API checks are performed as a bonus but do NOT affect pass/fail. # If set, API checks are performed as a bonus but do NOT affect pass/fail.
# Required scopes: read:user, read:repository, read:admin (or just "admin") # Required scopes: read:user, read:repository, read:admin (or just "admin")
# Generate token at: Settings → Applications → Generate New Token # Generate token at: Settings → Applications → Generate New Token
# CI_GITEA_TOKEN=your-admin-api-token # CI_GITEA_API_TOKEN=your-admin-api-token
# Legacy CI_GITEA_TOKEN is also accepted.
# Integration test API retries (optional, default: 3). # Integration test API retries (optional, default: 3).
# Number of times to retry API checks waiting for runner to appear. # Number of times to retry API checks waiting for runner to appear.
@@ -50,6 +51,16 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL # Used by PIP_INSTALL to configure PIP_EXTRA_INDEX_URL
CI_GITEA_USERNAME=your-gitea-username CI_GITEA_USERNAME=your-gitea-username
# 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
# different user than the PR author.
# Legacy CI_GITEA_TOKEN and REVIEW_GITEA_TOKEN are accepted as fallbacks.
# DEVELOPER_GITEA_API_TOKEN=your-developer-token
# CI_GITEA_API_TOKEN=your-ci-token
# REVIEWER_GITEA_API_TOKEN=your-reviewer-token
# Vikunja API token (required for `make create-task` dev workflow) # Vikunja API token (required for `make create-task` dev workflow)
# Generate at: Vikunja → Settings → API Tokens # Generate at: Vikunja → Settings → API Tokens
# VIKUNJA_TOKEN=your-vikunja-api-token # VIKUNJA_TOKEN=your-vikunja-api-token
+87 -147
View File
@@ -6,21 +6,38 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs: 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 runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint run: make setup-image EXTRAS=ci,lint
# --- quality steps ---
- name: Lint all - name: Lint all
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -32,7 +49,6 @@ jobs:
make pytest-cov make pytest-cov
- name: Documentation gate (coverage + stale refs + lint + version refs + prose) - name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env: env:
PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1" DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_DOC_VERSIONS_PKG: grm DEVX_DOC_VERSIONS_PKG: grm
DEVX_VALE_LEVEL: warning DEVX_VALE_LEVEL: warning
@@ -45,8 +61,6 @@ jobs:
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations --translations src/grm/translations.json python3 -m devx.ci.check_translations --translations src/grm/translations.json
- name: Check unit test speed - name: Check unit test speed
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5 python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
@@ -67,52 +81,10 @@ jobs:
else else
echo "act_runner not found — skipping workflow dry-run (static lint still passed)" echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
fi fi
# --- detect-changes step ---
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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Release dry-run validation
env:
PYTHONPATH: src
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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
env: env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -120,22 +92,10 @@ jobs:
--base "origin/master" \ --base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \ --head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output --github-output
# --- validate-pr + pr-review steps (PR only) ---
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
- name: Validate auto-merge preconditions - name: Validate auto-merge preconditions
if: github.event_name == 'pull_request'
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6 DEVX_VIKUNJA_PROJECT_ID: 6
@@ -143,7 +103,6 @@ jobs:
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
PYTHONPATH: ${{ env.PYTHONPATH }}
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_auto_merge_ready \ python3 -m devx.ci.check_auto_merge_ready \
@@ -151,52 +110,65 @@ jobs:
--pr-title "$PR_TITLE" \ --pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \ --repo "$REPOSITORY" \
--pr-number "$PR_NUMBER" --pr-number "$PR_NUMBER"
- name: Run automated PR review
discover-runners: if: github.event_name == 'pull_request'
needs: [detect-changes] run: |
if: needs.detect-changes.outputs.ansible-changed == 'true' . .venv/bin/activate 2>/dev/null || true
runs-on: docker set -euo pipefail
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest python3 -m devx.ci.pr_review \
timeout-minutes: 10 "${{ github.event.number }}" \
outputs: "${{ github.repository }}"
runner-count: ${{ steps.discover.outputs.runner-count }} # --- release-dry-run step (conditional) ---
runner-indices: ${{ steps.discover.outputs.runner-indices }} - name: Release dry-run validation
steps: if: steps.detect.outputs.user-facing-changed == 'true'
- uses: actions/checkout@v4
- name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} DEVX_VERSION_FILE: src/grm/__init__.py
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} DEVX_TASK_PREFIX: GRM
run: make setup-image EXTRAS=ci run: |
- name: Discover available runners . .venv/bin/activate 2>/dev/null || true
id: discover 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: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }} MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.discover_runners \ python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \ --owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \ --repo "${{ github.event.repository.name }}" \
--github-output --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: molecule-tests:
needs: [quality, detect-changes, discover-runners] needs: [validate]
if: needs.detect-changes.outputs.ansible-changed == 'true' if: needs.validate.outputs.ansible-changed == 'true'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 timeout-minutes: 15
strategy: strategy:
fail-fast: true fail-fast: true
max-parallel: 3 max-parallel: 6
matrix: matrix:
runner-index: [1, 2, 3, 4, 5, 6] runner-index: [1, 2, 3, 4, 5, 6]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,molecule run: make setup-image EXTRAS=ci,molecule
- name: Install Ansible collections - name: Install Ansible collections
@@ -206,16 +178,25 @@ jobs:
- name: Discover assigned test pairs - name: Discover assigned test pairs
env: env:
RUNNER_INDEX: ${{ matrix.runner-index }} RUNNER_INDEX: ${{ matrix.runner-index }}
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }} MAX_RUNNERS: 6
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.molecule.distribute_molecule \ python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \ --runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \ --max-runners "$MAX_RUNNERS" \
--github-env --skip-if-excess --github-env
- name: Run molecule tests - name: Run molecule tests
if: env.SKIP != 'true' 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: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
if [ -z "$TEST_PAIRS" ]; then exit 0; fi if [ -z "$TEST_PAIRS" ]; then exit 0; fi
@@ -223,61 +204,22 @@ jobs:
echo "Docker not available in CI container — skipping molecule tests" echo "Docker not available in CI container — skipping molecule tests"
exit 0 exit 0
fi fi
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin _TOKEN="$CI_GITEA_API_TOKEN"; [ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN"
[ -z "$_TOKEN" ] && { echo "Gitea API token not set — skipping Docker login"; exit 0; }
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
# shellcheck disable=SC2086 # intentional word splitting for argument expansion # shellcheck disable=SC2086 # intentional word splitting for argument expansion
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
env:
GITEA_URL: ${{ github.server_url }}
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
RUN_ID: ${{ github.run_id }}
ANSIBLE_INJECT_INVOCATION: "1"
JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
DOCKER_HOST: unix:///var/run/docker.sock
pr-review:
if: github.event_name == 'pull_request'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Run automated PR review
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_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:
# Auto-merge runs after all CI checks pass. It reads the task ID # Auto-merge runs after validate + molecule-tests pass (or molecule is skipped).
# from the branch name, validates the PR title, and squash-merges.
# Uses always() so it evaluates even when molecule-tests is skipped # Uses always() so it evaluates even when molecule-tests is skipped
# (Gitea Actions skips dependent jobs of skipped jobs by default). # (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: >- if: >-
always() && always() &&
github.event_name == 'pull_request' && github.event_name == 'pull_request' &&
needs.quality.result == 'success' && needs.validate.result == 'success' &&
needs.pre-merge-check.result == 'success' && (needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
@@ -288,18 +230,17 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci run: make setup-image EXTRAS=ci
- name: Post approval review - name: Post approval review
env: env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \ python3 -m devx.ci.pr_review \
@@ -308,12 +249,11 @@ jobs:
--event APPROVE \ --event APPROVE \
--checklist-confirmed \ --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \ --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 - name: Squash merge with task ID
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6 DEVX_VIKUNJA_PROJECT_ID: 6
HEAD_REF: ${{ github.head_ref }} HEAD_REF: ${{ github.head_ref }}
+92 -224
View File
@@ -1,115 +1,136 @@
name: Post-merge name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs # Runs on every push to master (after CI workflow merges a PR).
# for release, publish, wiki sync, badges, and Vikunja task updates. # 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) # The badges step always runs (even on release commits) so version
# ├── release (skip if release commit) # badge picks up the new __version__. It runs last so it sees the
# │ └── publish (needs release — builds & publishes to PyPI) # new version if release created one.
# ├── 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.
# #
# When release creates a "release: vX.Y.Z" commit and tag, the publish # 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 # step builds and publishes the package to the Gitea PyPI registry.
# Gitea PyPI registry. The release commit's post-merge run still updates # The release commit's post-merge run still updates badges. Other
# badges (version badge picks up the new version). Other jobs skip. # steps (sync-wiki, vikunja) skip on release commits.
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch: workflow_dispatch:
concurrency:
group: post-merge-${{ github.ref }}
cancel-in-progress: true
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} PIP_BREAK_SYSTEM_PACKAGES: "1"
PYTHONPATH: src
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
jobs: jobs:
detect-type: detect-and-configure:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
outputs: outputs:
is-release: ${{ steps.check.outputs.is-release }} is-release: ${{ steps.check.outputs.is-release }}
is-automated: ${{ steps.check.outputs.is-automated }}
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci 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 - name: Check if this is a release commit
id: check id: check
env:
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit 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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Validate latest commit message - name: Validate latest commit message
if: steps.check.outputs.is-automated == 'false'
env: env:
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt 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: release-and-maintain:
needs: [detect-type] needs: [detect-and-configure]
if: needs.detect-type.outputs.is-release == 'false' if: always() && needs.detect-and-configure.result == 'success'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15 timeout-minutes: 15
outputs: outputs:
tag: ${{ steps.release-tag.outputs.tag }} tag: ${{ steps.release-tag.outputs.tag }}
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} ref: master
token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint run: make setup-image EXTRAS=ci,lint
- name: Configure git - name: Configure git
run: | run: |
git config user.name "grm-ci-bot" git config user.name "grm-ci-bot"
git config user.email "grm-ci-bot@oblachno.fyi" git config user.email "grm-ci-bot@oblachno.fyi"
# --- release + publish (only if not a release commit) ---
- name: Run release - name: Run release
id: release-tag id: release-tag
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
env: env:
PYTHONPATH: src
DEVX_VERSION_FILE: src/grm/__init__.py DEVX_VERSION_FILE: src/grm/__init__.py
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6 DEVX_VIKUNJA_PROJECT_ID: 6
@@ -117,207 +138,54 @@ jobs:
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release.outputs.tag }}
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci,lint
- name: Build and publish release - name: Build and publish release
if: steps.release-tag.outputs.tag != ''
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish \ git fetch --tags
"${{ needs.release.outputs.tag }}" \ git checkout "${{ steps.release-tag.outputs.tag }}"
"${{ github.repository }}" --auto-login python3 -m devx.ci.publish "${{ steps.release-tag.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure # --- sync-wiki + vikunja (skip on automated/release commits) ---
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki:
needs: [detect-type]
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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Sync documentation to wiki - name: Sync documentation to wiki
if: needs.detect-and-configure.outputs.is-automated == 'false'
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_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_TOKEN }}
- name: Fetch latest master
run: |
git fetch origin master
git reset --hard origin/master
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=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_TOKEN: ${{ secrets.CI_GITEA_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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Update Vikunja task - name: Update Vikunja task
if: needs.detect-and-configure.outputs.is-automated == 'false'
env: env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: src
DEVX_TASK_PREFIX: GRM DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6 DEVX_VIKUNJA_PROJECT_ID: 6
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}" python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure # --- badges (always run — even on release commits) ---
if: failure() - name: Generate and push badges
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PRE_COMMIT_ALLOW_NO_CONFIG: "1"
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_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: make setup-image EXTRAS=ci
- name: Ensure branch protection and labels
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: grm
DEVX_REPO_OWNER: oblachno-oss
DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .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 - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure --auto-login \ python3 -m devx.ci.notify_failure --auto-login \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \ --workflow "post-merge/release-and-maintain" \
--commit "${{ github.sha }}" --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 extends: existence
message: "'%s' should be in lowercase." message: "'%s' should be in lowercase."
link: 'https://developers.google.com/style/colons' link: 'https://developers.google.com/style/colons'
nonword: true
level: warning level: warning
scope: sentence 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: tokens:
- '(?<!:[^ ]+?):\s[A-Z]' - '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
+1 -1
View File
@@ -6,4 +6,4 @@ level: error
nonword: true nonword: true
tokens: tokens:
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}' - '\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' link: 'https://developers.google.com/style/pronouns#personal-pronouns'
ignorecase: true ignorecase: true
level: warning 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: tokens:
- (?:^|\s)I\s - '(?<=^|\s)I(?=[\s,])'
- (?:^|\s)I,\s - "\\bI'm\\b"
- \bI'm\b
- \bme\b - \bme\b
- \bmy\b - \bmy\b
- \bmine\b - \bmine\b
+5 -2
View File
@@ -4,8 +4,11 @@ link: "https://developers.google.com/style/capitalization#capitalization-in-titl
level: warning level: warning
scope: heading scope: heading
match: $sentence 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: exceptions:
- Azure - Azure
- CLI - 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 nonword: true
action: action:
name: replace 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: swap:
'\b(?:eg|e\.g\.)(?=[\s,;])': for example '\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
'\b(?:ie|i\.e\.)(?=[\s,;])': that is '\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
+9 -1
View File
@@ -3,5 +3,13 @@ message: "Use the Oxford comma in '%s'."
link: 'https://developers.google.com/style/commas' link: 'https://developers.google.com/style/commas'
scope: sentence scope: sentence
level: warning level: warning
nonword: true
# List items may be several words long, not just one. Two guards keep the
# false-positive rate down: the item can't open with a clause-introducer
# (', which ...', ', specifically ...'), and neither item may contain an
# auxiliary verb, which is what separates a list from a compound predicate
# (', 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: tokens:
- '(?:[^,]+,){1,}\s\w+\s(?:and|or)' - ',\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)(?:(?!\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' link: 'https://developers.google.com/style/parentheses'
nonword: true nonword: true
level: suggestion 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: 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 nonword: true
level: error level: error
tokens: tokens:
- \b\d+(?:B|kB|MB|GB|TB) - '\b\d+(?:B|kB|MB|GB|TB)\b'
- \b\d+(?:ns|ms|s|min|h|d) - '\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'." message: "Use '%s' instead of '%s'."
link: "https://developers.google.com/style/word-list" link: "https://developers.google.com/style/word-list"
level: warning 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 ignorecase: false
action: action:
name: replace name: replace
swap: 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 Ajax: AJAX
a\.k\.a|aka: or|also known as
Android device: Android-powered device Android device: Android-powered device
android: Android android: Android
API explorer: APIs Explorer API explorer: APIs Explorer
application: app
approx\.: approximately
authN: authentication authN: authentication
authZ: authorization authZ: authorization
autoupdate: automatically update
cellular data: mobile data
cellular network: mobile network
chapter: documents|pages|sections
check box: checkbox
CLI: command-line tool CLI: command-line tool
click on: click|click in
Cloud: Google Cloud Platform|GCP Cloud: Google Cloud Platform|GCP
Container Engine: Kubernetes Engine Container Engine: Kubernetes Engine
content type: media type
curated roles: predefined roles
data are: data is
Developers Console: Google API Console|API Console 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 account: Google Account
Google accounts: Google Accounts Google accounts: Google Accounts
Googling: search with Google Googling: search with Google
grayed-out: unavailable
HTTPs: HTTPS HTTPs: HTTPS
in order to: to
ingest: import|load
k8s: Kubernetes 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 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 url: URL
vs\.: versus
World Wide Web: web 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`. 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 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). CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
## Architecture ## Architecture
- **Python CLI** (`src/grm/`) — Click-based CLI that delegates to Ansible - **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 - **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 - **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 Branch protection and labels are automatically configured by
`devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`), `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 post-merge workflow on every push to master.
The following rules are enforced for `master`: The following rules are enforced for `master`:
- **Require pull request**: No direct pushes to master - **Require pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge - **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 - **Block force pushes**: No history rewriting on master
The auto-merge workflow enforces the APPROVE review check programmatically 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 ### 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. 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 ### 2. Create Branch
```bash ```bash
git checkout master && git pull git checkout master && git pull
@@ -117,8 +123,9 @@ architecture, code quality, security, i18n, testing, performance,
UX, documentation, workflow compliance, maintainability, resource UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging. management, backwards compatibility, and logging.
**Automated review (CI `pr-review` job):** Every PR triggers an automated **Automated review (CI `validate` 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 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 `COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
the **[auto]** items in the checklist: the **[auto]** items in the checklist:
@@ -172,10 +179,10 @@ rejected.
Then add the `ready-to-merge` label. The auto-merge workflow will: 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 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) 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>` 4. Squash-merge with title: `GRM-N: <conventional commit message>`
5. The post-merge workflow marks the Vikunja task as done 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 **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 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 ### CI Path Filtering
The CI workflow includes a `pre-merge-check` job (runs after quality + The CI workflow's `validate` job includes a pre-merge validation step
detect-changes) that validates branch format, PR title, and Vikunja task that validates branch format, PR title, and Vikunja task match. This
match. This fails fast before expensive molecule tests run. fails fast before expensive molecule tests run.
The CI workflow includes a `detect-changes` job that checks whether any files The `validate` job also includes a `detect-changes` step that checks
under `ansible/` or `.ansible-lint` have changed. If no Ansible files are whether any files under `ansible/` or `.ansible-lint` have changed. If
changed, molecule tests are skipped — this prevents non-Ansible changes no Ansible files are changed, molecule tests are skipped — this prevents
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from
test infrastructure flakiness. being blocked by molecule test infrastructure flakiness.
### Dynamic Runner Discovery ### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners Molecule tests are distributed across available Gitea Actions runners
dynamically via `devx.molecule.discover_runners`. The `discover-runners` dynamically via `devx.molecule.discover_runners`. The `validate` job
job queries the Gitea API for runners at all levels (repo, org, instance) 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 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, (no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
then to a default of 3. then to a default of 3.
@@ -218,47 +226,27 @@ then to a default of 3.
### Automated Release Pipeline ### Automated Release Pipeline
After a PR is merged to master, the **post-merge workflow** After a PR is merged to master, the **post-merge workflow**
(`.gitea/workflows/post-merge.yml`) runs automatically. This single (`.gitea/workflows/post-merge.yml`) runs automatically. Consolidated
workflow consolidates release, wiki sync, badge generation, and into 2 jobs (from 7) to reduce runner overhead:
Vikunja task updates:
1. **detect-type** — Checks if the commit is a regular merge or a 1. **detect-and-configure** — Configures repo (branch protection, labels),
release commit (`release: vX.Y.Z`). All subsequent jobs skip for detects release commit, validates commit message. Outputs `is-release`
release commits (the `[skip ci]` tag also prevents re-triggering). and `is-automated` for the next job.
2. **release** — Runs `devx.ci.release` which: 2. **release-and-maintain** — Runs all post-merge maintenance as
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only conditional steps:
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`, - **release** (if not a release commit) — Runs `devx.ci.release` which
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version checks for user-facing changes via `classify_changes` (skips if only
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes. workflow/infrastructure files changed), uses git-cliff for semver,
- Uses **git-cliff** to calculate the next semver version from conventional commits updates `__version__`, updates `CHANGELOG.md`, runs lint+tests, commits
- Updates `__version__` in `src/grm/__init__.py` (single source of truth) with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes to master.
- Updates `CHANGELOG.md` with the new version section - **publish** (if release created a tag) — Builds and publishes the
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy package to the Gitea PyPI registry. Checks out the release tag
- If lint or tests fail, **aborts immediately** — no commit, no tag within the same job.
- Commits with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents - **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
re-triggering post-merge on the release commit) - **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
- Creates an annotated tag `vX.Y.Z` on the release commit - **badges** (always) — Generates and pushes quality badge SVGs to the
- Pushes both the commit and tag to master `badges` branch. Fetches latest master first to pick up release commits.
- `--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.
### Smart CI: User-Facing vs Workflow-Only Changes ### 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 `devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
avoids dev tools importing directly from CI modules. avoids dev tools importing directly from CI modules.
2. **Publish job** (in `post-merge.yml`, needs: release): 2. **Publish step** (in the `release-and-maintain` job, runs after the release step creates a tag):
- Runs after the release job creates a tag - Runs after the release step creates a tag
- Gets the tag from `needs.release.outputs.tag` - Gets the tag from the release step's output
- Builds the Python package - Builds the Python package
- Publishes to the Gitea PyPI registry - Publishes to the Gitea PyPI registry
- Creates a Gitea release with git-cliff-generated release notes - 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) - `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
- `systemctl --user` tasks must be guarded by `docker_rootless_setup` - `systemctl --user` tasks must be guarded by `docker_rootless_setup`
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they only create files) - 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 ## Molecule Scenarios
@@ -536,7 +530,7 @@ docs/
### Documentation Coverage ### Documentation Coverage
- `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented - `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 - Enforced: 100% coverage for public CLI commands and major architectural components
### Updating Documentation ### Updating Documentation
@@ -566,7 +560,7 @@ the user should not need to specify which profile to use.
| Profile | Purpose | | 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 | | `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification | | `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync for grm docs | | `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 | | 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 | | PR ready for review | `pr-reviewer` | Foreground |
| Molecule tests need to run | `molecule-runner` | Background | | Molecule tests need to run | `molecule-runner` | Background |
| Dependency upgrade requested | `dep-upgrader` | 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`, use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`). `ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in Standard labels are created automatically by `configure_repo` (runs as
post-merge on every master push). If a label does not exist yet, the 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 subagent's issue creation will still succeed — labels can be added
afterwards. afterwards.
+60
View File
@@ -2,6 +2,66 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [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
- Bump devx to 0.38.0 and migrate to role-based Gitea tokens
## [0.16.0] - 2026-07-07 ## [0.16.0] - 2026-07-07
### Features ### Features
+10 -9
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 $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install
# Lean setup for CI jobs that need pytest + lint tools + runtime deps # Lean setup for CI jobs that need pytest + lint tools + runtime deps
# (detect-changes, discover-runners, pr-review, sync-wiki, badges) # (validate job steps: detect-changes, discover-runners, pr-review;
# badges job runs generate_badges.py which needs ruff, pyright, bandit # 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 setup-ci: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]' @$(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 @$(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 setup-quality: $(VENV)/bin/activate .env configure-gitea-pypi
@$(PIP_INSTALL) install -e '.[ci,lint]' @$(PIP_INSTALL) install -e '.[ci,lint]'
@$(BIN)/python -m devx.tools.install_tools @$(BIN)/python -m devx.tools.install_tools
@@ -86,7 +87,8 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
# the venv symlink first, then installs the project. # the venv symlink first, then installs the project.
setup-image: setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \ @if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \ _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/"; 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)],); \ pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
@@ -159,10 +161,9 @@ workflow-dryrun: devx-workflow-dryrun
workflow-check: devx-workflow-check workflow-check: devx-workflow-check
configure-gitea-pypi: configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ _TOKEN="$$CI_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ if [ -z "$$_TOKEN" ]; then echo "[configure-gitea-pypi] Gitea API token not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \ echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)."
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
ansible-lint: ansible-lint:
PATH="$(PWD)/$(BIN):$$PATH" $(BIN)/ansible-lint ansible/ PATH="$(PWD)/$(BIN):$$PATH" $(BIN)/ansible-lint ansible/
@@ -183,7 +184,7 @@ test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov $(BIN)/pytest tests/integration/ -v --no-cov
MOLECULE := $(realpath $(BIN))/molecule 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 # Quick local test: Ubuntu 22.04 only, all scenarios
molecule: 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) [![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) [![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/f0f26310e77e1a999dd6f23376d5a1617ea46c67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/python.svg)](https://www.python.org/downloads/)
## Why GRM? ## 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>/` - **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` - **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
- **Data directory**: `/var/lib/gitea-runner/<name>/` - **Data directory**: `/var/lib/gitea_runner/<name>/`
- **Config directory**: `/etc/gitea-runner/<name>/` - **Config directory**: `/etc/gitea_runner/<name>/`
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable) - **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
- **Docker prune timer**: Per-instance daily cleanup - **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. 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 ```text
grm install <host> grm install <host>
└── RunnerManager.install() └── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml └── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner └── role: gitea_runner
├── user_setup.yml (create per-runner system user + lingering) ├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user) ├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service) ├── install_runner.yml (download binary, config, register, service)
+6 -6
View File
@@ -6,13 +6,13 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Stop gitea-runner user service - name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner ansible.builtin.command: systemctl --user stop gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- name: Stop and disable healthcheck timer - name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
@@ -30,14 +30,14 @@
- name: Include deregistration - name: Include deregistration
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: deregister.yml 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 - name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner ansible.builtin.command: systemctl --user disable gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
+3 -3
View File
@@ -6,13 +6,13 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Enable gitea-runner user service - name: Enable gitea-runner user service
ansible.builtin.command: systemctl --user enable gitea-runner ansible.builtin.command: systemctl --user enable gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
@@ -21,7 +21,7 @@
- name: Start gitea-runner user service - name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner ansible.builtin.command: systemctl --user start gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: systemd_available.stat.exists
+1 -1
View File
@@ -3,4 +3,4 @@
hosts: all hosts: all
become: true become: true
roles: roles:
- role: gitea-runner - role: gitea_runner
+34 -34
View File
@@ -6,11 +6,11 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Get runner user UID - 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 register: runner_uid_result
changed_when: false changed_when: false
failed_when: false failed_when: false
@@ -23,20 +23,20 @@
- name: Stop gitea-runner user service - name: Stop gitea-runner user service
ansible.builtin.command: systemctl --user stop gitea-runner ansible.builtin.command: systemctl --user stop gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: gitea_runner_systemd_available.stat.exists
changed_when: true changed_when: true
failed_when: false failed_when: false
- name: Disable gitea-runner user service - name: Disable gitea-runner user service
ansible.builtin.command: systemctl --user disable gitea-runner ansible.builtin.command: systemctl --user disable gitea-runner
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: gitea_runner_systemd_available.stat.exists
changed_when: true changed_when: true
failed_when: false failed_when: false
@@ -47,7 +47,7 @@
args: args:
executable: /bin/bash executable: /bin/bash
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock" 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) - name: Prune all Docker images, volumes, and build cache (rootless)
ansible.builtin.command: docker system prune -af --volumes ansible.builtin.command: docker system prune -af --volumes
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock" DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
@@ -67,7 +67,7 @@
- name: Stop rootless Docker daemon - name: Stop rootless Docker daemon
ansible.builtin.command: systemctl --user stop docker ansible.builtin.command: systemctl --user stop docker
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
changed_when: true changed_when: true
@@ -75,120 +75,120 @@
- name: Include deregistration - name: Include deregistration
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: deregister.yml 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 - name: Stop and disable healthcheck timer
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
become: true become: true
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
when: systemd_available.stat.exists when: gitea_runner_systemd_available.stat.exists
changed_when: true changed_when: true
failed_when: false failed_when: false
- name: Remove docker-prune user service file - name: Remove docker-prune user service file
ansible.builtin.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 state: absent
failed_when: false failed_when: false
- name: Remove docker-prune user timer file - name: Remove docker-prune user timer file
ansible.builtin.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 state: absent
failed_when: false failed_when: false
- name: Remove healthcheck user service file - name: Remove healthcheck user service file
ansible.builtin.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 state: absent
failed_when: false failed_when: false
- name: Remove healthcheck user timer file - name: Remove healthcheck user timer file
ansible.builtin.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 state: absent
failed_when: false failed_when: false
- name: Remove healthcheck script - name: Remove healthcheck script
ansible.builtin.file: 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 state: absent
failed_when: false failed_when: false
- name: Remove systemd user unit file - name: Remove systemd user unit file
ansible.builtin.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 state: absent
when: remove_systemd_template | default(true) when: remove_systemd_template | default(true)
- name: Kill remaining processes of runner user - 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 failed_when: false
changed_when: true changed_when: true
- name: Wait for processes to terminate - 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 failed_when: false
changed_when: false changed_when: false
- name: Disable lingering for runner user - 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 failed_when: false
changed_when: true changed_when: true
- name: Remove runner user and home directory - name: Remove runner user and home directory
ansible.builtin.user: ansible.builtin.user:
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}" name: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
state: absent state: absent
remove: true remove: true
when: remove_runner_user | default(true) when: gitea_runner_remove_user | default(true)
failed_when: false failed_when: false
- name: Remove Docker data root when user is kept - name: Remove Docker data root when user is kept
ansible.builtin.file: 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 state: absent
when: not (remove_runner_user | default(true)) when: not (gitea_runner_remove_user | default(true))
failed_when: false failed_when: false
- name: Remove act cache when user is kept - name: Remove act cache when user is kept
ansible.builtin.file: 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 state: absent
when: not (remove_runner_user | default(true)) when: not (gitea_runner_remove_user | default(true))
failed_when: false failed_when: false
- name: Remove systemd user config dir when user is kept - name: Remove systemd user config dir when user is kept
ansible.builtin.file: 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 state: absent
when: not (remove_runner_user | default(true)) when: not (gitea_runner_remove_user | default(true))
failed_when: false failed_when: false
- name: Remove subuid entry for runner user - name: Remove subuid entry for runner user
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/subuid path: /etc/subuid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:" regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent state: absent
failed_when: false failed_when: false
- name: Remove subgid entry for runner user - name: Remove subgid entry for runner user
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/subgid path: /etc/subgid
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:" regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
state: absent state: absent
failed_when: false failed_when: false
- name: Remove runner data directory - name: Remove runner data directory
ansible.builtin.file: 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 state: absent
- name: Remove runner config directory - name: Remove runner config directory
ansible.builtin.file: 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 state: absent
+1 -1
View File
@@ -2,6 +2,6 @@ collections:
- name: community.general - name: community.general
version: "==13.1.0" version: "==13.1.0"
- name: ansible.posix - name: ansible.posix
version: "==2.2.0" version: "==2.2.1"
- name: community.docker - name: community.docker
version: "==5.2.1" version: "==5.2.1"
+2 -2
View File
@@ -7,12 +7,12 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Resolve runner UID - name: Resolve runner UID
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: resolve_uid.yml tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service - 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
@@ -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: when:
- ansible_facts is defined - ansible_facts is defined
- ansible_facts['service_mgr'] | default('') == 'systemd' - 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 hosts: all
become: true become: true
vars: vars:
runner_name: "molecule-test-runner" gitea_runner_name: "molecule-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: 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 hosts: all
become: true become: true
vars: vars:
runner_name: "deregister-test-runner" gitea_runner_name: "deregister-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -13,7 +13,7 @@
ansible.builtin.copy: ansible.builtin.copy:
dest: "{{ gitea_runner_data_dir }}/.runner" dest: "{{ gitea_runner_data_dir }}/.runner"
content: | 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 }}" owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}" group: "{{ gitea_runner_service_user }}"
mode: "0644" mode: "0644"
@@ -22,12 +22,12 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "deregister-test-runner" gitea_runner_name: "deregister-test-runner"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
skip_runner_registration: false gitea_runner_skip_registration: false
tasks: tasks:
- name: Include deregistration tasks - name: Include deregistration tasks
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: deregister.yml tasks_from: deregister.yml
@@ -3,7 +3,7 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "deregister-test-runner" gitea_runner_name: "deregister-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -12,12 +12,12 @@
- name: Check registration file was removed - name: Check registration file was removed
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner" path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_file_stat register: gitea_runner_file_stat
- name: Assert registration file no longer exists - name: Assert registration file no longer exists
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- not runner_file_stat.stat.exists - not gitea_runner_file_stat.stat.exists
fail_msg: "Registration file (.runner) was not removed by deregistration" fail_msg: "Registration file (.runner) was not removed by deregistration"
- name: Check systemd user service still exists - 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 hosts: all
become: true become: true
vars: vars:
runner_name: "lifecycle-test-runner" gitea_runner_name: "lifecycle-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -23,7 +23,7 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "lifecycle-test-runner" gitea_runner_name: "lifecycle-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -3,7 +3,7 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "lifecycle-test-runner" gitea_runner_name: "lifecycle-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -5,11 +5,11 @@
vars: vars:
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-a" gitea_runner_name: "molecule-runner-a"
skip_runner_registration: true gitea_runner_skip_registration: true
docker_rootless_setup: false gitea_runner_docker_rootless_setup: false
roles: roles:
- role: gitea-runner - role: gitea_runner
- name: Converge second runner instance - name: Converge second runner instance
hosts: all hosts: all
@@ -17,8 +17,8 @@
vars: vars:
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
runner_name: "molecule-runner-b" gitea_runner_name: "molecule-runner-b"
skip_runner_registration: true gitea_runner_skip_registration: true
docker_rootless_setup: false gitea_runner_docker_rootless_setup: false
roles: 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 - name: Remove runner via remove-runner playbook
ansible.builtin.import_playbook: "../../../../remove-runner.yml" ansible.builtin.import_playbook: "../../../../remove-runner.yml"
vars: vars:
runner_name: "remove-test-runner" gitea_runner_name: "remove-test-runner"
registration_token: "fake-token-for-testing" registration_token: "fake-token-for-testing"
gitea_url: "http://localhost:3000" gitea_url: "http://localhost:3000"
skip_runner_registration: true gitea_runner_skip_registration: true
@@ -3,7 +3,7 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "remove-test-runner" gitea_runner_name: "remove-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: 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 hosts: all
become: true become: true
vars: vars:
runner_name: "template-test-runner" gitea_runner_name: "template-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: ansible.builtin.include_vars:
@@ -49,6 +49,9 @@
- "'Type=oneshot' in prune_service.content | b64decode" - "'Type=oneshot' in prune_service.content | b64decode"
- "'docker system prune' in prune_service.content | b64decode" - "'docker system prune' in prune_service.content | b64decode"
- "'docker volume 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" fail_msg: "Prune service template is missing expected directives"
- name: Read rendered prune timer template - name: Read rendered prune timer template
@@ -99,8 +102,26 @@
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- "'docker info' in healthcheck_script.content | b64decode" - "'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 docker.service' in healthcheck_script.content | b64decode"
- "'systemctl --user restart gitea-runner.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 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" - "gitea_runner_healthcheck_disk_threshold | string in healthcheck_script.content | b64decode"
fail_msg: "Healthcheck script template is missing expected content" 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 hosts: all
become: true become: true
vars: vars:
runner_name: "update-test-runner" gitea_runner_name: "update-test-runner"
tasks: tasks:
- name: Include update tasks - name: Include update tasks
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: update_runner.yml tasks_from: update_runner.yml
@@ -3,7 +3,7 @@
hosts: all hosts: all
become: true become: true
vars: vars:
runner_name: "update-test-runner" gitea_runner_name: "update-test-runner"
pre_tasks: pre_tasks:
- name: Load role defaults - name: Load role defaults
ansible.builtin.include_vars: 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 }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
- name: Enable and start healthcheck user timer - name: Enable and start healthcheck user timer
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
@@ -40,7 +41,8 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
@@ -15,7 +15,7 @@
- name: Include registration - name: Include registration
ansible.builtin.include_tasks: register.yml ansible.builtin.include_tasks: register.yml
when: not skip_runner_registration when: not gitea_runner_skip_registration
- name: Include service setup - name: Include service setup
ansible.builtin.include_tasks: service.yml 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 - name: Include healthcheck setup
ansible.builtin.include_tasks: healthcheck.yml ansible.builtin.include_tasks: healthcheck.yml
- name: Include pre-pull images
ansible.builtin.include_tasks: pre_pull_images.yml
- name: Include integration test - name: Include integration test
ansible.builtin.include_tasks: integration_test.yml 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
@@ -21,10 +21,11 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
- name: Enable and start docker-prune user timer - name: Enable and start docker-prune user timer
ansible.builtin.command: systemctl --user enable --now docker-prune.timer ansible.builtin.command: systemctl --user enable --now docker-prune.timer
@@ -32,7 +33,8 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
@@ -10,15 +10,15 @@
- name: Check if runner is already registered - name: Check if runner is already registered
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ gitea_runner_data_dir }}/.runner" path: "{{ gitea_runner_data_dir }}/.runner"
register: runner_registered register: gitea_runner_registered
- name: Register runner with Gitea - name: Register runner with Gitea
ansible.builtin.command: > ansible.builtin.command: >
{{ gitea_runner_binary_path }} register {{ gitea_runner_binary_path }} register
--token {{ registration_token }} --token {{ registration_token }}
--name {{ runner_name }} --name {{ gitea_runner_name }}
--instance {{ gitea_url }} --instance {{ gitea_url }}
--labels {{ runner_labels }} --labels {{ gitea_runner_labels }}
--no-interactive --no-interactive
args: args:
chdir: "{{ gitea_runner_data_dir }}" chdir: "{{ gitea_runner_data_dir }}"
@@ -26,8 +26,9 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}" 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" DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
when: not runner_registered.stat.exists when: not gitea_runner_registered.stat.exists
register: register_output register: gitea_runner_register_output
changed_when: "'already exists' not in register_output.stdout | default('')" changed_when: "'already exists' not in gitea_runner_register_output.stdout | default('')"
timeout: 60 timeout: 60
@@ -6,14 +6,14 @@
- name: Resolve runner service user - name: Resolve runner service user
ansible.builtin.set_fact: 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_data_dir: "/var/lib/gitea-runner"
gitea_runner_base_config_dir: "/etc/gitea-runner" gitea_runner_base_config_dir: "/etc/gitea-runner"
- name: Resolve runner data and config dirs - name: Resolve runner data and config dirs
ansible.builtin.set_fact: ansible.builtin.set_fact:
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}" gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}" gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
- name: Resolve runner service user UID - name: Resolve runner service user UID
ansible.builtin.getent: 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
@@ -13,10 +13,11 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
- name: Enable and start gitea-runner user service - name: Enable and start gitea-runner user service
ansible.builtin.command: systemctl --user enable --now gitea-runner ansible.builtin.command: systemctl --user enable --now gitea-runner
@@ -24,7 +25,8 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 changed_when: true
when: when:
- systemd_available.stat.exists - gitea_runner_systemd_available.stat.exists
- docker_rootless_setup - gitea_runner_docker_rootless_setup
@@ -2,4 +2,4 @@
- name: Check if systemd is available - name: Check if systemd is available
ansible.builtin.stat: ansible.builtin.stat:
path: /run/systemd/system path: /run/systemd/system
register: systemd_available register: gitea_runner_systemd_available
@@ -8,7 +8,8 @@
become_user: "{{ gitea_runner_service_user }}" become_user: "{{ gitea_runner_service_user }}"
environment: environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
when: when:
- systemd_available.stat.exists | default(false) | bool - gitea_runner_systemd_available.stat.exists | default(false) | bool
- docker_rootless_setup - gitea_runner_docker_rootless_setup
changed_when: true changed_when: true
@@ -6,21 +6,21 @@
shell: /bin/bash shell: /bin/bash
system: true system: true
create_home: true create_home: true
register: runner_user register: gitea_runner_user
- name: Set runner UID fact - name: Set runner UID fact
ansible.builtin.set_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 - name: Check if lingering is already enabled
ansible.builtin.stat: ansible.builtin.stat:
path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}" path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}"
register: linger_stat register: gitea_runner_linger_stat
- name: Enable lingering for runner user - name: Enable lingering for runner user
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }} ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
changed_when: not linger_stat.stat.exists changed_when: not gitea_runner_linger_stat.stat.exists
when: systemd_available.stat.exists when: gitea_runner_systemd_available.stat.exists
- name: Ensure subuid entry for runner user - name: Ensure subuid entry for runner user
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
@@ -21,6 +21,7 @@
environment: environment:
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock" DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}" 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 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 }} 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 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 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
@@ -7,18 +7,29 @@ DOCKER_HOST="unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}" XDG_RUNTIME_DIR="/run/user/{{ gitea_runner_uid }}"
export DOCKER_HOST XDG_RUNTIME_DIR export DOCKER_HOST XDG_RUNTIME_DIR
# 1. Check Docker daemon responsiveness # 1. Check Docker daemon responsiveness (with timeout — a bare `docker info` can
if ! docker info >/dev/null 2>&1; then # hang indefinitely on a stuck daemon, blocking the healthcheck itself).
echo "ERROR: Docker daemon not responding at ${DOCKER_HOST}" 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 systemctl --user restart docker.service
sleep 3 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" echo "CRITICAL: Docker daemon still down after restart"
exit 1 exit 1
fi fi
echo "RECOVERED: Docker daemon restarted successfully" echo "RECOVERED: Docker daemon restarted successfully"
fi 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 # 2. Check gitea-runner service is active
runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true) runner_state=$(systemctl --user is-active gitea-runner.service 2>/dev/null || true)
if [[ "$runner_state" != "active" ]]; then if [[ "$runner_state" != "active" ]]; then
@@ -37,10 +48,13 @@ fi
disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}') disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then if [[ "$disk_pct" -ge {{ gitea_runner_healthcheck_disk_threshold }} ]]; then
echo "WARN: Disk usage at ${disk_pct}%, pruning all runner resources" 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 system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until=1h" || true
docker volume prune -af --filter "label={{ gitea_runner_prune_label }}" || true docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" || true
# Also prune dangling images (no label) # Only prune dangling (untagged) images — keep tagged runner images (ci-full, ci-quality)
docker image prune -af || true 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}') disk_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
echo "INFO: Disk usage after prune: ${disk_pct}%" echo "INFO: Disk usage after prune: ${disk_pct}%"
fi fi
+4 -4
View File
@@ -6,12 +6,12 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Resolve runner UID - name: Resolve runner UID
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: resolve_uid.yml tasks_from: resolve_uid.yml
- name: Check if runner is already registered - name: Check if runner is already registered
@@ -21,11 +21,11 @@
- name: Include registration if not registered - name: Include registration if not registered
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: register.yml tasks_from: register.yml
when: when:
- not runner_registered.stat.exists - not runner_registered.stat.exists
- not skip_runner_registration | default(false) - not gitea_runner_skip_registration | default(false)
- name: Start gitea-runner user service - name: Start gitea-runner user service
ansible.builtin.command: systemctl --user start gitea-runner ansible.builtin.command: systemctl --user start gitea-runner
+2 -2
View File
@@ -6,12 +6,12 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Resolve runner UID - name: Resolve runner UID
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: resolve_uid.yml tasks_from: resolve_uid.yml
- name: Check systemd user service status - name: Check systemd user service status
+2 -2
View File
@@ -6,12 +6,12 @@
tasks: tasks:
- name: Include systemd availability check - name: Include systemd availability check
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: systemd_check.yml tasks_from: systemd_check.yml
- name: Resolve runner UID - name: Resolve runner UID
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: resolve_uid.yml tasks_from: resolve_uid.yml
- name: Stop gitea-runner user service - name: Stop gitea-runner user service
+1 -1
View File
@@ -6,5 +6,5 @@
tasks: tasks:
- name: Update runner - name: Update runner
ansible.builtin.include_role: ansible.builtin.include_role:
name: gitea-runner name: gitea_runner
tasks_from: update_runner.yml 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) [![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) [![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/f0f26310e77e1a999dd6f23376d5a1617ea46c67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/f0f26310e77e1a999dd6f23376d5a1617ea46c67/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/169df9153d73566fefc79a7b8d8e0e4eeaa461cc/python.svg)](https://www.python.org/downloads/)
## Overview ## 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: 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. 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 ## High-Level Design
@@ -26,7 +26,7 @@ The Ansible role handles all remote state: user creation, package installation,
grm install <host> grm install <host>
└── RunnerManager.install() └── RunnerManager.install()
└── ansible-playbook ansible/install-runner.yml └── ansible-playbook ansible/install-runner.yml
└── role: gitea-runner └── role: gitea_runner
├── user_setup.yml (create per-runner system user + lingering) ├── user_setup.yml (create per-runner system user + lingering)
├── rootless_docker.yml (rootless Docker setup under runner user) ├── rootless_docker.yml (rootless Docker setup under runner user)
├── install_runner.yml (download binary, config, register, service) ├── 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 | | `main.yml` | Entry point — includes all other task files in order |
| `systemd_check.yml` | Verifies systemd is available on the target host | | `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 | | `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 | | `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 | | `download_gitea_runner.yml` | Downloads the gitea_runner binary from GitHub releases |
| `validate.yml` | Validates the downloaded binary | | `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) - **User**: `grm-<name>` (dedicated system user with lingering enabled)
- **Home**: `/home/grm-<name>/` - **Home**: `/home/grm-<name>/`
- **Data**: `/var/lib/gitea-runner/<name>/` - **Data**: `/var/lib/gitea_runner/<name>/`
- **Config**: `/etc/gitea-runner/<name>/` - **Config**: `/etc/gitea_runner/<name>/`
- **Service**: `gitea-runner.service` (systemd user service) - **Service**: `gitea-runner.service` (systemd user service)
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner) - **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping) - **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
@@ -100,7 +100,7 @@ flowchart TD
EXEC["Executor<br/>executor.py"] EXEC["Executor<br/>executor.py"]
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"] REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
ANS["ansible-playbook subprocess"] 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"] USER["user_setup.yml<br/>create system user + lingering"]
DOCKER["rootless_docker.yml<br/>rootless Docker setup"] DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
INSTALL["install_runner.yml<br/>download, config, register, service"] 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) - User namespace mapping via `/etc/subuid` and `/etc/subgid` (range: 100000-165535)
- Rootless Docker socket at `/run/user/<UID>/docker.sock` - 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 - `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. 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 ### Secret handling
Registration tokens and admin API tokens are never exposed on the command line. The `RunnerManager._extra_vars_file()` context manager: 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 | | 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 | | `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 | | `post-merge.yml` | Push to `master` | Detect-and-configure + release-and-maintain (release, publish, wiki sync, badges, Vikunja task update) |
| `publish.yml` | Tag push (`v*`) | Build and publish package to PyPI, create Gitea release |
Every change to master goes through a mandatory PR workflow. No exceptions. 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 3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated) 4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
5. The post-merge workflow marks the Vikunja task as done 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 ### 9. Post-Merge Automation
After the squash-merge: 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 **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 workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below). - The **release step** (in the `release-and-maintain` job) automatically versions, tags, and publishes (see below).
## Branch Protection (Required Gitea Settings) ## 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 pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge - **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 - **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. 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 ## 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 pull requests: compares `origin/master` against the PR head SHA
- For pushes to master: compares `HEAD~1` against `HEAD` - For pushes to master: compares `HEAD~1` against `HEAD`
- Outputs `ansible-changed` as `true` or `false` - 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 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 1. `make setup` — full environment setup
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake 2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
3. `make pytest-cov` — unit tests with 100% coverage enforcement 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 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 ## Automated Release Pipeline
After a PR is merged to master, the release pipeline runs automatically. 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 - Sets up full dev environment (`make setup`) so lint and tests can run
- Installs git-cliff (version 2.13.0) - Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot` - 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 - 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` - 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 git-cliff (version 2.13.0)
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`) - Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
- Validates `PYPI_TOKEN` is set (warns if missing) - 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`) ### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master` - Triggers on push to `master`
- Consolidates release, wiki sync, badge generation, and Vikunja task updates into a single workflow - Consolidated from 7 jobs into 2 jobs to reduce runner overhead
- **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). - **detect-and-configure** — Configures repo (branch protection, labels), detects release commit, validates commit message. Outputs `is-release` and `is-automated` for the next job.
- **release** — Runs `devx.ci.release` (see Automated Release Pipeline below) - **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).
- **sync-wiki** — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki` - **validate-commit-msg step** — Validates the commit message follows conventional commit format.
- **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. - **configure-repo step** — Runs `devx.tools.configure_repo` to set up branch protection and labels.
- **vikunja** — Marks the corresponding Vikunja task as done via `devx.ci.post_merge` - **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 ### 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:** **CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change - **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) - **Release dry-run**: Only runs when user-facing files change (release-dry-run step in the validate job)
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs - **Validate job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: `release.py` calls `classify_changes` to check if any - **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 user-facing files changed since the last tag. If not, the release is skipped
entirely — no version bump, no tag, no publish. entirely — no version bump, no tag, no publish.
### Dynamic Runner Discovery ### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners 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 registered runners at three levels (repo, org, instance) and generates
a matrix of runner indices. If the API query fails (e.g., no admin a matrix of runner indices. If the API query fails (e.g., no admin
access for instance-level runners), it falls back to the access for instance-level runners), it falls back to the
@@ -263,15 +272,15 @@ feature branches.
### Release Commit Detection ### 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 `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 a release commit (format: `release: vX.Y.Z`). When a release commit
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja) is detected, all subsequent steps in the `release-and-maintain` job (release, publish, sync-wiki, vikunja)
are skipped — the tag push triggers the publish workflow instead. are skipped — the tag push triggers the publish step instead.
### Badge Generation and Push ### 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: `devx.ci.push_badges` which:
1. Fetches the latest master and hard-resets to it (picks up release commits) 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` 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 4. Copies SVG files to the branch root
5. Force-pushes the branch to the remote 5. Force-pushes the branch to the remote
The badges job depends on the `release` job and uses `if: always()` so it The badges step runs with `if: always()` so it
runs even if release fails or is skipped. This ensures the version badge 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 always reflects the actual state of the repository after any release
commits have been pushed. 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`. 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 7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `devx.ci.pr_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 ### 1. Create Vikunja task
Create a task in Vikunja project 6 to get a `GRM-N` identifier. 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 3. Wait for all CI checks to pass
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated) 4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
5. The post-merge workflow marks the Vikunja task as done 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. > **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 (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 pull request**: No direct pushes to master
- **Require approval review**: At least 1 `APPROVE` review before merge - **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 - **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. The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,7 +16,7 @@
│ ├── ui.py # Colorised console output │ ├── ui.py # Colorised console output
│ └── translations.json # Translation strings │ └── translations.json # Translation strings
├── ansible/ ├── ansible/
│ ├── roles/gitea-runner/ # Main Ansible role │ ├── roles/gitea_runner/ # Main Ansible role
│ │ ├── defaults/main.yml # Default variables │ │ ├── defaults/main.yml # Default variables
│ │ ├── tasks/ # Task files (13 files) │ │ ├── tasks/ # Task files (13 files)
│ │ ├── templates/ # Jinja2 templates (4 files) │ │ ├── templates/ # Jinja2 templates (4 files)
@@ -222,7 +222,7 @@ The pre-commit hook runs actionlint automatically when workflow files change.
|--------|-------------| |--------|-------------|
| `make setup` | Full setup: venv, deps, hooks, CI tools | | `make setup` | Full setup: venv, deps, hooks, CI tools |
| `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) | | `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) |
| `make setup-quality` | Setup for the quality CI job (lint + test deps) | | `make setup-quality` | Setup for the validate CI job (lint + test deps) |
| `make setup-molecule` | Full setup for molecule testing | | `make setup-molecule` | Full setup for molecule testing |
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) | | `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` | | `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` |

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