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.shexits 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_TOKENenv var or an interactiveread -rspprompt, handed toconfig.shin 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, nosudo. 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), matchingcoolify install. Divergence from Coolify'sAUTOUPDATE=falseposture: 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(dispatchrunner install, extend usage text) - Modify:
test/cli.sh(append cases before theecho "---"line)
Interfaces:
- Consumes: dispatcher passes post-
runner installargs verbatim; harnesscheckfunction. - Produces:
commands/runner-install.shtaking--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:
- 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" 2pattern. - Validation (still before the root check):
--reporequired (die "--repo <owner/repo> is required" 2); must match^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$elsedie "--repo must be owner/repo" 2.--versionrequired (die "--version <pin> is required" 2); normalize a leadingvaway (VERSION="${VERSION#v}").--usermust not beroot:die "runner user must not be root" 2.
- Guards: root check (
must run as root, exit 1) and the same Debian-family warn blockbootstrap.shuses. Alsocommand -v curl >/dev/null || die "curl is required (run rig bootstrap first)". - Registration token — only when registration is actually pending.
Registration is pending unless the runner user already exists AND
$RUNNER_DIR/.runnerexists (user absent ⇒ nothing can be registered ⇒ pending): if the user exists, deriveUSER_HOME/RUNNER_DIRas 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_TOKENfrom env, elseread -rsp "runner registration token (short-lived): " RUNNER_TOKEN; echo. Empty →die "empty registration token". - User: if
id -u "$RUNNER_USER"fails,useradd --create-home --shell /bin/bash "$RUNNER_USER"and log; else log "user exists". DeriveUSER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)";RUNNER_DIR="$USER_HOME/actions-runner". - Download + unpack (skip whole step, with a log line, when
$RUNNER_DIR/bin/Runner.Listeneralready exists — upgrades are the runner's own self-update, not a rig re-run):- Arch map:
x86_64→x64,aarch64→arm64, elsedie "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 -dworkspace (withtrap cleanup EXIT), extract into$RUNNER_DIR(mkdir -pfirst),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.shas root (idempotent apt installs of the runner's native deps).
- Arch map:
- Configure (skip, with a log line, when
$RUNNER_DIR/.runnerexists — 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.shrefuses to run as root, hencerunuser;--replacemakes re-registration after a box rebuild convergent;--unattendedbecause rig already collected everything interactive.) - Service via the runner's own
svc.sh(must run as root, and svc.sh resolves paths relative to cwd — keep the literalcdwrapper):if [ ! -e "$RUNNER_DIR/.service" ]; then (cd "$RUNNER_DIR" && ./svc.sh install "$RUNNER_USER") fi (cd "$RUNNER_DIR" && ./svc.sh start) # idempotent - 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.shand wirebin/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 installsubsection under Commands, aftercoolify 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/runneras a systemd service under an unprivileged user (defaultgithub-runner, created if absent, never root, no supplementary groups); no Docker, deliberately — the docker socket is a root API anddockergroup 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 addsself-hosteditself),--user. -
Token handling:
RUNNER_TOKENenv 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)
- Push the branch to the fork and open the PR against upstream:
git push -u fork feat/runner-install, thengh 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. - Run the
recordskill in the consuming project's brain.