Runski: turn idle Macs into GitHub Actions runners

A Mac that spends part of the day idle can still be useful to a software team. It may already have Xcode, a compiler, and the dependencies needed to build the team's application. The missing piece is a way to give it work from the same system that runs the rest of the team's continuous integration.

Runski turns that Mac into a GitHub Actions runner. It is written in Swift and executes jobs on macOS while GitHub manages workflow scheduling and displays the results. You can run it in the foreground, keep it running through a login agent, and choose whether it should accept new jobs only when the Mac is idle.

What runs on the Mac

A workflow still lives in your repository under .github/workflows/. GitHub handles triggers, job dependencies, matrix expansion, and runner selection. Runski registers the Mac, waits for a matching job, executes its steps, and sends status and logs back to GitHub.

Part of the workflowRunski's role
Shell stepsExecute commands using the Mac's installed tools
JavaScript actionsRun Node 20 or Node 24 actions, with action pre/post hooks
Composite actionsExecute the action's nested steps and pass inputs and outputs
Workflow behaviorEvaluate expressions and conditions; handle environment files, step outputs, cancellation, and continue-on-error
Job reportingStream logs and results to the Actions interface and retain local job logs

This makes Runski useful for teams that want to use existing Mac hardware for builds and tests. It also means the machine's actual setup matters. Install the Xcode version, SDKs, signing setup, and other tools that your workflow requires. Runski does not supply the full software image of a GitHub-hosted macOS runner.

Build from source

The current project requires macOS 14 or later and a Swift toolchain. The package declares Swift tools 5.10. Start with Apple's Xcode Command Line Tools installed and check that swift --version works.

Runski currently installs from source; there is no published Homebrew formula or tagged binary release. Build the executable, check its command help, and place it in a stable location:

git clone https://github.com/looskis/runski
cd runski
swift build -c release
.build/release/runski --help
mkdir -p "$HOME/.local/bin"
install -m 755 .build/release/runski "$HOME/.local/bin/runski"
export PATH="$HOME/.local/bin:$PATH"

The PATH change above applies to the current shell. Add that directory to your shell configuration if you want to use runski from future terminals. Keeping the executable in a stable location also matters when installing the login agent, which records its path.

Register a runner and try one job

In a private repository you administer, follow GitHub's runner registration steps: open Settings → Actions → Runners → New self-hosted runner and obtain a registration token. Replace OWNER/REPO below with your repository, and supply that token through the RUNSKI_REGISTRATION_TOKEN shell variable. The token authorizes registration; it is not the runner's name or a workflow secret.

runski register \
  --url https://github.com/OWNER/REPO \
  --token "$RUNSKI_REGISTRATION_TOKEN" \
  --name spare-mac
runski run

Keep Runski in the foreground for the first check. It automatically adds labels including self-hosted, runski, the operating system, and the architecture. In that repository, add this manually triggered workflow on the default branch:

name: Runski smoke test
on:
  workflow_dispatch:
jobs:
  mac-check:
    runs-on: [self-hosted, runski]
    steps:
      - name: Inspect the host
        run: |
          sw_vers
          uname -m

Run it from the repository's Actions tab. The label set makes the job eligible for any accessible runner carrying both labels; it does not pin the job to a specific machine. Give runners extra labels with --labels when workflows need a particular toolchain or class of hardware.

Once your own smoke test succeeds, stop the foreground process before running runski daemon install. That installs a per-user LaunchAgent, starts it, and configures it to start at login and restart after a crash. It requires a logged-in user session; it is not a system service that starts before login.

Share capacity with the person using the Mac

Idle scheduling is optional and disabled by default. To accept new jobs only after ten minutes without keyboard or mouse input, configure the idle threshold before starting Runski:

runski config --idle-seconds 600
runski daemon install
runski status
runski logs

If the agent is already installed and running, apply the configuration and use runski daemon restart when no jobs are active. The daemon reads its configuration at startup.

