Merge pull request #4 from claude-hdb/feat/runner-bootstrap-role

feat: runner follow-ups — bootstrap role + latest-version resolution
This commit is contained in:
Daniel Marin 2026-07-11 19:46:56 +01:00 committed by GitHub
commit bcaade0e93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 97 additions and 27 deletions

View file

@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade.
## Commands ## Commands
### `rig bootstrap <control-plane|workload>` ### `rig bootstrap <control-plane|workload|runner>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
second run changes nothing. second run changes nothing.
@ -28,10 +28,13 @@ second run changes nothing.
```sh ```sh
rig bootstrap control-plane --hostname my-coolify-box rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box
``` ```
- `--hostname <name>` — tailnet hostname (default: the role name) - `--hostname <name>` — tailnet hostname (default: the role name)
- `--ts-tag <tag>` — tailnet tag to advertise (default: `tag:server`) - `--ts-tag <tag>` — tailnet tag to advertise (default: `tag:server`;
the `runner` role defaults to `tag:ci` instead, and **refuses**
`tag:server` outright — see below)
What it does: installs `curl ca-certificates unattended-upgrades` (and What it does: installs `curl ca-certificates unattended-upgrades` (and
enables periodic unattended upgrades); writes an sshd hardening drop-in enables periodic unattended upgrades); writes an sshd hardening drop-in
@ -42,9 +45,14 @@ tailscale and joins your tailnet.
the interactive prompt. Use a **single-use, tagged, short-expiry** key. It the interactive prompt. Use a **single-use, tagged, short-expiry** key. It
lives in process memory only — rig never writes a credential to disk. lives in process memory only — rig never writes a credential to disk.
The two roles are identical today except the default hostname; they exist `control-plane` and `workload` are identical today except the default
because control-plane and workload boxes diverge over time, and because the hostname; they exist because the boxes diverge over time, and because each
next command applies to exactly one of them. follow-up command applies to exactly one role. `runner` is the box a CI
agent will live on, and it differs behaviorally: it defaults `--ts-tag` to
`tag:ci` and **refuses `tag:server`** — a runner executes repo-controlled
code, and advertising your server tag would extend every grant your servers
hold (SSH between them, say) to that code. The refusal turns the worst
misconfiguration from a documentation warning into a hard error.
### `rig coolify install --version <pin>` ### `rig coolify install --version <pin>`
@ -53,13 +61,14 @@ 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 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. explicit re-run with a new pin. The pin is required; there is no default.
### `rig runner install --repo <owner/repo> --version <pin>` ### `rig runner install --repo <owner/repo>`
Workload box only, run after `rig bootstrap workload`: Runner box only, run after `rig bootstrap runner` (the same two-step rhythm
as `bootstrap control-plane``coolify install`):
```sh ```sh
rig bootstrap workload --hostname my-ci-box --ts-tag tag:ci rig bootstrap runner --hostname my-ci-box
rig runner install --repo acme/widgets --version 2.335.1 rig runner install --repo acme/widgets
``` ```
Installs GitHub's official `actions/runner` as a systemd service under an Installs GitHub's official `actions/runner` as a systemd service under an
@ -75,6 +84,10 @@ 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 whose whole point is a narrow blast radius. Add Docker only once a job
genuinely needs it, and rethink the isolation model then. genuinely needs it, and rethink the isolation model then.
- `--version <pin>` — actions/runner release to install (default: the
latest release, resolved at install time; e.g. `--version 2.335.1`
the latest as of this writing). Pin it when you need a deterministic,
auditable install.
- `--name <name>` — runner name (default: this host's hostname) - `--name <name>` — runner name (default: this host's hostname)
- `--labels <csv>` — runner labels, replacing the `ci-runner` default — keep - `--labels <csv>` — runner labels, replacing the `ci-runner` default — keep
any label your workflows' `runs-on` needs (GitHub adds `self-hosted` itself) any label your workflows' `runs-on` needs (GitHub adds `self-hosted` itself)
@ -84,11 +97,13 @@ genuinely needs it, and rethink the isolation model then.
it at the interactive prompt. It's short-lived, consumed at registration, and it at the interactive prompt. It's short-lived, consumed at registration, and
never written to disk by rig. never written to disk by rig.
The version pin is required, same as `coolify install` — but unlike Coolify, Why latest-by-default here when `coolify install` demands a pin: the two
the installed runner **self-updates**: GitHub refuses jobs from stale tools age differently. Coolify never self-updates (`AUTOUPDATE=false`), so
runners, so freezing the version would just make it silently stop taking its version is a contract your deploy tooling is verified against — stating
work. The pin states what you install today; GitHub owns the treadmill after it is the point. The runner **self-updates regardless**: GitHub refuses jobs
that. from stale runners, so freezing it would just make it silently stop taking
work. The install-time version is a starting point either way; `--version`
exists for when you want that starting point deterministic and auditable.
Convergent — safe to re-run; an already-registered runner is left alone. Convergent — safe to re-run; an already-registered runner is left alone.

View file

