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 agent executes one job at a time, and there is no
option to make it take more. That is deliberate rather than a limitation to work
around: the agent has no isolation boundary inside it, so a job gets _work,
the tool cache, the whole filesystem and every port to itself. Two jobs in one
agent would race on all of it.
Concurrency therefore means more agents, not busier ones. That is the canonical answer on every platform, and it is what each of the three lanes here does:
| Lane | More agents by |
|---|---|
| Linux | one container per agent — runner-1 .. runner-12 in docker-compose.yml |
| macOS | ./provision.sh -n <N> |
| Windows | .\provision.ps1 -n <N> |
Twelve aarch64 replicas plus two amd64 are declared explicitly in
docker-compose.yml, at 2 CPUs and 4 GB each (RUNNER_CPUS, RUNNER_MEMORY).
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 one thread
per host core, oversubscribing the machine.
Memory, not CPU, is what limits the replica count. The limits are ceilings
rather than reservations, so overcommitting works right up until several
replicas peak together and the kernel starts killing builds — which reads as a
random compiler crash, not as an out-of-memory problem. start.sh prints the
total against what the VM actually has.
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.
They start with the rest of the fleet — a plain docker compose up -d --build
brings up all fourteen runners.
This overcommits memory, deliberately. Limits are ceilings rather than
reservations, and OrbStack exposes ~39 GiB to containers, so twelve replicas at
4 GB were already 48 GB of ceilings against 39 GiB. Fourteen makes it 56 GB.
That works while replicas do not peak together and gets builds OOM-killed when
they do. If that starts happening, lower RUNNER_MEMORY or run fewer aarch64
replicas by naming them:
RUNNER_MEMORY=3g docker compose up -d --build
docker compose up -d --build runner-1 runner-2 runner-3 runner-amd64-1The amd64 image is built with INSTALL_ESP=0: this lane builds hbf's Linux
binaries, and firmware's Xtensa work runs natively on the aarch64 fleet, so the
ESP toolchain would be dead weight. Its labels are narrowed to
hbf-builder-amd64 accordingly — a runner without that toolchain must not
advertise itself as a firmware builder.
Several steps here fetch from GitHub releases with no token — espup, the
just installer, the runner tarball, Pkl. The host gets 60 unauthenticated API
calls per hour, shared across everything on that IP, and when it runs out a
rebuild fails in whichever of those steps it reaches first:
espup: Failed to get latest Xtensa Rust version: ... 403 Forbidden
just: curl: (22) The requested URL returned error: 403
Neither is architecture-specific and neither indicates a broken image. Check before assuming a real failure:
curl -s https://api.github.com/rate_limit | jq .resources.coreOnly if a workflow asks for it. Label matching is an exact-string superset
test, not a prefix match, so hbf-builder-amd64 does not satisfy a job
requesting hbf-builder. Every self-hosted job in the org names an exact
label today ([fw-builder], [hbf-builder], [windows-arm64]), so nothing
can drift here by accident.
The one way it could: GitHub adds implicit self-hosted, Linux and X64 /
ARM64 labels to every runner, so a future job written as
runs-on: self-hosted or runs-on: [self-hosted, Linux] would match these
containers as readily as the native ones. Name the builder label explicitly and
that cannot happen.
Linux runs in Docker; macOS cannot. It is not virtualisable into the fleet, and
Apple's licence ties macOS VMs to Apple hardware, so a macOS runner is
provisioned natively onto a Mac that is set up once and kept.
macos/provision.sh is that provisioning, idempotent in the same way
windows/provision.ps1 is: re-running upgrades the toolchain and re-registers
against a freshly minted token.
The org has no GitHub Actions budget, so hosted jobs fail before they start:
The job was not started because recent account payments have failed
or your spending limit needs to be increased.
Linux work moved onto the fleet. macOS work had nowhere to go. Without a self-hosted Mac these stay stranded:
| workflow | job |
|---|---|
hbf publish-gui.yml |
the Hbf.app bundle (runs-on: [self-hosted, macos-arm64]) |
hbf publish-binaries.yml |
macos-aarch64, macos-x86_64 |
./macos/provision.sh --dry-run # derive everything, touch nothing
./macos/provision.sh # for realNothing needs preparing: with no credential in the environment the script asks
gh to mint the registration token, logging in and widening its own scope if it
has to (an org runner needs admin:org, where gh's ordinary login carries
read:org). GITHUB_PAT and RUNNER_TOKEN still work and take precedence, in
that order — RUNNER_TOKEN is the one that keeps a PAT off this machine
entirely.
The derived set is macos,macos-arm64,xcode-<major>. It deliberately does
not include hbf-builder, even though a Mac is perfectly capable hardware.
Fifteen hbf jobs ask for that label and every one is written for the Linux
fleet — apt deps, the WebKitGTK/Tauri stack, AppImage packaging, the xdg-open
shim, libudev. Since a runner takes any job whose runs-on its labels cover,
carrying hbf-builder would make all fifteen eligible to land here and fail,
non-deterministically, depending on which runner was free.
svc.sh install would write a LaunchAgent, and an agent starts at login.
After a reboot the runner would not come back until someone logged in, and jobs
would queue with no error anywhere — indistinguishable from a stalled fleet
until you go looking. So the provisioner writes the plist itself, into
/Library/LaunchDaemons, with RunAtLoad: it comes up at boot, unattended.
Skipping svc.sh means inheriting one step from it: the plist's program is
<runner root>/runsvc.sh, which the tarball ships only as bin/runsvc.sh, and
it is svc.sh install that normally copies it up. The provisioner does that
copy itself. Omitting it is silent in the worst way — launchd exec's a missing
file, both daemon logs stay zero bytes, launchctl print still shows the
service loaded, and the runner just never comes back after a reboot.
UserName is what makes a daemon usable rather than merely early. Homebrew and
rustup live in your home, so a root-owned daemon would run with a PATH
pointing at a toolchain in /var/root that does not exist. Running as the
invoking user keeps the toolchain, the cargo registry and the sccache config
exactly where the toolchain step put them. There is no separate service
account: it is not needed here, and one more account is one more thing to own.
SessionCreate gives each job its own security session. Not needed for today's
ad-hoc signing (publish-gui.yml asserts Signature=adhoc), but it is what a
Developer ID identity in the login keychain would later want, and it costs
nothing now.
Run the script as yourself, not with sudo. Homebrew and rustup install
into $HOME, so a fully-elevated run would put them in /var/root and leave
root-owned files behind. The single privileged step — installing the daemon —
calls sudo on its own, and prompts you once.
Re-running replaces the daemon cleanly: any actions.runner.* plist pointing
at this runner root is booted out and removed first, including a LaunchAgent
left by an earlier version of this script.
Xcode is a ~20 GB Apple-account-gated download, and which version a build machine carries is a decision rather than a detail. The script verifies it and explains what is wrong instead.
Version matters. hbf's publish-gui.yml needs Xcode 27 or newer: Icon
Composer saved hbf-gui/icons/icon.icon with 27-era features that Xcode 26.6's
actool cannot open. The provisioner warns when the installed Xcode is older,
because that job is routed here by macos-arm64 regardless and will fail in
actool.
No xcode-<major> label is published, and that is deliberate — see
Labels.
Default: macos,macos-<arch>, on top of the self-hosted, macOS and ARM64
labels GitHub attaches by itself.
Labels name what the machine is, not what it builds — the same rule as the
Windows runner. An arm64 Mac cross-compiles x86_64-apple-darwin perfectly
well, so macos-x64 would be a lie that breaks the day an Intel Mac appears.
No xcode-<major> label, though an earlier version published one. xcode-27
is a GitHub-hosted image label: a workflow naming it in runs-on goes to
GitHub's hosted pool however many self-hosted runners carry the same string, so
it could never steer publish-gui.yml here, which is the only thing it was for.
With no Actions budget the hosted pool kills such a run after six seconds, with
runner=null and an empty log. Publishing a reserved hosted label only invites
someone to write runs-on: xcode-27 again.
One agent takes one job, so a single Mac serves one macOS job at a time. -n
provisions several agents on the same machine:
./provision.sh -n 3 # three runners on this Mac
./provision.sh -n 3 --instance 2 # re-provision only the second of them--instance matters for upgrades: without it, re-running to fix instance 3
would tear down and re-register 1 and 2 as well.
Instance 1 is exactly what a single-instance install has always been — the
same ~/actions-runner, the same name, the same daemon label, the same
~/.cargo. Instances 2..N are siblings with a -2, -3 suffix. Numbering them
all -1..-N would have been tidier, but it would rename the runner on every Mac
already provisioned, leaving an orphaned registration online in the org and a
LaunchDaemon the script no longer recognises as its own.
What each instance gets of its own, and why:
| Private per instance | Why it cannot be shared |
|---|---|
CARGO_HOME (2..N) |
Two concurrent builds on one cargo registry is a build failure, not untidiness — this is the crc32fast … No such file or directory the Linux fleet hit before every replica got its own registry volume |
SWEEP_WORK_DIR |
Otherwise one instance sweeps another's target/ mid-compile |
GIT_CONFIG_GLOBAL |
job-started-hook.sh resets git's global config before every job; sharing one file means instance 2 wiping it under a job running on instance 1 |
| runner root, name, daemon label | Registration and launchd both key off these |
The toolchain — Homebrew, rustup, Xcode — stays shared and is installed once.
CARGO_BUILD_JOBS is divided by the instance count, because half the cores is
the budget for runner work as a whole rather than per agent. An explicit
--build-jobs is taken as given and is not divided.
CARGO_BUILD_JOBS defaults to half the cores, divided by the instance count.
On the machine this was written for, the Mac also hosts the OrbStack Linux
fleet and a Parallels VM, and an unthrottled native build starves the replicas
until their runners drop with "lost communication".
The disk sweep is the fleet's job-completed-hook.sh, reused rather than
duplicated, at SWEEP_MAX_GB=8.
Linux 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. Values that must differ between instances
on one machine cannot live there — see "Several runners on one machine" below
for where those go instead.
CARGO_BUILD_JOBS defaults to half the CPUs rather than all of them, divided
by the instance count. 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".
Everything in the macOS "Several runners on one machine" section applies here, with PowerShell spelling — and it matters more, because one Windows runner serialises every PR that needs Windows.
powershell -NoProfile -ExecutionPolicy Bypass -File .\provision.ps1 `
-ServiceAccount '.\ci' -n 3
# Re-provision only the second of them:
powershell -NoProfile -ExecutionPolicy Bypass -File .\provision.ps1 `
-ServiceAccount '.\ci' -n 3 -Instance 2Instance 1 keeps C:\actions-runner and its existing name and service;
instances 2..N are C:\actions-runner-2, -3 and so on, each registered under
its own name and therefore its own actions.runner.* service.
It prompts for the service account password once per instance registered.
config.cmd is deliberately not run with --unattended, so the password is
typed into the runner rather than passed on a command line where any other
process could read it. -n 3 therefore means three prompts.
Where the per-instance environment lives. A Windows service cannot read the
runner's .env the way Linux and macOS do, and machine-scope environment holds
one value per name — so provisioning instance 2 would overwrite instance 1's
hook paths and point both at one instance's hooks. Each instance's private
values (ACTIONS_RUNNER_HOOK_JOB_*, SWEEP_WORK_DIR, GIT_CONFIG_GLOBAL,
CARGO_HOME) are therefore written to that service's own Environment value
(REG_MULTI_SZ) under HKLM\SYSTEM\CurrentControlSet\Services\<service>,
which the Service Control Manager merges into the process it launches. Values
that are identical on every instance stay in machine scope, where they were.