Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e18d285ab | ||
|
|
f6ba60bda6 | ||
|
|
0545388b34 | ||
|
|
eb0a56350b | ||
|
|
f712a4493e | ||
|
|
9289b19162 | ||
|
|
185251090f | ||
|
|
fff930920f | ||
|
|
88eca1f6b8 | ||
|
|
1718a415c8 | ||
|
|
3f27ee1423 | ||
|
|
2cf267bace | ||
|
|
87513f9e8f | ||
|
|
8b1a959bc9 | ||
|
|
f6a4f1fe43 | ||
|
|
9ea7ae656b | ||
|
|
70240a13cf | ||
|
|
fd7b786db4 | ||
|
|
c4fe70979c | ||
|
|
dd9fc601fb | ||
|
|
62b37d045e | ||
|
|
390fcb9d4b | ||
|
|
9f8adf14bc | ||
|
|
92e4d2fd2b | ||
|
|
f30764d60f | ||
|
|
a0e3fc09c9 | ||
|
|
bb9e8e6c4b | ||
|
|
d8312ff62c | ||
|
|
39a8f20df2 | ||
|
|
240f08fff4 | ||
|
|
d38a64b0e1 | ||
|
|
89bd40c4a8 | ||
|
|
ab2101983f | ||
|
|
12f4aa4c92 | ||
|
|
60ad1ad30d | ||
|
|
0e64353b0d | ||
|
|
5956bb4fac |
@@ -76,7 +76,7 @@ If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
**Always run `make pytest-cov` before pushing** — CI enforces 100%
|
||||
coverage and will fail the PR if any lines are uncovered. This is the
|
||||
most common cause of CI quality job failures after code changes. The
|
||||
most common cause of CI validate job failures after code changes. The
|
||||
pre-push git hook only validates Vikunja task existence, not tests.
|
||||
|
||||
### API Response Type Checking
|
||||
|
||||
+12
-1
@@ -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.
|
||||
# Required scopes: read:user, read:repository, read:admin (or just "admin")
|
||||
# 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).
|
||||
# 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
|
||||
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)
|
||||
# Generate at: Vikunja → Settings → API Tokens
|
||||
# VIKUNJA_TOKEN=your-vikunja-api-token
|
||||
|
||||
+92
-166
@@ -6,21 +6,38 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
# Single validation job that merges: quality, detect-changes,
|
||||
# release-dry-run, pre-merge-check, pr-review, and discover-runners.
|
||||
# Uses ci-full image (has git-cliff for release-dry-run).
|
||||
# Saves ~5x checkout+setup overhead vs 6 separate jobs.
|
||||
validate:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
timeout-minutes: 10
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
runner-count: ${{ steps.discover-runners.outputs.runner-count }}
|
||||
runner-indices: ${{ steps.discover-runners.outputs.runner-indices }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci,lint
|
||||
# --- quality steps ---
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -30,37 +47,20 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
make pytest-cov
|
||||
- name: Documentation lint check
|
||||
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Documentation version reference check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_doc_versions --root . --package grm
|
||||
- name: Vale prose lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||
DEVX_DOC_VERSIONS_PKG: grm
|
||||
DEVX_VALE_LEVEL: warning
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Install vale if not present
|
||||
if ! command -v vale >/dev/null 2>&1; then
|
||||
python3 -m devx.tools.install_tools --tool vale
|
||||
fi
|
||||
vale sync
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md
|
||||
make devx-docs-check
|
||||
- name: Translation completeness check
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_translations --translations src/grm/translations.json
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
@@ -81,52 +81,10 @@ jobs:
|
||||
else
|
||||
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
|
||||
fi
|
||||
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_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
|
||||
# --- detect-changes step ---
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -134,22 +92,10 @@ jobs:
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
pre-merge-check:
|
||||
needs: [quality, detect-changes]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-image EXTRAS=ci
|
||||
# --- validate-pr + pr-review steps (PR only) ---
|
||||
- name: Validate auto-merge preconditions
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
@@ -157,7 +103,6 @@ jobs:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
PYTHONPATH: ${{ env.PYTHONPATH }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_auto_merge_ready \
|
||||
@@ -165,52 +110,65 @@ jobs:
|
||||
--pr-title "$PR_TITLE" \
|
||||
--repo "$REPOSITORY" \
|
||||
--pr-number "$PR_NUMBER"
|
||||
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
runner-count: ${{ steps.discover.outputs.runner-count }}
|
||||
runner-indices: ${{ steps.discover.outputs.runner-indices }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
- name: Run automated PR review
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
set -euo pipefail
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
# --- release-dry-run step (conditional) ---
|
||||
- name: Release dry-run validation
|
||||
if: steps.detect.outputs.user-facing-changed == 'true'
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Discover available runners
|
||||
id: discover
|
||||
DEVX_VERSION_FILE: src/grm/__init__.py
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run
|
||||
# --- discover-runners step (conditional on ansible-changed) ---
|
||||
- name: Discover available molecule runners
|
||||
id: discover-runners
|
||||
if: steps.detect.outputs.ansible-changed == 'true'
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.molecule.discover_runners \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
--github-output
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure --auto-login \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "ci/validate" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes, discover-runners]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
needs: [validate]
|
||||
if: needs.validate.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
max-parallel: 3
|
||||
max-parallel: 6
|
||||
matrix:
|
||||
runner-index: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci,molecule
|
||||
- name: Install Ansible collections
|
||||
@@ -220,16 +178,25 @@ jobs:
|
||||
- name: Discover assigned test pairs
|
||||
env:
|
||||
RUNNER_INDEX: ${{ matrix.runner-index }}
|
||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||
PYTHONPATH: src
|
||||
MAX_RUNNERS: 6
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.molecule.distribute_molecule \
|
||||
--runner-index "$RUNNER_INDEX" \
|
||||
--max-runners "$MAX_RUNNERS" \
|
||||
--github-env --skip-if-excess
|
||||
--github-env
|
||||
- name: Run molecule tests
|
||||
if: env.SKIP != 'true'
|
||||
env:
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
ANSIBLE_INJECT_INVOCATION: "1"
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||
@@ -237,61 +204,22 @@ jobs:
|
||||
echo "Docker not available in CI container — skipping molecule tests"
|
||||
exit 0
|
||||
fi
|
||||
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
_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
|
||||
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 runs after all CI checks pass. It reads the task ID
|
||||
# from the branch name, validates the PR title, and squash-merges.
|
||||
# Auto-merge runs after validate + molecule-tests pass (or molecule is skipped).
|
||||
# Uses always() so it evaluates even when molecule-tests is skipped
|
||||
# (Gitea Actions skips dependent jobs of skipped jobs by default).
|
||||
needs: [quality, detect-changes, pre-merge-check, pr-review, molecule-tests, release-dry-run]
|
||||
needs: [validate, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pre-merge-check.result == 'success' &&
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') &&
|
||||
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
@@ -302,18 +230,17 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.pr_review \
|
||||
@@ -322,12 +249,11 @@ jobs:
|
||||
--event APPROVE \
|
||||
--checklist-confirmed \
|
||||
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||
--body "Auto-approved: all CI checks passed (quality, molecule, pr-review, pre-merge-check)."
|
||||
--body "Auto-approved: all CI checks passed (validate, molecule-tests)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
|
||||
+93
-225
@@ -1,115 +1,136 @@
|
||||
name: Post-merge
|
||||
|
||||
# Runs on every push to master. A single workflow with conditional jobs
|
||||
# for release, publish, wiki sync, badges, and Vikunja task updates.
|
||||
# Runs on every push to master (after CI workflow merges a PR).
|
||||
# Consolidated into 2 jobs (from 7) to reduce runner overhead:
|
||||
# detect-and-configure ──→ release-and-maintain
|
||||
#
|
||||
# Job dependency graph:
|
||||
# Job 1: detect release commit, validate commit msg, configure repo
|
||||
# (branch protection, labels).
|
||||
# Job 2: release + publish + sync-wiki + vikunja + badges.
|
||||
# Individual steps are conditional on job 1 outputs.
|
||||
#
|
||||
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
# ├── release (skip if release commit)
|
||||
# │ └── publish (needs release — builds & publishes to PyPI)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||
# └── vikunja (skip if release commit — runs for ALL merges)
|
||||
#
|
||||
# sync-wiki and vikunja run for ALL non-release commits, not just when
|
||||
# release succeeds. This ensures the wiki and task tracker are updated
|
||||
# even for infrastructure-only changes (docs, CI config, etc.).
|
||||
#
|
||||
# The badges job uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This ensures
|
||||
# badges (tests, coverage, version, etc.) are always current.
|
||||
# The badges step always runs (even on release commits) so version
|
||||
# badge picks up the new __version__. It runs last so it sees the
|
||||
# new version if release created one.
|
||||
#
|
||||
# When release creates a "release: vX.Y.Z" commit and tag, the publish
|
||||
# job (which depends on release) builds and publishes the package to the
|
||||
# Gitea PyPI registry. The release commit's post-merge run still updates
|
||||
# badges (version badge picks up the new version). Other jobs skip.
|
||||
# step builds and publishes the package to the Gitea PyPI registry.
|
||||
# The release commit's post-merge run still updates badges. Other
|
||||
# steps (sync-wiki, vikunja) skip on release commits.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: post-merge-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
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 }}
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
detect-and-configure:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
is-automated: ${{ steps.check.outputs.is-automated }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
DEVX_REPO_NAME: grm
|
||||
DEVX_REPO_OWNER: oblachno-oss
|
||||
DEVX_STATUS_CHECKS: "CI / validate (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.configure_repo
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci
|
||||
- name: Validate latest commit message
|
||||
if: steps.check.outputs.is-automated == 'false'
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
if: steps.check.outputs.is-release == 'false'
|
||||
env:
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "HEAD~1" \
|
||||
--head "HEAD" \
|
||||
--github-output
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure --auto-login \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/detect-and-configure" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
release:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
release-and-maintain:
|
||||
needs: [detect-and-configure]
|
||||
if: always() && needs.detect-and-configure.result == 'success'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
ref: master
|
||||
token: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: make setup-image EXTRAS=ci,lint
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
# --- release + publish (only if not a release commit) ---
|
||||
- name: Run release
|
||||
id: release-tag
|
||||
if: needs.detect-and-configure.outputs.is-release == 'false' && needs.detect-and-configure.outputs.user-facing-changed == 'true'
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_VERSION_FILE: src/grm/__init__.py
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
@@ -117,207 +138,54 @@ jobs:
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_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
|
||||
if: steps.release-tag.outputs.tag != ''
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
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.publish \
|
||||
"${{ needs.release.outputs.tag }}" \
|
||||
"${{ github.repository }}" --auto-login
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure --auto-login \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
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
|
||||
git fetch --tags
|
||||
git checkout "${{ steps.release-tag.outputs.tag }}"
|
||||
python3 -m devx.ci.publish "${{ steps.release-tag.outputs.tag }}" "${{ github.repository }}" --auto-login
|
||||
# --- sync-wiki + vikunja (skip on automated/release commits) ---
|
||||
- name: Sync documentation to wiki
|
||||
if: needs.detect-and-configure.outputs.is-automated == 'false'
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||
- 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
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
|
||||
- name: Update Vikunja task
|
||||
if: needs.detect-and-configure.outputs.is-automated == 'false'
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
# --- badges (always run — even on release commits) ---
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
CI_GITEA_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/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)"
|
||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.configure_repo
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Fetch latest master to pick up any release commit that was pushed
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
python3 -m devx.ci.push_badges
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
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/configure-repo" \
|
||||
--workflow "post-merge/release-and-maintain" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
@@ -81,11 +81,12 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-docs
|
||||
name: documentation lint check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||
- id: docs-check
|
||||
name: documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||
entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_DOC_VERSIONS_PKG=grm DEVX_VALE_LEVEL=warning make devx-docs-check'
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
|
||||
@@ -34,14 +34,17 @@ write-good.E-Prime = NO
|
||||
write-good.So = NO
|
||||
write-good.ThereIs = NO
|
||||
write-good.TooWordy = NO
|
||||
write-good.Passive = NO
|
||||
|
||||
# Vale defaults — spelling catches too many technical terms
|
||||
Vale.Terms = NO
|
||||
Vale.Repetition = NO
|
||||
Vale.Spelling = NO
|
||||
|
||||
# Readability — warnings only, technical docs are naturally complex
|
||||
# Readability — technical docs are naturally complex, downgrade to suggestions
|
||||
Readability.FleschReadingEase = suggestion
|
||||
Readability.FleschKincaid = suggestion
|
||||
Readability.AutomatedReadability = suggestion
|
||||
Readability.ColemanLiau = suggestion
|
||||
Readability.LIX = suggestion
|
||||
Readability.GunningFog = suggestion
|
||||
|
||||
@@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
|
||||
level: warning
|
||||
scope: raw
|
||||
raw:
|
||||
- '(?s)```\n(?!.*```)'
|
||||
- '(?ms)^\n```\n.*?^```\s*$'
|
||||
|
||||
@@ -2,7 +2,7 @@ Based on [write-good](https://github.com/btford/write-good).
|
||||
|
||||
> Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too.
|
||||
|
||||
```text
|
||||
```
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Brian Ford
|
||||
|
||||
@@ -51,13 +51,13 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
||||
|
||||
Both run via `make workflow-check` and are part of `make lint-all`.
|
||||
The pre-commit hook runs actionlint automatically when workflow files change.
|
||||
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
|
||||
The CI `validate` job runs `make setup-image` (which installs all tools) then `make lint-all`.
|
||||
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Python CLI** (`src/grm/`) — Click-based CLI that delegates to Ansible
|
||||
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
|
||||
- **Ansible Role** (`ansible/roles/gitea_runner/`) — Idempotent role for rootless Docker runner setup with pasta networking (IPv6 support)
|
||||
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
||||
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
||||
|
||||
@@ -69,13 +69,13 @@ Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
Branch protection and labels are automatically configured by
|
||||
`devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`),
|
||||
which runs as a `configure-repo` job in
|
||||
which runs as a step in the `detect-and-configure` job in
|
||||
the post-merge workflow on every push to master.
|
||||
|
||||
The following rules are enforced for `master`:
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Require status checks**: CI validate + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically
|
||||
@@ -84,6 +84,12 @@ as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
### 1. Create Vikunja Task
|
||||
Create a task in Vikunja project 6 via `make create-task -- --title "Task title" --description "<h2>...</h2>"` (requires `VIKUNJA_TOKEN` in `.env`). This prints the `GRM-N` identifier and next-step instructions.
|
||||
|
||||
**IMPORTANT:** The task title must NOT include the `GRM-N:` prefix.
|
||||
The `make create-pr` and `check_auto_merge_ready` commands automatically
|
||||
prepend `GRM-N: ` to the Vikunja task title when forming the PR title.
|
||||
If the Vikunja task title already includes the prefix, the PR title will
|
||||
have a double prefix and auto-merge validation will fail.
|
||||
|
||||
### 2. Create Branch
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
@@ -117,8 +123,9 @@ architecture, code quality, security, i18n, testing, performance,
|
||||
UX, documentation, workflow compliance, maintainability, resource
|
||||
management, backwards compatibility, and logging.
|
||||
|
||||
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
||||
review via `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`). This job posts a review with
|
||||
**Automated review (CI `validate` job):** Every PR triggers an automated
|
||||
review via `python -m devx.ci.pr_review` as a step in the `validate` job.
|
||||
This posts a review with
|
||||
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
|
||||
the **[auto]** items in the checklist:
|
||||
|
||||
@@ -166,16 +173,16 @@ it attests that the reviewer has gone through every checklist category.
|
||||
The `--checklist-categories` flag is also **required** — it must list at
|
||||
least 8 of the 13 category numbers, ensuring the reviewer actually
|
||||
checked each category rather than rubber-stamping. The review body must
|
||||
be substantive (> 50 characters) — trivial approvals like "LGTM" are
|
||||
be substantive (> 50 characters) — perfunctory approvals like "LGTM" are
|
||||
rejected.
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
|
||||
2. **Check** that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
|
||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||
3. Wait for all CI checks to pass (including the `validate` job)
|
||||
4. Squash-merge with title: `GRM-N: <conventional commit message>`
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes (see below)
|
||||
6. The release-and-maintain job automatically versions, tags, and publishes (see below)
|
||||
|
||||
**If the branch is behind master** (another PR merged first), auto-merge
|
||||
automatically rebases the PR's head branch via the Gitea API. This triggers
|
||||
@@ -191,21 +198,22 @@ No manual rebase needed. To rebase manually: `make rebase` (local) or
|
||||
|
||||
### CI Path Filtering
|
||||
|
||||
The CI workflow includes a `pre-merge-check` job (runs after quality +
|
||||
detect-changes) that validates branch format, PR title, and Vikunja task
|
||||
match. This fails fast before expensive molecule tests run.
|
||||
The CI workflow's `validate` job includes a pre-merge validation step
|
||||
that validates branch format, PR title, and Vikunja task match. This
|
||||
fails fast before expensive molecule tests run.
|
||||
|
||||
The CI workflow includes a `detect-changes` job that checks whether any files
|
||||
under `ansible/` or `.ansible-lint` have changed. If no Ansible files are
|
||||
changed, molecule tests are skipped — this prevents non-Ansible changes
|
||||
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule
|
||||
test infrastructure flakiness.
|
||||
The `validate` job also includes a `detect-changes` step that checks
|
||||
whether any files under `ansible/` or `.ansible-lint` have changed. If
|
||||
no Ansible files are changed, molecule tests are skipped — this prevents
|
||||
non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from
|
||||
being blocked by molecule test infrastructure flakiness.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically via `devx.molecule.discover_runners`. The `discover-runners`
|
||||
job queries the Gitea API for runners at all levels (repo, org, instance)
|
||||
dynamically via `devx.molecule.discover_runners`. The `validate` job
|
||||
includes a `discover-runners` step (conditional on ansible-changed) that
|
||||
queries the Gitea API for runners at all levels (repo, org, instance)
|
||||
and generates a dynamic matrix. If the API can't see instance-level runners
|
||||
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
|
||||
then to a default of 3.
|
||||
@@ -218,47 +226,27 @@ then to a default of 3.
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the **post-merge workflow**
|
||||
(`.gitea/workflows/post-merge.yml`) runs automatically. This single
|
||||
workflow consolidates release, wiki sync, badge generation, and
|
||||
Vikunja task updates:
|
||||
(`.gitea/workflows/post-merge.yml`) runs automatically. Consolidated
|
||||
into 2 jobs (from 7) to reduce runner overhead:
|
||||
|
||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||
release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||
1. **detect-and-configure** — Configures repo (branch protection, labels),
|
||||
detects release commit, validates commit message. Outputs `is-release`
|
||||
and `is-automated` for the next job.
|
||||
|
||||
2. **release** — Runs `devx.ci.release` which:
|
||||
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
|
||||
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
|
||||
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
||||
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||
- Updates `__version__` in `src/grm/__init__.py` (single source of truth)
|
||||
- Updates `CHANGELOG.md` with the new version section
|
||||
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **aborts immediately** — no commit, no tag
|
||||
- Commits with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents
|
||||
re-triggering post-merge on the release commit)
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
||||
non-release commits (not just 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 just when release succeeds), so infrastructure-only
|
||||
changes still update the task tracker.
|
||||
|
||||
6. **publish** — Runs after release succeeds (needs: release). Builds and
|
||||
publishes the package to the Gitea PyPI registry. Gets the tag from the
|
||||
release job's `tag` output.
|
||||
2. **release-and-maintain** — Runs all post-merge maintenance as
|
||||
conditional steps:
|
||||
- **release** (if not a release commit) — Runs `devx.ci.release` which
|
||||
checks for user-facing changes via `classify_changes` (skips if only
|
||||
workflow/infrastructure files changed), uses git-cliff for semver,
|
||||
updates `__version__`, updates `CHANGELOG.md`, runs lint+tests, commits
|
||||
with `release: vX.Y.Z [skip ci]`, creates annotated tag, pushes to master.
|
||||
- **publish** (if release created a tag) — Builds and publishes the
|
||||
package to the Gitea PyPI registry. Checks out the release tag
|
||||
within the same job.
|
||||
- **sync-wiki** (if not automated) — Syncs documentation to the Gitea wiki.
|
||||
- **vikunja** (if not automated) — Marks the corresponding Vikunja task as done.
|
||||
- **badges** (always) — Generates and pushes quality badge SVGs to the
|
||||
`badges` branch. Fetches latest master first to pick up release commits.
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
@@ -379,9 +367,9 @@ platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
|
||||
`devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
|
||||
avoids dev tools importing directly from CI modules.
|
||||
|
||||
2. **Publish job** (in `post-merge.yml`, needs: release):
|
||||
- Runs after the release job creates a tag
|
||||
- Gets the tag from `needs.release.outputs.tag`
|
||||
2. **Publish step** (in the `release-and-maintain` job, runs after the release step creates a tag):
|
||||
- Runs after the release step creates a tag
|
||||
- Gets the tag from the release step's output
|
||||
- Builds the Python package
|
||||
- Publishes to the Gitea PyPI registry
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
@@ -486,7 +474,13 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they only create files)
|
||||
- On Arch Linux, `rootless_docker.yml` fetches the rootless setup scripts
|
||||
(`dockerd-rootless-setuptool.sh`, `dockerd-rootless.sh`) from `moby/moby` `contrib/`
|
||||
at a pinned ref (`gitea_runner_rootless_scripts_ref`) into `/usr/bin` and installs
|
||||
`rootlesskit` — Arch's `docker` package ships neither. These fetch tasks run
|
||||
regardless of `docker_rootless_setup` so CI exercises them on the archlinux platform.
|
||||
See ADR-011 in the decision log.
|
||||
|
||||
## Molecule Scenarios
|
||||
|
||||
@@ -536,7 +530,7 @@ docs/
|
||||
### Documentation Coverage
|
||||
|
||||
- `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented
|
||||
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
|
||||
- Runs as a CI step in the validate job with `--fail-on-missing` (blocks CI if docs are missing)
|
||||
- Enforced: 100% coverage for public CLI commands and major architectural components
|
||||
|
||||
### Updating Documentation
|
||||
@@ -566,7 +560,7 @@ the user should not need to specify which profile to use.
|
||||
|
||||
| Profile | Purpose |
|
||||
|---------|---------|
|
||||
| `ci-investigator` | Investigate CI failures (quality, molecule, release, publish, wiki sync) |
|
||||
| `ci-investigator` | Investigate CI failures (validate, molecule-tests, release-and-maintain) |
|
||||
| `molecule-runner` | Run 7 molecule scenarios across 4 platforms, report pass/fail |
|
||||
| `dep-upgrader` | Python + Ansible dependency upgrades with molecule verification |
|
||||
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync for grm docs |
|
||||
@@ -576,7 +570,7 @@ the user should not need to specify which profile to use.
|
||||
|
||||
| Trigger | Profile | Mode |
|
||||
|---------|---------|------|
|
||||
| CI run failure (quality, molecule-tests, release, publish, sync-wiki) | `ci-investigator` | Background |
|
||||
| CI run failure (validate, molecule-tests, release-and-maintain) | `ci-investigator` | Background |
|
||||
| PR ready for review | `pr-reviewer` | Foreground |
|
||||
| Molecule tests need to run | `molecule-runner` | Background |
|
||||
| Dependency upgrade requested | `dep-upgrader` | Background |
|
||||
@@ -590,7 +584,7 @@ the user should not need to specify which profile to use.
|
||||
2. **Background by default, foreground when blocking.**
|
||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
||||
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
|
||||
5. **Don't delegate trivial work** (<30s, <50 lines of context).
|
||||
5. **Don't delegate minor work** (<30s, <50 lines of context).
|
||||
6. **Compact after subagent returns.**
|
||||
7. **Never skip delegation to save time** — it keeps main context small.
|
||||
|
||||
@@ -602,8 +596,9 @@ tool, workflow, or process issues that warrant follow-up. These issues
|
||||
use the `feedback` label plus a category label (`tooling`,
|
||||
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
|
||||
|
||||
Standard labels are created automatically by `configure_repo` (runs in
|
||||
post-merge on every master push). If a label does not exist yet, the
|
||||
Standard labels are created automatically by `configure_repo` (runs as
|
||||
a step in `detect-and-configure` in post-merge on every master push).
|
||||
If a label does not exist yet, the
|
||||
subagent's issue creation will still succeed — labels can be added
|
||||
afterwards.
|
||||
|
||||
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.18.2] - 2026-07-16
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Load tun module and pre-configure systemd override for Arch rootless Docker
|
||||
|
||||
## [0.18.1] - 2026-07-16
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fetch rootless Docker scripts on Arch Linux
|
||||
|
||||
## [0.18.0] - 2026-07-12
|
||||
|
||||
### Features
|
||||
|
||||
- *(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
|
||||
|
||||
### Features
|
||||
|
||||
- Consolidate docs checks into devx-docs-check target
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Replace --strict with --verify for sync_wiki
|
||||
|
||||
## [0.15.0] - 2026-07-06
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release setup-image install update lint ansible-lint makefile-lint lint-all lint-ruff lint-format lint-bandit lint-deps typecheck checkmake install-hooks test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools check-api-identity-checks
|
||||
.PHONY: configure-gitea-pypi
|
||||
.PHONY: create-task create-pr push-with-pr git-push
|
||||
.PHONY: check-docs docs-check
|
||||
|
||||
PYTHON := python3
|
||||
VENV := .venv
|
||||
@@ -49,13 +50,14 @@ setup: $(VENV)/bin/activate .env activate-scripts configure-gitea-pypi
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install
|
||||
|
||||
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
|
||||
# badges job runs generate_badges.py which needs ruff, pyright, bandit
|
||||
# (validate job steps: detect-changes, discover-runners, pr-review;
|
||||
# release-and-maintain job steps: sync-wiki, badges)
|
||||
# badges step runs generate_badges.py which needs ruff, pyright, bandit
|
||||
setup-ci: $(VENV)/bin/activate .env configure-gitea-pypi
|
||||
@$(PIP_INSTALL) install -e '.[ci,lint]'
|
||||
@$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --skip-install --no-ansible-collections --no-pre-commit --no-tea-login
|
||||
|
||||
# Setup for the quality job (lint + test deps, actionlint tool)
|
||||
# Setup for the validate CI job (lint + test deps, actionlint tool)
|
||||
setup-quality: $(VENV)/bin/activate .env configure-gitea-pypi
|
||||
@$(PIP_INSTALL) install -e '.[ci,lint]'
|
||||
@$(BIN)/python -m devx.tools.install_tools
|
||||
@@ -85,7 +87,8 @@ setup-release: $(VENV)/bin/activate .env configure-gitea-pypi
|
||||
# the venv symlink first, then installs the project.
|
||||
setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$$CI_GITEA_TOKEN@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
|
||||
_TOKEN="$$CI_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -n "$$_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$CI_GITEA_USERNAME:$${_TOKEN}@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"; fi; \
|
||||
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
@@ -158,10 +161,9 @@ workflow-dryrun: devx-workflow-dryrun
|
||||
workflow-check: devx-workflow-check
|
||||
|
||||
configure-gitea-pypi:
|
||||
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
|
||||
_TOKEN="$$CI_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; [ -z "$$_TOKEN" ] && _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; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)."
|
||||
|
||||
ansible-lint:
|
||||
PATH="$(PWD)/$(BIN):$$PATH" $(BIN)/ansible-lint ansible/
|
||||
@@ -182,7 +184,7 @@ test-integration:
|
||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||
|
||||
MOLECULE := $(realpath $(BIN))/molecule
|
||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea_runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||
|
||||
# Quick local test: Ubuntu 22.04 only, all scenarios
|
||||
molecule:
|
||||
@@ -202,3 +204,7 @@ create-task: devx-create-task
|
||||
create-pr: devx-create-pr
|
||||
push-with-pr: devx-push-with-pr
|
||||
git-push: devx-push
|
||||
|
||||
# --- Documentation checks (via devx.mak fragment) -----------------------------
|
||||
check-docs: devx-check-docs
|
||||
docs-check: devx-docs-check
|
||||
|
||||
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why GRM?
|
||||
|
||||
@@ -147,7 +147,7 @@ GRM provides a single `grm` command with subcommands for the full runner lifecyc
|
||||
| `grm enable <name>` | Enable a runner to start on boot |
|
||||
| `grm disable <name>` | Disable and deregister a runner |
|
||||
| `grm status <name>` | Check the status of a registered runner |
|
||||
| `grm remove <name>` | Remove a runner completely (with remote cleanup) |
|
||||
| `grm remove <name>` | Remove a runner entirely (with remote cleanup) |
|
||||
| `grm remove <name> --force` | Remove only the local registry entry (skip remote cleanup) |
|
||||
| `grm list` | List all registered runners with live status |
|
||||
| `grm list --no-status` | List registered runners without SSH status checks |
|
||||
@@ -187,7 +187,7 @@ GRM reads configuration from a `.env` file in the current directory (loaded auto
|
||||
|
||||
### Sudo Password Handling
|
||||
|
||||
GRM delegates remote operations to Ansible, which uses `sudo` (become) on the target host. There are several ways to provide the sudo password, in priority order:
|
||||
GRM delegates remote operations to Ansible, which uses `sudo` (become) on the target host. There are multiple ways to provide the sudo password, in priority order:
|
||||
|
||||
1. **`--become-password-file <path>`** (CLI flag, global) — Read sudo password from a file. Works for all commands including `grm list`.
|
||||
2. **`GRM_BECOME_PASSWORD_FILE`** (env var) — Same as above, set in `.env` or environment.
|
||||
@@ -266,8 +266,8 @@ One of GRM's core features is the ability to run multiple isolated runners on th
|
||||
|
||||
- **Dedicated system user**: `grm-<name>` with its own home directory at `/home/grm-<name>/`
|
||||
- **Rootless Docker daemon**: Isolated Docker socket at `/run/user/<UID>/docker.sock`
|
||||
- **Data directory**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config directory**: `/etc/gitea-runner/<name>/`
|
||||
- **Data directory**: `/var/lib/gitea_runner/<name>/`
|
||||
- **Config directory**: `/etc/gitea_runner/<name>/`
|
||||
- **Systemd user service**: `gitea-runner.service` (independent start/stop/enable)
|
||||
- **Docker prune timer**: Per-instance daily cleanup
|
||||
|
||||
@@ -341,13 +341,13 @@ GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/grm/`) — Built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess. Secrets are passed via temporary JSON files to avoid exposure in the process list.
|
||||
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
|
||||
2. **Ansible Role** (`ansible/roles/gitea_runner/`) — Idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, registers the runner with Gitea, and sets up a Docker prune timer.
|
||||
|
||||
```text
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
└── role: gitea_runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Stop gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user stop gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
@@ -21,7 +21,7 @@
|
||||
- name: Stop and disable healthcheck timer
|
||||
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
@@ -30,14 +30,14 @@
|
||||
|
||||
- name: Include deregistration
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: deregister.yml
|
||||
when: not skip_runner_registration | default(false)
|
||||
when: not gitea_runner_skip_registration | default(false)
|
||||
|
||||
- name: Disable gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user disable gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Enable gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user enable gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
@@ -21,7 +21,7 @@
|
||||
- name: Start gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user start gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
hosts: all
|
||||
become: true
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
- role: gitea_runner
|
||||
|
||||
+34
-34
@@ -6,11 +6,11 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Get runner user UID
|
||||
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
ansible.builtin.command: id -u "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
register: runner_uid_result
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
@@ -23,20 +23,20 @@
|
||||
- name: Stop gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user stop gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
when: gitea_runner_systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Disable gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user disable gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
when: gitea_runner_systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
args:
|
||||
executable: /bin/bash
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
|
||||
@@ -57,7 +57,7 @@
|
||||
- name: Prune all Docker images, volumes, and build cache (rootless)
|
||||
ansible.builtin.command: docker system prune -af --volumes
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default('') }}/docker.sock"
|
||||
@@ -67,7 +67,7 @@
|
||||
- name: Stop rootless Docker daemon
|
||||
ansible.builtin.command: systemctl --user stop docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
changed_when: true
|
||||
@@ -75,120 +75,120 @@
|
||||
|
||||
- name: Include deregistration
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: deregister.yml
|
||||
when: not skip_runner_registration | default(false)
|
||||
when: not gitea_runner_skip_registration | default(false)
|
||||
|
||||
- name: Stop and disable healthcheck timer
|
||||
ansible.builtin.command: systemctl --user stop --disable runner-healthcheck.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
when: gitea_runner_systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Remove docker-prune user service file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.service"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/docker-prune.service"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove docker-prune user timer file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/docker-prune.timer"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/docker-prune.timer"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck user service file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.service"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/runner-healthcheck.service"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck user timer file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/runner-healthcheck.timer"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove healthcheck script
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}/healthcheck.sh"
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_runner_name) }}/healthcheck.sh"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove systemd user unit file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user/gitea-runner.service"
|
||||
state: absent
|
||||
when: remove_systemd_template | default(true)
|
||||
|
||||
- name: Kill remaining processes of runner user
|
||||
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
failed_when: false
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for processes to terminate
|
||||
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
ansible.builtin.command: "pkill -u {{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Disable lingering for runner user
|
||||
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
ansible.builtin.command: loginctl disable-linger "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
failed_when: false
|
||||
changed_when: true
|
||||
|
||||
- name: Remove runner user and home directory
|
||||
ansible.builtin.user:
|
||||
name: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
name: "{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}"
|
||||
state: absent
|
||||
remove: true
|
||||
when: remove_runner_user | default(true)
|
||||
when: gitea_runner_remove_user | default(true)
|
||||
failed_when: false
|
||||
|
||||
- name: Remove Docker data root when user is kept
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.local/share/docker"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.local/share/docker"
|
||||
state: absent
|
||||
when: not (remove_runner_user | default(true))
|
||||
when: not (gitea_runner_remove_user | default(true))
|
||||
failed_when: false
|
||||
|
||||
- name: Remove act cache when user is kept
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.cache/act"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.cache/act"
|
||||
state: absent
|
||||
when: not (remove_runner_user | default(true))
|
||||
when: not (gitea_runner_remove_user | default(true))
|
||||
failed_when: false
|
||||
|
||||
- name: Remove systemd user config dir when user is kept
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user"
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ gitea_runner_name) }}/.config/systemd/user"
|
||||
state: absent
|
||||
when: not (remove_runner_user | default(true))
|
||||
when: not (gitea_runner_remove_user | default(true))
|
||||
failed_when: false
|
||||
|
||||
- name: Remove subuid entry for runner user
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/subuid
|
||||
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
|
||||
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove subgid entry for runner user
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/subgid
|
||||
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}:"
|
||||
regexp: "^{{ gitea_runner_service_user | default('grm-' ~ gitea_runner_name) }}:"
|
||||
state: absent
|
||||
failed_when: false
|
||||
|
||||
- name: Remove runner data directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}"
|
||||
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ gitea_runner_name) }}"
|
||||
state: absent
|
||||
|
||||
- name: Remove runner config directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}"
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ gitea_runner_name) }}"
|
||||
state: absent
|
||||
|
||||
@@ -2,6 +2,6 @@ collections:
|
||||
- name: community.general
|
||||
version: "==13.1.0"
|
||||
- name: ansible.posix
|
||||
version: "==2.2.0"
|
||||
version: "==2.2.1"
|
||||
- name: community.docker
|
||||
version: "==5.2.1"
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Resolve runner UID
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: resolve_uid.yml
|
||||
|
||||
- name: Stop gitea-runner user service
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
gitea_runner_version: "1.0.8"
|
||||
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-26.04"
|
||||
skip_runner_registration: false
|
||||
|
||||
# Per-runner user (rootless isolation)
|
||||
gitea_runner_user_prefix: "grm-"
|
||||
gitea_runner_base_home: "/home"
|
||||
gitea_runner_service_user: "{{ gitea_runner_user_prefix }}{{ runner_name }}"
|
||||
gitea_runner_home: "{{ gitea_runner_base_home }}/{{ gitea_runner_service_user }}"
|
||||
|
||||
# Base paths (instance-scoped via runner_name)
|
||||
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
|
||||
gitea_runner_base_config_dir: "/etc/gitea-runner"
|
||||
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
|
||||
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
|
||||
gitea_runner_binary_path: "/usr/local/bin/gitea_runner"
|
||||
|
||||
# Prune configuration
|
||||
gitea_runner_prune_until: "24h"
|
||||
gitea_runner_prune_schedule: "daily"
|
||||
gitea_runner_prune_label: "gitea-runner=true"
|
||||
|
||||
# Service configuration
|
||||
gitea_runner_service_restart_sec: "5"
|
||||
|
||||
# Health check configuration
|
||||
gitea_runner_healthcheck_interval: "5min"
|
||||
gitea_runner_healthcheck_boot_delay: "2min"
|
||||
gitea_runner_healthcheck_disk_threshold: 85
|
||||
gitea_runner_healthcheck_script_path: "{{ gitea_runner_config_dir }}/healthcheck.sh"
|
||||
|
||||
# Admin token for runner deregistration via Gitea API.
|
||||
# If not set, falls back to registration_token (which likely lacks admin scope).
|
||||
# Set this to a token with admin scope to enable automatic runner cleanup on removal.
|
||||
gitea_admin_token: ""
|
||||
|
||||
# Removal defaults
|
||||
remove_systemd_template: true
|
||||
remove_runner_user: true
|
||||
|
||||
# Runner configuration
|
||||
gitea_runner_log_level: "info"
|
||||
gitea_runner_container_label: "gitea-runner=true"
|
||||
gitea_runner_file: ".runner"
|
||||
|
||||
# Docker installation (for rootless dependencies)
|
||||
docker_gpg_key_path: "/etc/apt/keyrings/docker.gpg"
|
||||
docker_apt_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ansible_facts['architecture'] }}"
|
||||
docker_apt_source_line: >-
|
||||
deb [arch={{ docker_apt_arch }} signed-by={{ docker_gpg_key_path }}]
|
||||
https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}
|
||||
{{ ansible_facts['distribution_release'] }} stable
|
||||
# Set to false in CI/molecule to skip rootless daemon startup (needs kernel userns)
|
||||
docker_rootless_setup: true
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "molecule-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "deregister-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "lifecycle-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "remove-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "template-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "update-test-runner"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
- name: Check if runner registration file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_stat
|
||||
|
||||
- name: Read runner registration file
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_content
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Parse runner registration data
|
||||
ansible.builtin.set_fact:
|
||||
runner_reg: >
|
||||
{{ (runner_file_content.content | b64decode | from_json)
|
||||
if (runner_file_content is defined and runner_file_content.content is defined)
|
||||
else {} }}
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Deregister runner from Gitea via API
|
||||
ansible.builtin.command: >
|
||||
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
|
||||
-H "Authorization: token {{ gitea_admin_token | default(registration_token) }}"
|
||||
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ runner_reg.id }}"
|
||||
args:
|
||||
chdir: "{{ gitea_runner_data_dir }}"
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- runner_reg.id is defined
|
||||
register: deregister_output
|
||||
changed_when: deregister_output.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: Warn if deregistration failed
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
WARNING: Runner deregistration from Gitea failed (rc={{ deregister_output.rc | default('N/A') }}).
|
||||
The runner entry may remain in Gitea's admin UI as offline.
|
||||
Use an admin token (gitea_admin_token var) to enable automatic cleanup,
|
||||
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
|
||||
when:
|
||||
- runner_file_stat.stat.exists | default(false) | bool
|
||||
- not skip_runner_registration
|
||||
- deregister_output is defined
|
||||
- deregister_output.rc | default(1) != 0
|
||||
|
||||
- name: Remove runner registration file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
state: absent
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
- name: Check runner registration file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_stat
|
||||
|
||||
- name: Read runner registration file
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_content
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Parse runner registration data
|
||||
ansible.builtin.set_fact:
|
||||
runner_reg: >
|
||||
{{ (runner_file_content.content | b64decode | from_json)
|
||||
if (runner_file_content is defined and runner_file_content.content is defined)
|
||||
else {} }}
|
||||
when: runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Verify runner user service active
|
||||
ansible.builtin.command: systemctl --user is-active gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
register: service_check
|
||||
changed_when: false
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
- name: Validate runner installation
|
||||
ansible.builtin.fail:
|
||||
msg: >
|
||||
Runner '{{ runner_name }}' is not properly installed:
|
||||
{% if not (runner_file_stat.stat.exists | default(false)) %}
|
||||
- Registration file (.runner) is missing. Registration may have failed.
|
||||
{% endif %}
|
||||
{% if docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active' %}
|
||||
- Systemd user service is not active.
|
||||
{% endif %}
|
||||
when: >
|
||||
not (runner_file_stat.stat.exists | default(false))
|
||||
or (docker_rootless_setup and not (service_check.stdout | default('') | trim) == 'active')
|
||||
|
||||
- name: Report runner status
|
||||
ansible.builtin.debug:
|
||||
msg: >
|
||||
Runner '{{ runner_name }}' is installed and running.
|
||||
Registered: {{ runner_file_stat.stat.exists | default(false) }}
|
||||
{% if runner_reg.id is defined %}Runner ID: {{ runner_reg.id }}{% endif %}
|
||||
{% if runner_reg.uuid is defined %}UUID: {{ runner_reg.uuid }}{% endif %}
|
||||
{% if runner_reg.address is defined %}Gitea: {{ runner_reg.address }}{% endif %}
|
||||
Service: {{ service_check.stdout | default('unknown') | trim }}
|
||||
|
||||
- name: Optional Gitea API verification
|
||||
when:
|
||||
- gitea_url is defined
|
||||
- gitea_admin_token is defined
|
||||
- gitea_admin_token | length > 0
|
||||
block:
|
||||
- name: Check admin runners API
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/admin/runners"
|
||||
headers:
|
||||
Authorization: "token {{ gitea_admin_token }}"
|
||||
method: GET
|
||||
status_code: [200, 401, 403, 404]
|
||||
return_content: true
|
||||
body_format: json
|
||||
register: admin_api_response
|
||||
ignore_errors: true
|
||||
|
||||
- name: Check repo runners API
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
|
||||
headers:
|
||||
Authorization: "token {{ gitea_admin_token }}"
|
||||
method: GET
|
||||
status_code: [200, 401, 403, 404]
|
||||
return_content: true
|
||||
body_format: json
|
||||
register: repo_api_response
|
||||
ignore_errors: true
|
||||
|
||||
- name: Report API status (informational only)
|
||||
ansible.builtin.debug:
|
||||
msg: >
|
||||
API checks (informational only — not used for pass/fail):
|
||||
Admin API: {{ admin_api_response.status | default('no response') }}.
|
||||
Repo API: {{ repo_api_response.status | default('no response') }}.
|
||||
{% if admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
|
||||
Runner found in admin API.
|
||||
{% endif %}
|
||||
{% if repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', runner_name) | list | length > 0 %}
|
||||
Runner found in repo API.
|
||||
{% endif %}
|
||||
rescue:
|
||||
- name: API check failed
|
||||
ansible.builtin.debug:
|
||||
msg: "API verification skipped due to connection or permission error."
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
- name: Ensure keyrings directory exists (Debian/Ubuntu)
|
||||
ansible.builtin.file:
|
||||
path: "/etc/apt/keyrings"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
|
||||
args:
|
||||
creates: "{{ docker_gpg_key_path }}"
|
||||
executable: /bin/bash
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Add Docker APT repository (Debian/Ubuntu)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/docker.list
|
||||
content: "{{ docker_apt_source_line }}\n"
|
||||
mode: "0644"
|
||||
register: docker_apt_repo
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
when:
|
||||
- ansible_facts['os_family'] == 'Debian'
|
||||
- docker_apt_repo is changed
|
||||
|
||||
- name: Install rootless Docker dependencies (Debian/Ubuntu)
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- uidmap
|
||||
- slirp4netns
|
||||
- fuse-overlayfs
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- docker-ce-rootless-extras
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
- rsync
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Update pacman cache (Arch Linux)
|
||||
community.general.pacman:
|
||||
update_cache: true
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
changed_when: false
|
||||
|
||||
- name: Install rootless Docker dependencies (Arch Linux)
|
||||
community.general.pacman:
|
||||
name:
|
||||
- docker
|
||||
- docker-compose
|
||||
- slirp4netns
|
||||
- fuse-overlayfs
|
||||
- rsync
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
|
||||
- name: Check if rootless Docker is already set up
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
|
||||
register: rootless_docker_check
|
||||
|
||||
- name: Set up rootless Docker for runner user
|
||||
ansible.builtin.command: dockerd-rootless-setuptool.sh install
|
||||
args:
|
||||
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
when:
|
||||
- docker_rootless_setup
|
||||
- not rootless_docker_check.stat.exists
|
||||
|
||||
- name: Start rootless Docker daemon (systemd user service)
|
||||
ansible.builtin.command: systemctl --user start docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when: docker_rootless_setup
|
||||
|
||||
- name: Enable rootless Docker daemon (systemd user service)
|
||||
ansible.builtin.command: systemctl --user enable docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when: docker_rootless_setup
|
||||
|
||||
- name: Wait for rootless Docker daemon to be ready
|
||||
ansible.builtin.command: docker version
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
register: docker_ready
|
||||
until: docker_ready.rc == 0
|
||||
retries: 10
|
||||
delay: 2
|
||||
changed_when: false
|
||||
when: docker_rootless_setup
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
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"
|
||||
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_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: "pasta" (IPv6 support) or "slirp4netns" (IPv4 only)
|
||||
# pasta has proper outgoing IPv6 support; slirp4netns does not (known limitation).
|
||||
gitea_runner_docker_rootless_net_driver: "pasta"
|
||||
|
||||
# IPv6 subnet for rootless Docker containers (ULA range, not routable on internet)
|
||||
gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
when:
|
||||
- ansible_facts is defined
|
||||
- ansible_facts['service_mgr'] | default('') == 'systemd'
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_docker_rootless_setup
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "molecule-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "molecule-test-runner"
|
||||
gitea_runner_name: "molecule-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "deregister-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
gitea_runner_name: "deregister-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -13,7 +13,7 @@
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ gitea_runner_data_dir }}/.runner"
|
||||
content: |
|
||||
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
|
||||
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ gitea_runner_name }}", "address": "http://localhost:3000"}
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
@@ -22,12 +22,12 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
gitea_runner_name: "deregister-test-runner"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_url: "http://localhost:3000"
|
||||
skip_runner_registration: false
|
||||
gitea_runner_skip_registration: false
|
||||
tasks:
|
||||
- name: Include deregistration tasks
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: deregister.yml
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
gitea_runner_name: "deregister-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -12,12 +12,12 @@
|
||||
- name: Check registration file was removed
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_stat
|
||||
register: gitea_runner_file_stat
|
||||
|
||||
- name: Assert registration file no longer exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not runner_file_stat.stat.exists
|
||||
- not gitea_runner_file_stat.stat.exists
|
||||
fail_msg: "Registration file (.runner) was not removed by deregistration"
|
||||
|
||||
- name: Check systemd user service still exists
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "lifecycle-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
gitea_runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -23,7 +23,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
gitea_runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
gitea_runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
+8
-8
@@ -5,11 +5,11 @@
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "molecule-runner-a"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
gitea_runner_name: "molecule-runner-a"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
- role: gitea_runner
|
||||
|
||||
- name: Converge second runner instance
|
||||
hosts: all
|
||||
@@ -17,8 +17,8 @@
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
runner_name: "molecule-runner-b"
|
||||
skip_runner_registration: true
|
||||
docker_rootless_setup: false
|
||||
gitea_runner_name: "molecule-runner-b"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
- role: gitea_runner
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "remove-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
- name: Remove runner via remove-runner playbook
|
||||
ansible.builtin.import_playbook: "../../../../remove-runner.yml"
|
||||
vars:
|
||||
runner_name: "remove-test-runner"
|
||||
gitea_runner_name: "remove-test-runner"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_url: "http://localhost:3000"
|
||||
skip_runner_registration: true
|
||||
gitea_runner_skip_registration: true
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "remove-test-runner"
|
||||
gitea_runner_name: "remove-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "template-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "template-test-runner"
|
||||
gitea_runner_name: "template-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Converge
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "http://localhost:3000"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_runner_name: "update-test-runner"
|
||||
gitea_runner_skip_registration: true
|
||||
gitea_runner_docker_rootless_setup: false
|
||||
roles:
|
||||
- role: gitea_runner
|
||||
+2
-2
@@ -3,9 +3,9 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "update-test-runner"
|
||||
gitea_runner_name: "update-test-runner"
|
||||
tasks:
|
||||
- name: Include update tasks
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: update_runner.yml
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "update-test-runner"
|
||||
gitea_runner_name: "update-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
- name: Check if runner registration file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: gitea_runner_file_stat
|
||||
|
||||
- name: Read runner registration file
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: gitea_runner_file_content
|
||||
when: gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Parse runner registration data
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_reg: >
|
||||
{{ (gitea_runner_file_content.content | b64decode | from_json)
|
||||
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
|
||||
else {} }}
|
||||
when: gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Deregister runner from Gitea via API
|
||||
ansible.builtin.command: >
|
||||
curl -sf --connect-timeout 5 --max-time 10 -X DELETE
|
||||
-H "Authorization: token {{ gitea_runner_admin_token | default(registration_token) }}"
|
||||
"{{ gitea_url }}/api/v1/admin/actions/runners/{{ gitea_runner_reg.id }}"
|
||||
args:
|
||||
chdir: "{{ gitea_runner_data_dir }}"
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
|
||||
when:
|
||||
- gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
- not gitea_runner_skip_registration
|
||||
- gitea_runner_reg.id is defined
|
||||
register: gitea_runner_deregister_output
|
||||
changed_when: gitea_runner_deregister_output.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: Warn if deregistration failed
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
WARNING: Runner deregistration from Gitea failed (rc={{ gitea_runner_deregister_output.rc | default('N/A') }}).
|
||||
The runner entry may remain in Gitea's admin UI as offline.
|
||||
Use an admin token (gitea_runner_admin_token var) to enable automatic cleanup,
|
||||
or remove it manually from {{ gitea_url }}/-/admin/actions/runners
|
||||
when:
|
||||
- gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
- not gitea_runner_skip_registration
|
||||
- gitea_runner_deregister_output is defined
|
||||
- gitea_runner_deregister_output.rc | default(1) != 0
|
||||
|
||||
- name: Remove runner registration file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
state: absent
|
||||
when: gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
+4
-4
@@ -31,8 +31,8 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
|
||||
- name: Enable and start healthcheck user timer
|
||||
ansible.builtin.command: systemctl --user enable --now runner-healthcheck.timer
|
||||
@@ -42,5 +42,5 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
|
||||
- name: Include registration
|
||||
ansible.builtin.include_tasks: register.yml
|
||||
when: not skip_runner_registration
|
||||
when: not gitea_runner_skip_registration
|
||||
|
||||
- name: Include service setup
|
||||
ansible.builtin.include_tasks: service.yml
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
- name: Check runner registration file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: gitea_runner_file_stat
|
||||
|
||||
- name: Read runner registration file
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: gitea_runner_file_content
|
||||
when: gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Parse runner registration data
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_reg: >
|
||||
{{ (gitea_runner_file_content.content | b64decode | from_json)
|
||||
if (gitea_runner_file_content is defined and gitea_runner_file_content.content is defined)
|
||||
else {} }}
|
||||
when: gitea_runner_file_stat.stat.exists | default(false) | bool
|
||||
|
||||
- name: Verify runner user service active
|
||||
ansible.builtin.command: systemctl --user is-active gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
register: gitea_runner_service_check
|
||||
changed_when: false
|
||||
when:
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
|
||||
- name: Validate runner installation
|
||||
ansible.builtin.fail:
|
||||
msg: >
|
||||
Runner '{{ gitea_runner_name }}' is not properly installed:
|
||||
{% if not (gitea_runner_file_stat.stat.exists | default(false)) %}
|
||||
- Registration file (.runner) is missing. Registration may have failed.
|
||||
{% endif %}
|
||||
{% if gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active' %}
|
||||
- Systemd user service is not active.
|
||||
{% endif %}
|
||||
when: >
|
||||
not (gitea_runner_file_stat.stat.exists | default(false))
|
||||
or (gitea_runner_docker_rootless_setup and not (gitea_runner_service_check.stdout | default('') | trim) == 'active')
|
||||
|
||||
- name: Report runner status
|
||||
ansible.builtin.debug:
|
||||
msg: >
|
||||
Runner '{{ gitea_runner_name }}' is installed and running.
|
||||
Registered: {{ gitea_runner_file_stat.stat.exists | default(false) }}
|
||||
{% if gitea_runner_reg.id is defined %}Runner ID: {{ gitea_runner_reg.id }}{% endif %}
|
||||
{% if gitea_runner_reg.uuid is defined %}UUID: {{ gitea_runner_reg.uuid }}{% endif %}
|
||||
{% if gitea_runner_reg.address is defined %}Gitea: {{ gitea_runner_reg.address }}{% endif %}
|
||||
Service: {{ gitea_runner_service_check.stdout | default('unknown') | trim }}
|
||||
|
||||
- name: Optional Gitea API verification
|
||||
when:
|
||||
- gitea_url is defined
|
||||
- gitea_runner_admin_token is defined
|
||||
- gitea_runner_admin_token | length > 0
|
||||
block:
|
||||
- name: Check admin runners API
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/admin/runners"
|
||||
headers:
|
||||
Authorization: "token {{ gitea_runner_admin_token }}"
|
||||
method: GET
|
||||
status_code: [200, 401, 403, 404]
|
||||
return_content: true
|
||||
body_format: json
|
||||
register: gitea_runner_admin_api_response
|
||||
ignore_errors: true
|
||||
|
||||
- name: Check repo runners API
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/repos/{{ gitea_runner_test_repo | default('oblachno-oss/grm') }}/actions/runners"
|
||||
headers:
|
||||
Authorization: "token {{ gitea_runner_admin_token }}"
|
||||
method: GET
|
||||
status_code: [200, 401, 403, 404]
|
||||
return_content: true
|
||||
body_format: json
|
||||
register: gitea_runner_repo_api_response
|
||||
ignore_errors: true
|
||||
|
||||
- name: Report API status (informational only)
|
||||
ansible.builtin.debug:
|
||||
msg: >
|
||||
API checks (informational only — not used for pass/fail):
|
||||
Admin API: {{ gitea_runner_admin_api_response.status | default('no response') }}.
|
||||
Repo API: {{ gitea_runner_repo_api_response.status | default('no response') }}.
|
||||
{% if gitea_runner_admin_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
|
||||
Runner found in admin API.
|
||||
{% endif %}
|
||||
{% if gitea_runner_repo_api_response.json.runners | default([]) | selectattr('name', 'equalto', gitea_runner_name) | list | length > 0 %}
|
||||
Runner found in repo API.
|
||||
{% endif %}
|
||||
rescue:
|
||||
- name: API check failed
|
||||
ansible.builtin.debug:
|
||||
msg: "API verification skipped due to connection or permission error."
|
||||
+1
-1
@@ -19,4 +19,4 @@
|
||||
|
||||
- name: Include integration test
|
||||
ansible.builtin.include_tasks: integration_test.yml
|
||||
when: not skip_runner_registration
|
||||
when: not gitea_runner_skip_registration
|
||||
+4
-4
@@ -23,8 +23,8 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
|
||||
- name: Enable and start docker-prune user timer
|
||||
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
|
||||
@@ -34,5 +34,5 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
+6
-6
@@ -10,15 +10,15 @@
|
||||
- name: Check if runner is already registered
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_registered
|
||||
register: gitea_runner_registered
|
||||
|
||||
- name: Register runner with Gitea
|
||||
ansible.builtin.command: >
|
||||
{{ gitea_runner_binary_path }} register
|
||||
--token {{ registration_token }}
|
||||
--name {{ runner_name }}
|
||||
--name {{ gitea_runner_name }}
|
||||
--instance {{ gitea_url }}
|
||||
--labels {{ runner_labels }}
|
||||
--labels {{ gitea_runner_labels }}
|
||||
--no-interactive
|
||||
args:
|
||||
chdir: "{{ gitea_runner_data_dir }}"
|
||||
@@ -27,7 +27,7 @@
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default(0) }}"
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid | default(0) }}/docker.sock"
|
||||
when: not runner_registered.stat.exists
|
||||
register: register_output
|
||||
changed_when: "'already exists' not in register_output.stdout | default('')"
|
||||
when: not gitea_runner_registered.stat.exists
|
||||
register: gitea_runner_register_output
|
||||
changed_when: "'already exists' not in gitea_runner_register_output.stdout | default('')"
|
||||
timeout: 60
|
||||
+3
-3
@@ -6,14 +6,14 @@
|
||||
|
||||
- name: Resolve runner service user
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ runner_name }}"
|
||||
gitea_runner_service_user: "{{ gitea_runner_user_prefix | default('grm-') }}{{ gitea_runner_name }}"
|
||||
gitea_runner_base_data_dir: "/var/lib/gitea-runner"
|
||||
gitea_runner_base_config_dir: "/etc/gitea-runner"
|
||||
|
||||
- name: Resolve runner data and config dirs
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ runner_name }}"
|
||||
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ runner_name }}"
|
||||
gitea_runner_data_dir: "{{ gitea_runner_base_data_dir }}/{{ gitea_runner_name }}"
|
||||
gitea_runner_config_dir: "{{ gitea_runner_base_config_dir }}/{{ gitea_runner_name }}"
|
||||
|
||||
- name: Resolve runner service user UID
|
||||
ansible.builtin.getent:
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
- 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
|
||||
|
||||
- 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
|
||||
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"
|
||||
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
|
||||
mode: "0644"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
when:
|
||||
- gitea_runner_docker_rootless_setup
|
||||
- not gitea_runner_rootless_docker_check.stat.exists
|
||||
|
||||
- name: Set up rootless Docker for runner user
|
||||
ansible.builtin.command: dockerd-rootless-setuptool.sh install
|
||||
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 }}"
|
||||
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 }}"
|
||||
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 }}"
|
||||
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 to use pasta with IPv6
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker.service.d/override.conf"
|
||||
content: |
|
||||
[Service]
|
||||
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET={{ gitea_runner_docker_rootless_net_driver }}"
|
||||
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit"
|
||||
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6"
|
||||
mode: "0644"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
register: 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 }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- gitea_runner_docker_rootless_setup
|
||||
- gitea_runner_docker_network_override is changed
|
||||
|
||||
- name: Configure rootless Docker daemon with IPv6 enabled
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ gitea_runner_home }}/.config/docker/daemon.json"
|
||||
content: |
|
||||
{
|
||||
"ipv6": true,
|
||||
"ip6tables": true,
|
||||
"fixed-cidr-v6": "{{ gitea_runner_docker_ipv6_cidr }}"
|
||||
}
|
||||
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 }}"
|
||||
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 }}"
|
||||
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
|
||||
+4
-4
@@ -15,8 +15,8 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
|
||||
- name: Enable and start gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user enable --now gitea-runner
|
||||
@@ -26,5 +26,5 @@
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists
|
||||
- gitea_runner_docker_rootless_setup
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
- name: Check if systemd is available
|
||||
ansible.builtin.stat:
|
||||
path: /run/systemd/system
|
||||
register: systemd_available
|
||||
register: gitea_runner_systemd_available
|
||||
+2
-2
@@ -9,6 +9,6 @@
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
when:
|
||||
- systemd_available.stat.exists | default(false) | bool
|
||||
- docker_rootless_setup
|
||||
- gitea_runner_systemd_available.stat.exists | default(false) | bool
|
||||
- gitea_runner_docker_rootless_setup
|
||||
changed_when: true
|
||||
+5
-5
@@ -6,21 +6,21 @@
|
||||
shell: /bin/bash
|
||||
system: true
|
||||
create_home: true
|
||||
register: runner_user
|
||||
register: gitea_runner_user
|
||||
|
||||
- name: Set runner UID fact
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_uid: "{{ runner_user.uid }}"
|
||||
gitea_runner_uid: "{{ gitea_runner_user.uid }}"
|
||||
|
||||
- name: Check if lingering is already enabled
|
||||
ansible.builtin.stat:
|
||||
path: "/var/lib/systemd/linger/{{ gitea_runner_service_user }}"
|
||||
register: linger_stat
|
||||
register: gitea_runner_linger_stat
|
||||
|
||||
- name: Enable lingering for runner user
|
||||
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
|
||||
changed_when: not linger_stat.stat.exists
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: not gitea_runner_linger_stat.stat.exists
|
||||
when: gitea_runner_systemd_available.stat.exists
|
||||
|
||||
- name: Ensure subuid entry for runner user
|
||||
ansible.builtin.lineinfile:
|
||||
+2
-2
@@ -21,6 +21,6 @@
|
||||
environment:
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
register: docker_version_output
|
||||
register: gitea_runner_docker_version_output
|
||||
changed_when: false
|
||||
when: docker_rootless_setup
|
||||
when: gitea_runner_docker_rootless_setup
|
||||
@@ -6,12 +6,12 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Resolve runner UID
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: resolve_uid.yml
|
||||
|
||||
- name: Check if runner is already registered
|
||||
@@ -21,11 +21,11 @@
|
||||
|
||||
- name: Include registration if not registered
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: register.yml
|
||||
when:
|
||||
- not runner_registered.stat.exists
|
||||
- not skip_runner_registration | default(false)
|
||||
- not gitea_runner_skip_registration | default(false)
|
||||
|
||||
- name: Start gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user start gitea-runner
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Resolve runner UID
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: resolve_uid.yml
|
||||
|
||||
- name: Check systemd user service status
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Resolve runner UID
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: resolve_uid.yml
|
||||
|
||||
- name: Stop gitea-runner user service
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
tasks:
|
||||
- name: Update runner
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
name: gitea_runner
|
||||
tasks_from: update_runner.yml
|
||||
|
||||
+6
-6
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Retrospective: CI Consolidation and devx Adoption
|
||||
|
||||
## Date
|
||||
2026-07-12
|
||||
|
||||
## Context
|
||||
The grm repo (Gitea Runner Manager) underwent CI workflow consolidation
|
||||
and adopted the latest devx package (v0.40.0 → v0.40.1) during this
|
||||
period. The self-approval fallback fix in devx v0.40.1 required passing
|
||||
`CI_GITEA_API_TOKEN` to the approval step in grm's CI workflow. This
|
||||
retrospective covers grm v0.17.2 through v0.18.0.
|
||||
|
||||
## Scope
|
||||
|
||||
PRs: GRM-144 (IPv6/pasta), GRM-145 (devx v0.40.0 adoption), GRM-146 (CI
|
||||
consolidation). Plus the self-approval fallback cherry-pick. ~12 commits.
|
||||
|
||||
## Timeline of Key Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| GRM-144 merged | IPv6 support via pasta network driver (v0.18.0) |
|
||||
| GRM-145 merged | Adopted devx v0.40.0, removed redundant crypto/vault wrappers |
|
||||
| GRM-146 merged | Consolidated CI and post-merge workflows (7→2 jobs) |
|
||||
| devx v0.40.1 bump | Cherry-picked self-approval fallback into grm CI |
|
||||
|
||||
No CI failures were specific to grm during this period. The grm CI
|
||||
passed cleanly on all runs. The only issue was the cross-repo
|
||||
self-approval bug (inherited from devx), which was fixed by bumping
|
||||
devx to v0.40.1 and passing `CI_GITEA_API_TOKEN` to the approval step.
|
||||
|
||||
## What Served Us Well
|
||||
|
||||
- **Clean CI consolidation.** GRM-146 merged 7 CI jobs into 2
|
||||
(validate + molecule-tests) without any CI failures. The
|
||||
consolidation pattern was already proven in devx (DEVX-126), so the
|
||||
application to grm was straightforward.
|
||||
- **devx adoption was smooth.** GRM-145 adopted devx v0.40.0 and removed
|
||||
redundant crypto/vault wrappers. The refactoring was clean — no test
|
||||
failures, no coverage drops.
|
||||
- **Molecule tests stable.** All 6 molecule scenarios passed on every
|
||||
CI run. The pasta networking change (GRM-144) was well-tested with
|
||||
molecule before merge.
|
||||
- **Pre-push hook caught missing Vikunja tasks.** The pre-push hook
|
||||
validates Vikunja task existence, preventing pushes without
|
||||
corresponding tasks.
|
||||
|
||||
## What Could Be Improved
|
||||
|
||||
### 1. Cross-Repo Dependency Propagation
|
||||
|
||||
When devx v0.40.1 was released with the self-approval fix, grm needed
|
||||
to bump its devx version and update the CI workflow to pass
|
||||
`CI_GITEA_API_TOKEN`. This was a manual process — there's no automated
|
||||
mechanism to detect that a devx release affects downstream repos.
|
||||
|
||||
**Impact:** The self-approval fix was in devx for ~30 min before grm
|
||||
was updated. If the timing had been different, grm PRs could have been
|
||||
blocked.
|
||||
|
||||
**Lesson:** When releasing a devx fix that affects CI workflows in
|
||||
downstream repos, bump devx in all repos in the same session. Consider
|
||||
a "dependabot" style check that flags outdated devx versions.
|
||||
|
||||
### 2. No Repo-Specific Retrospective Directory
|
||||
|
||||
The grm repo didn't have a `docs/retrospectives/` directory until now.
|
||||
Previous retrospectives were only in the infra repo. This meant grm-
|
||||
specific lessons weren't being captured.
|
||||
|
||||
**Impact:** Low — grm had fewer issues during this period. But going
|
||||
forward, grm-specific learnings should be documented here.
|
||||
|
||||
**Fix:** Created `docs/retrospectives/` directory with this
|
||||
retrospective.
|
||||
|
||||
## Improvements Implemented
|
||||
|
||||
### 1. CI Workflow Consolidation (MEDIUM impact)
|
||||
|
||||
Merged 7 CI jobs into 2 (validate + molecule-tests), matching the
|
||||
pattern established in devx. Reduced runner overhead by ~4 min per CI
|
||||
run.
|
||||
|
||||
### 2. devx v0.40.1 Adoption (HIGH impact)
|
||||
|
||||
Bumped devx to v0.40.1, picking up the self-approval fallback fix.
|
||||
Updated CI workflow to pass `CI_GITEA_API_TOKEN` to the approval step.
|
||||
|
||||
### 3. IPv6 Support via Pasta (MEDIUM impact)
|
||||
|
||||
GRM-144 enabled IPv6 in rootless Docker via the pasta network driver,
|
||||
replacing the previous slirp4netns setup. This improves network
|
||||
performance and enables IPv6 connectivity for runner containers.
|
||||
|
||||
## Action Items for Future Sessions
|
||||
|
||||
1. **Bump devx in all downstream repos when a CI-affecting fix is
|
||||
released.** Don't leave repos on stale devx versions.
|
||||
2. **Document grm-specific lessons in this retrospective directory.**
|
||||
Don't rely on the infra retrospective to cover grm issues.
|
||||
3. **When consolidating CI workflows, verify that all status checks
|
||||
referenced by branch protection are still present.** The
|
||||
consolidation renamed `quality` to `validate`, requiring a branch
|
||||
protection update.
|
||||
@@ -3,7 +3,7 @@
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/grm/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
2. **Ansible Role** (`ansible/roles/gitea_runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
## High-Level Design
|
||||
|
||||
@@ -26,7 +26,7 @@ The Ansible role handles all remote state: user creation, package installation,
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
└── role: gitea_runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
@@ -44,7 +44,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they only create files)
|
||||
|
||||
### Ansible task files
|
||||
|
||||
@@ -53,7 +53,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
| `main.yml` | Entry point — includes all other task files in order |
|
||||
| `systemd_check.yml` | Verifies systemd is available on the target host |
|
||||
| `user_setup.yml` | Creates the per-runner system user, enables lingering, configures subuid/subgid, creates data and config directories |
|
||||
| `rootless_docker.yml` | Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), runs `dockerd-rootless-setuptool.sh install`, starts and enables the rootless Docker daemon |
|
||||
| `rootless_docker.yml` | Installs Docker packages (apt for Debian/Ubuntu, pacman for Arch), provisions the rootless setup scripts on Arch (not shipped by the `docker` package), runs `dockerd-rootless-setuptool.sh install`, starts and enables the rootless Docker daemon |
|
||||
| `install_runner.yml` | Downloads the gitea_runner binary, creates the config file, validates the binary, registers the runner with Gitea, creates and starts the systemd user service |
|
||||
| `download_gitea_runner.yml` | Downloads the gitea_runner binary from GitHub releases |
|
||||
| `validate.yml` | Validates the downloaded binary |
|
||||
@@ -83,8 +83,8 @@ Each runner runs as a systemd user service under a dedicated system user (`grm-<
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<name>/`
|
||||
- **Data**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config**: `/etc/gitea-runner/<name>/`
|
||||
- **Data**: `/var/lib/gitea_runner/<name>/`
|
||||
- **Config**: `/etc/gitea_runner/<name>/`
|
||||
- **Service**: `gitea-runner.service` (systemd user service)
|
||||
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
|
||||
- **subuid/subgid**: `grm-<name>:100000:65536` (user namespace mapping)
|
||||
@@ -100,7 +100,7 @@ flowchart TD
|
||||
EXEC["Executor<br/>executor.py"]
|
||||
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
|
||||
ANS["ansible-playbook subprocess"]
|
||||
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
|
||||
ROLE["Ansible Role<br/>ansible/roles/gitea_runner/"]
|
||||
USER["user_setup.yml<br/>create system user + lingering"]
|
||||
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
|
||||
INSTALL["install_runner.yml<br/>download, config, register, service"]
|
||||
@@ -173,11 +173,30 @@ Each runner operates under a dedicated unprivileged system user. The Docker daem
|
||||
|
||||
- User namespace mapping via `/etc/subuid` and `/etc/subgid` (range: 100000-165535)
|
||||
- Rootless Docker socket at `/run/user/<UID>/docker.sock`
|
||||
- `slirp4netns` for user-mode networking
|
||||
- `pasta` for user-mode networking with IPv6 support (replaces `slirp4netns`, which lacks outgoing IPv6)
|
||||
- `fuse-overlayfs` for rootless container storage
|
||||
|
||||
The rootless Docker daemon is configured via a systemd user override
|
||||
(`docker.service.d/override.conf`) that sets:
|
||||
|
||||
- `DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta` — use pasta as the network driver
|
||||
- `DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit` — pasta's native port forwarding
|
||||
- `DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--ipv6` — enable IPv6 routing
|
||||
|
||||
The daemon.json enables IPv6 with a ULA subnet (`fd00:dead:beef::/48`)
|
||||
for container addressing. This ensures runner containers can reach
|
||||
both IPv4 and IPv6 services (e.g., the Gitea registry) without
|
||||
per-workaround DNS hacks.
|
||||
|
||||
Containers launched by the runner never have root access to the host. The rootless Docker daemon is started as a systemd user service and persists via lingering.
|
||||
|
||||
#### Platform-specific rootless provisioning
|
||||
|
||||
The rootless setup scripts (`dockerd-rootless-setuptool.sh` and `dockerd-rootless.sh`) are provided differently per OS:
|
||||
|
||||
- **Debian/Ubuntu** — shipped by the `docker-ce-rootless-extras` package (installed via the Docker APT repo).
|
||||
- **Arch Linux** — the `docker` package does **not** include these scripts, and no official Arch package provides them. The role fetches them from the upstream `moby/moby` `contrib/` directory at a pinned, overridable git ref (`gitea_runner_rootless_scripts_ref`, default `v28.5.1`) and installs them into `/usr/bin` — co-located with `docker`/`dockerd`/`rootlesskit`, which is required because `dockerd-rootless-setuptool.sh` derives its `BIN` directory from its own location and expects those binaries alongside it. The `rootlesskit` package (a required rootless runtime dependency that is not pulled in by Arch's `docker` package) is also installed explicitly.
|
||||
|
||||
### Secret handling
|
||||
|
||||
Registration tokens and admin API tokens are never exposed on the command line. The `RunnerManager._extra_vars_file()` context manager:
|
||||
|
||||
+42
-33
@@ -6,10 +6,9 @@ GRM uses a fully automated CI/CD pipeline built on Gitea Actions. Every change t
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ci.yml` | PR opened/synchronized | Quality checks (lint, test, coverage) + molecule tests |
|
||||
| `ci.yml` | PR opened/synchronized | Validate (lint, test, coverage, detect-changes, release-dry-run, pr-review, discover-runners) + molecule tests |
|
||||
| `auto-merge.yml` | PR labeled `ready-to-merge` | Validates and squash-merges the PR |
|
||||
| `post-merge.yml` | Push to `master` | Release, wiki sync, badges, Vikunja task update |
|
||||
| `publish.yml` | Tag push (`v*`) | Build and publish package to PyPI, create Gitea release |
|
||||
| `post-merge.yml` | Push to `master` | Detect-and-configure + release-and-maintain (release, publish, wiki sync, badges, Vikunja task update) |
|
||||
|
||||
Every change to master goes through a mandatory PR workflow. No exceptions.
|
||||
|
||||
@@ -101,14 +100,14 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
6. The release-and-maintain job automatically versions, tags, and publishes
|
||||
|
||||
### 9. Post-Merge Automation
|
||||
|
||||
After the squash-merge:
|
||||
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `devx.ci.post_merge` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
||||
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master`. The `detect-and-configure` job configures the repo and detects the commit type. The `release-and-maintain` job then runs the release, publish, sync-wiki, badges, and Vikunja steps as appropriate.
|
||||
- The **release step** (in the `release-and-maintain` job) automatically versions, tags, and publishes (see below).
|
||||
|
||||
## Branch Protection (Required Gitea Settings)
|
||||
|
||||
@@ -116,42 +115,46 @@ Configure the following branch protection rules for `master` in Gitea repo setti
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Require status checks**: CI validate + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
|
||||
## CI Path Filtering
|
||||
|
||||
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
|
||||
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` step in the `validate` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
|
||||
|
||||
The `detect-changes` job:
|
||||
The `detect-changes` step:
|
||||
|
||||
- For pull requests: compares `origin/master` against the PR head SHA
|
||||
- For pushes to master: compares `HEAD~1` against `HEAD`
|
||||
- Outputs `ansible-changed` as `true` or `false`
|
||||
|
||||
The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`.
|
||||
The `molecule-tests` job depends on the `validate` job (which includes the `detect-changes` step), and only runs if `ansible-changed == 'true'`.
|
||||
|
||||
CI triggers only on `opened` and `synchronize` PR events (not `labeled`).
|
||||
|
||||
## CI Quality Job
|
||||
## CI Validate Job
|
||||
|
||||
The `quality` job in `.gitea/workflows/ci.yml` runs:
|
||||
The `validate` job in `.gitea/workflows/ci.yml` consolidates the former quality, detect-changes, release-dry-run, pre-merge-check, pr-review, and discover-runners jobs into a single job. It runs:
|
||||
|
||||
1. `make setup` — full environment setup
|
||||
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
|
||||
3. `make pytest-cov` — unit tests with 100% coverage enforcement
|
||||
4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
|
||||
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
|
||||
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation (release-dry-run step)
|
||||
6. Pre-merge validation step — validates branch format, PR title, and Vikunja task match
|
||||
7. `detect-changes` step — checks whether Ansible files changed (gates molecule tests)
|
||||
8. `pr-review` step — automated PR review via `devx.ci.pr_review`
|
||||
9. `discover-runners` step — dynamic runner discovery for molecule tests (conditional on ansible-changed)
|
||||
|
||||
## Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically.
|
||||
|
||||
### Release Workflow (`.gitea/workflows/release.yml`)
|
||||
### Release Step (in the release-and-maintain job)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- Runs as a conditional step in the `release-and-maintain` job (skipped for release commits)
|
||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Configures git as `grm-ci-bot`
|
||||
@@ -169,9 +172,10 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||
|
||||
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
||||
### Publish Step (in the release-and-maintain job)
|
||||
|
||||
- Triggers on tag push (`v*`)
|
||||
- Runs as a conditional step in the `release-and-maintain` job (only if the release step created a tag)
|
||||
- Checks out the release tag within the same job
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
@@ -190,12 +194,17 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- Consolidates release, wiki sync, badge generation, and Vikunja task updates into a single workflow
|
||||
- **detect-type** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent jobs skip for release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||
- **release** — Runs `devx.ci.release` (see Automated Release Pipeline below)
|
||||
- **sync-wiki** — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
|
||||
- **badges** — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs after the release job (even if release fails or is skipped) so the version badge always reflects the latest state.
|
||||
- **vikunja** — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
|
||||
- Consolidated from 7 jobs into 2 jobs to reduce runner overhead
|
||||
- **detect-and-configure** — Configures repo (branch protection, labels), detects release commit, validates commit message. Outputs `is-release` and `is-automated` for the next job.
|
||||
- **detect-type step** — Runs `devx.ci.detect_release_commit` to check if the commit is a release commit (`release: vX.Y.Z`). All subsequent steps skip for release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||
- **validate-commit-msg step** — Validates the commit message follows conventional commit format.
|
||||
- **configure-repo step** — Runs `devx.tools.configure_repo` to set up branch protection and labels.
|
||||
- **release-and-maintain** — Runs all post-merge maintenance as conditional steps:
|
||||
- **release step** (if not a release commit) — Runs `devx.ci.release` (see Automated Release Pipeline below)
|
||||
- **publish step** (if release created a tag) — Builds and publishes the package to the Gitea PyPI registry
|
||||
- **sync-wiki step** (if not automated) — Syncs documentation to the Gitea wiki via `devx.ci.sync_wiki`
|
||||
- **vikunja step** (if not automated) — Marks the corresponding Vikunja task as done via `devx.ci.post_merge`
|
||||
- **badges step** (always) — Generates and pushes quality badge SVGs to the `badges` branch via `devx.ci.push_badges`. Runs even if release fails or is skipped so the version badge always reflects the latest state.
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
@@ -218,16 +227,16 @@ from accidentally skipping releases. Classification is config-driven via
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: `release.py` calls `classify_changes` to check if any
|
||||
- **Release dry-run**: Only runs when user-facing files change (release-dry-run step in the validate job)
|
||||
- **Validate job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release step**: `release.py` calls `classify_changes` to check if any
|
||||
user-facing files changed since the last tag. If not, the release is skipped
|
||||
entirely — no version bump, no tag, no publish.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically. The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for
|
||||
dynamically. The `discover-runners` step in the `validate` job runs `devx.molecule.discover_runners` which queries the Gitea API for
|
||||
registered runners at three levels (repo, org, instance) and generates
|
||||
a matrix of runner indices. If the API query fails (e.g., no admin
|
||||
access for instance-level runners), it falls back to the
|
||||
@@ -263,15 +272,15 @@ feature branches.
|
||||
|
||||
### Release Commit Detection
|
||||
|
||||
The `detect-type` job in the post-merge workflow runs
|
||||
The `detect-type` step in the `detect-and-configure` job (post-merge workflow) runs
|
||||
`devx.ci.detect_release_commit` to check whether the latest commit
|
||||
is a release commit (format: `release: vX.Y.Z`). When a release commit
|
||||
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
||||
are skipped — the tag push triggers the publish workflow instead.
|
||||
is detected, all subsequent steps in the `release-and-maintain` job (release, publish, sync-wiki, vikunja)
|
||||
are skipped — the tag push triggers the publish step instead.
|
||||
|
||||
### Badge Generation and Push
|
||||
|
||||
The `badges` job in the post-merge workflow runs
|
||||
The `badges` step in the `release-and-maintain` job (post-merge workflow) runs
|
||||
`devx.ci.push_badges` which:
|
||||
1. Fetches the latest master and hard-resets to it (picks up release commits)
|
||||
2. Generates quality badge SVG files via `devx.tools.generate_badges`
|
||||
@@ -279,8 +288,8 @@ The `badges` job in the post-merge workflow runs
|
||||
4. Copies SVG files to the branch root
|
||||
5. Force-pushes the branch to the remote
|
||||
|
||||
The badges job depends on the `release` job and uses `if: always()` so it
|
||||
runs even if release fails or is skipped. This ensures the version badge
|
||||
The badges step runs with `if: always()` so it
|
||||
runs even if the release step fails or is skipped. This ensures the version badge
|
||||
always reflects the actual state of the repository after any release
|
||||
commits have been pushed.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Every change to master goes through this workflow. No exceptions.
|
||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `devx.ci.pr_review`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
8. **Approve** — post an `APPROVE` review via `devx.ci.pr_review`
|
||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release-and-maintain job automatically versions and tags
|
||||
### 1. Create Vikunja task
|
||||
|
||||
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
|
||||
@@ -165,17 +165,17 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
6. The release-and-maintain job automatically versions, tags, and publishes
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge workflow by adding the `ready-to-merge` label. Manual merges bypass the `GRM-N <conventional>` format enforcement.
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Branch protection is automatically configured by `devx.tools.configure_repo` (runs as a `configure-repo` job in the post-merge workflow). The following rules are enforced for `master`:
|
||||
Branch protection is automatically configured by the `configure-repo` step in the `detect-and-configure` job of the post-merge workflow. The following rules are enforced for `master`:
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Require status checks**: CI validate + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
@@ -202,7 +202,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they only create files)
|
||||
- `apt` tasks use `cache_valid_time: 3600` to avoid unnecessary cache updates
|
||||
- `remove-runner.yml` runs `loginctl disable-linger` and removes subuid/subgid entries
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
│ ├── ui.py # Colorised console output
|
||||
│ └── translations.json # Translation strings
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ ├── roles/gitea_runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files (13 files)
|
||||
│ │ ├── templates/ # Jinja2 templates (4 files)
|
||||
@@ -222,7 +222,7 @@ The pre-commit hook runs actionlint automatically when workflow files change.
|
||||
|--------|-------------|
|
||||
| `make setup` | Full setup: venv, deps, hooks, CI tools |
|
||||
| `make setup-ci` | Lean setup for CI jobs (pytest + lint, no Ansible collections) |
|
||||
| `make setup-quality` | Setup for the quality CI job (lint + test deps) |
|
||||
| `make setup-quality` | Setup for the validate CI job (lint + test deps) |
|
||||
| `make setup-molecule` | Full setup for molecule testing |
|
||||
| `make setup-release` | Setup for release jobs (git-cliff, tea, lint tools) |
|
||||
| `make install-tools` | Install actionlint, git-cliff, act_runner, tea to `~/.local/bin` |
|
||||
|
||||
@@ -16,11 +16,11 @@ From the `Makefile`:
|
||||
- `test-unit` — `pytest tests/unit/ -v --no-cov` (unit tests without coverage)
|
||||
- `pytest-cov` — `pytest tests/ -v --cov=src/grm --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
|
||||
|
||||
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/grm/`. The CI quality job runs `make pytest-cov` on every PR, and the release workflow runs it again before tagging a release.
|
||||
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required for all code in `src/grm/`. The CI validate job runs `make pytest-cov` on every PR, and the release step in the release-and-maintain job runs it again before tagging a release.
|
||||
|
||||
### Test speed verification
|
||||
|
||||
The CI quality job also runs `python -m devx.tools.check_test_speed --max-seconds 10` to verify that unit tests run fast (under 10 seconds total). This catches performance regressions early.
|
||||
The CI validate job also runs `python -m devx.tools.check_test_speed --max-seconds 10` to verify that unit tests run fast (under 10 seconds total). This catches performance regressions early.
|
||||
|
||||
## Integration Tests
|
||||
|
||||
@@ -43,11 +43,11 @@ make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Molecule tests validate the Ansible role (`ansible/roles/gitea-runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
|
||||
Molecule tests validate the Ansible role (`ansible/roles/gitea_runner/`) by creating Docker containers as test hosts, applying the role, and verifying the results. Each scenario tests a specific aspect of the role.
|
||||
|
||||
### Scenarios
|
||||
|
||||
Seven Molecule scenarios are defined under `ansible/roles/gitea-runner/molecule/`:
|
||||
Seven Molecule scenarios are defined under `ansible/roles/gitea_runner/molecule/`:
|
||||
|
||||
| Scenario | Description | What it verifies |
|
||||
|----------|-------------|------------------|
|
||||
|
||||
@@ -14,7 +14,7 @@ GRM provides the following CLI commands for managing Gitea Actions runners. The
|
||||
| `grm enable` | `<runner_name>` | Enable a runner to start on boot |
|
||||
| `grm disable` | `<runner_name>` | Disable and deregister a runner |
|
||||
| `grm status` | `<runner_name>` | Check the status of a registered runner |
|
||||
| `grm remove` | `<runner_name>` | Remove a runner completely |
|
||||
| `grm remove` | `<runner_name>` | Remove a runner entirely |
|
||||
| `grm list` | — | List all registered runners with live status |
|
||||
| `grm health` | `[runner_name]` | Run health check (Docker, runner service, disk) on one or all runners |
|
||||
| `grm trigger-workflow` | `<workflow_id>` | Trigger a Gitea Actions workflow via the API |
|
||||
@@ -244,7 +244,7 @@ grm status <runner_name> [options]
|
||||
|
||||
## remove
|
||||
|
||||
Remove a registered Gitea Runner completely.
|
||||
Remove a registered Gitea Runner entirely.
|
||||
|
||||
```bash
|
||||
grm remove <runner_name> [options]
|
||||
@@ -335,7 +335,7 @@ grm health prod-runner
|
||||
|
||||
Output shows NAME, HOST, HEALTHY (yes/no), and MESSAGE columns. The command exits with code 1 if any runner is unhealthy.
|
||||
|
||||
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea-runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
|
||||
The health check is also run automatically via a systemd timer installed by the Ansible role. See `ansible/roles/gitea_runner/templates/runner-healthcheck.sh.j2` for the script and `runner-healthcheck.timer.j2` for the timer.
|
||||
|
||||
## trigger-workflow
|
||||
|
||||
|
||||
+15
-13
@@ -1,6 +1,6 @@
|
||||
# FAQ
|
||||
|
||||
## How do I obtain the Gitea registration token?
|
||||
## How to obtain the Gitea registration token
|
||||
|
||||
There are three levels of registration tokens, depending on which repositories the runner should serve:
|
||||
|
||||
@@ -10,15 +10,15 @@ There are three levels of registration tokens, depending on which repositories t
|
||||
|
||||
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
## What is the CI_GITEA_TOKEN and do I need it?
|
||||
## What is the CI_GITEA_TOKEN and is it needed?
|
||||
|
||||
`CI_GITEA_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
|
||||
|
||||
To generate one: Settings → Applications → Generate New Token, with the `admin` scope (or at minimum `read:user`, `read:repository`, `read:admin`).
|
||||
|
||||
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
|
||||
If you skip it, GRM will still verify the runner correctly — it won't show the extra API confirmation.
|
||||
|
||||
## How do I skip the sudo password prompt for automation?
|
||||
## How to skip the sudo password prompt for automation
|
||||
|
||||
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
|
||||
|
||||
@@ -34,7 +34,7 @@ Then use:
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner --no-ask-become-pass
|
||||
```
|
||||
|
||||
## Can I run multiple runners on the same host?
|
||||
## Can multiple runners run on the same host?
|
||||
|
||||
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
|
||||
|
||||
@@ -46,7 +46,7 @@ grm list
|
||||
|
||||
Runners on the same host never interfere with each other or with the host's Docker installation.
|
||||
|
||||
## Why does my runner appear offline after installation?
|
||||
## Why does a runner appear offline after installation?
|
||||
|
||||
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
|
||||
|
||||
@@ -64,7 +64,7 @@ This is a harmless cleanup traceback from Molecule's Docker driver when the test
|
||||
|
||||
GRM stores each runner's connection details (host, user, SSH key, Gitea URL, labels) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
|
||||
|
||||
## How do I update the gitea_runner binary?
|
||||
## How to update the gitea_runner binary
|
||||
|
||||
Use the `grm update` command:
|
||||
|
||||
@@ -80,7 +80,7 @@ grm update 192.168.1.10 --user ubuntu --version 1.0.8
|
||||
|
||||
The update command downloads the new binary and replaces the existing one at `/usr/local/bin/gitea_runner`. The runner service is restarted automatically.
|
||||
|
||||
## How do I completely remove a runner?
|
||||
## How to remove a runner entirely
|
||||
|
||||
Use the `grm remove` command:
|
||||
|
||||
@@ -99,13 +99,15 @@ grm remove prod-runner --force
|
||||
## What is the difference between disable and remove?
|
||||
|
||||
- **`grm disable <name>`** — Deregisters the runner from Gitea and stops the service, but leaves the user, directories, and service files in place. The runner can be re-enabled later with `grm enable` and re-registered with a new token.
|
||||
- **`grm remove <name>`** — Completely removes the runner: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
|
||||
- **`grm remove <name>`** — Removes the runner entirely: deregisters from Gitea, stops and disables the service, removes the system user, deletes all directories, and removes the local registry entry. This is irreversible.
|
||||
|
||||
## What operating systems are supported?
|
||||
|
||||
GRM supports Arch Linux (rolling), Ubuntu 22.04/24.04, and Debian 12. All supported OSes are tested in CI via Molecule scenarios on every PR that changes Ansible files.
|
||||
|
||||
## How do I change the UI language?
|
||||
On Arch Linux, the rootless Docker setup scripts (`dockerd-rootless-setuptool.sh`, `dockerd-rootless.sh`) and the `rootlesskit` runtime are provisioned automatically — Arch's `docker` package does not ship them. No extra setup is required.
|
||||
|
||||
## How to change the UI language
|
||||
|
||||
Set the `GRM_LANG` environment variable to one of the supported languages: `en` (English, default), `bg` (Bulgarian), `de` (German), `ru` (Russian), `zh` (Chinese), `pl` (Polish).
|
||||
|
||||
@@ -119,7 +121,7 @@ Or set it in your `.env` file:
|
||||
GRM_LANG=bg
|
||||
```
|
||||
|
||||
## How do I enable debug logging?
|
||||
## How to enable debug logging
|
||||
|
||||
Set the `GRM_LOG_LEVEL` environment variable to `DEBUG`:
|
||||
|
||||
@@ -129,7 +131,7 @@ GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
|
||||
The log file at `~/.local/state/grm/logs/grm.log` always captures DEBUG level regardless of this setting. Ansible execution logs are stored in timestamped files at `~/.local/state/grm/logs/ansible-<timestamp>.log`.
|
||||
|
||||
## What runner labels should I use?
|
||||
## What runner labels should be used?
|
||||
|
||||
By default, runners are registered with `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04`. You can override this with `--labels` or the `GITEA_RUNNER_LABELS` environment variable.
|
||||
|
||||
@@ -151,7 +153,7 @@ Yes. GRM is designed with security as a first-class concern:
|
||||
- **No shell injection**: The CLI never uses `shell=True` with subprocess.
|
||||
- **Bandit security scan**: The CI pipeline runs Bandit on every PR.
|
||||
|
||||
## Can I install GRM via pip?
|
||||
## Can GRM be installed via pip?
|
||||
|
||||
Yes:
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ make update HOST=192.168.1.10 USER=ubuntu VERSION=1.0.8
|
||||
|
||||
## Removing Runners
|
||||
|
||||
To remove a runner completely (deregisters from Gitea, removes user, directories, and service files):
|
||||
To remove a runner entirely (deregisters from Gitea, removes user, directories, and service files):
|
||||
|
||||
```bash
|
||||
grm remove prod-runner --token <registration-token>
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### `No such file or directory: dockerd-rootless-setuptool.sh` (Arch Linux)
|
||||
|
||||
**Symptom:** `grm install` fails on an Arch Linux host during rootless Docker setup with `Error executing command: [Errno 2] No such file or directory: b'dockerd-rootless-setuptool.sh'`.
|
||||
|
||||
**Cause:** Arch's `docker` package does not ship the rootless setup scripts (unlike Debian/Ubuntu's `docker-ce-rootless-extras`), and `rootlesskit` is not a dependency of the `docker` package.
|
||||
|
||||
**Solution:** This is handled automatically by GRM ≥ v0.19.0. Ensure you are on the latest release (`git checkout $(git describe --tags --abbrev=0)`). The role fetches the scripts from upstream `moby/moby` and installs `rootlesskit` automatically. If it still fails, verify the host can reach `raw.githubusercontent.com` (the script source) and check the Ansible log under `~/.local/state/grm/logs/`.
|
||||
|
||||
### Ansible connection fails (UNREACHABLE)
|
||||
|
||||
**Symptom:** Ansible reports `UNREACHABLE` when trying to connect to the target host.
|
||||
@@ -179,6 +187,7 @@ If Docker is not installed, install it via your package manager or [Docker's off
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Ansible UNREACHABLE | SSH connection failed | Verify `--user`, `--key`, and host reachability |
|
||||
| `No such file ... dockerd-rootless-setuptool.sh` (Arch) | Arch `docker` pkg lacks rootless scripts + `rootlesskit` | Fixed in v0.19.0; update GRM to latest release |
|
||||
| `GITEA_URL must be set` | Missing environment variable | Set `GITEA_URL` in `.env` or pass `--url` |
|
||||
| `GITEA_REGISTRATION_TOKEN must be set` | Missing environment variable | Set `GITEA_REGISTRATION_TOKEN` in `.env` or pass `--token` |
|
||||
| Runner appears offline | Registration failed or service not running | Check GITEA_URL, token validity, and service status |
|
||||
|
||||
+15
-9
@@ -27,25 +27,26 @@ version = {attr = "grm.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Minimal deps for CI scripts that only need click/dotenv
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
|
||||
# (validate job steps: detect-changes, discover-runners, pr-review;
|
||||
# release-and-maintain job steps: sync-wiki, badges, etc.)
|
||||
ci = [
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"build==1.5.0",
|
||||
"build==1.5.1",
|
||||
"twine==6.2.0",
|
||||
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
|
||||
"devx==0.35.1",
|
||||
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.1",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
# Lint and type-checking tools (validate job)
|
||||
lint = [
|
||||
"ruff==0.15.20",
|
||||
"ruff==0.15.21",
|
||||
"pyright==1.1.411",
|
||||
"bandit==1.9.4",
|
||||
"pip-audit==2.10.1",
|
||||
"pre-commit==4.6.0",
|
||||
"ansible-lint==26.4.0",
|
||||
"ansible-lint==26.6.0",
|
||||
]
|
||||
# Molecule testing (molecule-tests job)
|
||||
# Molecule testing (molecule-tests job — stays as a separate CI job)
|
||||
molecule = [
|
||||
"molecule==26.6.0",
|
||||
"molecule-docker==2.1.0",
|
||||
@@ -54,7 +55,7 @@ molecule = [
|
||||
dev = [
|
||||
"grm[ci,lint,molecule]",
|
||||
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
|
||||
"devx==0.35.1",
|
||||
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.47.1",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
@@ -101,8 +102,13 @@ vikunja_project_id = 6
|
||||
repo_owner = "oblachno-oss"
|
||||
repo_name = "grm"
|
||||
|
||||
# grm doesn't have its own CI scripts — it uses devx's CI modules.
|
||||
# Skip CI script checks (doc_coverage would otherwise look for src/ci/).
|
||||
[tool.devx.doc_coverage]
|
||||
ci_scripts_dir = ""
|
||||
|
||||
# Molecule test weights for LPT scheduling.
|
||||
# GRM has a single role (gitea-runner) with 7 scenarios.
|
||||
# GRM has a single role (gitea_runner) with 7 scenarios.
|
||||
# Weights are estimates — recalibrate from CI logs after next run.
|
||||
[tool.devx.molecule.weights]
|
||||
"multi-instance" = 8
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.15.0"
|
||||
__version__ = "0.18.2"
|
||||
|
||||
+6
-4
@@ -125,8 +125,8 @@ def cli(ctx: click.Context, become_password_file: str | None, verbose: bool) ->
|
||||
@click.option(
|
||||
"--admin-token",
|
||||
"-a",
|
||||
default=lambda: os.getenv("CI_GITEA_TOKEN"),
|
||||
help=_("Gitea admin API token for integration test (env: CI_GITEA_TOKEN)"),
|
||||
default=lambda: os.getenv("CI_GITEA_API_TOKEN") or os.getenv("CI_GITEA_TOKEN"),
|
||||
help=_("Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)"),
|
||||
)
|
||||
@click.option(
|
||||
"--integration-retries",
|
||||
@@ -528,8 +528,10 @@ def list_runners(ask_become_pass: bool, no_status: bool) -> None:
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
default=lambda: os.getenv("CI_GITEA_TOKEN"),
|
||||
help=_("Gitea API token (env: CI_GITEA_TOKEN)"),
|
||||
default=lambda: (
|
||||
os.getenv("DEVELOPER_GITEA_API_TOKEN") or os.getenv("CI_GITEA_API_TOKEN") or os.getenv("CI_GITEA_TOKEN")
|
||||
),
|
||||
help=_("Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)"),
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ class AnsibleExecutor:
|
||||
with open(log_file) as f:
|
||||
content = f.read()
|
||||
match = re.search(
|
||||
r'TASK \[gitea-runner : Report runner status\].*?"msg":\s*"([^"]+)"',
|
||||
r'TASK \[gitea_runner : Report runner status\].*?"msg":\s*"([^"]+)"',
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -100,14 +100,14 @@ class RunnerManager:
|
||||
|
||||
extra_vars: dict[str, str | int] = {
|
||||
"registration_token": token,
|
||||
"runner_name": name,
|
||||
"gitea_runner_name": name,
|
||||
"gitea_url": gitea_url,
|
||||
"gitea_runner_integration_retries": integration_retries,
|
||||
}
|
||||
if admin_token:
|
||||
extra_vars["gitea_admin_token"] = admin_token
|
||||
if labels is not None:
|
||||
extra_vars["runner_labels"] = labels
|
||||
extra_vars["gitea_runner_labels"] = labels
|
||||
|
||||
with track_steps() as tracker:
|
||||
tracker.begin(_("Installing Gitea Runner on {host}", host=host))
|
||||
@@ -216,7 +216,7 @@ class RunnerManager:
|
||||
"start-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name},
|
||||
{"gitea_runner_name": name},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Starting Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -243,7 +243,7 @@ class RunnerManager:
|
||||
"stop-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name},
|
||||
{"gitea_runner_name": name},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Stopping Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -270,7 +270,7 @@ class RunnerManager:
|
||||
"restart-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name},
|
||||
{"gitea_runner_name": name},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Restarting Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -297,7 +297,7 @@ class RunnerManager:
|
||||
"enable-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name},
|
||||
{"gitea_runner_name": name},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Enabling Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -331,7 +331,7 @@ class RunnerManager:
|
||||
"disable-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
|
||||
{"gitea_runner_name": name, "registration_token": token, "gitea_url": resolved_gitea_url},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Disabling Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -358,7 +358,7 @@ class RunnerManager:
|
||||
"status-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name},
|
||||
{"gitea_runner_name": name},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Checking status of Gitea Runner {name} on {host}", name=name, host=actual_host),
|
||||
@@ -401,7 +401,7 @@ class RunnerManager:
|
||||
"remove-runner.yml",
|
||||
actual_host,
|
||||
actual_user,
|
||||
{"runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
|
||||
{"gitea_runner_name": name, "registration_token": resolved_token, "gitea_url": resolved_gitea_url},
|
||||
actual_key,
|
||||
ask_become_pass,
|
||||
description=_("Removing Gitea Runner {name} from {host}", name=name, host=actual_host),
|
||||
|
||||
+14
-14
@@ -175,13 +175,13 @@
|
||||
"ru": "URL Gitea (env: GITEA_URL)",
|
||||
"zh": "Gitea URL(环境变量: GITEA_URL)"
|
||||
},
|
||||
"Gitea admin API token for integration test (env: CI_GITEA_TOKEN)": {
|
||||
"bg": "Gitea admin API токен за интеграционен тест (env: CI_GITEA_TOKEN)",
|
||||
"de": "Gitea-Admin-API-Token für Integrationstest (env: CI_GITEA_TOKEN)",
|
||||
"en": "Gitea admin API token for integration test (env: CI_GITEA_TOKEN)",
|
||||
"pl": "Token API administratora Gitea do testów integracyjnych (env: CI_GITEA_TOKEN)",
|
||||
"ru": "Токен админ API Gitea для интеграционного теста (env: CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: CI_GITEA_TOKEN)"
|
||||
"Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)": {
|
||||
"bg": "Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)",
|
||||
"de": "Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)",
|
||||
"en": "Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)",
|
||||
"pl": "Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)",
|
||||
"ru": "Gitea admin API token for integration test (env: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea admin API token for integration test(环境变量: CI_GITEA_API_TOKEN or CI_GITEA_TOKEN)"
|
||||
},
|
||||
"HEALTHY": {
|
||||
"bg": "ЗДРАВ",
|
||||
@@ -703,13 +703,13 @@
|
||||
"ru": "Git ref для запуска workflow (по умолчанию: master)",
|
||||
"zh": "运行工作流的 Git ref(默认:master)"
|
||||
},
|
||||
"Gitea API token (env: CI_GITEA_TOKEN)": {
|
||||
"bg": "Gitea API токен (env: CI_GITEA_TOKEN)",
|
||||
"de": "Gitea API-Token (env: CI_GITEA_TOKEN)",
|
||||
"en": "Gitea API token (env: CI_GITEA_TOKEN)",
|
||||
"pl": "Token API Gitea (env: CI_GITEA_TOKEN)",
|
||||
"ru": "Токен API Gitea (env: CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea API 令牌(环境变量:CI_GITEA_TOKEN)"
|
||||
"Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)": {
|
||||
"bg": "Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)",
|
||||
"de": "Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)",
|
||||
"en": "Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)",
|
||||
"pl": "Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)",
|
||||
"ru": "Gitea API token (env: DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)",
|
||||
"zh": "Gitea API token(环境变量:DEVELOPER_GITEA_API_TOKEN, CI_GITEA_API_TOKEN, or CI_GITEA_TOKEN)"
|
||||
},
|
||||
"List available workflows instead of triggering one": {
|
||||
"bg": "Списък на наличните работни процеси вместо изпълнение",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user