diff --git a/README.md b/README.md index c3ff72d..88c2627 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,45 @@ Control-plane box only. Installs Coolify at exactly the pinned version with the platform must never move underneath it on its own. Upgrading is an explicit re-run with a new pin. The pin is required; there is no default. +### `rig runner install --repo --version ` + +Workload box only, run after `rig bootstrap workload`: + +```sh +rig bootstrap workload --hostname my-ci-box --ts-tag tag:ci +rig runner install --repo acme/widgets --version 2.335.1 +``` + +Installs GitHub's official `actions/runner` as a systemd service under an +unprivileged user (default `github-runner`, created if absent, never root, no +supplementary groups). The runner is an agent, not a server: it long-polls +GitHub outbound and receives jobs down that already-established connection, +so it needs **zero inbound ports** and works fine behind a deny-all +firewall — it can even trigger deploys on hosts only it can reach, like a +tailnet-only control plane. + +No Docker, deliberately: the Docker socket is a root API and `docker` group +membership is root-equivalent, which is a gratuitous path to root on a box +whose whole point is a narrow blast radius. Add Docker only once a job +genuinely needs it, and rethink the isolation model then. + +- `--name ` — runner name (default: this host's hostname) +- `--labels ` — runner labels, replacing the `ci-runner` default — keep + any label your workflows' `runs-on` needs (GitHub adds `self-hosted` itself) +- `--user ` — the unprivileged service user (default: `github-runner`) + +**The registration token:** provide it via the `RUNNER_TOKEN` env var or type +it at the interactive prompt. It's short-lived, consumed at registration, and +never written to disk by rig. + +The version pin is required, same as `coolify install` — but unlike Coolify, +the installed runner **self-updates**: GitHub refuses jobs from stale +runners, so freezing the version would just make it silently stop taking +work. The pin states what you install today; GitHub owns the treadmill after +that. + +Convergent — safe to re-run; an already-registered runner is left alone. + ## What rig deliberately does NOT do - **Provider firewalls** — Docker publishes ports past host firewalls, so 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..115e436 --- /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 runner labels; replaces the default (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/docs/plans/2026-07-11-runner-install.md b/docs/plans/2026-07-11-runner-install.md new file mode 100644 index 0000000..a116c5f --- /dev/null +++ b/docs/plans/2026-07-11-runner-install.md @@ -0,0 +1,286 @@ +# 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 `, 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 --version [--name ] [--labels ] [--user ]`. + +**Command contract (`commands/runner-install.sh`):** + +Usage text (heredoc, shown on `--help` and usage errors): + +``` +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. +``` + +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 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 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_64` → `x64`, `aarch64` → `arm64`, 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): + ```bash + (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): + ```bash + 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 --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. +``` + +- [ ] **Step 1: Append failing tests** + +In `test/cli.sh`, insert before the `echo "---"` line: + +```bash +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** + +```bash +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 --version `. +- Workload-box command; run after `rig bootstrap workload`. Example block: + ```sh + 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** + +```bash +git add README.md +git commit -m "docs: README section for runner install" +``` + +--- + +## 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. 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 ]