2026-07-11 17:41:09 +00:00
#!/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.
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
# Convergent toward --repo: re-running against the repo the box is already on
# leaves it alone; a box registered to a DIFFERENT repo is refused, never
# silently restarted on the old one (that is `repoint`'s job).
2026-07-11 17:41:09 +00:00
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 "
2026-07-11 17:41:09 +00:00
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'
2026-07-11 18:44:43 +00:00
usage: rig runner install --repo <owner/repo> [ options]
2026-07-11 17:41:09 +00:00
--repo <owner/repo> GitHub repository the runner registers to ( required)
--version <pin> actions/runner release to install, e.g. 2.335.1
2026-07-11 18:44:43 +00:00
( default: the latest release, resolved at install
time — safe here because the runner self-updates
regardless; pin it when you need a deterministic,
auditable install)
2026-07-11 17:41:09 +00:00
--name <name> runner name ( default: this host' s hostname)
2026-07-11 17:51:08 +00:00
--labels <csv> runner labels; replaces the default ( default: ci-runner)
2026-07-11 17:41:09 +00:00
--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.
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
Convergent toward --repo: re-running against the repo this box is already on
re-uses the binary, skips registration, and never asks for a token. A box
registered to a DIFFERENT repo is refused — moving a runner is
` rig runner repoint --repo <owner/repo>` .
2026-07-11 17:41:09 +00:00
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 <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
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
2026-07-11 19:37:48 +00:00
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
# which would clobber this script's $VERSION.
2026-07-11 17:41:09 +00:00
# shellcheck source=/dev/null
2026-07-11 19:37:48 +00:00
OS_FAMILY = " $( . /etc/os-release && printf '%s %s' " ${ ID :- } " " ${ ID_LIKE :- } " ) "
case " $OS_FAMILY " in
2026-07-11 17:41:09 +00:00
*debian*) ; ;
2026-07-11 19:37:48 +00:00
*) warn " not a Debian-family system ( ${ OS_FAMILY :- unknown } ); proceeding anyway " ; ;
2026-07-11 17:41:09 +00:00
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
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
# --- is this box already registered somewhere else? --------------------------
# Before anything is prompted for, downloaded, or started: --repo must agree
# with what is already on the box. Everything below this point treats an
# existing .runner as "nothing to do" — which is right for the repo the box is
# already on, and silently wrong for any other. See assert_runner_repo.
#
# Registration is pending unless the runner user already exists AND
# $RUNNER_DIR/.runner exists (user absent => nothing can be registered).
2026-07-11 17:41:09 +00:00
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 "
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
assert_runner_repo " $RUNNER_DIR " " $REPO " || exit 1
2026-07-11 17:41:09 +00:00
if [ -e " $RUNNER_DIR /.runner " ] ; then
REG_PENDING = 0
fi
fi
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
# --- registration token — only when registration is actually pending -------
2026-07-11 17:41:09 +00:00
if [ " $REG_PENDING " -eq 1 ] ; then
RUNNER_TOKEN = " ${ RUNNER_TOKEN :- } "
2026-07-19 12:15:08 +00:00
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
# message at all (drill-measured). Refuse loudly, naming the variable.
2026-07-11 17:41:09 +00:00
if [ -z " $RUNNER_TOKEN " ] ; then
2026-07-19 12:15:08 +00:00
[ -t 0 ] || die "RUNNER_TOKEN is unset and stdin is not a tty — set RUNNER_TOKEN to run unattended"
read -rsp "runner registration token (short-lived): " RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set RUNNER_TOKEN to run unattended" ; }
2026-07-11 17:41:09 +00:00
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
2026-07-11 18:44:43 +00:00
if [ -z " $VERSION " ] ; then
# No pin given: resolve the latest release by following the redirect on
# the /releases/latest page — no API call, no rate limit, no JSON to
# parse on a dependency-free box.
LATEST_URL = " $( curl -fsSLI -o /dev/null -w '%{url_effective}' \
https://github.com/actions/runner/releases/latest) " \
|| die "could not resolve the latest actions/runner release"
VERSION = " ${ LATEST_URL ##*/ } "
VERSION = " ${ VERSION #v } "
case " $VERSION " in
"" | *[ !0-9.] *) die " could not parse a version from ${ LATEST_URL } " ; ;
esac
log " resolved latest actions/runner: ${ VERSION } "
fi
2026-07-11 17:41:09 +00:00
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)
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 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 "
2026-07-11 17:41:09 +00:00
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"