rig/docs/plans/2026-07-11-runner-install.md
2026-07-11 18:44:43 +00:00

16 KiB

rig runner install Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a rig runner install command that turns a bootstrapped workload box into a self-hosted GitHub Actions runner — the official actions/runner agent as a systemd service under an unprivileged user, with no Docker and zero inbound ports.

Why this shape: the runner is an agent, not a server — it opens an HTTPS long-poll out to GitHub and receives jobs down that already-established connection, so a box behind a deny-all firewall (or on a tailnet) can run CI jobs that reach hosts GitHub never could. Docker is deliberately refused: docker group membership is root-equivalent (the socket is a root API), and on a box whose design goal is a narrow blast radius for repo-controlled code that is a gratuitous path to root. Docker arrives only if/when a job genuinely needs it, and the isolation model gets revisited then (--ephemeral), not assumed.

Architecture: one new command script commands/runner-install.sh dispatched by bin/rig (mirroring coolify install), test cases appended to test/cli.sh, README section. No new dependencies — bash + the tools a rig bootstrap-ed Debian box already has (curl, tar, useradd, runuser, systemd). CI (shellcheck + cli tests) already globs commands/*.sh, so it covers the new script with no workflow change.

Tech Stack: bash only, shellcheck, GitHub Actions (existing ci.yml).

Global Constraints

  • Every script starts #!/usr/bin/env bash + set -euo pipefail.
  • shellcheck-clean at default severity: shellcheck install.sh bin/rig commands/*.sh test/cli.sh exits 0.
  • Exit codes: 2 = usage/argument error, 1 = runtime refusal (e.g. not root). All argument validation runs BEFORE the root check so error paths are testable as non-root.
  • No credential ever touches disk via rig. The registration token is read from the RUNNER_TOKEN env var or an interactive read -rsp prompt, handed to config.sh in memory, and never logged. (The runner itself persists its own derived credential under its install dir, owned by the runner user — that is the runner's design, not rig's doing.)
  • The runner user is never root and gets no supplementary groups — no docker, no sudo. rig must not install Docker here, ever.
  • Nothing org-specific in this repo — README/usage examples use generic names (acme/widgets); no real hostnames, tags beyond generic examples, or fleet details.
  • Convergent: a second run of the same command changes nothing and exits 0.
  • Conventional Commits (type: subject; no scope enum in this repo).
  • Version pin is required (--version <pin>, no default), matching coolify install. Divergence from Coolify's AUTOUPDATE=false posture: the installed runner self-updates (GitHub refuses jobs from stale runners — a runner that never updates silently stops working). The pin states what you install today; GitHub owns the treadmill after that. This difference is deliberate and gets a README sentence.

Task 1: runner install command + dispatcher wiring + tests

Files:

  • Create: commands/runner-install.sh
  • Modify: bin/rig (dispatch runner install, extend usage text)
  • Modify: test/cli.sh (append cases before the echo "---" line)

Interfaces:

  • Consumes: dispatcher passes post-runner install args verbatim; harness check function.
  • Produces: commands/runner-install.sh taking --repo <owner/repo> --version <pin> [--name <name>] [--labels <csv>] [--user <user>].

Command contract (commands/runner-install.sh):

Usage text (heredoc, shown on --help and usage errors):

usage: rig runner install --repo <owner/repo> --version <pin> [options]

  --repo <owner/repo>   GitHub repository the runner registers to (required)
  --version <pin>       actions/runner release to install, e.g. 2.335.1
                        (required; no default — you state what you install)
  --name <name>         runner name (default: this host's hostname)
  --labels <csv>        extra runner labels (default: ci-runner)
  --user <name>         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/<owner/repo>/actions/runners/registration-token).
It is consumed at registration and never written to disk by rig.

Behavior, in order:

  1. Arg parsing (before the root check). -h|--help → usage, exit 0. Unknown flag → die "unknown flag: $1" 2. Flags needing values use the existing [ $# -ge 2 ] || die "--x needs a value" 2 pattern.
  2. Validation (still before the root check):
    • --repo required (die "--repo <owner/repo> is required" 2); must match ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ else die "--repo must be owner/repo" 2.
    • --version required (die "--version <pin> is required" 2); normalize a leading v away (VERSION="${VERSION#v}").
    • --user must not be root: die "runner user must not be root" 2.
  3. Guards: root check (must run as root, exit 1) and the same Debian-family warn block bootstrap.sh uses. Also command -v curl >/dev/null || die "curl is required (run rig bootstrap first)".
  4. Registration token — only when registration is actually pending. Registration is pending unless the runner user already exists AND $RUNNER_DIR/.runner exists (user absent ⇒ nothing can be registered ⇒ pending): if the user exists, derive USER_HOME/RUNNER_DIR as in step 5 and test -e "$RUNNER_DIR/.runner". When NOT pending, skip token acquisition entirely — a converged second run must never demand a fresh short-lived token the operator no longer holds, and must exit 0. When pending: RUNNER_TOKEN from env, else read -rsp "runner registration token (short-lived): " RUNNER_TOKEN; echo. Empty → die "empty registration token".
  5. User: if id -u "$RUNNER_USER" fails, useradd --create-home --shell /bin/bash "$RUNNER_USER" and log; else log "user exists". Derive USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"; RUNNER_DIR="$USER_HOME/actions-runner".
  6. Download + unpack (skip whole step, with a log line, when $RUNNER_DIR/bin/Runner.Listener already exists — upgrades are the runner's own self-update, not a rig re-run):
    • Arch map: x86_64x64, aarch64arm64, else die "unsupported arch: $(uname -m)".
    • URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz".
    • Download to a mktemp -d workspace (with trap cleanup EXIT), extract into $RUNNER_DIR (mkdir -p first), chown -R "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR".
    • No checksum flag: the transport is TLS to github.com and the runner self-updates from the same origin forever after — a one-time hash adds ritual, not trust. (Stated here so it reads as a decision, not an omission.)
    • Run "$RUNNER_DIR"/bin/installdependencies.sh as root (idempotent apt installs of the runner's native deps).
  7. Configure (skip, with a log line, when $RUNNER_DIR/.runner exists — already registered):
    (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)
    
    (config.sh refuses to run as root, hence runuser; --replace makes re-registration after a box rebuild convergent; --unattended because rig already collected everything interactive.)
  8. Service via the runner's own svc.sh (must run as root, and svc.sh resolves paths relative to cwd — keep the literal cd wrapper):
    if [ ! -e "$RUNNER_DIR/.service" ]; then
      (cd "$RUNNER_DIR" && ./svc.sh install "$RUNNER_USER")
    fi
    (cd "$RUNNER_DIR" && ./svc.sh start)   # idempotent
    
  9. Final logs: runner name + labels; "verify it shows Idle under the repo's Settings > Actions > Runners"; remind that the deny-all provider firewall stays the operator's job outside rig (existing README stance) and that the box needs no inbound ports for this.

Log prefix: rig-runner: (matching rig-bootstrap: / rig-coolify:), same log/warn/die helpers as bootstrap.sh.

Dispatcher (bin/rig): add a runner) case exactly mirroring coolify) (sub must be install, else usage + exit 2), and add to the usage heredoc:

  runner install --repo <owner/repo> --version <pin> [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.
  • Step 1: Append failing tests

In test/cli.sh, insert before the echo "---" line:

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
  • Step 2: Run tests to verify the new ones fail

Run: bash test/cli.sh Expected: the 16 existing checks pass; the bare runner check is already green pre-implementation (the dispatcher's unknown-command branch prints the usage text and exits 2, which happens to satisfy it); every runner: check FAILS (file not found → exit 127); harness exits 1.

  • Step 3: Implement commands/runner-install.sh and wire bin/rig

Per the command contract above. chmod +x commands/runner-install.sh.

  • Step 4: Run tests to verify they pass

Run: bash test/cli.sh Expected: 25 passed, 0 failed (or 22 + 3 skip lines as root), exit 0.

  • Step 5: shellcheck

Run: shellcheck install.sh bin/rig commands/*.sh test/cli.sh Expected: exit 0, no output.

  • Step 6: Commit
git add commands/runner-install.sh bin/rig test/cli.sh
git commit -m "feat: runner install command — GitHub Actions runner as an unprivileged systemd service"

Task 2: README section

Files:

  • Modify: README.md (new ### rig runner install subsection under Commands, after coolify install)

Interfaces:

  • Consumes: the Task 1 command surface, verbatim.

  • Produces: operator-facing doc matching the README's existing voice.

  • Step 1: Write the section

Content requirements (write in the README's existing voice, don't paste this list):

  • Heading: ### rig runner install --repo <owner/repo> --version <pin>.

  • Workload-box command; run after rig bootstrap workload. Example block:

    rig bootstrap workload --hostname my-ci-box --ts-tag tag:ci
    rig runner install --repo acme/widgets --version 2.335.1
    
  • The agent-not-server point: the runner long-polls GitHub outbound and receives jobs down that connection — zero inbound ports, so it works behind a deny-all firewall and can trigger deploys on hosts only it can reach (e.g. a tailnet-only control plane).

  • The posture: official actions/runner as a systemd service under an unprivileged user (default github-runner, created if absent, never root, no supplementary groups); no Docker, deliberately — the docker socket is a root API and docker group membership is root-equivalent; add Docker only when a job truly needs it and rethink isolation then.

  • Flags: --name (default: hostname), --labels (default: ci-runner; GitHub adds self-hosted itself), --user.

  • Token handling: RUNNER_TOKEN env or interactive prompt; short-lived; never written to disk by rig.

  • The version-pin note: pin is required like coolify install, but unlike Coolify the runner then self-updates — GitHub refuses stale runners, so freezing it means it silently stops taking jobs. Deliberate divergence.

  • Convergent: safe to re-run; an already-registered runner is left alone.

  • Step 2: Full local gate

Run: shellcheck install.sh bin/rig commands/*.sh test/cli.sh && bash test/cli.sh Expected: shellcheck silent; 25 passed, 0 failed, exit 0.

  • Step 3: Commit
git add README.md
git commit -m "docs: README section for runner install"

Addendum (2026-07-11, operator-requested, post final review)

A third bootstrap role, runner, joins control-plane|workload — requested for CLI consistency (each follow-up command applies to exactly one role) and because it closes a real footgun mechanically: the role defaults --ts-tag to tag:ci and refuses tag:server (exit 2, validated before the root check). Forgetting the tag flag previously joined the CI box with the default server tag — the exact misconfiguration the runner posture exists to prevent. Everything else about bootstrap is unchanged; runner install's contract is untouched. Tests: +2 (runner refuses tag:server, runner role parses / refuses non-root) → 27 non-root.

Second amendment (same day): runner install --version becomes optional — omitted, rig resolves the latest release at install time by following the releases/latest redirect (no API, no rate limit, no JSON parsing; validated against a digits-and-dots pattern before use). Safe here, and only here, because the runner self-updates regardless of what you install; coolify install keeps its mandatory pin — Coolify never self-updates, so its version is a verified contract, not a starting point. The version required test is replaced by a --version needs a value test → still 27 non-root.

Integration (orchestrator, after final review — not an SDD task)

  1. Push the branch to the fork and open the PR against upstream: git push -u fork feat/runner-install, then gh pr create --repo heavy-duty/rig --head claude-hdb:feat/runner-install ... with a body summarizing the posture (outbound-only agent, unprivileged user, no Docker, token never on disk) and noting that the end-to-end functional test is the box rehearsal (README Testing section) — the CLI tests cover argument/refusal paths only.
  2. Run the record skill in the consuming project's brain.