Sparehand

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.

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

WorkerA job runsKept between jobs
Linux or Windows (WSL) with Docker runningin a new container made from GitHub's runner image (ghcr.io/actions/runner), deleted when the job endsnothing
Macas your user on that Mac, under a launchd agentthe runner's work folder (_work) and whatever the job changed in your home
Linux or WSL without Docker, or with --no-containeras your user there, under a systemd user unitthe 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:

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

CommandWhat it does
sparehand runner add WORKER OWNER/REPORegisters 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 listThe 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/REPOStops 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 WORKERIts 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

Setting (hub)DefaultMeaning
SPAREHAND_RUNNER_WAIT1800Seconds a job waits on the worker for a free job slot before it runs anyway
SPAREHAND_RUNNER_IMAGEghcr.io/actions/runner:latestThe container image
SPAREHAND_RUNNER_PACKAGE, SPAREHAND_RUNNER_SHA256GitHub's latest releaseAnother address for the runner's .tar.gz (a mirror, a pinned version), and its checksum
SPAREHAND_GHghThe GitHub command line to use; - to never use it

When something is wrong