feat(runner): status, remove, and repoint — the runner lifecycle verbs #11

Merged
dan-claude-bot merged 2 commits from feat/runner-lifecycle into main 2026-07-13 13:33:11 +00:00
7 changed files with 501 additions and 7 deletions

View file

@ -170,6 +170,84 @@ exists for when you want that starting point deterministic and auditable.
Convergent — safe to re-run; an already-registered runner is left alone.
### `rig runner status`
```sh
rig runner status
```
What this box's runner is registered to — repo, runner name, labels,
install dir, systemd unit and its state. Reads the runner's own on-disk
config; no token, no network call. Exits 1 when no runner is installed.
The answer to "wait, which repo is this box wired to?" should not require
knowing that the config lives in a dotfile under an unprivileged user's home.
### `rig runner remove`
```sh
rig runner remove
rig runner remove --local # no token; leaves a stale entry to delete by hand
```
Stops and uninstalls the systemd service, then deregisters the runner from
GitHub. The binary and its user stay put, so a later `rig runner install`
re-registers without downloading anything.
**The token here is a *removal* token, not a registration token** — a
different endpoint, and mixing them up is the easy mistake:
```sh
gh api -X POST repos/<owner/repo>/actions/runners/remove-token
```
Supply it via `RUNNER_REMOVE_TOKEN` or the prompt; it never touches disk.
`--local` is the escape hatch for when the registration is already gone
server-side (or you can't mint a token): the box is cleaned, but a stale
offline runner stays listed in the repo, for you to delete from
Settings → Actions → Runners.
The service always comes down *first*, in both paths. GitHub's own removal
refuses to run while the service is installed ("Uninstall service first"),
and `--local` skips that check entirely — which would otherwise leave a
running service pointed at config that no longer exists.
Convergent — a box with no runner installed exits 0.
### `rig runner repoint --repo <owner/repo>`
```sh
rig runner repoint --repo acme/widgets
```
Moves an installed runner from one repository to another: deregister,
re-register, reusing the binary already on the box. It keeps the runner's
existing name unless you pass `--name`.
This is the verb that was missing. `runner install` is convergent *by
skipping* — it sees a registered runner and leaves it alone — so it can
create a runner but never move one, and re-pointing a box meant hand-rolled
`config.sh`/`svc.sh` incantations against an install path only rig knew.
Two short-lived tokens, each minted from **its own** repo — `RUNNER_REMOVE_TOKEN`
for the one it's leaving, `RUNNER_TOKEN` for the one it's joining. Both are
collected **before** anything is torn down: a token you turn out not to have
should fail while the runner is still registered and working, not halfway
through the move. If re-registration fails anyway, rig says so plainly and
prints the exact `runner install` line that finishes the job.
> **Labels do not survive a move on their own.** GitHub holds them; the runner
> does not persist them locally. rig now records what it registered with, so
> `repoint` and `status` can read it back — but a runner installed before rig
> did that has nothing to read, and `repoint` falls back to the `ci-runner`
> default and warns loudly before it touches anything. Labels are what
> `runs-on` matches, so a silent change there is a workflow that simply stops
> finding its runner. Pass `--labels` if yours differ.
Convergent — repointing to the repo it is already on changes nothing, exits 0,
and never asks for a token.
## What rig deliberately does NOT do
- **Provider firewalls** — Docker publishes ports past host firewalls, so

38
bin/rig
View file

@ -20,10 +20,22 @@ commands:
systemd timer. rig installs the machinery and templates an empty
0600 bindings file; you fill in the age recipient and S3 details.
Control-plane box only. Run as root.
runner install --repo <owner/repo> --version <pin> [options]
runner install --repo <owner/repo> [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.
runner status [--user <name>]
What this box's runner is registered to: repo, name, labels, unit.
Reads the box only — no token, no network call. Run as root.
runner remove [--local] [--user <name>]
Take the service down and deregister the runner. Prompts for the
short-lived removal token (RUNNER_REMOVE_TOKEN env overrides).
Run as root.
runner repoint --repo <owner/repo> [options]
Move an installed runner to another repository — deregister, then
re-register, reusing the binary already on the box. Needs a removal
token for the old repo and a registration token for the new one.
Run as root.
install/upgrade:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
@ -62,13 +74,29 @@ case "$cmd" in
runner)
shift
sub="${1:-}"
if [ "$sub" != "install" ]; then
usage >&2
exit 2
fi
case "$sub" in
install)
shift
exec "$ROOT/commands/runner-install.sh" "$@"
;;
status)
shift
exec "$ROOT/commands/runner-status.sh" "$@"
;;
remove)
shift
exec "$ROOT/commands/runner-remove.sh" "$@"
;;
repoint)
shift
exec "$ROOT/commands/runner-repoint.sh" "$@"
;;
*)
usage >&2
exit 2
;;
esac
;;
-h|--help|help)
usage
exit 0

