feat: add Phase 3 — Runners + manage_runner.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-26 15:20:12 -06:00
parent eaffb97144
commit 6b82752d9e
4 changed files with 655 additions and 0 deletions

408
manage_runner.sh Executable file
View File

@@ -0,0 +1,408 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# manage_runner.sh — Add, remove, or list Gitea Actions runners
# Reads runner definitions from runners.conf (pipe-delimited format).
# Supports two runner types:
# docker — Linux hosts, deployed as Docker container via docker-compose
# native — macOS, deployed as binary + launchd service
# Can be used standalone or called by phase3_runners.sh / phase3_teardown.sh.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_TOKEN ACT_RUNNER_VERSION
RUNNERS_CONF="${SCRIPT_DIR}/runners.conf"
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
add --name <runner_name> Deploy and register a runner
remove --name <runner_name> Stop and deregister a runner
list Show all runners with status
Options:
--name <name> Runner name as defined in runners.conf
--help Show this help
EOF
exit 1
}
# ---------------------------------------------------------------------------
# Parse a runner entry from runners.conf by name
# Sets: RUNNER_NAME, RUNNER_SSH_HOST, RUNNER_SSH_USER, RUNNER_SSH_PORT,
# RUNNER_DATA_PATH, RUNNER_LABELS, RUNNER_TYPE
# Returns 1 if not found.
# ---------------------------------------------------------------------------
parse_runner_entry() {
local target_name="$1"
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
return 1
fi
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
# Trim whitespace from fields
name=$(echo "$name" | xargs)
if [[ "$name" == "$target_name" ]]; then
RUNNER_NAME="$name"
RUNNER_SSH_HOST=$(echo "$host" | xargs)
RUNNER_SSH_USER=$(echo "$user" | xargs)
RUNNER_SSH_PORT=$(echo "$port" | xargs)
RUNNER_DATA_PATH=$(echo "$path" | xargs)
RUNNER_LABELS=$(echo "$labels" | xargs)
RUNNER_TYPE=$(echo "$type" | xargs)
return 0
fi
done < "$RUNNERS_CONF"
log_error "Runner '$target_name' not found in runners.conf"
return 1
}
# ---------------------------------------------------------------------------
# List all runner entries from runners.conf (without looking up a name)
# Outputs lines to stdout: name|host|user|port|path|labels|type
# ---------------------------------------------------------------------------
all_runner_entries() {
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found at $RUNNERS_CONF"
return 1
fi
while IFS= read -r line; do
# Skip comments and blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
echo "$line"
done < "$RUNNERS_CONF"
}
# ---------------------------------------------------------------------------
# Execute a command on the runner's host.
# For "local" hosts (macOS), runs the command directly.
# For remote hosts, SSHs into them — uses direct ssh (not ssh_exec from
# common.sh) because runner hosts have their own SSH creds defined in
# runners.conf, not from the standard *_IP/*_SSH_USER env vars.
# ---------------------------------------------------------------------------
runner_ssh() {
local cmd="$*"
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
# macOS runner — execute directly on this machine
eval "$cmd"
else
ssh -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-p "${RUNNER_SSH_PORT}" \
"${RUNNER_SSH_USER}@${RUNNER_SSH_HOST}" \
"$cmd"
fi
}
# ---------------------------------------------------------------------------
# SCP a file to the runner's host.
# For "local" hosts, just copies the file.
# ---------------------------------------------------------------------------
runner_scp() {
local src="$1" dst="$2"
if [[ "$RUNNER_SSH_HOST" == "local" ]]; then
cp "$src" "$dst"
else
scp -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
-o BatchMode=yes \
-P "${RUNNER_SSH_PORT}" \
"$src" "${RUNNER_SSH_USER}@${RUNNER_SSH_HOST}:${dst}"
fi
}
# ---------------------------------------------------------------------------
# add_docker_runner — Deploy a runner as a Docker container on a Linux host
# Steps:
# 1. Create data directory
# 2. Render + SCP docker-compose.yml
# 3. Render + SCP runner-config.yaml
# 4. Start container
# The act_runner Docker image auto-registers with Gitea using env vars
# (GITEA_INSTANCE_URL + GITEA_RUNNER_REGISTRATION_TOKEN) on first boot.
# ---------------------------------------------------------------------------
add_docker_runner() {
log_info "Deploying Docker runner '${RUNNER_NAME}' on ${RUNNER_SSH_HOST}..."
# Check if container is already running
local status
status=$(runner_ssh "docker ps --filter name=gitea-runner-${RUNNER_NAME} --format '{{.Status}}'" 2>/dev/null || true)
if [[ "$status" == *"Up"* ]]; then
log_info "Runner '${RUNNER_NAME}' already running — skipping"
return 0
fi
# Create data directory on remote host
runner_ssh "mkdir -p '${RUNNER_DATA_PATH}'"
# Render docker-compose template with runner-specific vars
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}"
render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile"
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml"
rm -f "$tmpfile"
# Render runner config
tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile"
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
# Start the container
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose up -d 2>/dev/null || docker-compose up -d"
log_success "Docker runner '${RUNNER_NAME}' started on ${RUNNER_SSH_HOST}"
}
# ---------------------------------------------------------------------------
# add_native_runner — Deploy a runner as a native binary on macOS
# Steps:
# 1. Download act_runner binary for macOS (arm64 or amd64)
# 2. Register with Gitea (--no-interactive)
# 3. Render launchd plist
# 4. Load plist via launchctl
# Native runners are used on macOS because Docker Desktop is heavyweight
# and unreliable for long-running background services.
# ---------------------------------------------------------------------------
add_native_runner() {
log_info "Deploying native runner '${RUNNER_NAME}' on local machine..."
# Resolve ~ to actual home directory for local execution
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
export RUNNER_DATA_PATH
local plist_name="com.gitea.runner.${RUNNER_NAME}.plist"
local plist_path="$HOME/Library/LaunchAgents/${plist_name}"
# Check if launchd service is already loaded
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
log_info "Runner '${RUNNER_NAME}' already loaded in launchd — skipping"
return 0
fi
# Create data directory
mkdir -p "${RUNNER_DATA_PATH}"
# Download act_runner binary if not present
if [[ ! -x "${RUNNER_DATA_PATH}/act_runner" ]]; then
# Detect architecture — Apple Silicon (arm64) vs Intel (x86_64)
local arch
arch=$(uname -m)
case "$arch" in
arm64) arch="arm64" ;;
x86_64) arch="amd64" ;;
*) log_error "Unsupported architecture: $arch"; return 1 ;;
esac
local download_url="https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-darwin-${arch}"
log_info "Downloading act_runner v${ACT_RUNNER_VERSION} for darwin-${arch}..."
curl -sfL -o "${RUNNER_DATA_PATH}/act_runner" "$download_url"
chmod +x "${RUNNER_DATA_PATH}/act_runner"
log_success "act_runner binary downloaded"
fi
# Register the runner with Gitea (generates .runner file in data dir)
# --no-interactive skips prompts, --config generates default config if missing
if [[ ! -f "${RUNNER_DATA_PATH}/.runner" ]]; then
log_info "Registering runner with Gitea..."
"${RUNNER_DATA_PATH}/act_runner" register \
--no-interactive \
--instance "${GITEA_INTERNAL_URL}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" \
--name "${RUNNER_NAME}" \
--labels "${RUNNER_LABELS}" \
--config "${RUNNER_DATA_PATH}/config.yaml"
log_success "Runner registered"
fi
# Render runner config (overwrites any default generated by register)
local tmpfile
tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile"
cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile"
# Render launchd plist
tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/com.gitea.runner.plist.tpl" "$tmpfile"
mkdir -p "$HOME/Library/LaunchAgents"
cp "$tmpfile" "$plist_path"
rm -f "$tmpfile"
# Load the launchd service
launchctl load "$plist_path"
log_success "Native runner '${RUNNER_NAME}' loaded via launchd"
}
# ---------------------------------------------------------------------------
# remove_docker_runner — Stop + remove Docker runner container
# ---------------------------------------------------------------------------
remove_docker_runner() {
log_info "Removing Docker runner '${RUNNER_NAME}' from ${RUNNER_SSH_HOST}..."
# Check if docker-compose file exists
if runner_ssh "test -f '${RUNNER_DATA_PATH}/docker-compose.yml'" 2>/dev/null; then
runner_ssh "cd '${RUNNER_DATA_PATH}' && docker compose down 2>/dev/null || docker-compose down" || true
log_success "Docker runner '${RUNNER_NAME}' stopped"
else
log_info "No docker-compose.yml found for runner '${RUNNER_NAME}' — already removed"
fi
}
# ---------------------------------------------------------------------------
# remove_native_runner — Unload launchd service + remove binary + plist
# ---------------------------------------------------------------------------
remove_native_runner() {
log_info "Removing native runner '${RUNNER_NAME}' from local machine..."
# Resolve ~ to actual home directory
RUNNER_DATA_PATH="${RUNNER_DATA_PATH/#\~/$HOME}"
local plist_name="com.gitea.runner.${RUNNER_NAME}.plist"
local plist_path="$HOME/Library/LaunchAgents/${plist_name}"
# Unload launchd service if loaded
if launchctl list 2>/dev/null | grep -q "com.gitea.runner.${RUNNER_NAME}"; then
launchctl unload "$plist_path" 2>/dev/null || true
log_success "Launchd service unloaded"
fi
# Remove plist file
if [[ -f "$plist_path" ]]; then
rm -f "$plist_path"
log_success "Plist removed: $plist_path"
fi
# Remove runner data directory (binary, config, registration)
if [[ -d "${RUNNER_DATA_PATH}" ]]; then
printf 'Remove runner data at %s? [y/N] ' "${RUNNER_DATA_PATH}"
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
rm -rf "${RUNNER_DATA_PATH}"
log_success "Runner data removed"
else
log_info "Runner data preserved"
fi
fi
}
# ---------------------------------------------------------------------------
# list_runners — Print table of all runners with their Gitea status
# Queries the Gitea admin API for registered runners and cross-references
# with runners.conf to show which are online/offline/unregistered.
# ---------------------------------------------------------------------------
list_runners() {
log_info "Listing runners..."
# Fetch registered runners from Gitea admin API
local api_runners
api_runners=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]")
# Print header
printf '%-20s %-16s %-10s %-8s %-10s\n' "NAME" "HOST" "LABELS" "TYPE" "STATUS"
printf '%-20s %-16s %-10s %-8s %-10s\n' "----" "----" "------" "----" "------"
# For each entry in runners.conf, look up status in API response
while IFS='|' read -r name host user port path labels type; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
host=$(echo "$host" | xargs)
labels=$(echo "$labels" | xargs)
type=$(echo "$type" | xargs)
# Search for this runner in the API response by name
local status
status=$(printf '%s' "$api_runners" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true)
if [[ -z "$status" ]]; then
status="not-found"
fi
printf '%-20s %-16s %-10s %-8s %-10s\n' "$name" "$host" "$labels" "$type" "$status"
done < "$RUNNERS_CONF"
}
# ---------------------------------------------------------------------------
# Main — parse command and dispatch
# ---------------------------------------------------------------------------
COMMAND=""
RUNNER_ARG_NAME=""
while [[ $# -gt 0 ]]; do
case "$1" in
add|remove|list) COMMAND="$1"; shift ;;
--name)
if [[ $# -lt 2 ]]; then log_error "--name requires a value"; usage; fi
RUNNER_ARG_NAME="$2"; shift 2 ;;
--help|-h) usage ;;
*) log_error "Unknown argument: $1"; usage ;;
esac
done
if [[ -z "$COMMAND" ]]; then
log_error "No command specified"
usage
fi
case "$COMMAND" in
add)
if [[ -z "$RUNNER_ARG_NAME" ]]; then
log_error "add requires --name <runner_name>"
usage
fi
require_vars GITEA_RUNNER_REGISTRATION_TOKEN
parse_runner_entry "$RUNNER_ARG_NAME"
case "$RUNNER_TYPE" in
docker) add_docker_runner ;;
native) add_native_runner ;;
*) log_error "Unknown runner type: $RUNNER_TYPE (must be 'docker' or 'native')"; exit 1 ;;
esac
;;
remove)
if [[ -z "$RUNNER_ARG_NAME" ]]; then
log_error "remove requires --name <runner_name>"
usage
fi
parse_runner_entry "$RUNNER_ARG_NAME"
case "$RUNNER_TYPE" in
docker) remove_docker_runner ;;
native) remove_native_runner ;;
*) log_error "Unknown runner type: $RUNNER_TYPE"; exit 1 ;;
esac
;;
list)
list_runners
;;
*)
log_error "Unknown command: $COMMAND"
usage
;;
esac

