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.
2026-07-13 13:25:27 +00:00
|
|
|
#!/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
|
|
|
|
|
|
fix(runner): install refuses a box registered to another repo
`rig runner install --repo <B>` on a box already registered to repo A
treated the mere existence of .runner as "already registered", skipped
configure, restarted the service still pointed at A, and reported success.
--repo was accepted, validated, and then ignored — leaving B with zero
runners and its `runs-on` jobs queued against one that will never come.
This is the natural next command after a partial `repoint`, and the failure
is worse than a no-op: moving a runner between repos is a trust-boundary
act, so quietly putting it back on the old one defeats the point of the move.
Gate install on the repo .runner actually names. Convergence — the property
worth keeping — is untouched: re-running against the repo the box is already
on still skips registration, never prompts for a token, and exits 0.
Skipping when the repo *differs* was never convergence, only a silently
ignored argument, so it now fails and names both repos, pointing at
`runner repoint` (move) or `runner remove` (start over). An unreadable
.runner is refused too — it is no licence to assume a match.
The .runner reader that `status` and `repoint` each carried is lifted into
commands/lib/runner-config.sh, which now also holds the guard. Its json_field
no longer dies bare under `set -o pipefail` when a key is missing, which is
what `status`'s own ${REPO_URL:-unknown} fallback always assumed.
Tests: the guard is exercised against a fixture .runner (refuses another repo
naming both, points at repoint, no-ops on the same repo, passes an
unregistered box, refuses an unreadable one) plus an ordering assertion that
it precedes svc.sh start — reaching it through the CLI would need root and a
really-registered runner, which the dependency-free harness cannot fabricate.
All three mutants (guard deleted, guard comparing nothing, guard moved below
the service start) go red.
Closes #13
2026-07-13 14:57:28 +00:00
|
|
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
|
|
|
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
|
|
|
|
. "$HERE/lib/runner-config.sh"
|
|
|
|
|
|
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.
2026-07-13 13:25:27 +00:00
|
|
|
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 -------------------------------------------
|
fix(runner): install refuses a box registered to another repo
`rig runner install --repo <B>` on a box already registered to repo A
treated the mere existence of .runner as "already registered", skipped
configure, restarted the service still pointed at A, and reported success.
--repo was accepted, validated, and then ignored — leaving B with zero
runners and its `runs-on` jobs queued against one that will never come.
This is the natural next command after a partial `repoint`, and the failure
is worse than a no-op: moving a runner between repos is a trust-boundary
act, so quietly putting it back on the old one defeats the point of the move.
Gate install on the repo .runner actually names. Convergence — the property
worth keeping — is untouched: re-running against the repo the box is already
on still skips registration, never prompts for a token, and exits 0.
Skipping when the repo *differs* was never convergence, only a silently
ignored argument, so it now fails and names both repos, pointing at
`runner repoint` (move) or `runner remove` (start over). An unreadable
.runner is refused too — it is no licence to assume a match.
The .runner reader that `status` and `repoint` each carried is lifted into
commands/lib/runner-config.sh, which now also holds the guard. Its json_field
no longer dies bare under `set -o pipefail` when a key is missing, which is
what `status`'s own ${REPO_URL:-unknown} fallback always assumed.
Tests: the guard is exercised against a fixture .runner (refuses another repo
naming both, points at repoint, no-ops on the same repo, passes an
unregistered box, refuses an unreadable one) plus an ordering assertion that
it precedes svc.sh start — reaching it through the CLI would need root and a
really-registered runner, which the dependency-free harness cannot fabricate.
All three mutants (guard deleted, guard comparing nothing, guard moved below
the service start) go red.
Closes #13
2026-07-13 14:57:28 +00:00
|
|
|
REPO_URL="$(runner_repo_url "$RUNNER_DIR")"
|
|
|
|
|
RUNNER_NAME="$(runner_agent_name "$RUNNER_DIR")"
|
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.
2026-07-13 13:25:27 +00:00
|
|
|
|
|
|
|
|
# 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}"
|