#!/bin/bash
# register-runner.sh — register a repo-scoped GitHub Actions runner on a dedicated CI host.
# audience: AI coding agents first. Invoke BY PATH; never re-derive the ssh/config.sh cascade.
#
# WHY THIS EXISTS: the naive form of this job is a hand-typed cascade of `gh api` token minting,
# scp, tar, config.sh and systemctl over ssh. Every hand-typed run drops a step: the token leaks
# into argv, the tarball is unverified, the unit is written but never enabled, or the runner
# registers and is never confirmed online. It also has one non-obvious footgun that silently
# breaks every naive attempt: non-interactive ssh to these hosts has a BROKEN PATH (a `ft:`
# profile fault), so bare `curl`/`tar`/`systemctl` exit 127. Remote steps therefore set PATH
# explicitly and the remote body is fed to `bash -s` over stdin rather than quoted inline.
#
# SCOPE: repo-scoped runners only. `alexcodeplace` is a GitHub USER account, so org-level runners
# are impossible without transferring repos to an organization. A repo-scoped runner only ever
# receives its own repository's jobs, which is why a shared `<project>-gate` label is safe here.
#
# CONTRACT: prints one line of JSON on stdout. Exit 0 = runner registered AND confirmed online.
# Any other exit is a hard failure with the reason on stderr; nothing is left half-registered that
# the script could itself undo. FAIL-CLOSED: an unexpected state aborts, never guesses or forces.
#
# usage:
#   register-runner.sh --repo <owner/name> --host <ssh-host> --name <runner-name> \
#                      --label <label> [--runner-version <vX.Y.Z>] [--dry-run]

set -euo pipefail

REPO="" HOST="" NAME="" LABEL="" RUNNER_VERSION="" DRY_RUN=0

die() { printf 'register-runner: %s\n' "$*" >&2; exit 1; }
emit() { printf '{"status":"%s","repo":"%s","host":"%s","runner":"%s","label":"%s"}\n' "$1" "$REPO" "$HOST" "$NAME" "$LABEL"; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --repo)           REPO="${2:-}"; shift 2;;
    --host)           HOST="${2:-}"; shift 2;;
    --name)           NAME="${2:-}"; shift 2;;
    --label)          LABEL="${2:-}"; shift 2;;
    --runner-version) RUNNER_VERSION="${2:-}"; shift 2;;
    --dry-run)        DRY_RUN=1; shift;;
    *) die "unknown argument: $1";;
  esac
done