View file

@ -162,6 +162,11 @@ else
(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)
# GitHub owns the labels and the runner does not persist them locally, so
# `runner status` and `runner repoint` would have nothing to read. Record
# what we registered with — box-local metadata, never a credential.
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR/.rig-labels"
fi
# --- service -------------------------------------------------------------

108
commands/runner-remove.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# rig runner remove — take the service down and deregister the runner.
# Convergent: a box with nothing installed exits 0.
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 remove [options]
--local wipe this box's registration without contacting GitHub
(no token needed)
--user <name> unprivileged service user (default: github-runner)
Stops and uninstalls the systemd service, then deregisters the runner from
GitHub. The runner binary and its user stay on the box, so a later
`rig runner install` re-registers without downloading anything.
Provide the short-lived REMOVAL token — not a registration token, they are
different endpoints — via the RUNNER_REMOVE_TOKEN env var or the interactive
prompt:
gh api -X POST repos/<owner/repo>/actions/runners/remove-token
It is consumed at deregistration and never written to disk by rig.
--local is the escape hatch for when the registration is already gone
server-side, or you cannot mint a token: the box is cleaned, but a stale
offline runner is left listed in the repo — delete it by hand from
Settings > Actions > Runners.
Convergent: safe to re-run; a box with no runner installed exits 0.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
LOCAL=0
RUNNER_USER="github-runner"
while [ $# -gt 0 ]; do
case "$1" in
--local) LOCAL=1; shift ;;
--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 ------------------------------------------------------------
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
# --- nothing to remove? -----------------------------------------------------
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
log "no ${RUNNER_USER} user on this box; nothing to remove"
exit 0
fi
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/actions-runner"
if [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$RUNNER_DIR/.service" ]; then
log "no runner registered in ${RUNNER_DIR}; nothing to remove"
exit 0
fi
# --- removal token — only when a server-side deregistration is pending -------
REMOVE_TOKEN=""
if [ -e "$RUNNER_DIR/.runner" ] && [ "$LOCAL" -eq 0 ]; then
REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}"
if [ -z "$REMOVE_TOKEN" ]; then
read -rsp "runner removal token (short-lived): " REMOVE_TOKEN
echo
fi
[ -n "$REMOVE_TOKEN" ] || die "empty removal token"
fi
# --- service ---------------------------------------------------------------
# This must come first in BOTH paths. config.sh's removal throws "Uninstall
# service first" while the service is configured; and `remove --local` skips
# that check entirely, which would otherwise strand a running service pointed
# at config that no longer exists.
if [ -e "$RUNNER_DIR/.service" ]; then
log "stopping and uninstalling the service"
(cd "$RUNNER_DIR" && ./svc.sh stop)
(cd "$RUNNER_DIR" && ./svc.sh uninstall)
else
log "no service installed; skipping"
fi
# --- deregister -------------------------------------------------------------
if [ -e "$RUNNER_DIR/.runner" ]; then
if [ "$LOCAL" -eq 1 ]; then
log "wiping the local registration only (--local)"
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
./config.sh remove --local)
warn "a stale offline runner is still listed in the repo — delete it from Settings > Actions > Runners"
else
log "deregistering from GitHub"
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
./config.sh remove --token "$REMOVE_TOKEN")
fi
rm -f "$RUNNER_DIR/.rig-labels"
fi
log "runner removed; the binary stays at ${RUNNER_DIR} for a future rig runner install"

