diff --git a/.gitattributes b/.gitattributes index fe8fe8e..064c716 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ # Force all .sh files to use LF (Unix) line endings -*.sh text eol=lf \ No newline at end of file +*.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 diff --git a/README.md b/README.md index e4aa516..afb5550 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ". ''"` 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 `\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 `\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//`) 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 +`\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". diff --git a/windows/provision.ps1 b/windows/provision.ps1 new file mode 100644 index 0000000..d9e6053 --- /dev/null +++ b/windows/provision.ps1 @@ -0,0 +1,957 @@ +<# +.SYNOPSIS + Provisions a Windows self-hosted GitHub Actions runner for JKU Racing. + +.DESCRIPTION + The Windows counterpart to this repo's Dockerfile + entrypoint.sh. It is a + script rather than an image because there is no Windows equivalent of the + Linux fleet here: Windows containers cannot run on the ARM64 Parallels VM + this targets, so the runner is installed natively onto a machine that is + provisioned once and kept. + + Everything is idempotent. Re-running it upgrades the toolchain in place and + re-registers the runner against a freshly minted token; it is the intended + way to update a machine, not just to build a new one. + + Registration follows entrypoint.sh: a PAT mints a short-lived registration + token on every run, because registration tokens expire after ~1 hour and a + static one goes stale between provisioning and the next re-run. + +.PARAMETER Url + Org or repo to register against. Org-level (the default) is what the Linux + fleet uses and lets one machine serve every repo. + +.PARAMETER Pat + Classic PAT with `admin:org` (org-level) or `repo` (repo-level). Used ONLY to + mint a registration token, never stored on the machine. Prefer passing it via + the GITHUB_PAT environment variable so it stays out of your shell history. + +.PARAMETER ServiceAccount + Existing local account the runner service logs on as, e.g. ".\ci". It must + already exist -- this script will not create an account, because creating one + means choosing its password and that is yours to type, not mine to generate. + + It MUST NOT be LocalSystem, and the script refuses if you ask for it. Two + independent failures come from running the build as SYSTEM, both found the + hard way while porting hbf: + + - tauri caches its NSIS toolchain under %LOCALAPPDATA%\tauri\NSIS. Under + SYSTEM that resolves inside C:\Windows\system32\config\systemprofile, + where the download reports success but nothing lands, and the bundler + then dies with "Unable to start child process, error 0x2" -- which is + ERROR_FILE_NOT_FOUND, not the emulation failure it reads as. + - node_modules created by a SYSTEM build is owned by SYSTEM, and a later + build under any other account hangs or fails EPERM on it. + + You are never asked for the password by THIS script. config.cmd prompts for + it itself, so it goes straight into the runner's own stdin and never reaches + a command line, an environment variable, or this file. + +.PARAMETER Labels + Runner labels. Default targets the machine by what it IS, not what it builds: + an ARM64 Windows box cross-compiles x86_64-pc-windows-msvc perfectly well + (proven: `Target: x64`, payload PE machine 0x8664), so labelling it + "windows-x64" would be a lie that breaks the day someone adds an x64 box. + +.PARAMETER BuildJobs + Cap on cargo's parallelism. Defaults to half the CPUs, because this VM is + expected to share a host with other work -- on the machine this was written + for, an unthrottled VM build starves the OrbStack Linux fleet badly enough + that its runners drop with "lost communication". + +.EXAMPLE + $env:GITHUB_PAT = '' + .\provision.ps1 -ServiceAccount '.\ci' +#> +[CmdletBinding()] +param( + [string] $Url = 'https://github.com/jkuracing', + [string] $Pat = $env:GITHUB_PAT, + # A registration token minted elsewhere, as an alternative to -Pat. The Linux + # entrypoint accepts RUNNER_TOKEN for the same reason: it lets the PAT stay + # off this machine entirely -- mint the token where the PAT already lives and + # pass only the short-lived result. Expires in ~1 hour. + [string] $RegistrationToken = $env:RUNNER_TOKEN, + [Parameter(Mandatory)] + [string] $ServiceAccount, + [string] $Name = "win-$env:COMPUTERNAME", + [string] $Labels = '', + [string] $RunnerRoot = 'C:\actions-runner', + # Empty means "resolve the latest release at run time", which is what every + # other download here does. A hand-pinned version only goes stale: the runner + # self-updates on first contact with GitHub anyway, so pinning buys nothing + # and guarantees the first job runs on a just-replaced binary. Set it + # explicitly only to reproduce a specific machine. + [string] $RunnerVersion = '', + [int] $BuildJobs = 0, + [string] $PythonVersion = '3.12', + [switch] $SkipToolchain, + # Skip the registration step and stop after the toolchain. config.cmd prompts + # for the service account password on an interactive console, so a session + # without a real stdin (a remote `prlctl exec`, a scripted deploy) cannot + # answer it. This lets the long unattended half run there and the short + # interactive half be done by a person. + [switch] $SkipRegistration, + # Print everything this run would derive, then exit without touching the + # machine. Worth having before provisioning a box you care about, and it is + # how the derivation below is tested without side effects. + [switch] $DryRun +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Info ($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Warn ($m) { Write-Host "!! $m" -ForegroundColor Yellow } +function Fail ($m) { Write-Host "!! $m" -ForegroundColor Red; exit 1 } + +# -------------------------------------------------------------------------- +# Job hooks +# +# Embedded rather than shipped as sibling files so this script is the ONLY +# thing you need on a new machine: download it, run it, done. The trade is that +# the hook sources live inside a here-string; they are single-quoted, so +# nothing in them is expanded by this script. +# -------------------------------------------------------------------------- + +$JobStartedHook = @' +<# + Runs before EVERY job, via ACTIONS_RUNNER_HOOK_JOB_STARTED. + + The PowerShell twin of job-started-hook.sh, and it exists for exactly the + same reason. A shared setup snippet used across canvas-consuming repos + configures a git `insteadOf` rewrite with `git config --global set`, then + `--add` for a second value under the same key. On an ephemeral hosted runner + that is harmless -- the machine is destroyed when the job ends. Here the + service account outlives every job, so those values accumulate in its + .gitconfig until a later `set` collides with an already multi-valued key: + + error: cannot overwrite multiple values with a single value + + It is invisible in any one job and only appears once a machine has served + enough canvas-consuming jobs to pile up a second value. + + This is a job-STARTED hook rather than only a completed one because a + cancelled, timed-out or killed job skips the completed hook entirely, and its + accumulated .gitconfig would survive into the next job -- which is the exact + collision being prevented. Running before every job closes the gap instead of + narrowing it. + + "Clean baseline" means the file's absence: nothing in provision.ps1 writes a + .gitconfig for the service account, so that is what a freshly provisioned + machine starts with. +#> +$ErrorActionPreference = 'Continue' + +$gitconfig = Join-Path $env:USERPROFILE '.gitconfig' +if (Test-Path $gitconfig) { + Remove-Item -Force $gitconfig -ErrorAction SilentlyContinue + Write-Host "hook: reset $gitconfig to a clean baseline" +} + +# Never fail the job. This runs adjacent to work that must not be put at risk +# by a cleanup step. +exit 0 +'@ + +$JobCompletedHook = @' +<# + Runs after EVERY job, via ACTIONS_RUNNER_HOOK_JOB_COMPLETED. + + The PowerShell twin of job-completed-hook.sh: bound `target/` between jobs so + a persistent machine does not drift until the disk fills. On the Linux fleet + that surfaced not as "out of disk" but as a linker bus error, which cost real + time to diagnose; a Windows machine will fail differently but no more + clearly. + + The budget is higher here than the fleet's 4 GB because this is ONE machine + rather than twelve replicas sharing a volume, and because a Windows build + tree carries both the host and the cross target -- hbf builds + aarch64-pc-windows-msvc and x86_64-pc-windows-msvc from one checkout. + + Why a job hook rather than a scheduled task: the runner invokes this between + jobs, so it can never delete a target/ out from under a live compile. + + This bounds STEADY STATE, not the peak. A build in flight can exceed the + threshold and is only swept once it finishes. +#> +$ErrorActionPreference = 'Continue' + +# Written the long way rather than with `??`: the runner may invoke this hook +# with Windows PowerShell 5.1, which has no null-coalescing operator and would +# fail to parse the file outright. +$maxGb = if ($env:SWEEP_MAX_GB) { [int]$env:SWEEP_MAX_GB } else { 8 } +# The runner root's _work, NOT RUNNER_WORKSPACE. RUNNER_WORKSPACE is +# per-repository, so using it would enforce the budget once per repo rather +# than once per machine -- on the Linux fleet that let each replica hold twice +# its nominal budget with two repos checked out. +$workDir = if ($env:SWEEP_WORK_DIR) { $env:SWEEP_WORK_DIR } else { 'C:\actions-runner\_work' } +if (-not (Test-Path $workDir)) { exit 0 } + +function Get-SizeMb($Path) { + try { + [Math]::Round((Get-ChildItem -LiteralPath $Path -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum / 1MB) + } catch { 0 } +} + +$usedMb = Get-SizeMb $workDir +$limitMb = $maxGb * 1024 +if ($usedMb -le $limitMb) { + Write-Host "sweep: _work at $usedMb MB, under the $limitMb MB budget -- keeping it warm" + exit 0 +} + +Write-Host "sweep: _work at $usedMb MB exceeds $limitMb MB -- removing target dirs" + +# Largest first, stopping as soon as the budget is met, so the machine keeps as +# much warmth as the budget allows instead of being emptied wholesale. Only +# genuine cargo target dirs are touched: the CACHEDIR.TAG / debug / release +# test avoids deleting a source directory that merely happens to be named +# "target". +$targets = Get-ChildItem -LiteralPath $workDir -Recurse -Directory -Force -Filter 'target' -ErrorAction SilentlyContinue | + Where-Object { + (Test-Path (Join-Path $_.FullName 'CACHEDIR.TAG')) -or + (Test-Path (Join-Path $_.FullName 'debug')) -or + (Test-Path (Join-Path $_.FullName 'release')) + } | + ForEach-Object { [pscustomobject]@{ Path = $_.FullName; Mb = Get-SizeMb $_.FullName } } | + Sort-Object Mb -Descending + +foreach ($t in $targets) { + if ($usedMb -le $limitMb) { break } + Remove-Item -LiteralPath $t.Path -Recurse -Force -ErrorAction SilentlyContinue + $usedMb -= $t.Mb + Write-Host "sweep: removed $($t.Path) ($($t.Mb) MB), now ~$usedMb MB" +} + +# Never fail the job: this runs after the work that matters is already done and +# reported, and a sweep problem must not turn a green job red. +exit 0 +'@ + +function Write-HookFiles { + param([Parameter(Mandatory)][string] $Destination) + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + $a = Join-Path $Destination 'job-started-hook.ps1' + $b = Join-Path $Destination 'job-completed-hook.ps1' + # ASCII, not the default UTF-8-with-BOM of Set-Content on 5.1: a BOM ahead of + # the first line is tolerated by PowerShell but shows up in diffs and logs. + $JobStartedHook | Set-Content -LiteralPath $a -Encoding ASCII + $JobCompletedHook | Set-Content -LiteralPath $b -Encoding ASCII + + # The runner is pointed at these .sh wrappers, NOT at the .ps1 directly. + # + # It invokes a .ps1 hook as `powershell.EXE -command ". ''"` with no + # -ExecutionPolicy, so on a default install the unsigned hook is refused -- + # and because a non-zero hook fails the job, that kills EVERY job in the + # "Set up runner" step before a workflow line executes. The invocation is not + # configurable. + # + # .sh rather than .cmd: the runner accepts only '.sh', '.ps1' or '.js' and + # rejects anything else outright with "is not a valid path to a script". + # bash is guaranteed here anyway -- Git for Windows is already mandatory + # because every composite action in these workflows declares `shell: bash`. + # + # The wrapper hardcodes the absolute Windows path rather than deriving it + # from $0: Git Bash would hand back a POSIX path (/c/actions-runner/...) + # that powershell -File cannot resolve, and provisioning knows the real path + # already. + # + # Relaxing the machine's execution policy would also work, and is the wrong + # trade: a system-wide security setting loosened so two of our own scripts + # can run. + $wrappers = @() + foreach ($ps1 in @($a, $b)) { + $sh = [IO.Path]::ChangeExtension($ps1, '.sh') + $body = @( + '#!/bin/sh' + '# Generated by provision.ps1 -- see the comment there for why this exists.' + "exec powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File '$ps1'" + ) -join "`n" + # WriteAllText with explicit LF: Set-Content emits CRLF on Windows, and a + # shell script whose lines end in \r fails with a confusing "not found". + [IO.File]::WriteAllText($sh, $body + "`n", (New-Object Text.UTF8Encoding($false))) + $wrappers += $sh + } + # Remove the .cmd wrappers an earlier revision generated; the runner rejects + # them by extension, so leaving them would only confuse a later reader. + foreach ($stale in @($a, $b)) { + Remove-Item -Force -LiteralPath ([IO.Path]::ChangeExtension($stale, '.cmd')) -ErrorAction SilentlyContinue + } + Info "Hooks written to $Destination" + return $wrappers +} + +# -------------------------------------------------------------------------- +# gh helpers +# +# gh writes to stderr in normal operation, and this script runs with +# ErrorActionPreference = 'Stop', under which `2>&1` on a native command throws +# NativeCommandError. Every gh call therefore goes through here, which relaxes +# that for the duration and hands back the exit code plus clean text. +# -------------------------------------------------------------------------- + +function Invoke-Gh { + param( + [Parameter(Mandatory)][string[]] $GhArgs, + # Let gh own the console so it can prompt, print a device code, and open a + # browser. Output is not captured in this mode -- it belongs to the user. + [switch] $Interactive + ) + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + if ($Interactive) { + & gh @GhArgs + return [pscustomobject]@{ Code = $LASTEXITCODE; Output = '' } + } + # ToString() per record: formatting an ErrorRecord wraps PowerShell's own + # "At