#!/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="${PERIODVAULT_RUNNER_DIR:-${HOME}/.periodvault-runner}" RUNNER_LABELS="self-hosted,macOS,periodvault" RUNNER_NAME="" REPO_SLUG="" REG_TOKEN="" FORCE=false FOREGROUND=false PUSH_REGISTRY="" BUILD_TARGET="" PLIST_LABEL="com.periodvault.actions-runner" PLIST_PATH="${HOME}/Library/LaunchAgents/${PLIST_LABEL}.plist" # Resolved during Linux operations INFRA_DIR="" usage() { cat <<'EOF' Usage: ./scripts/runner.sh --mode [options] Required: --mode MODE One of: setup, start, stop, status, build-image, uninstall Options (macOS): --runner-dir DIR Installation directory (default: ~/.periodvault-runner) --labels LABELS Comma-separated labels (default: self-hosted,macOS,periodvault) --name NAME Runner name (default: periodvault-) --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): --target TARGET Dockerfile target: slim or full (default: builds both) --push REGISTRY Tag and push to a registry (e.g. localhost: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 runners ./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 slim + full ./scripts/runner.sh --mode build-image --target slim # build slim only ./scripts/runner.sh --mode build-image --push localhost:5000 # build + push to local registry Environment overrides: PERIODVAULT_RUNNER_DIR Runner installation directory (macOS only) EOF } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- 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." } 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 periodvault 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 ;; --target) shift; [[ $# -gt 0 ]] || die "--target requires a value (slim or full)" BUILD_TARGET="$1"; shift ;; --force) FORCE=true; shift ;; --foreground) FOREGROUND=true; shift ;; --push) shift; [[ $# -gt 0 ]] || die "--push requires a registry address (e.g. localhost: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 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 # =========================================================================== 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}" 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'([0-9a-f]{64})', 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)." } 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="periodvault-$(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" < Label ${PLIST_LABEL} ProgramArguments ${RUNNER_DIR}/run.sh WorkingDirectory ${RUNNER_DIR} RunAtLoad KeepAlive StandardOutPath ${RUNNER_DIR}/logs/stdout.log StandardErrorPath ${RUNNER_DIR}/logs/stderr.log EnvironmentVariables PATH /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin HOME ${HOME} 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 these repository variables:" log ' CI_RUNS_ON_MACOS: ["self-hosted", "macOS", "periodvault"]' log "" log "Via CLI:" log ' gh variable set CI_RUNS_ON_MACOS --body '"'"'["self-hosted","macOS","periodvault"]'"'" 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 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 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..." unload_service if [[ -f "$PLIST_PATH" ]]; then rm -f "$PLIST_PATH" log "Removed plist: $PLIST_PATH" fi 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 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() { require_cmd docker 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/" } compose() { docker compose -f "${INFRA_DIR}/docker-compose.yml" "$@" } do_build_image() { find_infra_dir ensure_docker local targets=() if [[ -n "$BUILD_TARGET" ]]; then targets+=("$BUILD_TARGET") else targets+=("slim" "full") fi for target in "${targets[@]}"; do local image_tag="periodvault-runner:${target}" if [[ -n "$PUSH_REGISTRY" ]]; then image_tag="${PUSH_REGISTRY}/periodvault-runner:${target}" fi log "Building runner image: ${image_tag} (target: ${target}, platform: linux/amd64)" DOCKER_BUILDKIT=1 docker build --platform linux/amd64 --pull \ --target "$target" \ -t "$image_tag" \ "$INFRA_DIR" if [[ -n "$PUSH_REGISTRY" ]]; then log "Pushing ${image_tag}..." docker push "$image_tag" log "Image pushed: ${image_tag}" else log "Image built locally: ${image_tag}" fi done if [[ -z "$PUSH_REGISTRY" ]]; then log "" log "Use --push to push to a registry." log "Example: ./scripts/runner.sh --mode build-image --push localhost:5000" fi } do_setup_linux() { find_infra_dir ensure_docker log "Docker-based runner setup (infra/runners/)" log "" 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 if [[ ! -f "${INFRA_DIR}/envs/periodvault.env" ]]; then if [[ -f "${INFRA_DIR}/envs/periodvault.env.example" ]]; then cp "${INFRA_DIR}/envs/periodvault.env.example" "${INFRA_DIR}/envs/periodvault.env" log "Created ${INFRA_DIR}/envs/periodvault.env from template." log "Edit this file to configure REPO_URL, RUNNER_NAME, and resource limits." else die "Missing envs/periodvault.env.example template in ${INFRA_DIR}" fi else log "envs/periodvault.env already exists; skipping." fi log "" log "Starting runners..." compose up -d log "" log "Setup complete. Verify with: ./scripts/runner.sh --mode status" log "" log "To activate self-hosted CI, set these repository variables:" log ' gh variable set CI_RUNS_ON --body '"'"'["self-hosted","Linux","X64"]'"'" log ' gh variable set CI_RUNS_ON_ANDROID --body '"'"'["self-hosted","Linux","X64","android-emulator"]'"'" } do_start_linux() { find_infra_dir ensure_docker log "Starting Docker runners..." compose up -d log "Runners started." } do_stop_linux() { find_infra_dir ensure_docker log "Stopping Docker runners..." compose down log "Runners 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 runners..." compose down -v --rmi local 2>/dev/null || compose down -v log "Docker runners removed (containers, volumes, local images)." log "" log "Note: Runners should auto-deregister from GitHub (ephemeral mode)." log "If stale runners remain, remove them manually:" log " gh api -X DELETE repos/OWNER/REPO/actions/runners/RUNNER_ID" } # =========================================================================== # Entry point # =========================================================================== 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 "$@"