Docs › GitHub Actions on your own machines
GitHub Actions on your own machines
GitHub bills hosted runners by the minute, and macOS minutes cost the most. A workflow can run on a machine you own instead, for nothing: GitHub calls that a self-hosted runner. sparehand runner makes a worker one in a single step:
sparehand runner add myworker you/your-repo
Then, in the repository's workflow:
jobs:
test:
runs-on: [self-hosted, sparehand]
The same from the dashboard: GitHub Actions… on the worker's card.
What the one step does: it gets a registration token, puts GitHub's runner on the worker, registers it for that repository as sparehand-myworker, and starts it as a service of your user there. From then on a job of that repository with those labels runs on the worker, as one of its jobs: it takes a job slot, shows in sparehand status, and waits while the worker is paused or full.
Before you add one: public repositories
Use self-hosted runners for private repositories. On a public repository anyone can open a pull request, and a pull request can change a workflow, so a stranger can make your machine run their code: on your network, with what the runner's user can reach, and possibly leaving something behind for the next job. GitHub's own documentation gives the same advice.
Sparehand asks GitHub whether the repository is public before it does anything else. If it is, runner add stops (exit 3) and says why. At a terminal you can go on by typing the repository's name; a script needs --public; the dashboard asks in a box of its own. If you do go on, set the repository to require approval for workflows from outside contributors (the repository's Settings → Actions → General), and prefer a Linux worker with Docker, where each job gets its own container.
When GitHub can't be asked (no network, or gh signed in to an account that can't see the repository), runner add stops too: say which it is yourself with --private or --public.
The token
Registering a runner takes a registration token from GitHub, good for one hour.
- With GitHub's command line on the hub (
gh auth login, as an account that administers the repository): Sparehand asksghfor the token. Nothing to paste. - Without it: open the repository's Settings → Actions → Runners → New self-hosted runner (
https://github.com/you/your-repo/settings/actions/runners/new) and copy the token from the line that starts with./config.sh. At a terminalrunner addasks for it, without showing it; the dashboard has a field for it. A script gives it on stdin withSPAREHAND_RUNNER_TOKEN_STDIN=1.
The token goes to the worker over the SSH connection's stdin and to GitHub's config.sh in its environment. It is never an argument (other users of the machine can read arguments), and it is not kept: not on the hub, not on the worker, not in the dashboard's log. What stays on the worker is the runner's own credential, which GitHub issues at registration and which only that runner can use. A personal access token pasted by mistake is refused.
Where a job runs
| Worker | A job runs | Kept between jobs |
|---|---|---|
| Linux or Windows (WSL) with Docker running | in a new container made from GitHub's runner image (ghcr.io/actions/runner), deleted when the job ends | nothing |
| Mac | as your user on that Mac, under a launchd agent | the runner's work folder (_work) and whatever the job changed in your home |
Linux or WSL without Docker, or with --no-container | as your user there, under a systemd user unit | the same |
The container gets three folders of the runner and nothing else of the machine: no Docker socket, no extra rights, not your home folder. That has a cost: a job in it can't start containers itself, so workflows that use container:, services: or Docker actions need --no-container.
A job that runs as your user can read what you can read on that machine: SSH keys, tokens in your home, the other checkouts. That is the reason for the advice above, and for using a container where there is one. macOS has no containers for macOS jobs, so a Mac worker always runs them as your user. If that matters, give the Mac a separate user account for Sparehand and add the worker through it.
On a Mac the launchd agent runs while your user is logged in there (turn on automatic login for a Mac that restarts unattended). On Linux the unit runs without a login once lingering is on, which runner add tries to turn on (loginctl enable-linger); on a WSL without systemd, start the runners again after Windows restarts with sparehand runner start myworker.
A runner's job is one of the worker's jobs
The worker runs GitHub's listener for one job at a time. Sparehand's supervisor around it applies the worker's rules, the same as for sparehand run:
- Job slots. A job that arrives when the worker's slots are taken (
sparehand set NAME jobs N) waits in its first step, "Set up runner", and its log at GitHub says so. It starts when a slot is free, or after 30 minutes anyway (SPAREHAND_RUNNER_WAIT, in seconds). While it runs it holds a slot, sosparehand runsends other work elsewhere. A runner that only listens holds none. - Full or paused. While the worker has no free slot, or is paused (
sparehand pause NAME), the listener isn't running at all: GitHub shows the runner offline and keeps the jobs in its queue (for up to a day) until the worker has room again. - Pause while a job runs stops the job, as it stops a
sparehand runjob. Arunjob is then rerun elsewhere; an Actions job can't be handed back, so GitHub shows it as failed. Run it again there once the worker is resumed. - The watchdog. A job is stopped when the worker is about to run out of memory or overheats (only the newest job there), or when its GPU gets busy with someone's game. Same result at GitHub: failed, run it again.
- Worker mode. With
SPAREHAND_WORKER_MODE=autothe worker's apps close when a job starts and reopen after the idle time, as for any job. - Stopping one.
sparehand statuslists it ("GitHub Actions job, you/your-repo: CI / test #12"), the dashboard's Jobs tab too, andsparehand stop NAME PIDends it.
The runner's settings are read when it is added. After changing SPAREHAND_WORKER_MODE or SPAREHAND_RUNNER_WAIT, remove the runner and add it again; a changed job limit reaches it at once.
Commands
| Command | What it does |
|---|---|
sparehand runner add WORKER OWNER/REPO | Registers the worker as a runner for that repository and starts it. --labels a,b: its labels besides GitHub's own (self-hosted, the system, the architecture); default sparehand. --name NAME: its name at GitHub (default sparehand- and the worker's name). --no-container: jobs run as your user even where Docker runs. --public: yes, the repository is public (see above); --private: don't ask GitHub. |
sparehand runner list | The runners this hub set up: worker, repository, name, and how its jobs run. --tsv for scripts. |
sparehand runner status [WORKER] | What each does now: listening, running a job (which), taking no job (why), stopped, or not working (with the reason). With gh signed in, also whether GitHub sees it online. --tsv for scripts. |
sparehand runner remove WORKER OWNER/REPO | Stops it, deregisters it at GitHub (a remove token, from gh or pasted like the registration token) and deletes everything of it from the worker: the service, the runner, its credential, the container and the image Sparehand pulled for it. --local: from the worker only; GitHub then lists it as offline until you remove it on the repository's runner page. |
sparehand runner stop WORKER · runner start WORKER | Its runners' services off and on. Stopped, GitHub shows them offline and keeps their jobs. |
sparehand remove WORKER (and the dashboard's "Remove and clean up") covers the runners: each is deregistered at GitHub when gh is signed in, and all of them are deleted from the machine with the rest. Without gh the answer names the page where the leftover entry at GitHub is removed.
A cloud worker can't be a runner: it is stopped while idle, so GitHub would see it offline most of the time.
What it needs
- On the hub: nothing more.
ghis optional (it saves pasting the token and letsstatusask GitHub). - On a Mac worker, or without Docker:
curlandtar(both come with the system), and a few hundred MB of disk for the runner. The package comes from GitHub's releases ofactions/runner; when the hub can look the release up, the worker checks the download against the SHA-256 GitHub publishes. - On Linux or WSL with Docker: the Docker daemon running, and your user allowed to use it. Docker Desktop on Windows works once its WSL integration is on for that distribution.
- GitHub stops sending jobs to a runner that is far behind the current version. The plain runner updates itself between jobs; for the container, Sparehand pulls the image again once a day.
| Setting (hub) | Default | Meaning |
|---|---|---|
SPAREHAND_RUNNER_WAIT | 1800 | Seconds a job waits on the worker for a free job slot before it runs anyway |
SPAREHAND_RUNNER_IMAGE | ghcr.io/actions/runner:latest | The container image |
SPAREHAND_RUNNER_PACKAGE, SPAREHAND_RUNNER_SHA256 | GitHub's latest release | Another address for the runner's .tar.gz (a mirror, a pinned version), and its checksum |
SPAREHAND_GH | gh | The GitHub command line to use; - to never use it |
When something is wrong
- "GitHub gave no registration token": the account
ghis signed in with isn't an administrator of the repository. Sign in as one, or paste a token. - "GitHub didn't register it": the token was wrong, older than an hour, or for another repository; or a runner of that name exists there already (
--namegives another). - Status says "not working": the listener ends as soon as it starts. The reason is on the same line; the usual ones are a runner that was removed on GitHub's page (remove it here and add it again) and no network on the worker. Its log is
~/.sparehand/runners/OWNER__REPO/logthere. - Jobs stay queued at GitHub:
sparehand runner statussays whether the worker is paused, full or off, and the workflow'sruns-onmust name labels the runner has. - A job failed at GitHub with no error of its own (it says the runner stopped or was lost): Sparehand stopped it (pause, memory, heat, a game on the GPU, or
sparehand stop). The worker's log above says which.