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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ bin/runpool the executable and its dispatcher
lib/common.sh config, logging, pool loading, launch agents, deregistration
lib/lifecycle.sh register, set-count, up, down, reregister, remove
lib/apply.sh the pools file, and reconciling the machine to it
lib/scheduler.sh status, autoscale, sweep, clean, schedule
lib/scheduler.sh status, doctor, autoscale, sweep, clean, schedule
lib/notify.sh the optional notifier hook and what triggers it
lib/stats.sh job durations from recorded telemetry
lib/stats.sh job durations from recorded telemetry, and queue times
via contrib/telemetry-join.sh
contrib/ optional pieces the user opts into: job hook, webhook notifier,
demo status fixture
skills/runpool/ agent skill for *using* runpool, shipped with the tool
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,19 @@ The first job after a quiet spell waits about a minute for its pool to come up.
| `apply [--dry-run] [--file PATH]` | Reconcile the machine to a file describing its pools |
| `up` / `down <pool>` | Bring a pool online, or stand it down |
| `status [--json]` | Local state alongside what GitHub actually sees |
| `doctor` | Why is nothing picking this up. Reports; changes nothing |
| `pools` | List registered pools |
| `reregister <pool>` | Recreate GitHub registrations, keeping the local install |
| `remove <pool>` | Deregister and delete a pool |
| `clean [pool]` | Prune work directories, temp, diagnostics, old binaries, caches |
| `stats` | What jobs actually cost, from recorded telemetry |
| `stats [--queue]` | What jobs actually cost, from recorded telemetry. `--queue` adds the wait before each job started |
| `pause` / `resume` | Global kill switch |
| `schedule install\|remove` | The background agents that drive everything above |

**`status --json --local` skips the GitHub query**, reporting those fields as `null`. Anything refreshing on a timer should use it: one API call per pool per minute is thousands a day, and it makes a passive readout fail whenever the network does.

**`doctor` answers "why is nothing picking this up" in one command.** It checks `gh` and its authentication, that GitHub still has the registrations, that the launch agents exist — including the tick agent, which nothing else looks at and without which no pool autoscales at all — and then disk headroom, config permissions and the organisation's runner-group setting. Each failure comes with what to do about it, and it exits non-zero when something is actually wrong. It reports and repairs nothing, so it is safe to run at any moment, including mid-job.

`skills/runpool/` is an agent skill for *using* RunPool: wiring a repository to local CI, choosing a scope, and diagnosing a job that queues and never starts.

## Describing a machine's pools
Expand Down Expand Up @@ -133,7 +136,7 @@ Unset, it reports nothing and works as well. `contrib/notify-webhook.sh` is a re
- **For an organisation, that control is GitHub's, not RunPool's.** A runner group carries `allows_public_repositories`, it is `false` by default, and runners land in the default group, so public repos in the org do not get them. RunPool reads that setting when you register and warns only if it has been turned on. [SECURITY.md](SECURITY.md) covers the whole picture, including what RunPool deliberately does not do.
- **A runner can look healthy while GitHub has dropped it.** GitHub prunes registrations that have not connected for a long time. The local install still starts and connects and then picks up nothing, so jobs queue forever against a pool reporting as running. That is what the `github` column in `status` is for, and `reregister` fixes it.
- **`services:` and `container:` do not force a hosted runner.** Those two workflow keys are Linux-only, but an ordinary `docker run` inside a step works anywhere Docker does, including here.
- **More runners is not obviously more throughput**, and the contention warning scales with pool size: it defaults to six times core count, while a busy pool of N runners reaches roughly N times core count on its own. `runpool stats` and `contrib/telemetry-join.sh` settle both questions on your machine, using queue time rather than argument.
- **More runners is not obviously more throughput**, and the contention warning scales with pool size: it defaults to six times core count, while a busy pool of N runners reaches roughly N times core count on its own. `runpool stats` describes what jobs cost and `runpool stats --queue` adds the wait before each one started, which is the figure that moves when capacity changes. Read it with the qualifier it prints: a wait can be a cold pool waking or a dependency that has not finished, and neither is fixed by more runners. `contrib/telemetry-join.sh` gives you the raw rows to separate them.

