Files

745 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# runner.sh — Setup, manage, and tear down a GitHub Actions self-hosted runner.
#
# Supports two platforms:
# - macOS: Installs the runner agent natively, manages it as a launchd service.
# - Linux: Delegates to Docker-based runner infrastructure in infra/runners/.
#
# Typical flow:
# 1) ./scripts/runner.sh --mode setup # install/configure runner
# 2) ./scripts/runner.sh --mode status # verify runner is online
# 3) (push/PR triggers CI on the self-hosted runner)
# 4) ./scripts/runner.sh --mode stop # stop runner
# 5) ./scripts/runner.sh --mode uninstall # deregister and clean up
set -euo pipefail
MODE=""
RUNNER_DIR="${AUGUR_RUNNER_DIR:-${HOME}/.augur-runner}"
RUNNER_LABELS="self-hosted,macOS,ARM64"
RUNNER_NAME=""
REPO_SLUG=""
REG_TOKEN=""
FORCE=false
FOREGROUND=false
PUSH_REGISTRY=""
PLIST_LABEL="com.augur.actions-runner"
PLIST_PATH="${HOME}/Library/LaunchAgents/${PLIST_LABEL}.plist"
# Resolved during Linux operations
INFRA_DIR=""
usage() {
cat <<'EOF'
Usage:
./scripts/runner.sh --mode <setup|start|stop|status|build-image|uninstall> [options]
Required:
--mode MODE One of: setup, start, stop, status, build-image, uninstall
Options (macOS):
--runner-dir DIR Installation directory (default: ~/.augur-runner)
--labels LABELS Comma-separated labels (default: self-hosted,macOS,ARM64)
--name NAME Runner name (default: augur-<hostname>)
--repo OWNER/REPO GitHub repository (default: auto-detected from git remote)
--token TOKEN Registration/removal token (prompted if not provided)
--force Force re-setup even if already configured
--foreground Start in foreground instead of launchd service
Options (Linux — Docker mode):
On Linux, this script delegates to Docker Compose in infra/runners/.
Configuration is managed via .env and envs/*.env files.
See infra/runners/README.md for details.
Options (build-image):
--push REGISTRY Tag and push to a registry (e.g. 192.168.1.82:5000)
Common:
-h, --help Show this help
Examples (macOS):
./scripts/runner.sh --mode setup
./scripts/runner.sh --mode setup --token ghp_xxxxx
./scripts/runner.sh --mode start
./scripts/runner.sh --mode start --foreground
./scripts/runner.sh --mode status
./scripts/runner.sh --mode stop
./scripts/runner.sh --mode uninstall
Examples (Linux):
./scripts/runner.sh --mode setup # prompts for .env, starts runner
./scripts/runner.sh --mode start # docker compose up -d
./scripts/runner.sh --mode stop # docker compose down
./scripts/runner.sh --mode status # docker compose ps + logs
./scripts/runner.sh --mode uninstall # docker compose down -v --rmi local
Examples (build-image — works on any OS):
./scripts/runner.sh --mode build-image # build locally
./scripts/runner.sh --mode build-image --push 192.168.1.82:5000 # build + push to registry
Environment overrides:
AUGUR_RUNNER_DIR Runner installation directory (macOS only)
EOF
}
# ---------------------------------------------------------------------------
# Helpers (consistent with actions-local.sh)
# ---------------------------------------------------------------------------
log() {
printf '[runner] %s\n' "$*"
}
warn() {
printf '[runner] WARNING: %s\n' "$*" >&2
}
die() {
printf '[runner] ERROR: %s\n' "$*" >&2
exit 1
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || die "required command not found: $cmd"
}
# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
detect_os() {
case "$(uname -s)" in
Darwin) printf 'darwin' ;;
Linux) printf 'linux' ;;
*) die "Unsupported OS: $(uname -s). This script supports macOS and Linux." ;;
esac
}
ensure_macos() {
[[ "$(detect_os)" == "darwin" ]] || die "This operation requires macOS."
}
# Locate the infra/runners/ directory relative to the repo root.
# The script lives at scripts/runner.sh, so repo root is one level up.
find_infra_dir() {
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local repo_root="${script_dir}/.."
INFRA_DIR="$(cd "${repo_root}/infra/runners" 2>/dev/null && pwd)" || true
if [[ -z "$INFRA_DIR" ]] || [[ ! -f "${INFRA_DIR}/docker-compose.yml" ]]; then
die "Could not find infra/runners/docker-compose.yml. Ensure you are running from the augur repo."
fi
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
shift; [[ $# -gt 0 ]] || die "--mode requires a value"
MODE="$1"; shift ;;
--runner-dir)
shift; [[ $# -gt 0 ]] || die "--runner-dir requires a value"
RUNNER_DIR="$1"; shift ;;
--labels)
shift; [[ $# -gt 0 ]] || die "--labels requires a value"
RUNNER_LABELS="$1"; shift ;;
--name)
shift; [[ $# -gt 0 ]] || die "--name requires a value"
RUNNER_NAME="$1"; shift ;;
--repo)
shift; [[ $# -gt 0 ]] || die "--repo requires a value"
REPO_SLUG="$1"; shift ;;
--token)
shift; [[ $# -gt 0 ]] || die "--token requires a value"
REG_TOKEN="$1"; shift ;;
--force)
FORCE=true; shift ;;
--foreground)
FOREGROUND=true; shift ;;
--push)
shift; [[ $# -gt 0 ]] || die "--push requires a registry address (e.g. 192.168.1.82:5000)"
PUSH_REGISTRY="$1"; shift ;;
-h|--help)
usage; exit 0 ;;
*)
die "unknown argument: $1" ;;
esac
done
[[ -n "$MODE" ]] || die "--mode is required (setup|start|stop|status|build-image|uninstall)"
case "$MODE" in
setup|start|stop|status|build-image|uninstall) ;;
*) die "invalid --mode: $MODE (expected setup|start|stop|status|build-image|uninstall)" ;;
esac
}
# ---------------------------------------------------------------------------
# Repo detection
# ---------------------------------------------------------------------------
detect_repo() {
if [[ -n "$REPO_SLUG" ]]; then
return
fi
local remote_url=""
remote_url="$(git remote get-url origin 2>/dev/null || true)"
if [[ -z "$remote_url" ]]; then
die "Could not detect repository from git remote. Use --repo OWNER/REPO."
fi
# Extract OWNER/REPO from HTTPS or SSH URLs
REPO_SLUG="$(printf '%s' "$remote_url" \
| sed -E 's#^(https?://github\.com/|git@github\.com:)##' \
| sed -E 's/\.git$//')"
if [[ -z "$REPO_SLUG" ]] || ! printf '%s' "$REPO_SLUG" | grep -qE '^[^/]+/[^/]+$'; then
die "Could not parse OWNER/REPO from remote URL: $remote_url. Use --repo OWNER/REPO."
fi
log "Auto-detected repository: $REPO_SLUG"
}
# ===========================================================================
# macOS: Native runner agent + launchd service
# ===========================================================================
# ---------------------------------------------------------------------------
# Runner download and verification (macOS)
# ---------------------------------------------------------------------------
detect_arch() {
local arch
arch="$(uname -m)"
case "$arch" in
arm64|aarch64) printf 'arm64' ;;
x86_64) printf 'x64' ;;
*) die "Unsupported architecture: $arch" ;;
esac
}
download_runner() {
require_cmd curl
require_cmd shasum
require_cmd tar
local arch
arch="$(detect_arch)"
log "Fetching latest runner release metadata..."
local release_json
release_json="$(curl -fsSL "https://api.github.com/repos/actions/runner/releases/latest")"
local version
version="$(printf '%s' "$release_json" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/')"
if [[ -z "$version" ]]; then
die "Could not determine latest runner version from GitHub API."
fi
log "Latest runner version: $version"
local tarball="actions-runner-osx-${arch}-${version}.tar.gz"
local download_url="https://github.com/actions/runner/releases/download/v${version}/${tarball}"
# Extract expected SHA256 from release body.
# The body contains HTML comments like:
# <!-- BEGIN SHA osx-arm64 -->HASH<!-- END SHA osx-arm64 -->
local sha_marker="osx-${arch}"
local expected_sha=""
expected_sha="$(printf '%s' "$release_json" \
| python3 -c "
import json,sys,re
body = json.load(sys.stdin).get('body','')
m = re.search(r'<!-- BEGIN SHA ${sha_marker} -->([0-9a-f]{64})<!-- END SHA ${sha_marker} -->', body)
print(m.group(1) if m else '')
" 2>/dev/null || true)"
mkdir -p "$RUNNER_DIR"
local dest="${RUNNER_DIR}/${tarball}"
if [[ -f "$dest" ]]; then
log "Tarball already exists: $dest"
else
log "Downloading: $download_url"
curl -fSL -o "$dest" "$download_url"
fi
if [[ -n "$expected_sha" ]]; then
log "Verifying SHA256 checksum..."
local actual_sha
actual_sha="$(shasum -a 256 "$dest" | awk '{print $1}')"
if [[ "$actual_sha" != "$expected_sha" ]]; then
rm -f "$dest"
die "Checksum mismatch. Expected: $expected_sha, Got: $actual_sha"
fi
log "Checksum verified."
else
warn "Could not extract expected SHA256 from release metadata; skipping verification."
fi
log "Extracting runner into $RUNNER_DIR..."
tar -xzf "$dest" -C "$RUNNER_DIR"
rm -f "$dest"
log "Runner extracted (version $version)."
}
# ---------------------------------------------------------------------------
# Registration (macOS)
# ---------------------------------------------------------------------------
prompt_token() {
if [[ -n "$REG_TOKEN" ]]; then
return
fi
log ""
log "A registration token is required."
log "Obtain one from: https://github.com/${REPO_SLUG}/settings/actions/runners/new"
log "Or via the API:"
log " curl -X POST -H 'Authorization: token YOUR_PAT' \\"
log " https://api.github.com/repos/${REPO_SLUG}/actions/runners/registration-token"
log ""
printf '[runner] Enter registration token: '
read -r REG_TOKEN
[[ -n "$REG_TOKEN" ]] || die "No token provided."
}
register_runner() {
if [[ -z "$RUNNER_NAME" ]]; then
RUNNER_NAME="augur-$(hostname -s)"
fi
log "Registering runner '${RUNNER_NAME}' with labels '${RUNNER_LABELS}'..."
local config_args=(
--url "https://github.com/${REPO_SLUG}"
--token "$REG_TOKEN"
--name "$RUNNER_NAME"
--labels "$RUNNER_LABELS"
--work "${RUNNER_DIR}/_work"
--unattended
)
if [[ "$FORCE" == "true" ]]; then
config_args+=(--replace)
fi
"${RUNNER_DIR}/config.sh" "${config_args[@]}"
log "Runner registered."
}
# ---------------------------------------------------------------------------
# launchd service management (macOS)
# ---------------------------------------------------------------------------
create_plist() {
mkdir -p "${RUNNER_DIR}/logs"
mkdir -p "$(dirname "$PLIST_PATH")"
cat > "$PLIST_PATH" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${PLIST_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${RUNNER_DIR}/run.sh</string>
</array>
<key>WorkingDirectory</key>
<string>${RUNNER_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${RUNNER_DIR}/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>${RUNNER_DIR}/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>${HOME}</string>
</dict>
</dict>
</plist>
EOF
log "Launchd plist created: $PLIST_PATH"
}
load_service() {
if launchctl list 2>/dev/null | grep -q "$PLIST_LABEL"; then
log "Service already loaded; unloading first..."
launchctl unload "$PLIST_PATH" 2>/dev/null || true
fi
launchctl load "$PLIST_PATH"
log "Service loaded."
}
unload_service() {
if launchctl list 2>/dev/null | grep -q "$PLIST_LABEL"; then
launchctl unload "$PLIST_PATH" 2>/dev/null || true
log "Service unloaded."
else
log "Service is not loaded."
fi
}
service_is_running() {
launchctl list 2>/dev/null | grep -q "$PLIST_LABEL"
}
# ---------------------------------------------------------------------------
# macOS mode implementations
# ---------------------------------------------------------------------------
do_setup_darwin() {
detect_repo
if [[ -f "${RUNNER_DIR}/.runner" ]] && [[ "$FORCE" != "true" ]]; then
log "Runner already configured at $RUNNER_DIR."
log "Use --force to re-setup."
do_status_darwin
return
fi
download_runner
prompt_token
register_runner
create_plist
load_service
log ""
log "Setup complete. Runner is registered and running."
log ""
log "To activate self-hosted CI, set the repository variable CI_RUNS_ON to:"
log ' ["self-hosted", "macOS", "ARM64"]'
log "in Settings > Secrets and variables > Actions > Variables."
log ""
log "Or via CLI:"
log " gh variable set CI_RUNS_ON --body '[\"self-hosted\", \"macOS\", \"ARM64\"]'"
log ""
log "Energy saver: ensure your Mac does not sleep while the runner is active."
log " System Settings > Energy Saver > Prevent automatic sleeping"
}
do_start_darwin() {
[[ -f "${RUNNER_DIR}/.runner" ]] || die "Runner not configured. Run --mode setup first."
if [[ "$FOREGROUND" == "true" ]]; then
log "Starting runner in foreground (Ctrl-C to stop)..."
exec "${RUNNER_DIR}/run.sh"
fi
if service_is_running; then
log "Runner service is already running."
return
fi
if [[ ! -f "$PLIST_PATH" ]]; then
log "Plist not found; recreating..."
create_plist
fi
load_service
log "Runner started."
}
do_stop_darwin() {
unload_service
log "Runner stopped."
}
do_status_darwin() {
log "Runner directory: $RUNNER_DIR"
if [[ ! -f "${RUNNER_DIR}/.runner" ]]; then
log "Status: NOT CONFIGURED"
log "Run --mode setup to install and register the runner."
return
fi
# Parse runner config
local runner_name=""
if command -v python3 >/dev/null 2>&1; then
runner_name="$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('agentName',''))" "${RUNNER_DIR}/.runner" 2>/dev/null || true)"
fi
if [[ -z "$runner_name" ]]; then
runner_name="(could not parse)"
fi
log "Runner name: $runner_name"
if service_is_running; then
log "Service: RUNNING"
else
log "Service: STOPPED"
fi
if pgrep -f "Runner.Listener" >/dev/null 2>&1; then
log "Process: ACTIVE (Runner.Listener found)"
else
log "Process: INACTIVE"
fi
# Show recent logs
local log_file="${RUNNER_DIR}/logs/stdout.log"
if [[ -f "$log_file" ]]; then
log ""
log "Recent log output (last 10 lines):"
tail -n 10 "$log_file" 2>/dev/null || true
fi
local diag_dir="${RUNNER_DIR}/_diag"
if [[ -d "$diag_dir" ]]; then
local latest_diag
latest_diag="$(ls -t "${diag_dir}"/Runner_*.log 2>/dev/null | head -n1 || true)"
if [[ -n "$latest_diag" ]]; then
log ""
log "Latest runner diagnostic (last 5 lines):"
tail -n 5 "$latest_diag" 2>/dev/null || true
fi
fi
}
do_uninstall_darwin() {
log "Uninstalling self-hosted runner..."
# Stop service first
unload_service
# Remove plist
if [[ -f "$PLIST_PATH" ]]; then
rm -f "$PLIST_PATH"
log "Removed plist: $PLIST_PATH"
fi
# Deregister from GitHub
if [[ -f "${RUNNER_DIR}/config.sh" ]]; then
if [[ -z "$REG_TOKEN" ]]; then
detect_repo
log ""
log "A removal token is required to deregister the runner."
log "Obtain one from: https://github.com/${REPO_SLUG}/settings/actions/runners"
log "Or via the API:"
log " curl -X POST -H 'Authorization: token YOUR_PAT' \\"
log " https://api.github.com/repos/${REPO_SLUG}/actions/runners/remove-token"
log ""
printf '[runner] Enter removal token (or press Enter to skip deregistration): '
read -r REG_TOKEN
fi
if [[ -n "$REG_TOKEN" ]]; then
"${RUNNER_DIR}/config.sh" remove --token "$REG_TOKEN" || warn "Deregistration failed; you may need to remove the runner manually from GitHub settings."
log "Runner deregistered from GitHub."
else
warn "Skipping deregistration. Remove the runner manually from GitHub settings."
fi
fi
# Clean up runner directory
if [[ -d "$RUNNER_DIR" ]]; then
log "Removing runner directory: $RUNNER_DIR"
rm -rf "$RUNNER_DIR"
log "Runner directory removed."
fi
log "Uninstall complete."
}
# ===========================================================================
# Linux: Docker-based runner via infra/runners/
# ===========================================================================
# Ensure Docker and docker compose are available.
ensure_docker() {
require_cmd docker
# Check for docker compose (v2 plugin or standalone)
if docker compose version >/dev/null 2>&1; then
return
fi
if command -v docker-compose >/dev/null 2>&1; then
warn "Found docker-compose (standalone). docker compose v2 plugin is recommended."
return
fi
die "docker compose is required. Install Docker Compose v2: https://docs.docker.com/compose/install/"
}
# Run docker compose in the infra/runners directory.
# Accepts any docker compose subcommand and arguments.
compose() {
docker compose -f "${INFRA_DIR}/docker-compose.yml" "$@"
}
do_build_image() {
find_infra_dir
ensure_docker
local dockerfile_dir="${INFRA_DIR}"
# Determine the image tag based on whether --push was given.
# With --push: tag includes the registry so docker push knows where to send it.
# Without --push: clean local name.
local image_tag="augur-runner:latest"
if [[ -n "$PUSH_REGISTRY" ]]; then
image_tag="${PUSH_REGISTRY}/augur-runner:latest"
fi
# Always target linux/amd64 — the Dockerfile hardcodes x86_64 binaries
# (Go linux-amd64, runner agent linux-x64). This ensures correct arch
# even when building on an ARM Mac.
log "Building runner image: ${image_tag} (platform: linux/amd64)"
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 --pull -t "$image_tag" "$dockerfile_dir"
if [[ -n "$PUSH_REGISTRY" ]]; then
log "Pushing to ${PUSH_REGISTRY}..."
docker push "$image_tag"
log "Image pushed to ${image_tag}"
else
log "Image built locally as ${image_tag}"
log "Use --push <registry> to push to a remote registry."
fi
}
do_setup_linux() {
find_infra_dir
ensure_docker
log "Docker-based runner setup (infra/runners/)"
log ""
# Create .env from template if it doesn't exist
if [[ ! -f "${INFRA_DIR}/.env" ]]; then
if [[ -f "${INFRA_DIR}/.env.example" ]]; then
cp "${INFRA_DIR}/.env.example" "${INFRA_DIR}/.env"
log "Created ${INFRA_DIR}/.env from template."
log "Edit this file to set your GITHUB_PAT."
log ""
printf '[runner] Enter your GitHub PAT (or press Enter to edit .env manually later): '
read -r pat_input
if [[ -n "$pat_input" ]]; then
sed -i "s/^GITHUB_PAT=.*/GITHUB_PAT=${pat_input}/" "${INFRA_DIR}/.env"
log "GITHUB_PAT set in .env"
fi
else
die "Missing .env.example template in ${INFRA_DIR}"
fi
else
log ".env already exists; skipping."
fi
# Create per-repo env from template if it doesn't exist
if [[ ! -f "${INFRA_DIR}/envs/augur.env" ]]; then
if [[ -f "${INFRA_DIR}/envs/augur.env.example" ]]; then
cp "${INFRA_DIR}/envs/augur.env.example" "${INFRA_DIR}/envs/augur.env"
log "Created ${INFRA_DIR}/envs/augur.env from template."
log "Edit this file to configure REPO_URL, RUNNER_NAME, and resource limits."
else
die "Missing envs/augur.env.example template in ${INFRA_DIR}"
fi
else
log "envs/augur.env already exists; skipping."
fi
log ""
log "Starting runner..."
compose up -d
log ""
log "Setup complete. Verify with: ./scripts/runner.sh --mode status"
log ""
log "To activate self-hosted CI, set the repository variable CI_RUNS_ON to:"
log ' ["self-hosted", "Linux", "X64"]'
log ""
log "Via CLI:"
log " gh variable set CI_RUNS_ON --body '[\"self-hosted\", \"Linux\", \"X64\"]'"
}
do_start_linux() {
find_infra_dir
ensure_docker
log "Starting Docker runner..."
compose up -d
log "Runner started."
}
do_stop_linux() {
find_infra_dir
ensure_docker
log "Stopping Docker runner..."
compose down
log "Runner stopped."
}
do_status_linux() {
find_infra_dir
ensure_docker
log "Docker runner status (infra/runners/):"
log ""
compose ps
log ""
log "Recent logs (last 20 lines):"
compose logs --tail 20 2>/dev/null || true
}
do_uninstall_linux() {
find_infra_dir
ensure_docker
log "Uninstalling Docker runner..."
compose down -v --rmi local 2>/dev/null || compose down -v
log "Docker runner removed (containers, volumes, local images)."
log ""
log "Note: The runner should auto-deregister from GitHub (ephemeral mode)."
log "If a stale runner remains, remove it manually:"
log " gh api -X DELETE repos/OWNER/REPO/actions/runners/RUNNER_ID"
}
# ===========================================================================
# Entry point — routes to macOS or Linux implementation
# ===========================================================================
main() {
parse_args "$@"
local os
os="$(detect_os)"
case "$MODE" in
setup)
if [[ "$os" == "darwin" ]]; then do_setup_darwin; else do_setup_linux; fi ;;
start)
if [[ "$os" == "darwin" ]]; then do_start_darwin; else do_start_linux; fi ;;
stop)
if [[ "$os" == "darwin" ]]; then do_stop_darwin; else do_stop_linux; fi ;;
status)
if [[ "$os" == "darwin" ]]; then do_status_darwin; else do_status_linux; fi ;;
build-image)
do_build_image ;;
uninstall)
if [[ "$os" == "darwin" ]]; then do_uninstall_darwin; else do_uninstall_linux; fi ;;
*)
die "unexpected mode: $MODE" ;;
esac
}
main "$@"