Windows is a distinct operating environment
A Windows runner is not a Linux runner with different path separators. The
operator owns Windows service identity, NTFS ACLs, certificate stores, WinHTTP
and process proxy settings, PowerShell execution policy, antivirus exclusions,
path length, line endings, reparse points, and host lifecycle. Workflow authors
must select shell: pwsh deliberately and avoid assuming Bash utilities exist.
The supported registration boundary is the same: a one-hour registration
token is passed to config.cmd; the runner application owns its internal
identity and credential files.
Task 1 — Author supported registration
Save the following as Register-Runner.ps1 in an extracted official Windows
runner directory:
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $Owner,
[Parameter(Mandatory)] [string] $Repository,
[Parameter(Mandatory)] [string] $RunnerName,
[string] $Label = 'windows-iac',
[string] $RunnerGroup = 'Default',
[string] $WorkDirectory = '_work'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if (-not (Test-Path -LiteralPath '.\config.cmd' -PathType Leaf)) {
throw 'Run this script from an extracted official actions/runner directory.'
}
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
throw 'The GitHub CLI is required to request a repository registration token.'
}
$token = gh api --method POST `
-H 'Accept: application/vnd.github+json' `
"/repos/$Owner/$Repository/actions/runners/registration-token" `
--jq .token
if ([string]::IsNullOrWhiteSpace($token)) { throw 'GitHub returned no registration token.' }
if ($PSCmdlet.ShouldProcess("$Owner/$Repository", 'Register ephemeral Windows runner')) {
& .\config.cmd `
--url "https://github.com/$Owner/$Repository" `
--token $token `
--name $RunnerName `
--runnergroup $RunnerGroup `
--labels $Label `
--work $WorkDirectory `
--unattended `
--ephemeral `
--disableupdate
if ($LASTEXITCODE -ne 0) { throw "config.cmd failed with exit code $LASTEXITCODE" }
}
$token = $null
SupportsShouldProcess enables -WhatIf for the wrapper, but config.cmd
itself is an external state-changing command and runs only when phase two
supplies a disposable target.
Task 2 — Define service identity and NTFS policy
The persistent-runner service path is included for environments that cannot use ephemeral instances. Prefer ephemeral replacement; if a service is required, inventory permissions and generate the proposed ACL change first:
$RunnerRoot = 'C:\actions-runner'
$WorkRoot = 'D:\actions-work'
$ServiceAccount = 'CONTOSO\svc-gh-runner'
Get-Acl -LiteralPath $RunnerRoot | Format-List
Get-Acl -LiteralPath $WorkRoot | Format-List
@(
"icacls `"$RunnerRoot`" /inheritance:r",
"icacls `"$RunnerRoot`" /grant:r `"${ServiceAccount}:(OI)(CI)(RX)`"",
"icacls `"$WorkRoot`" /inheritance:r",
"icacls `"$WorkRoot`" /grant:r `"${ServiceAccount}:(OI)(CI)(M)`""
) | Set-Content -LiteralPath '.\proposed-acl-commands.txt' -Encoding utf8
The identity needs read/execute on binaries and modify on the work directory. It does not need local Administrator, interactive logon, domain-admin membership, or access to unrelated deployment shares. Use a gMSA where domain policy supports one and Kerberos access is required.
For a persistent supported service, run config.cmd from an elevated console
with --runasservice --windowslogonaccount CONTOSO\svc-gh-runner --windowslogonpassword <password> (or answer the interactive service prompts);
there is no svc.cmd in the Windows runner package — svc.sh is Linux/macOS
only. Manage the installed service with Get-Service, Start-Service, and
Stop-Service against the actions.runner.* service name, and remove the
runner with .\config.cmd remove --token <removal-token>. Do not combine
--ephemeral with a service expected to restart indefinitely; the fleet
scheduler owns one-job replacement.
Task 3 — Author service and network diagnostics
Save as Test-RunnerHost.ps1:
[CmdletBinding()]
param(
[string] $RunnerRoot = 'C:\actions-runner',
[string] $WorkRoot = 'D:\actions-work'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$results = [ordered]@{
TimestampUtc = [DateTime]::UtcNow.ToString('o')
PowerShell = $PSVersionTable.PSVersion.ToString()
ExecutionPolicy = (Get-ExecutionPolicy -List | Out-String).Trim()
RunnerRootExists = Test-Path -LiteralPath $RunnerRoot
WorkRootExists = Test-Path -LiteralPath $WorkRoot
GitHub443 = Test-NetConnection github.com -Port 443 -InformationLevel Quiet
ApiGitHub443 = Test-NetConnection api.github.com -Port 443 -InformationLevel Quiet
WinHttpProxy = (netsh winhttp show proxy | Out-String).Trim()
ProcessHttpsProxy = $env:HTTPS_PROXY
LongPathsEnabled = (Get-ItemPropertyValue `
-Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
-Name LongPathsEnabled -ErrorAction SilentlyContinue)
}
$results.Services = @(Get-Service | Where-Object Name -Like 'actions.runner.*' |
Select-Object Name, Status, StartType)
$results.RecentRunnerEvents = @(Get-WinEvent -FilterHashtable @{
LogName = 'Application'; StartTime = (Get-Date).AddHours(-2)
} -ErrorAction SilentlyContinue | Where-Object Message -Match 'runner|actions' |
Select-Object -First 50 TimeCreated, Id, LevelDisplayName, Message)
$results | ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath '.\windows-runner-diagnostics.json' -Encoding utf8
The diagnostic avoids printing credential files or environment secrets.
Private-CA troubleshooting also records Cert:\LocalMachine\Root, validates
the corporate root thumbprint, and tests TLS from the service-account context,
not only from an administrator’s interactive shell.
Task 4 — Define workflow portability rules
jobs:
windows-verify:
runs-on: [self-hosted, Windows, X64, windows-iac]
timeout-minutes: 10
defaults:
run:
shell: pwsh
steps:
- name: Record platform and checkout behavior
run: |
$ErrorActionPreference = 'Stop'
"runner=$env:RUNNER_NAME os=$env:RUNNER_OS arch=$env:RUNNER_ARCH"
git config --show-origin --get core.autocrlf
git config --show-origin --get core.longpaths
- name: Prove no host-global deployment credential
run: |
if (Test-Path Env:AWS_SECRET_ACCESS_KEY) {
throw 'A static AWS secret is present; use job-scoped OIDC.'
}
Shared repositories should commit .gitattributes for intentional line
endings, avoid case-only path differences, and test path length. Do not “fix”
scripts by globally changing line endings on the runner host.
Task 5 — Define phase-two evidence
# Phase-two Windows runner evidence
- [ ] Windows edition/build, PowerShell, runner version, and archive SHA-256 recorded.
- [ ] Dedicated service/fleet identity and effective privileges recorded.
- [ ] NTFS inheritance and effective access on runner/work roots recorded.
- [ ] Proxy and root-CA behavior tested from the runner identity.
- [ ] config.cmd creates the repository-scoped runner record.
- [ ] A [self-hosted, Windows, X64, windows-iac] job completes with shell pwsh.
- [ ] No static cloud credential, Docker named pipe, or broad host share is exposed.
- [ ] CRLF, long-path, and case-sensitivity fixture completes.
- [ ] Ephemeral runner accepts one job; scheduler removes the host and stale record.
- [ ] Persistent exception tests Stop-Service on actions.runner.* and config.cmd remove --token.
- [ ] Disposable repository, runner record, host, and secrets are removed.
Validation
Phase one verifies that the lesson uses config.cmd — including its
--runasservice service path and remove --token teardown — contains no
hand-authored runner internals, scopes the repository and labels, separates
ephemeral scheduling from service operation, and covers Windows-only failure
domains. PowerShell parsing, service installation, live job selection, and
teardown remain phase-two evidence.
Expected outcome
The course now gives Windows administrators an explicit PowerShell operating model rather than Linux commands with renamed paths. The deliverable is a reviewable Windows runbook and an exact contract for disposable execution—not a claim that a Windows runner has already been tested.