Skip to content

Provision a native Windows runner - #7

Open
persello wants to merge 13 commits into
mainfrom
feat/windows-runner
Open

Provision a native Windows runner#7
persello wants to merge 13 commits into
mainfrom
feat/windows-runner

Conversation

@persello

@persello persello commented Sep 7, 2026

Copy link
Copy Markdown
Member

Adds windows/ — a PowerShell provisioning script and two job hooks — so a
Windows machine can join the fleet. Linux runs in Docker; Windows containers do
not run on the ARM64 Parallels VM this targets, so the runner is installed
natively onto a machine that is provisioned once and kept.

No open issues in this repo, so nothing to link — flagging that explicitly
rather than leaving the omission ambiguous.

What it does

provision.ps1 is idempotent: re-running it upgrades the toolchain and
re-registers against a freshly minted token, which is how a machine is meant to
be updated rather than only built.

It mirrors entrypoint.sh where the two overlap — a PAT mints a short-lived
registration token on every run, and the same five stale state files are
cleared before reconfiguring. .runner_migrated is in that list for the reason
entrypoint.sh documents at length: config.cmd treats the marker alone as
proof the runner is already configured, and leaving it behind is what silently
took the Linux fleet offline about ten days after a rebuild.

The hooks are PowerShell twins of the .sh ones, for the same failure modes:
an accumulating .gitconfig that eventually collides with a later
git config set, and a target/ that drifts until the disk fills.

Two constraints enforced in code, not just documented

The service account may not be LocalSystem. Both of these were found the
hard way porting hbf to Windows:

  • tauri caches its NSIS toolchain under %LOCALAPPDATA%\tauri\NSIS. Under
    SYSTEM that resolves inside systemprofile, where the download reports
    success but nothing lands, and the bundler dies with Unable to start child process, error 0x2ERROR_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.

config.cmd is invoked without --unattended. Everything else is
supplied, so the only thing it can prompt for is the service account password —
which keeps it in the runner's own stdin instead of this process's command
line, where --windowslogonpassword would put it.

Labelling

By what the machine is (windows-arm64 / windows-x64), not what it
builds. An ARM64 Windows box cross-compiles x86_64-pc-windows-msvc fine —
verified end to end, including an NSIS installer whose payload is PE machine
0x8664 — so calling an ARM64 machine windows-x64 would break the first time
a real x64 machine joins.

Verified

Run against the ARM64 VM:

  • All three scripts parse under Windows PowerShell 5.1 Desktop. Not a
    formality — the machine has no PowerShell 7, so the runner will invoke the
    hooks with 5.1, which makes avoiding ?? in job-completed-hook.ps1
    load-bearing rather than defensive.
  • All four guardrails refuse correctly, each exiting before touching the
    machine: non-elevated, LocalSystem as the service account, a nonexistent
    local account, and a missing PAT.
  • -DryRun derivation: ARM64 detected, labels defaulting to machine
    identity, CARGO_BUILD_JOBS halved from 8 CPUs to 4, org URLs resolving to
    /orgs/{org}/... and repo URLs to /repos/{owner}/{repo}/....
  • actions/runner publishes a win-arm64 asset, so an ARM64 runner is
    supported at all — worth confirming before depending on it.

Three review-found bugs were fixed before any of this ran: a ?? that will not
parse on 5.1, a Get-ChildItem -Include that silently matches nothing without
a wildcard path, and .\ci being unresolvable as an ACL identity.

Still NOT verified

Everything that needs a real service account, whose password is yours to type:
the toolchain installs, the Windows11SDK.22621 component pin, and whether
config.cmd prompts for only the password when given --runasservice --windowslogonaccount without --unattended. The first real run should be
attended.

Wiring hbf's workflows to the new label is deliberately a separate change.

🤖 Generated with Claude Code

persello and others added 13 commits September 7, 2026 18:29
Linux runs in Docker; Windows cannot. Windows containers do not 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. provision.ps1 is idempotent, so
re-running it is how a machine is upgraded rather than only how one is built.

Registration mirrors entrypoint.sh: a PAT mints a short-lived registration
token on every run, and the same five stale state files are cleared before
reconfiguring -- `.runner_migrated` included, since config.cmd treats that
marker alone as proof the runner is already configured and leaving it behind
is what silently took the Linux fleet offline after a self-update.