When idle gating is enabled, Runski takes a slot offline while the user is active and brings it back after the idle threshold. A job already running is allowed to finish when the user returns. This avoids cancelling a build halfway through, but the build can still compete with the person's work until it completes.

For concurrency, add --slots 2 when registering the runner. That registers two slots, named spare-mac and spare-mac-2 in this example. Each can execute one job and has its own working directory. They still share CPU, memory, disk, and the host environment. Pick a slot count that fits the builds you actually run.

Runski can prevent automatic idle sleep during a job. It cannot guarantee availability through shutdowns, deliberate sleep, lost connectivity, or a user logging out.

Where credentials, secrets, and logs go

Runski stores its state under ~/.runski by default. Its credential vault uses the Secure Enclave when available. The implementation falls back to a software key and prints a warning when it cannot use the enclave. Check the reported vault backend before relying on hardware-backed protection.

DataWhere it goes
Runner credentialsSealed in the local vault and used to authenticate with GitHub
Optional local secretsStored through runski secrets, then exposed to job steps as environment variables by default
Job logsStreamed to GitHub and saved under ~/.runski/logs/jobs/
Optional HTTP log sinkReceives log batches at the endpoint you configure

Local secrets can be entered through stdin using runski secrets set NAME. runski config --no-local-secrets disables their automatic injection into job environments. Runski masks known secrets in logs, but a workflow can still read secrets made available to it and send data elsewhere. Encrypted storage and log masking do not make untrusted workflow code safe to run.

Use trusted repositories and control which workflows can reach the runner. GitHub's self-hosted runner guidance explains that these machines are managed by you and need not start clean for every job. Jobs execute with the access of the user running Runski. Separate working directories are useful organization, not isolation from the rest of that user's files.

Compatibility and hosted fallback

Runski supports shell, JavaScript, and composite actions. Docker actions, container jobs, and service containers are unsupported on this macOS runner. Problem-matcher commands are accepted, but their matching behavior is not implemented. JavaScript actions may require Runski to download the corresponding Node runtime when a suitable local one is unavailable.

The repository also includes a pick-runner composite action. A separate selector job can query runner availability and return either self-hosted labels or a hosted fallback such as macos-latest. The action currently lives inside the Runski repository; the standalone looskis/pick-runner@v1 reference shown in its draft documentation is not published.

The selector needs a token allowed to list the relevant runners. Its require-idle option excludes runners already busy with jobs; that is separate from Runski's keyboard-and-mouse idle threshold. Selection is a point-in-time check, not a reservation. If a Mac goes offline afterward, the queued job does not automatically move to a hosted runner.

Start with one trusted repository, one slot, and a small workflow. Confirm toolchain compatibility and observe the effect on the Mac before expanding. Read the Runski reference for the full CLI and implementation, or explore the other Looskis tools.

Frequently asked questions

Can I install Runski with Homebrew?

The current installation path is to clone the repository and run swift build -c release on macOS 14 or later with a suitable Swift toolchain. There is no published Homebrew formula or tagged binary release yet.

Does idle mode stop a build when I return to my Mac?

No. With idle gating enabled, Runski waits for the configured period without keyboard or mouse input before accepting new work. Jobs already running can finish when you return.

Are credentials always protected by the Secure Enclave?

Runski uses the Secure Enclave when available and falls back to a software key with a warning when it is unavailable. Check the reported vault backend. Secrets exposed to a running job are accessible to its code regardless of how they were stored.

Can one Mac run several jobs at once?

Yes. Register with --slots to create multiple runner slots, each able to execute one job with a separate working directory. Those jobs share the host resources and user account.

Does Runski support every GitHub Actions workflow?

No. Shell, JavaScript, and composite actions are supported, but Docker actions, container jobs, and service containers are not. Problem matchers are not applied, and workflows still need compatible tools installed on the Mac.

Will a queued job automatically switch to GitHub-hosted hardware?

No. The included pick-runner action can select hosted or self-hosted labels before a job is scheduled. It does not reserve a Mac or migrate an already queued job if that Mac later goes offline.

Sources & further reading

Talk with us about your workflow →