87
phase3_post_check.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase3_post_check.sh — Verify Phase 3 (Runners) succeeded
# Checks that every runner defined in runners.conf is registered and online
# in the Gitea admin panel.
# Exits 0 only if ALL checks pass.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_TOKEN
log_info "=== Phase 3 Post-Check ==="
RUNNERS_CONF="${SCRIPT_DIR}/runners.conf"
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found"
exit 1
fi
PASS=0
FAIL=0
# ---------------------------------------------------------------------------
# Fetch all registered runners from Gitea admin API (single call)
# This avoids making N API calls — one per runner — which would be slow.
# ---------------------------------------------------------------------------
API_RUNNERS=$(gitea_api GET "/admin/runners" 2>/dev/null || echo "[]")
# ---------------------------------------------------------------------------
# Check each runner: exists in API response AND status is online/idle/active
# A runner that registered but is not running will show as "offline".
# ---------------------------------------------------------------------------
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
# Look up runner by name in the API response
local_status=$(printf '%s' "$API_RUNNERS" | jq -r --arg n "$name" '.[] | select(.name == $n) | .status' 2>/dev/null || true)
if [[ -z "$local_status" ]]; then
log_error "FAIL: Runner '${name}' not found in Gitea admin"
FAIL=$((FAIL + 1))
elif [[ "$local_status" == "offline" ]]; then
log_error "FAIL: Runner '${name}' is registered but offline"
FAIL=$((FAIL + 1))
else
log_success "Runner '${name}' is ${local_status}"
PASS=$((PASS + 1))
fi
done < "$RUNNERS_CONF"
# ---------------------------------------------------------------------------
# Check: runner count matches runners.conf
# ---------------------------------------------------------------------------
EXPECTED_COUNT=$(grep -v '^\s*#' "$RUNNERS_CONF" | grep -v '^\s*$' | wc -l | xargs)
ACTUAL_COUNT=$(printf '%s' "$API_RUNNERS" | jq 'length' 2>/dev/null || echo 0)
if [[ "$ACTUAL_COUNT" -ge "$EXPECTED_COUNT" ]]; then
log_success "Runner count OK: ${ACTUAL_COUNT} registered (${EXPECTED_COUNT} expected)"
PASS=$((PASS + 1))
else
log_error "FAIL: Only ${ACTUAL_COUNT} runners registered (expected ${EXPECTED_COUNT})"
FAIL=$((FAIL + 1))
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
printf '\n'
log_info "Results: ${PASS} passed, ${FAIL} failed"
if [[ $FAIL -gt 0 ]]; then
log_error "Phase 3 post-check FAILED"
exit 1
else
log_success "Phase 3 post-check PASSED — all runners are online"
exit 0
fi