The job hooks are PowerShell twins of the .sh ones and exist for the same
reasons: an accumulating .gitconfig collides with a later `git config set`,
and target/ drifts until the disk fills. The Linux entrypoint exports the
cargo knobs before run.sh; a Windows service has no equivalent and the
runner's .env is read only by the Linux systemd unit, so these are set as
machine-level environment and the service is restarted to pick them up.

Two constraints are enforced in code rather than left to documentation. The
service account may not be LocalSystem: tauri's NSIS cache resolves inside
systemprofile where the download lands nowhere it later looks (surfacing as a
misleading `error 0x2`, which is ERROR_FILE_NOT_FOUND), and node_modules
created by a SYSTEM build blocks every later build under another account. And
config.cmd is invoked WITHOUT --unattended so it prompts for the account
password itself, keeping it out of this process's command line.

Runners are labelled by what the machine is, not what it builds. An ARM64
Windows box cross-compiles x86_64-pc-windows-msvc fine -- verified end to end,
including an NSIS installer whose payload is PE machine 0x8664 -- so labelling
it windows-x64 would break the first time a real x64 machine joins.

CARGO_BUILD_JOBS defaults to half the CPUs: this VM shares a host, and an
unthrottled Windows build starves the OrbStack fleet into "lost communication".

Not yet executed anywhere. The target VM was powered off while this was
written, so the scripts are unrun and their syntax unverified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ning

Run against the ARM64 VM now it is back up.

All three scripts parse under Windows PowerShell 5.1 Desktop, which is not a
formality: the machine has no PowerShell 7, so the runner will invoke the
hooks with 5.1 and the deliberate avoidance of `??` in job-completed-hook.ps1
is load-bearing rather than defensive.

All four guardrails were exercised and refuse correctly: non-elevated,
LocalSystem as the service account, a nonexistent local account, and a missing
PAT. Each exits before touching the machine.

Two changes came out of testing rather than review:

-RunnerVersion no longer defaults to a hand-pinned 2.331.0. Latest is already
2.337.0, and since the runner self-updates on first contact with GitHub a pin
buys nothing while guaranteeing the first job runs on a just-replaced binary.
It now resolves the latest release the same way the Git, LLVM and pkl installs
already do, and stays overridable to reproduce a specific machine. Confirmed
actions/runner does publish a win-arm64 asset, so an ARM64 runner is supported
at all -- worth checking before depending on it.

-DryRun prints every derived value and exits without side effects. Added to
make the derivation testable, but it earns its place for anyone about to
provision a machine they care about. Verified it resolves org URLs to
/orgs/{org}/... and repo URLs to /repos/{owner}/{repo}/..., detects ARM64,
defaults labels to machine identity, and halves the CPU count for
CARGO_BUILD_JOBS.

Still unverified: the toolchain installs, the VS Build Tools component pin,
and whether config.cmd prompts for only the password when given
--runasservice --windowslogonaccount without --unattended. Those need a real
service account, whose password is the user's to type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copy provision.ps1 to a machine and run it -- that is the whole procedure. The
job hooks are embedded as here-strings and written to <RunnerRoot>\hooks during
provisioning, so the script needs nothing else from this repo. -DryRun emits
them to %TEMP% instead, which makes them inspectable before committing to a
machine and is how the emission is tested.

bun now comes from its published per-architecture zip rather than by piping
bun.sh/install.ps1 into Invoke-Expression. It publishes bun-windows-aarch64.zip
alongside bun-windows-x64.zip, so the direct download works identically on both
architectures, is deterministic, and does not execute a remote script as
Administrator. Note an x64 machine older than bun's baseline cutoff needs the
-baseline zip and will otherwise die on an illegal instruction.

Verified on the ARM64 VM: provision.ps1 parses under PowerShell 5.1, -DryRun
derives correctly, and the hooks it emits parse under 5.1 as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rofile

