How Do I Run PowerShell Scripts in GitHub Actions?

Set shell: pwsh on the step. GitHub Actions defaults to pwsh on Windows runners and bash on Linux, and pwsh and powershell are two different interpreters.

Set the shell on the step with shell: pwsh and let the script exit non-zero when the work fails. GitHub Actions runs pwsh by default on Windows runners and bash -e by default on Linux and macOS runners, and the pwsh and powershell keywords start two different interpreters, so a run block that leaves the shell unset behaves differently depending on which runner picked the job up.

Answer

Three settings decide whether a PowerShell step does what you expect: which shell keyword the step uses, what the runner prepends and appends around your script, and whether your script surfaces failures as a non-zero process exit.

The shell keyword table below is transcribed from the GitHub Actions workflow syntax reference, checked on 2026-08-13.

shell valueCommand the runner executesWhere it works
unsetpwsh -command ". '{0}'" on Windows, bash -e {0} elsewhereall runners
pwshpwsh -command ". '{0}'"Windows, Linux, macOS
powershellpowershell -command ". '{0}'" (PowerShell Desktop)Windows only
bashbash --noprofile --norc -eo pipefail {0}all runners, via Git for Windows on Windows
shsh -e {0}Linux and macOS
cmd%ComSpec% /D /E:ON /V:OFF /S /C "CALL "{0}""Windows only
pythonpython {0}all runners

Two rows in that table cause most of the confusion. An unset shell on a Linux job sends your PowerShell through bash -e, where the first $ErrorActionPreference line fails as a shell syntax error. And powershell is PowerShell Desktop, which is Windows PowerShell 5.1 and exists only on Windows images, so a workflow that uses it cannot move to a Linux runner without an edit.

For the pwsh and powershell keywords, the runner prepends $ErrorActionPreference = 'stop' to your script and appends if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE } so the step status follows the last exit code, per the exit codes and error action preference section of the same reference. Replacing the keyword with a custom shell string such as pwsh -File {0} drops both, which is the usual reason a migrated workflow stops failing on errors.

A working Windows step looks like this:

name: package
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: warp-windows-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - name: Build the release package
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          $PSNativeCommandUseErrorActionPreference = $true
          ./scripts/package.ps1 -Configuration Release
      - name: Fail when nothing was packaged
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          $packages = Get-ChildItem -Path ./dist -Filter *.nupkg
          if ($packages.Count -lt 1) {
            Write-Host "::error::no packages were produced in ./dist"
            exit 1
          }
          Write-Host "packaged $($packages.Count) files"

Setting $ErrorActionPreference inside the block is redundant with the built-in keyword and worth keeping anyway, because the line survives if someone later switches the step to a custom shell string. The exit 1 is what actually fails the step: the appended exit $LASTEXITCODE reports whatever process exit code PowerShell ends on.

This workflow moves to WarpBuild by changing runs-on. The Windows x86-64 images carry the same tooling as GitHub-hosted runners, per the cloud runners documentation, so shell: pwsh resolves to the same interpreter after the label change. Labels and sizes are listed on the Windows runners page.

Detail

The silent failure: a script that returns zero after an internal error

