From e463493bd115374147f8ddb06e07a2b8d0abe066 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Sat, 11 Jul 2026 17:41:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20runner=20install=20command=20=E2=80=94?= =?UTF-8?q?=20GitHub=20Actions=20runner=20as=20an=20unprivileged=20systemd?= =?UTF-8?q?=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- bin/rig | 14 ++++ commands/runner-install.sh | 157 +++++++++++++++++++++++++++++++++++++ test/cli.sh | 14 ++++ 3 files changed, 185 insertions(+) create mode 100755 commands/runner-install.sh diff --git a/bin/rig b/bin/rig index 286ab12..464b536 100755 --- a/bin/rig +++ b/bin/rig @@ -14,6 +14,10 @@ commands: (TS_AUTHKEY env overrides the prompt). Run as root. coolify install --version Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. + runner install --repo --version [options] + GitHub Actions runner as a systemd service under an unprivileged + user — outbound-only, no Docker. Prompts for the short-lived + registration token (RUNNER_TOKEN env overrides). Run as root. install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash @@ -36,6 +40,16 @@ case "$cmd" in shift exec "$ROOT/commands/coolify-install.sh" "$@" ;; + runner) + shift + sub="${1:-}" + if [ "$sub" != "install" ]; then + usage >&2 + exit 2 + fi + shift + exec "$ROOT/commands/runner-install.sh" "$@" + ;; -h|--help|help) usage exit 0 diff --git a/commands/runner-install.sh b/commands/runner-install.sh new file mode 100755 index 0000000..4756409 --- /dev/null +++ b/commands/runner-install.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# rig runner install — GitHub Actions self-hosted runner as a systemd service +# under an unprivileged user. Outbound-only (long-poll to GitHub), no Docker. +# Convergent: safe to re-run; an already-registered runner is left alone. +set -euo pipefail + +log() { printf 'rig-runner: %s\n' "$*"; } +warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig runner install --repo --version [options] + + --repo GitHub repository the runner registers to (required) + --version actions/runner release to install, e.g. 2.335.1 + (required; no default — you state what you install) + --name runner name (default: this host's hostname) + --labels extra runner labels (default: ci-runner) + --user unprivileged service user (default: github-runner; + created if absent; never root) + +Installs GitHub's official actions/runner as a systemd service under an +unprivileged user. The runner is an agent, not a server: it long-polls +GitHub outbound and needs ZERO inbound ports. No Docker is installed and +the runner user gets no supplementary groups. + +Provide the short-lived registration token via the RUNNER_TOKEN env var or +the interactive prompt (get one from the repo's Settings > Actions > +Runners > "New self-hosted runner", or: + gh api -X POST repos//actions/runners/registration-token). +It is consumed at registration and never written to disk by rig. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +REPO="" +VERSION="" +RUNNER_NAME="$(hostname)" +LABELS="ci-runner" +RUNNER_USER="github-runner" +while [ $# -gt 0 ]; do + case "$1" in + --repo) + [ $# -ge 2 ] || die "--repo needs a value" 2 + REPO="$2"; shift 2 ;; + --version) + [ $# -ge 2 ] || die "--version needs a value" 2 + VERSION="$2"; shift 2 ;; + --name) + [ $# -ge 2 ] || die "--name needs a value" 2 + RUNNER_NAME="$2"; shift 2 ;; + --labels) + [ $# -ge 2 ] || die "--labels needs a value" 2 + LABELS="$2"; shift 2 ;; + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- validation ---------------------------------------------------------- +[ -n "$REPO" ] || die "--repo is required" 2 +if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then + die "--repo must be owner/repo" 2 +fi +[ -n "$VERSION" ] || die "--version is required" 2 +VERSION="${VERSION#v}" +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" +if [ -r /etc/os-release ]; then + # shellcheck source=/dev/null + . /etc/os-release + case "${ID:-} ${ID_LIKE:-}" in + *debian*) ;; + *) warn "not a Debian-family system (ID=${ID:-unknown}); proceeding anyway" ;; + esac +else + warn "cannot read /etc/os-release; proceeding anyway" +fi +command -v curl >/dev/null || die "curl is required (run rig bootstrap first)" + +# --- registration token — only when registration is actually pending ------- +# Pending unless the runner user already exists AND $RUNNER_DIR/.runner +# exists (user absent => nothing can be registered => pending). +REG_PENDING=1 +if id -u "$RUNNER_USER" >/dev/null 2>&1; then + USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" + RUNNER_DIR="$USER_HOME/actions-runner" + if [ -e "$RUNNER_DIR/.runner" ]; then + REG_PENDING=0 + fi +fi +if [ "$REG_PENDING" -eq 1 ]; then + RUNNER_TOKEN="${RUNNER_TOKEN:-}" + if [ -z "$RUNNER_TOKEN" ]; then + read -rsp "runner registration token (short-lived): " RUNNER_TOKEN + echo + fi + [ -n "$RUNNER_TOKEN" ] || die "empty registration token" +fi + +# --- user -------------------------------------------------------------------- +if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash "$RUNNER_USER" + log "created user ${RUNNER_USER}" +else + log "user exists" +fi +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/actions-runner" + +# --- download + unpack ------------------------------------------------------ +if [ -e "$RUNNER_DIR/bin/Runner.Listener" ]; then + log "runner binary already present; skipping download (self-update owns upgrades)" +else + case "$(uname -m)" in + x86_64) ARCH="x64" ;; + aarch64) ARCH="arm64" ;; + *) die "unsupported arch: $(uname -m)" ;; + esac + URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz" + WORKDIR="$(mktemp -d)" + cleanup() { rm -rf "$WORKDIR"; } + trap cleanup EXIT + log "downloading actions/runner ${VERSION} (${ARCH})" + curl -fsSL "$URL" -o "$WORKDIR/runner.tar.gz" + mkdir -p "$RUNNER_DIR" + tar xzf "$WORKDIR/runner.tar.gz" -C "$RUNNER_DIR" + chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR" + log "installing runner native dependencies" + "$RUNNER_DIR"/bin/installdependencies.sh +fi + +# --- configure --------------------------------------------------------------- +if [ -e "$RUNNER_DIR/.runner" ]; then + log "already registered; skipping configure" +else + log "registering runner ${RUNNER_NAME} against ${REPO}" + (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ + ./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \ + --name "$RUNNER_NAME" --labels "$LABELS" --unattended --replace) +fi + +# --- service ------------------------------------------------------------- +if [ ! -e "$RUNNER_DIR/.service" ]; then + (cd "$RUNNER_DIR" && ./svc.sh install "$RUNNER_USER") +fi +(cd "$RUNNER_DIR" && ./svc.sh start) + +log "runner ${RUNNER_NAME} (labels: ${LABELS}) installed and running" +log "verify it shows Idle under the repo's Settings > Actions > Runners" +log "the deny-all provider firewall stays the operator's job outside rig — this box needs no inbound ports for the runner" diff --git a/test/cli.sh b/test/cli.sh index df81a0f..7298a3d 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -52,6 +52,20 @@ else echo "skip: coolify non-root refusal (running as root)" fi +check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner +check "runner: --help exits 0" 0 "usage:" "$ROOT/commands/runner-install.sh" --help +check "runner: repo required, exit 2" 2 "--repo" "$ROOT/commands/runner-install.sh" --version 2.335.1 +check "runner: version required, exit 2" 2 "--version" "$ROOT/commands/runner-install.sh" --repo acme/widgets +check "runner: repo needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo +check "runner: rejects bad repo slug" 2 "owner/repo" "$ROOT/commands/runner-install.sh" --repo not-a-slug --version 2.335.1 +check "runner: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 --user root +check "runner: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-install.sh" --nope +if [ "$(id -u)" -ne 0 ]; then + check "runner: refuses non-root" 1 "must run as root" env RUNNER_TOKEN=x "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 +else + echo "skip: runner non-root refusal (running as root)" +fi + echo "---" echo "$PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]