refactor: rootless Docker, fix auto-merge, molecule platform matrix
CI / quality (pull_request) Failing after 1m4s
CI / molecule-tests (0) (pull_request) Has been skipped
CI / molecule-tests (1) (pull_request) Has been skipped
CI / molecule-tests (2) (pull_request) Has been skipped

Three major improvements:

1. Rootless Docker refactor: Removes docker/binary modes, unifies to
   rootless Docker with per-runner system users. Each runner gets its
   own rootless Docker daemon, systemd user service, and isolated
   environment. Simplifies CLI (removes --mode option), Ansible role
   (single code path), and molecule scenarios (removes binary scenario).

2. Auto-merge fix: Fixes status check context mismatch in branch
   protection (was requiring "lint", "unit-tests", "molecule-tests" but
   actual contexts are "CI / quality", "CI / molecule-tests*"). Adds
   retry/wait logic to auto_merge.py that polls commit statuses for up
   to 15 minutes before attempting merge, eliminating the chicken-and-egg
   problem where auto-merge would fail because CI hadn't completed yet.

3. Molecule platform matrix: Adds OS platform matrix to CI — all 6
   scenarios now run on all 4 supported OSes (ubuntu-2204, ubuntu-2404,
   debian-12, archlinux) = 24 test pairs distributed across 3 parallel
   runners. Updates distribute_molecule.py to distribute (scenario,
   platform) pairs. Updates Makefile with molecule-all target for
   local multi-platform testing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Emil Simeonov
2026-06-20 21:02:52 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0c6c735000
commit 55c2746569
71 changed files with 2444 additions and 1277 deletions
+77
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env python3
"""Auto-merge PR by extracting task ID from branch and validating PR title.
Waits for CI checks to complete before attempting the merge.
Usage:
REPO_TOKEN=<token> python3 scripts/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
"""
import os
import time
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -16,6 +19,8 @@ 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
load_dotenv(override=True)
@@ -45,6 +50,66 @@ def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
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")
@@ -75,6 +140,18 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
validate_pr_title(pr_title)
# 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."))
merge_title = f"{task_id}: {pr_title}"
try:
-2
View File
@@ -7,10 +7,8 @@ Usage:
from __future__ import annotations
import argparse
import re
import subprocess # nosec B404
import sys
import click
+76 -21
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env python3
"""Distribute molecule scenarios across N parallel runners.
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
splits them evenly across the requested number of runners.
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 deregister
# 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
import sys
from dataclasses import dataclass
from pathlib import Path
import click
@@ -23,6 +29,36 @@ 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).
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": ""},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": ""},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": ""},
{"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."""
@@ -40,19 +76,26 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios)
def distribute(scenarios: list[str], max_runners: int) -> list[list[str]]:
"""Split *scenarios* into *max_runners* balanced groups (round-robin)."""
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, scenario in enumerate(scenarios):
groups[i % max_runners].append(scenario)
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 scenarios_for_runner(
scenarios: list[str], runner_index: int, max_runners: int
) -> list[str]:
"""Return the subset of scenarios assigned to *runner_index*."""
groups = distribute(scenarios, max_runners)
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(
_(
@@ -84,19 +127,31 @@ def scenarios_for_runner(
is_flag=True,
help="List all discovered scenarios, one per line.",
)
def cli(runner_index: int | None, max_runners: int, list_all: bool) -> None:
@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 runner_index is None:
groups = distribute(scenarios, max_runners)
for i, group in enumerate(groups):
click.echo(f"Runner {i}: {' '.join(group) if group else '(none)'}")
if list_platforms:
for p in PLATFORMS:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
assigned = scenarios_for_runner(scenarios, runner_index, max_runners)
click.echo(" ".join(assigned))
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
+1 -3
View File
@@ -10,10 +10,8 @@ from __future__ import annotations
import platform
import shutil
import subprocess # nosec B404
import sys
from pathlib import Path
import urllib.request
from pathlib import Path
import click
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Run all molecule scenarios on all supported OS platforms.
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
set -euo pipefail
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
for p in \
ubuntu-2204:geerlingguy/docker-ubuntu2204-ansible:latest: \
ubuntu-2404:geerlingguy/docker-ubuntu2404-ansible:latest: \
debian-12:geerlingguy/docker-debian12-ansible:latest: \
archlinux:marcstraube/archlinux-ansible:latest:/usr/lib/systemd/systemd
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