Rust was the one toolchain here defaulting to a per-user path, and the failure
mode is invisible until the first job. Provisioning runs elevated -- often as
SYSTEM -- while jobs run as the service account, so rustup's default
%USERPROFILE% install puts cargo in the provisioning account's profile,
publishes that unreadable path on the machine PATH, and the runner then
registers cleanly and fails every job with "cargo not found". CARGO_HOME and
RUSTUP_HOME are now set to C:\rust before rustup-init runs, and the service
account is granted Modify there because cargo writes to CARGO_HOME (the
registry cache, and `cargo install`) rather than only reading it.

Found by reading the script against the account layout it would actually meet,
not by running it.

Two options come with it, both of which the Linux side already has in spirit:

-RegistrationToken mirrors entrypoint.sh's RUNNER_TOKEN. Minting the token
where the PAT already lives and passing only the short-lived result keeps the
PAT off the provisioned machine entirely.

-SkipRegistration stops after the toolchain. config.cmd prompts for the account
password on an interactive console, so a remote or scripted session cannot
answer it; this splits the long unattended half from the short interactive one
and prints the exact command to finish with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registration now tries -RegistrationToken, then -Pat, then the GitHub CLI. The
gh path is the one worth having: its credential is managed and revocable
rather than a classic PAT pasted through a shell, and there is nothing to
create beforehand. gh is installed as part of the toolchain to make it
available, and is useful on a CI box regardless.

It needs one grant. gh's ordinary login carries read:org while registering an
org runner needs admin:org, so the failure path prints `gh auth refresh -h
github.com -s admin:org` verbatim, and notes that a repo-scoped runner wants
repo admin instead -- and that an org which disables repo-level runners reports
that as a 404 rather than a permission error, which is not guessable.

Three bugs found by running it, not reading it:

The credential guard predated the gh fallback and rejected the very case it was
meant to allow, so the gh path was unreachable. It now treats gh as a
credential, and only insists on one when the toolchain step is also skipped.

Under this script's ErrorActionPreference of 'Stop', `2>&1` on a native command
throws a NativeCommandError, so gh's message aborted the script instead of
reaching the guidance written for it. Relaxed around that one call.

Even captured, `2>&1` yields ErrorRecords whose formatting wraps PowerShell's
own "At <script>:<line> char:" trace around gh's one-line message. ToString()
per record instead, since the whole point of the block is that a human can read
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Provisioning no longer hands the operator homework. The script installs gh,
drives `gh auth login` itself when gh is not logged in, and asks gh to widen
its own scope when a mint is refused -- gh's ordinary login carries read:org
while an org runner needs admin:org, which was previously a instruction to go
and read. It also offers to create the service account when it is missing,
reading the password twice and comparing, because a typo there does not fail
there: it fails later as a service that installs cleanly and refuses to start.

So a blank machine now needs one elevated command and nothing prepared in
advance -- no PAT minted, no account created.

The two prompts that remain are the operator's own and are never stored or
displayed: the account password, and the GitHub login. config.cmd still asks
for the password a second time, which stays deliberate -- it keeps it inside
the runner rather than on a command line.

Every interactive path degrades to a printed instruction rather than a hang,
since a prlctl exec / WinRM / scheduled-task session has no console for gh to
prompt on, and a hang there is worse than a failure. Verified both: a missing
account and an unauthenticated gh each fail with guidance and exit 1 under a
non-interactive session.

Two parse errors caught before any of that ran: a stray brace left by the
edit, and a line broken after `-ceq`, which PowerShell does not treat as a
continuation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous revision still told the reader to mint a PAT and create an account
before running anything, which the script now does for them. Documents what the
single run actually does, the two prompts it raises and why config.cmd asks for
the password a second time, and how to split it for a session with no console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The split run told the operator to continue with
`C:\actions-runner\provision.ps1`, which did not exist: provisioning copied the
hooks there but never the script itself, so the second half failed with
CommandNotFoundException. The copy you first ran from is usually somewhere
temporary -- a Downloads folder, a share, a checkout since deleted -- so
pointing at it is not a fix either.

It now installs itself at <RunnerRoot>\provision.ps1, which makes the
continuation and any later upgrade the same command on every machine no matter
where the first half was run from, and the printed instruction points there
rather than at $MyInvocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every documented invocation was `.\provision.ps1`, which a default Windows
install refuses: RemoteSigned/Restricted will not run an unsigned .ps1 invoked
by path, and it fails with PSSecurityException before a single line executes.