$ErrorActionPreference = 'Stop' promotes non-terminating cmdlet errors into terminating ones. It does nothing about native commands. A .exe that exits 3 sets $LASTEXITCODE to 3 and execution continues to the next line, and if that next line is another native command that succeeds, $LASTEXITCODE is reset to 0. The appended exit then reports 0 and the step turns green over a failed test run.

      - name: Integration tests and coverage report
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          ./build/run-tests.exe --suite integration
          & dotnet tool run reportgenerator -reports:./TestResults/*.xml -targetdir:./coverage

If run-tests.exe exits 3 and reportgenerator exits 0, the job passes. The guard is either an explicit check after each native call, or the preference variable that makes native exit codes obey $ErrorActionPreference:

      - name: Integration tests and coverage report
        shell: pwsh
        run: |
          $ErrorActionPreference = 'Stop'
          $PSNativeCommandUseErrorActionPreference = $true
          ./build/run-tests.exe --suite integration
          if ($LASTEXITCODE -ne 0) {
            throw "run-tests.exe exited with $LASTEXITCODE"
          }
          & dotnet tool run reportgenerator -reports:./TestResults/*.xml -targetdir:./coverage

$PSNativeCommandUseErrorActionPreference defaults to $false, per the PowerShell preference variables reference, so set it in the script rather than relying on the interpreter version. It exists in PowerShell 7 only, which is another reason to prefer shell: pwsh over shell: powershell. For tools that use non-zero exit codes to report information, such as robocopy, Microsoft's reference shows setting the variable back to $false inside a script block and checking $LASTEXITCODE against the tool's own thresholds.

Set the shell once instead of on every step

Per-step shell keys drift. A workflow-level default keeps every run block on one interpreter:

defaults:
  run:
    shell: pwsh

jobs:
  test:
    runs-on: warp-windows-latest-x64-8x
    defaults:
      run:
        shell: pwsh
        working-directory: ./src
    steps:
      - uses: actions/checkout@v4
      - run: dotnet test --configuration Release --logger trx

Job-level defaults override workflow-level defaults, and a step-level shell overrides both, as documented under defaults.run.shell.

PowerShell on Linux, macOS, and BYOC runners

pwsh is cross platform, so a PowerShell step runs on Linux and macOS labels too. Image contents decide whether it is already there. The WarpBuild Linux x86-64 and macOS images match the GitHub-hosted tooling lists linked from the preinstalled software documentation, and the Ubuntu 24.04 and 26.04 ARM64 images are compatible with GitHub's ARM64 runner images per the same documentation, so pwsh is already on the image without an extra install step.

On BYOC with a custom Windows VM image, WarpBuild runs .ps1 startup scripts through pwsh -command and falls back to powershell -command, per the custom VM images documentation. GitHub's reference describes the same fallback for self-hosted Windows runners without PowerShell Core installed, so a custom image that ships only Windows PowerShell 5.1 will silently run shell: pwsh steps under PowerShell Desktop.

If the job never starts at all, the shell is not the problem. Runner group access, bot permissions, and workflow path restrictions are covered in common issues.

What the step costs

Windows runner shapes and rates, checked on 2026-08-13 against the pricing page:

Runner labelImagevCPUMemoryPer minute
warp-windows-latest-x64-4xWindows Server 2022416GB$0.016
warp-windows-latest-x64-8xWindows Server 2022832GB$0.032
warp-windows-latest-x64-16xWindows Server 20221664GB$0.064
warp-windows-latest-x64-32xWindows Server 202232128GB$0.128

The warp-windows-2025-x64-<size> labels and the warp-windows-2025-vs2026-x64-<size> labels carry the same four shapes at the same rates, with Windows Server 2025 and Visual Studio 2026 respectively. A PowerShell packaging job that runs 4 minutes on warp-windows-latest-x64-4x bills 4 x $0.016 = $0.064.

When a PowerShell step fails without a useful log line, the surrounding tooling matters more than the script.

Should I use shell: pwsh or shell: powershell?

Use pwsh unless the script needs a Windows PowerShell 5.1 module. The pwsh keyword runs PowerShell Core and works on Windows, Linux, and macOS runners, while powershell runs PowerShell Desktop and exists only on Windows. Both keywords get $ErrorActionPreference = 'stop' prepended by the runner, and only pwsh has $PSNativeCommandUseErrorActionPreference. Windows labels and sizes are on the WarpBuild Windows runners page.

Why does my PowerShell step pass when the script clearly failed?

The runner appends an exit that reports $LASTEXITCODE, and $LASTEXITCODE holds the code of the last native command that ran. If a failing .exe is followed by a command that succeeds, the variable is reset to zero and the step turns green. Check $LASTEXITCODE right after each native call and throw, or set $PSNativeCommandUseErrorActionPreference to $true. Once the step fails correctly, retrying a failed step in GitHub Actions covers what to do about flaky ones.

How do I split a large PowerShell or .NET test suite across runners?

Shard the suite with a matrix so each leg runs a slice on its own runner, and give every leg the same shell setting through defaults.run.shell so an error-handling difference does not show up on one leg only. The guide to .NET test parallelism on GitHub Actions covers shard sizing and result merging.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.