162
commands/runner-repoint.sh Executable file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# rig runner repoint — move an installed runner from one repository to another.
# Deregister, then re-register against the new repo, reusing the binary that is
# already on the box.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
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 repoint --repo <owner/repo> [options]
--repo <owner/repo> the repository to move the runner TO (required)
--name <name> runner name (default: keep the name it has now)
--labels <csv> runner labels (default: the labels rig recorded at
install; else ci-runner)
--user <name> unprivileged service user (default: github-runner)
--local skip the server-side deregistration of the OLD repo
(no removal token needed) — leaves a stale offline
runner listed there, to delete by hand
Deregisters the runner from the repository it is on now and registers it
against --repo. The runner binary, its user, and its systemd service are
reused, so nothing is downloaded.
Two short-lived tokens, each minted from its OWN repository:
RUNNER_REMOVE_TOKEN removal token, from the CURRENT repo (not needed
with --local)
gh api -X POST repos/<current>/actions/runners/remove-token
RUNNER_TOKEN registration token, from the repo in --repo
gh api -X POST repos/<new>/actions/runners/registration-token
Either may be typed at the prompt instead. Both are collected BEFORE the
runner is touched — a token you turn out not to have should fail while the
runner is still registered, not halfway through the move. Neither is written
to disk by rig.
LABELS ARE NOT RECOVERABLE FROM THE BOX: GitHub holds them and the runner
does not persist them. rig records what it registered with, but a runner
installed before rig did that has nothing to read — repoint then falls back
to the ci-runner default and says so. Check your workflows' runs-on.
Convergent: repointing to the repo it is already on changes nothing, exits 0,
and never asks for a token.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
REPO=""
RUNNER_NAME=""
LABELS=""
RUNNER_USER="github-runner"
LOCAL=0
while [ $# -gt 0 ]; do
case "$1" in
--repo)
[ $# -ge 2 ] || die "--repo needs a value" 2
REPO="$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 ;;
--local) LOCAL=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- validation ------------------------------------------------------------
[ -n "$REPO" ] || die "--repo <owner/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
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
id -u "$RUNNER_USER" >/dev/null 2>&1 \
|| die "no runner installed (no ${RUNNER_USER} user) — use: rig runner install"
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/actions-runner"
[ -e "$RUNNER_DIR/.runner" ] \
|| die "no runner registered in ${RUNNER_DIR} — use: rig runner install"
# --- what is it registered to now? ------------------------------------------
json_field() {
grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" \
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//'
}
CURRENT_URL="$(json_field "$RUNNER_DIR/.runner" gitHubUrl)"
TARGET_URL="https://github.com/${REPO}"
if [ "$CURRENT_URL" = "$TARGET_URL" ]; then
log "already registered to ${REPO}; nothing to do"
exit 0
fi
# Keep the runner's identity across the move unless told otherwise.
if [ -z "$RUNNER_NAME" ]; then
RUNNER_NAME="$(json_field "$RUNNER_DIR/.runner" agentName)"
[ -n "$RUNNER_NAME" ] || die "could not read the current runner name from ${RUNNER_DIR}/.runner"
fi
if [ -z "$LABELS" ]; then
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
LABELS="$(cat "$RUNNER_DIR/.rig-labels")"
else
LABELS="ci-runner"
warn "this box has no rig label record (installed before rig kept one)"
warn "re-registering with the default labels: ${LABELS}"
warn "if your workflows' runs-on expects anything else, ctrl-c and pass --labels"
fi
fi
log "moving runner ${RUNNER_NAME} from ${CURRENT_URL} to ${TARGET_URL}"
log "labels: ${LABELS}"
# --- tokens, both up front --------------------------------------------------
# Collected before anything is torn down: a missing or expired token must fail
# while the runner is still registered and working.
if [ "$LOCAL" -eq 0 ]; then
RUNNER_REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}"
if [ -z "$RUNNER_REMOVE_TOKEN" ]; then
read -rsp "removal token for ${CURRENT_URL} (short-lived): " RUNNER_REMOVE_TOKEN
echo
fi
[ -n "$RUNNER_REMOVE_TOKEN" ] || die "empty removal token"
export RUNNER_REMOVE_TOKEN
fi
RUNNER_TOKEN="${RUNNER_TOKEN:-}"
if [ -z "$RUNNER_TOKEN" ]; then
read -rsp "registration token for ${TARGET_URL} (short-lived): " RUNNER_TOKEN
echo
fi
[ -n "$RUNNER_TOKEN" ] || die "empty registration token"
export RUNNER_TOKEN
# --- move -------------------------------------------------------------------
REMOVE_ARGS=(--user "$RUNNER_USER")
[ "$LOCAL" -eq 1 ] && REMOVE_ARGS+=(--local)
"$HERE/runner-remove.sh" "${REMOVE_ARGS[@]}"
if ! "$HERE/runner-install.sh" \
--repo "$REPO" --name "$RUNNER_NAME" --labels "$LABELS" --user "$RUNNER_USER"
then
warn "the runner is now deregistered from ${CURRENT_URL} and NOT registered anywhere"
die "re-registration failed — fix the cause, then run: rig runner install --repo ${REPO} --name ${RUNNER_NAME} --labels ${LABELS} --user ${RUNNER_USER}"
fi
log "repointed to ${TARGET_URL}"
log "verify it shows Idle under that repo's Settings > Actions > Runners, and gone from the old one"

