Provision a native Windows runner - #7
Open
persello wants to merge 13 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
windows/— a PowerShell provisioning script and two job hooks — so aWindows 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.ps1is idempotent: re-running it upgrades the toolchain andre-registers against a freshly minted token, which is how a machine is meant to
be updated rather than only built.
It mirrors
entrypoint.shwhere the two overlap — a PAT mints a short-livedregistration token on every run, and the same five stale state files are
cleared before reconfiguring.
.runner_migratedis in that list for the reasonentrypoint.shdocuments at length:config.cmdtreats the marker alone asproof 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
.shones, for the same failure modes:an accumulating
.gitconfigthat eventually collides with a latergit config set, and atarget/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:
%LOCALAPPDATA%\tauri\NSIS. UnderSYSTEM that resolves inside
systemprofile, where the download reportssuccess but nothing lands, and the bundler dies with
Unable to start child process, error 0x2—ERROR_FILE_NOT_FOUND, not the x86-emulation failureit reads as.
node_modulescreated by a SYSTEM build is owned by SYSTEM, and any laterbuild under another account hangs or fails
EPERM.config.cmdis invoked without--unattended. Everything else issupplied, 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
--windowslogonpasswordwould put it.Labelling
By what the machine is (
windows-arm64/windows-x64), not what itbuilds. An ARM64 Windows box cross-compiles
x86_64-pc-windows-msvcfine —verified end to end, including an NSIS installer whose payload is PE machine
0x8664— so calling an ARM64 machinewindows-x64would break the first timea real x64 machine joins.
Verified
Run against the ARM64 VM:
formality — the machine has no PowerShell 7, so the runner will invoke the
hooks with 5.1, which makes avoiding
??injob-completed-hook.ps1load-bearing rather than defensive.
machine: non-elevated,
LocalSystemas the service account, a nonexistentlocal account, and a missing PAT.
-DryRunderivation: ARM64 detected, labels defaulting to machineidentity,
CARGO_BUILD_JOBShalved from 8 CPUs to 4, org URLs resolving to/orgs/{org}/...and repo URLs to/repos/{owner}/{repo}/....actions/runnerpublishes awin-arm64asset, so an ARM64 runner issupported at all — worth confirming before depending on it.
Three review-found bugs were fixed before any of this ran: a
??that will notparse on 5.1, a
Get-ChildItem -Includethat silently matches nothing withouta wildcard path, and
.\cibeing 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.22621component pin, and whetherconfig.cmdprompts for only the password when given--runasservice --windowslogonaccountwithout--unattended. The first real run should beattended.
Wiring hbf's workflows to the new label is deliberately a separate change.
🤖 Generated with Claude Code