Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project are documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
versioning follows [SemVer](https://semver.org/).

## [Unreleased]

### Changed

- **Every workflow job now picks its runner from the repository variable
`CI_RUNS_ON`.** `{{RUNNER}}` renders to
`${{ fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }}`, and `ci.yml` and
`pr-auto-merge.yml`, which had `ubuntu-latest` hardcoded and ignored the
config's `runner`, use it too. Switching a repo to a self-hosted pool is
`gh variable set CI_RUNS_ON --body '["self-hosted","linux","vps"]'`, and
deleting the variable is the kill switch back to GitHub-hosted, no PR needed.
Deploy templates keep `ubuntu-latest` until each one is proven on the pool.

### Fixed

- **`setup-self-hosted-runner.sh` no longer produces a runner that dies on its
first restart.** It passed a one-hour registration token and kept the
registration inside the container, so any restart after the token expired
looped on "Cannot configure the runner because it is already configured".
The registration now lives in a host volume
(`CONFIGURED_ACTIONS_RUNNER_FILES_DIR`), automatic deregistration on stop is
off, the image is pinned, the container gets CPU, memory and PID limits, and
the token reaches the host over stdin instead of the command line.

## [2.3.0] — 2026-08-31

### Added
Expand Down
7 changes: 6 additions & 1 deletion commands/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ nowhere.
that detection left genuinely ambiguous:
- **Stack** — confirm detected or correct it
- **Deploy** — vercel | supabase-functions | docker-ghcr | npm-publish | static-pages | none
- **Runner** — self-hosted (zero CI minutes) | github
- **Runner**: self-hosted (zero CI minutes) | github. Either way every
job reads `runs-on` from the repository variable `CI_RUNS_ON` and falls
back to `ubuntu-latest` when it is unset. For self-hosted, bring the runner
up with `scripts/setup-self-hosted-runner.sh` and finish with the
`gh variable set CI_RUNS_ON ...` line it prints; deleting the variable
sends the repo back to GitHub-hosted runners without a PR.
- **Auth** — oauth (subscription token, no metered cost) | apikey (pay per use)
- **Critical files / custom validators** — optional, multi-select

Expand Down
9 changes: 6 additions & 3 deletions scripts/lib/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,12 @@ export function buildPlaceholderMap(
INVARIANT_REFS: (config.layers ?? []).length
? `the invariants for ${(config.layers ?? []).join(", ")}`
: "the invariants in this repo",
// GitHub Actions runner. self-hosted only when the config asks for it;
// otherwise the generic GitHub-hosted runner, so workflows run in any repo.
RUNNER: config.runner === "self-hosted" ? "[self-hosted, linux, x64]" : "ubuntu-latest",
// GitHub Actions runner, chosen per repo at run time by the repository
// variable CI_RUNS_ON (a JSON label array such as ["self-hosted","linux"]).
// Unset, every job falls back to the GitHub-hosted runner, so deleting the
// variable is the kill switch when the self-hosted pool is down. The config's
// `runner` only decides whether setup prints the command that sets it.
RUNNER: `\${{ fromJSON(vars.CI_RUNS_ON || '"ubuntu-latest"') }}`,
// Issue triage. `github-models` runs the classifier free in Actions over the
// GITHUB_TOKEN; `off` makes the triage workflow a no-op via its top-level if.
ISSUES_TRIAGE: config.issues?.triage ?? "github-models",
Expand Down
76 changes: 53 additions & 23 deletions templates/scripts/setup-self-hosted-runner.sh.template
Original file line number Diff line number Diff line change
@@ -1,54 +1,84 @@
#!/usr/bin/env bash
# setup-self-hosted-runner.sh — brings up a containerized self-hosted runner
# setup-self-hosted-runner.sh: brings up a containerized self-hosted runner
# to zero out GitHub Actions minute usage (a self-hosted runner doesn't count
# against the minute limit, for any repo visibility).
#
# SECURITY: use ONLY on a PRIVATE repo with trusted collaborators. On a public
# repo, a fork PR runs arbitrary code on your host. Don't break this.
# repo, a fork PR runs arbitrary code on your host. Don't break this. The
# container never gets the host's docker.sock.
#
# Pre: docker on the target host; gh authenticated with admin on the repo.
#
# usage: bash setup-self-hosted-runner.sh <owner/repo> <host-ssh> <label>
# e.g.: bash setup-self-hosted-runner.sh {{REPO}} root@1.2.3.4:22 node01
# usage: bash setup-self-hosted-runner.sh <owner/repo> <host-ssh> <label> [cpus] [memory]
# e.g.: bash setup-self-hosted-runner.sh {{REPO}} root@1.2.3.4:22 node01 2 6g
#
# Idempotent. The runner's registration lives in a host volume, so restarting
# or recreating the container reuses it instead of asking for a new token.
# (The old version passed a one-hour token and kept the registration inside the
# container: after the first restart it looped on "already configured".)

set -euo pipefail

REPO="${1:?owner/repo}"
HOST="${2:?user@host[:port] of the node}" # e.g. root@1.2.3.4 or root@1.2.3.4:5922
LABEL="${3:?runner label}" # e.g. node01
CPUS="${4:-2}"
MEMORY="${5:-6g}"
PORT="22"; case "$HOST" in *:*) PORT="${HOST##*:}"; HOST="${HOST%:*}";; esac

NAME="gha-runner-$(echo "$REPO" | tr '/' '-')"
IMAGE="myoung34/github-runner:2.337.0-ubuntu-noble"
SLUG="$(echo "$REPO" | tr '/' '-')"
NAME="gha-runner-$SLUG"
DATA="/docker/gha-runners/data/$SLUG"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$PORT" "$HOST")

echo "→ minting registration token (expires ~1h, not persisted)..."
REG=$(gh api -X POST "repos/$REPO/actions/runners/registration-token" --jq '.token')
[ -n "$REG" ] || { echo "token failed"; exit 1; }
if "${SSH[@]}" "test -f '$DATA/config/.runner'"; then
echo "→ registration already stored in $DATA/config, reusing it"
REG=""
else
echo "→ minting registration token (expires ~1h, used once)..."
REG=$(gh api -X POST "repos/$REPO/actions/runners/registration-token" --jq '.token')
[ -n "$REG" ] || { echo "token failed"; exit 1; }
fi

echo "→ bringing up container '$NAME' on $HOST:$PORT (label: self-hosted,linux,x64,$LABEL)..."
ssh -o StrictHostKeyChecking=accept-new -p "$PORT" "$HOST" \
"docker rm -f '$NAME' 2>/dev/null; \
docker pull myoung34/github-runner:latest >/dev/null && \
echo "→ bringing up container '$NAME' on $HOST:$PORT (labels: self-hosted,linux,x64,$LABEL)..."
printf '%s' "$REG" | "${SSH[@]}" "set -e; mkdir -p '$DATA/config' '$DATA/work'; \
REG=\$(cat); \
docker rm -f '$NAME' >/dev/null 2>&1 || true; \
docker pull -q '$IMAGE' >/dev/null; \
docker run -d --restart unless-stopped --name '$NAME' \
--cpus '$CPUS' --memory '$MEMORY' --pids-limit 2048 \
--security-opt no-new-privileges:true \
--log-opt max-size=10m --log-opt max-file=3 \
-v '$DATA/config:/runner/config' -v '$DATA/work:/runner/work' \
-e REPO_URL='https://github.com/$REPO' \
-e RUNNER_NAME='$LABEL-$(echo "$REPO"|cut -d/ -f2)' \
-e RUNNER_TOKEN='$REG' \
-e RUNNER_TOKEN=\"\$REG\" \
-e RUNNER_SCOPE=repo \
-e LABELS='$LABEL' \
-e RUNNER_WORKDIR=/tmp/runner-work \
-e DISABLE_AUTO_UPDATE=true \
myoung34/github-runner:latest && sleep 15 && \
docker logs --tail 8 '$NAME'"
-e RUNNER_WORKDIR=/runner/work \
-e CONFIGURED_ACTIONS_RUNNER_FILES_DIR=/runner/config \
-e DISABLE_AUTOMATIC_DEREGISTRATION=true \
'$IMAGE' >/dev/null && sleep 20 && \
docker logs --tail 6 '$NAME' 2>&1 | grep -v -i token"

echo "→ confirming on GitHub..."
gh api "repos/$REPO/actions/runners" \
--jq '.runners[] | .name+" status="+.status+" labels="+([.labels[].name]|join(","))'

cat <<'NOTE'
cat <<NOTE

OK. Every keepwright workflow picks its runner from the repository variable
CI_RUNS_ON. Point this repo at the new runner with:

gh variable set CI_RUNS_ON -R $REPO --body '["self-hosted","linux","$LABEL"]'

Kill switch (runner down, pool offline): delete the variable and every job
goes back to ubuntu-latest on the next run, no PR needed:

OK. Now point the HEAVY jobs at it in the workflows:
runs-on: [self-hosted, linux, x64]
Keep light/critical/rare jobs on ubuntu-latest (resilience if the node
goes down: self-hosted jobs sit in the QUEUE, they don't fail).
gh variable delete CI_RUNS_ON -R $REPO

Re-register (if you recreate the container): run this script again.
The runner image ships Node 18; jobs that need a newer Node must pin it with
actions/setup-node. Deploy workflows (templates/workflows/deploy/*) stay on
ubuntu-latest until each one has a green run on the self-hosted runner.
NOTE
8 changes: 4 additions & 4 deletions templates/workflows/ci.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
# Python: mypy .
type-check:
name: Type check
runs-on: ubuntu-latest
runs-on: {{RUNNER}}
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2 # or setup-node@v4, or setup-deno@v2, or setup-python@v5
Expand All @@ -28,7 +28,7 @@ jobs:
# ─── Lint ──────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
runs-on: {{RUNNER}}
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
Expand All @@ -39,7 +39,7 @@ jobs:
# ─── Custom validators ─────────────────────────────────────────────────
validators:
name: Validators
runs-on: ubuntu-latest
runs-on: {{RUNNER}}
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
Expand All @@ -54,7 +54,7 @@ jobs:
# ─── Tests (optional) ──────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
runs-on: {{RUNNER}}
if: hashFiles('**/*.test.ts', '**/*.spec.ts') != ''
steps:
- uses: actions/checkout@v5
Expand Down
2 changes: 1 addition & 1 deletion templates/workflows/pr-auto-merge.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
runs-on: {{RUNNER}}
steps:
# Only the shared pattern file, so the gate reads the same secret shapes
# as every other check instead of carrying its own copy. A sparse checkout
Expand Down
Loading