81
commands/runner-status.sh Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# rig runner status — what is this box's runner registered to?
# Read-only: reports what is already on the box. No credential, no network call.
set -euo pipefail
log() { printf 'rig-runner: %s\n' "$*"; }
die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig runner status [--user <name>]
--user <name> unprivileged service user (default: github-runner)
Prints the repository this box's runner is registered to, its runner name,
the labels rig recorded when it registered, the install directory, and the
systemd unit and its state.
Reads only the runner's own on-disk config — no GitHub token, no network
call. Exits 1 when no runner is installed.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
RUNNER_USER="github-runner"
while [ $# -gt 0 ]; do
case "$1" in
--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 ------------------------------------------------------------
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
# --- guards ----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root"
id -u "$RUNNER_USER" >/dev/null 2>&1 \
|| die "no runner installed (no ${RUNNER_USER} user on this box)"
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/actions-runner"
[ -e "$RUNNER_DIR/.runner" ] \
|| die "no runner registered in ${RUNNER_DIR}"
# --- read the runner's own config -------------------------------------------
# .runner is JSON. Kept dependency-free on purpose: a rig-bootstrapped box has
# no jq, and installing one to read five fields would be a poor trade.
json_field() {
grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" \
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//'
}
REPO_URL="$(json_field "$RUNNER_DIR/.runner" gitHubUrl)"
RUNNER_NAME="$(json_field "$RUNNER_DIR/.runner" agentName)"
# GitHub owns the labels; the runner does not persist them locally. rig records
# what it registered with, so a box installed before this existed reports the
# honest answer rather than a guess.
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
LABELS="$(cat "$RUNNER_DIR/.rig-labels")"
else
LABELS="(not recorded on this box — GitHub holds them; see the repo's Settings > Actions > Runners)"
fi
if [ -r "$RUNNER_DIR/.service" ]; then
UNIT="$(cat "$RUNNER_DIR/.service")"
STATE="$(systemctl is-active "$UNIT" 2>/dev/null || true)"
SERVICE="${UNIT} (${STATE:-unknown})"
else
SERVICE="(not installed as a service)"
fi
log "repo: ${REPO_URL:-unknown}"
log "name: ${RUNNER_NAME:-unknown}"
log "labels: ${LABELS}"
log "dir: ${RUNNER_DIR}"
log "service: ${SERVICE}"

View file

@ -80,6 +80,38 @@ else
echo "skip: runner non-root refusal (running as root)"
fi
check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate
check "runner status: --help exits 0" 0 "usage:" "$ROOT/commands/runner-status.sh" --help
check "runner status: user needs value" 2 "needs a value" "$ROOT/commands/runner-status.sh" --user
check "runner status: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-status.sh" --user root
check "runner status: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-status.sh" --nope
check "runner remove: --help exits 0" 0 "usage:" "$ROOT/commands/runner-remove.sh" --help
check "runner remove: user needs value" 2 "needs a value" "$ROOT/commands/runner-remove.sh" --user
check "runner remove: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-remove.sh" --user root
check "runner remove: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-remove.sh" --nope
check "runner repoint: --help exits 0" 0 "usage:" "$ROOT/commands/runner-repoint.sh" --help
check "runner repoint: repo required" 2 "--repo" "$ROOT/commands/runner-repoint.sh"
check "runner repoint: repo needs value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo
check "runner repoint: rejects bad slug" 2 "owner/repo" "$ROOT/commands/runner-repoint.sh" --repo not-a-slug
check "runner repoint: labels need value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --labels
check "runner repoint: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --user root
check "runner repoint: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-repoint.sh" --nope
if [ "$(id -u)" -ne 0 ]; then
check "runner status: refuses non-root" 1 "must run as root" "$ROOT/commands/runner-status.sh"
check "runner remove: refuses non-root" 1 "must run as root" \
env RUNNER_REMOVE_TOKEN=x "$ROOT/commands/runner-remove.sh"
# --local too: the token-free path must still not be runnable by the runner user.
check "runner remove: --local refuses non-root" 1 "must run as root" \
"$ROOT/commands/runner-remove.sh" --local
check "runner repoint: refuses non-root" 1 "must run as root" \
env RUNNER_REMOVE_TOKEN=x RUNNER_TOKEN=y "$ROOT/commands/runner-repoint.sh" --repo acme/widgets
else
echo "skip: runner status/remove/repoint non-root refusals (running as root)"
fi
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
# error in it would be invisible here and would first surface at 04:00 on a live
# control plane. Extract it and syntax-check what actually gets written.