@ -8,10 +8,11 @@ usage() {
usage: rig <command> [args] usage: rig <command> [args]
commands: commands:
bootstrap <control-plane|workload> [--hostname <name>] [--ts-tag <tag>] bootstrap <control-plane|workload|runner> [--hostname <name>] [--ts-tag <tag>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades, OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
tailscale join. Prompts for a single-use tailnet pre-auth key tailscale join. Prompts for a single-use tailnet pre-auth key
(TS_AUTHKEY env overrides the prompt). Run as root. (TS_AUTHKEY env overrides the prompt). Run as root. Role runner
defaults to tag:ci and refuses tag:server.
coolify install --version <pin> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
runner install --repo <owner/repo> --version <pin> [options] runner install --repo <owner/repo> --version <pin> [options]

View file

@ -9,10 +9,13 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <control-plane|workload> [--hostname <name>] [--ts-tag <tag>] usage: rig bootstrap <control-plane|workload|runner> [--hostname <name>] [--ts-tag <tag>]
--hostname tailnet hostname (default: the role name) --hostname tailnet hostname (default: the role name)
--ts-tag tailnet tag to advertise (default: tag:server) --ts-tag tailnet tag to advertise (default: tag:server;
role runner defaults to tag:ci and refuses tag:server —
a CI box executes repo-controlled code, and your server
tag's grants must never extend to it)
Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or
enter it at the interactive prompt. It is used once and never written to disk. enter it at the interactive prompt. It is used once and never written to disk.
@ -22,14 +25,18 @@ EOF
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
case "$ROLE" in case "$ROLE" in
control-plane|workload) shift ;; control-plane|workload|runner) shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane|workload)" 2 ;; "") usage >&2; die "role required (control-plane|workload|runner)" 2 ;;
*) die "unknown role: $ROLE (want control-plane|workload)" 2 ;; *) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;;
esac esac
TS_HOSTNAME="$ROLE" TS_HOSTNAME="$ROLE"
TS_TAG="tag:server" if [ "$ROLE" = "runner" ]; then
TS_TAG="tag:ci"
else
TS_TAG="tag:server"
fi
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--hostname) --hostname)
@ -42,6 +49,12 @@ while [ $# -gt 0 ]; do
esac esac
done done
# A runner executes repo-controlled code; advertising the server tag would
# extend every grant your servers hold to that code. Refused, not warned.
if [ "$ROLE" = "runner" ] && [ "$TS_TAG" = "tag:server" ]; then
die "role runner must not advertise tag:server" 2
fi
# --- guards ------------------------------------------------------------------ # --- guards ------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@ -109,4 +122,6 @@ fi
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane" ]; then if [ "$ROLE" = "control-plane" ]; then
log "next: rig coolify install --version <pin>" log "next: rig coolify install --version <pin>"
elif [ "$ROLE" = "runner" ]; then
log "next: rig runner install --repo <owner/repo> --version <pin>"
fi fi

View file

@ -10,11 +10,14 @@ die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig runner install --repo <owner/repo> --version <pin> [options] usage: rig runner install --repo <owner/repo> [options]
--repo <owner/repo> GitHub repository the runner registers to (required) --repo <owner/repo> GitHub repository the runner registers to (required)
--version <pin> actions/runner release to install, e.g. 2.335.1 --version <pin> actions/runner release to install, e.g. 2.335.1
(required; no default — you state what you install) (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)
--name <name> runner name (default: this host's hostname) --name <name> runner name (default: this host's hostname)
--labels <csv> runner labels; replaces the default (default: ci-runner) --labels <csv> runner labels; replaces the default (default: ci-runner)
--user <name> unprivileged service user (default: github-runner; --user <name> unprivileged service user (default: github-runner;
@ -66,7 +69,6 @@ done
if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
die "--repo must be owner/repo" 2 die "--repo must be owner/repo" 2
fi fi
[ -n "$VERSION" ] || die "--version <pin> is required" 2
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 [ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
@ -123,6 +125,20 @@ else
aarch64) ARCH="arm64" ;; aarch64) ARCH="arm64" ;;
*) die "unsupported arch: $(uname -m)" ;; *) die "unsupported arch: $(uname -m)" ;;
esac esac
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
URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz" URL="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-${ARCH}-${VERSION}.tar.gz"
WORKDIR="$(mktemp -d)" WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; } cleanup() { rm -rf "$WORKDIR"; }

View file

@ -274,6 +274,27 @@ git commit -m "docs: README section for runner install"
--- ---
## Addendum (2026-07-11, operator-requested, post final review)
A third bootstrap role, `runner`, joins `control-plane|workload` — requested
for CLI consistency (each follow-up command applies to exactly one role) and
because it closes a real footgun mechanically: the role defaults `--ts-tag`
to `tag:ci` and **refuses `tag:server`** (exit 2, validated before the root
check). Forgetting the tag flag previously joined the CI box with the default
server tag — the exact misconfiguration the runner posture exists to prevent.
Everything else about bootstrap is unchanged; `runner install`'s contract is
untouched. Tests: +2 (`runner refuses tag:server`, `runner role parses /
refuses non-root`) → 27 non-root.
Second amendment (same day): `runner install --version` becomes **optional**
— omitted, rig resolves the latest release at install time by following the
`releases/latest` redirect (no API, no rate limit, no JSON parsing; validated
against a digits-and-dots pattern before use). Safe here, and only here,
because the runner self-updates regardless of what you install; `coolify
install` keeps its mandatory pin — Coolify never self-updates, so its version
is a verified contract, not a starting point. The `version required` test is
replaced by a `--version needs a value` test → still 27 non-root.
## Integration (orchestrator, after final review — not an SDD task) ## Integration (orchestrator, after final review — not an SDD task)
1. Push the branch to the fork and open the PR **against upstream**: 1. Push the branch to the fork and open the PR **against upstream**:

View file

@ -36,10 +36,12 @@ check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bo
check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope
check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname
check "bootstrap: runner refuses tag:server" 2 "must not advertise tag:server" "$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
else else
echo "skip: bootstrap non-root refusal (running as root)" echo "skip: bootstrap non-root refusals (running as root)"
fi fi
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
@ -55,7 +57,7 @@ fi
check "bare runner shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" runner 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: --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: 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: version needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version
check "runner: repo needs value" 2 "needs a value" "$ROOT/commands/runner-install.sh" --repo 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: 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: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-install.sh" --repo acme/widgets --version 2.335.1 --user root