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
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"[shellscript]": {
"editor.defaultFormatter": "mkhl.shfmt"
},
"shfmt.executablePath": "shfmt",
"shfmt.executablePath": "${workspaceFolder}/scripts/dev/shfmt.sh",
"shfmt.flags": [
"-i",
"2",
Expand Down
22 changes: 12 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

This repo treats the devcontainer as the canonical “clean room” environment.

If you haven’t used devcontainers before: it’s just a Docker image that contains all the lint/test tools this repo
expects. Running CI inside it means we all see the same results (instead of “works on my machine”).
If you haven’t used devcontainers before, don’t worry: it’s a Docker image with all the tools this repo expects
(shellcheck, shfmt, bats, kcov, markdownlint, systemd-analyze, etc.).
Running CI inside it means we all see the same results, instead of “works on my machine”.

Before opening (or updating) a PR, you must run the full pipeline in the devcontainer and ensure it passes.
Before opening (or updating) a PR, please run the full pipeline in the devcontainer and make sure it’s green.

Why so strict? Most of the code here is Bash + systemd glue, and tiny differences in tool versions (shellcheck,
kcov, markdownlint, systemd-analyze) can change results. The devcontainer keeps reviews and CI predictable.
Why so strict? Most of the code here is Bash + systemd glue.
Small differences in tool versions can change lint results and even test behavior.
The devcontainer keeps reviews and CI predictable.

### CI pipeline at a glance

Expand All @@ -36,7 +38,7 @@ flowchart LR
The pipeline is split into stages so you can run the part you’re working on without waiting for everything.

- `lint-sh`: sanity checks for shell scripts (syntax, shellcheck, formatting).
This catches common Bash footguns before you even boot a Pi.
This catches common Bash footguns before you deploy it onto a host.
- `lint-yaml`: lints YAML files (cloud-init examples and GitHub workflow config).
- `lint-systemd`: verifies `systemd` unit files.
This doesn’t start services; it checks the unit files are valid and consistent.
Expand All @@ -56,7 +58,7 @@ The pipeline is split into stages so you can run the part you’re working on wi
./scripts/ci.sh
```

That is the same pipeline GitHub CI uses.
That runs the same pipeline GitHub Actions runs for this repo.

### Option B: Docker CLI (no VS Code)

Expand Down Expand Up @@ -91,11 +93,11 @@ You can run individual stages by name:

### Local runs (non-devcontainer)

You can run locally with:
If you already have the toolchain installed on your machine, you can also run:

```bash
make ci
```

Local environments can drift (tool versions, missing dependencies). If local results differ from CI, trust the
devcontainer result and treat local runs as “best effort”.
Local environments can drift (tool versions, missing dependencies).
If local results differ from CI, trust the devcontainer/CI result and treat local runs as “best effort”.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,4 @@ format-shell:
exit 0; \
fi; \
echo "Formatting shell scripts with shfmt..."; \
shfmt -w -i 2 -ci -sr "$${files[@]}"
./scripts/dev/shfmt.sh -w -i 2 -ci -sr "$${files[@]}"
44 changes: 31 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
# runner

Bash + systemd appliance for running a single GitHub Actions self-hosted runner on Linux (including Raspberry Pi).
This repo is a small “appliance” (a handful of Bash scripts + systemd unit files) that runs a single
GitHub Actions self-hosted runner on a Linux machine.

Goal:
If you’re new to self-hosted runners: it’s GitHub’s runner program, but running on your own machine
instead of GitHub’s hosted runners.

- Keep exactly one host runner process managed by systemd.
- Route job execution into an ephemeral `systemd-nspawn` guest (systemd PID1 semantics)
via GitHub Actions runner container hooks.
Linux notes:

- This works on general Linux (Raspberry Pi is supported, but it’s not a requirement).
- systemd is used to start/stop things reliably at boot.

What it aims to do:

- Keep a single host runner process managed by systemd.
- When a workflow uses job containers, run the job steps inside a short-lived `systemd-nspawn` guest
instead of using Docker.
(If `systemd-nspawn` is new to you: it’s a lightweight container that boots a small Linux userspace
with systemd inside it.)

## Documentation

Expand All @@ -16,6 +27,9 @@ Goal:

## Quick start (dev + CI)

If you want to run the repo’s checks locally, the easiest path is to use the devcontainer.
It’s a Docker image with the exact lint/test tools CI uses.

Build the devcontainer image:

```bash
Expand All @@ -42,14 +56,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the required pre-PR checks.

## Runtime model

At a glance, systemd manages two key units:
At runtime, systemd manages two services:

- `runner-install.service` (first-boot installer; retried until it succeeds)
- `runner.service` (runs the configured GitHub runner)

## Installation (cloud-init / Pi Imager)

The recommended install flow is:
The normal install flow is designed for “first boot” setups (cloud-init or Pi Imager):

1. cloud-init writes `/etc/runner/config.env`.
2. cloud-init installs a one-time installer unit + bootstrap script.
Expand All @@ -64,10 +78,12 @@ Examples:

Runtime configuration lives in `/etc/runner/config.env`.

Required (first-boot bootstrap):
Bootstrap repo pin (used when the host needs to fetch this repo to install/update itself):

- `RUNNER_BOOTSTRAP_REPO_URL` (a `git clone` URL for this repo or your fork)
- `RUNNER_BOOTSTRAP_REPO_REF` (branch/tag/commit; pinning to a tag/commit is recommended)

- `APPLIANCE_REPO_URL`
- `APPLIANCE_REPO_REF` (branch/tag/commit; pinning to a tag/commit is recommended)
If you omit these, bootstrap defaults to this repo on `main`.

Optional:

Expand All @@ -76,19 +92,21 @@ Optional:
- `APPLIANCE_APT_PACKAGES` (space-separated extra packages for install)
- `APPLIANCE_DRY_RUN=1` (do not modify system; record intended actions)

Runner:
Runner paths:

- `RUNNER_ACTIONS_RUNNER_DIR` (default: `/opt/runner/actions-runner`)
- `RUNNER_HOOKS_DIR` (default: `/usr/local/lib/runner`)

Job isolation (`systemd-nspawn`):
Job isolation (`systemd-nspawn`) settings:

- `RUNNER_NSPAWN_BASE_ROOTFS` (default: `/var/lib/runner/nspawn/base-rootfs`)
- `RUNNER_NSPAWN_READY_TIMEOUT_S` (default: `20`)
- `RUNNER_NSPAWN_BIND` / `RUNNER_NSPAWN_BIND_RO` (space-separated bind mount entries)

## Day-2 operations

These are the “what’s running?” commands you’ll use most often.

Inspect service status:

```bash
Expand All @@ -104,7 +122,7 @@ ls -l /var/lib/runner/installed || true

## Manual install (no cloud-init)

If you cannot use cloud-init, you can install via SSH.
If you can’t use cloud-init, you can still install over SSH.

1. Install prerequisites:

Expand Down
10 changes: 4 additions & 6 deletions cloud-init/user-data.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ write_files:
content: |
# Core config (required)
# Pin to a tag/commit for deterministic builds.
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main

# Optional: where to clone to
# APPLIANCE_CHECKOUT_DIR=/opt/runner
Expand Down Expand Up @@ -48,10 +48,8 @@ write_files:
die "Network not ready yet"
fi

local repo_url="${APPLIANCE_REPO_URL:-}"
local repo_ref="${APPLIANCE_REPO_REF:-}"
[[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required"
[[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required"
local repo_url="${RUNNER_BOOTSTRAP_REPO_URL:-https://github.com/theaussiepom/github-runner.git}"
local repo_ref="${RUNNER_BOOTSTRAP_REPO_REF:-main}"

local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}"
if [[ ! -d "$checkout_dir/.git" ]]; then
Expand Down
23 changes: 18 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# Architecture

This document explains how runner fits together, in plain language.

runner is a systemd-managed appliance for running a single GitHub Actions self-hosted runner.

The runner process lives on the host, but job execution is intended to run inside an ephemeral
`systemd-nspawn` guest (systemd PID1 semantics).
The runner process lives on the host.
When a workflow runs job steps “in a container”, those steps are intended to run inside an ephemeral
`systemd-nspawn` guest.

If `systemd-nspawn` is new to you: it’s a lightweight way to boot a small Linux userspace
with systemd as PID 1.
In this repo we create the guest for a job, run the step(s), then throw the guest away.

## Glossary

Expand All @@ -15,6 +22,11 @@ See [Glossary](glossary.md).
- Make first-boot installs idempotent and retryable.
- Avoid Docker at runtime by routing containerized jobs through `systemd-nspawn`.

Non-goals (helpful framing):

- This is not a multi-runner fleet manager.
- This is not trying to replace GitHub Actions; it’s a runner installation with a strict runtime model.

## High-level component map

```mermaid
Expand Down Expand Up @@ -79,7 +91,7 @@ sequenceDiagram
alt already installed
BOOT-->>SD: exit 0
else not installed
BOOT->>REPO: clone/fetch APPLIANCE_REPO_URL@APPLIANCE_REPO_REF
BOOT->>REPO: clone/fetch RUNNER_BOOTSTRAP_REPO_URL@RUNNER_BOOTSTRAP_REPO_REF
BOOT->>INST: exec installer from checkout
INST-->>SD: enable units, write installed marker
end
Expand All @@ -96,8 +108,9 @@ sudo systemctl restart runner-install.service

`runner-service.sh` sets `ACTIONS_RUNNER_CONTAINER_HOOKS` when `container-hooks.sh` is present.

This is intended to allow the runner to execute containerized jobs without Docker by routing the hook
callbacks through `ci-nspawn-run`.
This is intended to allow the runner to execute containerized jobs without Docker.
The runner calls the hook script with a small JSON payload; our hook implementation translates that
into a `ci-nspawn-run` invocation.

Limitations:

Expand Down
34 changes: 26 additions & 8 deletions docs/config-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,48 @@ runner is configured via `/etc/runner/config.env`.

Notes:

- `APPLIANCE_REPO_URL` + `APPLIANCE_REPO_REF` are required for first-boot installs (bootstrap clones the repo).
- `RUNNER_BOOTSTRAP_REPO_URL` + `RUNNER_BOOTSTRAP_REPO_REF` tell the host where to `git clone` this installer from.
Use your fork URL if you maintain one.
If you omit them, bootstrap defaults to this repo on `main`.
- A line like `FOO=` means “set but empty”.

If you’re not sure what to put in here:

- Start with the “Minimal” example.
- Then only add the options you actually need.

---

## 1) Minimal: pin the repo

```bash
# Required: bootstrap pin
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
# Preferred: bootstrap pin
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main
```

What this does:

- On first boot, runner’s bootstrap script clones `RUNNER_BOOTSTRAP_REPO_URL` at `RUNNER_BOOTSTRAP_REPO_REF` and
runs the installer from that checkout.
- Pinning to a tag or commit is the easiest way to make installs repeatable.

---

## 2) Deterministic installs: pin to a tag or commit SHA

```bash
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=v0.1.0
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=v0.1.0
```

---

## 3) Customize checkout, install packages, and nspawn settings

```bash
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main

# Where bootstrap clones the repo
APPLIANCE_CHECKOUT_DIR=/opt/runner
Expand All @@ -50,3 +63,8 @@ RUNNER_NSPAWN_BASE_ROOTFS=/var/lib/runner/nspawn/base-rootfs
RUNNER_NSPAWN_BIND="/dev/dri:/dev/dri"
RUNNER_NSPAWN_BIND_RO="/etc/resolv.conf:/etc/resolv.conf"
```

Tips:

- `APPLIANCE_APT_PACKAGES` is for anything you want available on the host (for example `jq`).
- The `RUNNER_NSPAWN_BIND*` settings are useful when your jobs need access to a specific device or host file.
4 changes: 2 additions & 2 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ Short definitions for terms used across the docs.

## Appliance concepts

- **Bootstrap**: The first-boot script that fetches/clones a pinned repo ref and runs the installer.
- **Bootstrap**: The first boot script. It clones a pinned repo ref and runs the installer.
- **Install marker**: A file (default: `/var/lib/runner/installed`) that prevents rerunning install.
- **Repo pinning**: Installing from a specific branch/tag/commit via `APPLIANCE_REPO_URL` + `APPLIANCE_REPO_REF`.
- **Repo pinning**: Installing from a specific branch/tag/commit via `RUNNER_BOOTSTRAP_REPO_URL` + `RUNNER_BOOTSTRAP_REPO_REF`.

## Linux + systemd

Expand Down
6 changes: 3 additions & 3 deletions examples/config.env
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Example configuration for runner
# Copy to: /etc/runner/config.env

# Repo pinning for the one-time installer bootstrap (required)
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
# Repo pinning for the one-time installer bootstrap
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main

# Optional: install extra packages
# APPLIANCE_APT_PACKAGES="jq"
Expand Down
6 changes: 3 additions & 3 deletions examples/config.env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Example configuration for /etc/runner/config.env

# Repo pinning for the one-time installer bootstrap (required)
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
# Repo pinning for the one-time installer bootstrap
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main

# Optional: where bootstrap clones to
# APPLIANCE_CHECKOUT_DIR=/opt/runner
Expand Down
10 changes: 4 additions & 6 deletions examples/pi-imager/user-data.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ write_files:
permissions: "0644"
content: |
# Core config (required)
APPLIANCE_REPO_URL=https://github.com/your-org/github-runner.git
APPLIANCE_REPO_REF=main
RUNNER_BOOTSTRAP_REPO_URL=https://github.com/your-org/github-runner.git
RUNNER_BOOTSTRAP_REPO_REF=main

# Optional: where to clone to
# APPLIANCE_CHECKOUT_DIR=/opt/runner
Expand Down Expand Up @@ -41,10 +41,8 @@ write_files:
die "Network not ready yet"
fi

local repo_url="${APPLIANCE_REPO_URL:-}"
local repo_ref="${APPLIANCE_REPO_REF:-}"
[[ -n "$repo_url" ]] || die "APPLIANCE_REPO_URL is required"
[[ -n "$repo_ref" ]] || die "APPLIANCE_REPO_REF is required"
local repo_url="${RUNNER_BOOTSTRAP_REPO_URL:-https://github.com/theaussiepom/github-runner.git}"
local repo_ref="${RUNNER_BOOTSTRAP_REPO_REF:-main}"

local checkout_dir="${APPLIANCE_CHECKOUT_DIR:-/opt/runner}"
if [[ ! -d "$checkout_dir/.git" ]]; then
Expand Down
Loading