Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~75 min

Lab 31: Design Windows self-hosted runner operations with PowerShell

C · Simulation

Objectives

  • Use `config.cmd` — including its `--runasservice` service path — rather than editing runner internals
  • Run the Windows service under a dedicated least-privilege identity
  • Constrain NTFS access to the runner installation and work directories
  • Diagnose service, proxy, private-CA, line-ending, path, and execution-policy failures with PowerShell
  • Define live registration, job, teardown, and evidence gates for phase two

Prerequisites

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.

Deliverables

  • · Register-Runner.ps1 — supported repository-scoped ephemeral registration
  • · Test-RunnerHost.ps1 — service, network, certificate, filesystem, and tool diagnostics
  • · windows-runner-threat-model.md — Windows-specific trust boundaries
  • · phase-two-evidence.md — live Windows execution and cleanup gates

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.