102
phase3_runners.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase3_runners.sh — Deploy Gitea Actions runners from runners.conf
# Depends on: Phase 1 complete (Gitea running on Unraid with admin token)
# Steps:
# 1. Get runner registration token from Gitea admin API
# 2. Save token to .env for reuse by manage_runner.sh
# 3. Deploy each runner defined in runners.conf via manage_runner.sh
# Idempotent: skips already-deployed runners, reuses valid tokens.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_TOKEN ACT_RUNNER_VERSION
phase_header 3 "Gitea Actions Runners"
RUNNERS_CONF="${SCRIPT_DIR}/runners.conf"
# ---------------------------------------------------------------------------
# Pre-check: runners.conf must exist with at least one entry
# ---------------------------------------------------------------------------
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_error "runners.conf not found — copy runners.conf.example and fill in values"
exit 1
fi
# Count non-comment, non-blank lines to verify there are runners to deploy
RUNNER_COUNT=$(grep -v '^\s*#' "$RUNNERS_CONF" | grep -v '^\s*$' | wc -l | xargs)
if [[ "$RUNNER_COUNT" -eq 0 ]]; then
log_error "No runners defined in runners.conf"
exit 1
fi
log_info "Found ${RUNNER_COUNT} runner(s) in runners.conf"
# ---------------------------------------------------------------------------
# Step 1: Get or reuse runner registration token
# The registration token is a shared secret that all runners use to register
# themselves with the Gitea instance. It's retrieved from the admin API and
# saved to .env so manage_runner.sh can use it independently.
# ---------------------------------------------------------------------------
log_step 1 "Getting runner registration token..."
if [[ -n "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]]; then
log_info "Registration token already in .env — reusing"
else
# Gitea returns the token as a JSON object: {"token": "..."}
TOKEN_RESPONSE=$(gitea_api GET "/admin/runners/registration-token")
GITEA_RUNNER_REGISTRATION_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | jq -r '.token')
if [[ -z "$GITEA_RUNNER_REGISTRATION_TOKEN" ]] || [[ "$GITEA_RUNNER_REGISTRATION_TOKEN" == "null" ]]; then
log_error "Failed to get runner registration token from Gitea"
exit 1
fi
save_env_var "GITEA_RUNNER_REGISTRATION_TOKEN" "$GITEA_RUNNER_REGISTRATION_TOKEN"
log_success "Registration token saved to .env"
fi
# ---------------------------------------------------------------------------
# Step 2: Deploy each runner via manage_runner.sh
# Iterates over every non-comment line in runners.conf, extracts the name
# (first pipe-delimited field), and invokes manage_runner.sh add.
# manage_runner.sh handles its own idempotency (skips already-running runners).
# ---------------------------------------------------------------------------
log_step 2 "Deploying runners..."
DEPLOYED=0
FAILED=0
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
log_info "Processing runner: ${name}"
if "${SCRIPT_DIR}/manage_runner.sh" add --name "$name"; then
DEPLOYED=$((DEPLOYED + 1))
else
log_error "Failed to deploy runner: ${name}"
FAILED=$((FAILED + 1))
fi
done < "$RUNNERS_CONF"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
printf '\n'
log_info "Results: ${DEPLOYED} deployed, ${FAILED} failed (out of ${RUNNER_COUNT})"
if [[ $FAILED -gt 0 ]]; then
log_error "Some runners failed to deploy — check logs above"
exit 1
fi
log_success "Phase 3 complete — all ${RUNNER_COUNT} runner(s) deployed"

58
phase3_teardown.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# phase3_teardown.sh — Tear down all runners defined in runners.conf
# For each runner: calls manage_runner.sh remove to stop and clean up.
# Clears GITEA_RUNNER_REGISTRATION_TOKEN from .env since it becomes useless
# after all runners are removed.
# Safe to run against already-torn-down runners (no errors).
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
load_env
require_vars GITEA_INTERNAL_URL GITEA_ADMIN_TOKEN ACT_RUNNER_VERSION
log_warn "=== Phase 3 Teardown: Runners ==="
RUNNERS_CONF="${SCRIPT_DIR}/runners.conf"
if [[ ! -f "$RUNNERS_CONF" ]]; then
log_info "runners.conf not found — nothing to tear down"
exit 0
fi
# ---------------------------------------------------------------------------
# Step 1: Remove each runner via manage_runner.sh
# manage_runner.sh handles its own safety (skips already-removed runners).
# ---------------------------------------------------------------------------
printf 'This will stop and remove all runners. Continue? [y/N] '
read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
while IFS='|' read -r name rest; do
# Skip comments and blank lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
name=$(echo "$name" | xargs)
log_info "Removing runner: ${name}"
"${SCRIPT_DIR}/manage_runner.sh" remove --name "$name" || true
done < "$RUNNERS_CONF"
log_success "All runners removed"
else
log_info "Skipped runner removal"
fi
# ---------------------------------------------------------------------------
# Step 2: Clear the registration token from .env
# The token is single-use in some Gitea configurations — generating a new one
# on next deploy is safer than reusing a potentially stale token.
# ---------------------------------------------------------------------------
if [[ -n "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]]; then
save_env_var "GITEA_RUNNER_REGISTRATION_TOKEN" ""
log_success "GITEA_RUNNER_REGISTRATION_TOKEN cleared from .env"
fi
log_success "Phase 3 teardown complete"