Operations¶
Day-to-day maintenance, health repair, drain/resume, and routine procedures for a live fleet.
Health check¶
./health.sh # report: launchd + GitHub state per runner
./health.sh --repair # also restart any service that is loaded but dead
health.sh exists because no runner plist sets KeepAlive. runsvc.sh
supervises the listener (Runner.Listener), so a crashed listener recovers on
its own. A crashed or OOM-killed RunnerService.js does not. LaunchD will not
revive it, and the only symptom is one repo's jobs queueing forever while every
other repo looks fine.
health.sh --repair restarts dead services. It skips draining and drained
runners intentionally — a repair that undoes a deliberate drain is worse than
no repair.
Scheduled maintenance¶
Add two LaunchAgents on a real fleet:
<!-- ~/Library/LaunchAgents/com.runner-fleet.health.plist -->
<!-- health.sh --repair every 30 minutes -->
<!-- ~/Library/LaunchAgents/com.runner-fleet.cleanup.plist -->
<!-- cleanup.sh --apply weekly (e.g. Sunday 02:00) -->
See examples/launchd-health.plist and examples/launchd-cleanup.plist for
ready-to-install templates.
Draining a runner for maintenance¶
Stopping a runner with svc.sh stop kills whatever it is building, leaving a
red build on GitHub. drain-runner.sh drains gracefully instead.
scripts/drain-runner.sh project-ios-2 --drain # stop after current job
scripts/drain-runner.sh project-ios-2 # check drain state
scripts/drain-runner.sh project-ios-2 --resume # put it back
An idle runner stops immediately. A busy runner is marked draining
and stops after its job, which requires the completion hook installed:
Without the hook, the drain intent is recorded but the service is not stopped automatically — drain the runner again once it is idle.
The dashboard shows draining / drained badges and offers Drain/Resume
buttons in the runner drawer.
Taking a whole host out of rotation¶
Put a .drain file at the fleet root:
echo drained > ~/actions-runners/.drain # this Mac takes no new runners
rm ~/actions-runners/.drain # back into rotation
The agent reports this state on its next heartbeat. Placement will not choose a drained host. Runners already on the host keep working — this stops the fleet putting new work there, not existing work.
Disk cleanup¶
What it cleans:
- DerivedData older than 7 days (keyed on mtime, so actively used projects are
never candidates)
- Unavailable simulators (xcrun simctl delete unavailable)
- Runner _diag logs older than 14 days
- Playwright browser cache sizes (reported every run)
- Stale Playwright __dirlock files older than 6 hours (skipped while an
install is in flight)
Refuses to run while any job is in flight. Deleting DerivedData under a
live xcodebuild produces a failure that looks like a code problem — the worst
kind of CI flake to chase. Stale Playwright locks are only removed when no
playwright install is running.
Playwright caches can be several gigabytes per runner. If disk alerts fire
repeatedly, check cache sizes in the cleanup dry-run output and confirm
workflows export PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TOOL_CACHE/ms-playwright
so each runner keeps its own copy.
Backup¶
# Stop the daemon while copying, or use SQLite's online backup:
sqlite3 dashboard/fleet.db ".backup dashboard/fleet.db.bak"
The only thing worth backing up is fleet.db — it holds every run and job ever
recorded, and GitHub discards run detail after 90 days. The scripts are in git.
The runner registrations are in actions-runners/*/. runner but can be
re-registered; the database history cannot be recovered once GitHub's copy ages
out.
Logs¶
cd dashboard && ./fleetctl.sh logs # tail daemon log (stdout+stderr)
cd dashboard && ./fleetctl.sh logs --lines 200 # last 200 lines
ls ~/actions-runners/project-ios/_diag/ # runner's own diagnostic logs
The runner writes a new _diag/Runner_ log for every started listener. These
grow without bound and are cleaned by cleanup.sh.
Deregistering a runner¶
scripts/deregister.sh project-ios-2 # dry run
scripts/deregister.sh project-ios-2 --apply # actually remove it
deregister.sh refuses if:
- The runner is currently busy (mid-job)
- It is the last runner for its repo (removing the last would leave the repo
with no runner at all)
To deregister the last runner for a repo, you need to do it from the GitHub UI.
Re-registering a stale runner¶
A runner whose token has expired or that missed too many GitHub heartbeats shows
as offline permanently. Re-register it:
scripts/deregister.sh project-ios --apply # remove old registration
./register.sh owner/project-ios # fresh registration
If the runner directory is healthy but the registration is stale, the same
command will overwrite it — register.sh detects an existing registration and
removes it before creating a new one.
Incident checklist¶
If a job has been queued for more than a minute:
- Open the Runs tab — hover the queued indicator to see the classifier verdict.
- Open the Fleet tab — find the runner(s) for that repo. Are they online?
- If
runner-down: open the runner drawer → recent events show why it stopped. Try./health.sh --repair. - If
label-mismatch: the job'sruns-onrequires a label no runner has. Register a runner with that label or update the workflow. - If
repo-capacity: the queue is longer than the runner count can absorb. Use the Capacity tab to add a runner, or enable autoscaling. - If
host-saturation: the host is too busy. Check the load/swap meters. - If
github-delay: the runner is ready; GitHub is slow to dispatch. Wait.
If the dashboard is not updating (collector lapsed):