## Not on a Mac?

Expand Down
11 changes: 9 additions & 2 deletions bin/runpool
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,14 @@ runpool — on-demand self-hosted GitHub Actions runner pools for macOS
status [--json] [--local]
local state alongside what GitHub actually sees;
--local skips the GitHub query entirely
doctor why is nothing picking this up: gh, registrations,
launch agents, disk, permissions. Reports and
changes nothing; exits non-zero if something is wrong
pools list registered pools
stats job durations by concurrency, to size the pool with data
stats [--queue] what recorded jobs cost, to size the pool with data
--queue adds the wait before a runner picked each
job up, joining the records to GitHub: one API call
per run, so it is not part of plain stats
reregister <pool> recreate GitHub registrations, keep the local install
remove <pool> deregister and delete a pool

Expand Down Expand Up @@ -98,8 +104,9 @@ case "${cmd}" in
down-all) _rp_down_all "$@" ;;
remove) _rp_remove "$@" ;;
status) _rp_status "$@" ;;
doctor) _rp_doctor "$@" ;;
pools) _rp_pools ;;
stats) _rp_stats ;;
stats) _rp_stats "$@" ;;
tick) _rp_tick ;;
autoscale) _rp_autoscale ;;
sweep) _rp_sweep ;;
Expand Down
28 changes: 28 additions & 0 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,34 @@ _rp_scope_path() {
if [ "$1" = "org" ]; then echo "/orgs/$2"; else echo "/repos/$2"; fi
}

# Whether organisation $1's DEFAULT runner group lets public repositories use
# its runners. Echoes 'true', 'false' or 'unknown'.
#
# 'unknown' is a third answer and not a synonym for 'false': reading runner
# groups needs admin:org, and a caller that folded the two together would
# report a permission problem as an all-clear.
#
# GitHub owns this control at organisation scope. The setting defaults to false
# and runners land in the default group because config.sh is never passed
# --runnergroup, so RunPool reads it and reports it and does nothing else. In
# particular it does NOT enumerate the organisation's public repositories to
# re-derive the same answer; SECURITY.md and AGENTS.md both state why the
# repository and organisation cases are deliberately asymmetric.
#
# Shared rather than inline because it now has two callers that must agree:
# `register` reads it once when a pool is created, and `doctor` reads it on
# every run — the setting can be switched on long after the pool exists.
_rp_org_allows_public() {
local pub
pub=$(gh api "/orgs/$1/actions/runner-groups" \
--jq '[.runner_groups[] | select(.default == true) | .allows_public_repositories][0]' 2>/dev/null)
case "${pub}" in
true) echo true ;;
false) echo false ;;
*) echo unknown ;;
esac
}

# ---------------------------------------------------------------------------
# Runner binary
# ---------------------------------------------------------------------------
Expand Down
10 changes: 6 additions & 4 deletions lib/lifecycle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ _rp_register() {
return 1
}

local scope="" target="" count="2" watch="" clean="" tok allow_public=0 vis="" pub=""
local scope="" target="" count="2" watch="" clean="" tok allow_public=0 vis=""
while [ $# -gt 0 ]; do
case "$1" in
--repo|--org|--count|--watch)
Expand Down Expand Up @@ -121,9 +121,11 @@ _rp_register() {
#
# A warning rather than a refusal, and no failing closed, precisely because
# this is not RunPool's control to enforce.
pub=$(gh api "/orgs/${target}/actions/runner-groups" \
--jq '[.runner_groups[] | select(.default == true) | .allows_public_repositories][0]' 2>/dev/null)
case "${pub}" in
#
# The read itself lives in lib/common.sh, because `doctor` reports the same
# setting and this one is consulted only at create: it can be switched on
# the day after and nothing here would ever mention it again.
case "$(_rp_org_allows_public "${target}")" in
true)
_rp_log "WARNING: the default runner group on ${target} has allows_public_repositories=true, so public repositories in that organisation can use these runners. Turn it off in the organisation's Actions runner-group settings unless that is deliberate."
;;
Expand Down
Loading