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
7 changes: 6 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# Force all .sh files to use LF (Unix) line endings
*.sh text eol=lf
*.sh text eol=lf
# PowerShell and batch are Windows-native; keep CRLF so they read correctly
# in Notepad and are byte-identical to what is executed on the runner.
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
225 changes: 223 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Dockerized self-hosted GitHub Runner
# Self-hosted GitHub Runners

A self-hosted GitHub Actions runner Docker image configured for JKU Racing firmware development.
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](#windows-runners)).

## Pre-installed Tools

Expand Down Expand Up @@ -131,3 +133,222 @@ docker compose up -d --build
> Always pass `--build`. Plain `docker compose up -d` only builds when the image
> is missing, so it will happily keep running a stale image after the Dockerfile
> or `entrypoint.sh` changes.

## Windows runners

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:

```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:

```powershell
$u = 'https://raw.githubusercontent.com/jkuracing/github-runner/main/windows/provision.ps1'
Invoke-WebRequest $u -OutFile provision.ps1 -UseBasicParsing
```

That 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.

### PowerShell execution policy

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".

### Unattended runs

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:

```powershell
# 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' -SkipToolchain
```

The 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.

### Credentials

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.

### The service account is not optional, and must not be SYSTEM

`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:

```powershell
New-LocalUser -Name 'ci' -Description 'GitHub Actions runner' -PasswordNeverExpires
```

Running 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 inside `systemprofile`, the download reports success,
nothing lands, and the bundler dies with `Unable to start child process,
error 0x2` -- which is `ERROR_FILE_NOT_FOUND`, not the x86-emulation failure
it reads as.
- `node_modules` created by a SYSTEM build is owned by SYSTEM, and any later
build under another account hangs or fails `EPERM` on it.

### Architecture

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.

### Toolchain

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.

### Hooks and machine environment

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".
Loading