Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b493f2a3 | ||
|
|
63e25d1245 | ||
|
|
996a8dc806 | ||
|
|
ea6276ff38 | ||
|
|
f0afa8171a | ||
|
|
09699696c0 | ||
|
|
ea8b71da1a | ||
|
|
a7eb4d1a68 | ||
|
|
e00d40dd54 | ||
|
|
63fb259bac | ||
|
|
5c1d848311 | ||
|
|
1717d55013 | ||
|
|
a676adb025 | ||
|
|
1d85a6da9d | ||
|
|
3822f6fe9a | ||
|
|
7b0e700fe5 | ||
|
|
b7a04f37de | ||
|
|
fcd26dd110 | ||
|
|
8579a4064f | ||
|
|
c271293b2d | ||
|
|
6ef38631fc | ||
|
|
92ca7ef7bb | ||
|
|
78c9b635e9 | ||
|
|
d65b2190e2 | ||
|
|
69b96e4785 | ||
|
|
a57a5c328c | ||
|
|
312b8241df | ||
|
|
227db4c457 | ||
|
|
da86eb0e9d | ||
|
|
084ea49523 | ||
|
|
8af88efeb5 | ||
|
|
36207be565 | ||
|
|
02909bf8b4 | ||
|
|
119d70e137 | ||
|
|
74db5f28c7 | ||
|
|
8aa00c7091 | ||
|
|
2a803c611b | ||
|
|
43b2a01361 | ||
|
|
d4ea2eef61 | ||
|
|
55c2746569 | ||
|
|
0c6c735000 | ||
|
|
c8970da942 | ||
|
|
3364355c73 | ||
|
|
0fadb7c504 | ||
|
|
ea18793963 | ||
|
|
9c51c8b62c | ||
|
|
d949bd3444 | ||
|
|
d8c31238bd | ||
|
|
ae27417a5f | ||
|
|
e9c0f22fc0 | ||
|
|
fefddda715 | ||
|
|
790b5c3769 | ||
|
|
b2acaefaf9 | ||
|
|
e1b589efab | ||
|
|
4fa3aeb54c | ||
|
|
7d25e059f7 | ||
|
|
4d2ac8d449 | ||
|
|
3048d7ade7 | ||
|
|
129cfe3c79 | ||
|
|
1a917e7a5d | ||
|
|
d4766da5f9 | ||
|
|
c05d7c9c4f | ||
|
|
c91626a8ff | ||
|
|
ad7c6255d5 | ||
|
|
e1173aeeb8 | ||
|
|
4da724ce53 | ||
|
|
1d3d2487ac | ||
|
|
39ef86647c | ||
|
|
5fb282e38e | ||
|
|
3d943b57dc | ||
|
|
a9adb71a08 | ||
|
|
1bbe3b4f43 | ||
|
|
12e6ce1601 | ||
|
|
51f204f90a | ||
|
|
ebb1088a8c | ||
|
|
18760f6a2a | ||
|
|
677745ac99 | ||
|
|
6ede871054 | ||
|
|
47d5df0aed | ||
|
|
3f7507500b | ||
|
|
3b3e002f7f | ||
|
|
815b59c537 | ||
|
|
3b68302274 | ||
|
|
b3ac955515 | ||
|
|
4c08606d0c | ||
|
|
a575a89026 | ||
|
|
adb758f5f5 | ||
|
|
e5964ca5a9 | ||
|
|
047ee05fa1 | ||
|
|
564c12e782 | ||
|
|
6414f2306c | ||
|
|
1ad7c0a816 | ||
|
|
68dcbc9b16 | ||
|
|
9378451a12 | ||
|
|
e08c11ea83 | ||
|
|
81dd11e720 | ||
|
|
a05cd1c7ee | ||
|
|
55fee77c91 | ||
|
|
b48038a3a9 | ||
|
|
994c30da57 | ||
|
|
aef24352c1 | ||
|
|
8c385dcbdc | ||
|
|
2530ec54cc | ||
|
|
cc000c226c | ||
|
|
0cea9b9490 | ||
|
|
b3838eb180 | ||
|
|
3c8654342f |
@@ -5,3 +5,6 @@ exclude_paths:
|
||||
- molecule/
|
||||
- .molecule/
|
||||
- .pytest_cache/
|
||||
skip_list:
|
||||
# Rootless Docker uses `systemctl --user` which the systemd module doesn't support
|
||||
- command-instead-of-module
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[checkmake]
|
||||
# Disable the phony rule which flags common .PHONY placement patterns
|
||||
# as it produces false positives for standard Makefile layouts
|
||||
disable=maxbodylength
|
||||
|
||||
+34
-3
@@ -1,4 +1,35 @@
|
||||
# Gitea instance URL (used for runner registration and API validation)
|
||||
GITEA_URL=https://git.example.com
|
||||
GITEA_TOKEN=your-personal-access-token
|
||||
# Optional: GITEA_RUNNER_USER=ubuntu
|
||||
# Optional: GITEA_RUNNER_KEY=~/.ssh/id_ed25519
|
||||
|
||||
# Runner registration token from Gitea admin panel:
|
||||
# Admin → Actions → Runners → Create Registration Token
|
||||
GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
|
||||
# Gitea API token for optional post-install API checks (informational only).
|
||||
# The integration test primarily verifies the runner by checking:
|
||||
# 1. The .runner registration file exists and is valid
|
||||
# 2. The container/service is running
|
||||
# If set, API checks are performed as a bonus but do NOT affect pass/fail.
|
||||
# Required scopes: read:user, read:repository, read:admin (or just "admin")
|
||||
# Generate token at: Settings → Applications → Generate New Token
|
||||
# REPO_TOKEN=your-admin-api-token
|
||||
|
||||
# Integration test API retries (optional, default: 3).
|
||||
# Number of times to retry API checks waiting for runner to appear.
|
||||
# GITEA_INTEGRATION_RETRIES=3
|
||||
|
||||
# Default SSH user for remote hosts (optional, overrides --user)
|
||||
# GITEA_RUNNER_USER=ubuntu
|
||||
|
||||
# Default SSH private key path (optional, overrides --key)
|
||||
# GITEA_RUNNER_KEY=~/.ssh/id_ed25519
|
||||
|
||||
# Default runner labels for Gitea Actions (optional, overrides --labels)
|
||||
# Format: <label>:<docker-image>[:<command>]
|
||||
# Use an official Gitea runner image with Node.js, Python and Docker CLI.
|
||||
# Avoid bare OS images like alpine:latest because actions/checkout@v4 needs Node.
|
||||
# GITEA_RUNNER_LABELS=docker:docker://gitea/runner-images:ubuntu-latest
|
||||
|
||||
# UI language for GRM console messages (optional, default: en)
|
||||
# Supported: en, bg, de, ru, zh
|
||||
# GRM_LANG=en
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/auto_merge.py \
|
||||
"${{ github.head_ref }}" \
|
||||
"${{ github.event.pull_request.title }}" \
|
||||
"${{ github.repository }}" \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.event.label.name }}"
|
||||
@@ -0,0 +1,90 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
make pytest-cov
|
||||
- name: Check unit test speed
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/release.py --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="origin/master"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BASE="HEAD~1"
|
||||
HEAD="HEAD"
|
||||
fi
|
||||
# Check if any Ansible-related files changed
|
||||
ANSIBLE_CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- ansible/ .ansible-lint 2>/dev/null | head -1)
|
||||
if [ -n "$ANSIBLE_CHANGED" ]; then
|
||||
echo "ansible-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Ansible files changed — molecule tests will run."
|
||||
else
|
||||
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Ansible files changed — skipping molecule tests."
|
||||
fi
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
strategy:
|
||||
matrix:
|
||||
runner-index: [0, 1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Discover assigned test pairs
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PAIRS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3)
|
||||
echo "Assigned pairs: $PAIRS"
|
||||
echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV
|
||||
- name: Run molecule tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/molecule_ci_guard.py $TEST_PAIRS
|
||||
env:
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Post-merge Vikunja update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
vikunja:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/post_merge.py \
|
||||
"$(git log -1 --pretty=%B)" \
|
||||
--commit-sha "$(git rev-parse HEAD)"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Validate PYPI_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.PYPI_TOKEN }}" ]; then
|
||||
echo "::warning::PYPI_TOKEN is not set — package will be built but not published to PyPI."
|
||||
fi
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/publish.py \
|
||||
"${{ github.ref_name }}" \
|
||||
"${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Install Python dependencies
|
||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "release" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -25,6 +25,13 @@ build/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Security scanner
|
||||
.bandit
|
||||
bandit-report.*
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
activate.sh
|
||||
activate.fish
|
||||
activate.zsh
|
||||
|
||||
+28
-20
@@ -1,25 +1,40 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-lint
|
||||
- id: validate-commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
|
||||
- id: lint-ruff
|
||||
name: ruff lint
|
||||
entry: .venv/bin/ruff check src/ tests/
|
||||
entry: make lint-ruff
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: ruff-format
|
||||
- id: lint-format
|
||||
name: ruff format check
|
||||
entry: .venv/bin/ruff format --check src/ tests/
|
||||
entry: make lint-format
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pyright
|
||||
- id: typecheck
|
||||
name: pyright type check
|
||||
entry: .venv/bin/pyright
|
||||
entry: make typecheck
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-bandit
|
||||
name: bandit security scan
|
||||
entry: make lint-bandit
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
@@ -27,30 +42,23 @@ repos:
|
||||
|
||||
- id: ansible-lint
|
||||
name: ansible-lint
|
||||
entry: .venv/bin/ansible-lint ansible/
|
||||
entry: make ansible-lint
|
||||
language: system
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: detect-secrets
|
||||
name: detect-secrets
|
||||
entry: .venv/bin/detect-secrets scan --baseline .secrets.baseline
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: .venv/bin/pytest tests/unit/ --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100
|
||||
entry: make pytest-cov
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
|
||||
- id: test-all
|
||||
name: run all tests
|
||||
entry: make test-all
|
||||
- id: commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.11.11
|
||||
3.12
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# AGENTS.md — Project Conventions for GRM
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
|
||||
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
|
||||
- **CI Scripts** (`scripts/`) — Automation for 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
|
||||
|
||||
## PR Workflow (Mandatory)
|
||||
|
||||
Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
### 1. Create Vikunja Task
|
||||
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
|
||||
|
||||
### 2. Create Branch
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b GRM-N-short-description
|
||||
```
|
||||
|
||||
### 3. Implement Changes
|
||||
- Write code following conventions below
|
||||
- Write/update tests (100% coverage required)
|
||||
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
|
||||
|
||||
### 4. Commit (Conventional Commits)
|
||||
Branch commits use conventional commit format (no `GRM-N:` prefix):
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### 5. Push and Create PR
|
||||
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
|
||||
- PR body: summary of changes, `Closes GRM-N`
|
||||
- Add `ready-to-merge` label **only after review is complete**
|
||||
|
||||
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
|
||||
Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
|
||||
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
|
||||
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
|
||||
- **Technical excellence**:
|
||||
- Architecture compliance and evolution
|
||||
- Single Responsibility Principle (SRP)
|
||||
- Deduplication (no copy-paste, single source of truth)
|
||||
- Code smells detection and removal
|
||||
- Best industry practices
|
||||
- Industry-grade code quality
|
||||
- Reusability
|
||||
- Clean code
|
||||
- Readability
|
||||
- Maintainability
|
||||
- Extensibility
|
||||
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
|
||||
- **Security**: No secrets in logs/process list, input validation, no injection vectors
|
||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||
|
||||
Post review comments using `scripts/review_pr.py`:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
```
|
||||
|
||||
### 7. Address Review Comments
|
||||
Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
|
||||
### 8. Approve and Merge
|
||||
Once all comments are addressed:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
```
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
1. **Validate** PR title format and match against Vikunja task title
|
||||
2. **Check** that at least one APPROVE review exists
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes (see below)
|
||||
|
||||
### CI Path Filtering
|
||||
|
||||
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.
|
||||
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically:
|
||||
|
||||
1. **Release workflow** (`.gitea/workflows/release.yml`):
|
||||
- Triggers on push to master
|
||||
- Runs `scripts/release.py` which uses **git-cliff** to:
|
||||
- Calculate the next semver version from conventional commits since the last tag
|
||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Create an annotated tag `vX.Y.Z` on the release commit
|
||||
- Push both the commit and tag to master
|
||||
- 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 `scripts/notify_failure.py`
|
||||
|
||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||
- Triggers on tag push (`v*`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
- Builds the Python package
|
||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `GRM-N <conventional commit>`. The
|
||||
`GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml`
|
||||
includes a `commit_preprocessors` entry that strips it before parsing. This
|
||||
ensures all merged work appears in the changelog.
|
||||
|
||||
### Version Bumping Rules (git-cliff)
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
|
||||
|
||||
### Title Format Summary
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||
- 100% test coverage required (`--cov-fail-under=100`)
|
||||
- Conventional commits on feature branches (no `GRM-N:` prefix)
|
||||
- Branch names must include `GRM-N` task ID
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
|
||||
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
|
||||
|
||||
## Ansible Role Structure
|
||||
|
||||
```
|
||||
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
|
||||
```
|
||||
|
||||
- `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)
|
||||
|
||||
## Molecule Scenarios
|
||||
|
||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||
Platform list is defined in `scripts/distribute_molecule.py` (single source of truth)
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
@@ -0,0 +1,96 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.2.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
|
||||
- Fix 12 critical workflow gaps in release pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Release push permission and notify_failure label IDs
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push.
|
||||
- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation.
|
||||
- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases.
|
||||
- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available).
|
||||
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`).
|
||||
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
|
||||
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
|
||||
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
|
||||
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
|
||||
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
|
||||
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
|
||||
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
|
||||
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
|
||||
|
||||
### Changed
|
||||
|
||||
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
|
||||
- `gitea_runner_data_dir` — Runtime data directory
|
||||
- `gitea_runner_config_dir` — Config directory
|
||||
- `gitea_runner_binary_path` — Binary install path
|
||||
- `gitea_runner_prune_until` — Prune age filter
|
||||
- `gitea_runner_prune_schedule` — Prune timer schedule
|
||||
- `gitea_runner_prune_label` — Docker label for pruning
|
||||
- `gitea_runner_service_restart_sec` — systemd restart interval
|
||||
- `gitea_runner_service_user` — Service user
|
||||
- `gitea_runner_log_level` — Runner log level
|
||||
- `gitea_runner_container_label` — Container label
|
||||
- `gitea_runner_file` — Runner metadata file
|
||||
- `docker_gpg_key_path` — Docker GPG key path
|
||||
- Added `console_scripts` entry point in `pyproject.toml` (`grm = "gitea_runner_manager.cli:cli"`).
|
||||
- Added shared `molecule/common/prepare.yml` to eliminate duplicated prepare playbooks.
|
||||
- Extracted repeated systemd availability check into `tasks/systemd_check.yml`.
|
||||
- Added idempotence checks to all Molecule scenarios.
|
||||
- Comprehensive README overhaul with Architecture, Configuration, Development, Testing, and Troubleshooting sections.
|
||||
- API URLs and repo configuration in `config.py` are now overridable via environment variables (`GRM_GITEA_API_URL`, `GRM_VIKUNJA_API_URL`, `GRM_REPO_OWNER`, `GRM_REPO_NAME`, `GRM_VIKUNJA_PROJECT_ID`).
|
||||
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup.
|
||||
|
||||
### Security
|
||||
|
||||
- **Critical fix**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line (CWE-214). Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced legacy runner terminology with `gitea_runner` / `gitea-runner` / `Gitea Runner`.
|
||||
- Updated default Docker image from `gitea/gitea_runner` to `gitea/runner`.
|
||||
- `Makefile` now uses the installed `grm` console script instead of `python grm`.
|
||||
- `pyproject.toml` ruff and pyright target versions updated from `py311` to `py312` to match `requires-python = ">=3.12"`.
|
||||
- `BRANCH_PROTECTION_CONFIG` updated with correct Gitea Actions status check contexts (including `(pull_request)` suffix) and `required_approvals: 0` for auto-merge.
|
||||
- `CONVENTIONAL_RE` no longer matches `BREAKING CHANGE` as a commit type (it is a footer, not a type).
|
||||
- `rootless_docker.yml` apt cache update now only runs when the Docker repo file changes (idempotent, but always refreshes on first add).
|
||||
- `service.yml` and `prune.yml` template creation tasks are not guarded by `docker_rootless_setup` (templates just create files, they don't need Docker; molecule tests set `docker_rootless_setup: false` but still verify the service file exists).
|
||||
- `molecule_all.sh` now sources the platform list from `distribute_molecule.py` to avoid duplication.
|
||||
|
||||
### Removed
|
||||
|
||||
- Deleted `setup.py` (redundant with `pyproject.toml`).
|
||||
- Deleted `grm` shell entrypoint script (replaced by `console_scripts`).
|
||||
- Deleted `initial-plan.md` and `tests/integration/test_provision.py` (dead code).
|
||||
- Removed empty `__init__.py` files from `tests/` directories.
|
||||
- Removed unused `runner_validated` fact from `validate.yml`.
|
||||
- Removed duplicate `prune.yml` and `integration_test.yml` includes from `install_runner.yml` (already included from `main.yml`).
|
||||
- Removed dead `tasks/config.yml` (never included by any playbook).
|
||||
- Removed dead `templates/gitea-runner.service.j2` (legacy system-level service, replaced by rootless `gitea-runner-user.service.j2`).
|
||||
- Removed dead "Reload systemd" handler (system-level reload, never notified, wrong scope for user services).
|
||||
- Removed dead `scripts/run_molecule_parallel.py` and its test (replaced by `molecule_ci_guard.py`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Molecule idempotence failures caused by non-idempotent service restart.
|
||||
- Missing `/etc/docker` directory handling in Molecule tests.
|
||||
- `ansible-lint` formatting warnings (yaml empty lines).
|
||||
- Verify playbooks now explicitly load role defaults so parameterized variables are available during verification.
|
||||
- Duplicate execution of prune and integration test tasks during installation (were included from both `main.yml` and `install_runner.yml`).
|
||||
- apt cache update reporting `changed` on every run due to `cache_valid_time: 0`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Contributing to GRM
|
||||
|
||||
Thank you for contributing to Gitea Runner Manager (GRM)!
|
||||
|
||||
## Branch Naming
|
||||
|
||||
All feature branches **must** include a `GRM-N` prefix corresponding to the Vikunja task identifier. Examples:
|
||||
|
||||
- `GRM-19`
|
||||
- `GRM-19-fix-bug`
|
||||
- `GRM-42-add-update-command`
|
||||
|
||||
The `GRM-N` prefix is mandatory — CI extracts it for merge messages and Vikunja updates.
|
||||
|
||||
## Commit Format
|
||||
|
||||
### Feature branches
|
||||
Use **conventional commits** on feature branches:
|
||||
|
||||
```
|
||||
feat: add new command
|
||||
fix: resolve timeout issue
|
||||
chore: update dependencies
|
||||
docs: improve README
|
||||
```
|
||||
|
||||
Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `ci`, `build`, `revert`, `BREAKING CHANGE`.
|
||||
|
||||
**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches.
|
||||
|
||||
### Master branch (squash merges)
|
||||
Squash commits on `master` must follow:
|
||||
|
||||
```
|
||||
GRM-N: <conventional commit message>
|
||||
```
|
||||
|
||||
Example: `GRM-24: fix: resolve molecule idempotence`.
|
||||
|
||||
This format is enforced by the auto-merge workflow, which validates the PR title is a conventional commit before squash-merging and prepending the task ID.
|
||||
|
||||
## Local Testing
|
||||
|
||||
```bash
|
||||
make test-all # Runs pytest-cov + molecule
|
||||
make lint-all # Runs ruff, pyright, bandit, ansible-lint, checkmake
|
||||
make lint-bandit # Security scan with bandit
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make molecule # All 6 molecule scenarios
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
- **ruff**: Line length 120
|
||||
- **pyright**: Strict mode
|
||||
- **bandit**: Security scan for Python code (no high/medium severity issues)
|
||||
- **Test coverage**: 100% required
|
||||
- **ansible-lint**: For all Ansible content
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule test-all clean
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean
|
||||
|
||||
PYTHON := python3
|
||||
VENV := .venv
|
||||
@@ -8,10 +8,7 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
|
||||
all: setup
|
||||
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake
|
||||
$(BIN)/pip install -e ".[dev]"
|
||||
$(BIN)/ansible-galaxy collection install -r ansible/requirements.yml
|
||||
$(BIN)/pre-commit install
|
||||
@echo "Setup complete. Activate the virtual environment with: source .venv/bin/activate"
|
||||
@bash scripts/setup.sh "$(BIN)"
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then \
|
||||
@@ -20,6 +17,7 @@ setup: $(VENV)/bin/activate .env activate-scripts checkmake
|
||||
fi
|
||||
|
||||
$(VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
@@ -28,22 +26,60 @@ activate-scripts: $(VENV)/bin/activate
|
||||
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
|
||||
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
|
||||
|
||||
install-hooks:
|
||||
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
||||
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||
@echo "Git hooks installed."
|
||||
|
||||
checkmake:
|
||||
@which checkmake >/dev/null 2>&1 || (which go >/dev/null 2>&1 && go install github.com/mrtazz/checkmake/cmd/checkmake@latest) || (echo "Warning: checkmake not installed. Install Go and run: go install github.com/mrtazz/checkmake/cmd/checkmake@latest" && exit 0)
|
||||
@python3 scripts/install_checkmake.py
|
||||
|
||||
install:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/python grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),)
|
||||
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
update:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make update HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/python grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),)
|
||||
$(BIN)/grm update $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(VERSION),--version $(VERSION),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
lint:
|
||||
start:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make start HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm start $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
stop:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make stop HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm stop $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
enable:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make enable HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm enable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
disable:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make disable HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm disable $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
status:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make status HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm status $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
remove:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make remove HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
lint-ruff:
|
||||
$(BIN)/ruff check src/ tests/
|
||||
|
||||
lint-format:
|
||||
$(BIN)/ruff format --check src/ tests/
|
||||
|
||||
typecheck:
|
||||
$(BIN)/pyright
|
||||
|
||||
lint: lint-ruff lint-format typecheck lint-bandit
|
||||
|
||||
lint-bandit:
|
||||
$(BIN)/bandit -r src/ scripts/
|
||||
|
||||
ansible-lint:
|
||||
$(BIN)/ansible-lint ansible/
|
||||
|
||||
@@ -53,13 +89,24 @@ makefile-lint:
|
||||
lint-all: lint ansible-lint makefile-lint
|
||||
|
||||
test-unit:
|
||||
$(BIN)/pytest tests/unit/ -v
|
||||
$(BIN)/pytest tests/unit/ -v --no-cov
|
||||
|
||||
test-integration:
|
||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||
|
||||
pytest-cov:
|
||||
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100
|
||||
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
MOLECULE := $(realpath $(BIN))/molecule
|
||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||
|
||||
# Quick local test: Ubuntu 22.04 only, all scenarios
|
||||
molecule:
|
||||
cd ansible/roles/gitea-runner && $(BIN)/molecule test
|
||||
@set -e; for s in default multi-instance lifecycle template-content deregister update; do if [ "$$s" = "default" ]; then $(MOLECULE_BASE) test; else $(MOLECULE_BASE) test -s $$s; fi; done
|
||||
|
||||
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
||||
molecule-all:
|
||||
@bash scripts/molecule_all.sh
|
||||
|
||||
test: test-all
|
||||
|
||||
|
||||
@@ -2,20 +2,80 @@
|
||||
|
||||
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
|
||||
|
||||
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. The runner binary (`gitea_runner`) is installed directly and managed as a systemd user service.
|
||||
|
||||
> **Pronunciation note:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM" in Latin letters) — the Bulgarian word for **thunder**. Wherever there are clouds, there may be thunders. This is an open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
|
||||
## Commit Convention & Branch Naming
|
||||
|
||||
This project uses **conventional commits** and **GRM-N branch prefixes**. See [AGENTS.md](AGENTS.md) for the full workflow.
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
### PR Workflow
|
||||
|
||||
Every change to master goes through a mandatory review workflow:
|
||||
|
||||
1. **Create Vikunja task** — get a `GRM-N` identifier
|
||||
2. **Create branch** — `GRM-N-short-description`
|
||||
3. **Implement** — write code, tests (100% coverage), update docs
|
||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`
|
||||
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 `scripts/review_pr.py`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
|
||||
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
|
||||
|
||||
### Automated Versioning & Releases
|
||||
|
||||
Versioning is fully automated using [git-cliff](https://git-cliff.org):
|
||||
|
||||
1. **After merge to master** — the release workflow runs `scripts/release.py`
|
||||
2. **git-cliff calculates the next version** from conventional commits since the last tag
|
||||
3. **Version file is updated** (`__init__.py`) and a `chore(release): prepare for vX.Y.Z` commit is created
|
||||
4. **An annotated tag `vX.Y.Z`** is pushed with the changelog as the tag message
|
||||
5. **The publish workflow triggers** on the tag — builds the package, optionally publishes to PyPI, and creates a Gitea release with generated release notes
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor |
|
||||
| `fix:` | patch |
|
||||
| `feat!:` / `BREAKING CHANGE` | minor (pre-1.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump |
|
||||
|
||||
`grm --version` reports the current version from `__init__.py`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple and focused** — no unnecessary features.
|
||||
- **Secure** — no hardcoded secrets, uses scoped tokens.
|
||||
- **Idempotent** — can be run multiple times safely.
|
||||
- **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
|
||||
- **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name.
|
||||
- **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI.
|
||||
- **Multi-instance** — run multiple isolated runners on the same host, each with its own system user, rootless Docker daemon, data directory, and systemd user service.
|
||||
- **Rootless Docker** — each runner gets its own rootless Docker daemon, avoiding conflicts with the host's Docker installation and enabling true parallel execution.
|
||||
- **Systemd-managed** — runners run as systemd user services (`gitea-runner.service`) under dedicated per-runner system users.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- Arch Linux
|
||||
- Ubuntu 22.04 / 24.04 / 26.04
|
||||
- Debian 12 / 13
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- Debian 12
|
||||
|
||||
All supported OSes are tested in CI via molecule scenarios on every PR.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -24,8 +84,8 @@ A lean command-line tool to automate the installation, configuration, and lifecy
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.11.11
|
||||
pyenv local 3.11.11
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
@@ -35,44 +95,341 @@ make setup
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_TOKEN=your-personal-access-token
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
The token needs `admin:runner` scope.
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
#### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
### Install a Runner
|
||||
|
||||
Using the CLI:
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
./grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
### Runner Registry
|
||||
|
||||
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
|
||||
|
||||
```bash
|
||||
# List all registered runners with live systemd status
|
||||
grm list
|
||||
```
|
||||
|
||||
### Manage Runner Lifecycle
|
||||
|
||||
Once a runner is installed, lifecycle commands work by runner name only:
|
||||
|
||||
```bash
|
||||
# Start a runner
|
||||
grm start prod-runner
|
||||
|
||||
# Stop a runner
|
||||
grm stop prod-runner
|
||||
|
||||
# Enable a runner to start on boot
|
||||
grm enable prod-runner
|
||||
|
||||
# Disable a runner (stops, deregisters, and disables systemd)
|
||||
grm disable prod-runner --token <token>
|
||||
|
||||
# Check runner status
|
||||
grm status prod-runner
|
||||
|
||||
# Remove a runner completely
|
||||
grm remove prod-runner --token <token>
|
||||
```
|
||||
|
||||
You can override any stored value by passing the corresponding flag:
|
||||
|
||||
```bash
|
||||
grm start prod-runner --host 192.168.1.11 --user root
|
||||
```
|
||||
|
||||
> **Automation tip:** If the remote host has passwordless sudo configured, pass `--no-ask-become-pass`.
|
||||
|
||||
### Multiple Instances on the Same Host
|
||||
|
||||
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
|
||||
|
||||
```bash
|
||||
# Install two runners on the same host
|
||||
grm install 192.168.1.10 --user ubuntu --name workflow-runner
|
||||
grm install 192.168.1.10 --user ubuntu --name build-runner
|
||||
|
||||
# Manage them independently by name
|
||||
grm stop workflow-runner
|
||||
grm status build-runner
|
||||
```
|
||||
|
||||
### Verify Runner
|
||||
|
||||
Check Gitea admin UI under **Actions → Runners**. The runner should appear as **Online**.
|
||||
The installer performs an automated integration test that verifies:
|
||||
|
||||
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
|
||||
2. **Systemd user service is active** — this proves the daemon is polling for jobs
|
||||
|
||||
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
|
||||
|
||||
Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
|
||||
|
||||
### View Logs
|
||||
|
||||
**GRM application logs** (Python CLI output):
|
||||
|
||||
```bash
|
||||
sudo journalctl -u act-runner-<name> -f
|
||||
# Application log file (all messages including DEBUG)
|
||||
cat ~/.local/state/grm/logs/grm.log
|
||||
|
||||
# Enable debug logging in the current session
|
||||
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
```
|
||||
|
||||
**Runner logs** (on the remote host):
|
||||
|
||||
```bash
|
||||
# Runner logs (via systemd user service)
|
||||
sudo -u grm-<name> journalctl --user -u gitea-runner -f
|
||||
```
|
||||
|
||||
The GRM application writes to two destinations:
|
||||
|
||||
| Destination | Level | Content |
|
||||
|-------------|-------|---------|
|
||||
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
|
||||
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
|
||||
|
||||
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
|
||||
|
||||
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
|
||||
|
||||
## Architecture
|
||||
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
```
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
├── prune.yml (Docker prune timer)
|
||||
└── integration_test.yml (validate service is active)
|
||||
```
|
||||
|
||||
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<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)
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runner/defaults/main.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `gitea_runner_version` | `1.0.8` | Runner binary version |
|
||||
| `runner_labels` | `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04` | Runner labels |
|
||||
| `skip_runner_registration` | `false` | Skip API registration (useful for tests) |
|
||||
| `gitea_runner_user_prefix` | `grm-` | Prefix for per-runner system users |
|
||||
| `gitea_runner_base_home` | `/home` | Base directory for runner user homes |
|
||||
| `gitea_runner_service_user` | `{{ prefix }}{{ runner_name }}` | Per-runner system user |
|
||||
| `gitea_runner_home` | `{{ base_home }}/{{ service_user }}` | Runner user home directory |
|
||||
| `gitea_runner_base_data_dir` | `/var/lib/gitea-runner` | Base data directory (instance-scoped) |
|
||||
| `gitea_runner_base_config_dir` | `/etc/gitea-runner` | Base config directory (instance-scoped) |
|
||||
| `gitea_runner_data_dir` | `{{ base }}/{{ runner_name }}` | Runtime data directory per instance |
|
||||
| `gitea_runner_config_dir` | `{{ base }}/{{ runner_name }}` | Config directory per instance |
|
||||
| `gitea_runner_binary_path` | `/usr/local/bin/gitea_runner` | Binary install path |
|
||||
| `gitea_runner_prune_until` | `24h` | Prune resources older than this |
|
||||
| `gitea_runner_prune_schedule` | `daily` | systemd timer schedule |
|
||||
| `gitea_runner_prune_label` | `gitea-runner=true` | Docker label for pruning |
|
||||
| `gitea_runner_service_restart_sec` | `5` | systemd RestartSec value |
|
||||
| `gitea_runner_log_level` | `info` | Runner log level |
|
||||
| `gitea_runner_container_label` | `gitea-runner=true` | Container label |
|
||||
| `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path |
|
||||
| `GRM_LANG` | `en` | CLI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LOG_LEVEL` | `INFO` | Console verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
| `GRM_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API URL for CI scripts |
|
||||
| `GRM_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API URL for post-merge scripts |
|
||||
| `GRM_REPO_OWNER` | `oblachno-oss` | Repository owner for CI scripts |
|
||||
| `GRM_REPO_NAME` | `grm` | Repository name for CI scripts |
|
||||
| `GRM_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID for task tracking |
|
||||
|
||||
Override any variable by passing it to the CLI with `--extra-vars` or by setting it in your Ansible inventory.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/gitea_runner_manager/ # Python CLI source
|
||||
│ ├── cli.py # Click commands
|
||||
│ ├── runner_manager.py # Ansible orchestration + registry integration
|
||||
│ ├── executor.py # Ansible subprocess execution
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files
|
||||
│ │ ├── templates/ # Jinja2 templates
|
||||
│ │ └── molecule/ # Test scenarios
|
||||
│ ├── install-runner.yml # Install playbook
|
||||
│ ├── update-runner.yml # Update playbook
|
||||
│ ├── start-runner.yml # Start playbook
|
||||
│ ├── stop-runner.yml # Stop playbook
|
||||
│ ├── enable-runner.yml # Enable playbook
|
||||
│ ├── disable-runner.yml # Disable playbook
|
||||
│ ├── status-runner.yml # Status playbook
|
||||
│ └── remove-runner.yml # Remove playbook
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── Makefile # Build & test automation
|
||||
└── pyproject.toml # Python project metadata
|
||||
```
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
make setup # Creates venv, installs deps, sets up hooks
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Running Linters
|
||||
|
||||
```bash
|
||||
make lint # Python (ruff + pyright + bandit)
|
||||
make lint-bandit # Security scan only
|
||||
make ansible-lint # Ansible
|
||||
make makefile-lint # Makefile
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Runs pytest with 100% coverage requirement.
|
||||
|
||||
### Molecule Tests
|
||||
|
||||
```bash
|
||||
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Runs six scenarios:
|
||||
|
||||
- **default** — Rootless Docker runner installation
|
||||
- **multi-instance** — Two isolated runner instances on the same host
|
||||
- **lifecycle** — Stop, disable, re-enable, and start sequence
|
||||
- **template-content** — Verify rendered systemd user service and prune templates
|
||||
- **deregister** — Runner deregistration
|
||||
- **update** — Runner binary update
|
||||
|
||||
All scenarios test idempotence (second run produces zero changes).
|
||||
|
||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests the full CLI lifecycle commands end-to-end ( mocked executor boundary).
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
```bash
|
||||
make test-all # Runs unit tests + linters + molecule
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Event loop is closed" warning
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
### Integration test fails
|
||||
|
||||
The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
2. **Service not running** — Daemon failed to start. Check:
|
||||
- `sudo -u grm-<name> systemctl --user status gitea-runner`
|
||||
- Logs for connection errors
|
||||
|
||||
### Rootless Docker: service fails to start
|
||||
|
||||
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
|
||||
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
|
||||
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
|
||||
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `setup` | Full environment setup |
|
||||
| `install` | Installs a runner on a host |
|
||||
| `lint` | Runs Python linters |
|
||||
| `update` | Updates a runner on a host |
|
||||
| `start` | Starts a runner instance |
|
||||
| `stop` | Stops a runner instance |
|
||||
| `enable` | Enables a runner to start on boot |
|
||||
| `disable` | Disables and deregisters a runner |
|
||||
| `status` | Checks runner status |
|
||||
| `remove` | Removes a runner completely |
|
||||
| `list` | Lists registered runners with live status |
|
||||
| `lint` | Runs Python linters (ruff, pyright, bandit) |
|
||||
| `lint-bandit` | Runs `bandit` security scanner |
|
||||
| `ansible-lint` | Runs `ansible-lint` |
|
||||
| `test-unit` | Runs unit tests with coverage |
|
||||
| `test-integration` | Runs integration tests |
|
||||
| `molecule` | Runs Ansible Molecule tests |
|
||||
| `test-all` | Runs all tests |
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
|
||||
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
|
||||
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
|
||||
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
|
||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
|
||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env fish
|
||||
# Activate the Python virtual environment for fish
|
||||
# Usage: source activate.fish
|
||||
|
||||
set -l script_dir (dirname (status --current-filename))
|
||||
source "$script_dir/.venv/bin/activate.fish"
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Activate the Python virtual environment for bash/zsh
|
||||
# Usage: source activate.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" && pwd)"
|
||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Activate the Python virtual environment for zsh
|
||||
# Usage: source activate.zsh
|
||||
|
||||
0="${ZERO:-${0:#$ZSH_ARGZERO}}"
|
||||
0="${${(M)0:#/*}:-$PWD/$0}"
|
||||
source "${0:A:h}/.venv/bin/activate"
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
- name: Disable Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- name: Include deregistration
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: deregister.yml
|
||||
when: not skip_runner_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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
- name: Enable Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
|
||||
- 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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
@@ -2,9 +2,5 @@
|
||||
- name: Install Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
gitea_url: "{{ gitea_url | mandatory }}"
|
||||
registration_token: "{{ registration_token | mandatory }}"
|
||||
runner_name: "{{ runner_name | default(inventory_hostname) }}"
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Gitea Runner Manager inventory example
|
||||
# Each line represents a target host for runner installation.
|
||||
#
|
||||
# Required variables per host:
|
||||
# ansible_user — SSH login user
|
||||
# ansible_ssh_private_key_file — Path to SSH private key
|
||||
#
|
||||
# Optional variables per host:
|
||||
# gitea_runner_version=1.0.8 — Runner binary version
|
||||
|
||||
[runners]
|
||||
192.168.1.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||
runner.example.com ansible_user=arch
|
||||
runner.example.com ansible_user=arch ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
- name: Remove Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
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) }}"
|
||||
register: runner_uid_result
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Set runner UID fact
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_uid: "{{ runner_uid_result.stdout }}"
|
||||
when: runner_uid_result.rc == 0
|
||||
|
||||
- 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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: 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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Stop rootless Docker daemon
|
||||
ansible.builtin.command: systemctl --user stop docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Include deregistration
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: deregister.yml
|
||||
when: not skip_runner_registration | default(false)
|
||||
|
||||
- name: Remove systemd user unit file
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home | default('/home/grm-' ~ runner_name) }}/.config/systemd/user/gitea-runner.service"
|
||||
state: absent
|
||||
when: remove_systemd_template | default(false)
|
||||
|
||||
- name: Kill remaining processes of runner user
|
||||
ansible.builtin.command: loginctl terminate-user "{{ gitea_runner_service_user | default('grm-' ~ 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) }}"
|
||||
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) }}"
|
||||
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) }}"
|
||||
state: absent
|
||||
remove: true
|
||||
when: remove_runner_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) }}:"
|
||||
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) }}:"
|
||||
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) }}"
|
||||
state: absent
|
||||
|
||||
- name: Remove runner config directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_config_dir | default('/etc/gitea-runner/' ~ runner_name) }}"
|
||||
state: absent
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
gitea_runner_version: "1.0.8"
|
||||
runner_labels: "docker,ubuntu-latest:docker://runner-images:ubuntu-22.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"
|
||||
|
||||
# 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,9 +1,12 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart act-runner
|
||||
ansible.builtin.systemd:
|
||||
name: "act-runner-{{ runner_name }}"
|
||||
state: restarted
|
||||
- name: Restart gitea-runner
|
||||
ansible.builtin.command: systemctl --user restart gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- ansible_facts is defined
|
||||
- ansible_facts['service_mgr'] | default('') == 'systemd'
|
||||
- docker_rootless_setup
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
- name: Prepare
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 0
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Install prerequisites for rootless Docker role (Debian/Ubuntu)
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- curl
|
||||
- gpg
|
||||
- python3-debian
|
||||
- ca-certificates
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Update pacman cache
|
||||
community.general.pacman:
|
||||
update_cache: true
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
|
||||
- name: Install prerequisites for rootless Docker role (Arch Linux)
|
||||
community.general.pacman:
|
||||
name:
|
||||
- curl
|
||||
- gnupg
|
||||
- ca-certificates
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
@@ -6,5 +6,7 @@
|
||||
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
|
||||
|
||||
@@ -3,19 +3,37 @@ driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: instance
|
||||
image: geerlingguy/docker-ubuntu2204-ansible:latest
|
||||
command: ""
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
- name: Prepare
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
@@ -2,47 +2,61 @@
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "molecule-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check act_runner binary exists
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/act_runner
|
||||
register: act_runner_stat
|
||||
- name: Check runner user exists
|
||||
ansible.builtin.user:
|
||||
name: "{{ gitea_runner_service_user }}"
|
||||
register: user_info
|
||||
check_mode: true
|
||||
|
||||
- name: Assert act_runner binary exists
|
||||
- name: Assert runner user exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- act_runner_stat.stat.exists
|
||||
fail_msg: "act_runner binary is missing"
|
||||
- user_info.state == "present"
|
||||
fail_msg: "Runner system user was not created"
|
||||
|
||||
- name: Check Docker is installed
|
||||
ansible.builtin.command: docker --version
|
||||
changed_when: false
|
||||
|
||||
- name: Check systemd service file exists
|
||||
- name: Check runner binary exists
|
||||
ansible.builtin.stat:
|
||||
path: "/etc/systemd/system/act-runner-molecule-test-runner.service"
|
||||
path: "{{ gitea_runner_binary_path }}"
|
||||
register: binary_stat
|
||||
|
||||
- name: Assert runner binary exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- binary_stat.stat.exists
|
||||
fail_msg: "Gitea runner binary is missing"
|
||||
|
||||
- name: Check systemd user service exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_stat
|
||||
|
||||
- name: Assert service file exists
|
||||
- name: Assert user service exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- service_stat.stat.exists
|
||||
fail_msg: "Systemd service file is missing"
|
||||
fail_msg: "Systemd user service is missing"
|
||||
|
||||
- name: Check prune timer exists
|
||||
- name: Check instance data directory exists
|
||||
ansible.builtin.stat:
|
||||
path: /etc/systemd/system/docker-prune.timer
|
||||
register: timer_stat
|
||||
path: "{{ gitea_runner_data_dir }}"
|
||||
register: data_dir_stat
|
||||
|
||||
- name: Assert prune timer exists
|
||||
- name: Assert instance data directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- timer_stat.stat.exists
|
||||
fail_msg: "Docker prune timer is missing"
|
||||
- data_dir_stat.stat.exists
|
||||
fail_msg: "Instance data directory is missing"
|
||||
|
||||
- name: Check config file exists
|
||||
- name: Check config file exists in config directory
|
||||
ansible.builtin.stat:
|
||||
path: /etc/act-runner/config.toml
|
||||
path: "{{ gitea_runner_config_dir }}/config.yaml"
|
||||
register: config_stat
|
||||
|
||||
- name: Assert config file exists
|
||||
@@ -50,3 +64,14 @@
|
||||
that:
|
||||
- config_stat.stat.exists
|
||||
fail_msg: "Config file is missing"
|
||||
|
||||
- name: Check prune timer exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
|
||||
register: timer_stat
|
||||
|
||||
- name: Assert prune timer exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- timer_stat.stat.exists
|
||||
fail_msg: "Docker prune timer is missing"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
side_effect: side_effect.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
- name: Create fake runner registration file
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Ensure fake .runner file exists
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ gitea_runner_data_dir }}/.runner"
|
||||
content: |
|
||||
{"id": 1, "uuid": "test-uuid-1234", "name": "{{ runner_name }}", "address": "http://localhost:3000"}
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Deregister runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
registration_token: "fake-token-for-testing"
|
||||
gitea_url: "http://localhost:3000"
|
||||
skip_runner_registration: false
|
||||
tasks:
|
||||
- name: Include deregistration tasks
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: deregister.yml
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "deregister-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check registration file was removed
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_stat
|
||||
|
||||
- name: Assert registration file no longer exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not runner_file_stat.stat.exists
|
||||
fail_msg: "Registration file (.runner) was not removed by deregistration"
|
||||
|
||||
- name: Check systemd user service still exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_stat
|
||||
|
||||
- name: Assert user service still exists after deregister
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- service_stat.stat.exists
|
||||
fail_msg: "Systemd user service was incorrectly removed"
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
side_effect: side_effect.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
- name: Stop runner instance
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Stop gitea-runner user service
|
||||
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user stop gitea-runner"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Disable gitea-runner user service
|
||||
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user disable gitea-runner"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Re-enable and start runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Enable gitea-runner user service
|
||||
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user enable gitea-runner"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Start gitea-runner user service
|
||||
ansible.builtin.command: "sudo -u {{ gitea_runner_service_user }} systemctl --user start gitea-runner"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "lifecycle-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check systemd user service exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_stat
|
||||
|
||||
- name: Assert user service exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- service_stat.stat.exists
|
||||
fail_msg: "Systemd user service is missing"
|
||||
|
||||
- name: Check instance data directory exists after lifecycle
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}"
|
||||
register: data_dir_stat
|
||||
|
||||
- name: Assert instance data directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- data_dir_stat.stat.exists
|
||||
fail_msg: "Instance data directory is missing after lifecycle"
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
- name: Converge first runner instance
|
||||
hosts: all
|
||||
become: true
|
||||
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
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
|
||||
- name: Converge second runner instance
|
||||
hosts: all
|
||||
become: true
|
||||
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
|
||||
roles:
|
||||
- role: gitea-runner
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check first runner user exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_home }}/grm-molecule-runner-a"
|
||||
register: home_a_stat
|
||||
|
||||
- name: Assert first runner user home exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- home_a_stat.stat.exists
|
||||
fail_msg: "First runner user home is missing"
|
||||
|
||||
- name: Check second runner user exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_home }}/grm-molecule-runner-b"
|
||||
register: home_b_stat
|
||||
|
||||
- name: Assert second runner user home exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- home_b_stat.stat.exists
|
||||
fail_msg: "Second runner user home is missing"
|
||||
|
||||
- name: Check first instance data directory exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_data_dir }}/molecule-runner-a"
|
||||
register: data_a_stat
|
||||
|
||||
- name: Assert first instance data directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- data_a_stat.stat.exists
|
||||
fail_msg: "First instance data directory is missing"
|
||||
|
||||
- name: Check second instance data directory exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_data_dir }}/molecule-runner-b"
|
||||
register: data_b_stat
|
||||
|
||||
- name: Assert second instance data directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- data_b_stat.stat.exists
|
||||
fail_msg: "Second instance data directory is missing"
|
||||
|
||||
- name: Check first instance config exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_config_dir }}/molecule-runner-a/config.yaml"
|
||||
register: config_a_stat
|
||||
|
||||
- name: Assert first instance config exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- config_a_stat.stat.exists
|
||||
fail_msg: "First instance config file is missing"
|
||||
|
||||
- name: Check second instance config exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_base_config_dir }}/molecule-runner-b/config.yaml"
|
||||
register: config_b_stat
|
||||
|
||||
- name: Assert second instance config exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- config_b_stat.stat.exists
|
||||
fail_msg: "Second instance config file is missing"
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "template-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check systemd user service exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_stat
|
||||
|
||||
- name: Assert user service exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- service_stat.stat.exists
|
||||
fail_msg: "Systemd user service is missing"
|
||||
|
||||
- name: Read rendered user service template
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_template
|
||||
|
||||
- name: Assert user service template contains expected directives
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'Type=simple' in service_template.content | b64decode"
|
||||
- "'ExecStart={{ gitea_runner_binary_path }}' in service_template.content | b64decode"
|
||||
- "'Restart=on-failure' in service_template.content | b64decode"
|
||||
- "'DOCKER_HOST=unix:///run/user' in service_template.content | b64decode"
|
||||
- "'XDG_RUNTIME_DIR=/run/user' in service_template.content | b64decode"
|
||||
fail_msg: "User service template is missing expected directives"
|
||||
|
||||
- name: Read rendered prune service template
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
|
||||
register: prune_service
|
||||
|
||||
- name: Assert prune service contains expected directives
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'Type=oneshot' in prune_service.content | b64decode"
|
||||
- "'docker system prune' in prune_service.content | b64decode"
|
||||
- "'docker volume prune' in prune_service.content | b64decode"
|
||||
fail_msg: "Prune service template is missing expected directives"
|
||||
|
||||
- name: Read rendered prune timer template
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
|
||||
register: prune_timer
|
||||
|
||||
- name: Assert prune timer contains expected directives
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'OnCalendar={{ gitea_runner_prune_schedule }}' in prune_timer.content | b64decode"
|
||||
- "'Persistent=true' in prune_timer.content | b64decode"
|
||||
fail_msg: "Prune timer template is missing expected directives"
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- 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
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: ${MOLECULE_PLATFORM_NAME:-ubuntu-2204}
|
||||
image: ${MOLECULE_PLATFORM_IMAGE:-ubuntu:22.04}
|
||||
command: ${MOLECULE_PLATFORM_COMMAND:-sleep infinity}
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
cgroupns_mode: host
|
||||
privileged: true
|
||||
pre_build_image: false
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
playbooks:
|
||||
converge: converge.yml
|
||||
prepare: ../common/prepare.yml
|
||||
side_effect: side_effect.yml
|
||||
env:
|
||||
ANSIBLE_ROLES_PATH: "../../.."
|
||||
|
||||
scenario:
|
||||
test_sequence:
|
||||
- dependency
|
||||
- cleanup
|
||||
- destroy
|
||||
- syntax
|
||||
- create
|
||||
- prepare
|
||||
- converge
|
||||
- idempotence
|
||||
- side_effect
|
||||
- verify
|
||||
- cleanup
|
||||
- destroy
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
- name: Update runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "update-test-runner"
|
||||
tasks:
|
||||
- name: Include update tasks
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: update_runner.yml
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
- name: Verify
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
runner_name: "update-test-runner"
|
||||
pre_tasks:
|
||||
- name: Load role defaults
|
||||
ansible.builtin.include_vars:
|
||||
dir: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults"
|
||||
tasks:
|
||||
- name: Check runner binary still exists after update
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_binary_path }}"
|
||||
register: binary_stat
|
||||
|
||||
- name: Assert binary executable exists after update
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- binary_stat.stat.exists
|
||||
fail_msg: "Runner binary missing after update"
|
||||
|
||||
- name: Check systemd user service still exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
register: service_stat
|
||||
|
||||
- name: Assert user service exists after update
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- service_stat.stat.exists
|
||||
fail_msg: "Systemd user service missing after update"
|
||||
|
||||
- name: Check instance data directory still exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}"
|
||||
register: data_stat
|
||||
|
||||
- name: Assert data directory exists after update
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- data_stat.stat.exists
|
||||
fail_msg: "Runner data directory missing after update"
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
- name: Ensure config directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/act-runner
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Create act_runner config file
|
||||
ansible.builtin.template:
|
||||
src: act-runner-config.toml.j2
|
||||
dest: /etc/act-runner/config.toml
|
||||
mode: "0644"
|
||||
notify: Restart act-runner
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- 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 with Gitea via CLI
|
||||
ansible.builtin.command: >
|
||||
{{ gitea_runner_binary_path }} delete
|
||||
--token {{ registration_token }}
|
||||
--name {{ runner_name }}
|
||||
--instance {{ gitea_url }}
|
||||
--no-interactive
|
||||
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
|
||||
register: deregister_output
|
||||
changed_when: deregister_output.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- 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,63 +0,0 @@
|
||||
---
|
||||
- name: Install Docker (Debian/Ubuntu)
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
block:
|
||||
- name: Install prerequisite packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Add Docker GPG key
|
||||
ansible.builtin.apt_key:
|
||||
url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
|
||||
keyring: /etc/apt/keyrings/docker.gpg
|
||||
when: ansible_distribution != 'Ubuntu' or ansible_distribution_major_version | int >= 22
|
||||
|
||||
- name: Add Docker repository
|
||||
ansible.builtin.apt_repository:
|
||||
repo: >-
|
||||
deb [arch={{ ansible_architecture }}
|
||||
signed-by=/etc/apt/keyrings/docker.gpg]
|
||||
https://download.docker.com/linux/{{ ansible_distribution | lower }}
|
||||
{{ ansible_distribution_release }} stable
|
||||
filename: docker
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Install Docker packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
state: present
|
||||
|
||||
- name: Install Docker (Arch Linux)
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
block:
|
||||
- name: Install Docker packages
|
||||
community.general.pacman:
|
||||
name:
|
||||
- docker
|
||||
- docker-compose
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Ensure Docker service is running
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Add user to docker group
|
||||
ansible.builtin.user:
|
||||
name: "{{ ansible_user | default(ansible_user_id) }}"
|
||||
groups: docker
|
||||
append: true
|
||||
when: ansible_user is defined or ansible_user_id is defined
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
- name: Get latest act_runner release info
|
||||
ansible.builtin.uri:
|
||||
url: https://gitea.com/gitea/act_runner/releases/latest
|
||||
return_content: true
|
||||
headers:
|
||||
Accept: application/json
|
||||
register: act_runner_release
|
||||
when: act_runner_version | default('latest') == 'latest'
|
||||
changed_when: false
|
||||
|
||||
- name: Set act_runner version from latest release
|
||||
ansible.builtin.set_fact:
|
||||
act_runner_version: "{{ act_runner_release.json.tag_name }}"
|
||||
when: act_runner_version | default('latest') == 'latest'
|
||||
|
||||
- name: Set act_runner download URL
|
||||
ansible.builtin.set_fact:
|
||||
act_runner_url: >-
|
||||
https://gitea.com/gitea/act_runner/releases/download/{{ act_runner_version }}/
|
||||
act_runner-{{ act_runner_version }}-linux-{{ ansible_architecture | regex_replace('x86_64', 'amd64') }}
|
||||
|
||||
- name: Ensure /usr/local/bin directory exists
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/bin
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download act_runner binary
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ act_runner_url }}"
|
||||
dest: /usr/local/bin/act_runner
|
||||
mode: "0755"
|
||||
force: true
|
||||
notify: Restart act-runner
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
- name: Get latest gitea_runner release info
|
||||
ansible.builtin.uri:
|
||||
url: https://gitea.com/api/v1/repos/gitea/runner/releases/latest
|
||||
return_content: true
|
||||
body_format: json
|
||||
headers:
|
||||
Accept: application/json
|
||||
register: gitea_runner_release
|
||||
when: gitea_runner_version | default('latest') == 'latest'
|
||||
changed_when: false
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: gitea_runner_release is not failed
|
||||
|
||||
- name: Set gitea_runner version from latest release
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_version: "{{ gitea_runner_release.json.tag_name }}"
|
||||
when: gitea_runner_version | default('latest') == 'latest'
|
||||
|
||||
- name: Set gitea_runner download version (strip v prefix)
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_download_version: "{{ gitea_runner_version | regex_replace('^v', '') }}"
|
||||
|
||||
- name: Set gitea_runner download URL
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_url: >-
|
||||
{{ 'https://gitea.com/gitea/runner/releases/download/v' ~ gitea_runner_download_version
|
||||
~ '/gitea-runner-' ~ gitea_runner_download_version ~ '-linux-'
|
||||
~ (ansible_facts['architecture'] | regex_replace('x86_64', 'amd64')) }}
|
||||
|
||||
- name: Ensure /usr/local/bin directory exists
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/bin
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Download gitea_runner binary
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ gitea_runner_url }}"
|
||||
dest: "{{ gitea_runner_binary_path }}"
|
||||
mode: "0755"
|
||||
force: false
|
||||
register: gitea_runner_download
|
||||
notify: Restart gitea-runner
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: gitea_runner_download is not failed
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
- name: Include gitea_runner download
|
||||
ansible.builtin.include_tasks: download_gitea_runner.yml
|
||||
|
||||
- name: Create gitea_runner config file
|
||||
ansible.builtin.template:
|
||||
src: gitea-runner-config.yaml.j2
|
||||
dest: "{{ gitea_runner_config_dir }}/config.yaml"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Include validation
|
||||
ansible.builtin.include_tasks: validate.yml
|
||||
|
||||
- name: Include registration
|
||||
ansible.builtin.include_tasks: register.yml
|
||||
when: not skip_runner_registration
|
||||
|
||||
- name: Include service setup
|
||||
ansible.builtin.include_tasks: service.yml
|
||||
@@ -1,38 +1,102 @@
|
||||
---
|
||||
- name: Wait for runner to appear in Gitea API
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/admin/runners"
|
||||
headers:
|
||||
Authorization: "token {{ registration_token }}"
|
||||
method: GET
|
||||
status_code: 200
|
||||
return_content: true
|
||||
register: runners_response
|
||||
until: >
|
||||
runners_response.json.runners | default([]) |
|
||||
selectattr('name', 'equalto', runner_name) | list | length > 0
|
||||
retries: 12
|
||||
delay: 10
|
||||
when: gitea_url is defined and registration_token is defined
|
||||
- name: Check runner registration file exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_file_stat
|
||||
|
||||
- name: Verify runner is online
|
||||
ansible.builtin.uri:
|
||||
url: "{{ gitea_url }}/api/v1/admin/runners"
|
||||
headers:
|
||||
Authorization: "token {{ registration_token }}"
|
||||
method: GET
|
||||
status_code: 200
|
||||
return_content: true
|
||||
register: runners_check
|
||||
when: gitea_url is defined and registration_token is defined
|
||||
- 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: Fail if runner is not online
|
||||
- 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 online in Gitea"
|
||||
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
|
||||
- registration_token is defined
|
||||
- >
|
||||
runners_check.json.runners | default([]) |
|
||||
selectattr('name', 'equalto', runner_name) |
|
||||
selectattr('status', 'equalto', 'online') | list | length == 0
|
||||
- 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,24 +1,19 @@
|
||||
---
|
||||
- name: Include OS-specific Docker installation
|
||||
ansible.builtin.include_tasks: docker.yml
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_tasks: systemd_check.yml
|
||||
|
||||
- name: Include act_runner download
|
||||
ansible.builtin.include_tasks: download_act_runner.yml
|
||||
- name: Include user setup
|
||||
ansible.builtin.include_tasks: user_setup.yml
|
||||
|
||||
- name: Include validation
|
||||
ansible.builtin.include_tasks: validate.yml
|
||||
- name: Include rootless Docker setup
|
||||
ansible.builtin.include_tasks: rootless_docker.yml
|
||||
|
||||
- name: Include config creation
|
||||
ansible.builtin.include_tasks: config.yml
|
||||
|
||||
- name: Include registration
|
||||
ansible.builtin.include_tasks: register.yml
|
||||
|
||||
- name: Include service setup
|
||||
ansible.builtin.include_tasks: service.yml
|
||||
- name: Include runner install
|
||||
ansible.builtin.include_tasks: install_runner.yml
|
||||
|
||||
- name: Include prune setup
|
||||
ansible.builtin.include_tasks: prune.yml
|
||||
|
||||
- name: Include integration test
|
||||
ansible.builtin.include_tasks: integration_test.yml
|
||||
when: not skip_runner_registration
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
---
|
||||
- name: Create docker-prune service file
|
||||
- name: Create docker-prune user service file
|
||||
ansible.builtin.template:
|
||||
src: docker-prune.service.j2
|
||||
dest: /etc/systemd/system/docker-prune.service
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.service"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Create docker-prune timer file
|
||||
- name: Create docker-prune user timer file
|
||||
ansible.builtin.template:
|
||||
src: docker-prune.timer.j2
|
||||
dest: /etc/systemd/system/docker-prune.timer
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-prune.timer"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Enable and start docker-prune timer
|
||||
ansible.builtin.systemd:
|
||||
name: docker-prune.timer
|
||||
state: started
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
- name: Reload systemd user daemon for prune timer
|
||||
ansible.builtin.command: systemctl --user daemon-reload
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
- name: Enable and start docker-prune user timer
|
||||
ansible.builtin.command: systemctl --user enable --now docker-prune.timer
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
---
|
||||
- name: Ensure work directory exists
|
||||
ansible.builtin.file:
|
||||
path: /var/lib/gitea-runner
|
||||
path: "{{ gitea_runner_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Check if runner is already registered
|
||||
ansible.builtin.stat:
|
||||
path: /var/lib/gitea-runner/.runner
|
||||
path: "{{ gitea_runner_data_dir }}/.runner"
|
||||
register: runner_registered
|
||||
|
||||
- name: Register runner with Gitea
|
||||
ansible.builtin.command: >
|
||||
/usr/local/bin/act_runner register
|
||||
{{ gitea_runner_binary_path }} register
|
||||
--token {{ registration_token }}
|
||||
--name {{ runner_name }}
|
||||
--instance {{ gitea_url }}
|
||||
--labels ubuntu-latest:docker://node:16-bullseye
|
||||
--labels {{ runner_labels }}
|
||||
--no-interactive
|
||||
args:
|
||||
chdir: /var/lib/gitea-runner
|
||||
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: not runner_registered.stat.exists
|
||||
register: register_output
|
||||
changed_when: "'already exists' not in register_output.stdout | default('')"
|
||||
timeout: 60
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
- name: Ensure keyrings directory exists (Debian/Ubuntu)
|
||||
ansible.builtin.file:
|
||||
path: "/etc/apt/keyrings"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Download and dearmor Docker GPG key (Debian/Ubuntu)
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
curl -fsSL "https://download.docker.com/linux/{{ ansible_facts['distribution'] | lower }}/gpg" | gpg --dearmor --yes -o {{ docker_gpg_key_path }}
|
||||
args:
|
||||
creates: "{{ docker_gpg_key_path }}"
|
||||
executable: /bin/bash
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Add Docker APT repository (Debian/Ubuntu)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/apt/sources.list.d/docker.list
|
||||
content: "{{ docker_apt_source_line }}\n"
|
||||
mode: "0644"
|
||||
register: docker_apt_repo
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Update apt cache after adding Docker repo (Debian/Ubuntu)
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
when:
|
||||
- ansible_facts['os_family'] == 'Debian'
|
||||
- docker_apt_repo is changed
|
||||
|
||||
- name: Install rootless Docker dependencies (Debian/Ubuntu)
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- uidmap
|
||||
- slirp4netns
|
||||
- fuse-overlayfs
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- docker-ce-rootless-extras
|
||||
- containerd.io
|
||||
- docker-compose-plugin
|
||||
- rsync
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
- name: Update pacman cache (Arch Linux)
|
||||
community.general.pacman:
|
||||
update_cache: true
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
changed_when: false
|
||||
|
||||
- name: Install rootless Docker dependencies (Arch Linux)
|
||||
community.general.pacman:
|
||||
name:
|
||||
- docker
|
||||
- docker-compose
|
||||
- slirp4netns
|
||||
- fuse-overlayfs
|
||||
- rsync
|
||||
state: present
|
||||
when: ansible_facts['os_family'] == 'Archlinux'
|
||||
|
||||
- name: Check if rootless Docker is already set up
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
|
||||
register: rootless_docker_check
|
||||
|
||||
- name: Set up rootless Docker for runner user
|
||||
ansible.builtin.command: dockerd-rootless-setuptool.sh install
|
||||
args:
|
||||
creates: "{{ gitea_runner_home }}/.config/systemd/user/docker.service"
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
when:
|
||||
- docker_rootless_setup
|
||||
- not rootless_docker_check.stat.exists
|
||||
|
||||
- name: Start rootless Docker daemon (systemd user service)
|
||||
ansible.builtin.command: systemctl --user start docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when: docker_rootless_setup
|
||||
|
||||
- name: Enable rootless Docker daemon (systemd user service)
|
||||
ansible.builtin.command: systemctl --user enable docker
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when: docker_rootless_setup
|
||||
|
||||
- name: Wait for rootless Docker daemon to be ready
|
||||
ansible.builtin.command: docker version
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
DOCKER_HOST: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
register: docker_ready
|
||||
until: docker_ready.rc == 0
|
||||
retries: 10
|
||||
delay: 2
|
||||
changed_when: false
|
||||
when: docker_rootless_setup
|
||||
@@ -1,16 +1,30 @@
|
||||
---
|
||||
- name: Create systemd service file
|
||||
- name: Create systemd user service file
|
||||
ansible.builtin.template:
|
||||
src: act-runner.service.j2
|
||||
dest: "/etc/systemd/system/act-runner-{{ runner_name }}.service"
|
||||
src: gitea-runner-user.service.j2
|
||||
dest: "{{ gitea_runner_home }}/.config/systemd/user/gitea-runner.service"
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart act-runner
|
||||
|
||||
- name: Enable and start act-runner service
|
||||
ansible.builtin.systemd:
|
||||
name: "act-runner-{{ runner_name }}"
|
||||
state: started
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
- name: Reload systemd user daemon
|
||||
ansible.builtin.command: systemctl --user daemon-reload
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
- name: Enable and start gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user enable --now gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- systemd_available.stat.exists
|
||||
- docker_rootless_setup
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Check if systemd is available
|
||||
ansible.builtin.stat:
|
||||
path: /run/systemd/system
|
||||
register: systemd_available
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
- name: Include gitea_runner download
|
||||
ansible.builtin.include_tasks: download_gitea_runner.yml
|
||||
|
||||
- name: Restart gitea-runner user service
|
||||
ansible.builtin.command: systemctl --user restart gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
|
||||
when:
|
||||
- systemd_available.stat.exists | default(false) | bool
|
||||
- docker_rootless_setup
|
||||
changed_when: true
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
- name: Create per-runner system user
|
||||
ansible.builtin.user:
|
||||
name: "{{ gitea_runner_service_user }}"
|
||||
home: "{{ gitea_runner_home }}"
|
||||
shell: /bin/bash
|
||||
system: true
|
||||
create_home: true
|
||||
register: runner_user
|
||||
|
||||
- name: Set runner UID fact
|
||||
ansible.builtin.set_fact:
|
||||
gitea_runner_uid: "{{ 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
|
||||
|
||||
- name: Enable lingering for runner user
|
||||
ansible.builtin.command: loginctl enable-linger {{ gitea_runner_service_user }}
|
||||
changed_when: not linger_stat.stat.exists
|
||||
|
||||
- name: Ensure subuid entry for runner user
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/subuid
|
||||
regexp: "^{{ gitea_runner_service_user }}:"
|
||||
line: "{{ gitea_runner_service_user }}:100000:65536"
|
||||
create: true
|
||||
mode: "0644"
|
||||
|
||||
- name: Ensure subgid entry for runner user
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/subgid
|
||||
regexp: "^{{ gitea_runner_service_user }}:"
|
||||
line: "{{ gitea_runner_service_user }}:100000:65536"
|
||||
create: true
|
||||
mode: "0644"
|
||||
|
||||
- name: Ensure XDG_RUNTIME_DIR exists
|
||||
ansible.builtin.file:
|
||||
path: "/run/user/{{ gitea_runner_uid }}"
|
||||
state: directory
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Ensure runner data directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure runner config directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_config_dir }}"
|
||||
state: directory
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Ensure systemd user directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ gitea_runner_home }}/.config/systemd/user"
|
||||
state: directory
|
||||
owner: "{{ gitea_runner_service_user }}"
|
||||
group: "{{ gitea_runner_service_user }}"
|
||||
mode: "0755"
|
||||
@@ -1,24 +1,26 @@
|
||||
---
|
||||
- name: Check act_runner binary exists
|
||||
- name: Check gitea_runner binary exists
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/act_runner
|
||||
register: act_runner_stat
|
||||
path: "{{ gitea_runner_binary_path }}"
|
||||
register: gitea_runner_stat
|
||||
|
||||
- name: Fail if act_runner binary is missing
|
||||
- name: Fail if gitea_runner binary is missing
|
||||
ansible.builtin.fail:
|
||||
msg: "act_runner binary not found at /usr/local/bin/act_runner"
|
||||
when: not act_runner_stat.stat.exists
|
||||
msg: "gitea_runner binary not found at {{ gitea_runner_binary_path }}"
|
||||
when: not gitea_runner_stat.stat.exists
|
||||
|
||||
- name: Verify act_runner is executable
|
||||
ansible.builtin.command: /usr/local/bin/act_runner --version
|
||||
register: act_runner_version_output
|
||||
- name: Verify gitea_runner is executable
|
||||
ansible.builtin.command: "{{ gitea_runner_binary_path }} --version"
|
||||
register: gitea_runner_version_output
|
||||
changed_when: false
|
||||
|
||||
- name: Verify Docker connectivity
|
||||
- name: Verify rootless Docker connectivity
|
||||
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_version_output
|
||||
changed_when: false
|
||||
|
||||
- name: Set runner_validated fact
|
||||
ansible.builtin.set_fact:
|
||||
runner_validated: true
|
||||
when: docker_rootless_setup
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
log.level = "info"
|
||||
runner.file = ".runner"
|
||||
container.label = "gitea-runner=true"
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner ({{ runner_name }})
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act-runner/config.toml
|
||||
WorkingDirectory=/var/lib/gitea-runner
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User={{ ansible_user | default('root') }}
|
||||
Group=docker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,9 +1,9 @@
|
||||
[Unit]
|
||||
Description=Docker prune for Gitea runner resources
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/docker system prune -f --filter "label=gitea-runner=true" --filter "until=24h"
|
||||
ExecStart=/usr/bin/docker volume prune -f --filter "label=gitea-runner=true" --filter "until=24h"
|
||||
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStart=/usr/bin/docker system prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
|
||||
ExecStart=/usr/bin/docker volume prune -f --filter "label={{ gitea_runner_prune_label }}" --filter "until={{ gitea_runner_prune_until }}"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=Daily Docker prune for Gitea runner resources
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
OnCalendar={{ gitea_runner_prune_schedule }}
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
log:
|
||||
level: "{{ gitea_runner_log_level }}"
|
||||
|
||||
runner:
|
||||
file: "{{ gitea_runner_file }}"
|
||||
fetch_timeout: 50s
|
||||
fetch_interval: 2s
|
||||
|
||||
container:
|
||||
label: "{{ gitea_runner_container_label }}"
|
||||
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (rootless)
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={{ gitea_runner_binary_path }} daemon --config {{ gitea_runner_config_dir }}/config.yaml
|
||||
WorkingDirectory={{ gitea_runner_data_dir }}
|
||||
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
|
||||
ExecStop=/bin/kill -TERM $MAINPID
|
||||
TimeoutStopSec=30
|
||||
Restart=on-failure
|
||||
RestartSec={{ gitea_runner_service_restart_sec }}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,2 +0,0 @@
|
||||
---
|
||||
act_runner_version: "latest"
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
- name: Start Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Check if runner is already registered
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
|
||||
register: runner_registered
|
||||
|
||||
- name: Include registration if not registered
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: register.yml
|
||||
when:
|
||||
- not runner_registered.stat.exists
|
||||
- not skip_runner_registration | default(false)
|
||||
|
||||
- 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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
- name: Status of Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: systemd_check.yml
|
||||
|
||||
- name: Check systemd user service status
|
||||
ansible.builtin.command: systemctl --user is-active gitea-runner
|
||||
become: true
|
||||
become_user: "{{ gitea_runner_service_user | default('grm-' ~ runner_name) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
register: service_status
|
||||
changed_when: false
|
||||
when: systemd_available.stat.exists
|
||||
|
||||
- name: Report service status
|
||||
ansible.builtin.debug:
|
||||
msg: "Service gitea-runner: {{ service_status.stdout | default('unknown') | trim }}"
|
||||
when: systemd_available.stat.exists
|
||||
|
||||
- name: Check runner registration file
|
||||
ansible.builtin.stat:
|
||||
path: "{{ gitea_runner_data_dir | default('/var/lib/gitea-runner/' ~ runner_name) }}/.runner"
|
||||
register: runner_file_stat
|
||||
|
||||
- name: Report runner registration
|
||||
ansible.builtin.debug:
|
||||
msg: "Runner registration file exists: {{ runner_file_stat.stat.exists | default(false) }}"
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
- name: Stop Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include systemd availability check
|
||||
ansible.builtin.include_role:
|
||||
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) }}"
|
||||
environment:
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid | default('') }}"
|
||||
when: systemd_available.stat.exists
|
||||
changed_when: true
|
||||
@@ -1,17 +1,10 @@
|
||||
---
|
||||
- name: Update Gitea Actions runner binary
|
||||
- name: Update Gitea Actions runner
|
||||
hosts: all
|
||||
become: true
|
||||
vars:
|
||||
act_runner_version: "{{ act_runner_version | default('latest') }}"
|
||||
vars: {}
|
||||
tasks:
|
||||
- name: Include download and validate tasks
|
||||
- name: Update runner
|
||||
ansible.builtin.include_role:
|
||||
name: gitea-runner
|
||||
tasks_from: download_act_runner.yml
|
||||
|
||||
- name: Restart act-runner service
|
||||
ansible.builtin.systemd:
|
||||
name: "act-runner-{{ runner_name | default(inventory_hostname) }}"
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
tasks_from: update_runner.yml
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# git-cliff configuration for GRM
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
trim = true
|
||||
render_always = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
require_conventional = false
|
||||
split_commits = false
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
fail_on_unmatched_commit = false
|
||||
use_branch_tags = false
|
||||
topo_order = false
|
||||
topo_order_commits = true
|
||||
sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^GRM-\\d+\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->Revert" },
|
||||
{ message = ".*", group = "<!-- 10 -->Other" },
|
||||
]
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entrypoint for Gitea Runner Manager CLI."""
|
||||
|
||||
from gitea_runner_manager.cli import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit hook: fail if unit tests take longer than 2 seconds.
|
||||
set -e
|
||||
python3 scripts/check_test_speed.py
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: fail if unit tests take longer than 2 seconds.
|
||||
set -e
|
||||
python3 scripts/check_test_speed.py
|
||||
-454
@@ -1,454 +0,0 @@
|
||||
# Gitea Runner Manager (GRM) – Complete Project Plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Gitea Runner Manager (GRM)** is a lean command‑line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on **Arch Linux, Ubuntu (22.04, 24.04, 26.04), and Debian (12, 13)** hosts. It is designed to:
|
||||
|
||||
- Be **simple and focused** – no unnecessary features.
|
||||
- Be **secure** – no hardcoded secrets, uses scoped tokens.
|
||||
- Be **idempotent** – can be run multiple times safely.
|
||||
- Be **flexible** – accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
|
||||
|
||||
GRM provides a unified CLI (`grm.py`) and a `make install` target to:
|
||||
|
||||
- List registered runners in a Gitea instance.
|
||||
- Generate registration tokens.
|
||||
- Install and configure a runner on a remote host (Docker, `act_runner`, systemd service, safe Docker pruning).
|
||||
- Update the `act_runner` binary without losing registration.
|
||||
- (Future) Uninstall a runner.
|
||||
|
||||
---
|
||||
|
||||
## 2. Key Design Decisions
|
||||
|
||||
| Area | Decision | Rationale |
|
||||
|------|----------|-----------|
|
||||
| Target OS | Arch Linux, Ubuntu 22.04/24.04/26.04, Debian 12/13 | Covers 99% of use cases; avoids complexity. |
|
||||
| Architecture | amd64 only | Hetzner and most cloud providers use x86_64. |
|
||||
| Backup | Lightweight config backup (optional) | Runner state is stored in Gitea; re‑registration is trivial. |
|
||||
| Monitoring | None | Gitea UI shows runner status; manual checks are enough. |
|
||||
| Logging | Systemd `journald` | Sufficient for debugging; no centralised logging needed. |
|
||||
| Pruning | Only runner‑labelled resources | Prevents accidental deletion of unrelated containers. |
|
||||
| Integration tests | Run after installation; fail if not successful | Ensures runner is functional from the start. |
|
||||
| Token storage | `.env` file or `--token` flag | No secrets in code; supports CI/CD. |
|
||||
| Host specification | Plain IP or hostname; SSH user and key overridable | Simplifies inventory management, works with any host. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
GRM consists of three layers:
|
||||
|
||||
1. **CLI (Python)**: User commands, Gitea API interactions, Ansible invocation.
|
||||
2. **Ansible Playbook**: Idempotent installation of runner on target host, adapting to OS distribution.
|
||||
3. **Integration Tests**: Run after installation; verify runner is online in Gitea.
|
||||
|
||||
```text
|
||||
+----------------+ +----------------+ +-----------------+
|
||||
| User / CI | ----> | grm.py CLI | ----> | Gitea API |
|
||||
+----------------+ +----------------+ +-----------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Ansible Playbook |
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Remote Host |
|
||||
| (Arch/Ubuntu |
|
||||
| /Debian) |
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Integration Tests|
|
||||
| (post-install) |
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
```
|
||||
gitea-runner-manager/
|
||||
├── .python-version # 3.11.11
|
||||
├── .env.example # Environment variables template
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── LICENSE (GPL-3.0)
|
||||
├── Makefile # Targets: setup, install, update, lint, ansible-lint, test, etc.
|
||||
├── pyproject.toml # Single source for Python dependencies
|
||||
├── setup.py # Minimal setup for editable install
|
||||
├── grm.py # CLI entrypoint
|
||||
├── src/
|
||||
│ └── gitea_runner_manager/
|
||||
│ ├── __init__.py
|
||||
│ ├── cli.py # CLI logic (click commands)
|
||||
│ ├── runner_manager.py # Core logic (API calls, Ansible invocation)
|
||||
│ ├── api_client.py # Gitea API interactions
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── unit/
|
||||
│ │ ├── test_runner_manager.py
|
||||
│ │ └── test_api_client.py
|
||||
│ └── integration/
|
||||
│ └── test_provision.py # Integration tests for installation
|
||||
├── ansible/
|
||||
│ ├── requirements.yml # Ansible collections
|
||||
│ ├── install-runner.yml # Main playbook
|
||||
│ ├── update-runner.yml # Update playbook (future)
|
||||
│ ├── inventory.example # Optional static inventory (not required)
|
||||
│ ├── group_vars/
|
||||
│ │ └── all.yml
|
||||
│ └── roles/
|
||||
│ └── gitea-runner/
|
||||
│ ├── tasks/
|
||||
│ │ ├── main.yml
|
||||
│ │ ├── docker.yml # Install Docker (OS-specific)
|
||||
│ │ ├── download_act_runner.yml # Download binary
|
||||
│ │ ├── validate.yml # Validate binary
|
||||
│ │ ├── register.yml # Register with Gitea
|
||||
│ │ ├── config.yml # Create config file
|
||||
│ │ ├── service.yml # Systemd service
|
||||
│ │ ├── prune.yml # Docker prune timer
|
||||
│ │ └── integration_test.yml # Post-install validation
|
||||
│ ├── handlers/
|
||||
│ │ └── main.yml
|
||||
│ ├── templates/
|
||||
│ │ ├── act-runner.service.j2
|
||||
│ │ ├── act-runner-config.toml.j2
|
||||
│ │ ├── docker-prune.service.j2
|
||||
│ │ └── docker-prune.timer.j2
|
||||
│ ├── vars/
|
||||
│ │ └── main.yml
|
||||
│ └── molecule/
|
||||
│ └── default/
|
||||
│ ├── molecule.yml
|
||||
│ ├── converge.yml
|
||||
│ ├── verify.yml
|
||||
│ └── prepare.yml
|
||||
└── .pre-commit-config.yaml # Pre-commit and pre-push hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Python Environment and Dependencies
|
||||
|
||||
- **Python version**: 3.11.11 (managed by pyenv).
|
||||
- **Virtual environment**: Created automatically by `make setup` (or manually with `python -m venv .venv`).
|
||||
|
||||
**Dependencies** (defined in `pyproject.toml`):
|
||||
|
||||
| Type | Packages |
|
||||
|------|----------|
|
||||
| Runtime | `requests`, `python-dotenv`, `click`, `ansible` |
|
||||
| Development | `pytest`, `pytest-cov`, `ruff`, `pyright`, `molecule`, `molecule-docker`, `ansible-lint`, `pre-commit` |
|
||||
|
||||
All dependencies are installed with `make setup` or `pip install -e .[dev]`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Makefile (Complete)
|
||||
|
||||
The `Makefile` provides the following targets:
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `setup` | Full environment setup: checks pyenv, installs Python dependencies, Ansible collections, and pre‑commit hooks. |
|
||||
| `install` | Installs a runner on a host. **Requires `HOST`**, optional `USER`, `KEY`, `NAME`, `TOKEN`. Example: `make install HOST=192.168.1.10 USER=arch NAME=my-runner` |
|
||||
| `update` | Updates the `act_runner` binary on the specified host (future). |
|
||||
| `lint` | Runs Python linters (`ruff`, `pyright`). |
|
||||
| `ansible-lint` | Runs `ansible-lint` on all playbooks and roles. |
|
||||
| `lint-all` | Runs `lint` and `ansible-lint`. |
|
||||
| `test-unit` | Runs unit tests with coverage. |
|
||||
| `pytest-cov` | Runs unit tests with **100% coverage requirement**. |
|
||||
| `molecule` | Runs Ansible Molecule tests. |
|
||||
| `test-all` | Runs `pytest-cov` and `molecule`. |
|
||||
| `clean` | Removes temporary files and caches. |
|
||||
|
||||
**Example usage**:
|
||||
|
||||
```bash
|
||||
make setup # Initialize development environment
|
||||
|
||||
# Install runner on a host (plain IP) with default user (ansible_user in inventory)
|
||||
make install HOST=192.168.1.10
|
||||
|
||||
# With custom user and SSH private key
|
||||
make install HOST=192.168.1.10 USER=arch KEY=~/.ssh/id_ed25519
|
||||
|
||||
# With custom runner name and token (token auto-generated if omitted)
|
||||
make install HOST=runner.example.com USER=ubuntu NAME=prod-runner
|
||||
|
||||
make ansible-lint # Lint Ansible code
|
||||
make test-all # Run all tests (unit + molecule)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-commit and Pre-push Hooks
|
||||
|
||||
Defined in `.pre-commit-config.yaml`. Hooks run automatically on `git commit` and `git push`.
|
||||
|
||||
| Hook | Stage | Purpose |
|
||||
|------|-------|---------|
|
||||
| `ruff-lint` | commit | Lint Python code |
|
||||
| `ruff-format` | commit | Format Python code |
|
||||
| `pyright` | commit | Type‑check Python code |
|
||||
| `ansible-lint` | commit | Lint Ansible playbooks/roles |
|
||||
| `detect-secrets` | commit | Prevent committing secrets |
|
||||
| `pytest-cov` | push | **100% unit test coverage** |
|
||||
| `test-all` | push | Run all tests (unit + molecule) |
|
||||
|
||||
If any hook fails, the commit or push is blocked.
|
||||
|
||||
---
|
||||
|
||||
## 8. CLI – `grm.py`
|
||||
|
||||
The CLI is built with `click` and provides the following commands:
|
||||
|
||||
```bash
|
||||
# List all registered runners
|
||||
./grm.py list
|
||||
|
||||
# Generate a new registration token
|
||||
./grm.py token
|
||||
|
||||
# Install and configure a runner on a remote host
|
||||
./grm.py install <host> --user <user> [--key <private_key_path>] [--name <runner_name>] [--token <token>]
|
||||
|
||||
# Update runner binary
|
||||
./grm.py update <host> --user <user> [--key <private_key_path>] [--version <specific_version>]
|
||||
```
|
||||
|
||||
**Options**:
|
||||
- `--user`: SSH user (default: from environment or `ansible_user` in inventory, fallback to `root`).
|
||||
- `--key`: Path to private SSH key (optional, uses default key if not provided).
|
||||
- `--name`: Runner name (default: hostname).
|
||||
- `--token`: Registration token (auto-generated if not provided).
|
||||
|
||||
**Environment**:
|
||||
- Reads `.env` file if present.
|
||||
- Uses `GITEA_URL`, `GITEA_TOKEN`, and optionally `GITEA_RUNNER_USER`, `GITEA_RUNNER_KEY` from environment.
|
||||
|
||||
**Implementation** (`src/gitea_runner_manager/cli.py`):
|
||||
- `list` → calls `api_client.get_runners()`.
|
||||
- `token` → calls `api_client.create_registration_token()`.
|
||||
- `install` → generates token (if not provided), builds an Ansible command with `-i <host>,` and `--user <user>` and `--private-key <key>`.
|
||||
- `update` → similar to install but with the update playbook.
|
||||
|
||||
**Ansible invocation**:
|
||||
```bash
|
||||
ansible-playbook install-runner.yml \
|
||||
-i "<host>," \
|
||||
-u <user> \
|
||||
--private-key <key> \
|
||||
--extra-vars "registration_token=<token> runner_name=<name>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Ansible Role – `gitea-runner`
|
||||
|
||||
The role performs the following tasks in order, adapting to the OS distribution using `ansible_facts['os_family']` and `ansible_distribution`.
|
||||
|
||||
### 9.1. `docker.yml` – OS‑specific Docker installation
|
||||
|
||||
- **For Debian/Ubuntu**:
|
||||
- Install `apt-transport-https`, `ca-certificates`, `curl`.
|
||||
- Add Docker GPG key and repository.
|
||||
- Install `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-compose-plugin`.
|
||||
|
||||
- **For Arch Linux**:
|
||||
- Install `docker`, `docker-compose` using `pacman`.
|
||||
- Ensure the `docker` systemd service is enabled and started.
|
||||
- Add the current user to the `docker` group.
|
||||
|
||||
The playbook detects the OS family and executes the appropriate block.
|
||||
|
||||
### 9.2. `download_act_runner.yml`
|
||||
- Fetches the latest (or specified) `act_runner` binary from Gitea releases.
|
||||
- Extracts it to `/usr/local/bin/act_runner` and sets executable permissions.
|
||||
- Uses `ansible_architecture` to choose the correct binary (`linux_amd64`).
|
||||
|
||||
### 9.3. `validate.yml`
|
||||
- Checks that `/usr/local/bin/act_runner` exists and is executable.
|
||||
- Runs `act_runner --version` to ensure it works.
|
||||
- Checks Docker connectivity (`docker version`).
|
||||
- Sets `runner_validated: true` if all checks pass.
|
||||
|
||||
### 9.4. `config.yml`
|
||||
- Creates `/etc/act-runner/config.toml` with the following content:
|
||||
```toml
|
||||
log.level = "info"
|
||||
runner.file = ".runner"
|
||||
container.label = "gitea-runner=true"
|
||||
```
|
||||
- This ensures all spawned containers are labelled, enabling safe pruning.
|
||||
|
||||
### 9.5. `register.yml`
|
||||
- Ensures work directory (`/var/lib/gitea-runner`) exists.
|
||||
- Runs `act_runner register` with the provided `registration_token`, `runner_name`, `labels`, and `gitea_url`.
|
||||
- Skips registration if `.act_runner` already exists (idempotent).
|
||||
|
||||
### 9.6. `service.yml`
|
||||
- Creates systemd service file `/etc/systemd/system/act-runner-{{ runner_name }}.service`.
|
||||
- Points to the config file with `--config /etc/act-runner/config.toml`.
|
||||
- Enables and starts the service.
|
||||
|
||||
### 9.7. `prune.yml`
|
||||
- Creates systemd service and timer for daily Docker prune:
|
||||
- `docker-prune.service`: runs `docker system prune` and `docker volume prune` with filters for `label=gitea-runner=true` and `until=24h`.
|
||||
- `docker-prune.timer`: triggers daily.
|
||||
- Enables and starts the timer.
|
||||
|
||||
### 9.8. `integration_test.yml`
|
||||
- Waits up to 2 minutes for the runner to appear in the Gitea API.
|
||||
- Checks that the runner status is `"online"`.
|
||||
- Fails the playbook if the runner is not found or not online.
|
||||
- This ensures that the runner is fully functional after installation.
|
||||
|
||||
---
|
||||
|
||||
## 10. Integration Tests (Detailed)
|
||||
|
||||
After registration and service start, the playbook runs `integration_test.yml`. It uses the Gitea API to verify the runner is online. The test is written in Ansible and uses the `uri` module.
|
||||
|
||||
**Conditions**:
|
||||
- Retry every 10 seconds for up to 12 attempts (2 minutes total).
|
||||
- If the runner is not found or not online, the playbook fails with a clear error message.
|
||||
|
||||
**Why this matters**:
|
||||
- Catches registration failures early.
|
||||
- Ensures the runner can communicate with Gitea.
|
||||
- Prevents deploying a broken runner.
|
||||
|
||||
---
|
||||
|
||||
## 11. Safe Docker Pruning
|
||||
|
||||
The runner labels all its containers with `gitea-runner=true` (via `container.label` in the config file). The prune service uses `--filter "label=gitea-runner=true"` to ensure it only removes resources created by the runner. This guarantees that other services on the same host are not affected.
|
||||
|
||||
---
|
||||
|
||||
## 12. Quality Gates
|
||||
|
||||
- **100% unit test coverage** (`make pytest-cov`).
|
||||
- **All linters pass** (ruff, pyright, ansible-lint).
|
||||
- **Molecule tests pass** (role validation in Docker container).
|
||||
- **Integration tests pass** (post‑installation validation).
|
||||
|
||||
These gates are enforced by pre‑push hooks.
|
||||
|
||||
---
|
||||
|
||||
## 13. Installation & Usage
|
||||
|
||||
### 13.1. Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.11.11
|
||||
pyenv local 3.11.11
|
||||
make setup
|
||||
```
|
||||
|
||||
### 13.2. Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.oblachno.oblachno.com
|
||||
# GITEA_TOKEN=your-personal-access-token
|
||||
# Optional: GITEA_RUNNER_USER=ubuntu # default SSH user
|
||||
# Optional: GITEA_RUNNER_KEY=~/.ssh/id_rsa
|
||||
```
|
||||
|
||||
The token needs `admin:runner` scope (or `admin` for full management).
|
||||
|
||||
### 13.3. Install a Runner
|
||||
|
||||
Using the CLI (recommended for flexibility):
|
||||
|
||||
```bash
|
||||
./grm.py install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
If `USER` is not provided, the CLI uses the environment variable `GITEA_RUNNER_USER` or falls back to the current local user's username (which may not exist on the remote host – it's better to always specify).
|
||||
|
||||
### 13.4. Verify Runner
|
||||
|
||||
Check Gitea admin UI under **Actions → Runners**. The runner should appear as **Online**.
|
||||
|
||||
### 13.5. Update Runner Binary (Future)
|
||||
|
||||
```bash
|
||||
./grm.py update 192.168.1.10 --user ubuntu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Molecule Tests
|
||||
|
||||
The Ansible role is tested with Molecule using a systemd‑enabled Docker container. For Arch Linux, we may use a different Docker image (e.g., `archlinux/archlinux`). The test suite will include scenarios for Ubuntu, Debian, and Arch Linux.
|
||||
|
||||
The `default` scenario:
|
||||
- Verifies Docker installation.
|
||||
- Checks that `act_runner` binary is present and executable.
|
||||
- Asserts that the systemd service is enabled and running.
|
||||
- Ensures the prune timer is active.
|
||||
- Runs the `integration_test` task with a mock Gitea API (or skips it if `runner_register=false`).
|
||||
|
||||
Molecule tests run as part of `make test-all`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Logging
|
||||
|
||||
- All Ansible output goes to stdout (visible in CLI).
|
||||
- `act_runner` logs go to `journald` via the systemd service.
|
||||
- To view runner logs: `sudo journalctl -u act-runner-<name> -f`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Future Extensions (Optional)
|
||||
|
||||
- **Uninstall**: A playbook to stop the service, remove the binary, and delete the work directory.
|
||||
- **Version pinning**: Allow specifying a particular `act_runner` version via CLI.
|
||||
- **Additional distributions**: Extend the role to support more distros if needed.
|
||||
|
||||
---
|
||||
|
||||
## 17. Success Criteria
|
||||
|
||||
- [ ] `make setup` configures the development environment.
|
||||
- [ ] `make install HOST=... USER=...` provisions a runner on Ubuntu, Debian, and Arch Linux.
|
||||
- [ ] Integration tests pass after installation; installation fails if they do not.
|
||||
- [ ] Pre‑commit and pre‑push hooks enforce quality gates (100% coverage, linting).
|
||||
- [ ] Molecule tests pass for all supported OS.
|
||||
- [ ] Docker prune only affects runner‑labelled resources.
|
||||
- [ ] `make ansible-lint` runs successfully.
|
||||
- [ ] Documentation is complete and accurate.
|
||||
|
||||
---
|
||||
|
||||
## 18. License
|
||||
|
||||
- **GPL‑3.0** – open source, free to use and modify.
|
||||
|
||||
---
|
||||
|
||||
**This GRM plan is production‑ready, lean, cross‑distribution, and flexible.** It supports Arch, Ubuntu, and Debian, and accepts plain IP addresses with configurable SSH user and key. All components are specified, and the `make setup` command gets a developer from zero to a fully configured environment in minutes.
|
||||
+21
-8
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gitea-runner-manager"
|
||||
version = "0.1.0"
|
||||
dynamic = ["version"]
|
||||
description = "Lean CLI to manage Gitea Actions runners"
|
||||
readme = "README.md"
|
||||
license = {text = "GPL-3.0"}
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.12"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
@@ -20,6 +20,12 @@ dependencies = [
|
||||
"ansible>=14.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
grm = "gitea_runner_manager.cli:cli"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "gitea_runner_manager.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=9.1.0",
|
||||
@@ -29,21 +35,28 @@ dev = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"bandit>=1.8.2",
|
||||
"pre-commit>=4.6.0",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/mrtazz/checkmake/cmd/checkmake@latest
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
gitea_runner_manager = ["translations.json"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
|
||||
pythonpath = ["src", "."]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (not counted in coverage)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
target-version = "py312"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
@@ -55,6 +68,6 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src"]
|
||||
pythonVersion = "3.11"
|
||||
include = ["src", "scripts"]
|
||||
pythonVersion = "3.12"
|
||||
strict = ["src/gitea_runner_manager"]
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR by extracting task ID from branch and constructing merge title.
|
||||
|
||||
Waits for CI checks to complete before attempting the merge.
|
||||
|
||||
PR title format: ``GRM-N: <vikunja task title>``
|
||||
Merge commit format: ``GRM-N <conventional commit message>``
|
||||
|
||||
The conventional commit message is taken from the first commit on the PR
|
||||
branch (the branch HEAD). This allows the PR title to be a human-friendly
|
||||
Vikunja task title while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient
|
||||
from gitea_runner_manager.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
TASK_ID_RE,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
READY_TO_MERGE = "ready-to-merge"
|
||||
MAX_WAIT_SECONDS = 900 # 15 minutes
|
||||
POLL_INTERVAL_SECONDS = 30
|
||||
|
||||
# PR title: GRM-N: <vikunja task title>
|
||||
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract GRM-N task identifier from branch name."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
"""Raise ClickException if PR title does not follow the required format.
|
||||
|
||||
Expected: ``GRM-N: <vikunja task title>``
|
||||
"""
|
||||
if not PR_TITLE_RE.match(pr_title):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n"
|
||||
" Expected: {task_id}: <task title>\n"
|
||||
" Got: {pr_title}",
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
if not pr_title.startswith(f"{task_id}:"):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
||||
task_id=task_id,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given GRM-N identifier.
|
||||
|
||||
Returns empty string if VIKUNJA_TOKEN is not set (skip validation).
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return ""
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return str(matches[0].get("title", ""))
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
return ""
|
||||
|
||||
|
||||
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
"""Validate that PR title matches the Vikunja task title.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
click.echo(_("Warning: could not fetch Vikunja task title, skipping title match validation."))
|
||||
return
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
expected=expected,
|
||||
pr_title=pr_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def has_approval_review(client: GiteaClient, pr_number: str) -> bool:
|
||||
"""Check whether the PR has at least one APPROVE review."""
|
||||
reviews = client.get_pr_reviews(pr_number)
|
||||
return any(r.get("state") == "APPROVED" for r in reviews)
|
||||
|
||||
|
||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
"""Extract the conventional commit message from PR commits.
|
||||
|
||||
Iterates commits in reverse order (newest first) to find the first
|
||||
message matching the conventional commit format. Falls back to the
|
||||
newest commit message if none match.
|
||||
"""
|
||||
for commit in reversed(commits):
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
return message
|
||||
# Fallback: use the newest commit's first line
|
||||
if commits:
|
||||
commit_info = commits[-1].get("commit", {})
|
||||
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
return ""
|
||||
|
||||
|
||||
def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
|
||||
"""Check whether the PR has the ready-to-merge label via the API."""
|
||||
labels = client.get_pr_labels(pr_number)
|
||||
return any(label.get("name") == READY_TO_MERGE for label in labels)
|
||||
|
||||
|
||||
def wait_for_ci(
|
||||
client: GiteaClient, sha: str, max_wait: int = MAX_WAIT_SECONDS, poll_interval: int = POLL_INTERVAL_SECONDS
|
||||
) -> bool:
|
||||
"""Poll commit statuses until all CI checks are complete (not pending).
|
||||
|
||||
Returns True if all checks are successful, False if any failed or timed out.
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks reported yet, waiting..."))
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
# Deduplicate by context — keep the latest status per context.
|
||||
latest: dict[str, dict[str, object]] = {}
|
||||
for s in statuses:
|
||||
ctx = s.get("context", "")
|
||||
if ctx not in latest or s.get("updated_at", "") > latest[ctx].get("updated_at", ""):
|
||||
latest[ctx] = s
|
||||
|
||||
ci_statuses = {ctx: s for ctx, s in latest.items() if ctx.startswith("CI /")}
|
||||
if not ci_statuses:
|
||||
click.echo(_("No CI checks found yet, waiting..."))
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
|
||||
if not pending:
|
||||
# All CI checks are complete — check if they all succeeded.
|
||||
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok")]
|
||||
if failed:
|
||||
click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed))))
|
||||
return False
|
||||
click.echo(_("All CI checks passed."))
|
||||
return True
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Waiting for CI checks: {pending} ({elapsed}s elapsed)",
|
||||
pending=", ".join(sorted(pending)),
|
||||
elapsed=elapsed,
|
||||
)
|
||||
)
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
click.echo(_("Timed out waiting for CI checks after {max_wait}s.", max_wait=max_wait))
|
||||
return False
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("branch")
|
||||
@click.argument("pr_title")
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
@click.argument("label_name", required=False, default="")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
# Gitea Actions may not populate github.event.label.name; fall back to API check.
|
||||
if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number):
|
||||
click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE))
|
||||
return
|
||||
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID (GRM-N) found in branch name '{branch}'.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
validate_pr_title(pr_title, task_id)
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Enforce APPROVE review before merge (Gap 2 fix)
|
||||
if not has_approval_review(client, pr_number):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Cannot merge: PR #{pr_number} has no APPROVE review. "
|
||||
"Please review and approve before adding the ready-to-merge label.",
|
||||
pr_number=pr_number,
|
||||
)
|
||||
)
|
||||
click.echo(_("PR has at least one APPROVE review."))
|
||||
|
||||
# Wait for CI checks to complete before attempting merge.
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if sha:
|
||||
click.echo(_("Waiting for CI checks on commit {sha}...", sha=sha[:8]))
|
||||
if not wait_for_ci(client, sha):
|
||||
raise click.ClickException(
|
||||
_("Cannot merge: CI checks did not pass. Please fix failing checks and re-label.")
|
||||
)
|
||||
else:
|
||||
click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait."))
|
||||
|
||||
# Build merge title: GRM-N <conventional commit message>
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
conv_msg = extract_conventional_msg(commits)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id} {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run unit tests and enforce a maximum execution-time budget.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_test_speed.py [--max-seconds N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
DEFAULT_MAX_SECONDS = 2.0
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
TEST_COMMAND,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout, result.stderr
|
||||
|
||||
|
||||
def parse_duration(output: str) -> float:
|
||||
"""Extract elapsed seconds from pytest summary line.
|
||||
|
||||
Raises:
|
||||
click.ClickException: when the timing line cannot be found.
|
||||
"""
|
||||
for line in output.splitlines():
|
||||
match = _TIMING_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(2))
|
||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
||||
|
||||
|
||||
def check_speed(duration: float, max_seconds: float) -> None:
|
||||
"""Validate duration is within budget; raise on violation."""
|
||||
if duration > max_seconds:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
|
||||
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
|
||||
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main(max_seconds: float) -> None:
|
||||
"""Run tests, parse timing, and enforce the budget."""
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
click.echo(
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--max-seconds",
|
||||
type=float,
|
||||
default=DEFAULT_MAX_SECONDS,
|
||||
show_default=True,
|
||||
help="Maximum allowed execution time in seconds.",
|
||||
)
|
||||
def cli(max_seconds: float) -> None:
|
||||
main(max_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure GRM repository: branch protection + labels via Gitea REST API.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/configure_repo.py
|
||||
"""
|
||||
|
||||
import http
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
GITEA_API_URL,
|
||||
LABEL_CONFIG,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
REPO_SETTINGS_CONFIG,
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _handle_http_error(e: APIError) -> None:
|
||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"HTTP {status} Forbidden — your token lacks admin rights.\n"
|
||||
"Make sure the token belongs to a repo owner or organisation admin.\n"
|
||||
"Alternatively, configure branch protection manually in Settings → Branches.",
|
||||
status=e.status,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("HTTP error: {status} — {message}", status=e.status, message=e.message)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
||||
|
||||
try:
|
||||
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
|
||||
client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
||||
click.echo(_(" - Direct pushes: BLOCKED (require PR)"))
|
||||
click.echo(
|
||||
_(
|
||||
" - Required approvals: {count}",
|
||||
count=BRANCH_PROTECTION_CONFIG["required_approvals"],
|
||||
)
|
||||
)
|
||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||
click.echo(_(" - Block outdated branches: yes"))
|
||||
click.echo(_(" - Block rejected reviews: yes"))
|
||||
checks = ", ".join(cast(list[str], BRANCH_PROTECTION_CONFIG["status_check_contexts"]))
|
||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||
|
||||
click.echo("")
|
||||
label_name = cast(str, LABEL_CONFIG["name"])
|
||||
click.echo(_("Creating {label} label...", label=label_name))
|
||||
result = client.ensure_label(
|
||||
name=cast(str, LABEL_CONFIG["name"]),
|
||||
color=cast(str, LABEL_CONFIG["color"]),
|
||||
description=cast(str, LABEL_CONFIG["description"]),
|
||||
)
|
||||
if result is None:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
else:
|
||||
click.echo(_(" Label '{label}' created.", label=label_name))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Configuring repository settings..."))
|
||||
client.update_repo_settings(cast(dict[str, object], REPO_SETTINGS_CONFIG))
|
||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Repository configuration complete."))
|
||||
except APIError as e:
|
||||
_handle_http_error(e)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
|
||||
|
||||
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
|
||||
crosses them with the supported OS platform matrix, then splits the
|
||||
resulting test pairs evenly across the requested number of runners.
|
||||
|
||||
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
|
||||
so the CI workflow can set the appropriate environment variables.
|
||||
|
||||
Usage:
|
||||
python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3
|
||||
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
|
||||
python3 scripts/distribute_molecule.py --list
|
||||
# prints all scenarios, one per line
|
||||
python3 scripts/distribute_molecule.py --list-platforms
|
||||
# prints all platforms, one per line
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestPair:
|
||||
"""A (scenario, platform) combination to test."""
|
||||
|
||||
scenario: str
|
||||
platform: dict[str, str]
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> TestPair:
|
||||
"""Deserialize from a pipe-delimited string."""
|
||||
parts = encoded.split("|")
|
||||
return TestPair(
|
||||
scenario=parts[0],
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
||||
)
|
||||
|
||||
|
||||
def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||
"""Return sorted list of molecule scenario directory names."""
|
||||
if root is None:
|
||||
root = MOLECULE_ROOT
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(
|
||||
_("Molecule directory not found: {path}", path=str(root))
|
||||
)
|
||||
scenarios = [
|
||||
d.name
|
||||
for d in root.iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_") and d.name != "common"
|
||||
]
|
||||
return sorted(scenarios)
|
||||
|
||||
|
||||
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
|
||||
"""Build the full cross-product of scenarios and platforms."""
|
||||
if platforms is None:
|
||||
platforms = PLATFORMS
|
||||
return [TestPair(s, p) for s in scenarios for p in platforms]
|
||||
|
||||
|
||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
||||
for i, pair in enumerate(pairs):
|
||||
groups[i % max_runners].append(pair)
|
||||
return groups
|
||||
|
||||
|
||||
def pairs_for_runner(
|
||||
pairs: list[TestPair], runner_index: int, max_runners: int
|
||||
) -> list[TestPair]:
|
||||
"""Return the subset of pairs assigned to *runner_index*."""
|
||||
groups = distribute(pairs, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Runner index {index} out of range (0..{max})",
|
||||
index=runner_index,
|
||||
max=max_runners - 1,
|
||||
)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Zero-based runner index. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--list",
|
||||
"list_all",
|
||||
is_flag=True,
|
||||
help="List all discovered scenarios, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--list-platforms",
|
||||
"list_platforms",
|
||||
is_flag=True,
|
||||
help="List all supported platforms, one per line.",
|
||||
)
|
||||
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
click.echo(s)
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs = build_pairs(scenarios)
|
||||
if runner_index is None:
|
||||
groups = distribute(pairs, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
assigned = pairs_for_runner(pairs, runner_index, max_runners)
|
||||
click.echo(" ".join(p.encode() for p in assigned))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install checkmake if it is not already present.
|
||||
|
||||
Tries to install via Go if available, otherwise downloads the latest
|
||||
pre-built Linux binary from the official GitHub releases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
CHECKMAKE_VERSION = "0.3.2"
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/checkmake/checkmake/releases/download/"
|
||||
f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}"
|
||||
)
|
||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by checkmake releases."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(
|
||||
f"Unsupported architecture: {machine}"
|
||||
)
|
||||
|
||||
|
||||
def _install_with_go() -> bool:
|
||||
"""Install checkmake using go install if Go is available."""
|
||||
go_bin = shutil.which("go")
|
||||
if go_bin is None:
|
||||
return False
|
||||
subprocess.run( # nosec B603
|
||||
[
|
||||
go_bin,
|
||||
"install",
|
||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _download_binary() -> None:
|
||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
||||
TARGET_PATH.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Install checkmake if not already present."""
|
||||
if shutil.which("checkmake") is not None:
|
||||
return
|
||||
|
||||
if not _install_with_go():
|
||||
_download_binary()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all molecule scenarios on all supported OS platforms.
|
||||
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
|
||||
# Platform list is sourced from scripts/distribute_molecule.py to avoid duplication.
|
||||
set -euo pipefail
|
||||
|
||||
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
|
||||
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
|
||||
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Read platforms from distribute_molecule.py (single source of truth)
|
||||
PLATFORMS_OUTPUT="$("$MOLECULE_BIN" python "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms 2>/dev/null || \
|
||||
python3 "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms)"
|
||||
|
||||
for p in $PLATFORMS_OUTPUT; do
|
||||
IFS="|" read -r name image command <<< "$p"
|
||||
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
|
||||
if [ -n "$command" ]; then
|
||||
export MOLECULE_PLATFORM_COMMAND="$command"
|
||||
else
|
||||
unset MOLECULE_PLATFORM_COMMAND
|
||||
fi
|
||||
echo "=== Platform: $name ==="
|
||||
for s in default multi-instance lifecycle template-content deregister update; do
|
||||
echo "--- Scenario: $s on $name ---"
|
||||
(
|
||||
cd "$ROLE_DIR"
|
||||
if [ "$s" = "default" ]; then
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
|
||||
else
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
|
||||
fi
|
||||
)
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
||||
|
||||
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
|
||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
||||
Docker networks, so parallel execution within a single runner is unsafe).
|
||||
|
||||
A background thread polls the Gitea API. If any other molecule matrix runner
|
||||
reports failure, the current molecule subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
Usage:
|
||||
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
|
||||
def get_running_jobs(
|
||||
gitea_url: str, owner: str, repo: str, token: str, run_id: int
|
||||
) -> list[dict]:
|
||||
"""Return jobs for the given workflow run."""
|
||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
|
||||
def any_other_runner_failed(
|
||||
jobs: list[dict], current_job_name: str, current_index: int
|
||||
) -> bool:
|
||||
"""Return True if any other molecule matrix job has failed."""
|
||||
for job in jobs:
|
||||
name = job.get("name", "")
|
||||
if not name.startswith(current_job_name):
|
||||
continue
|
||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
||||
continue
|
||||
if job.get("conclusion") == "failure":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def poll_for_other_failures(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
run_id: int,
|
||||
job_name: str,
|
||||
current_index: int,
|
||||
stop_event: threading.Event,
|
||||
failed_event: threading.Event,
|
||||
) -> None:
|
||||
"""Background thread: poll API and signal if another runner fails."""
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
||||
if any_other_runner_failed(jobs, job_name, current_index):
|
||||
click.echo(
|
||||
_(
|
||||
"Another molecule runner failed. Stopping this runner early."
|
||||
)
|
||||
)
|
||||
failed_event.set()
|
||||
return
|
||||
except requests.RequestException as exc:
|
||||
click.echo(_("API poll warning: {exc}", exc=exc))
|
||||
stop_event.wait(POLL_INTERVAL)
|
||||
|
||||
|
||||
def build_molecule_cmd(scenario: str) -> list[str]:
|
||||
"""Build the molecule command for a scenario."""
|
||||
cmd = ["molecule", "test"]
|
||||
if scenario != "default":
|
||||
cmd.extend(["-s", scenario])
|
||||
return cmd
|
||||
|
||||
|
||||
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Build environment for a single molecule pair."""
|
||||
scenario, platform_name, platform_image, platform_command = pair.split("|")
|
||||
env = base_env.copy()
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
||||
if platform_command:
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
return env
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
def cli(pairs: tuple[str, ...]) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/grm")
|
||||
owner, sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "grm"
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(
|
||||
_(
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
)
|
||||
)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
base_env = os.environ.copy()
|
||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
|
||||
if gitea_url and token and run_id:
|
||||
poller = threading.Thread(
|
||||
target=poll_for_other_failures,
|
||||
args=(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
run_id,
|
||||
job_name,
|
||||
current_index,
|
||||
stop_event,
|
||||
failed_event,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
poller.start()
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
click.echo(
|
||||
_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)
|
||||
)
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if failed_event.is_set():
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
click.echo(
|
||||
_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)
|
||||
)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Gitea issue when a CI workflow fails.
|
||||
|
||||
Used by the release and publish workflows to alert on failures that would
|
||||
otherwise go unnoticed in the Actions tab.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
--commit <commit_sha>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@click.option("--workflow", required=True, help="Workflow name.")
|
||||
@click.option("--commit", required=True, help="Commit SHA.")
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
f"- **Run ID**: #{run_id}\n"
|
||||
f"- **Commit**: `{commit[:8]}`\n"
|
||||
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
|
||||
f"{repo}/actions/runs/{run_id}\n\n"
|
||||
f"Please investigate and fix the issue."
|
||||
)
|
||||
|
||||
try:
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Created issue #{issue_id}: {title}",
|
||||
issue_id=issue.get("id", "?"),
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update Vikunja task after a merge to master.
|
||||
|
||||
Usage:
|
||||
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg> [--commit-sha <sha>]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import VikunjaClient
|
||||
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract GRM-N task identifier from the first line of commit message."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
match = TASK_ID_RE.search(first_line)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def extract_conventional_msg(commit_msg: str) -> str:
|
||||
"""Strip the GRM-N prefix from the commit subject.
|
||||
|
||||
Handles both formats:
|
||||
- ``GRM-N: <message>`` (legacy, colon-separated)
|
||||
- ``GRM-N <message>`` (current, space-separated)
|
||||
"""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line)
|
||||
|
||||
|
||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
||||
"""Resolve GRM-N identifier to Vikunja numeric task ID.
|
||||
|
||||
Paginates through the project's tasks to handle projects with more
|
||||
than 50 tasks.
|
||||
"""
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return int(matches[0]["id"])
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task for {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
|
||||
"""Build HTML comment body for Vikunja."""
|
||||
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg")
|
||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
||||
def main(commit_msg: str, commit_sha: str) -> None:
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
|
||||
task_id = extract_task_id(commit_msg)
|
||||
if not task_id:
|
||||
click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!"))
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
vikunja_task_id = 0
|
||||
try:
|
||||
vikunja_task_id = resolve_task_id(client, task_id)
|
||||
conv_msg = extract_conventional_msg(commit_msg)
|
||||
sha = commit_sha or "unknown"
|
||||
html = build_comment(task_id, conv_msg, sha)
|
||||
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Vikunja API error: HTTP {status} — {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
||||
task_id=task_id,
|
||||
vikunja_id=vikunja_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build package, optionally publish to PyPI, and create Gitea release.
|
||||
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def generate_release_notes(tag: str) -> str:
|
||||
"""Generate release notes for the given tag using git-cliff.
|
||||
|
||||
Falls back to a generic message if git-cliff is not available.
|
||||
"""
|
||||
cliff_bin = shutil.which("git-cliff")
|
||||
if not cliff_bin:
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
||||
|
||||
|
||||
def build_package() -> None:
|
||||
"""Build the Python package using python -m build."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[sys.executable, "-m", "build"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Package build failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def publish_to_pypi(token: str) -> None:
|
||||
"""Publish built packages to PyPI using twine."""
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"twine",
|
||||
"upload",
|
||||
"dist/*",
|
||||
"-u",
|
||||
"__token__",
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! PyPI publish failed:\n{stderr}",
|
||||
stderr=result.stderr.strip(),
|
||||
)
|
||||
)
|
||||
click.echo(_("Published to PyPI."))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("tag")
|
||||
@click.argument("repo")
|
||||
def main(tag: str, repo: str) -> None:
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
||||
|
||||
build_package()
|
||||
|
||||
if pypi_token:
|
||||
publish_to_pypi(pypi_token)
|
||||
else:
|
||||
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name)
|
||||
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
client.create_release(
|
||||
tag=tag,
|
||||
body=release_body,
|
||||
)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Release creation failed with HTTP {status}: {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Gitea release {tag} created.",
|
||||
tag=tag,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated release: calculate next version, update files, tag, and push.
|
||||
|
||||
Uses git-cliff to determine the next semver version from conventional commits
|
||||
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
|
||||
source of truth, read by setuptools via ``dynamic = ["version"]``) and
|
||||
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
|
||||
with the changelog as the tag message, and pushes both to trigger the publish
|
||||
workflow.
|
||||
|
||||
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
|
||||
clean while still being descriptive. Loops are prevented by the
|
||||
``has_unreleased_changes`` check — after a release commit is tagged, the next
|
||||
run finds no unreleased changes and exits.
|
||||
|
||||
This script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits with a message and does nothing. If the tag already exists
|
||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
INIT_FILE = "src/gitea_runner_manager/__init__.py"
|
||||
CHANGELOG_FILE = "CHANGELOG.md"
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_bumped_version() -> str:
|
||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
||||
version = result.stdout.strip()
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
"""Generate changelog content for the new version using git-cliff."""
|
||||
result = run_cmd(
|
||||
[
|
||||
"git-cliff",
|
||||
"--config",
|
||||
CLIFF_CONFIG,
|
||||
"--tag",
|
||||
f"v{new_version}",
|
||||
"--unreleased",
|
||||
"--bump",
|
||||
]
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
"""Check if there are conventional commits since the last tag.
|
||||
|
||||
If ``bumped_version`` is provided (from a prior git-cliff call), reuses it
|
||||
to avoid a duplicate subprocess invocation.
|
||||
"""
|
||||
if bumped_version is None:
|
||||
result = run_cmd(
|
||||
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
bumped_version = result.stdout.strip().lstrip("v")
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
current = latest.lstrip("v")
|
||||
return bumped_version != current
|
||||
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
content = f.read()
|
||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
updated = re.sub(
|
||||
r'^__version__\s*=\s*"[^"]*"',
|
||||
f'__version__ = "{new_version}"',
|
||||
content,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(INIT_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def update_changelog(changelog: str) -> None:
|
||||
"""Prepend the new changelog section to CHANGELOG.md.
|
||||
|
||||
If the file doesn't exist, create it with the changelog as the sole content.
|
||||
If it exists, insert the new version section after the header (before the
|
||||
first existing version section).
|
||||
"""
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(changelog + "\n")
|
||||
return
|
||||
|
||||
# Find the first version section header (## [...] or ## [unreleased])
|
||||
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
|
||||
if match:
|
||||
# Insert before the first version section
|
||||
pos = match.start()
|
||||
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
|
||||
else:
|
||||
# No version sections found — append
|
||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
def commit_release_changes(new_version: str) -> bool:
|
||||
"""Stage version file and changelog, then create a release commit.
|
||||
|
||||
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
|
||||
Returns True if a commit was created, False if there were no staged changes.
|
||||
"""
|
||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
|
||||
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "-m", f"release: v{new_version}"])
|
||||
return True
|
||||
|
||||
|
||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
||||
"""Create an annotated tag with the changelog as message and push it.
|
||||
|
||||
Returns True if the tag was created/pushed, False if it already existed.
|
||||
"""
|
||||
tag = f"v{new_version}"
|
||||
if tag_exists(tag):
|
||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
return True
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
def main(dry_run: bool) -> None:
|
||||
# Ensure we're on master
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master":
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
|
||||
# Calculate next version (single git-cliff call — Gap 7 fix)
|
||||
new_version = get_bumped_version()
|
||||
|
||||
# Check for unreleased changes (reuses the version we just calculated)
|
||||
if not has_unreleased_changes(bumped_version=new_version):
|
||||
click.echo(_("No unreleased changes found. Nothing to release."))
|
||||
return
|
||||
|
||||
current_tag = get_latest_tag()
|
||||
click.echo(
|
||||
_(
|
||||
"Bumping version: {current} -> v{new_version}",
|
||||
current=current_tag or "(none)",
|
||||
new_version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
if not changelog:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
# Update version file
|
||||
update_init_version(new_version)
|
||||
click.echo(_("Updated version in {init}", init=INIT_FILE))
|
||||
|
||||
# Update CHANGELOG.md (Gap 3 fix)
|
||||
update_changelog(changelog)
|
||||
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
|
||||
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
|
||||
committed = commit_release_changes(new_version)
|
||||
if committed:
|
||||
click.echo(_("Created release commit."))
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
# Create and push tag (Gap 4: handles existing tag)
|
||||
created = create_and_push_tag(new_version, changelog, dry_run)
|
||||
if created:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post a review on a Gitea pull request.
|
||||
|
||||
Used by the GRM workflow to post structured PR reviews. The review body
|
||||
is provided via --body and inline comments via a JSON file
|
||||
(--comments-json) or stdin (--comments-stdin). This script is a thin
|
||||
CLI wrapper around ``GiteaClient.create_review`` — the actual review
|
||||
analysis is performed by the agent before invoking this tool.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
|
||||
--event COMMENT \
|
||||
--body "Review body text" \
|
||||
--comments-json comments.json
|
||||
|
||||
The comments JSON file is a list of objects with keys:
|
||||
- path: file path in the repo
|
||||
- body: comment text
|
||||
- new_position: line number in the new file (1-based)
|
||||
- old_position: (optional) line number in the old file
|
||||
|
||||
Review focus areas (for the reviewer, not enforced by this script):
|
||||
- Functional completeness
|
||||
- Edge cases
|
||||
- Technical excellence: architecture compliance, SRP, deduplication,
|
||||
code smells, best practices, code quality, reusability, clean code,
|
||||
readability, maintainability, extensibility
|
||||
- Performance
|
||||
- Security
|
||||
- User experience
|
||||
- Documentation completeness and relevance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
VALID_EVENTS = ("APPROVE", "REQUEST_CHANGES", "COMMENT")
|
||||
|
||||
|
||||
def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict[str, Any]]:
|
||||
"""Parse inline comments from a JSON file or stdin."""
|
||||
if comments_json:
|
||||
try:
|
||||
with open(comments_json) as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(_("Invalid JSON in comments file: {error}", error=str(e))) from None
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Comments JSON must be a list of objects."))
|
||||
return data
|
||||
if comments_stdin:
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(_("Invalid JSON on stdin: {error}", error=str(e))) from None
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Stdin comments JSON must be a list of objects."))
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option(
|
||||
"--event",
|
||||
default="COMMENT",
|
||||
type=click.Choice(VALID_EVENTS),
|
||||
help="Review event type: APPROVE, REQUEST_CHANGES, or COMMENT.",
|
||||
)
|
||||
@click.option("--body", default="", help="Top-level review body text.")
|
||||
@click.option("--comments-json", default=None, help="Path to JSON file with inline comments.")
|
||||
@click.option(
|
||||
"--comments-stdin",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Read inline comments JSON from stdin.",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
event: str,
|
||||
body: str,
|
||||
comments_json: str | None,
|
||||
comments_stdin: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
comments = parse_comments(comments_json, comments_stdin)
|
||||
|
||||
if event != "APPROVE" and not body and not comments:
|
||||
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Failed to post review: HTTP {status} — {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"Review #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
review_id=review_id,
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
num_comments=len(comments),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BIN="${1:-.venv/bin}"
|
||||
|
||||
"$BIN/pip" install -e ".[dev]"
|
||||
"$BIN/ansible-galaxy" collection install -r ansible/requirements.yml
|
||||
"$BIN/pre-commit" install
|
||||
"$BIN/pre-commit" install --hook-type commit-msg
|
||||
"$BIN/pre-commit" install --hook-type pre-push
|
||||
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Activate the virtual environment with one of:"
|
||||
echo " source .venv/bin/activate (generic)"
|
||||
echo " source activate.sh (bash)"
|
||||
echo " source activate.fish (fish)"
|
||||
echo " source activate.zsh (zsh)"
|
||||
|
||||
# Verification
|
||||
"$BIN/grm" --version 2>/dev/null || true
|
||||
"$BIN/pre-commit" --version 2>/dev/null || true
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate commit messages for GRM.
|
||||
|
||||
Rules:
|
||||
- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix.
|
||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
||||
e.g. 'GRM-24: fix: resolve timeout'.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
|
||||
|
||||
|
||||
def first_line(text: str) -> str:
|
||||
return text.split("\n")[0]
|
||||
|
||||
|
||||
def get_branch() -> str:
|
||||
try:
|
||||
result = subprocess.run( # nosec
|
||||
["git", "symbolic-ref", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return ""
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg_file")
|
||||
def main(commit_msg_file: str) -> None:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
|
||||
branch = get_branch()
|
||||
subject = first_line(msg)
|
||||
|
||||
if branch == "master":
|
||||
if not MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commits must start with a task ID.\n"
|
||||
" Expected: GRM-N: <conventional commit message>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
|
||||
if not CONVENTIONAL_RE.match(remainder):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
||||
" Expected: GRM-N: <type>: <description>\n"
|
||||
" Got: {subject}",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if MASTER_TASK_ID_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n"
|
||||
" The task ID will be added automatically on merge via CI."
|
||||
)
|
||||
)
|
||||
|
||||
if not CONVENTIONAL_RE.match(subject):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! Commit message must follow conventional commit format.\n"
|
||||
" Expected: <type>: <description>\n"
|
||||
" Got: {subject}\n"
|
||||
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
||||
" perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
subject=subject,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user