From e395d6754acc1a0671ae938e69e201991c61ae7b Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Sat, 11 Jul 2026 18:25:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20runner=20bootstrap=20role=20?= =?UTF-8?q?=E2=80=94=20defaults=20tag:ci,=20refuses=20tag:server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 23 ++++++++++++++------- bin/rig | 5 +++-- commands/bootstrap.sh | 27 +++++++++++++++++++------ docs/plans/2026-07-11-runner-install.md | 12 +++++++++++ test/cli.sh | 4 +++- 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 88c2627..6e6bc2c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ PATH (`/usr/local/bin` when root). Re-run any time to upgrade. ## Commands -### `rig bootstrap ` +### `rig bootstrap ` Run as root on the fresh box (over SSH). Convergent — safe to re-run; a second run changes nothing. @@ -28,10 +28,13 @@ second run changes nothing. ```sh rig bootstrap control-plane --hostname my-coolify-box rig bootstrap workload --hostname my-prod-box +rig bootstrap runner --hostname my-ci-box ``` - `--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`; + 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 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 lives in process memory only — rig never writes a credential to disk. -The two roles are identical today except the default hostname; they exist -because control-plane and workload boxes diverge over time, and because the -next command applies to exactly one of them. +`control-plane` and `workload` are identical today except the default +hostname; they exist because the boxes diverge over time, and because each +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 ` @@ -55,10 +63,11 @@ explicit re-run with a new pin. The pin is required; there is no default. ### `rig runner install --repo --version ` -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 -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 ``` diff --git a/bin/rig b/bin/rig index 464b536..67d5bd6 100755 --- a/bin/rig +++ b/bin/rig @@ -8,10 +8,11 @@ usage() { usage: rig [args] commands: - bootstrap [--hostname ] [--ts-tag ] + bootstrap [--hostname ] [--ts-tag ] OS plumbing on a pristine Debian box: hardening, unattended-upgrades, 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 Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. runner install --repo --version [options] diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 697d20c..ac54a9a 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -9,10 +9,13 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--hostname ] [--ts-tag ] +usage: rig bootstrap [--hostname ] [--ts-tag ] --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 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) --------- ROLE="${1:-}" case "$ROLE" in - control-plane|workload) shift ;; + control-plane|workload|runner) shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane|workload)" 2 ;; - *) die "unknown role: $ROLE (want control-plane|workload)" 2 ;; + "") usage >&2; die "role required (control-plane|workload|runner)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload|runner)" 2 ;; esac 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 case "$1" in --hostname) @@ -42,6 +49,12 @@ while [ $# -gt 0 ]; do esac 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 ------------------------------------------------------------------ [ "$(id -u)" -eq 0 ] || die "must run as root" if [ -r /etc/os-release ]; then @@ -109,4 +122,6 @@ fi log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" if [ "$ROLE" = "control-plane" ]; then log "next: rig coolify install --version " +elif [ "$ROLE" = "runner" ]; then + log "next: rig runner install --repo --version " fi diff --git a/docs/plans/2026-07-11-runner-install.md b/docs/plans/2026-07-11-runner-install.md index a116c5f..c7a4694 100644 --- a/docs/plans/2026-07-11-runner-install.md +++ b/docs/plans/2026-07-11-runner-install.md @@ -274,6 +274,18 @@ 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. + ## Integration (orchestrator, after final review — not an SDD task) 1. Push the branch to the fork and open the PR **against upstream**: diff --git a/test/cli.sh b/test/cli.sh index 7298a3d..23d9dc8 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -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 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: 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 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 - echo "skip: bootstrap non-root refusal (running as root)" + echo "skip: bootstrap non-root refusals (running as root)" fi check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" -- 2.45.2 From 63b2effe032eda89c92c9771ffc2883621a2bb99 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Sat, 11 Jul 2026 18:44:43 +0000 Subject: [PATCH 2/2] feat: runner install resolves the latest release when --version is omitted Co-Authored-By: Claude Fable 5 --- README.md | 20 +++++++++++++------- commands/runner-install.sh | 22 +++++++++++++++++++--- docs/plans/2026-07-11-runner-install.md | 9 +++++++++ test/cli.sh | 2 +- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6e6bc2c..1e85868 100644 --- a/README.md +++ b/README.md @@ -61,14 +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 explicit re-run with a new pin. The pin is required; there is no default. -### `rig runner install --repo --version ` +### `rig runner install --repo ` Runner box only, run after `rig bootstrap runner` (the same two-step rhythm as `bootstrap control-plane` → `coolify install`): ```sh 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 @@ -84,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 genuinely needs it, and rethink the isolation model then. +- `--version ` — 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 ` — runner name (default: this host's hostname) - `--labels ` — runner labels, replacing the `ci-runner` default — keep any label your workflows' `runs-on` needs (GitHub adds `self-hosted` itself) @@ -93,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 never written to disk by rig. -The version pin is required, same as `coolify install` — but unlike Coolify, -the installed runner **self-updates**: GitHub refuses jobs from stale -runners, so freezing the version would just make it silently stop taking -work. The pin states what you install today; GitHub owns the treadmill after -that. +Why latest-by-default here when `coolify install` demands a pin: the two +tools age differently. Coolify never self-updates (`AUTOUPDATE=false`), so +its version is a contract your deploy tooling is verified against — stating +it is the point. The runner **self-updates regardless**: GitHub refuses jobs +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. diff --git a/commands/runner-install.sh b/commands/runner-install.sh index 115e436..5941b2a 100755 --- a/commands/runner-install.sh +++ b/commands/runner-install.sh @@ -10,11 +10,14 @@ die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig runner install --repo --version [options] +usage: rig runner install --repo [options] --repo GitHub repository the runner registers to (required) --version 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 runner name (default: this host's hostname) --labels runner labels; replaces the default (default: ci-runner) --user 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 die "--repo must be owner/repo" 2 fi -[ -n "$VERSION" ] || die "--version is required" 2 VERSION="${VERSION#v}" [ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 @@ -123,6 +125,20 @@ else aarch64) ARCH="arm64" ;; *) die "unsupported arch: $(uname -m)" ;; 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" WORKDIR="$(mktemp -d)" cleanup() { rm -rf "$WORKDIR"; } diff --git a/docs/plans/2026-07-11-runner-install.md b/docs/plans/2026-07-11-runner-install.md index c7a4694..c0ad06c 100644 --- a/docs/plans/2026-07-11-runner-install.md +++ b/docs/plans/2026-07-11-runner-install.md @@ -286,6 +286,15 @@ 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) 1. Push the branch to the fork and open the PR **against upstream**: diff --git a/test/cli.sh b/test/cli.sh index 23d9dc8..69492b1 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -57,7 +57,7 @@ fi 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: 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: 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 -- 2.45.2