Runner opt-in
Opting in runs the pull request's own code on this laptop as this user, and it
can read everything this user can: ~/.config/github-runner/config and the
GitHub App private key it names, which mints runner registration tokens for
the repository and belongs to an App holding Administration: write; the
per-repo App credentials under ~/.config/gh-apps/; the gh token; the SSH
agent; the Docker socket; the tailnet.
Not once, either. The opt-in workflow has five jobs, they run here one after another because each runner takes exactly one, and their own timeouts allow a little over two hours. Each installs that pull request's dependencies and runs its scripts. Nothing here bounds any of that, and on this host nothing can: the hooks beside the runner are mode 755 and owned by the user the job runs as, so the gate routes jobs rather than containing them. The hardening plan's non-admin runner user is the structural fix. Until it lands, opt in only to pull requests written here, and the only real gate is that the user has read the diff at the commit they name.
Never opt in on your own initiative. A red pull request is not a request. Wait for their words.
What this is
Hosted runners refuse to start while the account's billing is blocked, so a
pull-request job goes red before a step exists. Nothing about a pull request
routes work here: there is no label, no pull_request trigger, no path filter.
A run exists because somebody with write access dispatched
.github/workflows/pr-optin-run.yml by hand, naming the pull request number
and the commit they read, against a runner registered on this machine for that
pull request alone. Each runner is ephemeral — one job, then deregistered — and
a supervisor registers the next, so the jobs run here in sequence.
Two things follow from being a dispatch rather than a pull-request event, and they are the whole argument for this shape.
The definition that executes is the repository's own pr-optin-run.yml at
refs/heads/main, never the pull request's copy of it. The pull request's code
is checked out as data: it cannot add a step, change a runner, or reach a
secret. A label-gated pull_request run would have executed the pull request's
own workflow file, which is the design this replaced.
The run does not attach its checks to the pull request. The pull request stays red while hosted capacity is blocked, and the run is read on its own. That is the accepted cost of a gate a pull request cannot pull.
Four pieces have to agree, and they do not all live in the same place:
| Piece | Where it lives, and who supplies it |
|---|---|
.github/workflows/pr-optin-run.yml — five jobs, workflow_dispatch alone, runs-on built by format() from the dispatched number, ref: ${{ inputs.head_sha }} with persist-credentials: false |
The target repository's own default branch. Only the runner repository carries it today. |
trust-optin-job.sh, pre-job-optin.sh and clean-workspace.sh, the pre-job gate |
The runner repository's checkout. runner.sh copies all three into the runner directory from a path relative to itself, so whichever checkout you run the gesture from supplies the gate. |
scripts/runner.sh — preflight, registration, the supervisor, teardown |
The same checkout. |
| The dispatch | You. gh workflow run needs write access, so the gesture is collaborator-only by construction, and the pull request carries no state of ours. |
So the gestures below always run the runner repository's copy of runner.sh,
whatever repository is being opted in. Run them from a trusted checkout of its
default branch, never from a worktree of the pull request under review.
The pull request number reaches the runner twice, by different routes. The
pr-<N> runner label, built by format() inside runs-on, decides which
runner GitHub may send the job to. The opt-in record at
~/.config/github-runner/optin/<owner>/<repo>/<N>, written before any job
exists, is what the gate compares the dispatch payload against. The gate finds
the record by the number the dispatch claims, which is not circular: the
record's existence is the authorisation, and a dispatch naming a pull request
nobody opted in finds nothing.
What binds the reading to the run
Three things, and they are why this gesture is worth the risk it carries.
The workflow checks out inputs.head_sha rather than a branch, and its first
step asserts that git rev-parse HEAD equals it. The gate compares that same
input against the head_sha= line in the record. And a dispatch naming any
other commit is not merely refused: the gate deletes the record, which is the
supervisor's only permission to register another runner. The window closes, and
the user opts in again against what they have read.
Where the boundary sits now
Pull-request code still never reaches the persistent runners, la and orin,
but the dispatch gate moved what holds that true, and the file should be read
knowing where it moved to.
workflow_dispatch on refs/heads/main is a trusted event for
trust-main-job.sh, the persistent runners' own pre-job hook, so the event
type no longer excludes an opt-in job from them. Two things do, and they work
from opposite ends.
The runner label set is the routing: neither persistent runner carries
optin, and format() can only append after pr-, so no dispatch input
removes optin from that array or reaches la or orin. And
trust-main-job.sh now refuses pr-optin-run.yml by name, at any ref and
whatever the label set says, before it considers anything else — so a routing
mistake is a rejection rather than a breach.
Holding both in place, verify_pr_runner_routing.py pins the selector
expression to pr-optin-run.yml and refuses any other runs-on inside that
file, including a Main-only selector — which is approved everywhere else and
which here would put a checked-out pull request on a persistent runner.
Opt in
opt in <owner>/<repo> <N> [<sha>]
1. Read the pull request
gh pr view <N> --repo <owner>/<repo> --json number,title,author,headRefOid,isCrossRepository,state
gh pr diff <N> --repo <owner>/<repo>
Show the user the head sha and the diff. Among our own pull requests the author
proves nothing, since every agent in this fleet writes as their login — there the
gate is their reading, at that sha, now. What the author still separates is our
own pull requests from everyone else's, and app/dependabot is the case that
matters most, because a dependency bump's diff is a version string rather than
the code inside the new version, so reading it proves almost nothing.
Refuse and say which check failed:
author.loginis notfairchild, orauthor.is_botis true, or the login ends in[bot]or starts withapp/.- The user has not said they read the diff, or they name a sha that is not
headRefOid. isCrossRepositoryis true. This refusal is now load-bearing rather than academic: the old one rested on GitHub declining to send fork pull requests to self-hosted runners, and a dispatch on Main is not a fork pull request, so a fork's commit would check out and execute here like any other. Nothing in the machinery stops it. This line is the stop.stateis notOPEN.- This pull request is already opted in. These are the reads, and refusing is also correct when any of them cannot be read:
./scripts/runners.sh <owner>/<repo>
cat ~/.config/github-runner/optin/<owner>/<repo>/<N>
The record, the supervisor and the work tree are keyed by pull request, so two can be opted in at once and opting out of one leaves the other alone.
2. Preflight, register, then dispatch
./scripts/runner.sh preflight <owner>/<repo> <N>
./scripts/runner.sh optin <owner>/<repo> <N> <sha>
gh workflow run pr-optin-run.yml --repo <owner>/<repo> --ref main \
-f pull_request=<N> -f head_sha=<sha>
In that order, and the order is the safety. Preflight checks the dependencies,
the GitHub App configuration and its key by path, the three hook files, mise
and the checkout's mise.toml, a running supervisor and a conflicting record,
and prints the current head and any pinned tool not yet installed. optin
installs the gate, installs any missing pins, starts the supervisor with that
toolchain first on its PATH (every job inherits it; the pull request's own
mise.toml and the operator's global mise config are never read), registers
the first ephemeral runner and waits for a
listener the supervisor actually started, failing rather than reporting a
supervisor that cannot register. Only then does the dispatch have capacity to
land on. A dispatch over a failed opt-in queues its jobs against a runner that
will never exist, and a dispatched job has no hosted fallback — the fallback
branch is unreachable for a dispatch on Main, so the run waits until somebody
cancels it.
optin refuses if the pull request has moved: pass the sha they read as the
third argument and the script compares it against the current head rather than
silently binding a newer commit.
Add -f suites=lint,python to run a subset of the five. The names are lint,
python, files, draw and authkit; the default is all five. Two dispatches
for one pull request share a concurrency group, so the newer cancels the older.
If anything fails after optin has returned, roll the opt-in back before
reporting. optin traps its own failures and does this itself, so this covers
a failure of the dispatch step or anything outside the script:
./scripts/runner.sh optout <owner>/<repo> <N>
Report back the runner name, the head sha, the labels, the record path, the gate path, the supervisor's PID, and how many jobs were dispatched — that last is how long the laptop is committed for.
How an opt-in ends
Three ways. There is no label to take off a pull request, so nothing outward has to be undone; what ends is the supervisor's permission to register the next runner.
| Ending | What happened |
|---|---|
opt out |
The gesture below. It deletes the record, signals the supervisor's process group, deregisters and removes the work tree. |
| A push | The dispatch named a commit the record does not hold. The gate refused the job with exit 78 and deleted the record, so the supervisor's loop ends on its next pass. A new head needs a new reading and a new gesture; never reopen one on their behalf. |
| No job served | Five attempts in a row served no job, counting a failed registration and a failed token fetch, so the supervisor stopped rather than looping. An offline laptop reaches this. |
The heartbeat is how the second and third are told apart from the first: the supervisor's last log line names which ending it took. A run dispatched after any of them waits for a runner that is not there, so cancel it.
Watch
watch
gh run list --repo <owner>/<repo> --workflow pr-optin-run.yml --limit 5
gh run watch <run-id> --repo <owner>/<repo>
./scripts/runners.sh <owner>/<repo>
tail -n 5 ~/.local/share/actions-runner-optin/<owner>-<repo>-pr<N>/runner.log
Select the run by workflow and take the newest, not by commit: the run rides
refs/heads/main, so its commit is Main's and not the pull request's. The
lint job writes Ran pull request <N> at <sha> into the run summary, which
is how a run is tied back to what it was dispatched for — a dispatch whose
suites leaves lint out has no such line, so note the run id when you
dispatch it.
A runner appearing and disappearing between two calls to runners.sh is the
supervisor doing its work — five jobs means five registrations. The log's last
line is the heartbeat: it names the time and the runner it is listening as, and
it stops advancing when the supervisor stops.
The supervisor runs under nohup and outlives the session that started it. If
this session ends, those four reads are how the next one picks it up; the three
endings above are how it stops.
Opt out
opt out <owner>/<repo> <N>
./scripts/runner.sh optout <owner>/<repo> <N>
cat ~/.config/github-runner/optin/<owner>/<repo>/<N> # expect: no such file
./scripts/runners.sh <owner>/<repo> # expect: no optin-pr runner
optout refuses a number the record does not name, so a typo stops nothing
rather than tearing down the wrong opt-in. It removes the record first, then
signals the supervisor's whole process group, which reaches the listener, the
worker and anything the job started, then deregisters and deletes the work
tree. Run the two reads afterwards and report both, because a network failure
mid-teardown can leave a runner registered with the record already gone.
Opting out closes future routing only. It does not touch a job already
dispatched and running: the gate ran before that job's first step and will not
run again. Cancel a run in flight separately, and do it before optout if the
job is live, so the teardown is not racing a worker.
gh run cancel <run-id> --repo <owner>/<repo>
Opt out when the run finishes, when the reading is stale, or when stepping away from the laptop. It is not housekeeping: until one of the three endings happens, the supervisor registers a new runner after every job. Running it after an ending that already cleaned up is safe and worth doing, because it also deregisters a runner a network failure may have left behind.
When it will not work
- The target repository has no
.github/workflows/pr-optin-run.ymlon its default branch. Today only the runner repository does. The verifier admits the opt-in selector in that file and nowhere else, and admits no otherruns-oninside it:
uv run --script .github/scripts/verify_pr_runner_routing.py
- The dispatch names a commit the record does not hold, or the record is missing, world-readable, a symlink, or sits under a directory writable beyond its owner. The gate refuses with exit 78 before the first step and says which. A refusal on the sha also closes the opt-in.
- The GitHub App is not installed on the repository, its configuration at
~/.config/github-runner/configis absent, orcurl,jqoropensslis missing. Preflight names whichever it is. Never read that file or the key it points at. - A hook file is missing from the checkout.
optinrefuses to register rather than register without a gate, which is the intended failure. miseis not installed, cannot read the checkout'smise.toml, or a pinned tool will not install. Preflight oroptinrefuses and names it; nothing is recorded or registered. A job that finds the wrong interpreter means the supervisor was started by arunner.shthat predates reading the checkout'smise.toml.