[[ -n "$REPO"  ]] || die "--repo <owner/name> is required"
[[ -n "$HOST"  ]] || die "--host <ssh-host> is required"
[[ -n "$NAME"  ]] || die "--name <runner-name> is required"
[[ -n "$LABEL" ]] || die "--label <label> is required"
[[ "$REPO" == */* ]] || die "--repo must be owner/name, got: $REPO"
# The runner name becomes a directory name and a systemd unit name.
[[ "$NAME"  =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "--name must be alphanumeric with . _ - only: $NAME"
[[ "$LABEL" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "--label must be alphanumeric with . _ - only: $LABEL"

command -v gh  >/dev/null 2>&1 || die "gh CLI not installed"
command -v ssh >/dev/null 2>&1 || die "ssh not installed"
gh auth status >/dev/null 2>&1 || die "gh not authenticated — run: gh auth login"

gh api "/repos/$REPO" --jq .full_name >/dev/null 2>&1 \
  || die "repo not found or not accessible: $REPO"

# A same-named runner already on the repo means either a live runner (do not disturb) or a stale
# registration (needs a deliberate `gh api -X DELETE`). Either way it is not ours to overwrite.
if gh api "/repos/$REPO/actions/runners" --jq '.runners[].name' 2>/dev/null | grep -qxF "$NAME"; then
  die "a runner named '$NAME' is already registered on $REPO — remove it deliberately before re-registering"
fi

if [[ -z "$RUNNER_VERSION" ]]; then
  RUNNER_VERSION=$(gh api /repos/actions/runner/releases/latest --jq .tag_name) \
    || die "could not resolve the latest actions/runner release"
fi
[[ "$RUNNER_VERSION" == v* ]] || die "--runner-version must look like vX.Y.Z, got: $RUNNER_VERSION"
VER="${RUNNER_VERSION#v}"
TARBALL="actions-runner-linux-x64-${VER}.tar.gz"

# The release API publishes a sha256 digest per asset — the download is verified against it, so a
# truncated or substituted tarball aborts instead of producing a subtly broken runner.
DIGEST=$(gh api "/repos/actions/runner/releases/tags/${RUNNER_VERSION}" \
           --jq ".assets[] | select(.name==\"${TARBALL}\") | .digest") \
  || die "could not read release assets for ${RUNNER_VERSION}"
[[ "$DIGEST" == sha256:* ]] || die "no sha256 digest published for ${TARBALL} — refusing an unverified download"
SHA256="${DIGEST#sha256:}"

ssh -o BatchMode=yes -o ConnectTimeout=10 "$HOST" true 2>/dev/null \
  || die "cannot reach host over ssh without a prompt: $HOST"

DIR="actions-runner-${NAME}"
UNIT="runner-${NAME}.service"

if [[ $DRY_RUN -eq 1 ]]; then
  emit dry-run
  exit 0
fi

# Remote preflight is separate from the mutating step: the registration token is only minted once
# the host is known to be in a clean, registerable state, so a preflight abort never burns a token.
ssh -o BatchMode=yes "$HOST" /bin/bash -s -- "$DIR" <<'PREFLIGHT' || die "remote preflight failed on $HOST"
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
dir="$1"
[ -e "$HOME/$dir" ] && { echo "remote: $HOME/$dir already exists" >&2; exit 1; }
for b in curl tar systemctl sha256sum; do
  command -v "$b" >/dev/null 2>&1 || { echo "remote: missing required binary: $b" >&2; exit 1; }
done
# A user-scope unit only survives logout if lingering is on; without it the runner dies with the
# ssh session and the whole registration is silently useless.
loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes' \
  || { echo "remote: lingering is off for $(id -un) — run: loginctl enable-linger $(id -un)" >&2; exit 1; }
PREFLIGHT

TOKEN=$(gh api -X POST "/repos/$REPO/actions/runners/registration-token" --jq .token) \
  || die "could not mint a registration token for $REPO"
[[ -n "$TOKEN" ]] || die "registration token came back empty for $REPO"

# The token travels in the remote process environment, never in argv: argv is world-readable via
# /proc/<pid>/cmdline, whereas /proc/<pid>/environ is readable only by the owning user.
if ! ssh -o BatchMode=yes "$HOST" \
     RUNNER_REG_TOKEN="$TOKEN" /bin/bash -s -- \
     "$DIR" "$UNIT" "$NAME" "$LABEL" "$REPO" "$VER" "$TARBALL" "$SHA256" <<'REMOTE'
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
dir="$1" unit="$2" name="$3" label="$4" repo="$5" ver="$6" tarball="$7" sha256="$8"
root="$HOME/$dir"

# One shared tarball cache across every runner on this host: registering five runners must not pull
# 226MB five times.
cache="$HOME/.cache/actions-runner"
mkdir -p "$cache"
if ! printf '%s  %s\n' "$sha256" "$cache/$tarball" | sha256sum -c --status 2>/dev/null; then
  curl -fsSL -o "$cache/$tarball.part" \
    "https://github.com/actions/runner/releases/download/v${ver}/${tarball}"
  printf '%s  %s\n' "$sha256" "$cache/$tarball.part" | sha256sum -c --status \
    || { echo "remote: sha256 mismatch on $tarball" >&2; rm -f "$cache/$tarball.part"; exit 1; }
  mv "$cache/$tarball.part" "$cache/$tarball"
fi

mkdir -p "$root"
tar -xzf "$cache/$tarball" -C "$root"

# --replace is deliberately absent: the caller already proved no runner holds this name, so a
# collision here is an unexpected state that must abort rather than silently evict a live runner.
( cd "$root" && ./config.sh --unattended \
    --url "https://github.com/${repo}" \
    --token "$RUNNER_REG_TOKEN" \
    --name "$name" \
    --labels "$label" \
    --work _work )

# The tarball ships runsvc.sh under bin/ only; `svc.sh install` is what normally copies it to the
# root. This registers a user-scope unit instead of using svc.sh (which needs root), so the copy is
# ours to make. Without it the unit starts and dies with 203/EXEC on every restart.
cp "$root/bin/runsvc.sh" "$root/runsvc.sh"
chmod +x "$root/runsvc.sh"

mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/$unit" <<UNITFILE
[Unit]
Description=GitHub Actions Runner ($name)
After=network-online.target

[Service]
ExecStart=%h/$dir/runsvc.sh
WorkingDirectory=%h/$dir
KillMode=control-group
KillSignal=SIGTERM
TimeoutStopSec=5min
CPUWeight=30
IOWeight=30
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
UNITFILE

systemctl --user daemon-reload
systemctl --user enable --now "$unit"
REMOTE
then
  die "remote registration failed on $HOST — inspect ~/$DIR and 'systemctl --user status $UNIT'"
fi

# Registration is not the deliverable; an ONLINE runner is. A unit that starts and immediately
# crashes still leaves a registered-but-offline runner, which manifests later as a job queued
# forever rather than as an error here.
for _ in $(seq 1 30); do
  if [[ "$(gh api "/repos/$REPO/actions/runners" \
            --jq ".runners[] | select(.name==\"$NAME\") | .status" 2>/dev/null)" == "online" ]]; then
    emit online
    exit 0
  fi
  sleep 2
done

die "runner '$NAME' did not report online within 60s — check 'systemctl --user status $UNIT' on $HOST"
