Self-hosted GitHub Actions runners configured for JKU Racing firmware
development: a Docker image for Linux (amd64/arm64), and a native
PowerShell provisioning script for Windows (see Windows runners).
This runner includes all tools required for the firmware CI pipeline:
- Rust stable toolchain with
rustfmtandclippy - ESP-IDF Xtensa toolchain for ESP32-S3 development (
espup) - cargo-nextest for fast test execution
- just - Command runner used by the firmware project
- Pkl (v0.31.1) - Apple's configuration language (used by canvas);
/usr/local/binis writable by therunneruser so consuming workflows can self-install a different pinned version without hittingEACCES - maturin - Build Python wheels from Rust code
- Python 3.12 with development headers
- uv - Fast Python package manager
- python3-cffi - C FFI for Python
- SSH - Pre-configured with GitHub's host keys for private submodule access
- Standard build essentials (
build-essential,pkg-config,libssl-dev) - WebKitGTK / Tauri stack for hbf's desktop app, plus
filefor appimagetool
The image builds for both linux/amd64 and linux/arm64 (e.g. Apple Silicon via
OrbStack/Docker Desktop). Architecture-specific downloads (GitHub Actions runner,
Pkl) are selected from BuildKit's TARGETARCH; the Rust, ESP (espup) and Python
toolchains resolve their own host architecture.
Docker Compose and docker build produce a native image by default. To build
explicitly for one architecture:
docker buildx build --platform linux/arm64 -t github-runner .Note: if
TARGETARCHis unset (a build without BuildKit), the Dockerfile falls back todpkg --print-architecture, i.e. the base image's own architecture.
| Variable | Required | Description |
|---|---|---|
URL |
Yes | GitHub repository or organization URL |
GITHUB_PAT |
One of GITHUB_PAT / RUNNER_TOKEN |
Personal access token used to mint a fresh registration token on every container start. Preferred, since it never goes stale. Needs admin:org scope (classic PAT) for org-level runners, or repo/Administration for repo-level runners. |
RUNNER_TOKEN |
One of GITHUB_PAT / RUNNER_TOKEN |
Static runner registration token from GitHub. Expires ~1 hour after creation, so restarts after that will fail unless refreshed. Ignored if GITHUB_PAT is set. |
RUNNER_NAME |
No | Base name for the runner (default: runner) |
RUNNER_LABELS |
No | Comma-separated labels for the runner |
RUNNER_CPUS |
No | CPUs per replica; also caps CARGO_BUILD_JOBS (default: 2) |
RUNNER_MEMORY |
No | Memory per replica (default: 6g) |
A GitHub Actions runner executes one job at a time — there is no concurrency
setting inside the runner. Total parallelism is therefore just RUNNER_COUNT.
Eight replicas (runner-1 .. runner-8) are declared explicitly in
docker-compose.yml, at 2 CPUs and 6 GB each, sized for a 16-core / 64 GB host.
CARGO_BUILD_JOBS is pinned to RUNNER_CPUS — without that, cargo sizes its
thread pool from the host core count and every replica would spawn ~16
threads, oversubscribing the machine.
Memory, not CPU, is what limits the replica count. 8 x 6 GB = 48 GB of the
~58 GB the OrbStack VM exposes. Adding replicas without lowering RUNNER_MEMORY
will overcommit and get builds OOM-killed.
A single CI run only reaches 5 concurrent jobs (four checks in parallel, then
three builds behind needs). The reason more replicas still help is that
concurrency in firmware_ci.yml is keyed per branch, so several runs
execute at once and jobs queue globally.
To run fewer runners, name the services; to run bigger ones, raise the limits:
docker compose up -d --build runner-1 runner-2 runner-3
RUNNER_CPUS=4 RUNNER_MEMORY=10g docker compose up -d --buildReplicas are separate services rather than
deploy.replicasbecause a scaled service shares one set of volumes, and sccache cannot safely share a cache directory between concurrent server processes (see below).
Two caches survive container recreation, both per replica:
cargo-registry-N— the crate download cache.sccache-N— the compiler cache.setup-rust-dualin the firmware repo points sccache at$HOME/.cache/sccache.
Neither may be shared between replicas. sccache keeps its LRU index in memory
per server process, so containers sharing one directory evict against each
other. The registry was shared in an earlier revision and broke CI: unpacked
sources under registry/src disappear mid-compile when another container's
cargo garbage-collects the global cache, producing
could not execute process ... No such file or directory. The cost of not
sharing is N copies of the same crate downloads, which is the right trade.
The runner's _work directory is deliberately not persisted. The firmware
workflow checks out with clean: false to reuse target/, but a stale
submodule target/ surviving git submodule deinit is what produced
could not parse/generate dep info ... No such file or directory build
failures. sccache is content-hashed and immune to that staleness, so it is the
right layer to persist; _work is not.
# Set environment variables
export URL=https://github.com/jkuracing
export GITHUB_PAT=<your-pat>
# Start the runners
docker compose up -d --buildAlways pass
--build. Plaindocker compose up -donly builds when the image is missing, so it will happily keep running a stale image after the Dockerfile orentrypoint.shchanges.
runner-amd64-1 and runner-amd64-2 are linux/amd64 containers on the same
aarch64 host. OrbStack runs them through Rosetta, not qemu, so this is
translation at roughly native speed rather than emulation at a fraction of it.
They carry their own labels — hbf-builder-amd64,fw-builder-amd64 — rather
than the shared fw-builder,hbf-builder pair. A runner is offered a job when
its labels are a superset of the job's runs-on, so adding hbf-builder
here would let ordinary aarch64 work land on a translated container and run
slower for nothing.
Why not cross-compile from aarch64 instead. hbf's binaries pull in
tokio-serial → serialport, which links libudev on Linux. cargo-zigbuild
supplies a cross C toolchain but not an x86_64 libudev sysroot, so a cross
build needs a hand-maintained multiarch sysroot. An amd64 runner needs neither:
the image installs libudev-dev for whatever architecture it is built for.
Two replicas rather than twelve — x86_64 artifacts are published from pushes to
main, not from every PR, so this is a low-duty-cycle lane sized not to
compete with the aarch64 fleet for the host.
docker compose up -d --build runner-amd64-1 runner-amd64-2Linux runs in Docker; Windows does not. Windows containers cannot run on the
ARM64 Parallels VM this targets, so a Windows runner is provisioned natively
onto a machine that is set up once and kept. windows/provision.ps1 is that
provisioning, and it is idempotent -- re-running it upgrades the toolchain and
re-registers against a freshly minted token, which is the intended way to
update a machine rather than only to build one.
provision.ps1 is one self-contained file and the whole procedure. It
needs nothing else from this repo -- the job hooks are embedded and written out
during provisioning -- and it handles x64 and ARM64 identically.
On a blank Windows machine, in an elevated PowerShell:
# See exactly what it would do, without touching anything:
powershell -NoProfile -ExecutionPolicy Bypass -File .\provision.ps1 `
-ServiceAccount '.\ci' -DryRun
# Then for real:
powershell -NoProfile -ExecutionPolicy Bypass -File .\provision.ps1 `
-ServiceAccount '.\ci'-ExecutionPolicy Bypass is not decoration. A default Windows install
refuses to run an unsigned .ps1 invoked by path, with
PSSecurityException: running scripts is disabled on this system. Passing it
on the powershell.exe command line scopes the exemption to that single
process, which is why the script is invoked this way rather than asking you to
change the machine's policy.
Or fetch just that file onto a fresh machine first:
$u = 'https://raw.githubusercontent.com/jkuracing/github-runner/main/windows/provision.ps1'
Invoke-WebRequest $u -OutFile provision.ps1 -UseBasicParsingThat single run installs the toolchain, offers to create the service account, logs in to GitHub, registers the runner as a service and starts it. Nothing needs preparing beforehand -- no PAT to mint, no account to create.
It prompts for exactly two things, both yours, neither stored or displayed by the script:
- the runner account's password, if the account does not exist yet and you ask it to create one. Read twice and compared, because a typo here does not fail here -- it fails later, as a service that installs cleanly and then refuses to start.
- your GitHub login, through
gh's own flow.
config.cmd then asks for the account password a second time. That is
deliberate rather than an oversight: it keeps the password inside the runner
instead of on a command line, where --windowslogonpassword would put it.
A default Windows install will not run an unsigned .ps1 invoked by path. This
bites in two separate places, and both are handled rather than worked around by
loosening the machine's policy -- that is a system-wide security setting, and
changing it so this repo's own two hooks can run would be a poor trade.
Invoking the provisioner. Every documented command goes through
powershell -NoProfile -ExecutionPolicy Bypass -File ..., which scopes the
exemption to that one process. Running .\provision.ps1 directly fails with:
File ...\provision.ps1 cannot be loaded because running scripts is disabled
on this system.
+ FullyQualifiedErrorId : UnauthorizedAccess
Workflow steps. The runner writes each run: block to a temp .ps1 and
invokes it the same way, so on a machine at the Windows default every step
without an explicit shell: fails too. Consuming workflows should set
defaults.run.shell: bash for jobs on these runners — hbf's do.
The job hooks. The runner invokes a .ps1 hook as
powershell.EXE -command ". '<path>'" with no -ExecutionPolicy, and that is
not configurable. Because a non-zero hook fails the job, an unsigned .ps1
hook kills every job in the Set up runner step, before a single workflow
line executes:
Set up runner . : File C:\actions-runner\hooks\job-started-hook.ps1 cannot
be loaded because running scripts is disabled on this system
##[error]Process completed with exit code 1.
So ACTIONS_RUNNER_HOOK_JOB_STARTED / _COMPLETED point at generated .sh
wrappers instead, which re-invoke the .ps1 with the bypass. Both the wrappers
and the scripts live in <RunnerRoot>\hooks\.
.sh specifically, not .cmd: the runner accepts only .sh, .ps1 or .js
and rejects anything else with "is not a valid path to a script". bash is
guaranteed here regardless, since Git for Windows is already mandatory for
shell: bash steps. The wrappers hardcode the absolute Windows path rather
than deriving it from $0, because Git Bash reports a POSIX path
(/c/actions-runner/...) that powershell -File cannot resolve, and they are
written with LF endings -- a shell script with CRLF fails as a confusing
"not found".
Every interactive path degrades to a printed instruction rather than a hang,
which matters because a prlctl exec, WinRM or scheduled-task session has no
console for gh to prompt on, and a hang there is worse than a failure.
Detection is [Environment]::UserInteractive -and -not [Console]::IsInputRedirected.
For those sessions, split the run:
# Long and unattended: toolchain only.
powershell -NoProfile -ExecutionPolicy Bypass -File .\provision.ps1 `
-ServiceAccount '.\ci' -SkipRegistration
# Short and interactive, on the machine itself.
powershell -NoProfile -ExecutionPolicy Bypass -File C:\actions-runner\provision.ps1 `
-ServiceAccount '.\ci' -SkipToolchainThe script installs a copy of itself at <RunnerRoot>\provision.ps1, so the
second half -- and any later upgrade -- is the same command on every machine,
regardless of where the first half was run from.
Registration tries, in order: -RegistrationToken / RUNNER_TOKEN, then
-Pat / GITHUB_PAT, then gh. The gh path is the default and the one worth
using -- its credential is managed and revocable rather than a classic PAT
pasted through a shell. -RegistrationToken is the one that keeps a PAT off
the provisioned machine entirely: mint it where the credential already lives
and pass only the ~1h result.
gh's ordinary login carries read:org while registering an org runner
needs admin:org, so the script asks gh to widen its own scope when a mint is
refused rather than telling you to. A repo-scoped runner
(-Url https://github.com/<owner>/<repo>) needs admin on that repo instead,
and an org that disables repo-level runners reports that as a 404 rather than
a permission error.
config.cmd prompts for the account's password itself, so it never reaches a
command line, an environment variable, or this repo. Create the account first
(you choose the password); the script refuses to invent one:
New-LocalUser -Name 'ci' -Description 'GitHub Actions runner' -PasswordNeverExpiresRunning jobs as LocalSystem is rejected outright, because two independent things break under it and both were found the hard way:
- tauri caches its NSIS toolchain under
%LOCALAPPDATA%\tauri\NSIS. Under SYSTEM that resolves insidesystemprofile, the download reports success, nothing lands, and the bundler dies withUnable to start child process, error 0x2-- which isERROR_FILE_NOT_FOUND, not the x86-emulation failure it reads as. node_modulescreated by a SYSTEM build is owned by SYSTEM, and any later build under another account hangs or failsEPERMon it.
The runner is labelled by what the machine is (windows-arm64 or
windows-x64), not by what it builds. An ARM64 Windows box cross-compiles
x86_64-pc-windows-msvc perfectly well -- verified end to end, including an
NSIS installer whose payload is PE machine 0x8664 -- so labelling an ARM64
machine windows-x64 would be a lie that breaks the first time a real x64
machine joins.
makensis.exe is a 32-bit x86 binary and runs under ARM64's emulation, the
same way the amd64-only pkl this toolchain installs does. Nothing about the
Windows packaging path requires an x64 host.
Established empirically against hbf rather than from vendor docs:
| Tool | Why |
|---|---|
| Git for Windows | Required. Every composite action these workflows use declares shell: bash, which resolves to bash.exe on PATH. Without it the runner registers and then fails every job. |
| VS Build Tools | The MSVC linker. *-pc-windows-msvc cannot link without it. |
| Rust + both MSVC targets | Either direction of cross-compilation from one machine. |
cargo-nextest |
hbf's suite needs it; plain cargo test produces phantom 30s timeouts. |
| clang (LLVM) | ARM64 only -- ring assembles its crypto with it there. x64 links with MSVC alone. |
| Pkl | A build script shells out to it. No ARM64 build exists; the amd64 exe runs emulated. |
| bun | hbf-gui's generate_context! embeds ui/build at compile time. |
| WebView2 | Preinstalled on Windows 11; checked, not assumed. |
gh |
Mints the runner registration token, so no PAT is needed. |
uv + CPython |
Workflow steps assume Python: publish-gui.yml resolves the workspace version with python3 -c 'import tomllib...'. Installed machine-wide via UV_PYTHON_INSTALL_DIR, with a python3.exe copy beside python.exe because Windows CPython ships only the latter while every step written for Linux says python3. |
jq |
The shared vs-registry-auth action parses the registry config with it. Absent, that check fails as "returned 200 but not the registry config (SSO page?)" — pointing at the registry rather than at the missing binary. Linux gets jq from its base packages, so this gap is Windows-only. |
winget is deliberately unused -- it hangs under a non-interactive remote
session on this VM, so every install is curl plus a silent installer.
The job hooks are embedded in provision.ps1 and written to
<RunnerRoot>\hooks\ during provisioning -- that is what keeps the script a
single file. They are PowerShell twins of the .sh hooks, for the same
reasons: resetting an accumulating .gitconfig before each job, and bounding
target/ after it. -DryRun writes them to %TEMP% so you can read exactly
what will be installed.
They are written for Windows PowerShell 5.1 deliberately. The target VM has
no PowerShell 7, so that is what the runner invokes hooks with; a ?? in the
sweep hook would have failed to parse on every job.
The Linux entrypoint exports the cargo knobs before run.sh; a Windows service
has no equivalent hook and the runner's .env is read only by the Linux
systemd unit, so provision.ps1 sets them as machine-level environment and
restarts the service to pick them up.
CARGO_BUILD_JOBS defaults to half the CPUs rather than all of them. This VM
is expected to share a host with other work, and an unthrottled Windows build
starves the OrbStack Linux fleet badly enough that its runners drop with "lost
communication".