Validating PowerShell script syntax in GitHub Actions workflows

 
 
  • Gérald Barré

When writing GitHub Actions workflows, it is generally recommended to keep your scripts in separate files and reference them using the path attribute of the run step. This allows you to lint and test your scripts easily using standard tools.

However, this approach is not always practical. For instance, when creating a reusable workflow or a simple action, you might prefer to keep everything in a single file. In such cases, you often end up writing PowerShell scripts directly in the run step of your workflow.

The downside of inline scripts is that syntax errors go undetected until the workflow actually runs. A missing bracket or invalid operator only surfaces after you push your code.

The function below parses a GitHub Actions workflow file, extracts all PowerShell scripts from run steps, and checks their syntax without executing them.

#Prerequisites

To parse the YAML files, we'll use the powershell-yaml module. You can install it using the following command:

PowerShell
Install-Module -Name powershell-yaml -Scope CurrentUser -Force

#The Script

The script consists of two functions:

  1. Test-PowerShellScript: Takes a string of PowerShell code and uses System.Management.Automation.Language.Parser to check for syntax errors without executing the code. It also replaces GitHub expressions (e.g. ${{ github.job }}) with a placeholder variable to avoid false positives.
  2. Test-GhaPwshSteps: Parses the GitHub Actions workflow file, iterates through all jobs and steps, identifies steps that use PowerShell, and validates each script using Test-PowerShellScript.

Here is the complete script:

PowerShell
# Function to test PowerShell script syntax
# It does not execute the script, only parses it to find syntax errors
function Test-PowerShellScript {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [string]$ScriptText
    )

    # Replace GitHub expressions with a placeholder variable to avoid syntax errors
    # e.g. ${{ github.job }} -> $GitHubExpression
    $ScriptText = $ScriptText -replace '\$\{\{.*?\}\}', '$GitHubExpression'

    $tokens = $null
    $errors = $null

    try {
        [void][System.Management.Automation.Language.Parser]::ParseInput($ScriptText, [ref]$tokens, [ref]$errors)
    } catch {
        return @{ IsValid = $false; Errors = @($_) }
    }

    if ($errors -and $errors.Count -gt 0) {
        return @{ IsValid = $false; Errors = $errors }
    }

    return @{ IsValid = $true; Errors = @() }
}

# Find all PowerShell steps in a GitHub Actions workflow and test their syntax.
# The function returns a list of syntax errors found in PowerShell steps.
function Test-GhaPwshSteps {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path
    )

    if (-not (Get-Module -ListAvailable -Name powershell-yaml)) {
        Write-Warning "The 'powershell-yaml' module is required. Please run: Install-Module -Name powershell-yaml -Scope CurrentUser"
        return
    }
    Import-Module powershell-yaml

    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }

    $yamlContent = Get-Content -Path $Path -Raw
    try {
        $workflow = ConvertFrom-Yaml $yamlContent
    } catch {
        Write-Error "Failed to parse YAML: $_"
        return
    }

    # 1. Determine Global Default Shell
    $globalShell = $null
    if ($workflow.defaults -and $workflow.defaults.run -and $workflow.defaults.run.shell) {
        $globalShell = $workflow.defaults.run.shell
    }

    $results = @()

    # 2. Iterate Jobs
    if ($workflow.jobs) {
        foreach ($jobKey in $workflow.jobs.PSObject.Properties.Name) {
            $job = $workflow.jobs.$jobKey

            # Determine Job Default Shell (overrides global)
            $jobShell = $globalShell
            if ($job.defaults -and $job.defaults.run -and $job.defaults.run.shell) {
                $jobShell = $job.defaults.run.shell
            }

            # 3. Iterate Steps
            if ($job.steps) {
                $stepIndex = 0
                foreach ($step in $job.steps) {
                    $stepIndex++

                    # Skip if no 'run' key (e.g. uses: actions/checkout)
                    if (-not $step.run) { continue }

                    # Determine Step Shell (overrides job)
                    $stepShell = $jobShell
                    if ($step.shell) {
                        $stepShell = $step.shell
                    }

                    # Check if effective shell is PowerShell
                    # GitHub Actions accepts 'pwsh', 'powershell', or 'powershell {0}' custom formats
                    $isPwsh = $stepShell -match '^(pwsh|powershell)'

                    if ($isPwsh) {
                        $stepName = if ($step.name) { $step.name } else { "Step #$stepIndex" }

                        # Validate
                        $check = Test-PowerShellScript -ScriptText $step.run

                        if (-not $check.IsValid) {
                            foreach ($err in $check.Errors) {
                                $results += [PSCustomObject]@{
                                    Job      = $jobKey
                                    Step     = $stepName
                                    Line     = $err.Extent.StartLineNumber
                                    Column   = $err.Extent.StartColumnNumber
                                    Message  = $err.Message
                                    Snippet  = $step.run.Split("`n")[$err.Extent.StartLineNumber - 1]
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    return $results
}

#Usage

Use the Test-GhaPwshSteps function to validate your workflow files. To check all files in your repository at once, iterate over them as follows:

PowerShell
$files = @(
    Get-ChildItem -Path ".github/workflows" -Include "*.yml", "*.yaml" -Recurse
    Get-ChildItem -Path "." -Include "action.yml", "action.yaml" -Recurse
)

$errors = @()
foreach ($file in $files) {
    $errors += Test-GhaPwshSteps -Path $file.FullName
}

if ($errors.Count -eq 0) {
    Write-Host "No PowerShell syntax errors found in GitHub Actions workflows."
} else {
    Write-Host "PowerShell syntax errors found in GitHub Actions workflows:"
    $errors | Format-Table -AutoSize
}

This script won't catch all errors (such as logic errors or runtime issues), but it is effective at catching syntax errors like missing braces, invalid keywords, or malformed strings before you push your code.

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?