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

runner install is convergent by skipping: it sees a registered runner and
leaves it alone. So rig could create a runner and never move or destroy one,
and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh
incantations against an install layout only rig knew about.

- status: repo, name, labels, dir, unit — read-only, no token, no network.
- remove: service down, then deregister. --local wipes the box without
  contacting GitHub, leaving a stale entry to delete by hand.
- repoint: remove + re-register in one act, keeping the runner's name and
  reusing the binary already on the box.

The service always comes down before deregistration in both paths: GitHub's
removal throws "Uninstall service first" while the service is configured, and
--local bypasses that check entirely, which would strand a running service
pointed at deleted config.

repoint collects both tokens up front — a token you turn out not to have must
fail while the runner is still registered, not halfway through the move.

Labels are the sharp edge: GitHub holds them, the runner does not persist
them, and they are what runs-on matches. install now records what it
registered with so repoint and status can read it back; a runner installed
before that has nothing to read, so repoint falls back to the ci-runner
default and warns before it touches anything.
This commit is contained in:
claude-hdb 2026-07-13 13:25:27 +00:00
parent 8692652c0f
commit fcee110183
6 changed files with 423 additions and 7 deletions

42
bin/rig
View file

@ -20,10 +20,22 @@ commands:
systemd timer. rig installs the machinery and templates an empty systemd timer. rig installs the machinery and templates an empty
0600 bindings file; you fill in the age recipient and S3 details. 0600 bindings file; you fill in the age recipient and S3 details.
Control-plane box only. Run as root. 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 GitHub Actions runner as a systemd service under an unprivileged
user — outbound-only, no Docker. Prompts for the short-lived user — outbound-only, no Docker. Prompts for the short-lived
registration token (RUNNER_TOKEN env overrides). Run as root. 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: install/upgrade:
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
@ -62,12 +74,28 @@ case "$cmd" in
runner) runner)
shift shift
sub="${1:-}" sub="${1:-}"
if [ "$sub" != "install" ]; then case "$sub" in
usage >&2 install)
exit 2 shift
fi exec "$ROOT/commands/runner-install.sh" "$@"
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) -h|--help|help)
usage usage

View file

@ -162,6 +162,11 @@ else
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \ ./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" --labels "$LABELS" --unattended --replace) --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 fi
# --- service ------------------------------------------------------------- # --- 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)" echo "skip: runner non-root refusal (running as root)"
fi 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 # 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 # 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. # control plane. Extract it and syntax-check what actually gets written.