This was invisible during development because every test run went through
`powershell.exe -ExecutionPolicy Bypass -File ...` from the host, so the policy
was already bypassed on the command line and never on the documented path a
person actually types.

All documented invocations now carry it, as does the continuation the script
prints, with a note that it scopes the exemption to that one process. Changing
the machine's execution policy would be the wrong fix -- it is a
security setting, and a per-invocation flag achieves the same thing without
persisting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runner was registered and healthy, and every job still died in "Set up
runner" before executing a workflow line:

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

The runner invokes a .ps1 hook as `powershell.EXE -command ". '<path>'"` with
no -ExecutionPolicy, which a default install refuses for an unsigned script --
and a non-zero hook fails the job, so this took out every job rather than
degrading. That invocation is not configurable.

ACTIONS_RUNNER_HOOK_JOB_STARTED/_COMPLETED now point at generated .cmd
wrappers, which run through cmd.exe where no execution policy applies and
re-invoke the .ps1 with the bypass. Relaxing the machine's policy would also
have worked and is the wrong trade: a system-wide security setting loosened so
that two of our own scripts can run.

A -SkipRegistration re-run now also restarts an existing service, since it
still rewrites machine environment that a running service would otherwise keep
missing until something else restarted it -- which is exactly how this fix had
to be applied.

Third instance of this same root cause today, after the documented invocation
and the printed continuation. All three came from the same blind spot: every
development run went through `powershell -ExecutionPolicy Bypass` from the
host, so the policy was never actually in the path being tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The .cmd wrappers were rejected before they ran:

    C:\actions-runner\hooks\job-started-hook.cmd is not a valid path to a
    script. Make sure it ends in '.sh', '.ps1' or '.js'.

The runner dispatches hooks by extension and accepts only those three, so
cmd.exe was never an option -- I assumed it was rather than checking. .sh
carries the same property that made .cmd attractive (no PowerShell execution
policy applies to it) and bash is guaranteed anyway, since Git for Windows is
already mandatory for the `shell: bash` steps every composite action here uses.

Two details the wrapper depends on: the absolute Windows path is baked in at
generation time, because Git Bash would report a POSIX path that
`powershell -File` cannot resolve; and the file is written with explicit LF via
WriteAllText, because Set-Content emits CRLF and a shell script with \r line
endings fails as a confusing "not found".

Verified this time by running both hooks through bash exactly as the runner
does, rather than by reasoning about it: no CR bytes, bash resolves on PATH,
both exit 0, and the sweep hook reports its budget correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aces

jq is not a build tool, which is why it was missed: the shared
vs-registry-auth action parses the registry config with it, and when jq is
absent that check reports "returned 200 but not the registry config (SSO
page?)". That message points at the registry, and the first real CI run spent
its failure looking like a network or credential problem rather than a missing
binary. Linux gets jq from its base packages, so the gap exists only here.

Also documents the third place the execution policy bites -- workflow `run:`
blocks, which the runner writes to a temp .ps1 and invokes without
-ExecutionPolicy, so a job without defaults.run.shell: bash fails before
executing a line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
publish-gui.yml resolves the workspace version with
`python3 -c 'import tomllib...'`, which is `command not found` on a machine
without Python -- the Linux image carries 3.12 and uv, so nothing upstream
notices the assumption.

uv rather than the python.org installer: one binary with a predictable
per-architecture asset, no silent-install flags to get wrong, and the tool the
Linux side already uses. UV_PYTHON_INSTALL_DIR points at C:\python for the
same reason CARGO_HOME points at C:\rust -- uv's default is under
%LOCALAPPDATA%, so an elevated provisioning run would install Python where the
service account cannot see it.

Windows CPython ships python.exe and no python3.exe, while every step written
for Linux or macOS says `python3`, so a copy is placed beside it. Verified from
a fresh session that Git Bash resolves `python3` and that `import tomllib`
works: /c/python/cpython-3.12.14-windows-x86_64-none/python3, 3.12.14.

uv selects an x86_64 build on ARM64 by choice, reporting that native aarch64
support is not yet mature. It runs under emulation, which is fine for reading
a TOML file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant