#!/usr/bin/env bash # # add-host-runner.sh — Stand up an additional independent host-mode Gitea # Actions runner as its own launchd service, for more CI parallelism. # # The canonical runner is the Homebrew-managed `act_runner` service # (config: /opt/homebrew/etc/act_runner/config.yaml, capacity 2). Bumping a # single runner's capacity high overloads the laptop and shares one workdir; # registering *separate* runners gives clean isolation and scales linearly. # # Each extra runner gets: # - its own config dir ~/Library/Application Support/act_runner-/ # - its own .runner file (separate Gitea registration) # - its own workdir ~/.cache/act- (no cross-runner clashes) # - the SHARED secret /opt/homebrew/etc/act_runner/runner.env (env_file) # - its own launchd plist ~/Library/LaunchAgents/com.bytelyst.act_runner-.plist # # Usage: # bash add-host-runner.sh [capacity] [mode] # mode: host (default) | docker # bash add-host-runner.sh 2 2 && bash add-host-runner.sh 3 2 # two host runners # bash add-host-runner.sh 4 1 docker # a docker-mode runner # # Docker mode advertises a dedicated `docker` label (not `ubuntu-latest`) so it # does not hijack existing host-mode jobs. Target it with `runs-on: docker` and # reach Gitea from inside the job container via host.docker.internal:3300. # # Requires: act_runner on PATH, a Gitea admin PAT at ~/.gitea_c5_pat, # and the canonical runner.env to already exist (created during runner hardening). # # Idempotent: if runner is already registered it reloads the service and exits. set -euo pipefail N="${1:?usage: add-host-runner.sh [capacity] [mode] (mode: host|docker)}" CAP="${2:-2}" MODE="${3:-host}" # host | docker INSTANCE="${GITEA_INSTANCE:-http://localhost:3300}" DOCKER_IMAGE="${RUNNER_DOCKER_IMAGE:-catthehacker/ubuntu:act-latest}" PAT_FILE="${GITEA_PAT_FILE:-$HOME/.gitea_c5_pat}" CANONICAL_CONFIG="${CANONICAL_CONFIG:-/opt/homebrew/etc/act_runner/config.yaml}" SHARED_ENV_FILE="${SHARED_ENV_FILE:-/opt/homebrew/etc/act_runner/runner.env}" BASE="$HOME/Library/Application Support/act_runner-$N" CONFIG="$BASE/config.yaml" RUNNER_FILE="$BASE/.runner" WORKDIR="$HOME/.cache/act-$N" PLIST="$HOME/Library/LaunchAgents/com.bytelyst.act_runner-$N.plist" SVC_LABEL="com.bytelyst.act_runner-$N" RUNNER_NAME="$(hostname -s)-$N" LOG_DIR="$BASE/logs" command -v act_runner >/dev/null 2>&1 || { echo "✗ act_runner not on PATH (brew install act_runner)" >&2; exit 1; } [ -f "$PAT_FILE" ] || { echo "✗ no Gitea PAT at $PAT_FILE" >&2; exit 1; } [ -f "$CANONICAL_CONFIG" ] || { echo "✗ canonical config not found: $CANONICAL_CONFIG" >&2; exit 1; } PAT="$(cat "$PAT_FILE")" mkdir -p "$BASE" "$WORKDIR" "$LOG_DIR" echo "── add $MODE runner #$N (capacity $CAP) ──" [ "$MODE" = "docker" ] && echo " image : $DOCKER_IMAGE" # ── labels per mode (act_runner uses config-file labels, not --labels) ────── if [ "$MODE" = "docker" ]; then # Dedicated `docker` label so existing `runs-on: ubuntu-latest` jobs (which # assume host mode + localhost Gitea) are NOT hijacked. Opt in with # `runs-on: docker` and reach Gitea via host.docker.internal. LABELS="docker:docker://$DOCKER_IMAGE,ubuntu-docker:docker://$DOCKER_IMAGE,self-hosted:host" else LABELS="ubuntu-latest:host,macos-latest:host,macos-15:host,self-hosted:host" fi # ── already registered? just (re)load the service ─────────────────────────── if [ -f "$RUNNER_FILE" ]; then echo " ✓ runner #$N already registered ($RUNNER_FILE) — reloading service" launchctl bootout "gui/$(id -u)/$SVC_LABEL" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl load "$PLIST" 2>/dev/null || true launchctl list | grep "$SVC_LABEL" || echo " (service not listed — check $LOG_DIR)" exit 0 fi # ── derive a per-runner config from the canonical one ─────────────────────── # Preserve the proxy/env block + env_file; override file path, capacity, workdir. python3 - "$CANONICAL_CONFIG" "$CONFIG" "$RUNNER_FILE" "$CAP" "$WORKDIR" "$SHARED_ENV_FILE" "$MODE" "$LABELS" <<'PY' import sys, yaml src, dst, runner_file, cap, workdir, env_file, mode, labels = sys.argv[1:9] cfg = yaml.safe_load(open(src)) or {} cfg.setdefault("runner", {}) cfg["runner"]["file"] = runner_file cfg["runner"]["capacity"] = int(cap) cfg["runner"]["env_file"] = env_file # act_runner reads labels from the config file (it ignores `register --labels`). cfg["runner"]["labels"] = labels.split(",") cfg.setdefault("host", {}) cfg["host"]["workdir_parent"] = workdir if mode == "docker": c = cfg.setdefault("container", {}) c["docker_host"] = "-" # auto-detect host docker daemon c["force_pull"] = False # use locally-pulled image (corp proxy) c["privileged"] = False # Let job containers reach the host's Gitea on Docker Desktop. c["options"] = "--add-host=host.docker.internal:host-gateway" envs = cfg["runner"].setdefault("envs", {}) # Drop host-specific envs that are invalid inside a Linux container — these # leak the macOS HOME/PATH/PNPM_HOME and override workflow env, breaking # $HOME-relative writes. Let the container use its image defaults (/root). for k in ("HOME", "PATH", "PNPM_HOME"): envs.pop(k, None) # Containerized jobs inherit the corp proxy env; without this they route # host.docker.internal:3300 through the proxy and get a 504. Bypass it. for k in ("NO_PROXY", "no_proxy", "NPM_CONFIG_NOPROXY"): v = envs.get(k, "") if "host.docker.internal" not in v: envs[k] = (v + "," if v else "") + "host.docker.internal" yaml.safe_dump(cfg, open(dst, "w"), default_flow_style=False, sort_keys=False) print(f" + wrote {dst}") PY # ── fetch a one-time registration token (admin API) ───────────────────────── REG_TOKEN=$(curl -fsS -H "Authorization: token $PAT" \ "$INSTANCE/api/v1/admin/runners/registration-token" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])") [ -n "$REG_TOKEN" ] || { echo "✗ could not fetch registration token" >&2; exit 1; } # ── register (labels come from config file; --labels is informational) ────── act_runner register \ --no-interactive \ --instance "$INSTANCE" \ --token "$REG_TOKEN" \ --name "$RUNNER_NAME" \ --labels "$LABELS" \ --config "$CONFIG" echo " ✓ registered as $RUNNER_NAME" # ── write launchd plist ───────────────────────────────────────────────────── RUNNER_BIN="$(command -v act_runner)" cat > "$PLIST" < Label$SVC_LABEL ProgramArguments $RUNNER_BIN daemon --config $CONFIG RunAtLoad KeepAlive WorkingDirectory$BASE StandardOutPath$LOG_DIR/act_runner.log StandardErrorPath$LOG_DIR/act_runner.err EOF echo " + wrote $PLIST" # ── load the service ──────────────────────────────────────────────────────── launchctl bootout "gui/$(id -u)/$SVC_LABEL" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl load "$PLIST" sleep 3 if launchctl list | grep -q "$SVC_LABEL"; then echo " ✓ service loaded ($SVC_LABEL)" else echo " ⚠ service not listed — check $LOG_DIR/act_runner.err" fi echo " log: $LOG_DIR/act_runner.log"