Compare commits

..
Author SHA1 Message Date
Emil Simeonov f60b84c52d deps: bump devx from v0.50.2 to v0.51.0
CI / validate (pull_request) Failing after 1m39s
CI / molecule-tests (1) (pull_request) Skipped
CI / molecule-tests (2) (pull_request) Skipped
CI / molecule-tests (3) (pull_request) Skipped
CI / molecule-tests (4) (pull_request) Skipped
CI / auto-merge (pull_request) Skipped
2026-08-25 18:49:14 +02:00
18 changed files with 17 additions and 323 deletions
+2 -4
View File
@@ -299,18 +299,16 @@ jobs:
run: make setup-image EXTRAS=ci
- name: Post approval review
env:
DEVELOPER_GITEA_API_TOKEN: ${{ secrets.DEVELOPER_GITEA_API_TOKEN }}
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
. .venv/bin/activate 2>/dev/null || true
# Post APPROVE review via Gitea API to satisfy branch protection
# Uses DEVELOPER_GITEA_API_TOKEN (kireto) — a different user than
# the PR creator — so Gitea counts the approval (no self-approvals).
curl -s -X POST \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: token ${DEVELOPER_GITEA_API_TOKEN}" \
-H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate, molecule-tests)."}' \
|| echo "::warning::Failed to post approval review (best-effort)."
-12
View File
@@ -2,18 +2,6 @@
All notable changes to this project will be documented in this file.
## [0.23.0] - 2026-08-28
### Features
- Add pre-cache timer, force_pull, and Docker socket options to runner config
## [0.22.1] - 2026-08-26
### Bug Fixes
- Use kireto token for auto-merge approval review
## [0.22.0] - 2026-08-25
### Features
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/python.svg)](https://www.python.org/downloads/)
## Why GRM?
@@ -90,22 +90,6 @@ gitea_runner_remove_user: true
gitea_runner_log_level: "info"
gitea_runner_container_label: "gitea-runner=true"
gitea_runner_file: ".runner"
# force_pull: when false (default), the runner reuses locally cached images
# instead of pulling on every job. Pre-cached images (via the pre-cache timer
# or pre_pull_images task) eliminate registry thundering-herd when all runners
# start jobs simultaneously.
gitea_runner_force_pull: false
# Container options passed to `docker run` for CI job containers.
# Mounts the host rootless Docker socket as /run/host-docker.sock so
# start_docker.py inside the container can detect and use the host daemon
# (full disk, no nested DinD) instead of starting an inner dockerd.
gitea_runner_container_options: "-v /run/user/{{ gitea_runner_uid }}/docker.sock:/run/host-docker.sock"
# Volumes allowed in CI job containers (validated by the runner against
# container.options and job-level volumes). Must include the host Docker
# socket mount target.
gitea_runner_valid_volumes:
- "/run/host-docker.sock"
- "/run/user/{{ gitea_runner_uid }}/docker.sock"
# Containerd version pinning — Docker 28.x vendors containerd v2.1.x internally.
# containerd.io >= 2.3 ships a shim that returns a protobuf BootstrapResult which
@@ -151,11 +135,3 @@ gitea_runner_docker_ipv6_cidr: "fd00:dead:beef::/48"
# disk-space prune only removes dangling images, so pre-pulled tagged images persist.
# Set to [] to skip pre-pulling. Images are pulled as the runner user via rootless Docker.
gitea_runner_pre_pull_images: []
# Pre-cache timer: periodically pulls the runner container image so it stays
# fresh in the local Docker cache. This prevents thundering-herd registry
# timeouts when all runners start CI jobs simultaneously with empty caches.
# Runs every 6 hours (aligned with prune schedule). Set to empty string to
# disable the timer.
gitea_runner_pre_cache_schedule: "*-*-* 00/6:30:00"
gitea_runner_pre_cache_images: "{{ gitea_runner_pre_pull_images }}"
@@ -20,9 +20,6 @@
- name: Include pre-pull images
ansible.builtin.include_tasks: pre_pull_images.yml
- name: Include pre-cache timer
ansible.builtin.include_tasks: pre_cache.yml
- name: Include integration test
ansible.builtin.include_tasks: integration_test.yml
when: not gitea_runner_skip_registration
@@ -1,67 +0,0 @@
---
# Periodic timer that pre-pulls CI runner images into the local Docker cache.
# Prevents thundering-herd registry timeouts when all runners start jobs
# simultaneously with empty/stale caches. Runs every 6 hours (configurable).
# The prune timer removes dangling images but NOT tagged ones, so pre-pulled
# images persist between runs.
- name: Create docker-pull-images user service file
ansible.builtin.template:
src: docker-pull-images.service.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-pull-images.service"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_pre_cache_service
- name: Create docker-pull-images user timer file
ansible.builtin.template:
src: docker-pull-images.timer.j2
dest: "{{ gitea_runner_home }}/.config/systemd/user/docker-pull-images.timer"
owner: "{{ gitea_runner_service_user }}"
group: "{{ gitea_runner_service_user }}"
mode: "0644"
register: gitea_runner_pre_cache_timer
- name: Reload systemd user daemon for pre-cache 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 }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_service is changed or gitea_runner_pre_cache_timer is changed
- gitea_runner_pre_cache_schedule | length > 0
- gitea_runner_pre_cache_images | length > 0
- name: Enable and start docker-pull-images user timer
ansible.builtin.command: systemctl --user enable --now docker-pull-images.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_schedule | length > 0
- gitea_runner_pre_cache_images | length > 0
- name: Disable and stop docker-pull-images timer (no images or schedule)
ansible.builtin.command: systemctl --user disable --now docker-pull-images.timer
become: true
become_user: "{{ gitea_runner_service_user }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ gitea_runner_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ gitea_runner_uid | default(0) }}/bus"
changed_when: true
failed_when: false
when:
- gitea_runner_systemd_available.stat.exists
- gitea_runner_docker_rootless_setup
- gitea_runner_pre_cache_schedule | length == 0 or gitea_runner_pre_cache_images | length == 0
@@ -8,15 +8,6 @@
#
# Set gitea_runner_pre_pull_images to a list of image refs to pull, or
# empty list to skip pre-pulling.
#
# IMPORTANT: Do NOT use this mechanism for:
# - CI runner container images (e.g. ci-full) — these are already
# cached by the runner setup task and pulling them here is redundant.
# - Images that molecule tests pull themselves — molecule prepare/converge
# steps handle their own image pulls; pre-pulling them here wastes time
# and disk space.
# This mechanism is intended only for images that are needed by the runner
# itself but not pulled by any molecule scenario or runner setup step.
- name: Pre-pull Docker images for CI runner
ansible.builtin.command: "docker pull {{ item }}"
@@ -1,14 +0,0 @@
[Unit]
Description=Pre-pull Docker images for CI runner cache
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
Environment=DOCKER_HOST=unix:///run/user/{{ gitea_runner_uid }}/docker.sock
Environment=XDG_RUNTIME_DIR=/run/user/{{ gitea_runner_uid }}
# Pull each image quietly. docker pull exits 0 if image is already up-to-date,
# so this is idempotent. Errors are non-fatal (image may already be cached).
{% for image in gitea_runner_pre_cache_images %}
ExecStart=/usr/bin/docker pull -q {{ image }}
{% endfor %}
@@ -1,10 +0,0 @@
[Unit]
Description=Periodic Docker image pre-cache for CI runner
[Timer]
OnCalendar={{ gitea_runner_pre_cache_schedule }}
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
@@ -9,13 +9,3 @@ runner:
container:
label: "{{ gitea_runner_container_label }}"
docker_host: "unix:///run/user/{{ gitea_runner_uid }}/docker.sock"
force_pull: {{ gitea_runner_force_pull | lower }}
{% if gitea_runner_container_options | length > 0 %}
options: "{{ gitea_runner_container_options }}"
{% endif %}
{% if gitea_runner_valid_volumes | length > 0 %}
valid_volumes:
{% for volume in gitea_runner_valid_volumes %}
- "{{ volume }}"
{% endfor %}
{% endif %}
+6 -6
View File
@@ -8,12 +8,12 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/9ae4b38a319a6a0122111cebdbaa65c108222483/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/a53d44eff18d8fdd31bc703911bb5b22614c99d5/python.svg)](https://www.python.org/downloads/)
## Overview
-47
View File
@@ -1,47 +0,0 @@
# GRM-162: Add pre-cache timer, force_pull, and Docker socket options to runner config
## Problem
CI containers were not using the host's rootless Docker daemon, leading to
"no space left on device" errors. The runner config template was missing
`force_pull`, `options` (host Docker socket mount), and `valid_volumes`
fields. Additionally, no pre-cache timer existed to prevent thundering-herd
registry timeouts when all runners pull images simultaneously.
## Approach
REQ-1: Add `force_pull: false` to runner config template (explicit default
so the runner reuses locally cached images instead of pulling on every job)
REQ-2: Add `options` field to mount host rootless Docker socket as
`/run/host-docker.sock` so `start_docker.py` inside CI containers can detect
and use the host daemon (full disk, no nested DinD)
REQ-3: Add `valid_volumes` list for the socket mount targets (validated by
the runner against `container.options` and job-level volumes)
REQ-4: Add `pre_cache.yml` task with a systemd user timer that pre-pulls CI
images every 6 hours (configurable via `gitea_runner_pre_cache_schedule`)
REQ-5: Add `docker-pull-images.service.j2` and `docker-pull-images.timer.j2`
templates for the pre-cache timer
REQ-6: Timer is disabled when `gitea_runner_pre_cache_schedule` is empty or
`gitea_runner_pre_cache_images` is empty (graceful degradation)
## Test Plan
- `make lint-ci` passes (ansible-lint on new task/template files)
- `make molecule` converges successfully with the new pre-cache tasks
- Verify the runner config template renders correctly with and without
container options/valid_volumes
## Deploy Plan
- Merge to master → post-merge auto-publishes package
- Infra dependency PR auto-created to bump pinned grm version
- Runners pick up the new config on next `make setup` or ansible apply
## Rollback Plan
- Revert the merge commit
- Set `gitea_runner_pre_cache_schedule: ""` to disable the timer without
reverting
## Acceptance Criteria
- [x] REQ-1: `force_pull: false` in runner config template
- [x] REQ-2: `options` field mounts host Docker socket as `/run/host-docker.sock`
- [x] REQ-3: `valid_volumes` list includes both socket mount targets
- [x] REQ-4: `pre_cache.yml` task creates and manages systemd user timer
- [x] REQ-5: Service and timer templates created
- [x] REQ-6: Timer disabled gracefully when schedule or images empty
-21
View File
@@ -1,21 +0,0 @@
# GRM-167: Bump devx to v0.51.0
## Problem
devx v0.51.0 released with role defaults path support for create_dependency_pr. grm pins v0.50.2.
## Approach
Bump devx pin in pyproject.toml.
REQ-1: Bump devx from v0.50.2 to v0.51.0 in pyproject.toml
## Test Plan
- Verify CI passes with new devx version
## Deploy Plan
- Merge to master, auto-release
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: devx pinned to v0.51.0 in pyproject.toml
-21
View File
@@ -1,21 +0,0 @@
# GRM-168: Bump devx to v0.51.9
## Problem
grm pins devx@v0.51.0 which rejects `deps:` as a conventional commit type,
causing post-merge CI failures on dependency bump commits.
## Approach
REQ-1: Bump devx from v0.51.0 to v0.51.9 in pyproject.toml
## Test Plan
- `make lint-all` passes
- `make pytest-cov` passes
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Bump devx from v0.51.0 to v0.51.9 in pyproject.toml
-28
View File
@@ -1,28 +0,0 @@
# GRM-171: Use kireto token for auto-merge approval review
## Problem
The auto-merge workflow posts approval reviews with
`REVIEWER_GITEA_API_TOKEN` (emil), but emil is also the PR creator.
Gitea ignores self-approvals, so the merge fails with HTTP 405
`Does not have enough approvals`.
## Approach
REQ-1: Change the approval review step in `.gitea/workflows/ci.yml` to use
`DEVELOPER_GITEA_API_TOKEN` (kireto) instead of
`REVIEWER_GITEA_API_TOKEN` (emil), since kireto is a different user
than the PR creator.
## Test Plan
- `make lint-all` passes (workflow-lint validates the YAML)
- Next auto-merge PR succeeds (approval posted by kireto, merge completes)
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Change the approval review step in `.gitea/workflows/ci.yml`
to use `DEVELOPER_GITEA_API_TOKEN` (kireto) instead of
`REVIEWER_GITEA_API_TOKEN` (emil)
-38
View File
@@ -1,38 +0,0 @@
# GRM-172: Audit and document pre-pull image usage guidelines
## Problem
The grm repo contains a runner-level `pre_pull_images.yml` task file that
pre-pulls Docker images to avoid repeated pulls on every CI run. However,
there was no audit confirming that molecule `prepare.yml` files are not
also redundantly pre-pulling images that the runner setup already caches.
Wasteful pre-pulling wastes CI time and disk space.
## Approach
Audit all molecule `prepare.yml` files in the grm repo for pre-pull tasks.
The audit found NO molecule prepare.yml files contain pre-pull tasks, so no
code removal is needed. Document the audit findings in a spec and add a
comment to the runner-level `pre_pull_images.yml` task file clarifying that
it should not be used for images that molecule tests pull themselves (to
avoid redundant pulls).
REQ-1: Audit all molecule prepare.yml files for pre-pull tasks and confirm none exist
REQ-2: Add documentation comment to pre_pull_images.yml stating it should not be used for CI runner container images (already cached by runner setup) or images molecule tests pull themselves
REQ-3: Confirm gitea_runner_pre_pull_images default remains empty ([]) which is correct
## Test Plan
- Grep all molecule prepare.yml files for pre-pull patterns confirms zero matches
- Verify pre_pull_images.yml comment is present and accurate
- Verify gitea_runner_pre_pull_images default is [] in defaults/main.yml
- Run make lint-ci to confirm no lint regressions
## Deploy Plan
- Merge to master via auto-merge workflow
- No runtime changes; documentation-only
## Rollback Plan
- Revert the merge commit; comments are removed, no functional impact
## Acceptance Criteria
- [x] REQ-1: No molecule prepare.yml files in the grm repo contain pre-pull tasks (audit confirmed via grep)
- [x] REQ-2: pre_pull_images.yml contains a comment documenting it should not be used for CI runner container images or images molecule tests pull themselves
- [x] REQ-3: gitea_runner_pre_pull_images default remains empty ([]) in defaults/main.yml
+2 -2
View File
@@ -36,7 +36,7 @@ ci = [
"build==1.5.1",
"twine==6.2.0",
# Reusable CI/CD and dev tools (auto-merge, pr-review, pre-push checks, etc.)
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.51.9",
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.51.0",
]
# Lint and type-checking tools (validate job)
lint = [
@@ -56,7 +56,7 @@ molecule = [
dev = [
"grm[ci,lint,molecule]",
# Reusable CI/CD and dev tools (pre-push hooks, create-task, create-pr)
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.51.9",
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@v0.51.0",
# Non-Python dev dependency: checkmake (Makefile linter)
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
__version__ = "0.23.0"
__version__ = "0.22.0"